excludedCommands with a glob unsandboxes the entire Bash invocation, including commands that run before the excluded one
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
When an excludedCommands entry matches anywhere in a Bash invocation, the entire invocation runs outside the sandbox. Every other command in that same call inherits the bypass, which nullifies filesystem.denyRead and the network allowlist for the duration of the call.
This was previously reported in #40831 and #45113. Both were closed as not planned by the inactivity bot and are now locked, and the lock message asks that a new issue be filed, so this one carries a current-version reproduction on 2.1.220 plus one detail the earlier reports did not include: the bypass also covers commands that run before the excluded command. In the reproduction below, cat executes and succeeds before git runs at all. That rules out the shell inheriting a relaxed context partway through, and shows the sandboxing decision is made against the whole command string ahead of execution.
The exact-match form compounds the problem. A bare entry like "git" matches only the zero-argument string git, so it silently never fires on any real invocation. The value suggested by the published JSON schema is the bare form, so the natural path is to configure something that does nothing, notice git is still sandboxed, then switch to "git *", at which point the sandbox opens much wider than the wording implies.
Steps to Reproduce
Setup in ~/.claude/settings.json:
{
"sandbox": {
"enabled": true,
"excludedCommands": ["git *"],
"filesystem": {
"denyRead": ["~/.ssh"]
}
}
}
Test 1, non-excluded command alone is correctly confined:
cat ~/.ssh/id_ed25519.pub
# cat: /Users/me/.ssh/id_ed25519.pub: Operation not permitted
Test 2, the same command in a call that also contains an excluded command, with the excluded command placed last:
cat ~/.ssh/id_ed25519.pub
git --version
# ssh-ed25519 AAAAC3Nza... <- denyRead bypassed
# git version 2.50.1
Test 3, excluded command placed first, same outcome:
git hash-object ~/.ssh/id_ed25519.pub && cat ~/.ssh/id_ed25519.pub
# both succeed
Test 4, confirming the deny rule is still active between the runs above:
cat ~/.ssh/id_ed25519.pub
# Operation not permitted
Test 2 is the notable one. cat is the first command in the invocation and completes before git is reached, so it is unsandboxed by the presence of git later in the string rather than by anything git does.
Expected Behavior
Only the command matching an excludedCommands entry runs unsandboxed. Other commands in the same invocation stay confined, and filesystem.denyRead and the network allowlist continue to apply to them.
If per-command confinement within a single invocation is not practical, refusing to run a mixed invocation would be a safer failure mode than silently unsandboxing all of it. A mixed call currently produces a much larger exemption than the configuration expresses, with no warning.
Actual Behavior
The whole invocation runs unsandboxed as soon as any part of it matches an excludedCommands glob, regardless of command order.
Environment
- Claude Code 2.1.220
- macOS, Darwin 24.6.0
- Sandbox enabled via
~/.claude/settings.json, no managed settings file present - Reproduced with
filesystem.denyReadand confirmed against the network allowlist
Additional Context
The practical impact is that a user who excludes a common tool like git or docker, which is the documented use case, loses sandbox coverage on any call that mentions it. Since chaining a status or diff call together with other work is routine, the exemption ends up applying far more often than the configuration suggests.
Related: #10524 covers the bare-name form not matching. This report is about what happens once the glob does match.
3 Comments
One mitigation worth recording, verified on 2.1.220. Narrowing the entry from
git *to the specific subcommands that actually need the exemption keeps read-only git sandboxed, so a routinegit status && <other command>carries no exemption and the bypass stops being reachable from the commands most likely to be chained.It is not a clean fix. The list has to enumerate every subcommand that needs the exemption, which with commit signing enabled means every commit-creating one (commit, merge, rebase, cherry-pick, revert, am, tag) alongside the network ones and the ones that write
.git/config. Under-specifying it surfaces as an unrelated-looking error, because a sandboxedgit commitfails while reading the signing key rather than reporting anything about the sandbox.Matching is also adjacency-sensitive in a way that matters when applying this.
git commit *matchescd /p && git commit -m xbut notgit -C /p commit -m x, since the global option breaks the contiguous substring.Reproduced on Claude Code 2.1.234, macOS 26.5 (25F71), Apple silicon.
Settings under test:
"sandbox": {"excludedCommands": ["gh", "gh *", "ssh", "ssh *"]}.Minimal repro — the same write, same session, two Bash calls:
$HOMEis outside the sandbox's writable set, so the first call is correctly denied. Appendingan unrelated excluded command to the same invocation unsandboxes the whole thing, and the
write that was just denied succeeds. Deletion works the same way —
rm -f "$HOME/.probe"aloneis denied,
rm -f "$HOME/.probe"; gh --versionremoves it.Blast radius, measured rather than estimated. Over one day's session transcript,
47 of 203 Bash calls (23%) ran with no filesystem sandbox, selected purely by whether the
command happened to mention
ghorsshsomewhere in the string. That is not an edge casereachable only by a deliberately crafted command; it is the ordinary rate for a repo where
ghis a normal part of the workflow.One correction that may save others time. It is widely assumed — we assumed it too, and
wrote it down — that a leading
cdvoidsexcludedCommands, by analogy with the maintainer'snote on #87008 that nesting inside
bash -cdefeats it. That does not reproduce. Measuredwith the same settings:
| invocation | outcome |
|---|---|
|
gh api /rate_limit|5000— excluded, unsandboxed ||
cd /tmp; gh api /rate_limit|5000— still excluded; the prefix does not void it ||
for i in 1; do gh api /rate_limit; done|x509: OSStatus -26276— sandboxed (#82109) ||
bash -c 'gh api /rate_limit'|x509: OSStatus -26276— sandboxed |The model that fits all four: the matcher scans segments of the top-level invocation, so a
match anywhere in the string counts, but it does not descend into constructs that create a
new execution context. So prefixing does not help an attacker or a user; a match anywhere
unsandboxes everything at top level, including commands sequenced before it.
Neither exclusion is removable in our case —
ghis a Go binary whose TLS dies under Seatbelt(#26466) and
sshcannot resolve DNS sandboxed — so the bypass is permanent under currenttooling. We treat the sandbox as a blast-radius reducer for honest mistakes, not a boundary,
and rely on
PreToolUsehooks for anything we actually need enforced.Reproduced on the current release.
Setup in settings.json:
sandbox.enabled: true,sandbox.excludedCommands: ["git *"],sandbox.filesystem.denyRead: ["~/.sshtest"], with a planted file~/.sshtest/secret.gitcommand returns the denied file's contents. This happens whether thegitcommand is placed before or after the read, so the read is exempted by the mere presence of a matching command elsewhere in the string, not by anything git does — the first command runs and succeeds before git is reached.So once any part of an invocation matches an
excludedCommandsglob, the whole invocation runs unsandboxed andfilesystem.denyRead(and, by the same mechanism, the network allowlist) stops applying to the other commands in that call. This matches the reported behavior.🤖 Generated with Claude Code