Chapter 18. Claude Code Sandboxing and Blast Radius

The Claude Code Bash sandbox is OS-level process isolation around the commands Claude runs: writes confined to the working directory and the session temp directory, reads open to the whole filesystem except paths you deny. It is a different control from the permission contract, which decides which tool calls run at all rather than what a running command can reach. The agents that build my estate run shell commands on the same Mac that holds my SSH keys, my AWS credentials file, and the checkouts behind twenty-plus live surfaces, so both mechanisms stand between a bad command and that machine. I conflated them for a while, because both surface the same way in a session: something prompts, or something gets blocked. The case for running both is that they fail differently.

The sandbox bounds the tool. The permission contract bounds the agent. A contract failure is a judgment failure: a rule written broader than I meant, an approval clicked at hour six of a long session. A sandbox failure is a gap: an excluded command, an unsupported platform, a credential path nobody put on the deny list. Judgment failures happen at a rate, and you can drive the rate down. Gaps sit where they were built until you find them. Neither control covers the other’s weakness. That asymmetry, not paranoia, is the argument for stacking them.

How the sandbox is built: Seatbelt, bubblewrap, and the unsupported platforms

On macOS the box rides Seatbelt. On Linux and WSL2 it uses bubblewrap, with socat handling the network relay. WSL1 and native Windows are unsupported: on those platforms there is no box, and whatever posture you design leans on the permission contract alone. Ripgrep ships with the native binary, and an optional seccomp filter that blocks Unix domain sockets installs with:

npm install -g @anthropic-ai/sandbox-runtime

/sandbox opens a panel with Mode, Overrides, and Config tabs, plus a Dependencies tab when a package is missing. Selecting a mode there writes to .claude/settings.local.json, not the shared project file. If you version-control .claude/settings.json for a team, the mode toggle stays personal.

The config surface is a set of sandbox.* keys: enabled, failIfUnavailable, allowUnsandboxedCommands, autoAllowBashIfSandboxed (default true), excludedCommands, allowUnixSockets, allowAppleEvents, enableWeakerNetworkIsolation, and enableWeakerNestedSandbox. The key I flag first is failIfUnavailable. If the sandbox is a load-bearing control on a machine, you want a hard failure at startup when the box can’t be built, not a session that degrades to unboxed execution while looking identical. The docs I pulled never state the fresh-install default, and sandbox appears in neither settings file on this machine, so I cannot tell you from here whether an untouched install boxes your commands. Check it rather than assume it: if the sandbox is load-bearing for you, set enabled explicitly and pair it with failIfUnavailable so the machine tells you when it cannot comply.

What a sandboxed command can write, and what it can still read

The default box gives a sandboxed command two writable places: the working directory and the session temp directory that $TMPDIR points to. That is a tight boundary, and it kills the classic disaster class where a build script or a confused rm reaches outside the repo.

Reads are the opposite. By default a sandboxed command reads the whole filesystem except paths you deny, and that includes ~/.aws/credentials and ~/.ssh/ unless you deny them by name. The default box stops a stray write to your home directory and does nothing about a read of your private key followed by a request to an allowed host. Write isolation is the shipped posture. Read isolation is homework, and it stays undone until you do it.

The homework lives in sandbox.filesystem: allowWrite, denyWrite, denyRead, allowRead, and disabled. Path prefixes are / for absolute, ~/ for home, and ./ or bare for the project root in project settings (~/.claude in user settings). One property here does quiet, structural work: the arrays merge across settings scopes instead of replacing each other. A denyRead list in my user settings and another in a repo’s project settings both apply. I can keep a machine-wide credential deny list and let each repo add its own paths without either clobbering the other. Most permission-adjacent config wants the opposite (precedence, one winner); deny lists want accumulation, and they get it.

