Skip to content

Guide · Hooks · PreToolUse, PostToolUse, Stop

Claude Code hooks for safety

Three hook events do the safety work: PreToolUse can deny a call, PostToolUse records what it did, Stop can send the agent back to work. How each one works, a real deny byte for byte, eight guards to copy, and what no hook can see.

by: myrmitis LLC

Updated 2026-09-25

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

  1. 01Before the call runs
    PreToolUseCan blockpermissionDecision “deny”, or exit 2Bulkhead uses it forProtected paths, blocked commands, dollar caps, loop freeze
  2. 02After it succeeds
    PostToolUseCan’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
  3. 03When Claude finishes
    StopCan 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 PreToolUse hook 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.

stdout · bulkhead hook pre
{
  "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)"
}
The reason, as Claude reads it
⛔ 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):

stdout · bulkhead hook stop (abridged)
{
  "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

bulkhead.yaml
# 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/** and migrations/** (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

bulkhead.yaml
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 --prod and netlify deploy --prod; preview deploys still run.
03 · Publish
Denies npm, pnpm and yarn publish, but lets --dry-run through 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

bulkhead.yaml
# 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-usd covers -p runs 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

bulkhead.yaml
# 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 what PostToolUse recorded. A claim the evidence contradicts sends Claude back to work; a claim with no evidence is only noted. block is already the default.
08 · Ask
Pushes, gh writes, package publishes, cloud mutations, ssh/scp and curl POSTs score high risk. In ask mode 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.

Plain hook, no Bulkhead · .claude/hooks/deny-prod-deploy.sh
#!/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
.claude/settings.json
{
  "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:

Terminal
chmod +x .claude/hooks/deny-prod-deploy.sh
echo '{"tool_input":{"command":"vercel --prod"}}' | .claude/hooks/deny-prod-deploy.sh
echo $?   # 2 means blocked

What 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.

GapWhy a hook misses itWhat 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.shTwo separate tool calls; neither looks destructive alone.A sandbox or container
cd, then a relative pathcd .. && rm -rf bulkheadThe hook sees text, not the shell’s working directory as it changes.A sandbox or container
Encoded commandsecho cm0gLXJmIH4vYmFja3Vwcw== | base64 -d | shObfuscation defeats pattern matching by design.A sandbox or container
Files you @-mentionThey go into the prompt without a tool call, so no PreToolUse fires.A Read deny rule
Why the agent did itHooks 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 modeDocs 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:

In your repo
npx @bulkheadtools/cli init

init 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.

Let it cook. Keep the receipts.

One command installs the hooks and a policy file you own. Want to watch it refuse something first? The demo runs the incident corpus through the real hook in a throwaway repo.

Rather have a person read your setup? Book a Policy Review ($490). Opens Stripe checkout. After you pay, a person emails you the intake questions.

Your copy
npx @bulkheadtools/cli init

Or watch it refuse first:npx @bulkheadtools/cli demo