Claude Code · Intermediate/Lesson 05 of 08~5 min

Enforce a rule with a hook

CLAUDE.md is advice a model can miss. A hook is your own command that Claude Code runs at a fixed point in every session — and one exit code turns it from observation into enforcement.

The Third Way engineering team||Verified against Claude Code v2.1.285 (stable), October 2026

01The goal

Write a hook that blocks Claude’s edits to files you protect, register it in settings, test it without Claude, and watch it fire in a session.

02The idea

A hook is a command of yours that Claude Code runs at a fixed point in a session — every time, whatever Claude decides. Claude Code passes the hook JSON about the event on stdin; your script reads it, decides, and speaks through its exit code. The one that matters: a PreToolUse hook that exits with code 2 blocks the tool call — not a warning in a log — and what the script printed to stderr goes to Claude as the reason.

↳The events of one turn
SessionStart

Fires as the session begins — setup, environment notes.

UserPromptSubmit

Each prompt you send, before Claude acts on it.

PreToolUse

Before each tool call. Exit 2 blocks it; stderr becomes the reason Claude sees.

PostToolUse

After a successful tool call — format, log, tidy up.

Stop

When Claude finishes responding.

Notification

Whenever Claude Code needs your attention — a prompt, permission, or idle nudge.

A full reference of events exists — this is the spine a session actually runs on. Lesson 2 already smuggled one past you: the load log was an InstructionsLoaded hook.

A matcher picks which tools a hook watches: "Edit|Write" runs it for those two tools only. PreToolUse hooks fire before the permission-mode machinery, so a block holds in every mode — manual, accept-edits, plan, auto. That is the property CLAUDE.md lacks and the reason this lesson exists: a rule that must never be broken does not belong in prose.

03The walkthrough

Two hooks, both small enough to read in full — the only kind worth registering.

  1. 01Write .claude/hooks/protect-files.sh and make it executable. The shape below: read the JSON on stdin, take the file path, and block the paths the team must never let an agent touch. Edit and Write always send an absolute path in the JSON, so match on that.
  2. 02Write .claude/hooks/format-after-edit.sh — a PostToolUse twin that trims trailing whitespace from the file Claude just edited. The event arrives the same way; your script just responds differently.
  3. 03Register both in .claude/settings.json beside the permissions block from lesson 4 — PreToolUse with matcher "Edit|Write" pointing at protect-files.sh, PostToolUse with the same matcher pointing at format-after-edit.sh. Give each hooks entry "args": [] so Claude Code runs the script directly, with no shell — paths with spaces need no quoting. Prefix the command with ${CLAUDE_PROJECT_DIR} so it works from any folder in the project.
  4. 04Test without Claude — the step most people skip and the one that makes hooks maintainable. Pipe the JSON an Edit call would send straight into the script: echo "{\"tool_input\":{\"file_path\":\"$PWD/package-lock.json\"}}" | .claude/hooks/protect-files.sh; echo "exit $?". It prints a Blocked: line and exits 2. Run it again with a path you do not protect; exit 0.
  5. 05Start claude and run /hooks: a read-only browser listing your hooks and where each came from. Then ask for an edit the hook protects — "Use your Edit tool, not a shell command, to change the top-level version in package-lock.json to 1.0.1." Name the tool: an Edit|Write matcher does not see a shell-command edit. The edit is blocked, and Claude receives your Blocked: message as feedback.
↳.claude/hooks/protect-files.sh — the shape
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
FILE_PATH="${FILE_PATH//\\//}"
NAME=$(basename "$FILE_PATH")
 
case "/$FILE_PATH" in
*/package-lock.json | */.git/*)
echo "Blocked: $FILE_PATH is protected" >&2
exit 2
;;
esac
 
exit 0
Read stdin, extract the path, decide, speak through exit code. The full event reference documents every field your scripts can read.

04Your turn

Extend the protection: block a file named .env and .env.<anything>, a secrets/ folder anywhere in the project — and deliberately not .env.example, which teams commit on purpose, and not src/config.envelope.ts, which merely looks like one. Keep the package-lock.json and .git/ blocks. Test each path with the pipe trick until every row gives the exit code you expect: 2 for .env, config/.env.local, secrets/api.key, package-lock.json, .git/config; 0 for .env.example, src/config.envelope.ts, src/app.js. Then ask Claude, in a session, to use its Write tool to create secrets/test.key — your message comes back as the reason. Commit the script (keep it executable — git ls-files -s shows 100755) and .claude/settings.json together.

Hooks run with your user’s rights — in an interactive session once you trust the folder, and in a claude -p run without asking. Read a repository’s hooks before you trust it or script Claude Code in it; this cuts both ways, since you are also someone else’s colleague. And know the blind spot: an Edit|Write hook does not see shell commands — echo X >> .env goes through Bash. Lesson 4’s Read denies stop the cat direction; the write direction is checked against Edit rules, or covered by a hook with a Bash matcher, or a Stop hook that scans the working tree after the fact. Defence in depth, like everywhere else.

↳Check yourself
Q1

What does a PreToolUse hook’s exit code 2 do?

  1. ALogs a warning and lets the call through
  2. BBlocks the tool call, with stderr as the reason Claude sees
  3. CEnds the session
→Check your answer
Answer: B

The call does not happen, and Claude receives what the script printed to stderr as feedback — enforcement with an explanation, which is why the Blocked: message is worth writing well.

Q2

Why does a PreToolUse block hold in auto mode, where nothing prompts?

  1. AAuto mode disables hooks
  2. BAuto mode runs hooks as root
  3. CPreToolUse fires before the permission-mode check
→Check your answer
Answer: C

The hook runs before the permission machinery, so no mode relaxes it. Prose in CLAUDE.md has no such guarantee — that is the division of labour between them.

Q3

Claude edited .env with a shell command and your Edit|Write hook never fired. Why?

  1. AThe matcher only watches the Edit and Write tools
  2. BThe hook needed "args": []
  3. CHooks do not run in accept-edits mode
→Check your answer
Answer: A

A matcher is a filter over tool calls — shell commands are Bash calls and need a Bash matcher (or a Stop hook) to be seen at all.

The Third Way engineering team
Delivery engineering
Work with us