TL;DR Put
"if": "Bash(git commit *)"on the hook entry and Claude Code never spawns the process when the command doesn't match. It's the sameTool(pattern)syntax as your permission rules, one rule per entry, and with the tool name inside you can dropmatcherentirely. Watch where you put it:ifis only evaluated on the five tool events. On aStopor aSessionStartthat hook isn't skipped once, it's gone for good, and without--debug hooksnothing tells you.
You have a PreToolUse hook on Bash. It fires on every call: every ls, every cat, every two-word echo. The script reads the JSON, sees this one isn't its business and exits 0. You started a whole process to do nothing, and you do it dozens of times a session.
There's a field that cuts it off before any of that happens.
The result, from a real session with two commands back to back:
> run git status --short, then echo hola
# hook.log
{"hook_event_name":"PreToolUse","tool_name":"Bash",
"tool_input":{"command":"git status --short", ...}}
# One entry. The echo never launched anything.
How it works
if lives on the hook entry, next to type and command. It takes a permission rule in the Tool(pattern) syntax your allowlist already uses, the same one behind deny, allow and ask rules.
Claude Code evaluates it while working out which hooks match the call, before spawning the process. When the rule doesn't match there is no fork, no interpreter starting up, no stdin to write. The difference against a script that reads the JSON and bails early isn't milliseconds: one of the two never exists.
One rule per entry. No &&, no ||. Two conditions means two entries.
Setting it up
1. Filter by command
{
"hooks": {
"PreToolUse": [
{
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-commit.sh",
"if": "Bash(git commit *)"
}
]
}
]
}
}
Note what's missing: matcher. Once the tool name sits inside if, the matcher has nothing left to do.
2. Filter by path
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/lint.sh",
"if": "Write(src/**)"
}
For Write and Edit the pattern matches the file path, relative to your working directory. A Write to src/a.ts fires the hook; one to b.ts at the root doesn't. Since version 2.1.214 src/** means the src at your root and everything under it; for a folder named src at any depth, the pattern is **/src/**.
3. No parentheses, the whole tool
{ "type": "command", "command": "...", "if": "Bash" }
With no rule content, if matches every call to that tool. Handy when you want the whole filter written on the entry instead of split between matcher and if.
The five events where if is evaluated
if is only evaluated on five events: PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest and PermissionDenied. Those are the ones carrying a tool call to match against.
On anything else (Stop, SessionStart, Notification, PreCompact) the entry isn't skipped this once. It's dropped outright, every time, silently. Your hook is written, the JSON is valid, and it never runs.
Seeing it takes asking for it. Start the session with --debug hooks, then look for the line in that session's log:
grep "if condition" ~/.claude/debug/<session-id>.txt
2026-09-10T19:04:47.314Z [DEBUG] Hook if condition "Bash(git *)" cannot be evaluated for non-tool event Stop
If your Stop hook isn't firing, check for an if before you go debugging the script.
if filters, it doesn't protect
With Bash the filter is best-effort. Claude Code takes the command apart to see what actually runs, and when it can't tell, it fires the hook anyway: $TOOL git push triggers a Bash(git *) condition because nothing can know what that variable expands to. A pattern carrying more than the command name also fires on $(), backticks or variables.
The docs spell out the consequence: because the filter is best-effort, use the permission system rather than a hook to enforce a hard allow or deny. if is a performance tool.
Reference
| Piece | Value |
|---|---|
| Events where it's evaluated | PreToolUse · PostToolUse · PostToolUseFailure · PermissionRequest · PermissionDenied |
| Syntax | One permission rule: Bash(git *), Edit(*.ts), Write(src/**), or the bare name Bash |
| Combinations | None. One rule per entry; two conditions, two entries |
| Where it works | Every hook source: user, project and local settings.json, a plugin's hooks/hooks.json, and skill and subagent frontmatter |
| Hook types | command, http, mcp_tool, prompt, agent |
Official docs: Intercept and control agent behavior with hooks
Don't mix it up with conditional rules in .claude/rules/: those decide what enters your context, this decides whether a process gets spawned. It's the piece plain old hooks were missing, where matcher can only filter by tool name. It's also the same idea as the narrow matcher that auto-approves just the plan, one notch finer: there you scope by tool, here by what that tool is about to do.
Requirements
- Verified on Claude Code 2.1.267. The
src/**change landed in 2.1.214.