There is also a dedicated credential layer, sandbox.credentials, from v2.1.187. credentials.files entries accept "mode": "deny" and nothing else; credentials.envVars entries accept "deny" or "mask" (mask from v2.1.199) with an optional injectHosts list, and deny wins when a variable carries both. The list you write is the whole list: there is no built-in credential deny list, so nothing is protected until you enumerate it. mask also depends on sandbox.network.tlsTerminate, which is experimental (v2.1.199 and later), makes the built-in proxy terminate TLS itself, and adds no content filtering.

The box protects its own hinges. The sandbox denies writes to Claude Code’s settings.json files at every scope and to the managed settings directory, so a boxed command cannot rewrite the policy that boxes it, and as of v2.1.210 those deny rules resolve symlinks planted at the protected paths. The caveat matters more than the feature: sandbox.filesystem.disabled: true (v2.1.216 and later) skips filesystem isolation while keeping network isolation, and turning filesystem isolation off turns the self-protection deny rules off with it. Half-opening the box removes the lock along with the wall.

One accommodation earns its keep on my setup. When the working directory is a linked git worktree, the sandbox allows writes to the main repository’s shared .git directory so git commit can update refs and the index, while hooks/ and config inside it stay denied. Most of my work runs in sibling worktrees; a sandbox that broke committing from one would be a sandbox I turned off within the hour. Someone thought about this, and the hooks/ carve-out is the right shape: refs are data, hooks are code.

Network access: no domain is reachable until you list it

No domains are pre-allowed. The sandbox.network block carries allowedDomains, deniedDomains, tlsTerminate, httpProxyPort, socksProxyPort, strictAllowlist, and allowManagedDomainsOnly. There is one deliberate crossover between the two fences here: WebFetch(domain:...) allow rules from the permission contract also pre-allow those domains for the sandbox. A domain I trusted for fetching is a domain sandboxed commands can reach, one declaration instead of two.

By default a sandboxed command hitting a host outside the allowlist produces a prompt. sandbox.network.strictAllowlist: true (v2.1.219 and later) turns that into a denial with no prompt, and it is honored only from user settings, managed settings, or --settings. A repository’s .claude/settings.json or .claude/settings.local.json cannot set it, which is the right call: a cloned repo should not get to flip enforcement behavior on your machine.

Four ways a command gets out of the sandbox

The sandbox has four exits, and two of them cannot be closed.

Route out How it opens What closes it
dangerouslyDisableSandbox retry Claude retries a command that failed under the sandbox allowUnsandboxedCommands: false, or an ask rule on Bash(dangerouslyDisableSandbox:true)
excludedCommands entry Operator config (docker * and friends) Nothing at the managed level
sandbox.filesystem.disabled: true Operator config, v2.1.216+ Honored only from user, managed, or --settings sources
Unsupported platform WSL1, native Windows Nothing; no sandbox exists there

The first route is the one you will hit most, and it is the architectural hinge between the two systems. When a command fails under the sandbox, Claude may retry it with the dangerouslyDisableSandbox parameter, and that retry routes through the regular permission flow. The escape hatch does not dump into open air; it dumps into the other fence. A sandbox failure becomes a permission decision, which is the handoff working as designed: enforcement first, judgment on escape. If you want the hatch welded shut, "allowUnsandboxedCommands": false (shown as Strict sandbox mode) makes the parameter ignored outright. The middle setting is the one I like: an ask rule on Bash(dangerouslyDisableSandbox:true) forces a prompt on each retry, so escapes stay possible and visible. An escape I see is data about what the box is too tight for. An escape I don’t see is a hole.

excludedCommands deserves the most respect because it has no lock. Managed settings can pin the filesystem read surface (sandbox.filesystem.allowManagedReadPathsOnly) and the network surface (sandbox.network.allowManagedDomainsOnly), but excludedCommands has no managed-only equivalent, so a developer can append entries that run commands outside the sandbox regardless of central policy. On this machine the point is moot: no managed settings are deployed here, /etc/claude-code and /Library/Application Support/ClaudeCode/ both absent when I checked on 2026-07-27. I am the admin and the operator, so central lockdown would be me managing me. For a team deployment the asymmetry is the finding. You can lock reads and domains from the top and you cannot lock the exclusion list.

