Module 09 · 50 min

Hooks

Write a hook that actually blocks, and predict what every exit code does before you run it.

Surface
settings.json hooks
Ships to your plugin
hooks/hooks.json
A heavy steel interlock arm dropped across a narrow channel, seated into a notch on the far wall.

Up to here, every surface has been guidance. A rule asks. A skill suggests. A subagent gets told what to do and usually listens. All of those paths run through a model that weighs your instruction against everything else in its context, and on a bad day, your instruction loses.

A hook does not ask. Claude Code runs it at a fixed moment in a session: before a tool call, when Claude tries to finish its turn, and a dozen other points you will meet as you wire more of them. Those moments are the lifecycle events. Whether the model agrees with your hook is irrelevant. This is also the surface where one wrong return value gives you a policy that silently does nothing, and you will not get a warning.

Your hook sends its answer back two ways, and neither will feel familiar if you have never written a command-line program. The first is the exit code. When a program finishes, it reports a number to whoever started it. By long convention, zero means success and anything else means failure. Claude Code reads that number off your hook.

The second is what the hook printed. Programs write to two separate streams: stdout (standard output), where a normal print lands, and stderr (standard error), where error messages go. Claude Code treats those streams differently, so the stream you choose changes what happens next.

Four facts to get right before you write anything

Three gate arms across three channels: the first raised and clear, the second raised but bent with debris beside it, the third fully down and seated.

Exit 0 with no output is no decision. The docs are unambiguous: the tool call continues through the normal permission flow, the usual path where Claude Code checks your rules and asks you when it must. Your hook can deny a call, but staying silent does not approve it. Exit 0 means your hook stepped out of the conversation; it did not vote yes.

Exit 1 does not block. Exit 2 blocks. Exit 1 is what most programs return when they fail. Claude Code treats it as an error that does not stop anything, then proceeds with the action anyway. If your hook enforces a policy, exit 2 is the code that actually blocks. WorktreeCreate is the single exception: any non-zero exit aborts worktree creation. One caveat, and this trips people, Claude Code reads JSON output fields from stdout on every exit code, not only 0. Exit 1 is not inert. A hook that prints a valid JSON decision and then exits 1 has still made that decision.

PermissionRequest ignores exit 2 entirely. On that event the exit code is not honoured and the permission flow proceeds unchanged. To deny from a PermissionRequest hook, emit a decision object on stdout. No exit code works there.

if is a performance filter, not a gate. It runs only on the five tool events: PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, and PermissionDenied. On every other event, a hook with if set never runs at all. It holds exactly one permission rule, no &&, no ||, no list syntax. When Claude Code cannot parse the Bash command well enough to compare it, the filter fails open: it errs toward running your hook rather than skipping it, whatever the pattern says. Use if to avoid spawning a process on every unrelated tool call. Do not use it to enforce anything; the docs tell you outright to reach for the permission system instead.

A hook that fails the wrong way looks exactly like a hook that is working. That is why we get these four facts straight before writing a line of code.

The three levels of configuration

Now we need the shape of the config. Hook config nests three deep, in this order:

  1. The event. One of 31 lifecycle events, from SessionStart through SessionEnd.
  2. A matcher group. The matcher is the pattern that decides which tools the group applies to. Omit it, or use "*" or "", to match everything.
  3. The handlers. The programs that actually run. One or more, and all matching hooks in a group run in parallel, not one after another.

Identical handlers declared in more than one settings file collapse to a single run. A plugin’s or a skill’s copy of the same handler stays separate, so a plugin hook that duplicates a user hook runs twice.

The matcher is read one of two ways, and which one depends on the characters in it. Letters, digits, _, -, spaces, ,, and | and nothing else means an exact string, or a |-separated or ,-separated list of exact strings. Any other character makes it an unanchored JavaScript regular expression. A regular expression, or regex, is a pattern where some characters stand for other text instead of themselves: . means any single character and * means “any number of the thing before me”. Unanchored means the pattern matches if it is found anywhere in the name rather than having to account for the whole name.

