excludedCommands with a glob unsandboxes the entire Bash invocation, including commands that run before the excluded one

Status Open
Reported on v2.1.220
Maintainer reply ✓ Yes — bcherny
Activity 3 comments · opened Jul 25, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

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.denyRead and 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.

View original on GitHub ↗

3 Comments

doughayden · 1 month ago

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 routine git 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 sandboxed git commit fails 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 * matches cd /p && git commit -m x but not git -C /p commit -m x, since the global option breaks the contiguous substring.

mattlavine · 12 days ago

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:

touch "$HOME/.probe"                 → touch: Operation not permitted, exit 1, no file
touch "$HOME/.probe"; gh --version   → exit 0, FILE CREATED in $HOME

$HOME is outside the sandbox's writable set, so the first call is correctly denied. Appending
an 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" alone
is denied, rm -f "$HOME/.probe"; gh --version removes 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 gh or ssh somewhere in the string. That is not an edge case
reachable only by a deliberately crafted command; it is the ordinary rate for a repo where
gh is 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 cd voids excludedCommands, by analogy with the maintainer's
note on #87008 that nesting inside bash -c defeats it. That does not reproduce. Measured
with the same settings:

| invocation | outcome |
|---|---|
| gh api /rate_limit | 5000 — excluded, unsandboxed |
| cd /tmp; gh api /rate_limit | 5000still 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 — gh is a Go binary whose TLS dies under Seatbelt
(#26466) and ssh cannot resolve DNS sandboxed — so the bypass is permanent under current
tooling. We treat the sandbox as a blast-radius reducer for honest mistakes, not a boundary,
and rely on PreToolUse hooks for anything we actually need enforced.

bcherny collaborator · 5 days ago

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.

  • A standalone read of the denied file is correctly confined and returns no contents — confirming the sandbox and the deny rule are active.
  • A single Bash invocation combining a read of the denied file with a git command returns the denied file's contents. This happens whether the git command 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.
  • After those runs, a standalone read of the denied file is confined again, so the deny rule is still in effect.

So once any part of an invocation matches an excludedCommands glob, the whole invocation runs unsandboxed and filesystem.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