[BUG] PreToolUse "if: Bash(foo*)" falsely matches Bash commands containing $()

Resolved 💬 5 comments Opened Apr 15, 2026 by tdksk Closed Jun 3, 2026

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

  1. Create a scratch dir with .claude/settings.json containing the config above.
  2. Run:

``
claude -p --permission-mode acceptEdits 'Use the Bash tool with command: echo $(date)'
``

  1. Observe HOOK_FIRED on stderr and the tool call rejected — despite echo $(date) clearly not starting with foo.
  2. Replace the command with cat 'hello().txt' to confirm the #38017 control: 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.

View original on GitHub ↗

This issue has 5 comments on GitHub. Read the full discussion on GitHub ↗