Skip to content
Merged
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
62 changes: 62 additions & 0 deletions .github/workflows/sandbox-lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
# Implements mitigation M.29 from
# `docs/security/threat-model.md`: every change to the agent-host
# sandbox configuration in `.claude/settings.json` must be paired with
# a matching update to the canonical baseline at
# `tools/sandbox-lint/expected.json`, and the resulting configuration
# must satisfy the security invariants encoded in the linter.
---
name: sandbox-lint

on: # yamllint disable-line rule:truthy
pull_request:
paths:
- ".claude/settings.json"
- "tools/sandbox-lint/**"
- ".github/workflows/sandbox-lint.yml"
push:
branches: [main]
paths:
- ".claude/settings.json"
- "tools/sandbox-lint/**"
- ".github/workflows/sandbox-lint.yml"

permissions: {}

jobs:
sandbox-lint:
name: lint .claude/settings.json against baseline
runs-on: ubuntu-latest
permissions:
contents: read
env:
FORCE_COLOR: "1"
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
with:
enable-cache: true
# `--project` (not `--directory`) so the linter runs from the
# repository root; the default arguments resolve
# `.claude/settings.json` and `tools/sandbox-lint/expected.json`
# relative to cwd.
- name: Run sandbox-lint
run: uv run --project tools/sandbox-lint --group dev sandbox-lint
2 changes: 2 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ jobs:
path: tools/privacy-llm/redactor
- name: vulnogram-oauth-api
path: tools/vulnogram/oauth-api
- name: sandbox-lint
path: tools/sandbox-lint
# GitHub Actions log viewer renders ANSI colour escapes; without
# an attached TTY most tools default to monochrome. `FORCE_COLOR`
# is the de-facto signal honoured by uv, ruff, mypy, and pytest's
Expand Down
32 changes: 32 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -237,3 +237,35 @@ repos:
entry: uv run --directory tools/privacy-llm/checker pytest
files: ^tools/privacy-llm/checker/(src|tests|pyproject\.toml)
pass_filenames: false

# Project-local checks for the sandbox-lint Python project at
# `tools/sandbox-lint/`. Same shape as the other tool hooks.
# The pytest hook also covers the lint of `.claude/settings.json`
# against `tools/sandbox-lint/expected.json` because the project's
# tests load both files at module scope.
- repo: local
hooks:
- id: sandbox-lint-ruff-check
name: ruff check (sandbox-lint)
language: system
entry: uv run --directory tools/sandbox-lint ruff check
files: ^tools/sandbox-lint/(src|tests|pyproject\.toml)
pass_filenames: false
- id: sandbox-lint-ruff-format
name: ruff format (sandbox-lint)
language: system
entry: uv run --directory tools/sandbox-lint ruff format --check
files: ^tools/sandbox-lint/(src|tests|pyproject\.toml)
pass_filenames: false
- id: sandbox-lint-mypy
name: mypy (sandbox-lint)
language: system
entry: uv run --directory tools/sandbox-lint mypy
files: ^tools/sandbox-lint/(src|tests|pyproject\.toml)
pass_filenames: false
- id: sandbox-lint-pytest
name: pytest (sandbox-lint)
language: system
entry: uv run --directory tools/sandbox-lint pytest
files: ^(tools/sandbox-lint/(src|tests|pyproject\.toml|expected\.json)|\.claude/settings\.json)
pass_filenames: false
98 changes: 98 additions & 0 deletions tools/sandbox-lint/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*

