[BUG] 2.1.277: excludedCommands "every part must match" also drops single commands carrying a pre-subcommand flag (git -C, -c, --git-dir)
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report
- [x] I am using the latest version of Claude Code
What's Wrong?
2.1.277 includes this fix:
Fixed a sandbox.excludedCommands glob exempting an entire compound Bash command from the sandbox when only one part matched; every part must now match
That fix is correct and closes a real bypass (#81157). But it appears to over-match: a single command — no pipe, no chain, no redirect, therefore exactly one "part" — is also no longer exempted when it carries a flag before its subcommand.
With sandbox.excludedCommands: ["git *"]:
| Command | 2.1.276 | 2.1.277 |
|---|---|---|
| git status --porcelain | excluded | excluded |
| git --no-pager status --porcelain | excluded | excluded |
| git --literal-pathspecs status --porcelain | excluded | excluded |
| git -C . status --porcelain | excluded | sandboxed |
| git -c core.pager=cat status --porcelain | excluded | sandboxed |
| git --git-dir=<p>/.git --work-tree=<p> status --porcelain | excluded | sandboxed |
| git --exec-path=/usr/lib/git-core status --porcelain | excluded | sandboxed |
Every one of these is a single command and matches the glob git * as a plain string. Bare boolean pre-flags are unaffected; flags that take a value are not.
Impact, in order of severity:
git statusreturns plausible wrong output instead of failing. It exits 0 and produces well-formed porcelain that happens to be wrong: paths the sandbox masks (home-directory dotfiles such as.bashrcand.gitconfig, plus configured deny-read paths) are reported as untracked repository files. This reads as ordinary working-tree noise. In my case it was dismissed as exactly that, and the underlying condition went undiagnosed across several later commands, including a staging decision. Anything that parsesgit statusis affected. An error would have been strictly safer than a wrong answer.
- Credentialed operations fail with a misleading error.
git -C <path> fetch/pushfail withfatal: could not read Username for '<host>', because the credential helper cannot read its configuration inside the sandbox. The surfaced error points at credentials or networking rather than at sandboxing, which invites misattribution.
- A common pattern breaks.
git -C <dir> …is a standard way to act on another worktree without changing directory, including inside scripts and tooling.
Why this looks like over-matching rather than intent. If the goal were to exclude git's repo/config-retargeting flags on security grounds — they are genuine arbitrary-code-execution vectors (-c core.sshCommand=…, -c credential.helper=…, --exec-path= into a writable directory) — that would be defensible. But it is not mentioned in the changelog, which describes only the compound-command tightening; it is not in the settings documentation; --exec-path and -C are treated the same as -c while harmless value-less flags pass, a split that tracks "flag takes a value" rather than "flag is dangerous"; and the status failure mode is silent wrong output, which no intentional security control would choose.
What Should Happen?
A single command carrying pre-subcommand flags should be treated as one part, so git -C <path> status continues to match git * and run excluded — exactly as it did in 2.1.276.
If any of these flags are excluded deliberately, it should be documented in the settings reference and should fail loudly rather than returning sandbox-contaminated git status output.
Error Messages/Logs
# Same repo, same cwd, two separate Bash tool calls, 2.1.277.
$ git status --porcelain
# (clean — correct)
$ git -C . status --porcelain
?? .bashrc
?? .gitconfig
?? .profile
?? .zshrc
# …and further entries, one per sandbox-masked path.
# None of these files exist in the repository.
# Credentialed form:
$ git -C /path/to/repo fetch origin main
warning: unable to access '/path/to/repo/.gitmodules': Permission denied
fatal: could not read Username for 'https://<host>': No such device or address
# Same command without -C, immediately afterwards, succeeds:
$ git fetch origin main
From https://<host>/<owner>/<repo>
* branch main -> FETCH_HEAD
Steps to Reproduce
- Enable the sandbox and add a
gitexclusion in~/.claude/settings.json:
``json``
{
"sandbox": {
"enabled": true,
"excludedCommands": ["git *"]
}
}
- In any git repository, run this as a Bash tool call:
````
git status --porcelain
Note the output — it reflects the true working-tree state.
- As a separate Bash tool call (chaining would itself defeat the exclusion, correctly, under the 2.1.277 rule), run:
````
git -C . status --porcelain
- Compare.
-C .is a no-op, so the two should be identical. On 2.1.277 the second additionally lists sandbox-masked paths as untracked files.
The same split appears with git -c core.pager=cat status, git --git-dir=… --work-tree=… status, and git --exec-path=… status, while git --no-pager status and git --literal-pathspecs status remain correctly excluded.
Claude Model
Opus
Is this a regression?
Yes, this worked in a previous version
Last Working Version
2.1.276
Claude Code Version
2.1.277 (Claude Code)
Platform
Google Vertex AI
Operating System
Ubuntu/Debian Linux
Terminal/Shell
WSL (Windows Subsystem for Linux)
Additional Information
~~Not fully deterministic.~~ Withdrawn 2026-09-21 — see the correction below and this comment. ~~Across sessions on the same machine and version, the same command shape failed, failed, then succeeded within about 60 seconds. Behaviour was stable and reproducible within a given session but not identical across sessions, which suggests the classification may be cached or computed per session. Flagging it because it makes the bug hard for users to characterise — my own first attempt produced a clean, reproducible-looking rule that did not hold outside that session.~~
Correction (2026-09-21). The struck paragraph above does not hold up and should not be relied on. It rested on a command that failed three times and then succeeded within about 60 seconds; re-reading the literal tool-call inputs, rather than the remembered command strings, showed that the failing calls carried 2>&1 | head -20 and the succeeding one did not — ordinary compound behaviour under the 2.1.277 rule, fully deterministic. No claimed non-determinism has survived checking here since. This withdraws only my own evidence, not #92445, which is reported independently. Everything else in this report still reproduces, including on 2.1.278.
Only tested on Vertex AI. Nothing about this looks provider-specific — it is in the Bash/sandbox layer — but I have not confirmed it on the first-party API or Bedrock.
Workaround. Change directory in its own command, then run plain git:
cd <dir>
git status --porcelain
Note that (cd <dir> && git …) is not a workaround — that is genuinely a compound command, so the 2.1.277 rule correctly declines to exempt it.
A more durable workaround, which also isolates the cause to the program name, is to wrap git -C under a different program name (gitc) and exclude that instead — details in this comment.
Related issues.
- #81157 — the compound-command bypass that this 2.1.277 change fixes. This report is about that fix over-matching, not a request to revert it.
- #82109 —
excludedCommandsskipped when the excluded command is inside a shell loop; same family, command structure defeating the match. - #92445 —
excludedCommandsintermittently proxied. Reported independently; this report no longer offers evidence for it, per the correction above.
11 Comments
Additional case: the same false positive fires on a post-subcommand flag, where nothing is retargeted.
git rev-parse --git-dirasks git to print the git directory. It is not thegit --git-dir=PATH <subcommand>form and retargets nothing, but it appears to be matched on the token alone.Repro (Claude Code 2.1.277,
sandbox.excludedCommands: ["git *"], inside a clean git repo):Bisection isolates it to the
git rev-parse --git-dirsegment. Exit code is 0 and the output is well-formed, so the only tell is recognising the dotfile signature — which makes it easy to misread as a genuinely dirty working tree.git rev-parse --show-toplevel && git status --shortis unaffected and works as a substitute.Suggestion: match retargeting flags by position — a git-level option precedes the subcommand, so a flag appearing after the subcommand is a subcommand argument rather than a retarget. The same ambiguity likely exists for
-c(git -c k=v <subcmd>versus a subcommand's own-c).It's even worse for us…
Any non-excluded standalone command now breaks all exclusions.
Tool calls work well at first.
Run any other command and the sandbox exclusion no longer applies for subsequent commands:
I hate it when our dev process gets shut down completely by random new Claude Code bugs…
Even the intended change is quite invasive, given that simple things like
<excluded command> | headetc. now all break.Every project needs a huge amount of sandbox exclusions now!
And you can't even control sandboxing per tool invocation with a hook, which would be ideal.
Same "every part must match" change, different facet: the content of a single quoted argument is enough to lose the exclusion — no compound command, no pipe, no pre-subcommand flag.
With
"excludedCommands": ["git *"], these two differ only by the leading#:# passes: exit 1, "nothing to commit"
git commit --dry-run -m 'subject
body'
# fails: exit 128, fatal: unable to access '/Users/me/.gitconfig': Operation not permitted
git commit --dry-run -m 'subject
# body'
Quoting does not protect the argument: it is parsed as shell text where the shell itself interprets nothing. Measured:
| In the quoted argument | Result |
| --- | --- |
| line starting with
#(not the first), backtick,$(...)| fails ||
[Word]in double quotes — bracket group without a space, read as a glob class | fails|
[Two Words],- [ ],\|,?,*,<,>, emoji, line breaks, 900+ chars | passes |That covers most real commit messages and merge request descriptions, which routinely contain
##headings, backticks and- [ ]checkboxes — so writing either from a Bash tool call now fails, with a credentials error pointing at~/.gitconfigwhilegit pushworks in the same session.Versions: session transcripts (they record the version per call) show multi-line
--descriptionbodies with##, backticks and$(cat ...), up to 4830 characters, succeeding on 2.1.235 through 2.1.276 (last success 2026-09-18) and failing on 2.1.278 in 11 of 11 calls. Running 2.1.277 directly (~/.local/share/claude/versions/2.1.277 -p '...'), both-m "$(echo hello)"and-m "NOTICKET: [Documentation] subject"already fail there while the plain multi-line control passes, so this facet arrived in 2.1.277 as well.excludedCommandsin the project settings has been unchanged since 2026-04-22.Workaround:
git commit -F <file>is immune, since the message never appears in the command. A CLI without a--description-fileequivalent needs the body stripped of#lines and backticks.Still present on 2.1.278, re-measured 2026-09-21 on a second machine (Vertex AI, WSL2). Same repo, same cwd, two bare tool calls:
@ewessolek's version data in the comment above — succeeding through 2.1.276, failing from 2.1.277, on a different facet and a different platform — matches the bisect here, which makes a shared cause in that release look more likely.
A negative result worth recording: adding an explicit
git -C *entry toexcludedCommandschanges nothing, and neither does any othergitspelling. That is consistent with the classification happening before the glob list is consulted, keyed on the program name.Workaround: rename the program
Since the parse appears to key on
git, a wrapper under a different name is matched normally.~/.local/bin/gitc, containingexec git -C "$@""gitc *"insandbox.excludedCommandsBash(gitc:*)inpermissions.allow— without this, the auto-mode classifier refuses the new wrapper as[Self-Modification], trading a sandbox failure for a per-call permission promptPreToolUse(Bash)hook that rewrites a baregit -C DIR <subcommand>togitc DIR <subcommand>, so the habitual shape keeps working without anyone having to remember the ruleVerified on 2.1.278:
gitc <repo> ls-remote origin HEADreturns the ref andgitc <repo> status --shortis clean, where the identicalgit -Cforms fail and silently lie respectively. That is also a useful diagnostic in itself — the only difference between the working and broken calls is the program name.@fluidsonic — on "you can't even control sandboxing per tool invocation with a hook": a
PreToolUsehook can't change sandboxing directly, but it can rewrite the command, by returninghookSpecificOutput.updatedInputand omittingpermissionDecision(the rewritten command then goes through normal permission evaluation). That is what step 4 does, and it is how an unmatchable shape is turned into one that does match an entry. It may give you a way to normalise the./gradlewinvocations that lose the exclusion.Three traps for anyone copying this:
PreToolUse(Bash)hook that denies this shape, a second hook cannot rewrite it.** Both hooks receive the original tool input and a deny wins, so the rewrite has to be folded into the existing hook rather than added beside it.shlex.splitand rebuilding withshlex.quotewill neutralise$VAR, backticks,~and globs, producing a different command that still looks correct. Bail to the old behaviour on those.-C.-c,--git-dir,--work-treeand--exec-pathhave no equivalent single-flag wrapper and still needcdin its own call, then plaingit.Correction to the report
The "Not fully deterministic" note under Additional Information does not hold up, and I have struck it in the body.
It rested on a command that failed three times and then succeeded within about 60 seconds. Re-reading the literal tool-call inputs, rather than the remembered command strings, showed that the failing calls carried
2>&1 | head -20and the succeeding one did not — ordinary compound behaviour under the 2.1.277 rule, fully deterministic. Every later attempt to reproduce non-determinism here has resolved the same way: a pipe or redirect in the exact command text.This withdraws only my own evidence, not #92445, which is reported independently. But if the per-session-caching hypothesis was being treated as live on the strength of this report, it should not be.
Everything else in the report reproduces as written, including on 2.1.278.
@dannewman70 thank you. We can't use that rewrite approach as we have an extensive permission system in our own Claude Code plugin and would have to add an insane amount of sandbox exclusions and/or bash command rewrites to make this work.
For now, our workaround will likely be to disable the sandbox completely 😵
Long-term, we plan to use our own harness. Claude Code just throws wrenches into our development processes over and over again.
Still present on 2.1.280 (apt, Linux x86_64), which is newer than any datapoint above. Bisect on this box: works on 2.1.273, broken on 2.1.278 and 2.1.280.
Three things from our setup that I don't think are in the thread yet.
1. Our case is broader than the pre-subcommand-flag model. With
"excludedCommands": ["git clone *", "git fetch*"], none of these are exempted:| Command | Matching pattern | Result |
|---|---|---|
|
git clone <url> <path>|git clone *| sandboxed ||
git fetch origin|git fetch*| sandboxed ||
git fetch|git fetch*| sandboxed |No pipes, no chains, no pre-subcommand flag, no value-taking flag, and in the last case no arguments at all. This is consistent with @dannewman70's negative result that the classification happens before the glob list is consulted.
2. It is not the stateful behaviour @fluidsonic describes, at least not here. I tested the clone as the first Bash command in a brand-new session, with nothing run before it, and it is still sandboxed. So exclusions are inert from the start rather than being killed by an earlier non-excluded command. That may mean two distinct failure modes are in this thread.
3. Settings come from the managed tier, and sessions are not terminal sessions. Ours are in root-owned
/etc/claude-code/managed-settings.json, and sessions are spawned byclaude remote-controlrather than a terminal. Nobody above mentions either, so if the parse differs by settings tier or session origin, that may narrow it.A credential-free repro that fails unambiguously, since most symptoms here are silent wrong output or credential-shaped errors:
Public repo, no credentials involved, and the home directory is writable by that user outside the sandbox — so the error can only come from the sandbox.
Why this was expensive. With
sandbox.credentialsmasking aghtoken file, sandboxed git receives the sentinel and GitHub answersremote: Invalid username or token. That sent us through token scopes, credential helpers, repository rulesets and our own configuration management for several hours before anything pointed at the sandbox. Worth adding to the severity picture for #81157's fix: the failure mode doesn't just produce wrong answers, it actively misdirects.It also has no per-call workaround for helpers that write to sandbox-masked paths. Ours repairs a symlink inside
~/.ssh, so disabling the sandbox per call — which is what we're now doing for git — doesn't help there. Related symptom in #89931.More data points: it's a specific set of flags, not "any flag with a value" — and no exclusion pattern brings them back
I hit the same thing with
awsand dug into it alongsidegit. Setup: Claude Code 2.1.280 (the build bundled in the desktop app, macOS/Seatbelt), managed settings with"aws:*"and"git:*"insandbox.excludedCommands,~/.awsand~/Library/Keychainson the sandbox read-deny list. Every probe below is a single bare command — no pipe, no redirect, no&&.Detectors. For
aws, the read-deny on~/.awsis the tell: an excludedaws configure get regionprints the region; a sandboxed one can't see the config file. Forgit, the keychain deny is the tell: an excludedgit fetch origin --dry-runsucceeds silently through the osxkeychain helper; a sandboxed one fails withcould not read Username for 'https://github.com': Device not configured.1. Value-taking flags aren't the trigger
All in one session, seconds apart.
aws:The first line proves
defaultexists. Add--profile defaultand the CLI can no longer find it, or any other profile:~/.awshas become unreadable, which is exactly what the sandbox read-deny does.git, from inside a repo whose remote ishttps://github.com/…:-C .is the current directory andcolor.ui=falseis a no-op, so the values are not what matters. Across both tools, threeawsflags that take values keep the exclusion, while--profile,-C,-cand--git-dirlose it in any position and in=form. The common thread isn't "takes a value"; it's flags that change where the tool reads its identity or config. This looks like a fixed flag list rather than part-splitting.2. It's the exclusion matcher, not the auto-mode classifier
Same results with auto mode turned off.
3. You can't pattern your way out of it
I added these to a project-local
.claude/settings.local.jsonand started a fresh session:The last entry is a control:
touchon a path the sandbox normally refuses to write to succeeded, so the list was loaded. All three--profileforms still ran sandboxed. An exact-match entry for the complete failing command didn't help either. (I only testedawspatterns; I'd expectgit -C *to behave the same but did not run it.)4. Related shapes that compound it
A redirect (
> file,2>/dev/null), a$(…)substitution, a leadingVAR=xprefix and acd … && cmdchain all keep a command sandboxed even when every command word matches an exclusion. I believe those are documented, but combined with the--profilebehaviour there is currently no way to runawsagainst a non-default profile from inside a sandboxed session, and the standardgit -C <worktree>idiom is out too.5. Workarounds found
git:cd <path>as its own command, then baregit ….aws: set the profile through the settingsenvkey,"env": {"AWS_PROFILE": "<name>"}; a bareaws sts get-caller-identitythen resolves it and stays excluded. The downside is that it pins every AWS call in that settings scope to one profile, and removing the key needs a session restart before it takes effect.I agree with the reporter's expected behaviour: a single command should be one part, so
aws --profile <name> …andgit -C <path> …should matchaws:*andgit:*as they did in 2.1.276. If there is an intentional list of identity-retargeting flags behind this, it would help to have it documented, and to honour an explicitexcludedCommandsentry naming the flag as an opt-in.Partially fixed on 2.1.281 (macOS/Seatbelt, measured 2026-09-24). Follow-up to my comment above: the quoted-argument facet is half fixed, the flag facet is not. Same setup,
"excludedCommands": ["git *"], each probe a single bare Bash tool call; exit 1 = excluded ("nothing to commit"), exit 128 = sandboxed (fatal: unable to access '~/.gitconfig': Operation not permitted).| Probe | 2.1.280 | 2.1.281 |
| ------------------------------------------------------------- | ------- | --------------------- |
|
git commit --dry-run -m 'subject⏎⏎# body'| 128 | 1 ✓ ||
git commit --dry-run -m "NOTICKET: [Documentation] subject"| 128 | 1 ✓ ||
git commit --dry-run -m 'NOTICKET: [Documentation] subject'| 128 | 1 ✓ || `
git commit --dry-run -m 'subject withbacktick'| 128 | 128 ✗ |git commit --dry-run -m 'subject with $(echo x)'|
| 128 | 128 ✗ |git -c color.ui=false status --porcelain|
| 128 | 128 ✗ |git -C . status --porcelain|
| 128 | 128 ✗ |git rev-parse --git-dir|
(post-subcommand, cf. @dannewman70) | – | passes, prints.git|git status --porcelain` (control) | passes | passes ||
So a
#line and a single-word bracket group inside a quoted argument are no longer read as shell syntax, but a backtick or$(…)still is — even inside single quotes, where the shell performs no substitution. The value-taking pre-subcommand flags (-C,-c) are unchanged.Heads-up for anyone who re-tests with only the
#-line and[Word]probes: they pass on 2.1.281 now, while backticks in commit messages and MR descriptions still break the exclusion.Update: no change since my 2.1.281 report. Re-measured on 2.1.282, 2.1.284 and 2.1.285 (macOS),
excludedCommands: ["git *"], probegit commit --dry-run -m '…':| In the quoted argument | Result |
| ----------------------- | ------------------------- |
|
#body line,[Word]| passes (fixed in 2.1.281) || backtick | still fails |
|
$(…)| still fails |The failing cases still drop the exclusion silently: the command runs sandboxed and fails with
fatal: unable to access '~/.gitconfig': Operation not permitted, which reads like a credential problem. Backticks are the remaining blocker for real commit messages and MR descriptions, since file names are usually code-formatted.Still an issue on 2.1.291.