That distinction bites on tool names from Model Context Protocol (MCP) servers, which module 10 covers. mcp__memory contains only letters and underscores, so it is matched exactly against a tool literally named mcp__memory, which does not exist. mcp__memory__.* contains a dot, becomes a regex, and matches every tool from that server. FileChanged and StopFailure use a narrower exact set still: letters, digits, _, and | only.

There are five handler types: command, http, mcp_tool, prompt, and agent. Agent hooks are marked experimental. A command handler takes command plus optional args. Those arguments go straight to the program with no shell in between, that is what “exec form” means, so shell syntax such as a pipe or a glob arrives as plain text. It also takes async, asyncRewake, and shell for choosing bash or powershell.

Timeouts

Handler typeDefault timeout
command, http, mcp_tool600s
prompt30s
agent60s

Two events lower the command default: UserPromptSubmit drops it to 30 seconds, and MessageDisplay to 10. SessionEnd hooks share a 1.5-second budget between them, short enough that anything doing real work there needs async, the flag that lets a handler keep running in the background instead of holding the session up, or needs to live somewhere else entirely.

Two hooks that block, two different ways

Here is the whole interface. Claude Code describes the event to your hook as JSON on stdin (standard input), the stream a program reads when something feeds it text. The hook reads that, decides, and answers on stdout or with its exit code. That is it.

These are the real files from the lab, not toy examples. Together they cover both decision surfaces you will actually use.

your-project/
└─ .claude/
├─ hooks/
.claude/hooks/block_secrets.py ships to your plugin

Denies through JSON and exits 0 either way. The exit code carries no meaning here.

#!/usr/bin/env python3
"""PreToolUse (Read|Bash): deny access to secret files. JSON decision surface."""
import json, re, sys

SECRET = re.compile(r"(^|/)(\.env(\.[\w-]+)?|id_rsa|id_ed25519|.*\.pem|credentials\.json)$")
SECRET_IN_CMD = re.compile(r"(^|[\s/'\"])\.env(\.[\w-]+)?\b")

data = json.load(sys.stdin)
tool, inp = data.get("tool_name"), data.get("tool_input", {})

hit = None
if tool == "Read":
  path = inp.get("file_path", "").replace("\\", "/")     # Windows paths arrive with backslashes
  if SECRET.search(path) and not path.endswith(".env.example"):
      hit = path
elif tool == "Bash":
  cmd = inp.get("command", "")
  if SECRET_IN_CMD.search(cmd) and ".env.example" not in cmd:
      hit = cmd

if hit:
  print(json.dumps({"hookSpecificOutput": {
      "hookEventName": "PreToolUse",
      "permissionDecision": "deny",
      "permissionDecisionReason": f"Secret file access blocked ({hit}). Use .env.example for variable names.",
  }}))
sys.exit(0)   # exit 0 either way; the JSON carries the decision. Silence != approve.
Read more

The permissionDecision field takes deny, allow, or ask. Returning deny is the strongest thing a PreToolUse hook can say. Do not carry this shape over to PermissionRequest: that event ignores exit 2 and denies through a top-level decision object, so the nested PreToolUse shape is read as no decision there and your policy silently does not apply. Note that the Windows path normalisation is not decoration: tool_input arrives with backslashes on Windows and the regex is written for forward slashes.

A JSON-decision hook, an exit-code hook, and the settings that wire them. Click block_secrets.py first.

Notice the split. block_secrets.py exits 0 on the deny path. On the JSON surface the exit code is not carrying the decision, so making it 2 as well would be redundant at best and confusing to the next reader at worst. block_destructive.py does the opposite: no JSON at all, exit 2, and a stderr line written in language the model can act on.

The same hooks in four languages

The lab keeps every hook in Python, Bash, PowerShell, and Node. run_tests.sh runs all four against the same stdin fixtures. The tabs below are read from those files at build time, so the code you see here is the code the suite passed. Pick a language once and every tab group on the site follows.

hooks/python/block_secrets.py
#!/usr/bin/env python3
"""PreToolUse (Read|Bash): deny access to secret files. JSON decision surface."""
import json, re, sys

