sandbox.excludedCommands has no effect — excluded commands still run sandboxed (2.1.232, macOS)
sandbox.excludedCommands in user settings appears to be read but not honoured: a command listed there is still executed inside the sandbox, for both filesystem and network operations.
Environment
- Claude Code 2.1.232 (Homebrew cask,
/opt/homebrew/Caskroom/claude-code@latest/2.1.232/claude) - macOS 26.5.1, arm64
permissions.defaultMode: bypassPermissions- No
.claude/settings.local.jsonin the project (confirmed absent — see "Ruled out" below)
Relevant ~/.claude/settings.json:
"sandbox": {
"enabled": true,
"allowUnsandboxedCommands": true,
"excludedCommands": ["dotnet", "mvn", "mvnw", "./mvnw", "gh", "git", "flutter", "dart", "specfuse"]
}
Minimal repro
git is in excludedCommands. In a sandboxed Bash tool call:
$ git init -q /Users/Shared/sbtest-git
fatal: cannot mkdir /Users/Shared/sbtest-git: Operation not permitted
EXIT=128
The identical command with the sandbox explicitly disabled succeeds, so the directory is writable and it is the sandbox producing the denial:
$ git init -q /Users/Shared/sbtest-control # sandbox bypassed
EXIT=0
Expected: git is in excludedCommands, so it should run outside the sandbox and succeed in both cases.
Actual: the excluded command is sandboxed.
Also affects network, same cause
Both of these are listed in excludedCommands and both fail sandboxed, in ways that show the sandbox's proxy environment is being applied to them:
$ gh api repos/OWNER/REPO --jq .full_name
Get "https://api.github.com/repos/OWNER/REPO": tls: failed to verify certificate: x509: OSStatus -26276
$ git ls-remote git@github.com:OWNER/REPO.git HEAD
ssh_dispatch_run_fatal: Connection to UNKNOWN port 65535: Broken pipe
Sandboxed shells receive HTTPS_PROXY/ALL_PROXY=http://…@localhost:<port> plusGIT_SSH_COMMAND=ssh -o ProxyCommand='nc -X 5 -x localhost:<port> %h %p'. The gh failure is the MITM proxy's certificate not being in Go's trust store; the SSH failure is the proxy refusing port 22. Unsetting GIT_SSH_COMMAND turns the second into ssh: connect to host github.com port 22: Operation not permitted, i.e. the seatbelt layer, confirming both layers are applied to a command that should be exempt from them. The same gh call with the sandbox bypassed returns normally.
Ruled out
- Project-settings override.
.claude/settings.local.jsondid previously exist with its ownsandbox.excludedCommands; it was deleted, and the behaviour is unchanged. Reproduced with the file confirmed absent. - Settings staleness / capture-at-startup.
~/.claude/settings.jsonmtime07:51:05; session started07:54:38. The settings predate the session. - Replace-vs-merge across settings sources. Not the cause — one of the readers in the bundle collects the key with
flatMapacross sources, i.e. it merges. - Path-specific denial. The unsandboxed control above writes to the same path successfully.
- Compound-command parsing. First observed with
cmd | headappended, which could plausibly defeat prefix matching. Re-run bare, as shown above — same result.
Possibly useful
Strings in the bundle suggest the intended flow is a shouldSandbox-style predicate that returns false when the command matches excludedCommands (read via …sandbox?.excludedCommands, matched by a prefix/startsWith(prefix + " ") rule). If that predicate is reached at all here, the match seems to be failing; a bare git … should match a "git" prefix entry. Offered as a pointer only — I have not verified it against source.
Impact
With excludedCommands inert, git-over-SSH and gh are unusable in sandboxed calls, so every such invocation needs an explicit sandbox bypass. That makes the setting's security value illusory in the other direction too: a user who believes they have narrowed their exclusion list has not actually changed anything.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Regarding the failure of in-process
excludedCommandsand broken Git over SSH /ghproxy injection:This issue is an architectural side effect of placing the command exclusion dispatcher inside the application runtime. When Claude Code executes a tool call, the JS harness evaluates the command string before spawning a subprocess; if that predicate fails open or closed (e.g. during options merging, compound pipes, or subshells), the child inherits the artificial proxy environment (
GIT_SSH_COMMAND,HTTPS_PROXY) regardless of user intent.Moreover, forcing traffic through a local MITM proxy inevitably breaks Git over SSH (port 22) and Go TLS root verification because proxies cannot inspect or pass raw SSH tunnels cleanly without certificate trust.
To decouple developer workflows from in-tree JSON parser edge cases, Vetto enforces security as a native, out-of-tree kernel wrapper:
Key Differences:
FS_ONLY/ macOSstandardprofiles, outbound SSH (git@github.com:...) and HTTPS work natively through the host network stack without injecting brokenGIT_SSH_COMMANDproxies.~/.ssh/id_rsa,~/.aws, and.envremain masked to/dev/nullat the kernel VFS layer.Confirming this on Linux 2.1.236 with bubblewrap, so it is not specific to macOS or to Seatbelt, and it is still present four releases after 2.1.232.
Environment
~/.local/share/claude/versions/2.1.236)Probe settings — my shipping config plus the one candidate line:
(
enabled: falseis not honored on Linux and hasn't been since 2.1.220 — the sandbox runs regardless. Noted so the config isn't misread as "sandbox off". The fourghspellings are because aPreToolUsehook here may rewrite the command.)Result —
ghis inexcludedCommandsand is still sandboxedghfails here because the Linux sandbox mounts an empty tmpfs over/run/user, soghcannot reach the D-Bus session bus and cannot read its token out of the system keyring. In a plain terminal the same command prints✓ Logged in ... (keyring), so the token is valid and it is the sandbox producing the failure. WithexcludedCommandshonored,ghshould have run outside the sandbox and succeeded. (I have filed that/run/usermasking separately as #90717, since it is a distinct problem thatexcludedCommandsmerely fails to work around.)Command (2) is a deliberate control pair, in the same session: the
denyReadentry still bites and the non-denied file still reads. So the sandbox was active and the settings were loaded — this is not a "settings not found" failure, which is the main alternative explanation for a no-op setting.Two ways to get a false negative on Linux, both of which I hit first
excludedCommandsis matched against the Bash tool's command. If the excluded binary is invoked from inside a script (sh check.sh), the string being matched issh check.sh, and the result is a guaranteed false negative. My first run did exactly this and looked like a confirmation of the bug for the wrong reason. Both calls above are top-level.Related on Linux
On this build I could not find any working per-command escape from the sandbox:
excludedCommands(this issue),dangerouslyDisableSandbox: true(does not remove the filesystem masks either — with~/.sshindenyRead,ls ~/.sshshows the same empty tmpfs overlay with the flag set as without it), andsandbox.network.allowUnixSockets(#44180 — not implemented on Linux). If they share a dispatcher, fixing this one may cover more thanexcludedCommands.Still reproducing on 2.1.259 (eight days and 27 patch releases after 2.1.232), macOS Darwin 25.5.0, arm64. Adding three things the thread doesn't have yet: a probe that needs no writable path, a sandbox-off control on the same command, and the matcher code from the 2.1.259 bundle — which appears to rule out "the match is failing" as the explanation.
Probe: read the injected git config instead of writing to disk
The OP's repro proves sandboxing via a filesystem denial, which needs a path outside the write allowlist. A cheaper equivalent: the macOS sandbox injects
GIT_CONFIG_PARAMETERS, sogitwill report its own sandbox-injected config back to you.gitis in myexcludedCommands. As a bare, single, metacharacter-free Bash tool call:command line:is git's origin label forGIT_CONFIG_PARAMETERS. So thisgitprocess was started with the sandbox's proxy environment applied.Control: identical command, sandbox explicitly bypassed
Same session, same command, run with the sandbox off:
All three variables are absent and the config key is unset. So the injection is sandbox-specific, not something in my shell profile — and the excluded
gitabove got it.gh(also in the list) showed the matching network symptom before I addedenableWeakerNetworkIsolation: a baregh api user --jq .loginfailed withtls: failed to verify certificate: x509: OSStatus -26276, the same MITM-cert failure the OP reports. Flagging that setting explicitly since it is nowtruein my config and changes what a reader reproducingghwill see; it does not affect thegit configprobe above, which is purely local.Config in use
~/.claude/settings.json:The session itself was launched in bypassPermissions mode (so
defaultMode: "auto"in the file is not what governed the session — noting both rather than collapsing them). A project.claude/settings.local.jsondoes exist but contains onlypermissions.allow,outputStyleand MCP keys — nosandboxkey at any level, so there is nothing to override the user settings above (and per the bundle's own schema text, project settings are ignored for severalsandbox.*fields anyway).The matcher looks correct in 2.1.259, which narrows where the bug is
The OP offers "the match seems to be failing" as a pointer. Reading the shipped 2.1.259 bundle (
~/.local/share/claude/versions/2.1.259,grep -a+ context, minified names, line-broken here) suggests the opposite — there are two matchers, and my command should match under either:"git"parses as aprefixentry, andgit config --show-origin --get-all http.proxyAuthMethodsatisfiesn.startsWith("git" + " "). It contains no character inCdo, so matcher 2's early return does not fire either. Both should return true, and the command was still sandboxed.Two observations that may be more useful than the match question:
xdoreadsRn().sandbox?.excludedCommandsand splits compound commands into segments (so a pipeline can match);uJnreadsSz().flatMap(...)across settings sources and refuses outright on any of `; | &$ ( ) { } < > #`or a newline. Whichever one governs actual sandboxing, one of them is answering a different question than a user reading the docs would expect. IfuJn` is the sandboxing gate, then every compound command is unconditionally sandboxed regardless of the list — arguably defensible, but it should be documented, and it is not what my bare-command repro hit.gh/git-over-SSH half of it.I have not verified either against source; the bundle reading is offered as a pointer, the bare-command repro and the sandbox-off control are the load-bearing part.
Related
The git-over-SSH failure mode the OP mentions (
Connection to UNKNOWN port 65535) has its own root cause independent of this issue — the macOSGIT_SSH_COMMANDis built with credential-less SOCKS5 (#70684, also #82255). FixingexcludedCommandswould route around it; fixing that would not fix this.