sandbox.excludedCommands has no effect — excluded commands still run sandboxed (2.1.232, macOS)

Status Open
Reported on v2.1.232
Maintainer reply None cached
Activity 10 comments · opened Aug 26, 2026

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.json in 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> plus
GIT_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.json did previously exist with its own sandbox.excludedCommands; it was deleted, and the behaviour is unchanged. Reproduced with the file confirmed absent.
  • Settings staleness / capture-at-startup. ~/.claude/settings.json mtime 07:51:05; session started 07: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 flatMap across 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 | head appended, 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.

View original on GitHub ↗

3 Comments

shleder · 1 month ago

Regarding the failure of in-process excludedCommands and broken Git over SSH / gh proxy 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:

# Run Claude Code with full Git/SSH/Go toolchain support:
vetto --agent claude --profile standard -- claude

Key Differences:

  • Direct SSH & Git Compatibility: In Linux FS_ONLY / macOS standard profiles, outbound SSH (git@github.com:...) and HTTPS work natively through the host network stack without injecting broken GIT_SSH_COMMAND proxies.
  • Fail-Closed Secret Masking: While allowing Git and dev tools to work without false positives, ~/.ssh/id_rsa, ~/.aws, and .env remain masked to /dev/null at the kernel VFS layer.
  • Out-of-Tree Invariance: Policies are immutable and outside the Node.js process space, eliminating JSON parser merge failures and prompt-injection setting overrides.
kokosabu · 1 month ago

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

  • Claude Code 2.1.236 (native installer, ~/.local/share/claude/versions/2.1.236)
  • Ubuntu 22.04.5 LTS, kernel 5.15.0-190-generic, x86_64, bubblewrap 0.6.1

Probe settings — my shipping config plus the one candidate line:

{
  "sandbox": {
    "enabled": false,
    "filesystem": { "denyRead": ["~/.aws/credentials", "~/.aws/sso"] },
    "excludedCommands": ["gh *", "rtk gh *", "rtk proxy gh *", "rtk run gh *"]
  }
}

(enabled: false is 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 four gh spellings are because a PreToolUse hook here may rewrite the command.)

claude --settings probe.json --allowedTools Bash \
  -p 'Run these as TWO separate Bash tool calls, then paste both raw outputs verbatim and nothing else:
1) gh auth status
2) wc -c ~/.aws/sso/cache/*.json ~/.bashrc' < /dev/null

Result — gh is in excludedCommands and is still sandboxed

1) github.com
     X Failed to log in to github.com account <user> (default)
     - Active account: true
     - The token in default is invalid.
   (exit 1)

2) 5913 /home/<user>/.bashrc
   (the ~/.aws/sso/cache/*.json line is absent — that path is denyRead'd)

gh fails here because the Linux sandbox mounts an empty tmpfs over /run/user, so gh cannot 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. With excludedCommands honored, gh should have run outside the sandbox and succeeded. (I have filed that /run/user masking separately as #90717, since it is a distinct problem that excludedCommands merely fails to work around.)

Command (2) is a deliberate control pair, in the same session: the denyRead entry 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

  1. Match the top-level command string. excludedCommands is matched against the Bash tool's command. If the excluded binary is invoked from inside a script (sh check.sh), the string being matched is sh 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.
  1. Do not run the probe from inside another Claude Code session. The inner bubblewrap can add masks on top of the outer session's, but it cannot remove them, so any test of the form "does this setting un-block something" fails no matter what. Every probe above was run from a plain terminal.

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 ~/.ssh in denyRead, ls ~/.ssh shows the same empty tmpfs overlay with the flag set as without it), and sandbox.network.allowUnixSockets (#44180 — not implemented on Linux). If they share a dispatcher, fixing this one may cover more than excludedCommands.

noricha-vr · 1 month ago

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, so git will report its own sandbox-injected config back to you.

git is in my excludedCommands. As a bare, single, metacharacter-free Bash tool call:

$ git config --show-origin --get-all http.proxyAuthMethod
command line:	basic

command line: is git's origin label for GIT_CONFIG_PARAMETERS. So this git process was started with the sandbox's proxy environment applied.

Control: identical command, sandbox explicitly bypassed

Same session, same command, run with the sandbox off:

$ echo "GIT_CONFIG_PARAMETERS=[$GIT_CONFIG_PARAMETERS]"
GIT_CONFIG_PARAMETERS=[]
$ echo "HTTPS_PROXY_SET=$([ -n "$HTTPS_PROXY" ] && echo yes || echo no)"
HTTPS_PROXY_SET=no
$ echo "GIT_SSH_COMMAND_SET=$([ -n "$GIT_SSH_COMMAND" ] && echo yes || echo no)"
GIT_SSH_COMMAND_SET=no
$ git config --show-origin --get-all http.proxyAuthMethod
EXIT=1

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 git above got it.

gh (also in the list) showed the matching network symptom before I added enableWeakerNetworkIsolation: a bare gh api user --jq .login failed with tls: failed to verify certificate: x509: OSStatus -26276, the same MITM-cert failure the OP reports. Flagging that setting explicitly since it is now true in my config and changes what a reader reproducing gh will see; it does not affect the git config probe above, which is purely local.

Config in use

~/.claude/settings.json:

"permissions": { "defaultMode": "auto" },
"sandbox": {
  "enabled": true,
  "autoAllowBashIfSandboxed": true,
  "enableWeakerNetworkIsolation": true,
  "excludedCommands": ["docker-compose", "gh", "git", "ssh", "ssh-add", "ssh-keygen"]
}

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.json does exist but contains only permissions.allow, outputStyle and MCP keys — no sandbox key 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 several sandbox.* 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:

// matcher 1
function xdo(e) {
  let r = Rn().sandbox?.excludedCommands ?? [];
  if (r.length === 0) return !1;
  let o; try { o = Pp(e) } catch { o = [e] }        // splits into segments
  for (let f of o) { /* strips env-var prefixes, unquotes, then: */
    for (let I of r) { let F = x0e(I); for (let B of y) if (jun(F, B)) return !0 } }
  return !1
}

var Cdo = /[;|&`$(){}<>#\n\r]/;

// matcher 2
function uJn(e) {
  let n = Sz().flatMap((o) => o?.sandbox?.excludedCommands ?? []),
      r = e.trim();
  if (n.length === 0 || !r || Cdo.test(r)) return !1;   // any metachar => never excluded
  return n.some((o) => jun(x0e(o), r))
}

function jun(e, n) {
  switch (e.type) {
    case "prefix":   return n === e.prefix || n.startsWith(e.prefix + " ");
    case "exact":    return n === e.command;
    case "wildcard": return Pq(e.pattern, n)
  }
}

"git" parses as a prefix entry, and git config --show-origin --get-all http.proxyAuthMethod satisfies n.startsWith("git" + " "). It contains no character in Cdo, 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:

  1. The two matchers disagree by construction. xdo reads Rn().sandbox?.excludedCommands and splits compound commands into segments (so a pipeline can match); uJn reads Sz().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. If uJn` 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.
  1. The env injection may simply not be on the per-command path. The three variables in the control above look like they are applied to the shell environment the command inherits. If they are set when the sandboxed shell is established rather than per Bash call, an excluded command would inherit the full proxy environment no matter how the match resolves — which fits both this report and the 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 macOS GIT_SSH_COMMAND is built with credential-less SOCKS5 (#70684, also #82255). Fixing excludedCommands would route around it; fixing that would not fix this.

Showing cached comments. Read the full discussion on GitHub ↗