Hook Catalog for Claude Code Enforcement¶
Claude Code hooks are shell commands that intercept agent lifecycle events — blocking forbidden tool calls, enforcing CLI standards, and automating side effects — without relying on the model to follow instructions.
Also known as
Hook Examples & Recipes, Common Enforcement Patterns, Enforcing with Hooks, Hook Enforcement Patterns.
Why Hooks¶
Models carry strong priors (npm, git add -A, curl) and revert under pressure. Hooks move enforcement out of the context window and into the shell.
| Approach | Reliability | Override risk |
|---|---|---|
| AGENTS.md instruction | Low | High — model may ignore under pressure |
| System prompt rule | Medium | Medium — multi-step tasks cause drift |
PreToolUse hook |
High | Low — bypass via tool-switching (see When This Backfires) |
How Hooks Work¶
Claude Code hooks run on agent lifecycle events. Claude Code passes JSON on stdin; use jq to extract fields.
| Event | Fires when |
|---|---|
PreToolUse |
Before any tool call — can block it |
PostToolUse |
After a tool call succeeds |
UserPromptSubmit |
When the user sends a message |
Stop |
When the agent finishes a turn |
Returning a permissionDecision of "deny" blocks the tool call; the permissionDecisionReason is fed back into the agent's context.
CLI Tool Enforcement¶
Force project-mandated tools over training defaults.
Block npm, require bun:
#!/bin/bash
# .claude/hooks/enforce-bun.sh
COMMAND=$(jq -r '.tool_input.command')
if echo "$COMMAND" | grep -qE '^npm '; then
jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "Use bun instead of npm"}}'
else
exit 0
fi
{
"hooks": {
"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": ".claude/hooks/enforce-bun.sh"}]}]
}
}
Block python, require uv:
#!/bin/bash
# .claude/hooks/enforce-uv.sh
COMMAND=$(jq -r '.tool_input.command')
if echo "$COMMAND" | grep -qE '^python '; then
jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "Use uv run instead of python"}}'
else
exit 0
fi
Destructive Operation Guardrails¶
Block hard-to-reverse commands.
Block rm -rf:
#!/bin/bash
# .claude/hooks/block-rm.sh
COMMAND=$(jq -r '.tool_input.command')
if echo "$COMMAND" | grep -q 'rm -rf'; then
jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "rm -rf is blocked — move files to /tmp or use git clean"}}'
else
exit 0
fi
Block git reset --hard and git push --force:
#!/bin/bash
# .claude/hooks/block-destructive-git.sh
COMMAND=$(jq -r '.tool_input.command')
if echo "$COMMAND" | grep -qE 'git reset --hard|git push --force|git push -f'; then
jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "Destructive git command blocked — use --force-with-lease or open a PR"}}'
else
exit 0
fi
Block direct push to main:
#!/bin/bash
# .claude/hooks/block-push-main.sh
COMMAND=$(jq -r '.tool_input.command')
if echo "$COMMAND" | grep -qE 'git push.*(main|master)'; then
jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "Direct push to main is blocked — open a PR"}}'
else
exit 0
fi
Workflow Automation¶
Run side effects automatically.
Auto-lint after file writes:
{
"hooks": {
"PostToolUse": [{"matcher": "Edit|Write", "hooks": [{"type": "command", "command": "bun run lint --fix"}]}]
}
}
Log all prompts for audit:
#!/bin/bash
# .claude/hooks/log-prompts.sh
PROMPT=$(jq -r '.prompt')
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $PROMPT" >> ~/.claude/prompt-audit.log
{
"hooks": {
"UserPromptSubmit": [{"hooks": [{"type": "command", "command": ".claude/hooks/log-prompts.sh"}]}]
}
}
Desktop notification on agent completion:
#!/bin/bash
# .claude/hooks/notify-done.sh
# macOS — adapt for Linux (notify-send) or Windows (toast)
osascript -e 'display notification "Claude Code finished" with title "Done"'
{
"hooks": {
"Stop": [{"hooks": [{"type": "command", "command": ".claude/hooks/notify-done.sh"}]}]
}
}
Sandboxing¶
Restrict reads or execs during sensitive operations.
Restrict Bash to a command allowlist:
#!/bin/bash
# .claude/hooks/allowlist-bash.sh
COMMAND=$(jq -r '.tool_input.command')
ALLOWED="^(bun|git|tsc|eslint|cat|ls|echo)"
if ! echo "$COMMAND" | grep -qE "$ALLOWED"; then
jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "Command not in allowlist"}}'
else
exit 0
fi
Block outbound network calls during agent sessions:
#!/bin/bash
# .claude/hooks/block-network.sh
COMMAND=$(jq -r '.tool_input.command')
if echo "$COMMAND" | grep -qE 'curl|wget|fetch'; then
jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "Outbound network calls are blocked during agent sessions"}}'
else
exit 0
fi
Instruction Auditing¶
Track which instruction files load — useful for config drift across teammates.
Log loaded instructions:
#!/bin/bash
# .claude/hooks/log-instructions.sh
INSTRUCTIONS=$(jq -r '.instructions // empty')
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) Instructions loaded: $INSTRUCTIONS" >> ~/.claude/instructions-audit.log
{
"hooks": {
"InstructionsLoaded": [{"hooks": [{"type": "command", "command": ".claude/hooks/log-instructions.sh"}]}]
}
}
InstructionsLoaded fires when CLAUDE.md or .claude/rules/*.md load. The payload — file_path, memory_type, load_reason, trigger_file_path — is enough to audit per-session instruction loads (docs).
Hook Configuration and Combining¶
Multiple handlers can fire per event/matcher. Hooks scope at three levels (docs; settings):
| Location | Scope | Shareable |
|---|---|---|
~/.claude/settings.json |
All projects | No |
.claude/settings.json |
Single project | Yes — commit to repo |
.claude/settings.local.json |
Single project | No — gitignored |
When This Backfires¶
- False positive blocking: Over-broad regex matchers (matching
rminstead ofrm -rf) block legitimate commands; the model then exhausts retries or hallucinates workarounds. Validate patterns against real command logs. - Silent failures: A hook that exits non-zero without a
permissionDecisionReasongives the model no signal to adapt. Always emit a reason string. - Exit code 1 fails open: For most hook events Claude Code only treats exit code
2as a block;1is logged as a non-blocking error and the call proceeds — developers reaching for the conventional Unix failure code ship guards that silently fail open (hooks reference). - Tool-switching circumvention: Hooks fire per tool match. Block
Edit/Writeand the model reaches forBash+sed/python -c/heredoc; blockrmand it falls back toperl -e 'unlink(...)'. Anchor outcome-layer harms in file permissions, network policy, or a sandbox, and pair tool hooks with aBashmatcher for the obvious bypasses (issue #43189). - Exit-code-2 stop-instead-of-retry: A
PreToolUseblock with exit2is meant to feedstderrback so the agent adapts, but in practice Claude often stops mid-turn and waits for user input — turning a fixable guardrail into a hard halt (issue #24327). - Long sub-command chains bypass deny rules: Claude Code has been shown to skip permission checks when a tool call carries a long chain of sub-commands, falling back to asking the user instead of enforcing the deny (The Register, Apr 2026). Scope hook matchers to atomic commands.
- Fragile string matchers: Exact-command matchers break when the model varies invocation style (
git push origin mainvsgit push --set-upstream origin main). - No emergency override: A hook can block a legitimate time-sensitive operation; document an override (e.g.,
settings.local.jsonentry) so contributors are not stuck.
When to Use Hooks vs. Instructions¶
Hooks: rules that must hold without exception, strong opposing model priors (package managers, test runners), behavior that must survive multi-step sessions.
Instructions: contextual "prefer X when Y" guidance, or suggestions rather than requirements.
Key Takeaways¶
PreToolUse+Bashmatcher covers CLI enforcement and destructive guardrailsPostToolUse+Edit|Writematcher runs side effects like linting after file changes- Session-level events (
Stop,UserPromptSubmit) fire unconditionally — no matcher support - Return
permissionDecision: "deny"to block;permissionDecisionReasonfeeds back into the model - Project hooks in
.claude/settings.jsontravel with the repo and apply to all contributors
Related¶
- Hooks and Lifecycle Events: Intercepting Agent Behavior
- Hooks for Enforcement vs Prompts for Guidance
- Conditional Hook Execution: Filter Hooks by Tool Pattern
- PostToolUse Hooks: Automatic Formatting and Linting After Every File Edit
- PreCompact Hook: Vetoing Compaction at Lifecycle Boundaries
- On-Demand Skill Hooks: Session-Scoped Hook Guardrails
- StopFailure Hook: Observability for API Error Termination
- Poka-Yoke for Agent Tools