Gate tool calls with the Claude Code PreToolUse hook
Most Claude Code hooks react to what happened. PreToolUse is special: it runs before Claude executes a tool, so it is the one event that can look at a tool call and decide what happens next.
When it fires
PreToolUse fires immediately before Claude runs any tool: Bash, Edit, Write, Read, and the rest, including tools provided by MCP servers. The event payload includes the tool name and the input Claude is about to use, so your command can inspect exactly what is about to run before anything touches your machine.
Matching tools
Unlike lifecycle events, PreToolUse uses a matcher, a regular expression tested against the tool name, so you can target one tool or several:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "cat >> ~/claude-bash.jsonl" }
]
},
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "echo 'about to change a file'" }
]
}
]
}
}
The first matcher logs every shell command Claude is about to run to a file; the second reacts whenever a file is about to be edited or written. mcp__.* catches every tool coming from an MCP server, and .* catches everything.
Inspecting the payload
Because PreToolUse receives the tool input on stdin, you can read it and act on it. Logging the raw JSON is the fastest way to see the shape:
{
"hooks": {
"PreToolUse": [
{
"matcher": ".*",
"hooks": [{ "type": "command", "command": "cat >> ~/claude-pretool.jsonl" }]
}
]
}
}
Watch that file while you work and you will see the base fields (session_id, cwd, hook_event_name) plus the two that matter here: tool_name, and tool_input, an object whose shape depends on the tool. For Bash it carries command and description; for Edit and Write it carries file_path and the content. Those are the fields you match on for your own hook.
Blocking a call
There are two ways to stop a tool call.
The blunt one is the exit code: exit with 2 and Claude Code cancels the call, feeding whatever your script printed on stderr back to Claude as the reason. Claude then reads it and usually adapts, which is why the message is worth writing for a reader.
#!/usr/bin/env bash
# .claude/hooks/guard.sh
payload=$(cat)
cmd=$(echo "$payload" | jq -r '.tool_input.command // ""')
if echo "$cmd" | grep -qE '(^|[;&|]\s*)rm\s+-rf\s+/'; then
echo "Blocked: rm -rf on an absolute path is never allowed here." >&2
exit 2
fi
exit 0
Wire it up, and give it a short timeout so a slow guard never stalls a session:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/guard.sh", "timeout": 5 }
]
}
]
}
}
The precise one is a JSON decision printed on stdout. It lets you deny with a reason, allow outright (skipping the permission prompt you would normally get), or force a prompt on something that would otherwise pass:
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"secrets live in .env, read .env.example instead"}}'
Swap deny for allow or ask as needed. Because allow bypasses the usual prompt, keep those rules narrow.
Guarding a path
The other common guard is a file one. This blocks any write to your environment files, whatever the tool:
#!/usr/bin/env bash
payload=$(cat)
path=$(echo "$payload" | jq -r '.tool_input.file_path // ""')
case "$path" in
*.env|*.env.*|*/secrets/*)
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"this path is off limits"}}'
;;
esac
exit 0
Match it on Edit|Write|Read and it covers reading as well as writing.
What your exit code means
Because PreToolUse can change the outcome, its exit codes are worth memorising:
| Exit code | Effect |
|---|---|
0 | The call proceeds. stdout is available for a JSON decision. |
2 | The call is cancelled and stderr is sent back to Claude as the reason. |
| anything else | Non-blocking error: you see stderr, the call proceeds anyway. |
That last row catches people out. A guard script that crashes on a missing jq exits with 127, which is not a block, so the tool call runs. If your hook is a real guardrail, make it fail closed: check its dependencies at the top and exit 2 if they are missing.
Auditing what an agent actually ran
Blocking is the dramatic use; the quiet one is the record. Log every shell command to a file and you can answer "what did it do?" long after the session is gone:
#!/usr/bin/env bash
payload=$(cat)
jq -c '{ts: now, session: .session_id, cwd: .cwd, cmd: .tool_input.command}' <<< "$payload" >> ~/claude-audit.jsonl
exit 0
Then read it back with the same tool: jq -r 'select(.cmd) | .cmd' ~/claude-audit.jsonl | sort | uniq -c | sort -rn shows what your agents actually spend their time running, which is usually the fastest way to work out which commands belong in your permissions.allow list.
Hook or permission rule?
A lot of this overlaps with the permissions.deny list in your settings.json, and when a static rule is enough, the rule wins: it is declarative, reviewable and costs nothing at runtime. Reach for PreToolUse when the decision needs logic: inspecting the actual shell command, checking the current branch, calling out to your own policy service, or writing an audit trail of everything an agent tried.
Use it for guardrails, keep it fast
PreToolUse is where teams add lightweight guardrails: log every shell command for an audit trail, warn on writes to a sensitive path, or record what an agent touches. Keep the logic in a small script you control rather than a long inline command, keep it fast (it runs on the critical path of every tool call), and review project-level PreToolUse hooks like any other code: they run automatically, with your permissions. Remember too that hooks are snapshotted at session start, so restart Claude Code after changing your guard.
See what each session is doing, not just log it
A PreToolUse hook is great for a record. But if what you want is to see that a session is actively working (running tools right now versus sitting idle), Blooby already reads this event, and every other, for you and shows it as a busy, animated mascot per session. No matchers, no scripts: it sets up its own hooks and reflects the state live.
See it for yourself
download Get Blooby free