Skip to content

feat(sandbox): Landlock kernel sandbox for the agent process (in-container)#443

Closed
dpup wants to merge 4 commits into
mainfrom
feat/kernel-sandbox
Closed

feat(sandbox): Landlock kernel sandbox for the agent process (in-container)#443
dpup wants to merge 4 commits into
mainfrom
feat/kernel-sandbox

Conversation

@dpup

@dpup dpup commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

First cut of #396: OS-native kernel sandboxing, in-container mode.

What this does

isolation.kernel_sandbox: true in moat.yaml (or moat run --kernel-sandbox) applies a Landlock filesystem sandbox to the agent process inside the container — an inner wall behind the container boundary. The restriction is applied after the entrypoint's privilege drop, is inherited by every child process, and cannot be widened once applied: even code with arbitrary execution inside the run stays behind it.

The policy is a write allowlist (reads work everywhere):

Writable Everything else
/workspace, agent $HOME, /tmp, /var/tmp, /dev (+ioctl), /proc, /run, read-write mount targets, isolation.sandbox.allow_write entries write-denied by the kernel, regardless of file permissions
isolation:
  kernel_sandbox: true
  sandbox:
    allow_write: [/data]   # optional extras

How it works

  • internal/sandbox — policy computed host-side from the final mount set (pure, unit-tested), JSON-shipped via MOAT_SANDBOX_POLICY, applied in-container with go-landlock v0.9.0 (pure Go, by the Landlock maintainer). Best-effort ABI downgrade; a probe distinguishes "downgraded" from "unavailable" so degradation is always announced.
  • cmd/moat-sandbox — tiny static helper that self-restricts and execs the agent. Restrict-then-exec on one goroutine sidesteps go-landlock's multi-thread caveat. Fails closed if the policy is missing; logs kernel sandbox active (Landlock ABI vN) (or a warning) into the run's logs.
  • internal/sandboxbin — ships the helper into run images by embedding prebuilt linux/amd64+arm64 blobs in the moat binary, deliberately mirroring the internal/initbin pattern from feat(moatinit): rewrite the container entrypoint in Go and remove the shell #441 (committed fail-closed stubs, go generate wired into make build-cli/goreleaser, checksums + commit-guard tests) so the two merge cleanly — once feat(moatinit): rewrite the container entrypoint in Go and remove the shell #441 lands, this can fold into the Go moat-init.
  • moat-init.sh — final exec becomes exec [gosu moatuser] moat-sandbox "$@" when the policy env is set; fails closed if the helper is missing from the image.
  • Image pipelineImageSpec.NeedsKernelSandbox forces the init entrypoint + custom image and content-hashes the helper into the image tag, so toggling the sandbox (or a rebuilt helper) rebuilds the image. The run manager fail-fasts with rebuild instructions when the embedded helper is a stub (go install builds) or the arch is unsupported.

Design decisions (documented in docs/plans/2026-07-23-kernel-sandbox-design.md)

Testing

  • Unit (race detector): config validation both directions, policy construction (rw-mount inclusion and exclusion contracts), JSON round-trip + garbage rejection, Dockerfile/context-file generation with companion absence case, image-tag divergence, stub/checksum guards.
  • Real enforcement test (internal/sandbox/apply_linux_test.go): a sacrificial subprocess self-restricts and asserts allowed writes pass, denied writes fail, reads stay open (skips on non-Landlock kernels).
  • E2E (internal/e2e/kernel_sandbox_test.go): full docker run with a build-time moatuser-owned probe dir outside the allowlist — write denied only under the sandbox (companion run proves it's Landlock, not DAC), allow_write honored, kernel sandbox active line asserted. Both pass against real Docker (Landlock ABI v6).
  • Manual CLI validation: moat run --kernel-sandbox . -- sh -c ...kernel sandbox active (Landlock ABI v6), /workspace ok, /etc denied, reads ok.
  • golangci-lint run: 0 issues. Full unit suite green except the pre-existing network-dependent TestRegistryGithubBinaryURLsExist (fails on main in this environment too).

Follow-ups tracked in #396

macOS Seatbelt, containerless isolation.mode: local, deny_paths (needs masking mounts), Landlock TCP rules scoped to the proxy, audit-store surfacing of kernel denials.

🤖 Generated with Claude Code

dpup added 4 commits July 23, 2026 06:10
…ainer)

