Summary
permissions.deny rules for Bash are matched against the program name of each
sub-command in the parsed AST. Before matching, Claude Code unwraps a fixed,
eight-item list of wrapper programs. Any other wrapper — an ordinary binary or
script on PATH that execs its arguments — shifts the program name, so no
Bash(<program> ...) rule applies to the command underneath it. Deny and allow
rules alike are silently skipped.
This is not the env-var prefix case (#31558, closed) and not the command-chaining
case (#4956). Compound splitting works correctly. The hole is the wrapper
allowlist itself, and I could not find it named in any existing issue.
Root cause
From ~/.local/share/claude/versions/2.1.258, function ax(). The complete set
of wrappers it strips before rule matching:
timeout time nice stdbuf nohup command builtin noglob
plus leading VAR=value assignments. There is no fallback and no attempt to
resolve what an unknown wrapper actually executes.
Reproduction
permissions.deny: "Bash(git add -A*)"
- Ask for
git add -A → correctly denied.
- Ask for
git add -A preceded by any wrapper binary on PATH that forwards
its arguments → runs. The deny rule is never consulted.
Compound forms are correctly caught, which shows the AST splitter is not the
problem: cd <dir>; git revert ..., rm -f <path>; git add -A ..., and
git add -A && git status were all denied in the same environment.
Observed impact
In one real session, git add -A was denied six times over several weeks. The
same operation then ran four times behind a wrapper, committing 103 unintended
files and 657,373 insertions to a working repository. The user had explicitly
configured that rule to prevent exactly this.
Denials: 2026-08-27T18:15:51Z, 2026-08-28T07:23:26Z, 07:42:37Z, 07:59:53Z,
09:35:12Z, 09:41:26Z.
Bypasses: 2026-09-06T16:57:28Z, 17:12:26Z, 18:44:10Z, 19:01:59Z.
Why this is worth fixing even though deny rules are "best effort"
The documented position is that deny rules are client-side filtering, useful
against accidents rather than a determined bypass. This case is squarely the
accident it is meant to catch: no adversary, no prompt injection, no attempt to
evade. The wrapper was adopted for an unrelated reason and silently disabled
every git rule in the config.
Related, and relevant: #49874 recommends putting commands behind a wrapper
script specifically so allowlist matching stops seeing them. The same mechanism
that makes that workaround function is what defeats the deny list.
Suggested direction
- When the program of a sub-command is not a known wrapper and is not on
PATH as a resolvable non-wrapper, treat the command as unmatched rather
than unmatchable — i.e. escalate to a prompt instead of falling through.
- Or: surface an explicit warning at config-load time that
Bash(...) rules
only bind the literal program name and can be trivially shifted.
- Or: check every token of a sub-command against deny rules, not only the
leading program.
At minimum, document the eight-item unwrap list. Users writing deny rules today
have no way to know it exists or how short it is.
Environment
- Claude Code 2.1.258
- Linux
- Permission prompts suppressed for the session, so an unmatched command ran
silently rather than prompting. The bypass itself does not depend on that;
in a prompting session the same command would have asked instead of being
refused outright.
Summary
permissions.denyrules for Bash are matched against the program name of eachsub-command in the parsed AST. Before matching, Claude Code unwraps a fixed,
eight-item list of wrapper programs. Any other wrapper — an ordinary binary or
script on
PATHthat execs its arguments — shifts the program name, so noBash(<program> ...)rule applies to the command underneath it. Deny and allowrules alike are silently skipped.
This is not the env-var prefix case (#31558, closed) and not the command-chaining
case (#4956). Compound splitting works correctly. The hole is the wrapper
allowlist itself, and I could not find it named in any existing issue.
Root cause
From
~/.local/share/claude/versions/2.1.258, functionax(). The complete setof wrappers it strips before rule matching:
plus leading
VAR=valueassignments. There is no fallback and no attempt toresolve what an unknown wrapper actually executes.
Reproduction
permissions.deny:"Bash(git add -A*)"git add -A→ correctly denied.git add -Apreceded by any wrapper binary onPATHthat forwardsits arguments → runs. The deny rule is never consulted.
Compound forms are correctly caught, which shows the AST splitter is not the
problem:
cd <dir>; git revert ...,rm -f <path>; git add -A ..., andgit add -A && git statuswere all denied in the same environment.Observed impact
In one real session,
git add -Awas denied six times over several weeks. Thesame operation then ran four times behind a wrapper, committing 103 unintended
files and 657,373 insertions to a working repository. The user had explicitly
configured that rule to prevent exactly this.
Denials: 2026-08-27T18:15:51Z, 2026-08-28T07:23:26Z, 07:42:37Z, 07:59:53Z,
09:35:12Z, 09:41:26Z.
Bypasses: 2026-09-06T16:57:28Z, 17:12:26Z, 18:44:10Z, 19:01:59Z.
Why this is worth fixing even though deny rules are "best effort"
The documented position is that deny rules are client-side filtering, useful
against accidents rather than a determined bypass. This case is squarely the
accident it is meant to catch: no adversary, no prompt injection, no attempt to
evade. The wrapper was adopted for an unrelated reason and silently disabled
every git rule in the config.
Related, and relevant: #49874 recommends putting commands behind a wrapper
script specifically so allowlist matching stops seeing them. The same mechanism
that makes that workaround function is what defeats the deny list.
Suggested direction
PATHas a resolvable non-wrapper, treat the command as unmatched ratherthan unmatchable — i.e. escalate to a prompt instead of falling through.
Bash(...)rulesonly bind the literal program name and can be trivially shifted.
leading program.
At minimum, document the eight-item unwrap list. Users writing deny rules today
have no way to know it exists or how short it is.
Environment
silently rather than prompting. The bypass itself does not depend on that;
in a prompting session the same command would have asked instead of being
refused outright.