PreToolUse Hooks: Gating Before the Action
A PreToolUse hook is the last checkpoint before a tool runs — where you allow, ask, or deny with a contract the model cannot reason its way past.
From Knowing About Hooks to Engineering Them
A quick recap of the foundation from the skills-hooks-commands track: hooks intercept the agent loop at three injection points — PreToolUse (before a tool runs), PostToolUse (after a tool succeeds), and Stop (when the agent believes it is finished) — and the simplest interface at every point is the exit code: exit 0 lets the action proceed, exit 2 blocks it with the reason on stderr. If that paragraph read as new information, walk that track first; this lesson builds directly on it. From here we go one level deeper on the one hook that can actually stop a bad thing from happening: PreToolUse. It fires in the half-second between Claude deciding to run a tool and the tool actually running. That window is the only place in the entire agent loop where prevention is still possible. After the tool runs, you are doing cleanup. Before it runs, you are doing engineering.
The mastery move is to stop thinking of PreToolUse as "the blocking hook" and start thinking of it as a gate with three verdicts: allow, ask, deny. A skill author who only knows allow/deny ships gates that are too blunt — they either nag the human on every write or wave through things that should have been questioned. The three-verdict model is what makes a gate usable in daily work.
The Data That Reaches Your Hook
When Claude proposes a tool call, the runtime serializes it to JSON and pipes it to your hook's standard input. Your script reads it, inspects it, and returns a verdict. It is pure interception: read-only, fast, no network.
The payload shape is the thing to internalize, because every gating decision is a query against it:
{
"tool_name": "Write",
"tool_input": { "file_path": "src/auth/session.ts", "content": "..." }
}
The two load-bearing fields are tool_name (which tool) and tool_input (the proposed arguments). A gate for protected paths reads tool_input.file_path. A gate against secrets reads tool_input.content. A gate against a dangerous shell command reads tool_input.command. Each tool has a different input schema — the Write tool exposes content at tool_input.content, the Edit tool at tool_input.new_string — so a hook written for one tool will silently miss another. Match precisely, parse precisely.
Here is the minimal, correct skeleton. Notice it does the defensive parse first and fails open on anything unexpected:
#!/usr/bin/env bash
# ~/.claude/hooks/protect-paths.sh — PreToolUse, matcher "Write|Edit"
input="$(cat)"
# Fail open: a parse failure must never block legitimate work.
path="$(printf '%s' "$input" | jq -r '.tool_input.file_path // ""' 2>/dev/null)"
[ -z "$path" ] && exit 0
# Confirmed threat → deny. Everything else → allow.
case "$path" in
*.env|*/secrets/*|*/.git/*)
echo "BLOCKED: $path is a protected path. Edit it by hand, not through the agent." >&2
exit 2 ;;
esac
exit 0
And the wiring that makes it fire — this lives in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/protect-paths.sh" }]
}
]
}
}
The matcher is doing real work here. It is a regex against tool_name, and it decides whether your hook even runs. "Write|Edit" means the hook is invisible to Bash calls — they never pay the cost of running it. Get the matcher wrong and you either guard nothing or guard everything.
The Three-Verdict Gate
The exit-code contract you learned is the floor, not the ceiling. PreToolUse supports a richer JSON contract on stdout, and the difference between a crude gate and a professional one is whether it uses the middle verdict.
- Allow —
exit 0, or the explicit JSON form{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}on stdout. The tool runs untouched. This must be the answer for the overwhelming majority of calls. A gate that denies often is a broken gate; it trains the human to bypass it. - Ask —
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"..."}}on stdout. The runtime pauses, shows your reason, and waits for human approval. This is the verdict for actions that are plausibly correct but expensive to get wrong: writing to a config file, deleting a migration, a force-push. You are not blocking the human — you are inserting a deliberate beat. The envelope matters: the runtime only honorspermissionDecisioninsidehookSpecificOutput— unrecognized JSON on stdout with exit 0 is treated as a plain allow, so a malformed ask verdict fails open silently. - Deny —
exit 2, with the reason on stderr. The tool never runs. Claude receives your stderr text as the reason it must change course. No amount of model reasoning overrides it. Reserve this for the things you would be devastated to see happen even once: a push to main, a secret committed to a file, anrm -rfoutside a scratch directory.
The engineering judgment is matching verdict to consequence. Block too much and you have built friction the human routes around. Ask on everything and you have rebuilt the permission prompt you were trying to escape. The skilled author denies a tiny set of catastrophic actions, asks on a slightly larger set of high-consequence ones, and allows everything else in silence.
Why Deterministic Gating Beats a Rule
A CLAUDE.md rule that says "never push to main" is semantic. The model reads it, understands it, and follows it — until a long context buries it, a confident user instruction overrides it, or the file simply grows too long to fully load. A PreToolUse deny is deterministic. It fires on the matching call, every session, for zero tokens, and cannot be argued with.
The test for whether a rule belongs in a gate instead of in prose: would you be devastated if it were violated once? "Never push to main" — yes, devastated, that goes in a hook. "Prefer Supabase for auth" — no, a one-off slip is recoverable, that stays in CLAUDE.md. Mechanical consequences want mechanical enforcement; everything else is guidance.
Build It
Author the protected-path gate end to end: the script that reads the proposed write off stdin, the three-verdict logic (deny the protected paths, ask on .env, allow the rest), and the settings.json matcher that fires it on Write and Edit. Parse defensively — a malformed payload must exit 0, never block.
What's Next
PreToolUse is prevention — the verdict before the action. The next lesson is its mirror image: PostToolUse, the hook that fires after a tool succeeds. You cannot block there, but you can verify, and feeding that verification back into the model's context is how you turn a silent-broken edit into an immediate self-correction loop.