First cut of issue #396: isolation.kernel_sandbox applies a Landlock
filesystem write-allowlist to the agent process via the embedded
moat-sandbox helper, routed through moat-init's final exec chain.
…dbox

- config: isolation block validation both directions (accept/reject)
- sandbox: policy construction, JSON transport, and a subprocess test
  asserting real Landlock enforcement (allowed writes pass, denied
  writes fail, reads intact)
- deps: helper COPY + context file with companion absence case, image
  tag divergence, needsInit/NeedsCustomImage forcing
- e2e: full-stack docker run proving enforcement against a
  moatuser-owned probe dir, plus the unsandboxed companion run
@claude

claude Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @dpup's task in 14m 46s —— View job


Review: Landlock kernel sandbox (in-container)

  • Gather context (PR body, changed files, CLAUDE.md)
  • Run /code-review skill against origin/main...HEAD
  • Verify findings by building/testing/vet
  • Post final review summary

Verification performed: go build ./..., go vet ./... (clean), go test ./internal/sandbox/... ./internal/sandboxbin/... ./internal/config/... ./internal/deps/... ./internal/run/... (all pass), gofmt -l (no diffs). Traced apply_linux.go's restrict-then-exec ordering, moat-init.sh's fail-closed branches, manager_create.go's policy/mount wiring, and the image-tag content-hashing — all internally consistent with their doc comments.

