An external report flagged the Bash(*) allow in the committed
.claude/settings.json. Bash(*) stays: every command still runs inside
the OS sandbox, and deny and ask take precedence over it. Reviewing the
file against its own documentation turned up two real gaps instead.
security.md and settings.md cite an rm -rf* deny rule as the guard
against `rm -rf .` wiping the worktree and .git/, but the rule was lost
in ee30f6d446 when the deny list was rewritten. It is back.
denyRead covered ~/.aws, ~/.config/gcloud, ~/.gnupg and ~/.kube only.
~/.docker/config.json holds registry logins inline, ~/.git-credentials
and ~/.netrc hold plaintext credentials, and the default ~/.ssh/id_*
keys were readable because Assumption 10 kept ~/.ssh open for an
ask-gated git push that has since moved to deny. All nine paths are
denied now. The rest of ~/.ssh stays readable, so SSH fetches keep
known_hosts and authenticate through the agent socket. Sandboxed docker
calls pull anonymously and print a config warning on stderr.
Thanks to Hanno Horrmeyer for the report.
Reported-by: Hanno Horrmeyer
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
11 KiB
Claude Code Security Assumptions 🛡️
This page enumerates the trust assumptions that the Claude Code configuration in .claude/settings.json relies on. If any assumption becomes false on a given host, the corresponding rule in settings.md or sandbox.md MUST be re-evaluated before Claude Code is started. This file is the single reference for the architectural reasoning. Per-rule tables in the neighbouring pages summarise the mechanics and link here for the full argument.
For the rule catalog itself, see settings.md. For the OS-level sandbox configuration, see sandbox.md. For general agent workflow rules shared across all agents, see common.md.
Two-Layer Containment 🏗️
Claude Code safety rests on two layers.
The OS-level sandbox (sandbox.* block) is the primary containment. Every Bash command runs inside bwrap on Linux or sandbox-exec on macOS and is confined by filesystem and network rules. The sandbox itself bounds the blast radius of Bash rather than a per-command allowlist.
The permission lists (permissions.{allow,ask,deny}) are the policy gate that decides whether the agent may attempt a tool call at all. The allow list covers non-Bash tools plus the wildcard Bash(*), delegating real gating to ask and deny. The deny list applies as an unconditional hard block. The ask list pauses for operator confirmation.
The split means contributors do not need to extend the allowlist for every new shell command: Bash(*) already covers them, and sandbox confinement bounds the blast radius. The deny list catches the small set of operations whose blast radius is destructive even within the sandbox. For example, rm -rf . would still erase the working tree and .git/ because . is writable under allowWrite, so the rm -rf* deny rule closes that gap.
Assumption 1: The sandbox backend is installed and functional ⚙️
The Bash auto-allow path trusts the OS sandbox to confine commands. A missing or broken sandbox backend would cause auto-allow to approve unconfined commands.
Mitigation: sandbox.enabled: true with sandbox.failIfUnavailable: true refuses to start when the backend is unavailable. sandbox.allowUnsandboxedCommands: false rejects the dangerouslyDisableSandbox: true tool parameter, so there is no per-call escape.
If violated: Every Bash command runs unconfined on the host. Contributors MUST install the sandbox backend rather than weaken these flags. See the installer section in sandbox.md.
Assumption 2: sandbox.filesystem.allowWrite is the authoritative write boundary ✍️
Path-scoped write restrictions inside permissions.allow entries (for example a hypothetical Bash(tee /tmp/*) rather than Bash(*)) add no security on top of the sandbox. The sandbox enforces write scope at the syscall layer, so any path outside allowWrite fails with EROFS.
Enabled by this: The allow list uses the broad wildcard Bash(*) without maintaining narrow path lists. The single source of truth for writable paths is sandbox.filesystem.allowWrite in sandbox.md.
If violated: If a contributor widens allowWrite (for example by adding ~/ or /), the broad allow rules become genuinely broad. Changes to allowWrite SHOULD be reviewed with the same scrutiny as a deny rule.
Assumption 3: The host runs only test workloads 🧪
The Bash(*) allow grants broad access to the docker CLI (alongside every other shell command) under the assumption that no production containers share the host. Non-mutating-image subcommands (rm, kill, stop, system prune, volume rm, cp) can disrupt or destroy container state. Under test-only usage, the blast radius is bounded to ephemeral state that can be recreated from the compose stack.
Mitigation: Image-level mutation (run, build, push, login) and their synonyms (docker container run, docker image build, docker buildx build, docker image push) still route through ask, so the operator reviews them before they execute.
If violated: Running Claude Code on a host with production containers exposes those containers to disruption. A contributor on such a host SHOULD add read-only-narrowing docker * ask/deny entries in .claude/settings.local.json before starting a session.
Assumption 4: Operators review ask prompts before approval 👀
Every rule in permissions.ask assumes the operator reads the prompt and confirms the action matches intent. Rules in ask are NOT a hard barrier. They are a speed-bump that converts automatic execution into a deliberate decision.
Protected by this: git commit*, docker run*, docker build*, docker push*, docker login*, every mutating gh verb, and every non-GET gh api call. Each would be destructive or externally visible if executed without review.
If violated: Blind-approving ask prompts defeats the policy layer entirely. Operators MUST treat every ask prompt as a code-review checkpoint.
Assumption 5: DinD ask-gates are comfort thresholds, not hard barriers 🐳
The ask rules on docker run* / docker build* / docker push* / docker login* only catch the outermost invocation. Because the project uses Docker-in-Docker (for example docker exec <helper-container> docker run ...), a docker run invoked inside an exec'd helper container bypasses the ask-gate. The helper container has its own Docker daemon or a bind-mounted host socket, so the inner docker run executes directly.
What this means: The ask-gate on image-level mutation is a convenience for the common case, not a guarantee.
If violated: An invocation of docker exec <container> docker run --privileged -v /:/host ... escapes the sandbox without triggering the ask-gate. There is no automated mitigation for this path. Protection relies on the shell-loop deny rule, the sudo* deny rule, and operator review of any command that contains nested docker.
Assumption 6: Access to /var/run/docker.sock equals host root 🔓
The sandbox has sandbox.network.allowAllUnixSockets: true so that normal workflow tools (docker, systemctl, language servers) continue to work. Any command that reaches /var/run/docker.sock can request a container with -v /:/host --privileged, which the Docker daemon executes as root and mounts the host filesystem. The sandbox's allowWrite does NOT protect against this path because Docker is a trusted service on the other end of the socket.
Mitigation: docker run* and its synonyms are in ask, so the operator reviews them (subject to Assumption 5). The deny list blocks sudo*, so direct privilege escalation via sudo is unavailable.
If violated: This is the baseline cost of allowing Docker at all. Contributors on hosts where this cost is unacceptable SHOULD disable Docker socket access locally by denying /var/run/docker.sock in .claude/settings.local.json, accepting that docker commands will stop working.
Assumption 7: The Makefile is peer-reviewed and trusted 🔧
The Bash(*) allow lets the agent run any Makefile target without prompting. The Makefile is checked into git and reviewed on every pull request, so targets themselves are trusted. Every action a target performs still runs inside the sandbox.
Mitigation: Targets still cannot bypass allowWrite, allowedDomains, or the deny list. A malicious target that called rm -rf . would be stopped by the rm -rf* deny rule.
If violated: A compromised Makefile could chain multiple non-blocked destructive calls. The mitigation is the normal code-review process for pull requests.
Assumption 8: Shell control-flow loops are never necessary 🔁
AGENTS.md forbids for / while / until loops in Bash invocations, and Bash(for *) / Bash(while *) / Bash(until *) are in deny to enforce the rule. Every task has a flat-form equivalent: a single grep with multiple file arguments, xargs, a recursive glob, or a dedicated tool.
Why this matters: Loops can hide destructive operations (for example for f in $(ls); do rm "$f"; done) behind a single approval step. The deny rule converts a rule violation into a hard failure, so the agent receives concrete feedback and refactors to the flat form.
If violated: The attempt fails, the agent sees the error, and typically refactors within one retry. No host-level exposure.
Assumption 9: denyRead lists every credential directory 🔐
The sandbox's denyRead list (~/.aws, ~/.config/gcloud, ~/.docker/config.json, ~/.git-credentials, ~/.gnupg, ~/.kube, ~/.netrc, and the default private keys under ~/.ssh) is the primary mechanism that prevents the agent from reading host credentials. Every credential store an operator uses on the host is assumed to be covered, with the documented exceptions of the rest of ~/.ssh (see Assumption 10) and ~/.config/gh, which gh needs on every call.
If violated: A credential directory not listed in denyRead (for example ~/.azure, ~/.doctl, ~/.config/op) is readable by the agent, and its contents can leak into the transcript. Contributors adding a new credential store MUST extend denyRead in the same commit.
Assumption 10: ~/.ssh stays readable except for the default private keys 🔑
git fetch and git ls-remote over SSH need ssh(1) to read ~/.ssh/known_hosts and ~/.ssh/config. With the whole directory in denyRead, ssh fails with Host key verification failed. denyRead therefore lists only the default private key files, and ssh authenticates through the agent socket named in SSH_AUTH_SOCK, which allowAllUnixSockets: true keeps reachable. Bash(git push*) is in deny, so pushes stay with the operator.
Cost: The agent can authenticate with every key loaded into the SSH agent, although it cannot read the key itself. A private key stored under a non-default file name stays readable.
If violated: Without a running SSH agent, SSH fetches inside the sandbox fail with Permission denied (publickey); load the key into an agent or fetch over HTTPS. A contributor whose key uses a non-default file name MUST add that path to denyRead in .claude/settings.local.json.
Updating Assumptions 🧹
When adding a rule to settings.md or sandbox.md that relies on a novel trust premise, add a numbered assumption here rather than duplicating the reasoning in the rule's rationale column. Per-rule rationales SHOULD stay short (one to two sentences) and link back to this page for the full argument.