[FEATURE] Support argument-level matching in PreToolUse hook matchers (e.g. Bash commands)
Feature Request
Problem
Currently, PreToolUse hook matchers can only match on the tool name (e.g., Bash, Edit, Read). There is no way to filter hooks based on the tool's input arguments — for example, matching only git add commands within the Bash tool.
This means every Bash command (including ls, pwd, cat, git status) triggers the hook, and the hook script must filter internally. This adds unnecessary overhead and complexity for plugin developers.
Tested Matcher Patterns (None Work)
I conducted thorough testing with 21 different matcher patterns. All argument-level patterns fail silently — no error, no warning, hooks simply never fire.
| Matcher | Intent | Result |
|---------|--------|--------|
| Bash(git add.*) | Regex in parens | Never fires |
| Bash(git add *) | Glob in parens | Never fires |
| Bash(git add .+) | Regex+ in parens | Never fires |
| git add | Direct command string | Never fires |
| bash (lowercase) | Case-insensitive attempt | Never fires |
| BASH (uppercase) | Case-insensitive attempt | Never fires |
Working Patterns (Tool Name Only)
| Matcher | Description | Works? |
|---------|-------------|--------|
| Bash | Exact tool name | ✅ |
| Edit | Exact tool name | ✅ |
| Edit\|Write | Pipe-separated | ✅ |
| Bas.* | Regex on tool name | ✅ |
| [BE].* | Character class | ✅ |
| ^Bash$ | Anchored regex | ✅ |
| .* | Match-all regex | ✅ |
| * | Wildcard catch-all | ✅ |
| "" (empty) | Empty matcher | ✅ |
Test Methodology
- 21 PreToolUse hooks configured in
.claude/settings.local.json - Each hook runs a logging script that records matcher label, tool name, and input to
/tmp/hook-test-results.log - Tools triggered:
Bash(git status, git add ., cat),Read,Glob,Grep,Write,Edit - Same behavior observed in both project-level hooks (
.claude/settings.local.json) and plugin-level hooks (hooks/hooks.json)
<details>
<summary>Test logging script</summary>
#!/bin/bash
LOGFILE="/tmp/hook-test-results.log"
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name // "unknown"')
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // .tool_input.file_path // .tool_input.pattern // "none"')
echo "[$(date +%H:%M:%S)] FIRED matcher='$1' tool=$TOOL input='$CMD'" >> "$LOGFILE"
exit 0
</details>
<details>
<summary>Sample log output showing only tool-name matchers fire</summary>
[00:09:21] FIRED matcher='T09-regex-Bas-star' tool=Bash input='git status'
[00:09:21] FIRED matcher='T11-star-wildcard' tool=Bash input='git status'
[00:09:21] FIRED matcher='T19-anchored-Bash' tool=Bash input='git status'
[00:09:21] FIRED matcher='T12-empty-string' tool=Bash input='git status'
[00:09:21] FIRED matcher='T10-regex-match-all' tool=Bash input='git status'
[00:09:21] FIRED matcher='T18-charclass-BE' tool=Bash input='git status'
[00:09:21] FIRED matcher='T20-pipe-all-tools' tool=Bash input='git status'
[00:09:21] FIRED matcher='T01-exact-Bash' tool=Bash input='git status'
Note: T13 (Bash(git add.*)), T14 (Bash(git add *)), T15 (Bash(git add .+)), T16 (bash), T17 (BASH), T21 (git add) — none appear in any log output.
</details>
Proposed Solution
Support an optional argument pattern in matchers using the ToolName(pattern) syntax:
{
"matcher": "Bash(^git add)",
"hooks": [{
"type": "command",
"command": "./hooks/validate-git-add.sh",
"timeout": 5
}]
}
Where the pattern inside parentheses is matched against tool_input.command (for Bash) or tool_input.file_path (for Edit/Write/Read).
Additional patterns that would be useful:
"Bash(^git (add|commit))" // Match git add and git commit
"Bash(^npm |^yarn |^pnpm )" // Match package manager commands
"Edit(.*\\.py$)" // Match Python file edits only
"Read(.*\\.env.*)" // Match reading env files
Current Workaround
Use broad "Bash" matcher and filter inside the hook script:
#!/bin/bash
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
# Fast exit for non-matching commands
if ! echo "$CMD" | grep -qE "^git add"; then
exit 0
fi
# Run actual validation
# ...
This works but has downsides:
- Performance: Hook fires on every Bash command (~200+ times per session)
- Complexity: Every hook script must implement its own command filtering
- Plugin overhead: Plugin hooks like guard scripts run on every
ls,cat,pwd
Secondary Issue: Silent Failure
Invalid matchers like Bash(git add.*) produce no error or warning at session start or at hook evaluation time. This makes debugging extremely difficult. A warning would help:
Warning: Hook matcher "Bash(git add.*)" contains unsupported argument pattern.
Matchers only support tool name regex. See docs: https://code.claude.com/docs/en/hooks
Environment
- Platform: Linux (Fedora 6.18.7-200.fc43.x86_64)
- Tested in: Project-level hooks (
.claude/settings.local.json) and plugin-level hooks (hooks/hooks.json)
This issue has 4 comments on GitHub. Read the full discussion on GitHub ↗