- [`sandbox-lint`](#sandbox-lint)
- [What it checks](#what-it-checks)
- [How to use](#how-to-use)
- [CI wiring](#ci-wiring)
- [Updating the baseline](#updating-the-baseline)
- [Residual risk](#residual-risk)

<!-- END doctoc generated TOC please keep comment here to allow auto update -->

<!-- SPDX-License-Identifier: Apache-2.0
https://www.apache.org/licenses/LICENSE-2.0 -->

# `sandbox-lint`

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.

## What it checks

1. **Baseline parity.** Every key/value in the live settings file
must match the baseline. Lists tagged as set-typed (`denyRead`,
`allowRead`, `allowWrite`, `allowedDomains`, `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.
2. **Hard invariants.** Independent of the baseline, the live
settings must satisfy the security boundaries the threat model
commits to:
- `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`](src/sandbox_lint/__init__.py)
(`REQUIRED_PERMISSIONS_DENY`).
3. **Baseline self-check.** The same invariants are applied to
`expected.json` itself, 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:

```sh
uv run --directory tools/sandbox-lint --group dev sandbox-lint
```

Run with explicit paths (useful for tests):

```sh
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-lint`](../../.github/workflows/sandbox-lint.yml)
GitHub Actions workflow, on every PR that touches
`.claude/settings.json`, the baseline, or the lint code itself.
- The repository's `prek` config
([`.pre-commit-config.yaml`](../../.pre-commit-config.yaml)) runs
`pytest`, `ruff check`, `ruff format --check`, and `mypy` against
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`.
100 changes: 100 additions & 0 deletions tools/sandbox-lint/expected.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"sandbox": {
"enabled": true,
"filesystem": {
"denyRead": ["~/"],
"allowRead": [
".",
"~/.gitconfig",
"~/.config/git/",
"~/.config/gh/",
"~/.cache/",
"~/.local/share/uv/",
"~/.local/bin/",
"~/.config/apache-steward/",
"~/.gnupg/",
"/run/user/*/gnupg/"
],
"allowWrite": [
"~/.cache/",
"~/.local/share/uv/"
]
},
"network": {
"allowedDomains": [
"github.com",
"api.github.com",
"raw.githubusercontent.com",
"objects.githubusercontent.com",
"codeload.github.com",
"uploads.github.com",
"pypi.org",
"files.pythonhosted.org",
"lists.apache.org",
"cveprocess.apache.org",
"cve.org",
"www.cve.org",
"oauth2.googleapis.com",
"gmail.googleapis.com"
]
}
},
"permissions": {
"deny": [
"Read(~/.aws/**)",
"Read(~/.ssh/**)",
"Read(~/.netrc)",
"Read(~/.docker/**)",
"Read(~/.kube/**)",
"Read(~/.config/gh/**)",
"Read(~/.config/apache-steward/**)",
"Read(~/.config/gcloud/**)",
"Read(~/.azure/**)",
"Read(//**/.env)",
"Read(//**/.env.local)",
"Read(//**/.env.*.local)",
"Bash(curl *)",
"Bash(wget *)",
"Bash(aws *)",
"Bash(gcloud *)",
"Bash(az *)",
"Bash(kubectl *)",
"Bash(docker login *)",
"Bash(npm publish *)",
"Bash(pip install --upgrade *)",
"Bash(uv self update *)",
"Bash(gh auth token*)",
"Bash(gh auth refresh*)"
],
"ask": [
"Bash(git push *)",
"Bash(git push --force *)",
"Bash(git push --force-with-lease *)",
"Bash(gh pr create *)",
"Bash(gh pr edit *)",
"Bash(gh pr merge *)",
"Bash(gh issue create *)",
"Bash(gh issue edit *)",
"Bash(gh issue close *)",
"Bash(gh issue comment *)",
"Bash(gh release create *)",
"Bash(gh api * -X *)",
"Bash(gh api * -f *)",
"Bash(gh api * -F *)",
"Bash(gh gist *)",
"Bash(gh repo create *)",
"Bash(gh repo edit *)",
"Bash(gh repo delete *)",
"Bash(gh api * --method *)",
"Bash(gh api --method *)",
"Bash(gh api * --input *)",
"Bash(gh api --input *)",
"Bash(gh secret *)",
"Bash(gh ssh-key *)",
"Bash(gh release upload *)",
"Bash(gh release delete *)",
"Bash(gh workflow run *)"
]
}
}
88 changes: 88 additions & 0 deletions tools/sandbox-lint/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "sandbox-lint"
version = "0.1.0"
description = "Lint .claude/settings.json against the shipped baseline and the security invariants documented in docs/security/threat-model.md (mitigation M.29)."
readme = "README.md"
requires-python = ">=3.11"
license = { text = "Apache-2.0" }
# stdlib-only — JSON parsing and deep-diff are built into Python.
dependencies = []

[project.scripts]
sandbox-lint = "sandbox_lint:main"

[dependency-groups]
dev = [
"mypy>=1.11",
"pytest>=8.0",
"ruff>=0.6",
]

[tool.hatch.build.targets.wheel]
packages = ["src/sandbox_lint"]

[tool.ruff]
line-length = 110
target-version = "py311"
src = ["src", "tests"]

[tool.ruff.lint]
select = [
"E",
"W",
"F",
"I",
"B",
"UP",
"SIM",
"C4",
"RUF",
]
ignore = [
"E501",
]

[tool.ruff.lint.per-file-ignores]
"tests/**" = ["B", "SIM"]

[tool.mypy]
python_version = "3.11"
files = ["src", "tests"]
warn_unused_ignores = true
warn_redundant_casts = true
warn_unreachable = true
check_untyped_defs = true
no_implicit_optional = true
disallow_untyped_defs = true
disallow_incomplete_defs = true

[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
disallow_incomplete_defs = false

[tool.pytest.ini_options]
minversion = "8.0"
addopts = "-ra -q"
testpaths = ["tests"]
Loading
Loading