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.