Skip to content

feat(security): implement M.29 sandbox-lint for .claude/settings.json#93

Merged
potiuk merged 2 commits into
apache:mainfrom
andreahlert:feat-sandbox-lint
May 7, 2026
Merged

feat(security): implement M.29 sandbox-lint for .claude/settings.json#93
potiuk merged 2 commits into
apache:mainfrom
andreahlert:feat-sandbox-lint

Conversation

@andreahlert

Copy link
Copy Markdown
Collaborator

Summary

Implements mitigation M.29 from docs/security/threat-model.md (introduced in #91, pending review): every change to .claude/settings.json is gated by CI against a canonical baseline at tools/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 other tools/<name>/ projects). Two checks run together:
    1. Baseline parity, deep equality between live settings and expected.json with set semantics on denyRead, allowRead, allowWrite, allowedDomains, deny, ask. Any drift fails CI.
    2. Hard invariants, applied to both the live settings and the baseline so a future PR cannot weaken both files in lockstep without the lint catching it. Invariants enforce: denyRead contains ~/; allowRead does not contain credential or root paths (~/.aws, ~/.ssh, ~/.netrc, ~/.docker, ~/.kube, ~/.azure, ~/.config/gcloud, /, ~/); allowWrite is a subset of allowRead and contains no credential, config-root, or homedir-root path; permissions.deny contains 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.json changes (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:

  • Diff-against-main, fails on every edit, forcing a label or PR-title token to bypass. Turns every legitimate sandbox tweak into noise.
  • Canonical baseline + invariants (this PR). Every change requires editing two files, which is the explicit acknowledgement the threat model wants. The invariants catch the failure mode where someone edits both files in lockstep but past a security boundary.

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.json outside 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.md row M.29 should drop the Planned, not yet shipped note and link to tools/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-lint from repo root (OK against shipped baseline)
  • prek run --all-files (all hooks pass, including the new four)
  • CI green on this PR

Threat-model cross-references

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
Comment thread tools/sandbox-lint/tests/test_validator.py Fixed
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

@potiuk potiuk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice!

@potiuk potiuk merged commit 88d3d2e into apache:main May 7, 2026
14 checks passed
@andreahlert andreahlert deleted the feat-sandbox-lint branch May 8, 2026 03:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants