Permission rules are bypassed by semantically-equivalent command%2 0forms (`git -C`, `cd &&`, env prefixes)
Summary
permissions.ask / permissions.deny rules of the form Bash(git push*) match the raw command string by literal prefix. Any invocation that is semantically identical but does not begin with that literal text is not matched, and so escapes the rule. This is a soundness hole: a user who adds Bash(git push*) to ask/deny reasonably believes they have gated pushing, but they have only gated command strings that start with the characters git push.
I'm fairly confident the matcher is literal-prefix based on observed behavior (below); I have not read the matcher source, so treat the mechanism description as inferred.
Impact
- Severity: a permission rule the user trusts to be a guardrail silently does nothing for common command shapes.
- This bit me in practice: a
Bash(git push*)rule inaskdid not prompt forgit -C /path/to/repo push origin <branch>, and the push went through without confirmation. - Same class of bypass applies to
denyrules — e.g.Bash(git reset --hard*)is defeated bygit -C /path reset --hard.
Reproduction
~/.claude/settings.json:
{ "permissions": { "ask": ["Bash(git push*)"] } }
Then have Claude run each of these. Only the first is gated; the rest run without a prompt:
| Command | Matched by Bash(git push*)? |
|---|---|
| git push origin main | yes |
| git -C /path/to/repo push origin main | no |
| cd /path/to/repo && git push | no |
| FOO=1 git push | no |
| git -c k=v push | no |
All five are the same operation (pushing) from the user's intent perspective.
Why a prefix matcher can't fix this by adding more patterns
You cannot enumerate the bypasses with more glob rules:
gitaccepts global options before the subcommand:-C <path>,-c <k=v>,--git-dir=,--work-tree=,--namespace=,--exec-path=,-P/--no-pager, etc., in any combination and order.- The invocation can be preceded by env-var assignments (
FOO=1 git ...). - The whole thing can be embedded in a compound command (
a && git push,a; git push,x | git ..., subshells,xargs, here-strings).
The matching has to be semantic (parse the command, resolve the actual program + subcommand) rather than textual.
Proposed fix
Make Bash permission matching git-aware (and, more generally, argv-aware) instead of raw-string-prefix:
- Tokenize the command, split on shell operators (
&&,||,;,|, newlines) into individual simple-commands. - For each simple-command, strip leading
VAR=valassignments, resolve the program name, and — for known multiplexer tools likegit,npm,docker,kubectl— skip recognized global options to find the real subcommand. - Match rules against the normalized
(program, subcommand, flags)rather than the literal string.
At minimum, git-aware normalization would close the most dangerous cases (push / reset --hard / rebase / commit --amend / --force / --no-verify).
Interim mitigations worth shipping regardless
- Document clearly that
Bash(...)rules are literal-prefix and do not survivegit -C, env prefixes, or command chaining — today this is a silent footgun. - Consider a built-in, semantic guard for the well-known destructive/irreversible git operations, independent of user glob rules.
Workaround I'm using now
A PreToolUse Bash hook (~/.claude/hooks/git-guard.js) that parses tool_input.command: splits on shell operators, skips git global options to find the real subcommand, and hard-denies push / commit --amend / reset --hard / rebase / --force/-f/--force-with-lease / --no-verify / --no-gpg-sign, plus any commit carrying Claude attribution. A hook can see the full command and apply real parsing, which the static prefix matcher cannot — but every user shouldn't have to write their own parser to make a guardrail sound.
Environment
- Claude Code, model
claude-opus-4-8[1m] - Platform: macOS (darwin 25.5.0)
- Observed: 2026-06-07
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