[BUG] Hook `if` conditions for Bash fail open on $()/backticks even for command-name-only patterns
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
A hook if condition naming only a command — e.g. Bash(cat *) — fires on Bash commands that do not match it at all, as soon as the command contains a command substitution $(...) or a backtick.
The hooks reference documents the opposite for this exact case. Its Bash matching table contains these two rows:
| if pattern | Bash command | Hook runs? | Why (verbatim from docs) |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------ | ------------------------------- |
| Bash(rm *) | echo $(date) | no | "no subcommand matchesrm *" |
| Bash(git push *) | echo $(date) | yes | "patterns that specify more than the command name run the hook anyway on $(), backticks, or $VAR" | | |
On Windows the first row is not reproducible: the fail-open applies to command-name-only patterns too, so the distinction the table draws does not hold.
Measured with two PostToolUse spy hooks over two deterministic runs. CMDONLY_NEG is gated on Bash(cat *) while the executed command is always an echo, so it must never fire:
| command executed | markers written | verdict |
| --------------------------- | --------------------------------- | -------------------- |
| echo AAA plain text BBB | CMDONLY_POS | correct |
| echo AAA $HOME BBB | CMDONLY_POS | correct |
| echo AAA $(date) BBB | CMDONLY_NEG + CMDONLY_POS | unexpected |
| `echo AAA date BBB | CMDONLY_NEG + CMDONLY_POS` | unexpected |
Two notes that may help triage:
$VARbehaves better than documented. The docs list$VARamong the fail-open triggers, but$HOMEdiscriminates correctly here (row 2). So runtime and docs diverge in both directions: stricter than documented on$VAR, looser on command-name-only +$().- This is not just the "best-effort" caveat. The docs' own table gives
Bash(rm *)+echo $(date)as a no, so the caveat and the table contradict each other for this case. Either the table row or the implementation needs to change.
What Should Happen?
Per the documented matching table, a pattern naming only the command should discriminate even when the command contains a substitution:
Bash(cat *) must NOT fire on echo AAA $(date) BBB, because no subcommand matches cat *.
Fail-open should stay limited to the case the docs describe: patterns that specify more than the command name (e.g. Bash(git push *)).
If instead universal fail-open on $()/backticks is the intended behaviour regardless of pattern shape, then the fourth row of the table in the hooks reference is incorrect and should be fixed, and the docs should state plainly that any hook using if: "Bash(...)" must re-check tool_input.command inside the script itself.
Error Messages/Logs
markers.log after each run (the spy hook appends its tag)
# echo AAA plain text BBB
CMDONLY_POS
# echo AAA $HOME BBB
CMDONLY_POS
# echo AAA $(date) BBB
CMDONLY_NEG
CMDONLY_POS
# echo AAA `date` BBB
CMDONLY_NEG
CMDONLY_POS
Steps to Reproduce
- In an empty sandbox folder, create .claude/spy.sh:
#!/bin/bash
cat >/dev/null 2>&1
printf '%s\n' "$1" >> markers.log
exit 0
- Create .claude/settings.json with two PostToolUse spy hooks. CMDONLY_NEG is the test: it names a command (cat) that the executed command never uses.
{
"hooks": {
"PostToolUse": [
{ "matcher": "Bash", "hooks": [ { "type": "command",
"command": "bash .claude/spy.sh CMDONLY_NEG", "if": "Bash(cat *)" } ] },
{ "matcher": "Bash", "hooks": [ { "type": "command",
"command": "bash .claude/spy.sh CMDONLY_POS", "if": "Bash(echo *)" } ] }
]
}
}
- For each of the four commands below: empty markers.log, then have Claude Code run that command once, verbatim. Non-interactive form used here:
: > markers.log
claude -p --permission-mode bypassPermissions --allowedTools Bash \
"Run exactly this Bash command once, verbatim: echo AAA $(date) BBB"
Commands to test:
echo AAA plain text BBB
echo AAA $HOME BBB
echo AAA $(date) BBB
echo AAA `date` BBB
- Inspect markers.log after each run.
Expected: CMDONLY_NEG never appears.
Actual: CMDONLY_NEG appears for the $() and backtick cases. Deterministic across repeated runs.
Claude Model
None
Is this a regression?
No, this never worked
Last Working Version
_No response_
Claude Code Version
2.1.217 (Claude Code)
Platform
Anthropic API
Operating System
Windows
Terminal/Shell
VS Code integrated terminal
Additional Information
Environment: Windows 11; hooks in shell form are executed through Git Bash.
On the model field: left as None on purpose. The if matching happens in the harness before the hook runs, so the model is irrelevant to this behaviour. The matrix above was produced with fable[1m]; earlier runs of the same bench, covering the multi-token variants, used opus[1m] and matched bit-for-bit.
Context from the same test bench, in case it helps place this report: the bench has tracked two other if-field behaviours across roughly ten CLI versions —
- pipe alternation inside
if(e.g.Bash(a *) | Bash(b *)) never matches; later documented as unsupported, with separate handlers as the supported form; - forward-slash path patterns not matching Windows absolute paths — fixed in 2.1.176, issue #67610.
Practical impact: command substitution is very common in real commands, so a hook whose if matches nothing still runs on a large share of them. Every hook using if: "Bash(...)" therefore needs its own internal re-check of tool_input.command to avoid acting on unrelated commands. That requirement is not obvious from the current documentation, which presents the if field as a filter.
3 Comments
Still reproducible on 2.1.220 (Windows 11). Adding a follow-up measurement that changes what this bug costs in practice: the same fail-open happens in
PreToolUse, where theiffield is what decides whether a blocking hook gets invoked at all.The original report used
PostToolUsespies, so the observable damage was a hook running when it shouldn't — noise. I re-ran the same matrix with fourPreToolUsespies (bench extended to 44 spy hooks), one command per run,markers.logemptied before each:|
ifpattern | spy | matchesecho AAA …? || ------------------------------------- | ----------- | ------------------------------------- |
|
Bash(*)|PB_all| yes — catch-all control ||
Bash(echo AAA*)|PB_pos| yes — positive control ||
Bash(echo zzzpure*)|PB_neg| no — must never fire ||
Bash(echo AAA*) \| Bash(echo BBB*)|PB_pipe| unsupported syntax — must never fire || command executed | markers written | verdict |
| --------------------------- | -------------------------------- | ----------------------- |
|
echo AAA plain text BBB|PB_all PB_pos| correct ||
echo AAA $(date) BBB|PB_all PB_neg PB_pipe PB_pos| all four fire |PB_pipeis worth a note for triage: pipe alternation is not supported syntax insideif, and it still fires. That suggests the pattern is not being evaluated at all in the fail-open path, rather than being evaluated and matching too broadly.Why this is more than noise
In
PostToolUsea spurious invocation is wasted work. InPreToolUsetheiffield is the gate deciding when a blocking hook runs, so a hook written to guard one specific command is handed unrelated commands instead — including, in our case, destructive ones it was never meant to see. A hook that returnspermissionDecision: "deny"based on the assumption thatifalready narrowed the input can therefore deny the wrong call. The safe configurations are the two we ended up with: either noifat all, letting the script filter every time, or a full re-check oftool_input.commandinside the script. Both mean theiffield carries no security value on Bash, which is not what its documentation suggests.Scope — what is not affected
Same bench, same session, so this is a boundary and not a guess: on path-based tools the filter discriminates correctly on both events.
Write(**.py)andEdit(**.py)stay silent when the target is a.md; impossible patterns likeWrite(**zzznomatch**)never fire; and file content containing$(...)or backticks does not trigger the fail-open — so this is not a generic "input containing$()" problem, it is specific to the Bash command matcher.Read(*.pdf)(single wildcard) also matches and discriminates correctly.Reproduction
Identical to the original report, with the hook block moved to
PreToolUse:Expected:
PB_NEGnever appears. Actual: it appears whenever the command contains$(...)or a backtick. Deterministic across repeated runs, unchanged from 2.1.217 through 2.1.220.Thanks for the unusually precise report — the spy-hook matrix made this easy to verify.
Reproduced on Linux with the current release (2.1.233), so this isn't Windows-specific. Exact same results as your table:
Bash(cat *)correctly stays quiet forecho AAA plain text BBBandecho AAA $HOME BBB, but fires onecho AAA $(date) BBBand the backtick variant, deterministically.You're also right about the docs contradiction: the current hooks reference still lists
Bash(rm *)+echo $(date)→ "no", while the runtime applies substitution fail-open to command-name-only patterns too. And$VARis indeed stricter than documented. So implementation and docs disagree in both directions for these rows.Context on the behavior: when a command contains
$()/backticks, its effective subcommands can't be determined statically, so the matcher treats it as potentially matching any pattern and runs the hook — for a gating hook that's the conservative choice, since the hook script can still inspecttool_input.commandand decide. But that rationale isn't what the matching table documents, so either the implementation needs to honor the documented command-name-only distinction or the table needs to change and state plainly thatif: "Bash(...)"hooks must re-check the command in the script.Flagging for the team to decide which side (code or docs) should move. Until then, the reliable workaround is the one you identified: treat
ifas an optimization only, and re-checktool_input.commandinside the hook script.🤖 Generated with Claude Code
Hit the same fail-open through a third construct: ANSI-C quoting (
$'...'), not just$()/backticks.Setup: a
PreToolUsehook gated on"if": "Bash(git commit:*)", body denies unlessCOMMIT_SKILL=1is present.Repro — none of these contain
git,commit,$(), or a backtick, yet all fire the hook:A plain
echo hiorecho a | column -t(no ANSI-C quoting) does not fire it. So the fail-open isn't limited to command substitution — any construct the static matcher can't fully parse (here,$'...') appears to hit the same "can't determine effective subcommand, so match" path described in this issue for$()/backticks.Confirms the stated workaround is the only fix: treat
ifas an optimization only and re-checktool_input.commandinside the script itself. Worth broadening the issue title/description to cover ANSI-C quoting as another trigger, in case it's a distinct code path from the$()/backtick handling.