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 |
A permissionDecision of "deny" blocks the tool call. Claude Code feeds the permissionDecisionReason 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 treats only exit code
2as a block. Exit code1is logged as a non-blocking error and the call proceeds. Developers who reach 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. 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 mainversusgit push --set-upstream origin main). - No emergency override: a hook can block a legitimate time-sensitive operation. Document an override (for example, a
settings.local.jsonentry) so contributors are not stuck.
When to use hooks instead of 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.
FAQ¶
Why does a hook that exits 1 fail open?
Because for most hook events Claude Code treats only exit code 2 as a block. Exit code 1 is logged as a non-blocking error and the tool call proceeds anyway. Developers who reach for the conventional Unix failure code therefore ship guards that look enforced but silently let the forbidden command through (hooks reference).
Can the model route around a hook that blocks a tool?
Yes, because hooks fire per tool match. Block Edit and Write and the model reaches for Bash with sed, python -c, or a heredoc; block rm and it falls back to perl -e 'unlink(...)'. Anchor outcome-layer harms in file permissions, network policy, or a sandbox, and pair tool hooks with a Bash matcher for the obvious bypasses.
When should a rule be a hook rather than an instruction?
Use a hook when the rule must hold without exception, when the model carries a strong opposing prior (package managers, test runners, curl), or when the behavior must survive a long multi-step session where instructions drift. Keep it as an instruction when the guidance is contextual — "prefer X when Y" — or a suggestion rather than a hard requirement.
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
- Hook Exec Form vs Shell Form: Shell-Injection-Safe Hook Commands — choosing the args-array exec form so substituted tool input cannot inject shell metacharacters into a hook command