Skip to content

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 rm instead of rm -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 permissionDecisionReason gives 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 2 as a block; 1 is 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/Write and the model reaches for Bash + sed/python -c/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 (issue #43189).
  • Exit-code-2 stop-instead-of-retry: A PreToolUse block with exit 2 is meant to feed stderr back 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 main vs git push --set-upstream origin main).
  • No emergency override: A hook can block a legitimate time-sensitive operation; document an override (e.g., settings.local.json entry) 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 + Bash matcher covers CLI enforcement and destructive guardrails
  • PostToolUse + Edit|Write matcher runs side effects like linting after file changes
  • Session-level events (Stop, UserPromptSubmit) fire unconditionally — no matcher support
  • Return permissionDecision: "deny" to block; permissionDecisionReason feeds back into the model
  • Project hooks in .claude/settings.json travel with the repo and apply to all contributors
Feedback