SECRET = re.compile(r"(^|/)(\.env(\.[\w-]+)?|id_rsa|id_ed25519|.*\.pem|credentials\.json)$")
SECRET_IN_CMD = re.compile(r"(^|[\s/'\"])\.env(\.[\w-]+)?\b")

data = json.load(sys.stdin)
tool, inp = data.get("tool_name"), data.get("tool_input", {})

hit = None
if tool == "Read":
    path = inp.get("file_path", "").replace("\\", "/")     # Windows paths arrive with backslashes
    if SECRET.search(path) and not path.endswith(".env.example"):
        hit = path
elif tool == "Bash":
    cmd = inp.get("command", "")
    if SECRET_IN_CMD.search(cmd) and ".env.example" not in cmd:
        hit = cmd

if hit:
    print(json.dumps({"hookSpecificOutput": {
        "hookEventName": "PreToolUse",
        "permissionDecision": "deny",
        "permissionDecisionReason": f"Secret file access blocked ({hit}). Use .env.example for variable names.",
    }}))
sys.exit(0)   # exit 0 either way; the JSON carries the decision. Silence != approve.
JSON decision surface. Denies through permissionDecision and exits 0 on every path.
hooks/python/block_destructive.py
#!/usr/bin/env python3
"""PreToolUse (Bash): exit-code surface. Exit 2 + stderr blocks; Claude sees stderr."""
import json, re, sys

DANGEROUS = [
    (re.compile(r"\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)\b"), "recursive force delete"),
    (re.compile(r"\bgit\s+push\s+.*--force\b|\bgit\s+push\s+-f\b"), "force push"),
    (re.compile(r"\bgit\s+reset\s+--hard\b"), "hard reset"),
    (re.compile(r"\bDROP\s+(TABLE|DATABASE)\b", re.I), "SQL drop"),
]
cmd = json.load(sys.stdin).get("tool_input", {}).get("command", "")
for pattern, label in DANGEROUS:
    if pattern.search(cmd):
        print(f"Blocked ({label}): `{cmd}`. Explain what you need and ask the user.", file=sys.stderr)
        sys.exit(2)
sys.exit(0)
Exit-code surface. Exit 2 with one stderr sentence; Claude reads that sentence back.
hooks/python/tests_must_pass.py
#!/usr/bin/env python3
"""Stop: run the test suite; block stopping until it passes. Guards against infinite loops."""
import json, os, subprocess, sys

data = json.load(sys.stdin)
if data.get("stop_hook_active"):
    # We already sent Claude back once this turn. Let it stop and report, or you loop forever.
    sys.exit(0)

test_cmd = os.environ.get("HOOK_TEST_CMD", "python3 -m pytest -q")
try:
    r = subprocess.run(test_cmd, shell=True, capture_output=True, text=True, timeout=540)
except subprocess.TimeoutExpired:
    print(json.dumps({"systemMessage": "Stop hook: test suite timed out; not blocking."}))
    sys.exit(0)

if r.returncode == 0:
    sys.exit(0)

tail = (r.stdout + r.stderr)[-3000:]
print(json.dumps({
    "decision": "block",
    "reason": f"Tests are failing. Fix them before finishing.\n```\n{tail}\n```",
}))
A Stop hook. Reads stop_hook_active first, or it loops until Claude Code overrides it.

The Bash tab depends on jq, the same way the upstream reference does. The PowerShell tab targets pwsh 7 and reads $env:CLAUDE_PROJECT_DIR, never the bare variable.

Which events can block at all

You cannot block on every event. These do, on exit 2: PreToolUse, UserPromptSubmit, UserPromptExpansion, Stop, SubagentStop, TeammateIdle, TaskCreated, TaskCompleted, ConfigChange except for policy_settings, PostToolBatch, PreCompact, Elicitation, ElicitationResult, and, from v2.1.251, PreModelSwitch. WorktreeCreate blocks on any non-zero code.

The rest cannot block by exit code, whatever you return: PermissionRequest, PermissionDenied, StopFailure, PostToolUse, PostToolUseFailure, Notification, SubagentStart, SessionStart, Setup, SessionEnd, CwdChanged, DirectoryAdded, FileChanged, PostCompact, WorktreeRemove, InstructionsLoaded, MessageDisplay, and PostModelSwitch.

