[BUG] PreToolUse hook `if: "Bash(...)"` still false-positives on `$()` command substitution in 2.1.202
Environment
- Claude Code CLI:
2.1.202 - OS: macOS (Darwin 25.5.0)
Summary
A PreToolUse hook with if: "Bash(git commit*)" still fires on Bash commands that contain command substitution $(...) but no git token anywhere — e.g. echo $(date). The v2.1.163 changelog claims this class of false-positive was fixed:
Fixed hookif: "Bash(...)"conditions firing on every Bash command containing$()or$VAR; the pattern now matches against commands inside subshells and backticks too
On 2.1.202 the fix only holds for the $VAR case. Plain variable references are now correctly parsed and skipped, but command substitution $(...) still trips the filter into a spurious match. This is the same underlying behavior reported in #63066 (closed as not planned by the inactivity bot, not fixed) and is what makes the if semantics in #65501 (docs) observably wrong for $().
Minimal reproducer
~/.claude/settings.json (single hook, no external script — writes a marker file only when the filter matches):
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git commit*)",
"command": "touch /tmp/hook-fired"
}
]
}
]
}
}
Start a session and have Claude run each command as a Bash tool call, deleting the marker before each:
| # | Command | /tmp/hook-fired created? | Correct? |
|---|---------|---------------------------|----------|
| 1 | echo hi | no | ✅ correct — no git |
| 2 | echo "$HOME" | no | ✅ correct — $VAR, fixed in 2.1.163 |
| 3 | echo $(date) | yes | ❌ bug — no git token; $() trips the filter |
| 4 | git commit -m x | yes | ✅ correct — genuine match |
Expected
if: "Bash(git commit*)" matches only when a parsed subcommand actually starts with git commit. echo $(date) has one inner subcommand, date, which does not match, so the hook should be skipped — exactly as the 2.1.163 changelog says (“matches against commands inside subshells and backticks too”).
Actual
The hook fires on any command containing $(...) (and backticks) when the if pattern is more specific than a bare command name, regardless of whether git appears at all.
Evidence (debug log, --debug, this exact behavior on 2.1.202)
The auto-mode classifier logs the command each hook decision applies to. For echo $(date), the Bash(git commit*) hook fired with no skip line:
[auto-mode] new action being classified: {"Bash":"echo $(date)", ...}
...
[DEBUG] Hook PreToolUse (.../hygiene.sh commit) provided additionalContext (1132 chars)
For commands with a plain $VAR (f="..."; grep ... "$f"), the same hook was correctly skipped:
[DEBUG] Skipping hook due to if condition "Bash(git commit*)" not matching
The Skipping ... not matching line appears for every $VAR-only command and never for the $(...) command.
Impact
The if filter is documented as a precise filter, not a security boundary, so failing permissive on $() is surprising. In practice it fires hooks (and, for permissionDecision hooks, gates tool calls) on a large fraction of ordinary commands, since $(...) is extremely common. It is especially bad for hooks that inspect PR/commit bodies, whose content routinely contains $(...) and backticks in code snippets.
Workaround
Drop if and inspect tool_input.command from the hook's stdin, matching git/gh prefixes in the script itself.
Related
- #63066 — same behavior on 2.1.121, closed as
not plannedby the inactivity bot (reproducer #4 is the nested-$()case). - #65501 — docs for
if: "Bash(...)"matching; the$()example there is exactly the case still misbehaving. v2.1.163changelog entry claiming this was fixed.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Still reproduces on
2.1.216(macOS, Darwin 25.5.0), and classifying every Bash tool call from one real session against the hook firings shows the false-positive surface is wider than$(): two more constructs trip the filter that the2.1.163fix does not cover.Setup
Five
PreToolUsehooks onmatcher: "Bash", each gated by anifpattern of the shapeBash(git commit *),Bash(gh pr create *),Bash(gh issue create *), etc., each injectingadditionalContext. Evidence below comes from mapping the transcript'shook_successattachment records (which carry thetoolUseID) back to the correspondingtool_usecommands, so every fired/skipped classification is for a command actually executed in the session, not a synthetic probe.Results (26 Bash calls, 13 fired, 0 genuine matches)
| Construct in command | Example (abridged from session) | Hook fired? | Correct? |
|---|---|---|---|
| Tilde expansion in a variable assignment |
F=~/.claude/projects/foo.jsonl; jq -r '…' "$F"| yes | ❌ new — not covered by this issue's$()case; accounted for 8 of the 13 misfires ||
${…}brace expansion |while read -r ln; do sed -n "${ln}p" "$F"; done| yes | ❌ new — the2.1.163fix holds for bare$VARbut not the brace form ||
$(…)command substitution |F=$(ls "$D"/*.jsonl \| head -1)| yes | ❌ the originally reported case, still present ||
forloop + glob +"$VAR.suffix"interpolation |for F in "$S.jsonl" "$S"/agent-*.jsonl; do …; done| yes | ❌ fired with none of the three constructs above present; suspect is the loop, the glob, or the"$VAR.suffix"form || Plain
$VAR/"$VAR"only |F=/abs/path/foo.jsonlthenrg -c 'x' "$F"| no | ✅ the2.1.163fix holds || Bare
~as an argument |cat ~/.claude/hooks/foo.sh| no | ✅ || Quoted heredoc whose body contains
$(), backticks,${}|python3 << 'EOF' … EOF| no | ✅ heredoc bodies are correctly excluded |Two observations that may help locate the bug:
ifhooks fire together — the patterns never discriminate among themselves on a false positive. That is consistent with a single shared bail-out ("can't statically resolve the command, treat as match-all") rather than five independent pattern mismatches.~in assignments,${…}) plus the known$()path, not a blanket "any special character" fallback.Impact measured on this session
13 of 26 ordinary Bash calls (transcript/JSON forensics one-liners, no
git/ghanywhere) fired the full five-hook battery. Each firing injected all fiveadditionalContextpayloads (~19 KB per event). Summed over the session: ~63K tokens injected, amplifying to ~1.4M cache-read tokens as the injections persisted in context across subsequent requests — roughly 6–8% of the session's total usage, at a 0% true-positive rate.Bisected the unexplained row from the previous comment (
forloop + glob +"$VAR.suffix") on2.1.220(macOS, Darwin 25.5.0). The result inverts the suspicion: the glob and the"$VAR.suffix"interpolation are innocent — theforloop itself trips the filter, and, unexpectedly, a"$VAR.suffix"interpolation in the loop body suppresses the false positive.Setup
Single
PreToolUsehook,matcher: "Bash",if: "Bash(git commit *)", whose command iscat >> fired.jsonl— every firing records the exacttool_input.commandit fired for, so each classification below is a real Bash tool call from a throwaway session, not an inference. The anomalous rows were each reproduced in two independent sessions.Results
| # | Command | Hook fired? | Correct? |
|---|---|---|---|
| 1 |
for F in a b; do echo "$F"; done| yes | ❌ minimal case — no glob, no expansion, no substitution; reproduced twice || 2 |
for F in *.jsonl; do echo "$F"; done| yes | ❌ glob adds nothing || 3 |
S=abc; for F in a b; do echo "$F"; done| yes | ❌ leading assignment doesn't suppress || 4 |
for F in "$S.x" b; do echo "$F"; done| yes | ❌"$VAR.suffix"in the word list doesn't suppress || 5 |
S=abc; for F in "$S.jsonl" "$S"/agent-*.jsonl; do echo "$F"; done| yes | ❌ the originally reported composite || 6 |
S=abc; for F in a b; do echo "$S.x"; done| no | ⁉️ same loop as row 3, but"$VAR.suffix"in the body — suppressed; reproduced twice || 7 |
echo *.jsonl| no | ✅ bare glob alone is fine || 8 |
S=abc; echo "$S.jsonl"| no | ✅"$VAR.suffix"alone is fine || 9 |
git commit --allow-empty -m "Add positive control"| yes | ✅ genuine match |Legacy triggers re-checked with the same marker hook, all still present on
2.1.220:echo $(date)fires,F=~/tmp/x.txt; ls "$F"fires,echo "${HOME}"fires,echo hidoesn't.Observations
forkeyword looks like another route into the same match-all bail-out already suspected in the earlier comments (on every false positive, differently-patternedifhooks all fire together)."$VAR.suffix"reference in the body, while the same token in the loop's word list (row 4) rescues nothing. Whatever code path successfully resolves"$VAR.suffix"(row 8) appears to override the loop bail-out only when the token occurs in command position.permissionDecision: "allow"+additionalContextresponse. On a false positive, that combination silently auto-approves an unrelated command — theiffilter's blast radius includes permission decisions, not just injected context.Thanks for the very thorough reports. I re-ran your marker-hook setup on 2.1.233 (macOS) with
if: "Bash(git commit*)":echo hiandecho "$HOME"skipped the hook;echo $(date),for F in a b; do echo "$F"; done,echo "${HOME}", andF=~/tmp/x.txt; ls "$F"all fired it. So the behavior you describe is still present.It is currently intended, though, and documented rather than a regression: the
iffilter is best-effort and deliberately fails open (runs the hook) whenever it can't fully resolve what bash will execute. The 2.1.163 fix narrowed that only for patterns that constrain nothing beyond the command name (Bash(git *)/Bash(git:*)), and I confirmed on 2.1.233 thatBash(git *)does skipecho $(date). Multi-word patterns likeBash(git commit*), and constructs likeforloops,${VAR}, and~in assignments, still take the fail-open path. The docs table spells out the$()case: https://code.claude.com/docs/en/hooks#bash-if-matching (row "Bash(git push *)/echo $(date)/ yes").We agree this is confusing given how common
$()and${}are, and the token/permission blast radius you measured is a fair point. We're looking at whether the resolvable cases (${VAR},~in assignments, simpleforloops, and multi-word prefixes when the inner commands are all plain words) can be matched precisely without reopening obfuscation bypasses. Until then the workaround you found — inspecttool_input.commandinside the hook — is the reliable option.🤖 Generated with Claude Code