Where the sandbox breaks: watchman, docker, Go CLIs, and Apple Events

watchman breaks under the sandbox (run jest --no-watchman). docker needs a docker * entry in excludedCommands. Go-based CLIs such as gh, gcloud, and terraform can fail TLS verification under Seatbelt. open and osascript fail with error -600 because Apple Events are blocked unless allowAppleEvents is set. Read those documented incompatibilities as a list of pressures toward the exits above. The gh entry stings the most on this machine, since gh is how work ships here. Each friction point pushes the same direction: hit a wall, add an exclusion, move on. The result is that the exclusion list grows during exact moments when you are annoyed and in a hurry, and each entry is a standing unsandboxed lane. Audit the list when calm, not when blocked.

Version gates are their own platform note. This machine runs Claude Code 2.1.211, checked 2026-07-27, so filesystem.disabled (2.1.216) and strictAllowlist (2.1.219) do not exist here yet. Design the posture against the version you run, not against the newest page of the docs. A key that a given build does not recognize is not a control, whatever your settings file says.

autoAllowBashIfSandboxed, and what happens outside the box

autoAllowBashIfSandboxed defaults to true: a sandboxed command runs without a prompt. That default is the whole trade. Inside the box, enforcement replaces judgment, and the question “should this run” loses urgency because “what can it touch” has a bounded answer. The prompt budget this frees up is real. Prompt fatigue is a contract failure mode (the hour-six approval), and the sandbox attacks it at the source by making most commands not worth asking about.

Outside the box, the contract’s machinery takes over, and its texture is worth knowing because that is where judgment operates. Rules evaluate deny, then ask, then allow, first match wins, and specificity does not reorder them, so a broad deny cannot carry allowlist exceptions. The built-in read-only command set (ls, cat, grep, pwd, and the rest) runs without prompting and is not configurable, and commands longer than 10,000 characters prompt no matter what. The pattern language has edges that bite: Bash(ls *) matches ls -la but not lsof, while Bash(ls*) matches both; approving one compound command can save up to 5 separate rules you did not read one at a time; environment runners like devbox run and npx are not stripped before matching, so Bash(devbox run *) matches anything after run. Each of those edges is a place where a rule can end up broader than the operator’s mental model of it. That is what a judgment failure looks like in the wild: not recklessness, a mismatch between the rule you wrote and the rule you think you wrote.

The permission modes compose with the box. Subagents run in the same process as the parent session and use the same sandbox configuration, so their Bash commands are boxed whenever mine are: fanning out to parallel workers does not widen the blast radius. In dontAsk mode, anything that would prompt is auto-denied, so an unattended run that hits the sandbox escape hatch gets a dead end instead of a stalled session waiting on me. Even bypassPermissions keeps circuit breakers: explicit ask rules still fire, MCP tools marked requiresUserInteraction still prompt (v2.1.199 and later), and removals targeting the filesystem root or home directory, rm -rf / and rm -rf ~, prompt even when smuggled through $(...), backticks, or <(...) substitution as of v2.1.208.

The posture: which sandbox settings to set first

Trace any single control to its failure and you find the other one standing behind it. The contract approves a command it should not have: the sandbox bounds what the command reaches. The sandbox can’t run a command and the escape retry fires: the contract decides whether the unboxed version runs. The exceptions are the structural gaps, and they are enumerable: the exclusion list, the unsupported platforms, the read surface you never denied, the filesystem-disabled switch that takes the settings self-protection down with it.

Deny-read the credential paths by name, ~/.ssh/ and ~/.aws/credentials first, because the shipped default reads them. Put an ask rule on Bash(dangerouslyDisableSandbox:true) so escapes are events you witness. Treat excludedCommands as a change-controlled file, because no managed key will control it for you. Set failIfUnavailable anywhere the box is load-bearing. And keep the contract sharp on the surfaces the box does not cover, because the box was built knowing it has exits, and it hands each one to the fence you tuned or the fence you neglected.