Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
25 changes: 20 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand Down
72 changes: 72 additions & 0 deletions cmd/moat-sandbox/main_linux.go
Original file line number Diff line number Diff line change
@@ -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 <command> [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())
}
15 changes: 15 additions & 0 deletions cmd/moat-sandbox/main_other.go
Original file line number Diff line number Diff line change
@@ -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)
}
10 changes: 10 additions & 0 deletions cmd/moat/cli/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions docs/content/concepts/01-sandboxing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 -- <command>`.

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:
Expand Down
12 changes: 12 additions & 0 deletions docs/content/reference/01-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
Expand Down Expand Up @@ -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`) |

Expand Down Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions docs/content/reference/02-moat-yaml.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading