6 min readRishi

Claude Code Hooks: Automating Repo Guardrails Without Pre-Commit Fatigue

Every team I have worked with has the same pre-commit hook story. It starts clean — lint, test, format. Six months later it is a 90-second ritual, someone adds --no-verify to their muscle memory, and the checks stop catching anything because they have been bypassed for months. The hook exists, but the guardrail is gone.

Claude Code hooks are a different model. They run inside the agent's execution loop — before a file edit, before a shell command, on session start, at the end of a turn — and the agent cannot suppress them. If you need policy that actually holds, that is the right place to put it.

This post walks through the hook points I have found most useful, with the settings.json snippets to configure them.

The Hook Points That Matter

Claude Code exposes several hook events, but four of them do the most work in practice:

  • PreToolUse — fires before the agent invokes a tool (Edit, Write, Bash, etc.). You can inspect and block.
  • PostToolUse — fires after a tool call returns. Useful for auto-formatting, linting, or logging.
  • UserPromptSubmit — fires when the user submits a prompt. Inject context or refuse the prompt.
  • SessionStart — fires once at session boot. Warm caches, print banners, sync state.

Each hook is just a shell command. It receives JSON on stdin with the event payload. What it prints or exits with determines what happens next.

Blocking Dangerous Commands with PreToolUse

The most common guardrail I set up is a PreToolUse hook that filters Bash commands against a deny list. This is not about trusting the model less — it is about defense in depth when the model is reasoning about unfamiliar code.

Here is the minimum viable version:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/bash-deny.sh"
          }
        ]
      }
    ]
  }
}

And the script:

#!/usr/bin/env bash
# .claude/hooks/bash-deny.sh
set -euo pipefail

payload="$(cat)"
cmd="$(echo "$payload" | jq -r '.tool_input.command // empty')"

# Patterns that should never run unattended
patterns=(
  'rm -rf /'
  ':(){ :\|:& };:'
  'chmod -R 777'
  'git push --force'
  'curl .* \| bash'
  '> /dev/sda'
)

for p in "${patterns[@]}"; do
  if echo "$cmd" | grep -Eq "$p"; then
    # Exit code 2 blocks the tool call and returns the message to the model
    echo "Blocked by policy: command matches $p" >&2
    exit 2
  fi
done

exit 0

A non-zero exit with code 2 tells Claude Code to refuse the tool call and surface the reason back to the agent, which will then try something else. Exit code 0 lets it proceed. This is a much better shape than a pre-commit hook — the block happens before the destructive action, not after the diff is already on disk.

Auto-Formatting with PostToolUse

The most quality-of-life-y hook I set up runs a formatter whenever the agent edits a file:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/format-edited.sh"
          }
        ]
      }
    ]
  }
}
#!/usr/bin/env bash
# .claude/hooks/format-edited.sh
path="$(cat | jq -r '.tool_input.file_path // empty')"
[ -z "$path" ] && exit 0

case "$path" in
  *.ts|*.tsx|*.js|*.jsx)  npx --no-install prettier --write "$path" >/dev/null 2>&1 || true ;;
  *.py)                    ruff format "$path" >/dev/null 2>&1 || true ;;
  *.go)                    gofmt -w "$path" >/dev/null 2>&1 || true ;;
  *.rs)                    rustfmt "$path" >/dev/null 2>&1 || true ;;
esac

exit 0

The agent never has to remember to format. The format never drifts. And unlike a pre-commit hook, it runs continuously as the agent works, so you never end up with a giant "style" commit at the end.

Injecting Context with UserPromptSubmit

When your repo has non-obvious conventions, the agent will not discover them reliably by reading files. You can inject context into every prompt with a UserPromptSubmit hook:

{
  "hooks": {
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/inject-context.sh"
          }
        ]
      }
    ]
  }
}
#!/usr/bin/env bash
cat <<'EOF'
<system-note>
- Database migrations must include a down() function
- Never edit files under /generated — they are regenerated from proto
- Use @/ path alias, not relative imports, for anything outside the current feature folder
</system-note>
EOF
exit 0

Anything stdout writes gets appended to the user's prompt before the model sees it. You get a durable way to tell the agent "read this first, every time" without bloating a CLAUDE.md that gets auto-compacted on long sessions.

Session Start: Cache Warming and Guard Rails

SessionStart is where I put things that used to live in a terminal aliases file. Print the current branch, flag if the working tree is dirty, remind me what unfinished work exists:

#!/usr/bin/env bash
# .claude/hooks/session-start.sh
branch="$(git branch --show-current 2>/dev/null || echo 'not a git repo')"
dirty=""
git diff --quiet 2>/dev/null || dirty=" (dirty)"
echo "Session starting on branch: $branch$dirty"

# Warm any compiled caches the agent is likely to need
(npm run build --silent >/dev/null 2>&1 &) 2>/dev/null
exit 0

The output appears once at the top of the conversation. The background build means the first real edit the agent makes does not pay the cold-start compile cost.

A Practical Ruleset for Most Repos

Here is the hook config I start with on most new projects. It is deliberately small — four hooks that cover blocking, formatting, context, and visibility:

{
  "hooks": {
    "SessionStart": [
      { "hooks": [{ "type": "command", "command": ".claude/hooks/session-start.sh" }] }
    ],
    "UserPromptSubmit": [
      { "hooks": [{ "type": "command", "command": ".claude/hooks/inject-context.sh" }] }
    ],
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{ "type": "command", "command": ".claude/hooks/bash-deny.sh" }]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": ".claude/hooks/format-edited.sh" }]
      }
    ]
  }
}

Commit the .claude/hooks/ directory to the repo. Everyone who uses Claude Code on the project gets the same guardrails, without having to install anything or remember to run pre-commit install. The hooks cannot be bypassed by habit or --no-verify.

What Hooks Will Not Fix

Hooks are the wrong place for:

  • Expensive test suites. Put those in CI. A 3-minute PreToolUse on every Edit will make the agent unusable.
  • Style questions with debates. If the team disagrees on a convention, hook-enforcing it just shifts the argument into your issue tracker. Reach consensus first.
  • Replacing CLAUDE.md. A hook runs once per event; CLAUDE.md stays in the model's context. Documentation still belongs in CLAUDE.md; runtime enforcement belongs in hooks.

The Shape That Holds

Pre-commit hooks fail because they run at the wrong moment — after the work, before the push — and they can be bypassed by anyone impatient. Claude Code hooks run inside the action loop, before the destructive step, and the agent cannot override them from inside the conversation. That is the difference between a guardrail and a guideline.

Start with the four-hook config above. Watch which hooks fire often, which block real mistakes, and which are noise. Hooks are small shell scripts — deleting one is trivial when it stops earning its place.

Keep reading

Newsletter

New posts, straight to your inbox

One email per post. No spam, no tracking pixels, unsubscribe anytime.

Comments

No comments yet. Be the first.