Table of Contents generated with DocToc
sandbox-lint
Capability: substrate:sandbox
Harness: Claude Code, OpenCode
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
Prerequisites
- Runtime: Python 3.11+ run via
uv(stdlib only, no third-party deps). - CLIs: None beyond the runtime.
- Credentials / auth: None.
- Network: Runs fully offline/local — it only reads local JSON files.
What it checks
- Baseline parity. Every key/value in the live settings file
must match the baseline. Lists tagged as set-typed (
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. - Hard invariants. Independent of the baseline, the live
settings must satisfy the security boundaries the threat model
commits to:
sandbox.enabledistrue.sandbox.filesystem.denyReadcontains~/.sandbox.filesystem.allowReadcontains no credential or root paths (~/.aws,~/.ssh,~/.netrc,~/.docker,~/.kube,~/.azure,~/.config/gcloud,/,~/).sandbox.filesystem.allowWriteis a subset ofallowReadand contains no credential, config-root, or homedir-root path.permissions.denycontains the verbatim entries listed insrc/sandbox_lint/__init__.py(REQUIRED_PERMISSIONS_DENY).
- Baseline self-check. The same invariants are applied to
expected.jsonitself, so a PR cannot weaken the baseline in lockstep with the live settings without the lint catching the underlying boundary violation.
How to use
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.
CI wiring
The lint runs in two places:
- The
sandbox-lintGitHub Actions workflow, on every PR that touches.claude/settings.json, the baseline, or the lint code itself. - The repository’s
prekconfig (.pre-commit-config.yaml) runspytest,ruff check,ruff format --check, andmypyagainst this project, so contributors hit the same checks locally.
Updating the baseline
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.
Residual risk
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.