Each name above is its own page. Every one states what arrives on stdin, what each exit code does on that event, and which JSON fields it honours; the full list groups them the same way this section does.

PostToolUse on the second list is the one people trip over. By the time it fires, the write already happened. If you want to prevent something, PreToolUse is where you do it.

Build

Start with block_destructive.py in .claude/hooks/, wired under PreToolUse with a Bash matcher and no if filter yet. Ask Claude to clean up a scratch directory with rm -rf. It should be refused, and the refusal message you wrote should show up in Claude’s reasoning about what to do next.

Next add block_secrets.py alongside it and ask Claude to read your .env. Same outcome, different mechanism: nothing in stderr, exit 0, and a JSON permissionDecision doing the work.

Then measure the filter. Add "if": "Bash(rm *)" to the first handler and confirm a plain ls no longer spawns the process, while rm -rf still gets blocked. When that works, copy the whole hooks object into hooks/hooks.json in your plugin directory. The format is identical.

The mistake most people make first

You write a hook that catches something dangerous, and you end it the way you end every other script:

if pattern.search(cmd):
    print("Refusing to run that.", file=sys.stderr)
    sys.exit(1)

The command runs anyway. Your hook fired, your regex matched, your message went to stderr, and Claude Code deleted the directory. Exit 1 is a non-blocking error: Claude Code notes it and proceeds with the action. Only exit 2 blocks through the code alone.

What makes this hard to catch is that the hook is not broken in any visible way. There is no error, no warning, and your stderr message may well appear somewhere in the transcript, which reads like confirmation that the block worked. The only way to see the truth is to check whether the tool actually ran.

Change the 1 to a 2 and it blocks. Then read the caveat again, because the inverse case exists: if that script had printed a valid JSON decision on stdout before exiting 1, the decision would have applied, since Claude Code reads stdout JSON on every exit code. Exit 1 with JSON is a working hook. Exit 1 without JSON is a no-op that looks like a policy.

One more thing before we close. A hook is still not a security boundary, it is not something that holds when somebody is working to get past it. It runs your code with your privileges at a moment Claude Code chose, and there are ways around it: a Bash command Claude Code cannot parse makes if fail open, a command can be spelled in ways your regex does not anticipate, and hooks can be disabled by configuration. Use hooks for policy that is deterministic: the same input always produces the same decision with no model judgment in between. For a hard allow or deny, the permission system is the answer, and module 11 covers it.

Does this travel?

Better than the earlier research suggested. Seven of ten surveyed harnesses run hooks the same way, code whose answer does not depend on the model, and most of that shipped within the last year. Only Aider and Zed have nothing; Aider’s nearest analogs are --lint-cmd and --test-cmd.

The concept transfers completely: lifecycle event, shell command, exit code 2 blocks. The config file does not, and the event names are gratuitously different in every tool. Cursor uses camelCase in .cursor/hooks.json. camelCase and PascalCase are the two capitalisation styles for a compound name, preToolUse against PreToolUse. Cursor also adds prompt-based hooks that a language model judges rather than code, which has no Claude Code equivalent. Gemini CLI keeps Claude’s matcher-array structure but renames every event to BeforeTool, AfterTool, BeforeModel and so on. Codex CLI’s event names are near-identical to Claude’s and it decides via JSON with continue: false. Windsurf has a distinct powershell key and genuine Windows support. Cline has no config file at all: you drop a bare executable named exactly after the event into a hooks directory, and it is macOS and Linux only.

Copilot CLI is the outlier that matters. As of changelog 1.0.6 its hook configuration files “work across VS Code, Claude Code, and the CLI without modification by accepting PascalCase event names alongside camelCase”, nested matcher structure included. The JSON you write in this module is the JSON it reads. That is the transfer story in one sentence.

Check yourself

  1. Your PreToolUse hook detects a policy violation, prints an explanation to stderr, and exits 0. What happens to the tool call?
  2. You want a PermissionRequest hook to deny something. What do you return?
  3. A hook on SessionStart has "if": "Bash(git *)" set. When does it run?