s/agents-development
Hooks and Policy-as-Code
Last updated @legostin · 2026-04-11T17:45:58+00:00
Hooks and Policy-as-Code
CLAUDE.md tells the agent what you want. Permission rules declare which tools it may use. Neither of those is enough on its own for production. Sooner or later you need deterministic, programmatic checks that run regardless of what the model decides — "if it tries to edit a file matching infra/prod/**, block it. Every time. No exceptions."
That's what hooks are for. A hook is a shell command, HTTP endpoint, or LLM prompt that Claude Code runs at a specific point in its lifecycle. Hooks get JSON on stdin, control the flow through exit codes, and can block actions the model was about to take.
This page covers the hook model, all the event types that matter in production, and a few concrete patterns.
Hook events
| Event | When it fires | Can block? |
|---|---|---|
SessionStart |
Session begins or resumes | No |
UserPromptSubmit |
User submits a prompt, before processing | Yes |
PreToolUse |
Before a tool call executes | Yes |
PostToolUse |
After a tool call succeeds | No |
PostToolUseFailure |
After a tool call fails | No |
PermissionRequest |
A permission dialog is about to show | Yes |
PermissionDenied |
Auto mode denied a tool call | No |
Stop |
Claude finishes responding | Yes |
SubagentStart / SubagentStop |
Subagent lifecycle | Yes (Stop) |
PreCompact / PostCompact |
Around context compaction | No |
SessionEnd |
Session terminates | No |
The ones you will actually use: PreToolUse (block dangerous actions), PostToolUse (lint, format, test after edits), SessionStart (load environment), and UserPromptSubmit (sanitize / log prompts).
Where hooks live
Hooks are configured in settings.json, which has a clear precedence chain:
~/.claude/settings.json (user, all projects)
.claude/settings.json (project, checked in)
.claude/settings.local.json (project, gitignored)
managed-settings.json (org-wide, cannot be overridden)
The structure is three levels: event → matcher group → handlers.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/check-bash.sh"
}
]
}
]
}
}
The matcher field can be the tool name ("Bash"), a pipe-separated list ("Edit|Write"), or a JS regex ("mcp__memory__.*").
Handler types
Command hook — runs a shell command. Receives JSON on stdin with session info, cwd, tool name, and tool input.
{
"type": "command",
"command": "./.claude/hooks/check.sh",
"timeout": 30
}
HTTP hook — POSTs JSON to a URL, reads the response. Useful for centralized policy servers.
{
"type": "http",
"url": "http://policy.internal/claude",
"headers": { "Authorization": "Bearer $POLICY_TOKEN" },
"allowedEnvVars": ["POLICY_TOKEN"]
}
Prompt hook — runs a quick LLM check (cheap model). Good for fuzzy rules like "does this command match our ops policy?".
Agent hook — runs a full agent review. Heavy, but sometimes worth it.
Exit code semantics
Claude Code's exit code conventions are unusual. Read this once and pin it.
| Exit code | Meaning |
|---|---|
0 |
Success. Claude parses stdout as JSON for structured control. |
2 |
Blocking error. Stops the action. stderr is shown to Claude. |
| anything else | Non-blocking error. Shown in transcript. Execution continues. |
Exit 1 is treated as a non-blocking error. Use exit 2 only for policy enforcement.
Pattern 1: block dangerous bash
#!/bin/bash
# .claude/hooks/block-rm.sh
COMMAND=$(jq -r '.tool_input.command' < /dev/stdin)
if echo "$COMMAND" | grep -q 'rm -rf'; then
echo "Blocked: rm -rf requires manual execution" >&2
exit 2
fi
exit 0
Registered as:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/block-rm.sh" }
]
}
]
}
}
Pattern 2: PatchGate — lint and test after every edit
#!/bin/bash
# .claude/hooks/patchgate.sh — runs after Edit/Write
cd "$CLAUDE_PROJECT_DIR" || exit 0
if ! npm run lint --silent; then
echo "Lint failed — please fix before continuing" >&2
exit 2
fi
if ! npm test --silent -- --changed; then
echo "Tests failed on changed files" >&2
exit 2
fi
exit 0
Registered on PostToolUse for Edit|Write. This turns "green CI" into a per-edit invariant — the agent cannot advance past a broken state.
Pattern 3: redact secrets from prompts
#!/bin/bash
# .claude/hooks/redact-secrets.sh — fires on UserPromptSubmit
PROMPT=$(jq -r '.user_prompt' < /dev/stdin)
if echo "$PROMPT" | grep -qE 'AKIA[0-9A-Z]{16}|sk-[a-zA-Z0-9]{40,}'; then
echo "Prompt contains what looks like a secret. Scrub and resubmit." >&2
exit 2
fi
exit 0
JSON output for fine-grained control
Instead of plain exit codes, a hook can exit 0 and print structured JSON to stdout. This lets you allow, deny, modify input, or inject context:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Writes to infra/prod/ require a change ticket"
}
}
Or to modify the tool call before it runs:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"updatedInput": {
"command": "npm test -- --runInBand"
}
}
}
Interaction with permission rules
Hooks run before permission rules are consulted. A hook that exits 2 blocks the action regardless of allow rules. A hook that exits 0 does not bypass deny rules — they still fire afterward.
This lets you write open policies ("allow Bash") with targeted hooks that block specific patterns, rather than maintaining an ever-growing deny list.
Viewing and disabling
/hooksinside Claude Code shows a read-only browser of all configured hooks and their sources."disableAllHooks": truein settings disables everything temporarily — useful for debugging, never in CI.
Security warnings
- Hook scripts run with your user's permissions. Treat them like any cron job — they can do damage if written wrong.
- JSON on stdin can be corrupted by
.bashrcor.zshrcoutput. Make sure hook output is only the JSON object. - Don't commit hooks that reference secrets. Use env vars and document them.
Next: Multi-Agent Patterns