This is a well-executed, carefully-reasoned feature. The write-allowlist design, fail-closed posture (missing helper, missing policy env, stub binary all hard-error), the single-goroutine restrict-then-exec (sidesteps go-landlock's multi-thread caveat), and the content-hash-based image invalidation are all sound. Docs match code (allow_write requires kernel_sandbox: true is both enforced and documented).

One gap worth addressing before merge:

internal/run/manager_create.go:174-182 — The kernel-sandbox pre-flight fail-closed checks (unsupported architecture, embedded-stub detection) have no unit test coverage in internal/run. IsStub/Binary are tested in internal/sandboxbin, but the integration point in Create() that gates a run on them is only exercised by the Docker+Landlock-gated e2e test. This is easily testable today without mocking: the committed embed/ blobs are stubs by default (only go generate replaces them), so a test setting opts.Config.Isolation.KernelSandbox = true and asserting Create() returns the "rebuild with 'make build-cli'" error would pass right now, following the existing stubRuntime-based pattern in manager_test.go (e.g. TestManager_CreateWithBuildKit). Worth adding given this is the backstop that prevents silently shipping an unsandboxed run when a kernel sandbox was requested — per the repo's own invariant that test-coverage gaps on validation paths are the most common review miss.

Nothing else rose to the level of a blocking issue. Nice work on the design doc and the deliberate mirroring of the internal/initbin (#441) pattern for future consolidation.

@claude

claude Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @dpup's task in 5m 58s —— View job


Review: Landlock kernel sandbox (#443)

Todo list

  • Gather context (PR body, changed files, CLAUDE.md)
  • Read diff against origin/main
  • Review core sandbox logic (internal/sandbox, cmd/moat-sandbox)
  • Review image pipeline integration (internal/deps, internal/run/manager_create.go)
  • Review internal/sandboxbin embedding/checksums
  • Review config/docs changes
  • Verify with go build, go vet, targeted tests
  • Post final review

Summary

This is a well-built, carefully reasoned first cut. I read the full diff against origin/main and verified locally:

  • go build ./... — clean
  • go vet ./... — clean
  • gofmt -l on all changed packages — clean
  • go test -race ./internal/sandbox/... ./internal/sandboxbin/... ./internal/deps/... ./internal/config/... — all pass, including the real Landlock enforcement test (apply_linux_test.go), which actually applies the restriction to a sacrificial subprocess and confirms allowed writes succeed, denied writes fail, and reads stay open. That's genuine enforcement testing, not a mock.

Highlights worth calling out:

  • validateIsolation (internal/config/config.go:426) rejects deny_paths unconditionally rather than silently ignoring it — exactly the "fail loudly on unsupported settings" principle from CLAUDE.md, and isolation_test.go covers both the rejection and the accepted companion cases.
  • The fail-closed chain is consistent end-to-end: manager_create.go's pre-flight refuses to create a kernel-sandboxed run when the embedded helper is a stub or the arch is unsupported; moat-sandbox's main_linux.go refuses to exec unsandboxed if MOAT_SANDBOX_POLICY is missing or Apply errors; moat-init.sh refuses to start if the helper binary isn't present in the image. Each layer independently fails closed rather than silently degrading.
  • internal/sandboxbin's stub/checksum/commit-guard tests (TestCommittedBlobsAreStubs, TestStubFailsClosed) close a real gap — they specifically prevent a real cross-compiled binary from ever being committed alongside its regenerated checksum, which TestEmbeddedChecksums alone wouldn't catch.
  • Image tag hashing correctly content-hashes the embedded helper binary (internal/deps/builder.go) so a helper rebuild (new go-landlock version, new toolchain) invalidates cached images — verified by TestImageTagKernelSandbox.
  • The E2E test (internal/e2e/kernel_sandbox_test.go) is a good design: it builds a moatuser-owned probe directory outside the allowlist at image-build time, then proves the denial comes from Landlock and not from DAC permissions via the disabled-sandbox companion run.

Minor observations (non-blocking)

  1. No unit-level test for the manager_create.go pre-flight guard (internal/run/manager_create.go:168-182 — the stub/unsupported-arch rejection before resource allocation). It's simple logic and is implicitly exercised any time CI runs with an unregenerated stub binary, but there's no test that asserts the specific error message/behavior the way the sibling packages do. Given the "detector ↔ validator parity" and companion-case testing emphasis in this repo's CLAUDE.md, a quick table test here would close the loop — not required for this PR given the E2E coverage, just noted for a follow-up.
  2. apply_linux.go:38-41writable := p.AllowWrite; ... writable = append(writable, home) appends to a slice built directly from Policy.AllowWrite (the JSON-decoded field). Not a bug today (the decoded slice's cap == len, and Policy isn't reused after Apply), but if Policy were ever reused or passed by a caller with spare capacity, this append could mutate the caller's backing array. Worth a slices.Clone or explicit append([]string{}, p.AllowWrite...) if this code evolves, but harmless as currently called (single-use, freshly parsed).

Both are minor; I don't see any correctness, security, or race issues in the sandbox enforcement path itself. The design doc, docs, and CHANGELOG entry are accurate and match the implementation I read (verified the write-allowlist description, the ABI probe/degrade behavior, and the deny_paths/isolation.mode: local rejections against the actual code).

@dpup

dpup commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

Closing after a threat-model + runtime-coverage review (full write-up going to #396).

Short version: the in-container Landlock sandbox is a no-op on both of moat's default runtimes — Linux defaults to gVisor (which doesn't implement the landlock syscalls), and Apple containers ship a minimal kernel with the entire LSM framework compiled out (CONFIG_SECURITY=n; confirmed empirically — /proc/sys/kernel/landlock/version is absent inside an Apple container). It only enforces on --no-sandbox Linux runs and macOS Docker (LinuxKit), which is too thin a slice to justify a permanent security surface with a 'does nothing on your setup unless…' asterisk.

The work isn't lost: the branch, design doc, the cross-platform policy abstraction, and the binary-embedding pattern are the natural starting point for kernel sandboxing in a future containerless/local execution mode, where the agent is a native process and Landlock (Linux) / Seatbelt (macOS) actually become the primary wall. Keeping the branch feat/kernel-sandbox for that.

Detailed findings in #396.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant