Wire up Claude Code hooks so “don't touch that file” is policy, not a polite request
PreToolUse and PostToolUse โ small shell scripts that run on every tool call. The cheapest way to turn a convention into a guardrail Claude actually can't cross.
Claude Code hooks are shell commands that fire on specific lifecycle events — before a tool runs, after a tool runs, when the agent stops, when a session starts. They are declared in settings.json, scoped per-project or global, and receive a JSON blob on stdin describing exactly what's about to happen. The exit code decides what happens next: zero lets the tool call through, two blocks it and feeds stderr back to the model as the reason. They are the difference between telling Claude not to touch .env in CLAUDE.md and making it physically unable to.
Why feature hooks on the first Workshop. Of all the configuration surfaces in Claude Code — slash commands, subagents, MCP servers, settings — hooks are the one that converts convention into enforcement at the lowest cost. A line in CLAUDE.md is a request the model can forget or rationalise around; a PreToolUse hook is a process boundary the model can't cross. For an SRE working on infra repos where the difference between a typo and an outage is one file, that distinction is the whole point.
Guardrails (PreToolUse, exit 2 to block). The canonical first hook reads the proposed Write or Edit payload, checks the target path against a deny-list, and exits non-zero if it matches. Block writes to .env*, **/secrets/**, terraform.tfstate, anything under infra/prod/. Block Bash calls whose command starts with rm -rf or git push --force. The hook's stderr output becomes the message Claude sees, so write it as a sentence: “Refusing to write to .env — commit a .env.example instead.” The model reads it, picks a different path, and moves on.
Format and check (PostToolUse, exit 0, side effects). The second hook fires after a write succeeds and is where you bolt the team's standards onto the agent's output. Run prettier --write or ruff format on the file that just changed, so the diff in the PR looks like one of your engineers wrote it. Run tsc --noEmit or mypy on the touched file and feed the errors back via stderr — the agent sees them on the next turn and fixes them before you ever review. Run git status into a log file so you have an audit trail of what the agent touched in this session. None of these block; they just make the agent's work meet the bar.
Observability (SessionStart, Stop, Notification). The third bucket is the one most people don't reach for and should. A SessionStart hook can pre-load context: print the output of git log -5 --oneline and gh pr list --author @me so the agent starts every session knowing what's in flight. A Stop hook can post a summary to Slack or write a row to a CSV so you can answer “what did Claude do this week” without scrolling through six transcripts. A Notification hook can ping you when the agent has been waiting for input for more than thirty seconds — useful when you've context-switched and forgotten a session is open.
The trap to avoid is using hooks as a substitute for permissions. Hooks run synchronously and add latency to every tool call — a 200ms hook on Edit shows up as a noticeably slower agent. They also fail open if the script errors out (a syntax error in the shell script becomes “tool call proceeded anyway”), so the deny-list belongs in the permissions block of settings.json first, and hooks should add the things permissions can't express — conditional logic, path patterns with carve-outs, anything that depends on the content of the call rather than just the tool name.
See the try-it block for a 12-line PreToolUse hook that blocks edits to .env* files and tells the agent why.
Create the script. Make it executable. It reads the tool call from stdin and exits 2 to block:
mkdir -p .claude/hooks
cat > .claude/hooks/block-env.sh <<'EOF'
#!/usr/bin/env bash
input=$(cat)
path=$(echo "$input" | jq -r '.tool_input.file_path // empty')
if [[ "$path" == *.env* || "$path" == */secrets/* ]]; then
echo "Refusing to write to $path. Commit a .env.example instead." >&2
exit 2
fi
exit 0
EOF
chmod +x .claude/hooks/block-env.sh
Wire it up in .claude/settings.json so it runs before every Write or Edit:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{ "type": "command", "command": ".claude/hooks/block-env.sh" }
]
}
]
}
}
Restart Claude Code and ask it to “add a DATABASE_URL to .env”. You should see the tool call refused with your sentence in the agent's view; it will then propose writing to .env.example instead. If the hook doesn't fire, check jq is on PATH and the script is executable.