Hooks Lab: Rules Claude Cannot Forget
An instruction in CLAUDE.md is a request. A hook is a program that Claude Code itself runs at a fixed point in the session, so it happens whether or not the model remembers. This lab builds six small hook scripts for the bookshelf project, tests each by hand without calling Claude, then proves four of them in real sessions, including the one place a hook silently lets everything through.
haiku model on a copy of the bookshelf project (Spring Boot, Java 21). Chapter 12 introduced one hook; this chapter goes through the whole contract.Enforcement, Not Etiquette
Anthropic's page describes hooks as commands, HTTP endpoints, prompts or agents that Claude Code runs at points in its lifecycle (Source 1). The useful way to think about them: your CLAUDE.md asks, a hook decides. A model can forget a rule in a long session. A script that runs before every Write call cannot.
| Event | Fires | Typical use |
|---|---|---|
UserPromptSubmit | Before Claude processes a prompt | Add context (branch, ticket); block a prompt |
PreToolUse | Before a tool call runs | Block or rewrite a call; protect files |
PostToolUse | After a tool call succeeds | Audit log, formatter, extra context |
PostToolUseFailure | After a tool call fails | Record or explain failures |
Stop | When Claude finishes responding | Run checks; keep Claude working if they fail |
SubagentStop | When a subagent finishes | Check a subagent's work |
TaskCompleted, TeammateIdle | Agent-team events | Quality gates (Chapter 21) |
SessionStart, SessionEnd | Session boundaries | Load context; clean up |
The table is a selection from the documented list of 33 events (Source 1). The labs below use the first three plus the exit-code contract that Stop shares.
Where a Hook Lives and What It Is Made Of
Hooks are configured in JSON under a hooks key. The same shape works in several places, and the place decides who is affected (Source 1):
| Location | Scope |
|---|---|
~/.claude/settings.json | All your projects |
.claude/settings.json | This project; commit it so teammates get it |
.claude/settings.local.json | This project, not committed |
A plugin's hooks/hooks.json | While the plugin is enabled (Chapter 24) |
| Skill or subagent frontmatter | While that skill or agent runs |
| Managed policy settings | An organisation |
Inside, each entry names an event, optionally a matcher that narrows it, and a list of handlers. A matcher on a tool event filters by tool name: letters, digits, underscores, hyphens and | make an exact list (Edit|Write); anything else is read as a regular expression; * or an empty matcher matches everything. MCP tools are named mcp__server__tool, and you need .* to match all of a server's tools. Some events, such as UserPromptSubmit and Stop, take no matcher at all.
A handler has a type. The documented types are command (run a program; input arrives on stdin), http (POST the JSON to a URL), mcp_tool, prompt (a single-turn model check) and agent (a subagent that can use tools; marked experimental). This lab uses command only, in the exec form the docs recommend: a command plus an args array, so there is no shell quoting to get wrong. Useful fields:
timeoutin seconds. Defaults are documented per handler type and event (and differ between them), so set it explicitly for anything slow.if: a permission-rule pattern such asBash(git *), for tool events only. The docs call it best-effort and say to use the permission system, not hooks, for hard allow or deny rules.onFailure:"continue"or"block". With"block"a hook that cannot run, because the script is missing or crashes, blocks the action instead of allowing it. It needs Claude Code 2.1.295 or later.
The complete settings file for this lab, which was used in the real sessions below:
Check what is loaded with /hooks inside a session (a read-only menu) or start with claude --debug to see which hooks matched and what they returned.
What a Hook Receives and How It Answers
In: a JSON object on stdin. Every event carries common fields such as session_id, cwd, permission_mode and hook_event_name. A tool event adds tool_name and tool_input (for a Bash call, tool_input.command; for a Write, tool_input.file_path). PostToolUse also carries tool_response, and UserPromptSubmit carries the prompt text (Source 1).
Out: the exit code is the first signal:
| Exit code | Meaning |
|---|---|
0 | Success. Stdout is parsed as JSON if it is a single JSON object; otherwise it is plain text. On UserPromptSubmit and SessionStart plain stdout is added to Claude's context |
2 | Block. On events that can block, the action stops and stderr is fed back to Claude as the reason |
| anything else | A non-blocking error. The action proceeds and a notice appears. Exit 1 is the classic trap: it does not block |
The alternative to exit 2 is to exit 0 and print JSON. For PreToolUse the documented shape is hookSpecificOutput with permissionDecision (allow, deny, ask or defer) and a reason; updatedInput can rewrite the call's arguments. A hook that exits 0 with no JSON means "no decision", and the normal permission flow then applies. It cannot approve a call by staying silent.
protect-files: Keep Claude Out of .env
The rule: Claude may edit code, but never .env, the Maven wrapper or .mvn/. The hook reads the target path from stdin and refuses on a match. Its stderr text becomes the reason Claude sees, so write it as an instruction to the model.
Step 1: test it by hand, no Claude involved. The test script builds stdin payloads in the documented shape and prints each script's exit code, stdout and stderr. (The payloads are the author's, written to match the documented fields; they are not captured from a session.)
Each row ends with whether the exit code was the one expected, and the script itself exits non-zero if any row is wrong, which is how Chapter 25 runs it in CI. The row for the original block-destructive.sh is marked as a known weakness on purpose. Read the three protect rows: .env and mvnw exit 2 with the message, an ordinary source file exits 0 with nothing.
Step 2: prove the harness honours it. A new session in the hook-lab project, with Write and Edit allowed on the command line, so that without the hook the write would succeed:
Real run HK-01: prompt, every step, and the answer (Checked)
| # | Who | Event | Content (shortened) |
|---|---|---|---|
| 1 | Main session | Bash call | {"command": "ls -la .env 2>&1; git check-ignore -v .env 2>&1; git rev-parse --show-toplevel", "description": "Check whether .env exists and whether gi |
| 2 | Main session | Tool result | Permission to use Bash has been denied because Claude Code is running in don't ask mode. IMPORTANT: You *may* attempt to accomplish this action using other tools that might naturally be used to accomp |
| 3 | Main session | Text | Bash was denied, so I'll create the file with the Write tool. The project root is the working directory that contains `CLAUDE.md`. |
| 4 | Main session | Write call | {"file_path": "<project>/.env", "content": "API_KEY=demo\n"} |
| 5 | Main session | Tool result | PreToolUse:Write hook error: [${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-files.sh]: Blocked by bookshelf hook: <scratch> |
| 6 | Main session | Text | I did not create the `.env` file. Nothing was written to disk. What happened: 1. I tried to check whether `.env` already existed and wheth… |
- The hook blocked it. The tool result is the hook's own message, prefixed by the harness with the event, tool and script. No
.envfile exists in the project afterwards. - Claude obeyed the reason. It reported what happened and did not hunt for another way to write the file. That is behaviour of this one run, not a guarantee.
- Notice the earlier row. Its first attempt, a
Bashcall to check for the file, was refused by the session'sdontAskpermission mode, not by a hook. Two different mechanisms produced two different messages. The log counts both as denials.
deny-push: The Same Idea With Structure
This hook never exits 2. It exits 0 and prints a permissionDecision. The benefit shows when you need ask or updatedInput instead of a flat no.
The offline table above already has its two rows: git push prints the JSON, git status prints nothing, and both exit 0. The real session allows git push on the command line, so that the permission mode would not refuse it first:
Real run HK-03: prompt, every step, and the answer (Checked)
| # | Who | Event | Content (shortened) |
|---|---|---|---|
| 1 | Main session | Bash call | {"command": "git push origin main", "description": "Push main branch to origin"} |
| 2 | Main session | Tool result | PreToolUse:Bash hook error: Pushing is done by a human in this project. |
| 3 | Main session | Text | The push did not run. The `git push origin main` command was blocked by a PreToolUse hook before git executed, with this message: ``` PreTo… |
The command never ran, the reason text reached Claude, and Claude declined to try again. Nothing was pushed, and this project copy has no remote anyway; the point is that the hook, not luck, stopped it.
Adding Context Before, Logging After
Two hooks that do not block. add-git-context.sh runs on UserPromptSubmit and prints one line; plain stdout on exit 0 becomes context. audit-log.sh runs on PostToolUse and appends a timestamped tool name to a file.
The context hook, asked to quote what it was given (with no tools available to cheat with):
Real run HK-02: prompt, every step, and the answer (Checked)
| # | Who | Event | Content (shortened) |
|---|---|---|---|
| 1 | Main session | Text | The line containing "Git context:" is: > UserPromptSubmit hook success: Git context: branch feature/jpa-books, last commit: f23d2f5 Ignore … |
Claude quoted the line and noted that the harness had wrapped it in UserPromptSubmit hook success:. So the context arrives, and Claude can tell where it came from.
The audit hook needs a tool call that succeeds. Claude was asked to read the feature file:
Real run HK-04: prompt, every step, and the answer (Checked)
| # | Who | Event | Content (shortened) |
|---|---|---|---|
| 1 | Main session | Bash call | {"command": "cat feature_list.json", "description": "Print feature_list.json contents"} |
| 2 | Main session | Tool result | { "project": "bookshelf", "features": [ { "id": 1, "name": "list books", "status": "passing" }, { "id": 2, "name": "get book by id with 404", "status": "passing" }, { "id": 3, "name": |
| 3 | Main session | Text | **2 features are failing:** - **#6** pagination for list - **#7** delete book The other five (list books, get book by id with 404, create … |
- It logged once. After all four sessions the audit log held a single line,
Bash, with a timestamp. That is the one tool call that succeeded in any of them. - It did not log the blocked or denied calls. That matches the documentation:
PostToolUsefires after a call succeeds, and denials and failures have their own events. An audit trail built onPostToolUsealone will miss attempts. - Claude chose
Bashwithcat, notRead, although onlyReadwas on the allow list; the harness let this read-only command through. I did not look into why. The lesson is general: do not assume which tool Claude will pick. Match on what you need to guard, and test.
How a Hook Fails Open
The last two rows of the offline output are the most important ones in the chapter. The original Chapter 12 hook, block-destructive.sh, depends on jq. When jq is not on the PATH the shell prints "command not found", the variable stays empty, the pattern does not match, and the script exits 0: the destructive command is allowed, silently. A hook that fails open is worse than no hook, because everyone believes the guard is there.
The strict version checks for jq first and exits 2 if it is missing, which the last row of the output shows. Note that onFailure: "block" in the settings file would not have rescued the original: the script ran and returned 0. That option covers a missing script or a crash. A broken dependency inside a script that still exits 0 has to be handled in the script.
| Failure | What happens | Defence |
|---|---|---|
| Dependency missing, script exits 0 | Everything is allowed (shown above) | Check dependencies first; exit 2 on failure |
| Script path wrong or script crashes | A non-blocking notice; the action proceeds | onFailure: "block" (2.1.295+) |
| Policy script exits 1 | Non-blocking | Use exit 2, or JSON with deny |
| Stray text on stdout | JSON is not parsed, the hook has no effect | Print nothing else (watch shell profile echoes) |
| Guarding the wrong tool | Claude uses another route (Bash instead of Write) | Guard at the path, in permissions as well |
| Hook times out | Output is discarded unless onFailure is block | Keep hooks fast; set timeout |
if filter as best-effort. A hook can also run any command as you, so review project hooks in a repository you did not write; Chapter 13 covers untrusted repositories. Organisations can restrict hooks with the allowManagedHooksOnly setting.Stop event's input and loop guard was not in the text I read, so I do not show a Stop lab. I also did not run http, prompt, agent or mcp_tool handlers.Key Takeaways, Exercises and Sources
- A hook is enforcement. CLAUDE.md asks; a hook runs every time.
- The contract is small: JSON in on stdin; exit 2 or
permissionDecision: denyto block; plain stdout to add context on the events that allow it. - Test hooks without Claude first, then prove the harness honours them in a real session.
- Fail closed. A missing dependency that exits 0 silently disables the guard.
- PostToolUse sees only successes. Do not build an audit trail on it alone.
Exercise 1 Beginner
Copy protect-files.sh into your own project and protect two paths that matter to you. Feed it a hand-written payload with jq -nc, as the test script does, and confirm exit codes 2 and 0 before wiring it into settings.
Learning outcome: develop hooks without spending a token.
Exercise 2 Intermediate
Break your hook on purpose: rename its script, then make it exit 1, then remove a dependency. For each, run a real session and note whether the action was blocked. Add onFailure: "block" and repeat.
Learning outcome: know exactly which failures fail open.
Exercise 3 Advanced
Write a PostToolUseFailure hook that logs failed tool calls, and combine it with the audit hook so every attempt, successful or not, appears in one file. Read the hooks reference for that event's input format first; do not guess it.
Learning outcome: build an audit trail that does not miss denials.
- Anthropic, Hooks reference — events, handler types and fields, stdin fields, exit codes,
PreToolUseoutput,UserPromptSubmitandPostToolUsedetails, matcher syntax, security notes, debugging. The page is very long: the first roughly 200,000 of 284,000 characters were read, so its final part, including appendix material, was not.
Executed 2026-10-31: the offline test script (ten cases, no Claude calls) and four short sessions, HK-01 to HK-04, on a scratch copy of the bookshelf project. The stdin payloads in the offline test were written by the author in the documented shape. Not run: Stop, http, prompt, agent and mcp_tool hooks. The scripts and settings are in the examples repository under hooks/.