feat(security): implement M.29 sandbox-lint for .claude/settings.json#93
Merged
Conversation
Mitigation M.29 in `docs/security/threat-model.md` (PR apache#91) committed to lint the agent-host sandbox configuration in CI on every PR that touches it. This is the implementation: - `tools/sandbox-lint/` — new stdlib-only Python project. The CLI compares `.claude/settings.json` against the canonical baseline at `tools/sandbox-lint/expected.json` (set semantics on `denyRead`, `allowRead`, `allowWrite`, `allowedDomains`, `deny`, `ask`) and runs three layers of hard invariants — required `denyRead` entries, forbidden `allowRead` and `allowWrite` paths, required `permissions.deny` entries — against both the live settings and the baseline itself. The same invariants applied to the baseline catch the case where a future PR weakens both files in lockstep. - `.github/workflows/sandbox-lint.yml` — runs the linter on every PR that touches `.claude/settings.json`, the baseline, or the linter code. Path-scoped so the rest of the matrix is unaffected. - `.pre-commit-config.yaml` — adds `ruff check`, `ruff format --check`, `mypy`, and `pytest` hooks for the new project; the pytest hook also fires when `.claude/settings.json` changes because the test suite loads both files. - `.github/workflows/tests.yml` — adds the new project to the per-project pytest matrix so the visible-signal lane reports pass/fail in the CI checks list. Threat-model cross-references are in `tools/sandbox-lint/README.md`. The X3 residual (a maintainer editing the file locally outside a PR) remains accepted; the lint gates the shipped configuration, not local overrides during a single agent run. Generated-by: Claude Opus 4.7
Two CI failures on PR apache#93 against `apache/airflow-steward`: - `lychee` fails because `tools/sandbox-lint/README.md` linked to `docs/security/threat-model.md` paths and anchors — that file lands in a companion PR and is not on `main` yet. Replace the three direct links with prose references that note the threat-model doc is a companion PR; the lint stands on its own. - `CodeQL` (`py/incomplete-url-substring-sanitization`) fires on the test sentinel `evil.example.com` because the rule keys on hostname-shaped literals appearing in `in` substring checks. This is test code asserting against a diff message, not URL validation, but the rule is right that hostname-shaped sentinels are a footgun. Rename to `sandbox-lint-test-extra-marker` so the sentinel is unambiguously not a URL pattern. Generated-by: Claude Opus 4.7
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements mitigation M.29 from
docs/security/threat-model.md(introduced in #91, pending review): every change to.claude/settings.jsonis gated by CI against a canonical baseline attools/sandbox-lint/expected.json, plus a set of hard security invariants that hold regardless of legitimate edits to either file.The threat model committed to this lint as the residual control for X3, Sandbox bypass via developer override and is currently shipped as Planned, not yet shipped. This PR turns it on.
What's new
tools/sandbox-lint/: stdlib-only Python project (mirrors the layout of the othertools/<name>/projects). Two checks run together:expected.jsonwith set semantics ondenyRead,allowRead,allowWrite,allowedDomains,deny,ask. Any drift fails CI.denyReadcontains~/;allowReaddoes not contain credential or root paths (~/.aws,~/.ssh,~/.netrc,~/.docker,~/.kube,~/.azure,~/.config/gcloud,/,~/);allowWriteis a subset ofallowReadand contains no credential, config-root, or homedir-root path;permissions.denycontains the verbatim Read/Bash entries listed in the validator..github/workflows/sandbox-lint.yml: path-scoped to.claude/settings.json, the baseline, and the linter code itself. SHA-pinned actions, matching the rest of the repo..pre-commit-config.yaml: ruff/mypy/pytest hooks for the new project, with the pytest hook also firing when.claude/settings.jsonchanges (the suite loads both files at module scope)..github/workflows/tests.yml: adds the new project to the visible-signal pytest matrix.Why this design
The threat model's wording is "lint against the shipped baseline" + "allowlist of changes". Two interpretations were viable:
The second matches the threat model's intent more cleanly and produces less review-time friction on benign changes.
What this does not catch
A maintainer running an agent locally can edit
.claude/settings.jsonoutside a PR and run a single agent session against the weakened sandbox. The lint gates the shipped configuration, not local overrides. This residual is documented under residual risk #4 (file lands in #91) and is out of scope for M.29 by design.Follow-up after #91 merges
Once #91 is in
main,docs/security/threat-model.mdrow M.29 should drop the Planned, not yet shipped note and link totools/sandbox-lint/. That's a small follow-up commit, intentionally not bundled here so this PR stands on its own.Test plan
uv run --directory tools/sandbox-lint --group dev pytest(36 passed)uv run --directory tools/sandbox-lint --group dev ruff check(clean)uv run --directory tools/sandbox-lint --group dev ruff format --check(clean)uv run --directory tools/sandbox-lint --group dev mypy(clean)uv run --project tools/sandbox-lint --group dev sandbox-lintfrom repo root (OK against shipped baseline)prek run --all-files(all hooks pass, including the new four)Threat-model cross-references
docs/security/threat-model.mdmitigation M.29 (lands in docs(security): add release-blocking threat model #91)docs/security/threat-model.mdX3, Sandbox bypass via developer override (lands in docs(security): add release-blocking threat model #91)docs/security/threat-model.mdresidual #4 (lands in docs(security): add release-blocking threat model #91)