[BUG] PreToolUse "if: Bash(foo*)" falsely matches Bash commands containing $()
Summary
PreToolUse hook if patterns falsely match Bash commands that contain $() command substitution in certain positions. #38017 / #32876 were about literal ( ) characters in quoted arguments and were fixed in v2.1.89; this is a different pattern-matcher path that handles $() command substitution syntax and is still broken on v2.1.109.
Previously filed as #42457, auto-closed as a duplicate of #38017 in error and auto-locked before re-triage.
Environment
$ claude --version
2.1.109 (Claude Code)
- OS: macOS (Darwin 24.6.0)
Configuration (.claude/settings.json)
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(foo*)",
"command": "echo 'HOOK_FIRED' >&2; exit 2"
}
]
}
]
},
"permissions": { "allow": ["Bash(*)"] }
}
The hook is meant to fire only for commands starting with foo. Observation point: when it fires, HOOK_FIRED appears on stderr and the tool call is rejected with exit code 2.
Results on v2.1.109
Reproduced in both bypassPermissions and acceptEdits permission modes. In default mode, $() is pre-rejected by a separate safety check before the hook is evaluated, so this specific hook-matching bug is not directly observable there.
| # | Command | Bash(foo*) match? |
|---|---------|---------------------|
| 1 | foo | Yes ✅ (expected yes) |
| 2 | echo hello | No ✅ |
| 3 | echo "text $(echo hello) text" | No ✅ |
| 4 | cat 'hello().txt' (control: #38017 case) | No ✅ |
| 5 | echo $(date) | Yes ❌ |
| 6 | echo --flag="$(echo value)" | Yes ❌ |
| 7 | echo "a" "$(echo b)" | Yes ❌ |
| 8 | ls "$(echo /tmp)" | Yes ❌ |
Row 4 is the control case: cat 'hello().txt' contains literal ( ) in a quoted argument — exactly the #38017 / #32876 class — and correctly does not match Bash(foo*) on v2.1.109. Rows 5–8 contain $() command substitution and incorrectly match the same pattern under the same config / same version. This isolates the bug from #38017 / #32876: literal ( ) in quoted arguments no longer match Bash(foo*), but $() command substitution still does.
Positional pattern
$()inside a single double-quoted interpolated string (row 3) → correctly not matched$()as a standalone argument (row 5), after=(row 6), or as a separate quoted arg (rows 7–8) → incorrectly matched
Impact
Benign commands containing $() (e.g. ls "$(git rev-parse --show-toplevel)") are incorrectly blocked in acceptEdits / bypassPermissions when hooks use patterns such as Bash(foo*).
Reproduction
- Create a scratch dir with
.claude/settings.jsoncontaining the config above. - Run:
````
claude -p --permission-mode acceptEdits 'Use the Bash tool with command: echo $(date)'
- Observe
HOOK_FIREDon stderr and the tool call rejected — despiteecho $(date)clearly not starting withfoo. - Replace the command with
cat 'hello().txt'to confirm the#38017control: same config, same version, no match, no block.
I have a dual-hook logging harness (records every attempted Bash command + whether each if condition matched) I can share on request.
Related observation
Using "matcher": "Bash(foo*)" at the outer level (instead of "matcher": "Bash" + inner "if": "Bash(foo*)") causes the hook to never fire, not even for bare foo. If matcher is intentionally tool-name-only, this may be a docs / load-time-validation gap rather than a pattern-matcher bug.
This issue has 5 comments on GitHub. Read the full discussion on GitHub ↗