diff --git a/CHANGELOG.md b/CHANGELOG.md index 67c498a0..5c6eb6b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,11 @@ Moat is pre-1.0. The CLI interface and `moat.yaml` schema may change between min ## Unreleased -Adds HTTP request-body inspection to Keep policies. File- and pack-based `network.keep_policy` rules can now match on the parsed JSON request body, so policies can enforce content-based rules (e.g. block requests whose body carries a secret) instead of host/method/path alone. Also adds native volume backing for `volumes:` (`type: volume`) — a Docker named volume on the engine's native filesystem, for container-only working directories that should bypass the host↔VM filesystem-sharing layer a bind mount crosses. The routing proxy now serves a discovery index at its bare hosts so you can browse an agent's endpoints instead of memorizing hostnames. +Adds HTTP request-body inspection to Keep policies. File- and pack-based `network.keep_policy` rules can now match on the parsed JSON request body, so policies can enforce content-based rules (e.g. block requests whose body carries a secret) instead of host/method/path alone. Also adds native volume backing for `volumes:` (`type: volume`) — a Docker named volume on the engine's native filesystem, for container-only working directories that should bypass the host↔VM filesystem-sharing layer a bind mount crosses. The routing proxy now serves a discovery index at its bare hosts so you can browse an agent's endpoints instead of memorizing hostnames. New `isolation.kernel_sandbox` adds a Landlock kernel sandbox around the agent process as defense-in-depth behind the container boundary. ### Added +- **Kernel sandbox (Landlock)** — `isolation.kernel_sandbox: true` (or `moat run --kernel-sandbox`) applies a Landlock filesystem sandbox to the agent process inside the container, as an inner wall behind the container boundary. The whole filesystem stays readable; writes are kernel-restricted to the workspace, the agent home, scratch paths (`/tmp`, `/var/tmp`, `/dev`, `/proc`, `/run`), read-write mount targets, and `isolation.sandbox.allow_write` entries. The restriction is applied after the entrypoint's privilege drop, inherited by every child process, and irreversible for the lifetime of the run — code with arbitrary execution inside the run cannot widen it. Enforcement is best-effort: kernels without Landlock (pre-5.13, gVisor) log a warning and run unsandboxed, and the container log records `kernel sandbox active (Landlock ABI vN)` when enforcement is live. Landlock sets `no_new_privs`, so setuid binaries (`sudo`) do not work inside a sandboxed run. First cut of [#396](https://github.com/majorcontext/moat/issues/396): in-container Linux mode only — macOS Seatbelt, containerless local mode, and `deny_paths` are follow-ups. See [Sandboxing](https://majorcontext.com/moat/concepts/sandboxing). ([#443](https://github.com/majorcontext/moat/pull/443)) - **Copilot CLI settings passthrough** — `moat copilot` now carries over user preferences from the host's Copilot settings file (`$COPILOT_HOME/settings.json` when set, otherwise `~/.copilot/settings.json`; contextTier, effortLevel, footer, includeCoAuthoredBy, model, mouse, subagents, tabs, theme). Legacy `colorMode` values are written as the current `theme` setting. An optional `~/.moat/copilot/settings.json` provides moat-specific overrides that win over host settings. Settings that execute commands (`statusLine`) are only allowed from the moat override file. CLI flags and `moat.yaml` fields take precedence over settings.json values. ([#438](https://github.com/majorcontext/moat/pull/438)) - **GitHub Copilot CLI agent** — run GitHub Copilot CLI with `moat copilot`. Copilot uses the existing `github` grant: Moat injects that GitHub token for GitHub/Copilot API hosts plus HTTPS git, while the container receives only placeholders. `moat copilot` installs `@github/copilot`, stages Copilot config/context, passes `--allow-all` by default, and supports `copilot.model`, `copilot.context`, `copilot.reasoning_effort`, `copilot.experimental`, and `copilot.autopilot` in `moat.yaml`. See [Running GitHub Copilot CLI](https://majorcontext.com/moat/guides/copilot). ([#436](https://github.com/majorcontext/moat/pull/436)) - **Pi packages & safe defaults** — declare Pi extensions/skills/themes in `pi.packages` (remote `npm:`/`git:`/`https:`/`ssh:` sources) and Moat installs them into the image at build time via `pi install`, baked into a reproducible cached layer. Every `moat pi` image also bakes a safe `~/.pi/agent/settings.json` — `defaultProjectTrust: never` (a checked-out repo's own `.pi/` extensions, which are arbitrary code, do not auto-load), telemetry off, quiet startup — that a workspace cannot override. Because Pi config can redirect model traffic to any host, `moat pi` now warns under a permissive network policy (only `network.policy: strict` truly constrains egress). See [Running Pi](https://majorcontext.com/moat/guides/pi). ([#434](https://github.com/majorcontext/moat/pull/434)) diff --git a/Makefile b/Makefile index c36f3c75..4e446c83 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,9 @@ -.PHONY: all help build test test-unit test-e2e test-bats lint fix clean coverage snapshot +.PHONY: all help build build-cli generate-sandbox restore-sandbox-stubs test test-unit test-e2e test-bats lint fix clean coverage snapshot + +# Committed fail-closed placeholders for the embedded moat-sandbox helper. +# `go generate ./internal/sandboxbin` overwrites them with real cross-compiled +# binaries at build time; they must never be committed in that state. +SANDBOX_STUBS := internal/sandboxbin/embed/moat-sandbox-linux-amd64 internal/sandboxbin/embed/moat-sandbox-linux-arm64 internal/sandboxbin/checksums.txt # Default target - running "make" shows help all: help @@ -18,16 +23,26 @@ help: ## Show this help message build: ## Build the project go build ./... -build-cli: ## Build the CLI binary ./moat - go build -ldflags "-s -w -X github.com/majorcontext/moat/cmd/moat/cli.version=dev -X github.com/majorcontext/moat/cmd/moat/cli.commit=$$(git rev-parse --short HEAD) -X github.com/majorcontext/moat/cmd/moat/cli.date=$$(date -u +%Y-%m-%dT%H:%M:%SZ)" -o moat ./cmd/moat +build-cli: ## Build the CLI binary ./moat (regenerates the embedded moat-sandbox binaries, then restores the committed stubs) + @go generate ./internal/sandboxbin && \ + go build -ldflags "-s -w -X github.com/majorcontext/moat/cmd/moat/cli.version=dev -X github.com/majorcontext/moat/cmd/moat/cli.commit=$$(git rev-parse --short HEAD) -X github.com/majorcontext/moat/cmd/moat/cli.date=$$(date -u +%Y-%m-%dT%H:%M:%SZ)" -o moat ./cmd/moat; rc=$$?; \ + git checkout -- $(SANDBOX_STUBS); exit $$rc + +generate-sandbox: ## Cross-compile cmd/moat-sandbox into internal/sandboxbin/embed (over the committed stubs; run 'make restore-sandbox-stubs' before committing) + go generate ./internal/sandboxbin + +restore-sandbox-stubs: ## Restore the committed moat-sandbox stub blobs after a manual generate-sandbox + git checkout -- $(SANDBOX_STUBS) test: test-unit test-e2e test-bats ## Run all tests (unit + E2E + hooks) test-unit: ## Run unit tests with race detector (use ARGS for filtering, e.g., ARGS='-run TestName') go test -race $(ARGS) ./... -test-e2e: ## Run E2E tests (use ARGS for filtering, e.g., ARGS='-run TestName') - go test -tags=e2e -timeout=30m $(ARGS) ./internal/e2e/ +test-e2e: ## Run E2E tests (regenerates the embedded moat-sandbox binaries, then restores the committed stubs; use ARGS for filtering) + @go generate ./internal/sandboxbin && \ + go test -tags=e2e -timeout=30m $(ARGS) ./internal/e2e/; rc=$$?; \ + git checkout -- $(SANDBOX_STUBS); exit $$rc test-bats: ## Run bats tests for Claude Code hooks @which bats > /dev/null || (echo "bats not installed. Install from https://github.com/bats-core/bats-core" && exit 1) diff --git a/cmd/moat-sandbox/main_linux.go b/cmd/moat-sandbox/main_linux.go new file mode 100644 index 00000000..90272955 --- /dev/null +++ b/cmd/moat-sandbox/main_linux.go @@ -0,0 +1,72 @@ +//go:build linux + +// Command moat-sandbox applies the Moat kernel sandbox (Landlock) to itself +// and then execs the agent command, which inherits the restriction along +// with every process it spawns. It is installed into run images at +// /usr/local/bin/moat-sandbox and invoked by the moat-init entrypoint as the +// last link of the exec chain (after the privilege drop), so the restriction +// covers exactly the agent process tree. +// +// The policy arrives JSON-encoded in MOAT_SANDBOX_POLICY (see +// internal/sandbox), which is scrubbed from the environment before exec. +// +// Restricting and exec'ing from a single goroutine sidesteps go-landlock's +// multi-thread caveat: execve replaces the whole process, and the new +// program inherits the Landlock domain of the exec'ing thread. +package main + +import ( + "fmt" + "os" + "os/exec" + "syscall" + + "github.com/majorcontext/moat/internal/sandbox" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "moat: moat-sandbox: %v\n", err) + os.Exit(1) + } +} + +func run() error { + args := os.Args[1:] + if len(args) == 0 { + return fmt.Errorf("usage: moat-sandbox [args...]") + } + + // Fail closed: this binary is only ever invoked when a kernel sandbox + // was requested. Exec'ing the agent unrestricted because the policy went + // missing would silently void the guarantee. + policyStr := os.Getenv(sandbox.PolicyEnv) + if policyStr == "" { + return fmt.Errorf("%s is not set; refusing to run the command unsandboxed", sandbox.PolicyEnv) + } + policy, err := sandbox.ParsePolicy(policyStr) + if err != nil { + return err + } + os.Unsetenv(sandbox.PolicyEnv) + + status, err := sandbox.Apply(policy) + if err != nil { + // Landlock is available but enforcement failed — fail closed rather + // than degrade an explicitly requested security boundary. + return err + } + if status.ABI == 0 { + fmt.Fprintln(os.Stderr, "moat: kernel sandbox requested but Landlock is unavailable "+ + "(requires Linux 5.13+ with the landlock syscalls allowed; gVisor and Docker <23 do not support it); "+ + "continuing WITHOUT the kernel sandbox") + } else { + fmt.Fprintf(os.Stderr, "moat: kernel sandbox active (Landlock ABI v%d, filesystem write allowlist)\n", status.ABI) + } + + path, err := exec.LookPath(args[0]) + if err != nil { + return fmt.Errorf("finding %q: %w", args[0], err) + } + return syscall.Exec(path, args, os.Environ()) +} diff --git a/cmd/moat-sandbox/main_other.go b/cmd/moat-sandbox/main_other.go new file mode 100644 index 00000000..2636c179 --- /dev/null +++ b/cmd/moat-sandbox/main_other.go @@ -0,0 +1,15 @@ +//go:build !linux + +// moat-sandbox only runs inside Linux containers; this stub keeps +// `go build ./...` working on other platforms. +package main + +import ( + "fmt" + "os" +) + +func main() { + fmt.Fprintln(os.Stderr, "moat: moat-sandbox only runs on Linux") + os.Exit(1) +} diff --git a/cmd/moat/cli/exec.go b/cmd/moat/cli/exec.go index 7b2b6b13..c9c252b4 100644 --- a/cmd/moat/cli/exec.go +++ b/cmd/moat/cli/exec.go @@ -255,6 +255,16 @@ func ExecuteRun(ctx context.Context, opts intcli.ExecOptions) (*run.Run, error) clipboard = false } + // --kernel-sandbox enables the Landlock kernel sandbox ad hoc; moat.yaml's + // isolation.kernel_sandbox is the declarative equivalent. Flag only adds — + // there is no flag to disable a config-enabled kernel sandbox. + if opts.Flags.KernelSandbox { + if opts.Config == nil { + opts.Config = &config.Config{} + } + opts.Config.Isolation.KernelSandbox = true + } + // Append CLI --mount flags to config mounts for _, ms := range opts.Flags.Mounts { me, parseErr := config.ParseMount(ms) diff --git a/docs/content/concepts/01-sandboxing.md b/docs/content/concepts/01-sandboxing.md index fda8de9a..6f9b407e 100644 --- a/docs/content/concepts/01-sandboxing.md +++ b/docs/content/concepts/01-sandboxing.md @@ -52,6 +52,31 @@ When using `docker+gvisor`, the container runs inside gVisor, but Docker-in-Dock Both modes require Docker as the container runtime. Apple containers do not support Docker socket mounting or privileged mode. See [Dependencies](../reference/06-dependencies.md#docker-dependencies) for configuration details. +## Kernel sandbox (Landlock) + +The container boundary is Moat's outer wall. `isolation.kernel_sandbox` adds an inner one: a [Landlock](https://docs.kernel.org/userspace-api/landlock.html) filesystem sandbox applied to the agent process itself, just after the container entrypoint drops privileges. Landlock restrictions are enforced by the kernel, inherited by every child process, and cannot be widened once applied — even code with arbitrary execution inside the run stays behind them. + +```yaml +# moat.yaml +isolation: + kernel_sandbox: true + sandbox: + allow_write: # extra writable container paths (optional) + - /data +``` + +Or ad hoc: `moat run --kernel-sandbox -- `. + +The policy is a write allowlist: the whole container filesystem stays readable, and writes are limited to `/workspace`, the agent's home directory, `/tmp`, `/var/tmp`, `/dev`, `/proc`, `/run`, every read-write mount target, and any `allow_write` entries. Everything else — `/usr`, `/etc`, paths owned by the run user but outside the allowlist — is write-denied by the kernel regardless of file permissions. + +What it guarantees, and what it does not: + +- **Best-effort by design.** Landlock needs Linux 5.13+ with the `landlock_*` syscalls permitted (Docker 23+ allows them by default; gVisor does not implement them). When unavailable, the run starts anyway and logs a warning — check `moat logs` for the `kernel sandbox active (Landlock ABI vN)` line to confirm enforcement. +- **Allowlist-only.** Landlock cannot deny a subpath inside an allowed tree, so there is no `deny_paths` support yet (tracked in [#396](https://github.com/majorcontext/moat/issues/396)). Use read-only mounts or mount `exclude:` lists to mask paths instead. +- **Filesystem only.** Network policy stays with the proxy (see [Network policies](./05-networking.md)); the kernel sandbox does not restrict sockets in this first cut. +- **`sudo` stops working.** Landlock requires `no_new_privs`, which disables setuid binaries inside the sandboxed process tree. Install dependencies at build time or via `hooks.pre_run` (which runs before the sandbox is applied). +- **Complements, not replaces, the container.** Under gVisor the guest kernel provides no Landlock and the gVisor boundary is already stronger; the kernel sandbox matters most with `sandbox: none`/`--no-sandbox` and on Apple containers. + ## Limitations Container isolation is not a security boundary against a determined attacker. It provides: diff --git a/docs/content/reference/01-cli.md b/docs/content/reference/01-cli.md index bea40e56..5999d715 100644 --- a/docs/content/reference/01-cli.md +++ b/docs/content/reference/01-cli.md @@ -52,6 +52,7 @@ The agent commands (`moat claude`, `moat copilot`, `moat codex`, `moat gemini`, | `--workspace-mode bind\|volume` | Workspace mode: `bind` (default) or `volume` (isolated Docker named volume). Overrides `workspace.mode` in `moat.yaml`. Docker-only for `volume`. | | `--no-clipboard` | Disable host clipboard bridging for this run | | `--no-sandbox` | Disable gVisor sandbox (Docker only) | +| `--kernel-sandbox` | Apply a Landlock kernel sandbox to the agent process (Linux, filesystem write allowlist) | | `--no-prompt` | Never prompt to grant missing credentials; fail with the missing-grants error instead. Also set via `MOAT_NO_PROMPT=1`. Prompting only happens on an interactive terminal. | | `--tty-trace FILE` | Capture terminal I/O to file for debugging (e.g., `session.json`) | | `--worktree BRANCH` | Run in a git worktree for this branch (alias: `--wt`) | @@ -132,6 +133,7 @@ moat run [flags] [path] [-- command] | `--no-clipboard` | Disable host clipboard bridging for this run | | `--workspace-mode bind\|volume` | Workspace mode: `bind` (default) mounts the host directory at `/workspace`; `volume` copies it into an isolated Docker named volume. Overrides `workspace.mode` in `moat.yaml`. Docker-only for `volume`. | | `--no-sandbox` | Disable gVisor sandboxing (Docker only) | +| `--kernel-sandbox` | Apply a Landlock kernel sandbox to the agent process (Linux, filesystem write allowlist) | | `--no-prompt` | Never prompt to grant missing credentials; fail with the missing-grants error instead. Also set via `MOAT_NO_PROMPT=1`. Prompting only happens on an interactive terminal. | | `--tty-trace FILE` | Capture terminal I/O to file for debugging (e.g., `session.json`) | @@ -213,6 +215,16 @@ Disables gVisor sandboxing for Docker containers. By default, Moat runs Docker c moat run --no-sandbox ./my-project ``` +### --kernel-sandbox + +Applies a Landlock kernel sandbox to the agent process inside the container: the filesystem stays readable, but writes are limited to the workspace, the agent home, scratch paths, read-write mounts, and any `isolation.sandbox.allow_write` entries from `moat.yaml`. The restriction is inherited by every child process and cannot be lifted for the lifetime of the run. Equivalent to `isolation.kernel_sandbox: true` in `moat.yaml`. + +**When to use:** Defense-in-depth for runs of untrusted or exploratory code — even if the agent escapes its intended workflow, it cannot modify files outside the allowlist. Requires Linux 5.13+ in the container's kernel; degrades with a logged warning otherwise. Not compatible with `sudo` inside the run (Landlock sets `no_new_privs`). See [Sandboxing](../concepts/01-sandboxing.md#kernel-sandbox-landlock). + +```bash +moat run --kernel-sandbox ./my-project -- npm test +``` + --- ## moat claude diff --git a/docs/content/reference/02-moat-yaml.md b/docs/content/reference/02-moat-yaml.md index 281851fd..81571f81 100644 --- a/docs/content/reference/02-moat-yaml.md +++ b/docs/content/reference/02-moat-yaml.md @@ -1007,6 +1007,51 @@ Setting `sandbox: none` is equivalent to running with `--no-sandbox`. Use this w --- +## Isolation + +OS-native kernel sandboxing applied to the agent process inside the container. See [Sandboxing](../concepts/01-sandboxing.md#kernel-sandbox-landlock) for the security model. + +### isolation.kernel_sandbox + +Applies a Landlock filesystem sandbox (Linux kernel 5.13+) to the agent process before it starts. The restriction is a write allowlist — reads work everywhere, writes are limited to the workspace, the agent home, `/tmp`, `/var/tmp`, `/dev`, `/proc`, `/run`, read-write mount targets, and `isolation.sandbox.allow_write` entries. It is inherited by every child process and cannot be lifted for the lifetime of the run. + +```yaml +isolation: + kernel_sandbox: true +``` + +- Type: `boolean` +- Default: `false` +- CLI override: `--kernel-sandbox` (enable only; the flag cannot disable a config-enabled sandbox) + +Enforcement is best-effort: on kernels without Landlock (pre-5.13, gVisor) the run starts unsandboxed and logs a warning. The container log shows `kernel sandbox active (Landlock ABI vN)` when enforcement is live. Because Landlock requires `no_new_privs`, setuid binaries (`sudo`) do not work inside a sandboxed run; `hooks.pre_run` executes before the sandbox is applied and is unaffected. + +### isolation.sandbox.allow_write + +Extra absolute container paths the agent may write to, in addition to the defaults above. + +```yaml +isolation: + kernel_sandbox: true + sandbox: + allow_write: + - /data +``` + +- Type: `string[]` (absolute container paths) +- Default: `[]` +- Requires `kernel_sandbox: true` + +### isolation.mode + +Reserved for the containerless local mode planned in [#396](https://github.com/majorcontext/moat/issues/396). Only `container` (the default) is accepted today. + +### isolation.sandbox.deny_paths + +Reserved and currently rejected with an error: Landlock policies are allowlist-only, so denying a path inside an allowed tree is not enforceable. Use read-only mounts or mount `exclude:` lists to mask paths instead. + +--- + ## Container Container resource limits and settings that apply to both Docker and Apple container runtimes. diff --git a/docs/plans/2026-07-23-kernel-sandbox-design.md b/docs/plans/2026-07-23-kernel-sandbox-design.md new file mode 100644 index 00000000..c606c86d --- /dev/null +++ b/docs/plans/2026-07-23-kernel-sandbox-design.md @@ -0,0 +1,142 @@ +# Kernel Sandbox (Landlock) — In-Container Defense-in-Depth + +**Issue:** [#396](https://github.com/majorcontext/moat/issues/396) +**Status:** First cut — Linux Landlock, in-container mode only. + +## Goal + +Apply an OS-native, kernel-enforced filesystem sandbox to the agent process +inside the Moat container, so that even with arbitrary code execution the agent +cannot widen its own restrictions. This is the first slice of issue #396: +in-container Landlock enforcement. macOS Seatbelt, the containerless "local +mode", `deny_paths`, and kernel-level network rules are follow-ups. + +## Prior art surveyed + +- **OpenAI Codex CLI** — Rust `landlock` crate + seccomp: "read everywhere, + write only workspace + tmp" posture. No deny-list inside allowed trees + (Landlock cannot express it). +- **Anthropic sandbox-runtime** — bubblewrap (not Landlock) on Linux, Seatbelt + on macOS. Write-denied-by-default with `allowWrite` paths; reads allowed. +- **go-landlock** (github.com/landlock-lsm/go-landlock, v0.9.0, MIT, by the + Landlock maintainer) — pure Go, no cgo, best-effort ABI downgrade, handles + `no_new_privs` and multi-thread restriction internally. +- **systemd / OpenSSH / Chrome** — allowlist-style Landlock policies layered + under a stronger outer boundary, same shape as this design. + +Key constraint discovered: **Landlock is allowlist-only.** A `deny_paths` +entry inside an allowed tree is not expressible (v1–v6 ABIs). Rather than +silently ignoring the issue-sketch field, `isolation.sandbox.deny_paths` +parses but returns a clear "not yet supported" error. + +## Configuration surface + +```yaml +# moat.yaml +isolation: + kernel_sandbox: true # apply Landlock to the agent process (default: false) + sandbox: + allow_write: # extra absolute container paths writable by the agent + - /data +``` + +- `isolation.mode` is reserved (accepts `container` or empty; `local` errors + with a pointer to #396 until the containerless mode ships). +- `isolation.sandbox.deny_paths` errors with an explanation (allowlist-only). +- CLI: `moat run --kernel-sandbox` enables it ad hoc (shared ExecFlags, so all + agent commands get it). + +## Default policy (in-container) + +Reads are allowed everywhere; writes are allowlisted (Codex-style posture, +adapted to Moat's container layout): + +| Access | Paths | +|--------|-------| +| Read-only | `/` (everything) | +| Read-write | `/workspace`, `$HOME`, `/tmp`, `/var/tmp`, `/dev` (+ioctl), `/proc`, `/run`, rw bind-mount targets, named-volume targets, `isolation.sandbox.allow_write` entries | + +Rationale: + +- `$HOME` rw: agents write config, caches, logs (`~/.claude`, `~/.npm`, …). +- `/dev` rw + ioctl: TTY handling (`/dev/tty`, `/dev/ptmx`, `/dev/shm`). +- `/proc`, `/run` rw: `/dev/stdout` → `/proc/self/fd/1`; unix sockets (SSH + agent bridge at `/run/moat/ssh`) need write to connect. Both are already + masked/read-only-protected by the container runtime where it matters. +- rw mount targets: a mount the user asked for must stay writable; `:ro` + mounts are excluded (already read-only at the mount layer). +- Renames across directories need the Landlock `refer` right (ABI v2+); rw + rules include it. +- Network (TCP bind/connect, ABI v4) is deliberately **not** restricted: + domain-level policy stays with the credential proxy. First cut is + filesystem-only. + +The policy is computed **host-side** (`internal/sandbox`, unit-testable pure +function), serialized as JSON into `MOAT_SANDBOX_POLICY`, and applied +in-container by a small helper binary. + +## Enforcement mechanics + +1. `internal/sandbox` — `Policy` type, `BuildPolicy(...)` from config + mounts, + JSON round-trip. Host-side, cross-platform. +2. `cmd/moat-sandbox` — tiny static Go binary. Reads `MOAT_SANDBOX_POLICY`, + scrubs it from the environment, applies Landlock via go-landlock + best-effort, prints one status line to stderr (`kernel sandbox active + (Landlock ABI vN)` or a degradation warning), then `exec`s the agent + command. Single-threaded restrict-then-exec avoids the Go thread race; + restrictions are inherited by all children and cannot be lifted. +3. `internal/sandboxbin` — embeds prebuilt linux/amd64 + linux/arm64 + `moat-sandbox` binaries in the moat CLI, mirroring the `internal/initbin` + pattern from PR #441: committed fail-closed stubs, `go generate` builds the + real blobs (wired into `make build-cli` / goreleaser's `go generate ./...`), + checksums verified by a unit test. When #441 merges, this can fold into the + Go moat-init. +4. `internal/deps` — `ImageSpec.NeedsKernelSandbox` triggers the moat-init + entrypoint, COPYs `moat-sandbox` into the image, and contributes to the + image tag hash (toggling the sandbox rebuilds the image). +5. `moat-init.sh` — final exec chain becomes + `exec [gosu moatuser] /usr/local/bin/moat-sandbox "$@"` when + `MOAT_SANDBOX_POLICY` is set; fails closed if the helper is missing. +6. `internal/run/manager_create.go` — builds the policy (workspace target, + home, rw mounts, config extras), sets the env var and the ImageSpec flag, + and fails fast with rebuild instructions if the embedded helper is a stub + or the architecture is unsupported. + +## Degradation & non-guarantees (documented honestly) + +- **Best-effort by design**: if Landlock is unavailable (kernel < 5.13, + seccomp blocking the syscalls — Docker < 23 — or gVisor), the helper warns + and continues unsandboxed. The warning is visible in `moat logs`. +- Features degrade with kernel ABI (e.g. `refer` needs 5.19+, ioctl grouping + 6.10+); go-landlock's best-effort handles this. +- `no_new_privs` (required by Landlock) disables setuid binaries — `sudo` + stops working inside a sandboxed run. Build-time deps and `pre_run` hooks + are unaffected (they run before the sandbox is applied). +- Under gVisor the guest kernel does not provide Landlock; the container + boundary + gVisor is already the stronger wall there. The kernel sandbox + matters most with `--no-sandbox`/`sandbox: none` and on Apple containers. +- Already-open file descriptors (stdio, the TTY) are not affected — expected + and desirable. + +## Testing + +- Unit: config validation (both directions per invariant #1), policy + construction (defaults, rw-mount inclusion **and** ro-mount exclusion, + normalization, JSON round-trip), Dockerfile generation with/without the + flag, image-tag divergence, stub detection + checksums. +- Linux-only in-process: subprocess test applies a policy and asserts allowed + writes succeed, denied writes fail, reads still work (skips when Landlock + is unavailable). +- E2E (Docker): real `moat run` with `kernel_sandbox: true` asserting + workspace writes succeed, `/etc` writes fail, and the status line appears; + companion run without the flag asserts no restriction and no status line. + +## Follow-ups (tracked in #396) + +- macOS Seatbelt profile for Apple containers' agent process. +- Containerless `isolation.mode: local`. +- `deny_paths` (needs a masking mechanism — tmpfs overlay mounts — or future + kernel support). +- Landlock TCP rules scoped to the proxy port (needs answers to the + proxy-interaction open questions in #396). +- Surfacing kernel denials into the audit store. diff --git a/go.mod b/go.mod index 59bb0d65..a63c2159 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/docker/docker v28.5.2+incompatible github.com/docker/go-connections v0.6.0 github.com/go-git/go-git/v5 v5.17.1 + github.com/landlock-lsm/go-landlock v0.9.0 github.com/majorcontext/gatekeeper v0.13.0 github.com/majorcontext/keep v0.6.0 github.com/mattn/go-isatty v0.0.20 @@ -200,6 +201,7 @@ require ( gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gotest.tools/v3 v3.5.2 // indirect + kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 // indirect modernc.org/libc v1.66.10 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index daab6f55..bff60cf8 100644 --- a/go.sum +++ b/go.sum @@ -333,6 +333,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/landlock-lsm/go-landlock v0.9.0 h1:2q8G8yx9Hsd5bV+R6PJfgQl0zszNxC8KO+SIqGwfxlw= +github.com/landlock-lsm/go-landlock v0.9.0/go.mod h1:mn5GSi81Jf7yMs5WSi+SUi4sUeNLUGVdbT4Id6wXNQw= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= @@ -788,6 +790,8 @@ honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 h1:Z06sMOzc0GNCwp6efaVrIrz4ywGJ1v+DP0pjVkOfDuA= +kernel.org/pub/linux/libs/security/libcap/psx v1.2.77/go.mod h1:+l6Ee2F59XiJ2I6WR5ObpC1utCQJZ/VLsEbQCD8RG24= modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= diff --git a/internal/cli/types.go b/internal/cli/types.go index ccd06333..4fd4f7cd 100644 --- a/internal/cli/types.go +++ b/internal/cli/types.go @@ -18,6 +18,7 @@ type ExecFlags struct { KeepContainer bool Interactive bool NoSandbox bool + KernelSandbox bool NoClipboard bool NoPrompt bool TTYTrace string // Path to save terminal I/O trace for debugging @@ -34,6 +35,7 @@ func AddExecFlags(cmd *cobra.Command, flags *ExecFlags) { cmd.Flags().StringVar(&flags.Runtime, "runtime", "", "container runtime to use (apple, docker)") cmd.Flags().StringVar(&flags.WorkspaceMode, "workspace-mode", "", "workspace mode: 'bind' (default) or 'volume' (isolated copy in a named volume)") cmd.Flags().BoolVar(&flags.NoSandbox, "no-sandbox", false, "disable gVisor sandbox (reduced isolation, Docker only)") + cmd.Flags().BoolVar(&flags.KernelSandbox, "kernel-sandbox", false, "apply a Landlock kernel sandbox to the agent process (Linux, filesystem write allowlist)") cmd.Flags().BoolVar(&flags.NoClipboard, "no-clipboard", false, "disable host clipboard bridging") cmd.Flags().BoolVar(&flags.NoPrompt, "no-prompt", false, "never prompt to grant missing credentials; fail instead") cmd.Flags().StringVar(&flags.TTYTrace, "tty-trace", "", "capture terminal I/O to file for debugging (e.g., session.json)") diff --git a/internal/config/config.go b/internal/config/config.go index 2072e162..ff8cf6b1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -60,6 +60,10 @@ type Config struct { // Empty string or omitted uses default (gVisor enabled). Sandbox string `yaml:"sandbox,omitempty"` + // Isolation configures OS-native kernel sandboxing (Landlock) applied to + // the agent process inside the container. See IsolationConfig. + Isolation IsolationConfig `yaml:"isolation,omitempty"` + // Runtime forces a specific container runtime ("docker" or "apple"). // If not set, moat auto-detects the best available runtime. // Useful when agent needs docker:dind on macOS (Apple containers can't run dind). @@ -80,6 +84,40 @@ type Config struct { DeprecatedRuntime *deprecatedRuntime `yaml:"-"` } +// IsolationConfig configures OS-native kernel sandboxing for the agent +// process (issue #396). The first cut supports Linux Landlock applied inside +// the container: the whole filesystem stays readable, writes are limited to +// the workspace, the agent home, scratch paths, read-write mounts, and +// sandbox.allow_write entries. Enforcement is best-effort: kernels without +// Landlock (pre-5.13, gVisor) log a warning and run unsandboxed. +type IsolationConfig struct { + // Mode selects the isolation mode. Only "container" (the default) is + // supported today; "local" (containerless kernel-sandbox-only runs) is + // planned in issue #396. + Mode string `yaml:"mode,omitempty"` + + // KernelSandbox applies a Landlock filesystem sandbox to the agent + // process before it starts. Inherited by all child processes and + // irreversible for the lifetime of the run. + KernelSandbox bool `yaml:"kernel_sandbox,omitempty"` + + // Sandbox tunes the kernel sandbox policy. + Sandbox SandboxPathsConfig `yaml:"sandbox,omitempty"` +} + +// SandboxPathsConfig tunes the kernel sandbox filesystem policy. +type SandboxPathsConfig struct { + // AllowWrite lists extra absolute container paths the agent may write + // to, in addition to the defaults (workspace, home, /tmp, /var/tmp, + // /dev, /proc, /run, and read-write mount targets). + AllowWrite []string `yaml:"allow_write,omitempty"` + + // DenyPaths is reserved and currently rejected: Landlock policies are + // allowlist-only, so denying a path inside an allowed tree is not + // expressible. Tracked in issue #396. + DenyPaths []string `yaml:"deny_paths,omitempty"` +} + // UlimitSpec defines a resource limit with soft and hard values. // Use -1 for unlimited. type UlimitSpec struct { @@ -382,6 +420,38 @@ type PiConfig struct { // (the source is also single-quoted when written into the build script). var piPackageSafe = regexp.MustCompile(`^[A-Za-z0-9@:/._~%+#-]+$`) +// validateIsolation checks the isolation block. Unsupported settings fail +// loudly instead of being silently ignored: a user who writes deny_paths and +// gets no error would believe those paths are protected when they are not. +func validateIsolation(iso IsolationConfig) error { + switch iso.Mode { + case "", "container": + // supported + case "local": + return fmt.Errorf("isolation.mode \"local\" is not yet supported — the containerless kernel-sandbox mode is tracked in https://github.com/majorcontext/moat/issues/396; remove the mode field to use container isolation") + default: + return fmt.Errorf("invalid isolation.mode %q: must be omitted or 'container'", iso.Mode) + } + if len(iso.Sandbox.DenyPaths) > 0 { + return fmt.Errorf("isolation.sandbox.deny_paths is not yet supported — Landlock policies are allowlist-only, so denying paths inside an allowed tree is not enforceable (tracked in https://github.com/majorcontext/moat/issues/396); remove deny_paths, or narrow isolation.sandbox.allow_write instead") + } + if !iso.KernelSandbox { + if len(iso.Sandbox.AllowWrite) > 0 { + return fmt.Errorf("isolation.sandbox.allow_write is set but isolation.kernel_sandbox is false — set kernel_sandbox: true to enable the sandbox, or remove allow_write") + } + return nil + } + for _, p := range iso.Sandbox.AllowWrite { + if strings.TrimSpace(p) == "" { + return fmt.Errorf("isolation.sandbox.allow_write: entries must not be empty") + } + if !strings.HasPrefix(p, "/") { + return fmt.Errorf("isolation.sandbox.allow_write: %q is not an absolute path — entries are container paths (the workspace is mounted at /workspace and is already writable)", p) + } + } + return nil +} + // validatePiPackages checks that each pi.packages entry is a remote source Moat // can install at image build time. Local paths are rejected because // `pi install ` records a relative path that does not resolve at runtime. @@ -673,6 +743,11 @@ func Load(dir string) (*Config, error) { return nil, fmt.Errorf("invalid sandbox value %q: must be empty (default) or 'none'", cfg.Sandbox) } + // Validate isolation settings + if err := validateIsolation(cfg.Isolation); err != nil { + return nil, err + } + // Validate base_image: prevent Dockerfile injection via newlines/whitespace. if cfg.BaseImage != "" { cfg.BaseImage = strings.TrimSpace(cfg.BaseImage) diff --git a/internal/config/isolation_test.go b/internal/config/isolation_test.go new file mode 100644 index 00000000..76ff97f9 --- /dev/null +++ b/internal/config/isolation_test.go @@ -0,0 +1,142 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// loadIsolationConfig writes a moat.yaml with the given isolation block and +// loads it. +func loadIsolationConfig(t *testing.T, isolationYAML string) (*Config, error) { + t.Helper() + dir := t.TempDir() + content := "agent: test\n" + isolationYAML + if err := os.WriteFile(filepath.Join(dir, "moat.yaml"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return Load(dir) +} + +func TestLoadConfigIsolationKernelSandbox(t *testing.T) { + cfg, err := loadIsolationConfig(t, ` +isolation: + kernel_sandbox: true + sandbox: + allow_write: + - /data + - /var/cache/custom +`) + if err != nil { + t.Fatalf("Load: %v", err) + } + if !cfg.Isolation.KernelSandbox { + t.Error("Isolation.KernelSandbox = false, want true") + } + if len(cfg.Isolation.Sandbox.AllowWrite) != 2 { + t.Errorf("AllowWrite = %v, want 2 entries", cfg.Isolation.Sandbox.AllowWrite) + } +} + +func TestLoadConfigIsolationDefaultsOff(t *testing.T) { + // Companion: a config without an isolation block leaves the sandbox off. + cfg, err := loadIsolationConfig(t, "") + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Isolation.KernelSandbox { + t.Error("Isolation.KernelSandbox = true for empty config, want false") + } + if cfg.Isolation.Mode != "" { + t.Errorf("Isolation.Mode = %q for empty config, want empty", cfg.Isolation.Mode) + } +} + +func TestLoadConfigIsolationMode(t *testing.T) { + tests := []struct { + name string + mode string + wantErr string + }{ + {"container accepted", "container", ""}, + {"local rejected with pointer to issue", "local", "not yet supported"}, + {"unknown rejected", "chroot", "invalid isolation.mode"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := loadIsolationConfig(t, "isolation:\n mode: "+tt.mode+"\n") + if tt.wantErr == "" { + if err != nil { + t.Fatalf("Load: %v", err) + } + return + } + if err == nil { + t.Fatalf("Load accepted isolation.mode %q, want error", tt.mode) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error = %v, want it to mention %q", err, tt.wantErr) + } + }) + } +} + +func TestLoadConfigIsolationRejectsDenyPaths(t *testing.T) { + // deny_paths must fail loudly, not be silently ignored: a user who wrote + // it would otherwise believe those paths are protected. + _, err := loadIsolationConfig(t, ` +isolation: + kernel_sandbox: true + sandbox: + deny_paths: + - /home/moatuser/.ssh +`) + if err == nil { + t.Fatal("Load accepted deny_paths, want error") + } + if !strings.Contains(err.Error(), "deny_paths is not yet supported") { + t.Errorf("error = %v, want it to mention deny_paths being unsupported", err) + } +} + +func TestLoadConfigIsolationAllowWriteValidation(t *testing.T) { + tests := []struct { + name string + yaml string + wantErr string + }{ + { + "relative path rejected", + "isolation:\n kernel_sandbox: true\n sandbox:\n allow_write: [./data]\n", + "not an absolute path", + }, + { + "empty entry rejected", + "isolation:\n kernel_sandbox: true\n sandbox:\n allow_write: [\"\"]\n", + "must not be empty", + }, + { + "allow_write without kernel_sandbox rejected", + "isolation:\n sandbox:\n allow_write: [/data]\n", + "kernel_sandbox is false", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := loadIsolationConfig(t, tt.yaml) + if err == nil { + t.Fatal("Load succeeded, want error") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error = %v, want it to mention %q", err, tt.wantErr) + } + }) + } + + // Companion: absolute paths pass, and an empty allow_write list is fine + // with the sandbox enabled. + if _, err := loadIsolationConfig(t, "isolation:\n kernel_sandbox: true\n"); err != nil { + t.Errorf("kernel_sandbox without allow_write should load, got: %v", err) + } +} diff --git a/internal/deps/builder.go b/internal/deps/builder.go index 54e71409..0671571a 100644 --- a/internal/deps/builder.go +++ b/internal/deps/builder.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/majorcontext/moat/internal/providers/pi" + "github.com/majorcontext/moat/internal/sandboxbin" ) // ImageTag generates a deterministic image tag for a set of dependencies. @@ -53,6 +54,17 @@ func ImageTag(deps []Dependency, opts *ImageSpec) string { if opts.NeedsClipboard { hashInput += ",clipboard:xvfb" } + // Content-hash the embedded moat-sandbox helper so toggling the kernel + // sandbox — or a rebuilt helper (new go-landlock version, new toolchain) — + // invalidates cached images that carry a stale or missing binary. + if opts.NeedsKernelSandbox { + if bin := sandboxbin.Binary(); bin != nil { + bh := sha256.Sum256(bin) + hashInput += ",kernel-sandbox:" + hex.EncodeToString(bh[:])[:8] + } else { + hashInput += ",kernel-sandbox:unsupported-arch" + } + } // When the moat-init entrypoint is used, hash the script contents so that // changes to moat-init.sh (e.g. adding /etc/hosts injection for synthetic diff --git a/internal/deps/dockerfile.go b/internal/deps/dockerfile.go index b2d9c430..199855be 100644 --- a/internal/deps/dockerfile.go +++ b/internal/deps/dockerfile.go @@ -8,6 +8,8 @@ import ( "github.com/majorcontext/moat/internal/providers/claude" "github.com/majorcontext/moat/internal/providers/pi" + "github.com/majorcontext/moat/internal/sandbox" + "github.com/majorcontext/moat/internal/sandboxbin" ) // HooksConfig holds hook commands for Dockerfile generation and image tagging. @@ -619,6 +621,20 @@ func writeEntrypoint(b *strings.Builder, opts *ImageSpec, dockerMode DockerMode, b.WriteString("# Moat initialization script (privilege drop + feature setup)\n") b.WriteString("COPY moat-init.sh /usr/local/bin/moat-init\n") b.WriteString("RUN chmod +x /usr/local/bin/moat-init\n") + // The moat-sandbox helper (embedded in the moat host binary, + // arch-matched via runtime.GOARCH like run images themselves) applies + // the Landlock kernel sandbox as the last link of the exec chain. The + // run manager rejects kernel-sandboxed runs on architectures without + // an embedded helper, so Binary() is non-nil whenever + // NeedsKernelSandbox is set; the guard is defense-in-depth. + if opts.NeedsKernelSandbox { + if sandboxBin := sandboxbin.Binary(); sandboxBin != nil { + contextFiles["moat-sandbox"] = sandboxBin + b.WriteString("# Moat kernel sandbox helper (Landlock, applied before agent exec)\n") + b.WriteString("COPY moat-sandbox " + sandbox.HelperPath + "\n") + b.WriteString("RUN chmod +x " + sandbox.HelperPath + "\n") + } + } b.WriteString("ENTRYPOINT [\"/usr/local/bin/moat-init\"]\n") } else { b.WriteString(fmt.Sprintf("# Run as non-root user\nUSER %s\n", containerUser)) diff --git a/internal/deps/imagespec.go b/internal/deps/imagespec.go index 657ca559..de916e17 100644 --- a/internal/deps/imagespec.go +++ b/internal/deps/imagespec.go @@ -74,6 +74,12 @@ type ImageSpec struct { // Hooks contains user-defined lifecycle hook commands. Hooks *HooksConfig + // NeedsKernelSandbox indicates the run applies a Landlock kernel sandbox + // to the agent process (isolation.kernel_sandbox). Requires the moat-init + // entrypoint so the final exec chain can route through the moat-sandbox + // helper binary, which this flag COPYs into the image. + NeedsKernelSandbox bool + // NeedsWorkspaceVolume indicates the run uses volume-mode workspaces, which // require the moat-init entrypoint to populate the named volume from the // read-only staging bind and chown it (both as root, before the privilege @@ -90,7 +96,8 @@ func (s *ImageSpec) NeedsCustomImage(hasDeps bool) bool { hasHooks := s.Hooks != nil && (s.Hooks.PostBuild != "" || s.Hooks.PostBuildRoot != "" || s.Hooks.PreRun != "") return hasDeps || s.BaseImage != "" || s.NeedsSSH || len(s.InitProviders) > 0 || s.NeedsFirewall || s.NeedsInitFiles || s.NeedsClipboard || - len(s.ClaudePlugins) > 0 || hasHooks || s.NeedsWorkspaceVolume || s.PiBakeSettings + len(s.ClaudePlugins) > 0 || hasHooks || s.NeedsWorkspaceVolume || s.PiBakeSettings || + s.NeedsKernelSandbox } // needsInit returns whether the moat-init entrypoint script is required. @@ -106,6 +113,10 @@ func (s *ImageSpec) NeedsCustomImage(hasDeps bool) bool { // the non-root user (USER moatuser) with no entrypoint; named volumes are created // root-owned, and moat-init is what chowns them so that user can write — without // it the run hits EACCES on first write to the volume. +// +// NeedsKernelSandbox is included because the sandbox is applied by routing the +// entrypoint's final exec through the moat-sandbox helper; without moat-init +// there is no exec chain to hook into and MOAT_SANDBOX_POLICY would be ignored. func (s *ImageSpec) needsInit(dockerMode DockerMode) bool { if s == nil { return dockerMode != "" @@ -113,7 +124,8 @@ func (s *ImageSpec) needsInit(dockerMode DockerMode) bool { hasPreRun := s.Hooks != nil && s.Hooks.PreRun != "" return s.NeedsSSH || len(s.InitProviders) > 0 || s.NeedsClipboard || dockerMode != "" || hasPreRun || s.NeedsGitIdentity || s.NeedsInitFiles || - s.NeedsFirewall || s.HasNamedVolumes || s.NeedsWorkspaceVolume + s.NeedsFirewall || s.HasNamedVolumes || s.NeedsWorkspaceVolume || + s.NeedsKernelSandbox } // initProviderHashComponents returns sorted hash strings for InitProviders. diff --git a/internal/deps/kernel_sandbox_test.go b/internal/deps/kernel_sandbox_test.go new file mode 100644 index 00000000..d0811df7 --- /dev/null +++ b/internal/deps/kernel_sandbox_test.go @@ -0,0 +1,68 @@ +package deps + +import ( + "strings" + "testing" + + "github.com/majorcontext/moat/internal/sandbox" +) + +// The kernel sandbox must force BOTH a custom image and the moat-init +// entrypoint: the moat-sandbox helper is COPY'd into the image, and +// moat-init.sh's final exec chain is what routes through it. Without either, +// MOAT_SANDBOX_POLICY would be set but never honored. +func TestNeedsKernelSandboxForcesCustomImageAndInit(t *testing.T) { + if !(&ImageSpec{NeedsKernelSandbox: true}).NeedsCustomImage(false) { + t.Error("NeedsKernelSandbox should force NeedsCustomImage true") + } + if !(&ImageSpec{NeedsKernelSandbox: true}).needsInit("") { + t.Error("NeedsKernelSandbox should force needsInit true") + } + // Companion cases live in TestNeedsWorkspaceVolumeForcesCustomImageAndInit: + // an empty spec needs neither a custom image nor the entrypoint. +} + +func TestGenerateDockerfileKernelSandbox(t *testing.T) { + result, err := GenerateDockerfile(nil, &ImageSpec{NeedsKernelSandbox: true}) + if err != nil { + t.Fatalf("GenerateDockerfile error: %v", err) + } + if !strings.Contains(result.Dockerfile, "COPY moat-sandbox "+sandbox.HelperPath) { + t.Errorf("Dockerfile should COPY the moat-sandbox helper, got:\n%s", result.Dockerfile) + } + if _, ok := result.ContextFiles["moat-sandbox"]; !ok { + t.Error("ContextFiles should carry the moat-sandbox helper binary") + } + // The helper is useless without the entrypoint that routes exec through it. + if !strings.Contains(result.Dockerfile, "ENTRYPOINT [\"/usr/local/bin/moat-init\"]") { + t.Error("Dockerfile should keep the moat-init ENTRYPOINT") + } +} + +func TestGenerateDockerfileWithoutKernelSandbox(t *testing.T) { + // Companion: a spec that needs init for another reason must not ship the + // helper binary or its COPY line. + result, err := GenerateDockerfile(nil, &ImageSpec{NeedsGitIdentity: true}) + if err != nil { + t.Fatalf("GenerateDockerfile error: %v", err) + } + if strings.Contains(result.Dockerfile, "moat-sandbox") { + t.Errorf("Dockerfile should not reference moat-sandbox, got:\n%s", result.Dockerfile) + } + if _, ok := result.ContextFiles["moat-sandbox"]; ok { + t.Error("ContextFiles should not carry moat-sandbox when the kernel sandbox is off") + } +} + +func TestImageTagKernelSandbox(t *testing.T) { + deps := []Dependency{{Name: "node", Version: "22"}} + with := ImageTag(deps, &ImageSpec{NeedsKernelSandbox: true}) + without := ImageTag(deps, &ImageSpec{}) + if with == without { + t.Error("ImageTag should differ when the kernel sandbox is toggled (image carries the helper binary)") + } + // Determinism companion: same spec, same tag. + if with != ImageTag(deps, &ImageSpec{NeedsKernelSandbox: true}) { + t.Error("ImageTag should be deterministic for the same spec") + } +} diff --git a/internal/deps/scripts/moat-init.sh b/internal/deps/scripts/moat-init.sh index eccd7525..f9a03c0e 100644 --- a/internal/deps/scripts/moat-init.sh +++ b/internal/deps/scripts/moat-init.sh @@ -617,14 +617,32 @@ fi # This happens when Docker is started with --user to match host UID on Linux. # If we're root and moatuser exists, drop privileges with gosu. # If moatuser doesn't exist, fail - running as root defeats the security model. +# +# When MOAT_SANDBOX_POLICY is set (isolation.kernel_sandbox), the final exec +# routes through the moat-sandbox helper, which applies a Landlock filesystem +# sandbox to itself and execs the command; the restriction is inherited by +# every child process. The helper runs after the privilege drop so exactly the +# agent process tree is confined. Fail closed if the helper is missing: a +# requested kernel sandbox must never be skipped silently. populate_workspace_volume setup_workspace_mcp_json run_pre_run_hook +if [ -n "$MOAT_SANDBOX_POLICY" ] && [ ! -x /usr/local/bin/moat-sandbox ]; then + echo "Error: kernel sandbox requested (MOAT_SANDBOX_POLICY set) but /usr/local/bin/moat-sandbox is missing from the image." >&2 + echo "Rebuild the run image with 'moat run --rebuild' using a moat binary that supports isolation.kernel_sandbox." >&2 + exit 1 +fi if [ "$(id -u)" != "0" ]; then # Already non-root (e.g., --user was passed to docker run) + if [ -n "$MOAT_SANDBOX_POLICY" ]; then + exec /usr/local/bin/moat-sandbox "$@" + fi exec "$@" elif id moatuser >/dev/null 2>&1; then # Running as root, moatuser exists - drop privileges + if [ -n "$MOAT_SANDBOX_POLICY" ]; then + exec gosu moatuser /usr/local/bin/moat-sandbox "$@" + fi exec gosu moatuser "$@" else # Running as root, no moatuser - fail with clear error diff --git a/internal/e2e/kernel_sandbox_test.go b/internal/e2e/kernel_sandbox_test.go new file mode 100644 index 00000000..13e5a4c4 --- /dev/null +++ b/internal/e2e/kernel_sandbox_test.go @@ -0,0 +1,145 @@ +//go:build e2e +// +build e2e + +package e2e + +import ( + "context" + "runtime" + "strings" + "testing" + + llsys "github.com/landlock-lsm/go-landlock/landlock/syscall" + "github.com/majorcontext/moat/internal/config" + "github.com/majorcontext/moat/internal/run" +) + +// sandboxProbeScript exercises the kernel sandbox boundary from inside the +// container. /opt/probe is created writable-by-moatuser at image build time, +// so a write failure there can only come from Landlock, not from DAC +// permissions. /opt/extra proves isolation.sandbox.allow_write works. +const sandboxProbeScript = ` +echo "WS_WRITE=$(touch /workspace/probe 2>/dev/null && echo ok || echo denied)" +echo "HOME_WRITE=$(touch "$HOME/probe" 2>/dev/null && echo ok || echo denied)" +echo "TMP_WRITE=$(touch /tmp/probe 2>/dev/null && echo ok || echo denied)" +echo "PROBE_WRITE=$(touch /opt/probe/f 2>/dev/null && echo ok || echo denied)" +echo "EXTRA_WRITE=$(touch /opt/extra/f 2>/dev/null && echo ok || echo denied)" +echo "ETC_READ=$(cat /etc/os-release >/dev/null 2>&1 && echo ok || echo denied)" +` + +const sandboxProbeHook = "mkdir -p /opt/probe /opt/extra && chown moatuser:moatuser /opt/probe /opt/extra" + +// requireLandlock skips unless the host kernel supports Landlock. Containers +// share the host kernel on Linux, so a host probe is authoritative; on other +// hosts the container kernel cannot be probed from the test process. +func requireLandlock(t *testing.T) { + t.Helper() + if runtime.GOOS != "linux" { + t.Skip("Landlock e2e requires a Linux host (container kernel == host kernel)") + } + if abi, err := llsys.LandlockGetABIVersion(); err != nil || abi < 1 { + t.Skip("host kernel does not support Landlock") + } +} + +// runSandboxProbe creates, starts, and waits for a run executing +// sandboxProbeScript with the given config, returning all captured log lines. +func runSandboxProbe(t *testing.T, name string, cfg *config.Config) []string { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + mgr, err := run.NewManagerWithOptions(run.ManagerOptions{NoSandbox: &[]bool{true}[0]}) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + defer mgr.Close() + + r, err := mgr.Create(ctx, run.Options{ + Name: name, + Workspace: createTestWorkspace(t), + Cmd: []string{"sh", "-c", sandboxProbeScript}, + Config: cfg, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + defer mgr.Destroy(context.Background(), r.ID) + + if err := mgr.Start(ctx, r.ID); err != nil { + t.Fatalf("Start: %v", err) + } + if err := mgr.Wait(ctx, r.ID); err != nil { + t.Fatalf("Wait: %v", err) + } + + logs, err := r.Store.ReadLogs(0, 200) + if err != nil { + t.Fatalf("ReadLogs: %v", err) + } + lines := make([]string, 0, len(logs)) + for _, entry := range logs { + lines = append(lines, entry.Line) + } + return lines +} + +func assertLogLine(t *testing.T, lines []string, want string) { + t.Helper() + for _, line := range lines { + if strings.Contains(line, want) { + return + } + } + t.Errorf("logs missing %q:\n%s", want, strings.Join(lines, "\n")) +} + +// TestKernelSandboxEnforced runs a real container with +// isolation.kernel_sandbox and asserts the Landlock write-allowlist holds: +// workspace/home/tmp/allow_write writable, a moatuser-owned path outside the +// allowlist denied, reads everywhere intact. +func TestKernelSandboxEnforced(t *testing.T) { + requireDocker(t) + requireLandlock(t) + + cfg := &config.Config{ + Isolation: config.IsolationConfig{ + KernelSandbox: true, + Sandbox: config.SandboxPathsConfig{ + AllowWrite: []string{"/opt/extra"}, + }, + }, + Hooks: config.HooksConfig{PostBuildRoot: sandboxProbeHook}, + } + lines := runSandboxProbe(t, "test-kernel-sandbox-on", cfg) + + assertLogLine(t, lines, "kernel sandbox active (Landlock ABI v") + assertLogLine(t, lines, "WS_WRITE=ok") + assertLogLine(t, lines, "HOME_WRITE=ok") + assertLogLine(t, lines, "TMP_WRITE=ok") + assertLogLine(t, lines, "PROBE_WRITE=denied") + assertLogLine(t, lines, "EXTRA_WRITE=ok") + assertLogLine(t, lines, "ETC_READ=ok") +} + +// TestKernelSandboxDisabled is the companion: the identical image and probe +// without isolation.kernel_sandbox must not restrict anything and must not +// announce a sandbox — proving the denial above comes from Landlock, not +// from image permissions. +func TestKernelSandboxDisabled(t *testing.T) { + requireDocker(t) + requireLandlock(t) + + cfg := &config.Config{ + Hooks: config.HooksConfig{PostBuildRoot: sandboxProbeHook}, + } + lines := runSandboxProbe(t, "test-kernel-sandbox-off", cfg) + + assertLogLine(t, lines, "PROBE_WRITE=ok") + assertLogLine(t, lines, "WS_WRITE=ok") + for _, line := range lines { + if strings.Contains(line, "kernel sandbox active") { + t.Errorf("unsandboxed run announced a kernel sandbox: %q", line) + } + } +} diff --git a/internal/run/manager_create.go b/internal/run/manager_create.go index b85c6c20..22cc8c54 100644 --- a/internal/run/manager_create.go +++ b/internal/run/manager_create.go @@ -42,6 +42,8 @@ import ( "github.com/majorcontext/moat/internal/providers/claude" // only for settings types (LoadAllSettings, Settings, MarketplaceConfig) - provider setup uses provider interfaces copilotprov "github.com/majorcontext/moat/internal/providers/copilot" "github.com/majorcontext/moat/internal/runctx" + "github.com/majorcontext/moat/internal/sandbox" + "github.com/majorcontext/moat/internal/sandboxbin" "github.com/majorcontext/moat/internal/secrets" "github.com/majorcontext/moat/internal/snapshot" "github.com/majorcontext/moat/internal/sshagent" @@ -163,6 +165,22 @@ func (m *Manager) Create(ctx context.Context, opts Options) (resRun *Run, retErr } } + // Kernel sandbox pre-flight: fail before allocating any resources when the + // embedded moat-sandbox helper cannot be shipped. Binary() is nil on + // architectures moat builds no run images for, and IsStub catches builds + // that bypassed `go generate` (bare `go build`, `go install`) — shipping + // the stub would fail closed at container start with a worse error. + kernelSandbox := opts.Config != nil && opts.Config.Isolation.KernelSandbox + if kernelSandbox { + bin := sandboxbin.Binary() + if bin == nil { + return nil, fmt.Errorf("isolation.kernel_sandbox is not supported on %s: no moat-sandbox helper is built for this architecture (supported: amd64, arm64)", goruntime.GOARCH) + } + if sandboxbin.IsStub(bin) { + return nil, fmt.Errorf("isolation.kernel_sandbox requires the embedded moat-sandbox helper, but this moat binary carries the placeholder stub — rebuild with 'make build-cli' (or run 'go generate ./internal/sandboxbin' before 'go build')") + } + } + // Get ports from config var ports map[string]int if opts.Config != nil && len(opts.Config.Ports) > 0 { @@ -1206,6 +1224,7 @@ region = %s PiPackages: piPackages, HasNamedVolumes: configHasNamedVolumes(opts.Config), Hooks: hooks, + NeedsKernelSandbox: kernelSandbox, // Volume mode requires the moat-init entrypoint to populate + chown the // named volume as root; force a custom image with init even when the run // has no deps/grants (otherwise the volume is silently left empty). @@ -2020,6 +2039,30 @@ region = %s }() } + // Kernel sandbox: compute the Landlock write-allowlist from the final + // mount set (every read-write target must stay writable — the workspace, + // worktree git dirs, named volumes, docker.sock) plus the config's + // allow_write entries, and ship it to the in-container moat-sandbox + // helper. moat-init.sh routes its final exec through the helper whenever + // MOAT_SANDBOX_POLICY is set. + if kernelSandbox { + var rwTargets []string + for _, mnt := range mounts { + if !mnt.ReadOnly { + rwTargets = append(rwTargets, mnt.Target) + } + } + var allowWrite []string + if opts.Config != nil { + allowWrite = opts.Config.Isolation.Sandbox.AllowWrite + } + encoded, encErr := sandbox.BuildPolicy(rwTargets, allowWrite).Encode() + if encErr != nil { + return nil, encErr + } + proxyEnv = append(proxyEnv, sandbox.PolicyEnv+"="+encoded) + } + // Create container containerID, err := m.defaultRuntime().CreateContainer(ctx, container.Config{ Name: r.ID, diff --git a/internal/sandbox/apply_linux.go b/internal/sandbox/apply_linux.go new file mode 100644 index 00000000..c14207c8 --- /dev/null +++ b/internal/sandbox/apply_linux.go @@ -0,0 +1,72 @@ +//go:build linux + +package sandbox + +import ( + "fmt" + "os" + + "github.com/landlock-lsm/go-landlock/landlock" + llsys "github.com/landlock-lsm/go-landlock/landlock/syscall" +) + +// Status reports what enforcement was actually achieved. +type Status struct { + // ABI is the Landlock ABI version supported by the running kernel. + // 0 means Landlock is unavailable (kernel < 5.13, or the landlock + // syscalls are blocked by seccomp — e.g. Docker < 23) and nothing was + // enforced. + ABI int +} + +// Apply enforces the policy on the calling process via Landlock. The +// restriction is inherited by all children and cannot be lifted. +// +// If Landlock is unavailable, Apply returns Status{ABI: 0} with a nil error +// and enforces nothing — the caller decides how loudly to warn. This probe +// exists because go-landlock's best-effort mode also succeeds silently on +// kernels without Landlock, which would hide the degradation entirely. +// +// The agent's home directory ($HOME, set by the entrypoint's privilege drop) +// is added to the writable set: agents write config, caches, and logs there. +func Apply(p Policy) (Status, error) { + abi, err := llsys.LandlockGetABIVersion() + if err != nil || abi < 1 { + return Status{ABI: 0}, nil + } + + writable := p.AllowWrite + if home := os.Getenv("HOME"); home != "" { + writable = append(writable, home) + } + + rules := []landlock.Rule{ + // Read everywhere: domain-level secrets policy stays with the + // credential proxy; the kernel wall is about writes. + landlock.RODirs("/"), + } + for _, dir := range writable { + rule := landlock.RWDirs(dir). + // Renames/links across directory boundaries (git gc, package + // managers moving staged trees) need the v2 "refer" right. + WithRefer(). + // Slim images may lack /var/tmp etc.; a missing path must not + // abort the whole restriction. + IgnoreIfMissing() + if dir == "/dev" { + // IOCTLs on TTYs and /dev/null are grouped under a dedicated + // right from ABI v5; without it terminal handling breaks. + rule = rule.WithIoctlDev() + } + rules = append(rules, rule) + } + + // V5 is the newest ABI whose filesystem rights we use; BestEffort + // downgrades (dropping refer/truncate/ioctl grouping as needed) on older + // kernels. RestrictPaths only handles filesystem access — TCP restriction + // (v4) is deliberately left to the credential proxy in this first cut. + if err := landlock.V5.BestEffort().RestrictPaths(rules...); err != nil { + return Status{ABI: abi}, fmt.Errorf("applying landlock policy: %w", err) + } + return Status{ABI: abi}, nil +} diff --git a/internal/sandbox/apply_linux_test.go b/internal/sandbox/apply_linux_test.go new file mode 100644 index 00000000..df5eddeb --- /dev/null +++ b/internal/sandbox/apply_linux_test.go @@ -0,0 +1,115 @@ +//go:build linux + +package sandbox + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + llsys "github.com/landlock-lsm/go-landlock/landlock/syscall" +) + +// TestApplyEnforcement verifies real Landlock enforcement in a sacrificial +// subprocess (the restriction is irreversible, so it must not be applied to +// the test process itself): writes inside the allowlist succeed, writes +// outside fail, and reads outside stay allowed. +func TestApplyEnforcement(t *testing.T) { + if abi, err := llsys.LandlockGetABIVersion(); err != nil || abi < 1 { + t.Skip("Landlock not available on this kernel; skipping enforcement test") + } + + base := t.TempDir() + allowed := filepath.Join(base, "allowed") + denied := filepath.Join(base, "denied") + for _, d := range []string{allowed, denied} { + if err := os.Mkdir(d, 0o755); err != nil { + t.Fatal(err) + } + } + + cmd := exec.Command(os.Args[0], "-test.run=TestApplyHelperProcess", "-test.v") + cmd.Env = append(os.Environ(), + "GO_SANDBOX_HELPER=1", + "SANDBOX_TEST_ALLOWED="+allowed, + "SANDBOX_TEST_DENIED="+denied, + // Apply adds $HOME to the writable set; clear it so the assertions + // below only reflect the explicit policy. + "HOME=", + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("helper process failed: %v\n%s", err, out) + } + for _, want := range []string{ + "RESULT allowed-write=ok", + "RESULT denied-write=denied", + "RESULT outside-read=ok", + } { + if !strings.Contains(string(out), want) { + t.Errorf("helper output missing %q:\n%s", want, out) + } + } +} + +// TestApplyHelperProcess is the sacrificial subprocess for +// TestApplyEnforcement; it self-restricts and reports what the kernel +// actually enforces. It only runs when re-exec'd with GO_SANDBOX_HELPER=1. +func TestApplyHelperProcess(t *testing.T) { + if os.Getenv("GO_SANDBOX_HELPER") != "1" { + t.Skip("helper process for TestApplyEnforcement") + } + allowed := os.Getenv("SANDBOX_TEST_ALLOWED") + denied := os.Getenv("SANDBOX_TEST_DENIED") + + status, err := Apply(Policy{AllowWrite: []string{allowed}}) + if err != nil { + fmt.Printf("RESULT apply-error=%v\n", err) + os.Exit(1) + } + if status.ABI < 1 { + fmt.Println("RESULT apply-error=landlock unexpectedly unavailable") + os.Exit(1) + } + + if err := os.WriteFile(filepath.Join(allowed, "f"), []byte("x"), 0o644); err == nil { + fmt.Println("RESULT allowed-write=ok") + } else { + fmt.Printf("RESULT allowed-write=denied (%v)\n", err) + } + if err := os.WriteFile(filepath.Join(denied, "f"), []byte("x"), 0o644); err != nil { + fmt.Println("RESULT denied-write=denied") + } else { + fmt.Println("RESULT denied-write=ok") + } + if _, err := os.ReadFile("/etc/hostname"); err == nil { + fmt.Println("RESULT outside-read=ok") + } else { + fmt.Printf("RESULT outside-read=denied (%v)\n", err) + } + + // Exit before test-framework teardown: the restricted process may not be + // able to write wherever `go test` wants to (profiles, temp files). + os.Exit(0) +} + +func TestApplyUnavailableKernelReportsZeroABI(t *testing.T) { + // Companion contract check: Status.ABI == 0 means "nothing enforced" and + // Apply must not error in that case. We can't force unavailability here, + // so this pins the available-kernel side: ABI is probed, not hardcoded. + abi, err := llsys.LandlockGetABIVersion() + if err != nil || abi < 1 { + st, applyErr := Apply(Policy{}) + if applyErr != nil { + t.Errorf("Apply on non-Landlock kernel: %v, want nil (warn-and-degrade contract)", applyErr) + } + if st.ABI != 0 { + t.Errorf("Status.ABI = %d on non-Landlock kernel, want 0", st.ABI) + } + return + } + t.Skip("Landlock available; unavailability path exercised only on pre-5.13 kernels") +} diff --git a/internal/sandbox/policy.go b/internal/sandbox/policy.go new file mode 100644 index 00000000..68a59ab0 --- /dev/null +++ b/internal/sandbox/policy.go @@ -0,0 +1,106 @@ +// Package sandbox implements the kernel sandbox (Landlock) policy applied to +// the agent process inside a Moat container (issue #396, in-container mode). +// +// The policy is computed on the host by the run manager (BuildPolicy), +// serialized as JSON into the MOAT_SANDBOX_POLICY environment variable, and +// applied in-container by the moat-sandbox helper binary (cmd/moat-sandbox) +// as the final step of the entrypoint exec chain. Landlock restrictions are +// inherited by every child process and cannot be lifted once applied. +// +// The posture is "read everywhere, write only where allowed": the whole +// container filesystem stays readable, and writes are limited to the +// workspace, the agent's home, scratch/system paths, read-write mounts, and +// any isolation.sandbox.allow_write entries from moat.yaml. Landlock is +// allowlist-only, so deny-style rules inside an allowed tree are not +// expressible; see docs/plans/2026-07-23-kernel-sandbox-design.md. +package sandbox + +import ( + "encoding/json" + "fmt" + "path" + "sort" +) + +// PolicyEnv is the environment variable carrying the JSON-encoded Policy +// from the run manager to the in-container moat-sandbox helper. The helper +// scrubs it from the environment before exec'ing the agent. +const PolicyEnv = "MOAT_SANDBOX_POLICY" + +// HelperPath is where the moat-sandbox helper binary is installed inside +// run images. +const HelperPath = "/usr/local/bin/moat-sandbox" + +// defaultWritePaths are container paths writable under every kernel-sandbox +// policy, in addition to the workspace, mounts, and user-configured paths. +// +// - /tmp, /var/tmp: scratch space. +// - /dev: TTY handling (/dev/tty, /dev/ptmx, /dev/shm); device IOCTLs are +// granted separately (see apply_linux.go). +// - /proc: shells write through /dev/stdout -> /proc/self/fd/1; sensitive +// areas (/proc/sys and friends) are already masked read-only by the +// container runtime. +// - /run: connecting to a unix socket requires write access to its path +// (e.g. the SSH agent bridge at /run/moat/ssh). +// +// The agent's home directory is intentionally absent: it is only known +// in-container (gosu sets $HOME during the privilege drop), so the helper +// adds it at apply time. +var defaultWritePaths = []string{"/tmp", "/var/tmp", "/dev", "/proc", "/run"} + +// Policy describes the filesystem restrictions applied to the agent process. +// Reads are always allowed everywhere; AllowWrite lists the directory trees +// that stay writable. +type Policy struct { + AllowWrite []string `json:"allow_write"` +} + +// BuildPolicy computes the in-container policy: the built-in defaults, every +// read-write mount target (the workspace — bind or named volume — always +// arrives as one), and the user-configured allow_write entries, cleaned, +// de-duplicated, and sorted for determinism. All paths are container paths +// and must be absolute. A read-only workspace mount is deliberately not +// added: the mount layer already denies writes there. +func BuildPolicy(rwMountTargets, allowWrite []string) Policy { + seen := make(map[string]bool) + var paths []string + add := func(p string) { + if p == "" { + return + } + p = path.Clean(p) + if !seen[p] { + seen[p] = true + paths = append(paths, p) + } + } + for _, p := range defaultWritePaths { + add(p) + } + for _, p := range rwMountTargets { + add(p) + } + for _, p := range allowWrite { + add(p) + } + sort.Strings(paths) + return Policy{AllowWrite: paths} +} + +// Encode serializes the policy for transport in PolicyEnv. +func (p Policy) Encode() (string, error) { + data, err := json.Marshal(p) + if err != nil { + return "", fmt.Errorf("encoding sandbox policy: %w", err) + } + return string(data), nil +} + +// ParsePolicy decodes a policy previously produced by Encode. +func ParsePolicy(s string) (Policy, error) { + var p Policy + if err := json.Unmarshal([]byte(s), &p); err != nil { + return Policy{}, fmt.Errorf("parsing %s: %w", PolicyEnv, err) + } + return p, nil +} diff --git a/internal/sandbox/policy_test.go b/internal/sandbox/policy_test.go new file mode 100644 index 00000000..8d344eb9 --- /dev/null +++ b/internal/sandbox/policy_test.go @@ -0,0 +1,102 @@ +package sandbox + +import ( + "reflect" + "testing" +) + +func TestBuildPolicyDefaults(t *testing.T) { + p := BuildPolicy(nil, nil) + want := []string{"/dev", "/proc", "/run", "/tmp", "/var/tmp"} + if !reflect.DeepEqual(p.AllowWrite, want) { + t.Errorf("AllowWrite = %v, want %v", p.AllowWrite, want) + } +} + +func TestBuildPolicyIncludesMountsAndExtras(t *testing.T) { + p := BuildPolicy( + []string{"/workspace", "/var/run/docker.sock"}, + []string{"/data"}, + ) + for _, path := range []string{"/workspace", "/var/run/docker.sock", "/data", "/tmp"} { + if !contains(p.AllowWrite, path) { + t.Errorf("AllowWrite = %v, missing %q", p.AllowWrite, path) + } + } +} + +func TestBuildPolicyExcludesNothingItWasNotGiven(t *testing.T) { + // Companion to the inclusion test: the caller filters read-only mounts, + // so a policy built without them must not contain them. This pins the + // contract that BuildPolicy adds no mount paths on its own. + p := BuildPolicy(nil, nil) + for _, path := range []string{"/workspace", "/", "/etc", "/home"} { + if contains(p.AllowWrite, path) { + t.Errorf("AllowWrite = %v, unexpectedly contains %q", p.AllowWrite, path) + } + } +} + +func TestBuildPolicyCleansAndDedupes(t *testing.T) { + p := BuildPolicy([]string{"/workspace/", "/workspace", "/tmp"}, []string{"/data/../data"}) + count := 0 + for _, path := range p.AllowWrite { + if path == "/workspace" { + count++ + } + } + if count != 1 { + t.Errorf("AllowWrite = %v, want exactly one /workspace entry", p.AllowWrite) + } + if contains(p.AllowWrite, "/data/../data") || !contains(p.AllowWrite, "/data") { + t.Errorf("AllowWrite = %v, want cleaned /data", p.AllowWrite) + } + // Empty strings are dropped, not turned into ".". + p = BuildPolicy([]string{""}, nil) + if contains(p.AllowWrite, ".") || contains(p.AllowWrite, "") { + t.Errorf("AllowWrite = %v, empty input should be dropped", p.AllowWrite) + } +} + +func TestBuildPolicyDeterministic(t *testing.T) { + a := BuildPolicy([]string{"/b", "/a"}, []string{"/c"}) + b := BuildPolicy([]string{"/a", "/b"}, []string{"/c"}) + if !reflect.DeepEqual(a, b) { + t.Errorf("policies differ by input order: %v vs %v", a, b) + } +} + +func TestPolicyEncodeParseRoundTrip(t *testing.T) { + p := BuildPolicy([]string{"/workspace"}, []string{"/data"}) + s, err := p.Encode() + if err != nil { + t.Fatalf("Encode: %v", err) + } + got, err := ParsePolicy(s) + if err != nil { + t.Fatalf("ParsePolicy: %v", err) + } + if !reflect.DeepEqual(got, p) { + t.Errorf("round trip = %+v, want %+v", got, p) + } +} + +func TestParsePolicyRejectsGarbage(t *testing.T) { + // Companion to the round-trip test: malformed transport must error, not + // yield an empty (allow-nothing... or worse, misparsed) policy silently. + if _, err := ParsePolicy("not json"); err == nil { + t.Error("ParsePolicy(garbage) succeeded, want error") + } + if _, err := ParsePolicy(""); err == nil { + t.Error("ParsePolicy(empty) succeeded, want error") + } +} + +func contains(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false +} diff --git a/internal/sandboxbin/checksums.txt b/internal/sandboxbin/checksums.txt new file mode 100644 index 00000000..3a4eabcc --- /dev/null +++ b/internal/sandboxbin/checksums.txt @@ -0,0 +1,2 @@ +e00d5d9cf8b1557361d3e43df37c3bd40577f011fe754a07ce7f65fcb083f499 moat-sandbox-linux-amd64 +e00d5d9cf8b1557361d3e43df37c3bd40577f011fe754a07ce7f65fcb083f499 moat-sandbox-linux-arm64 diff --git a/internal/sandboxbin/embed/moat-sandbox-linux-amd64 b/internal/sandboxbin/embed/moat-sandbox-linux-amd64 new file mode 100644 index 00000000..9177d045 --- /dev/null +++ b/internal/sandboxbin/embed/moat-sandbox-linux-amd64 @@ -0,0 +1,13 @@ +#!/bin/sh +# moat-sandbox-stub +# +# Committed placeholder for the real moat-sandbox helper binary so a fresh +# clone compiles (go:embed requires the file to exist). The real binary is +# cross-compiled over this file by `go generate ./internal/sandboxbin`, which +# `make build-cli` and the release pipeline run automatically. +# +# Fail closed: if this stub ever ships into a run image, running it must +# refuse loudly rather than exec the agent without the requested kernel +# sandbox. +echo "FATAL: moat-sandbox stub embedded - rebuild moat via 'make build-cli' (runs go generate ./internal/sandboxbin)" >&2 +exit 1 diff --git a/internal/sandboxbin/embed/moat-sandbox-linux-arm64 b/internal/sandboxbin/embed/moat-sandbox-linux-arm64 new file mode 100644 index 00000000..9177d045 --- /dev/null +++ b/internal/sandboxbin/embed/moat-sandbox-linux-arm64 @@ -0,0 +1,13 @@ +#!/bin/sh +# moat-sandbox-stub +# +# Committed placeholder for the real moat-sandbox helper binary so a fresh +# clone compiles (go:embed requires the file to exist). The real binary is +# cross-compiled over this file by `go generate ./internal/sandboxbin`, which +# `make build-cli` and the release pipeline run automatically. +# +# Fail closed: if this stub ever ships into a run image, running it must +# refuse loudly rather than exec the agent without the requested kernel +# sandbox. +echo "FATAL: moat-sandbox stub embedded - rebuild moat via 'make build-cli' (runs go generate ./internal/sandboxbin)" >&2 +exit 1 diff --git a/internal/sandboxbin/gen/gen.go b/internal/sandboxbin/gen/gen.go new file mode 100644 index 00000000..667b5edd --- /dev/null +++ b/internal/sandboxbin/gen/gen.go @@ -0,0 +1,58 @@ +// Command gen cross-compiles cmd/moat-sandbox into the embed/ blobs and +// refreshes checksums.txt. It is invoked by `go generate +// ./internal/sandboxbin` from the sandboxbin package directory (go generate +// sets the working directory to the directory of the file containing the +// directive). +// +// Build flags: CGO_ENABLED=0 for a static binary that runs on any base image +// (no dynamic loader / glibc dependency), -trimpath and -ldflags "-s -w" for +// reproducible, minimal blobs. The checksums are committed alongside the +// stubs; a unit test hashes the embedded bytes against checksums.txt to +// catch a stale or hand-edited blob. Note the checksums pin the Go +// toolchain: a toolchain upgrade changes the -trimpath output and fails the +// checksum test until regenerated. +package main + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "os/exec" + "path/filepath" +) + +const target = "github.com/majorcontext/moat/cmd/moat-sandbox" + +func main() { + arches := []string{"amd64", "arm64"} + sums := "" + for _, arch := range arches { + out := filepath.Join("embed", "moat-sandbox-linux-"+arch) + // go build -o refuses to overwrite a non-object file (the committed + // shell-script stub), so clear the target first. + if err := os.Remove(out); err != nil && !os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "sandboxbin gen: removing %s: %v\n", out, err) + os.Exit(1) + } + cmd := exec.Command("go", "build", "-trimpath", "-ldflags", "-s -w", "-o", out, target) + cmd.Env = append(os.Environ(), "CGO_ENABLED=0", "GOOS=linux", "GOARCH="+arch) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "sandboxbin gen: building %s for %s: %v\n", target, arch, err) + os.Exit(1) + } + data, err := os.ReadFile(out) + if err != nil { + fmt.Fprintf(os.Stderr, "sandboxbin gen: reading %s: %v\n", out, err) + os.Exit(1) + } + sum := sha256.Sum256(data) + sums += hex.EncodeToString(sum[:]) + " " + filepath.Base(out) + "\n" + } + if err := os.WriteFile("checksums.txt", []byte(sums), 0o644); err != nil { + fmt.Fprintf(os.Stderr, "sandboxbin gen: writing checksums.txt: %v\n", err) + os.Exit(1) + } +} diff --git a/internal/sandboxbin/sandboxbin.go b/internal/sandboxbin/sandboxbin.go new file mode 100644 index 00000000..943a5a8a --- /dev/null +++ b/internal/sandboxbin/sandboxbin.go @@ -0,0 +1,71 @@ +// Package sandboxbin embeds the prebuilt moat-sandbox helper binaries +// (cmd/moat-sandbox cross-compiled static for linux/amd64 and linux/arm64) +// so the moat host binary can ship them into run images without any network +// fetch at image-build time. It mirrors the internal/initbin pattern from +// the moat-init Go entrypoint work (PR #441) so the two can merge later. +// +// The committed blobs under embed/ are human-readable fail-closed stubs (a +// tiny shell script that prints a FATAL message and exits 1), so a fresh +// clone always compiles. `go generate ./internal/sandboxbin` cross-compiles +// the real binaries over the stubs and refreshes checksums.txt; it is wired +// into `make build-cli` and the goreleaser before hook (`go generate ./...`). +// Never commit the regenerated real blobs — `make restore-sandbox-stubs` +// puts the stubs back. +// +// The embed directory is deliberately named embed/ (not dist/): the repo's +// .gitignore has a bare `dist/` that matches at any depth and would silently +// untrack the committed stubs. +package sandboxbin + +import ( + "bytes" + _ "embed" + "runtime" +) + +//go:generate go run ./gen + +//go:embed embed/moat-sandbox-linux-amd64 +var binAMD64 []byte + +//go:embed embed/moat-sandbox-linux-arm64 +var binARM64 []byte + +//go:embed checksums.txt +var Checksums string + +// stubMarker identifies the committed placeholder blobs. The stub is a shell +// script (reviewable text, runs on any Linux) whose second line carries this +// marker; a real cross-compiled binary is an ELF image and can never start +// with it. +const stubMarker = "#!/bin/sh\n# moat-sandbox-stub" + +// BinaryFor returns the embedded helper for a GOARCH, or nil when no binary +// is embedded for that architecture. Run images are always built for the +// host's own architecture, so runtime.GOARCH selects the right blob. +func BinaryFor(goarch string) []byte { + switch goarch { + case "amd64": + return binAMD64 + case "arm64": + return binARM64 + default: + return nil + } +} + +// Binary returns the embedded helper matching the host architecture, or nil +// on architectures moat does not build run images for. +func Binary() []byte { + return BinaryFor(runtime.GOARCH) +} + +// IsStub reports whether b is the committed fail-closed placeholder rather +// than a real cross-compiled helper. The run manager refuses to create a +// kernel-sandboxed run from a stub build (with rebuild instructions), and +// the stub itself fails loudly at runtime — the backstop for channels that +// bypass generation entirely (`go install`, bare `go build`). A stub must +// never exec the agent unsandboxed. +func IsStub(b []byte) bool { + return bytes.HasPrefix(b, []byte(stubMarker)) +} diff --git a/internal/sandboxbin/sandboxbin_test.go b/internal/sandboxbin/sandboxbin_test.go new file mode 100644 index 00000000..44767a97 --- /dev/null +++ b/internal/sandboxbin/sandboxbin_test.go @@ -0,0 +1,108 @@ +package sandboxbin + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "testing" +) + +// TestEmbeddedChecksums hashes the embedded blobs against the committed +// checksums.txt, catching a stale or hand-edited blob (in either state: +// committed stubs, or blobs regenerated by go generate). +func TestEmbeddedChecksums(t *testing.T) { + want := map[string]string{} + for _, line := range strings.Split(strings.TrimSpace(Checksums), "\n") { + fields := strings.Fields(line) + if len(fields) != 2 { + t.Fatalf("malformed checksums.txt line: %q", line) + } + want[fields[1]] = fields[0] + } + got := map[string][]byte{ + "moat-sandbox-linux-amd64": BinaryFor("amd64"), + "moat-sandbox-linux-arm64": BinaryFor("arm64"), + } + if len(want) != len(got) { + t.Fatalf("checksums.txt has %d entries, want %d", len(want), len(got)) + } + for name, data := range got { + sum := sha256.Sum256(data) + if hex.EncodeToString(sum[:]) != want[name] { + t.Errorf("%s: embedded bytes do not match checksums.txt — regenerate with 'go generate ./internal/sandboxbin' (or restore stubs with 'make restore-sandbox-stubs')", name) + } + } +} + +func TestBinaryForArchSelection(t *testing.T) { + if BinaryFor("amd64") == nil { + t.Error("BinaryFor(amd64) = nil, want embedded blob") + } + if BinaryFor("arm64") == nil { + t.Error("BinaryFor(arm64) = nil, want embedded blob") + } + // Companion: unsupported architectures yield nil, not a wrong-arch blob. + if BinaryFor("riscv64") != nil { + t.Error("BinaryFor(riscv64) != nil, want nil for unsupported arch") + } + if Binary() == nil { + t.Error("Binary() = nil on a supported host arch") + } +} + +func TestIsStub(t *testing.T) { + // The committed placeholders must be recognized as stubs so the run + // manager can refuse to create kernel-sandboxed runs from them... + if !IsStub([]byte("#!/bin/sh\n# moat-sandbox-stub\necho FATAL >&2\nexit 1\n")) { + t.Error("IsStub(stub content) = false, want true") + } + // ...and real binaries (ELF magic) must not be misclassified. + if IsStub([]byte("\x7fELF\x02\x01\x01\x00moat-sandbox")) { + t.Error("IsStub(ELF bytes) = true, want false") + } + // Companion: an arbitrary shell script that is not the stub is not a stub. + if IsStub([]byte("#!/bin/sh\necho hello\n")) { + t.Error("IsStub(unrelated script) = true, want false") + } +} + +// TestCommittedBlobsAreStubs is the commit guard: the embed blobs tracked in +// git must be the fail-closed stubs, never the real cross-compiled binaries +// (which are multi-MB build artifacts, not source). `go generate` overwrites +// them during a build; the build/test Makefile targets restore them +// afterward, so a clean checkout — and therefore CI and any commit — always +// carries stubs. +// +// This closes the gap TestEmbeddedChecksums leaves: that test only checks the +// blobs match checksums.txt, so a real binary committed together with its +// regenerated checksum passes it. This test fails on that state. +// +// If this fails locally, you have generated real binaries in your tree (e.g. +// via a bare `go generate`): run `make restore-sandbox-stubs` before +// committing. +func TestCommittedBlobsAreStubs(t *testing.T) { + for _, arch := range []string{"amd64", "arm64"} { + if !IsStub(BinaryFor(arch)) { + t.Errorf("embed/moat-sandbox-linux-%s is a real binary, not the committed stub — run 'make restore-sandbox-stubs' before committing (the real binaries are build artifacts, baked into ./moat at compile time, and must never be committed)", arch) + } + } +} + +// TestStubFailsClosed pins the fail-closed contract of the committed stub: +// it must be a shell script that prints a FATAL message and exits 1, never +// a silent no-op that would exec the agent without the requested sandbox. +func TestStubFailsClosed(t *testing.T) { + for _, arch := range []string{"amd64", "arm64"} { + b := BinaryFor(arch) + if !IsStub(b) { + t.Skipf("%s blob is a real binary (generated tree), stub contract not applicable", arch) + } + s := string(b) + if !strings.Contains(s, "FATAL: moat-sandbox stub embedded") { + t.Errorf("%s stub missing FATAL message", arch) + } + if !strings.Contains(s, "exit 1") { + t.Errorf("%s stub missing exit 1", arch) + } + } +}