[BUG] Bash deny rules bypassed by any wrapper program outside ax()'s 8-item unwrap list
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 noBash(<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 -Apreceded by any wrapper binary onPATHthat 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 ..., andgit 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.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