Table of Contents generated with DocToc
sandbox-lintCapability: substrate:sandbox
Harness: Claude Code, Codex, Cursor, Gemini CLI, OpenCode, Kiro
Lints .claude/settings.json against the shipped baseline at tools/sandbox-lint/expected.json, and against the security invariants documented in docs/security/threat-model.md (mitigation M.29). The threat-model document lands in a companion PR; the lint stands on its own and runs immediately on merge.
OpenCode. --opencode opencode.json lints the other harness's security config. OpenCode has no sandbox.filesystem allow/deny model (its filesystem isolation comes from the OS-level sandbox of the secure-agent setup), so there is no baseline to diff — instead the lint asserts invariants on the permission policy (OpenCode permissions): the policy must not be a blanket "allow", permission.bash must not default to "allow", no rule may auto-approve a dangerous command family (git push, sudo, curl/wget, rm -rf, cloud CLIs, kubectl, docker run, ssh, interpreters, …) via last-match-wins evaluation, and webfetch / external_directory must not be blanket-"allow".
uv run --project tools/sandbox-lint sandbox-lint --opencode opencode.json
Kiro. --kiro .kiro/agents/<name>.json lints a Kiro CLI agent config. Kiro‘s shell posture is an allowlist/denylist rather than a per-command decision map (Kiro shell tool): allowedCommands / deniedCommands are full-string-anchored regex, deny is evaluated before allow, denyByDefault is the native allowlist-safe posture, and Kiro’s default (no config) is to prompt — so, unlike OpenCode, a missing policy is not flagged. The invariants: the shell tool (shell / execute_bash / * / @builtin) must not be in allowedTools (that auto-approves every shell command and overrides toolsSettings), no allowedCommands regex may auto-approve a dangerous family (and stay unblocked by deniedCommands), aws / web_fetch must not be blanket-allowed in allowedTools, and web_fetch.trusted must not be a blanket .*.
uv run --project tools/sandbox-lint sandbox-lint --kiro .kiro/agents/<name>.json
Any other harness (Codex, Cursor, Gemini CLI, …). --any-harness validates the harness-neutral OS-level security posture: checks that the two enforcement components shared across all runtimes — tools/agent-isolation/agent-iso.sh (layer 0, clean-env credential strip) and tools/agent-guard/ dispatch core (layer 3, harness-neutral --exec command gating) — are present in the framework tree. Runtimes without a dedicated settings.json or opencode.json get the same enforcement posture through these two OS-level layers rather than through a harness-specific config file.
uv run --project tools/sandbox-lint sandbox-lint --any-harness # or with an explicit framework root: uv run --project tools/sandbox-lint sandbox-lint --any-harness /path/to/magpie
uv (stdlib only, no third-party deps).denyRead, allowRead, allowWrite, allowedDomains, excludedCommands, deny, ask) are compared as sets so a re-order does not trip the lint, but every addition or removal does. Any drift fails CI.sandbox.enabled is true.sandbox.filesystem.denyRead contains ~/.sandbox.filesystem.allowRead contains no credential or root paths (~/.aws, ~/.ssh, ~/.netrc, ~/.docker, ~/.kube, ~/.azure, ~/.config/gcloud, /, ~/).sandbox.filesystem.allowWrite is a subset of allowRead and contains no credential, config-root, or homedir-root path.permissions.deny contains the verbatim entries listed in src/sandbox_lint/__init__.py (REQUIRED_PERMISSIONS_DENY).expected.json itself, so a PR cannot weaken the baseline in lockstep with the live settings without the lint catching the underlying boundary violation.Run from the repository root:
uv run --directory tools/sandbox-lint --group dev sandbox-lint
Run with explicit paths (useful for tests):
uv run --directory tools/sandbox-lint --group dev sandbox-lint \ --settings .claude/settings.json \ --expected tools/sandbox-lint/expected.json
Exit code is 0 on a clean pass, 1 on any invariant violation or baseline drift.
For runtimes that do not expose a per-harness sandbox configuration file (Codex, Cursor, Gemini CLI, Kiro, and any other agent runtime not listed under --settings or --opencode), the security posture is enforced at the OS level by two harness-agnostic components:
Layer numbers follow the RFC-AI-0002 four-layer model (Layer 0 clean-env, Layer 1 filesystem sandbox, Layer 2 tool permissions, Layer 3 forced confirmation). This presence check covers the two layers that need no per-harness settings.json:
| Layer | Component | Purpose |
|---|---|---|
| Layer 0 (clean-env) | tools/agent-isolation/agent-iso.sh | Strips credential-shaped env vars (GH_TOKEN, AWS_*, ANTHROPIC_API_KEY, …) before exec, so even a fully sandboxed session cannot exfiltrate secrets via the environment. Made harness-agnostic (agent-iso <cli>) by harness-posture-portability. |
| Layer 3 (forced confirmation / action guard) | tools/agent-guard/ dispatch core | The harness-neutral equivalent of Claude Code's permissions.ask. Inspects every shell command before execution and denies the ones that break a hard framework rule (git push, wrong commit trailer, premature --ready-for-review, …). The --exec path (added by harness-guard-exec-mode) lets any harness or shell wrapper enforce these rules without a harness-specific hook adapter. |
sandbox-lint --any-harness [FRAMEWORK_ROOT] checks that both components are present. FRAMEWORK_ROOT is auto-detected by walking up from CWD: the nearest ancestor that either contains tools/agent-isolation/ directly (the framework tree) or ships the installed snapshot at .apache-magpie/tools/agent-isolation/ (an adopter repo).
# From an adopter repo root — the .apache-magpie/ snapshot is auto-detected: uv run --project tools/sandbox-lint sandbox-lint --any-harness # From the Magpie framework tree itself: uv run --project tools/sandbox-lint sandbox-lint --any-harness # Or point at an explicit framework root: uv run --project tools/sandbox-lint sandbox-lint --any-harness /path/to/magpie
Exit code is 0 when both components are present, 1 when any are missing. A 0 confirms the enforcement components are present, not that a harness is actively wired to them (see Scope below). The output identifies which layer is missing and points to the relevant setup documentation.
Scope. This check validates the presence of the enforcement components, not their live runtime behaviour. To probe whether the OS sandbox is actually active in a running agent session (SSH-agent reachability, localhost port binding, docker/podman socket), use setup-isolated-setup-doctor.
The lint runs in two places:
sandbox-lint GitHub Actions workflow, on every PR that touches .claude/settings.json, the baseline, or the lint code itself.prek config (.pre-commit-config.yaml) runs pytest, ruff check, ruff format --check, and mypy against this project, so contributors hit the same checks locally.Any legitimate edit to .claude/settings.json must be paired with the same edit to tools/sandbox-lint/expected.json in the same PR. This is the explicit acknowledgement that mitigation M.29 requires: two files, two edits, one review surface. The lint refuses to pass if the two diverge.
A maintainer running an agent locally can edit .claude/settings.json to weaken the sandbox without ever opening a PR. This lint catches the shipped configuration but not local overrides during a single agent run. The companion threat-model document records this under section X3, Sandbox bypass via developer override and Residual risk #4; consult that document once it lands on main.