Claude Code hooks are shell commands (or HTTP endpoints, MCP tool calls, model prompts, or subagents) that Claude Code runs at fixed points in a session, handing each one the event as JSON. For safety, three events matter. PreToolUse runs before a tool call and can deny it. PostToolUse runs after a call succeeds; it can’t undo anything, but it can record what the call proved. Stop runs when Claude finishes responding and can send it back to work. A command hook is code outside the model: write it as a fixed rule and it gives the same answer every time, but it can only judge what it is shown.
The three Claude Code hooks that do safety work
- 01Before the call runsPreToolUseCan blockpermissionDecision “deny”, or exit 2Bulkhead uses it forProtected paths, blocked commands, dollar caps, loop freeze
- 02After it succeedsPostToolUseCan’t undoThe call already ran: it can give Claude feedback, not undo itBulkhead uses it forRecords what the call proved: tests passed or failed, a commit, a PR
- 03When Claude finishesStopCan blockdecision “block” plus a reason, or exit 2Bulkhead uses it forChecks “done” claims against what PostToolUse recorded
Every command hook speaks the same protocol: the event arrives on stdin, and the hook answers with an exit code and, optionally, JSON on stdout. Two details decide whether a guard actually guards (both straight from the hooks reference):
- Exit 2 blocks. Exit 1 does not. Without valid JSON on stdout, Claude Code treats exit 1 as a non-blocking error and runs the tool anyway. A policy hook that fails with the usual Unix error code is a hook that allows.
- A hook that fails is not a hook that denies. A mistyped script path, a hook that can’t start, or a
PreToolUsehook that hits its timeout all end the same way: the call continues through the normal permission flow. Watch the first run of any guard you add.
A real PreToolUse deny, byte for byte
This is what Bulkhead’s PreToolUse handler printed when the agent in our scripted demo session tried git push --force origin main: real engine output, from the session the home page prints.
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "⛔ Bulkhead blocked this action.\nGuard: blocked-commands\nRule: force-push-protected-branch\nWhy: Force-push to a protected branch (main/master) — refusing.\nThis is a deterministic policy check that runs outside the model and cannot be overridden by reasoning.\nTo proceed, a human must change bulkhead.yaml or perform this action manually."
},
"systemMessage": "🛡 Bulkhead blocked: git push --force origin main — force-push to a protected branch (ledger #7)"
}⛔ Bulkhead blocked this action.
Guard: blocked-commands
Rule: force-push-protected-branch
Why: Force-push to a protected branch (main/master) — refusing.
This is a deterministic policy check that runs outside the model and cannot be overridden by reasoning.
To proceed, a human must change bulkhead.yaml or perform this action manually.Three fields do the work. permissionDecision: "deny" stops the call; when hooks disagree, deny beats defer, ask and allow. permissionDecisionReason goes to Claude, not to you, and says the check is deterministic so the agent doesn’t argue with it. systemMessage is the one line you see, because a reason only Claude reads makes every block invisible to the human.
When Bulkhead has no objection it returns no permission decision (only {"suppressOutput":true}). It never returns "allow", which would skip the permission prompt and override your own settings; silence leaves Claude Code’s normal permission flow in charge.
Stop uses a different shape: a top-level decision: "block" and a reason Claude has to act on. Here is the completion check refusing a false “All tests pass” (reason cut at the evidence line):
{
"decision": "block",
"reason": "⛔ Bulkhead completion check failed.\nYou stated \"All tests pass\" but the evidence ledger disagrees — the latest relevant evidence is: test_failed: npm test (exit 1) …",
"systemMessage": "🛡 Bulkhead: agent said \"All tests pass\" but the latest `npm test` exited 1 — sent back to work (ledger #8)"
}Claude Code caps this: after 8 consecutive Stop blocks it ends the turn anyway, and the stop_hook_active input flag tells a hook it is already in a retry (docs). Bulkhead records the verdict on a retry and doesn’t block twice, so a completion check can’t trap a session.
Eight guards to copy
Each is a snippet of bulkhead.yaml, the file npx @bulkheadtools/cli init writes and you own. Your rules add to the built-ins; deleting a built-in line doesn’t switch it off. Patterns are case-insensitive regexes over the whole Bash command. We ran every snippet through the 0.2.0 engine before publishing it.
01Paths the agent may never write
# 01 · paths the agent may never write
protected_paths:
deny:
- "infra/**"
- "secrets/**"
- "**/*.pem"- 01 · Paths
- Denies Write and Edit to those paths.
.env,.env.*,prod/**andmigrations/**(at the repo root) are already in the defaults, and Bulkhead’s own policy, ledger and hook settings can’t be carved out. For Bash, the obvious forms are caught too —rm, redirects,tee,sed -i,cp/mv— but not a script or interpreter that writes the file.
02–04Commands that stay with a human
blocked_commands:
# 02 · no production deploys from the agent
- pattern: '\b(?:vercel|netlify)\b[^\n;&|]*\s--prod\b'
message: "Production deploy (a human runs this one)"
# 03 · no package publishing (dry runs pass)
- pattern: '\b(?:npm|pnpm|yarn)\s+publish\b(?![^\n;&|]*--dry-run)'
message: "Package publish"
# 04 · hands off the production cluster
- pattern: '\bkubectl\b[^\n;&|]*--context[= ]\S*prod'
message: "kubectl against a production context"- 02 · Deploys
- Denies
vercel --prodandnetlify deploy --prod; preview deploys still run. - 03 · Publish
- Denies
npm,pnpmandyarn publish, but lets--dry-runthrough so the agent can still check what would ship. - 04 · Cluster
- Only works when the context is named on the command line. A rule reads the command, not your kubeconfig’s current context.
05–06Money and loops
# 05 · a hard dollar cap
budget:
session_usd: 5
daily_usd: 20
# 06 · freeze a loop
loop:
max_repeats: 8
window_seconds: 300- 05 · Cap
- Spend is summed from the session transcript on every tool call; past the cap, every call is denied until a human raises it. One long model turn can overshoot before the next call is refused. Claude Code’s own
--max-budget-usdcovers-pruns only (more on cost caps). - 06 · Loop
- The eighth identical call (same tool, same arguments) inside five minutes is denied as a suspected loop. Changing any argument resets the count, so real iteration isn’t frozen.
07–08Claims and approvals
# 07 · refuse a false "done"
verify:
mode: block
block_on_missing: false
# 08 · ask before it leaves the machine
risky:
mode: ask
min_level: high- 07 · Done
- On
Stop, claims such as “all tests pass”, “committed” or “opened a PR” are checked against whatPostToolUserecorded. A claim the evidence contradicts sends Claude back to work; a claim with no evidence is only noted.blockis already the default. - 08 · Ask
- Pushes,
ghwrites, package publishes, cloud mutations,ssh/scpandcurlPOSTs score high risk. Inaskmode Bulkhead returns"ask"and Claude Code shows you its approval prompt, in auto mode too.
Without Bulkhead: a minimal plain hook
If you would rather own a dozen lines of shell, here is guard 2 as a standalone PreToolUse hook. It needs jq, and it exits 2, not 1.
#!/usr/bin/env bash
# Minimal PreToolUse guard. No Bulkhead; needs jq.
# Claude Code sends the tool call as JSON on stdin.
cmd=$(jq -r '.tool_input.command // empty')
if printf '%s' "$cmd" | grep -Eq '(vercel|netlify)[^;&|]*--prod'; then
echo "Blocked: production deploys are run by a human, not the agent." >&2
exit 2 # 2 blocks the call; stderr is the reason Claude reads
fi
exit 0 # no opinion: the normal permission flow decides{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/deny-prod-deploy.sh"
}
]
}
]
}
}Make it executable, then feed it a fake tool call before you trust it:
chmod +x .claude/hooks/deny-prod-deploy.sh
echo '{"tool_input":{"command":"vercel --prod"}}' | .claude/hooks/deny-prod-deploy.sh
echo $? # 2 means blockedWhat hooks can’t see
A hook judges only the tool call it is handed. Everything below gets past a command-string rule, ours included. The last column is what covers it.
| Gap | Why a hook misses it | What covers it |
|---|---|---|
Code inside an interpreterpython3 -c "import shutil; shutil.rmtree('/Users/me/backups')" | The hook reads the command string, not what the program will do. | A sandbox or container |
A script written, then runsh ./scripts/cleanup.sh | Two separate tool calls; neither looks destructive alone. | A sandbox or container |
cd, then a relative pathcd .. && rm -rf bulkhead | The hook sees text, not the shell’s working directory as it changes. | A sandbox or container |
Encoded commandsecho cm0gLXJmIH4vYmFja3Vwcw== | base64 -d | sh | Obfuscation defeats pattern matching by design. | A sandbox or container |
Files you @-mention | They go into the prompt without a tool call, so no PreToolUse fires. | A Read deny rule |
| Why the agent did it | Hooks see the call, not the prompt behind it. They don’t stop prompt injection. | Auto mode’s classifier, in part; a sandbox to limit the damage |
| When hooks don’t run or don’t answer | --bare, --safe-mode, disableAllHooks, an uninstall, a timeout, a hook that won’t start. | A sandbox for unattended runs; watch for hook errors |
| Bypass mode | Docs say a deny holds; #20946 says a command ran first. | Not supportedRead why |
The first four are pinned as known misses in Bulkhead’s open corpus, next to its 26 denials and 15 ordinary commands let through, so you can see where the line is: run the proof. The layer that covers them is in sandbox vs guardrails. Then install the hooks:
npx @bulkheadtools/cli initinit writes bulkhead.yaml and merges PreToolUse, PostToolUse and Stop entries into .claude/settings.json, each with a 10-second timeout. It works in Manual, acceptEdits and auto mode; auto mode vs deterministic hooks shows how the layers stack.
Questions
What is the difference between exit code 2 and a JSON deny?
Both block a PreToolUse call. Exit 2 is the blunt form: stderr becomes the reason Claude sees, and nothing printed on stdout can override the block. JSON on exit 0 is the structured form: permissionDecision (allow, deny, ask or defer), a reason, and optionally updatedInput or additionalContext. Pick one per hook. Exit 1 blocks nothing.
Can Claude edit its own hooks?
Claude Code treats .claude/ as a protected directory: outside bypass mode, a write there needs your approval or, in auto mode, the classifier’s review. Bulkhead adds its own boundary: the Edit/Write tools and the obvious shell writes (sed -i, redirects, tee, cp/mv, truncate) are denied on the policy, the ledger and the hook settings, and so is re-running Bulkhead’s installer. A script or interpreter that writes the file is not caught, so pair hooks with a sandbox.
Do hooks slow Claude Code down?
Each command hook is a process start, and every tool call runs two of them (PreToolUse and PostToolUse). We measured Bulkhead at about 0.7 s per hook call through npx (warm cache) — about 1.4 s of that per tool call — and about 0.12 s calling an installed CLI directly, about 0.24 s per tool call (2026-09-25, our build sandbox; yours will differ). Each hook has a 10-second timeout; one that runs past it doesn't block. bulkhead init --command can point the hooks at a local install.
Should I use a prompt hook (an LLM) as a safety guard?
Prompt hooks send the event to a Claude model (Haiku by default) and act on its answer. That is judgment, which suits fuzzy checks. For a line that must hold every time, like never force-pushing main, use a command hook with a fixed rule: no model, same answer every time.
Do hooks run in auto mode and in bypass mode?
In auto mode, yes: a deny is honoured, and an ask forces a prompt the classifier can’t approve silently. In bypass mode the docs say a deny still blocks, but a public bug report (anthropics/claude-code#20946) describes a command completing first. Bulkhead does not support bypass mode.