[BUG] PreToolUse hook `if: "Bash(...)"` still false-positives on `$()` command substitution in 2.1.202

Status Open
Reported on v2.1.202
Maintainer reply ✓ Yes — bcherny
Activity 4 comments · opened Jul 7, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

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 hook if: "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 planned by 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.163 changelog entry claiming this was fixed.

View original on GitHub ↗

3 Comments

stefanobaghino · 1 month ago

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 the 2.1.163 fix does not cover.

Setup

Five PreToolUse hooks on matcher: "Bash", each gated by an if pattern of the shape Bash(git commit *), Bash(gh pr create *), Bash(gh issue create *), etc., each injecting additionalContext. Evidence below comes from mapping the transcript's hook_success attachment records (which carry the toolUseID) back to the corresponding tool_use commands, 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 — the 2.1.163 fix holds for bare $VAR but not the brace form |
| $(…) command substitution | F=$(ls "$D"/*.jsonl \| head -1) | yes | ❌ the originally reported case, still present |
| for loop + 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.jsonl then rg -c 'x' "$F" | no | ✅ the 2.1.163 fix 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:

  • Whenever the filter misfires, all five differently-patterned if hooks 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.
  • The heredoc row shows the parser already handles one hard case correctly, so the failures look like specific unhandled expansions (~ 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/gh anywhere) fired the full five-hook battery. Each firing injected all five additionalContext payloads (~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.

stefanobaghino · 1 month ago

Bisected the unexplained row from the previous comment (for loop + glob + "$VAR.suffix") on 2.1.220 (macOS, Darwin 25.5.0). The result inverts the suspicion: the glob and the "$VAR.suffix" interpolation are innocent — the for loop itself trips the filter, and, unexpectedly, a "$VAR.suffix" interpolation in the loop body suppresses the false positive.

Setup

Single PreToolUse hook, matcher: "Bash", if: "Bash(git commit *)", whose command is cat >> fired.jsonl — every firing records the exact tool_input.command it 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 hi doesn't.

Observations

  • The for keyword looks like another route into the same match-all bail-out already suspected in the earlier comments (on every false positive, differently-patterned if hooks all fire together).
  • Row 6 is the interesting one for locating the bug: an otherwise-firing loop is rescued by a "$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.
  • Severity note beyond token waste: a common shape for these gated hooks is a permissionDecision: "allow" + additionalContext response. On a false positive, that combination silently auto-approves an unrelated command — the if filter's blast radius includes permission decisions, not just injected context.
bcherny collaborator · 15 days ago

Thanks for the very thorough reports. I re-ran your marker-hook setup on 2.1.233 (macOS) with if: "Bash(git commit*)": echo hi and echo "$HOME" skipped the hook; echo $(date), for F in a b; do echo "$F"; done, echo "${HOME}", and F=~/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 if filter 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 that Bash(git *) does skip echo $(date). Multi-word patterns like Bash(git commit*), and constructs like for loops, ${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, simple for loops, 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 — inspect tool_input.command inside the hook — is the reliable option.

🤖 Generated with Claude Code

Showing cached comments. Read the full discussion on GitHub ↗