A developer was cleaning up an old repository. Claude executed rm -rf tests/ patches/ plan/ ~/. That trailing ~/ wiped their entire Mac - Desktop, Documents, Downloads, Keychain, application data. Years of work, gone in seconds. The command had been approved because the user was focused on the first three directories.
CLAUDE.md had a rule: "never run destructive commands." The model had read it. The model had followed it hundreds of times. And then, in one context-heavy session with a legitimately complex cleanup task, it didn't.
This is the fundamental problem with prompt-based safety in agentic systems: prompts are probabilistic. They suggest behavior. They do not guarantee it. A sufficiently long conversation dilutes instructions. A novel task framing routes around them. Adversarial inputs can override them entirely. You would not secure a database with a SQL comment that says "please don't DROP TABLE." You should not secure an autonomous agent with a markdown instruction that says "please don't delete important files."
Hooks are the enforcement mechanism that makes agent policy deterministic. They are shell scripts - and HTTP endpoints, and LLM-based validators - that fire at fixed points in the agent lifecycle and physically intercept tool calls before or after execution. A PreToolUse hook that blocks rm -rf does not rely on the model remembering your policy. It runs as a separate process every time the pattern appears, regardless of conversation length, regardless of context state, regardless of what the model believes it should do. The model cannot reason its way around it. The model cannot forget it. The hook runs.
This is the shift the slide names: policy-as-code for agents. Every rule you trust to a CLAUDE.md instruction is a rule the agent can violate. Every rule encoded in a hook is a rule the agent cannot violate. That distinction determines whether your safety guarantees are real or probabilistic.
The Hook Architecture: Four Lifecycle Events That Matter
Hooks fire at specific, fixed points in the agent session lifecycle. The slide identifies the four that cover the majority of production enforcement needs:
SessionStart - Fires once when the session initializes. Use it to load secrets into the environment, inject context that should be present for the entire session, or set up state the agent will reference throughout.
PreToolUse - Fires after Claude creates tool parameters but before the tool executes. This is the enforcement event. It is the only hook that can physically block a tool call before it runs. Everything that should never happen lives here.
PostToolUse - Fires after a tool completes successfully. This is the quality event. Use it to format output, run linters, append to audit logs, or inject feedback back into Claude's context based on what the tool returned.
Stop - Fires when Claude finishes responding and is about to stop. Use it to enforce completion gates: tests must pass, required files must exist, output quality must meet threshold. A Stop hook that exits with code 2 forces Claude to keep working.
These four events bracket the agent's tool execution loop completely. SessionStart sets the context. PreToolUse guards entry. PostToolUse validates exit. Stop enforces completion. Together they create a deterministic control layer that wraps every agent action.
The configuration that drives all of this is .claude/settings.json (project-scoped, committed to the repo) or ~/.claude/settings.json (user-scoped, applies across all projects):
{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "command": "./guards/block-rm-rf.sh" }, { "command": "./guards/block-force-push.sh" } ] }, { "matcher": "Edit|Write", "hooks": [ { "command": "./guards/no-secrets.sh" } ] } ], "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "command": "prettier --write $FILE" }, { "command": "./audit-log.sh" } ] } ] }}This is the exact configuration shown in the slide. Four guards covering the most critical failure modes: destructive commands, force pushes, secret leakage, and audit logging. Each guard is a script. Each script runs deterministically. None of them involve asking the model to remember a policy.
Why Prompts Fail Where Hooks Succeed
The temptation is to handle everything in CLAUDE.md: "never run rm -rf", "never commit secrets", "always run tests before finishing". This works reliably in short sessions on simple tasks. It breaks in production for three reasons:
Context dilution. As a session grows, early instructions compete with later context for attention weight. A constraint stated at session start with 1,000 tokens in the window carries different weight than the same constraint buried under 80,000 tokens of subsequent tool output. The model still sees it. It attends to it less.
Novel task framing. An agent asked to "clean up the workspace aggressively" in a context where multiple legitimate deletions have already occurred may classify rm -rf as consistent with the established pattern. It is not ignoring your rule. It is applying its judgment about what the rule means in context. That judgment can be wrong.
Adversarial inputs. Prompt injection through files, web content, or tool outputs can override inline constraints. An attacker who controls content the agent reads can instruct the agent to ignore previous safety rules. A PreToolUse hook is a separate process running outside the model's context - it cannot be injected into.
Trail of Bits, who operates Claude Code in security-critical environments, articulates this precisely in their production configuration: hooks are "structured prompt injection at opportune times: intercepting tool calls, injecting context, blocking known-bad patterns, and steering agent behavior. Guardrails, not walls." The framing matters. Hooks are not a complete security boundary - a human can still disable them, and novel attack patterns can route around them. But they are categorically more reliable than prompts for the class of rules that must not be probabilistic.
The practical threshold is: if violating the rule produces a consequence you cannot recover from in production (data loss, secret exposure, forced push to protected branch), it belongs in a hook. If violating the rule produces a consequence you can fix on the next turn (wrong format, missed lint rule, suboptimal output), it can live in a prompt.
The Wrong Way: Prompt-Only Safety
Here is the naive implementation that most teams start with. Every safety rule lives in CLAUDE.md. The agent reads it at session start and is expected to follow it throughout.
<!-- CLAUDE.md (wrong way - prompt-only safety) -->## CRITICAL SAFETY RULES**NEVER run these commands:**- rm -rf (any destructive deletion)- git push --force (force push to any branch)- Any command that modifies .env files**ALWAYS do before finishing:**- Run the full test suite- Check for hardcoded secrets- Format all modified files with Prettier**SECURITY:**- Never write API keys or tokens to files- Never commit .env filesThis looks comprehensive. It covers the right categories. And it will work correctly in the large majority of sessions. But "large majority" is not a guarantee. Production agents run thousands of sessions. Across thousands of sessions, the cases where context dilution, novel framing, or adversarial input causes a violation are not hypothetical - they are documented, named incidents.
The home directory nuke above had a CLAUDE.md rule. The Replit production database wipe during an explicit code freeze had rules. The secret exposed to a public repo for 11 days generating $30,000 in fraudulent charges - that developer had told their agent not to commit credentials. Prompt-only safety produces "the agent usually follows the rules." Hooks produce "the agent cannot break the rules."
The Right Way: Policy-as-Code with Hooks
Every rule that must not be probabilistic gets a hook. The implementation pattern is consistent across all four lifecycle events.
PreToolUse: The Enforcement Guard
#!/bin/bash# .claude/guards/block-rm-rf.sh# Reads JSON from stdin, blocks any bash command containing rm -rfset -euo pipefailINPUT=$(cat)TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty')# Only applies to Bash tool[ "$TOOL" != "Bash" ] && exit 0COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')# Block rm -rf in any formif echo "$COMMAND" | grep -qE 'rm\s+-rf'; then jq -n '{ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "Destructive rm -rf blocked. Use trash or targeted deletion." } }' exit 0fiexit 0 # Allow all other commands#!/bin/bash# .claude/guards/block-force-push.sh# Blocks git push --force to any branchINPUT=$(cat)COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')if echo "$COMMAND" | grep -qE 'git\s+push\s+.*(-f|--force)'; then jq -n '{ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "Force push blocked. Create a PR or request explicit approval." } }' exit 0fiexit 0#!/bin/bash# .claude/guards/no-secrets.sh# Blocks writes to Edit|Write tools that contain hardcoded credentialsINPUT=$(cat)CONTENT=$(echo "$INPUT" | jq -r '.tool_input.new_content // .tool_input.new_string // empty')# Pattern: API_KEY/SECRET/TOKEN followed by = and a 16+ char valueif echo "$CONTENT" | grep -qE '(API_KEY|SECRET|TOKEN|PASSWORD)\s*[=:]\s*["'"'"'][A-Za-z0-9_\-]{16,}'; then jq -n '{ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "Hardcoded credential detected. Use environment variables." } }' exit 0fiexit 0The exit code semantics are precise and must be correct:
exit 0with no JSON output: allow the operation, proceed- JSON with
permissionDecision: "deny": block the tool call, reason visible to Claude exit 2without JSON: block the tool call, reason shown directly to the user (not Claude)
The critical mistake: using exit 1 instead of exit 2 for security hooks. exit 1 signals a hook error - Claude Code logs it and proceeds with the tool call anyway. exit 2 or a deny decision in JSON actually blocks execution. Every security-critical PreToolUse hook must explicitly deny, not just exit non-zero.
SessionStart: Context Injection
#!/bin/bash# .claude/hooks/session-start.sh# Runs once when session initializes.# stdout is injected as context into Claude's conversation (not into the shell environment)# For actual environment variable injection, use Claude Code's env config or a wrapper script# Inject current git status and open work items as session contextecho "## Session Context (auto-injected at session start)"echo "Branch: $(git branch --show-current 2>/dev/null || echo 'not a git repo')"echo "Uncommitted changes: $(git status --short 2>/dev/null | wc -l | tr -d ' ') files"echo "Open TODOs in src/: $(grep -r 'TODO:' src/ 2>/dev/null | wc -l | tr -d ' ')"echo ""echo "## Active protection rules"echo "- rm -rf: BLOCKED (PreToolUse guard active)"echo "- git push --force: BLOCKED (PreToolUse guard active)"echo "- Secrets in files: BLOCKED (Edit|Write guard active)"echo "- All tool calls: LOGGED (audit.jsonl)"{ "hooks": { "SessionStart": [ { "hooks": [ { "command": "./.claude/hooks/session-start.sh" } ] } ] }}SessionStart has no matcher (it is not tool-based) and fires once per session. The output from the script is injected as context into the conversation. This is the mechanism for loading state that should be present throughout the session without requiring the agent to fetch it on every turn.
PostToolUse: Quality Enforcement
#!/bin/bash# .claude/hooks/audit-log.sh# Appends structured entry to audit log after every Bash and Edit|Write call# Input arrives on stdin as JSON from Claude CodeINPUT=$(cat)TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')TOOL=$(echo "$INPUT" | jq -r '.tool_name // "unknown"')COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')EXIT_CODE=$(echo "$INPUT" | jq -r '.tool_response.exit_code // ""')# Append structured JSONL entry - append-only, never truncatejq -nc \ --arg ts "$TIMESTAMP" \ --arg sid "$SESSION_ID" \ --arg tool "$TOOL" \ --arg cmd "$COMMAND" \ --arg file "$FILE" \ --arg exit "$EXIT_CODE" \ '{"timestamp":$ts,"session_id":$sid,"tool":$tool,"command":$cmd,"file":$file,"exit_code":$exit}' \ >> .claude/audit.jsonlThe PostToolUse hook receives both tool_input (what Claude sent to the tool) and tool_response (what the tool returned). This makes it the correct place for audit logging - you capture the intent AND the outcome, not just one. It is also the place for automatic formatting: the formatter runs on every file edit, unconditionally, regardless of whether Claude remembered to run it.
{ "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "command": "prettier --write $CLAUDE_TOOL_INPUT_FILE_PATH" }, { "command": "./.claude/hooks/audit-log.sh" } ] }, { "matcher": "Bash", "hooks": [ { "command": "./.claude/hooks/audit-log.sh" } ] } ] }}Multiple hooks on the same matcher run in order. If you need them to run in parallel (for independent validations), use & and wait in a wrapper script.
Stop: Completion Gates
#!/bin/bash# .claude/hooks/enforce-tests-pass.sh# Blocks the agent from stopping if tests have not been run and passedINPUT=$(cat)TRANSCRIPT=$(echo "$INPUT" | jq -r '.transcript_path // empty')# Check if any test runner was invoked during this sessionif [ -n "$TRANSCRIPT" ] && ! grep -q '"pytest\|npm test\|go test\|cargo test"' "$TRANSCRIPT" 2>/dev/null; then echo "Tests not run. Run the test suite before finishing." >&2 exit 2 # Forces Claude to continue workingfi# If tests were run, check the last test exit codeLAST_TEST_EXIT=$(grep -o '"exit_code":[0-9]*' "$TRANSCRIPT" 2>/dev/null | tail -1 | grep -o '[0-9]*')if [ "$LAST_TEST_EXIT" != "0" ] && [ -n "$LAST_TEST_EXIT" ]; then echo "Last test run exited with code $LAST_TEST_EXIT. Fix failing tests before finishing." >&2 exit 2 # Forces Claude to continue workingfiexit 0 # Allow stop{ "hooks": { "Stop": [ { "hooks": [ { "command": "./.claude/hooks/enforce-tests-pass.sh" } ] } ] }}The Stop hook is underused. Most teams configure PreToolUse guards and PostToolUse formatters but skip Stop. This is a mistake. Without a Stop gate, the agent can declare victory before the work is actually complete - a failure mode documented extensively in production autonomous agent deployments. The agent runs for 45 minutes, implements a feature, and stops. Tests were not run. Docs were not updated. The code compiles but the feature doesn't work. A Stop hook that checks for test execution and required artifacts forces the agent to continue if completion criteria are not met.
The Policy-as-Code Mental Model
The term in the slide - "policy-as-code" - is precise. It maps hooks onto the pattern that infrastructure teams have used for a decade to move from documented policy to enforced policy.
In infrastructure, "policy-as-code" means rules are expressed in executable code (Terraform sentinels, OPA policies, GitHub Actions checks) rather than in documentation that humans are expected to follow. The enforcement happens at the infrastructure level, not at the human level. An SRE who violates a Terraform sentinel policy doesn't need to be reminded of the rule - the deployment fails.
Hooks do the same thing for agent behavior. A rule in CLAUDE.md is documentation. A rule in a PreToolUse hook is enforcement. The agent that violates the CLAUDE.md rule needs to be reminded of it (and may not be). The agent that violates the hook policy finds the tool call blocked, with a specific reason code, every time.
The practical architecture this suggests is a clear separation of concerns:
In CLAUDE.md (behavioral guidance): How to approach problems. What style to use. When to ask for clarification. Preferences, not requirements.
In hooks (enforced policy): What operations are categorically blocked. What quality gates must pass. What must be logged. Requirements, not preferences.
This separation matters operationally. When a new team member joins, they can read CLAUDE.md to understand how Claude behaves on your project. They cannot accidentally disable your safety guarantees by writing differently in their CLAUDE.md additions - because those guarantees live in hooks, which run regardless of what any markdown file says.
The Hook Lifecycle: What the Enforcement Layer Looks Like End-to-End
flowchart TD
A[SessionStart]:::green --> B[Load secrets\nInject context\nSet up state]:::teal
B --> C[User prompt submitted]:::blue
C --> D[Claude plans tool call]:::blue
D --> E{PreToolUse hook\nmatches?}:::purple
E -->|No matcher match| F[Tool executes]:::blue
E -->|Matcher matches| G[Guard script runs]:::orange
G -->|exit 0 / allow| F
G -->|deny decision| H[Tool blocked\nReason returned to Claude]:::red
H --> D
F --> I{PostToolUse hook\nmatches?}:::purple
I -->|No| J{More tool calls?}:::purple
I -->|Yes| K[Formatter runs\nAudit logged\nFeedback injected]:::teal
K --> J
J -->|Yes| D
J -->|No| L{Stop hook?}:::purple
L -->|Tests passed\nGates met| M[Session ends]:::green
L -->|Gates not met| N[exit 2: Claude\nmust continue]:::red
N --> D
classDef blue fill:#4A90E2,color:#fff,stroke:#3A7BC8
classDef purple fill:#7B68EE,color:#fff,stroke:#6858DE
classDef teal fill:#98D8C8,color:#fff,stroke:#88C8B8
classDef green fill:#6BCF7F,color:#fff,stroke:#5BBF6F
classDef red fill:#E74C3C,color:#fff,stroke:#D43C2C
classDef orange fill:#FFA07A,color:#fff,stroke:#EF906A
The PreToolUse hook has one additional capability that most teams miss: it can not only block tool calls but also modify the tool input before execution. Rather than blocking a rm -rf variant that might be legitimate (cleaning a build directory), a hook can add safeguards transparently - converting a destructive command to a safer form:
#!/bin/bash# .claude/guards/sandbox-rm.sh# Instead of blocking rm -rf entirely, redirect to trash-cli for reversibilityINPUT=$(cat)COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')# Redirect rm -rf on build directories to trash (reversible deletion)if echo "$COMMAND" | grep -qE 'rm\s+-rf\s+.*(build|dist|\.next|node_modules)'; then SAFE_CMD=$(echo "$COMMAND" | sed 's/rm -rf/trash/g') jq -n \ --arg cmd "$SAFE_CMD" \ '{ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", permissionDecisionReason: "rm -rf redirected to trash for reversibility", updatedInput: { command: $cmd } } }' exit 0fiexit 0The updatedInput field passes a modified version of tool_input to the tool. Claude sees the allow decision. The tool receives the modified command. The modification is transparent - the agent believes it executed the original intent, and the result is recoverable if the deletion was a mistake.
This is the Probabilistic-to-Deterministic Boundary - the architectural line between behavior the agent decides (probabilistic, context-dependent, can be wrong) and behavior the enforcement layer decides (deterministic, context-independent, always correct). Everything above the boundary is agent reasoning. Everything at and below the boundary is hook enforcement. The goal of a production hook architecture is to push as many irreversible or high-consequence operations below this boundary as possible.
Hooks Are Recursive
One production behavior that surprises teams: hooks fire for subagent tool calls too. When Claude spawns a subagent via the Agent tool, that subagent's PreToolUse, PostToolUse, and Stop hooks execute using the same hook configuration. Your safety gates apply recursively down the delegation tree.
This is critical for the multi-agent architectures covered in the previous article in this series. An orchestrator with a PreToolUse guard against rm -rf passes that guard to every subagent it spawns. A researcher subagent that discovers a "helpful" cleanup opportunity cannot execute it. A reviewer subagent that decides to fix something it found cannot write to files if the reviewer's tool restrictions prevent it.
The corollary: SubagentStop is the hook event that fires when a subagent completes. Use it for cleanup, validation, and result logging specific to the subagent's scope - separate from the orchestrator's Stop gate which fires when the full session ends.
Performance: What Running Hooks on Every Tool Call Actually Costs
The concern about hook overhead is legitimate. PreToolUse hooks run synchronously - they block tool execution until they complete. A hook that takes 2 seconds on every bash command adds 2 seconds to every bash call the agent makes. In a session with 200 bash calls, that is 400 seconds of overhead.
The operational threshold from practitioners who run hundreds of hooks in production: keep PreToolUse hooks under 500ms. For most guard scripts (pattern matching on a string, checking a file list), this is trivially achievable - sub-10ms is typical. The mistake is running heavyweight validation (full secret scans, network calls, test runs) in PreToolUse on every matched call.
Three patterns manage overhead:
Smart dispatching. One entry-point script routes to specific guards based on the command content, rather than running all guards on every call. A bash hook that first checks whether the command matches any dangerous pattern at all (a single grep pass) and only runs deeper validation on matches avoids the overhead on the 95% of commands that are plainly safe.
Async hooks for non-blocking operations. Audit logging, notification forwarding, and webhook calls do not need to complete before the agent continues. Add "async": true to hooks that do not need to influence execution. The log entry gets written; the agent does not wait for it.
PostToolUse for non-blocking quality checks. Formatting, linting, and style checks happen after the tool executes and cannot block it retroactively. Put them in PostToolUse, not PreToolUse. This eliminates the latency from the tool call path while still ensuring quality.
Production Checklist: Hooks Before You Ship Autonomous Agents
PreToolUse enforcement
- Is
rm -rf(or equivalent destructive deletion) blocked? Exit code must produce a deny decision, not justexit 1. - Is
git push --forceblocked? At minimum for protected branches, ideally for all branches. - Is hardcoded secret detection (API keys, tokens, passwords) active on
Edit|Writetools? - Is direct write to
.env,*.pem,*.key, and production config files blocked? - Are all blocking hooks using
exit 2orpermissionDecision: "deny"rather thanexit 1?
SessionStart context
- Are secrets loaded from a vault or secret manager, not from
.envfiles in the repo? - Is relevant session context (branch, open issues, pending tasks) injected at start rather than fetched per-turn?
PostToolUse quality
- Does every file edit run the formatter automatically? (Prettier, Black, gofmt, etc.)
- Is there a structured audit log of every tool call with timestamp, session ID, tool name, and result?
- Are audit logs written in a queryable format (JSONL) rather than plaintext?
Stop gates
- Does the
Stophook verify that the test suite was run at least once in this session? - Does the
Stophook verify that required output artifacts exist if the task produces them? - Is
exit 2used to force continuation when gates are not met (notexit 1, which only warns)?
Operational
- Are all hook scripts tested manually before deployment? (
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | ./guards/block-rm-rf.sh) - Is
.claude/settings.jsoncommitted to the repo so the entire team gets the same hooks? - Are user-level hooks (
~/.claude/settings.json) reviewed for conflicts with project-level hooks? - Is hook execution time profiled? Any
PreToolUsehook over 500ms needs optimization. - For autonomous agent deployments: is there a human checkpoint mechanism for operations the hooks allow but that carry residual risk?
References
- Anthropic / Claude Code Docs. (2026). Hooks reference. https://code.claude.com/docs/en/hooks
- Anthropic / Claude Agent SDK Docs. (2026). Intercept and control agent behavior with hooks. https://platform.claude.com/docs/en/agent-sdk/hooks
- Dotzlaw, G. et al. (January 2026). Claude Code Hooks: The Deterministic Control Layer for AI Agents. https://www.dotzlaw.com/insights/claude-hooks/
- Crosley, B. (March 2026). Claude Code Hooks Tutorial: 5 Production Hooks From Scratch. https://blakecrosley.com/blog/claude-code-hooks-tutorial
- Paddo. (January 2026). Claude Code Hooks: Guardrails That Actually Work. https://paddo.dev/blog/claude-code-hooks-guardrails/
- Trail of Bits. (2026). Opinionated defaults for Claude Code. https://github.com/trailofbits/claude-code-config
- Yurukusa. (2026). 16 production hooks from 700+ hours of autonomous Claude Code operation. https://github.com/yurukusa/claude-code-hooks
- Disler. (2026). Claude Code Hooks Mastery. https://github.com/disler/claude-code-hooks-mastery
- Pixelmojo. (2026). Claude Code Hooks: All 12 Events with Examples. https://www.pixelmojo.io/blogs/claude-code-hooks-production-quality-ci-cd-patterns
- ClaudeLog. (2026). Hooks Mechanics Reference. https://claudelog.com/mechanics/hooks/
- ClaudeFast. (2026). Claude Code Hooks: Complete Guide to All 12 Lifecycle Events. https://claudefa.st/blog/tools/hooks/hooks-guide
- Backslash Security. (2026). Claude Code Security Best Practices. https://www.backslash.security/blog/claude-code-security-best-practices
- Anthropic / GitHub. (2026). hook-development skill - SKILL.md. https://github.com/anthropics/claude-code/blob/main/plugins/plugin-dev/skills/hook-development/SKILL.md
Related Articles
- Which Claude Code Layer Solves Your Problem? A Diagnostic Guide for AI Engineers
- Agent Skills Are Not Prompts. They Are Production Knowledge Infrastructure.
- Claude Code Guide: Build Agentic Workflows with Commands, MCP, and Subagents
- Subagents: How to Run Parallelism Inside a Single Agent Session Without Poisoning the Parent