Issue 00 · Autumn 2026日本語 · RSS
AgentKoji

Claude Code Hooks in Practice

Our git guard hook let +main and git -C slip through. What hook exit codes really mean, and how to fix a hook by measuring it.

PR: This article contains a placement for an affiliate program we plan to join (a learning service). Our verdicts do not depend on payouts.

A rule in CLAUDE.md is a request. A hook is a mechanism. Below we write a PreToolUse hook that stops destructive git commands, find its holes, and fix them. Test environment: Linux, Claude Code 2.1.301, jq 1.7, git 2.47.3, on 2026-10-16. We tested the script by piping the input JSON documented by Anthropic into it; we did not observe it firing inside a live Claude Code session.

Exit codes first

From the hooks reference (https://code.claude.com/docs/en/hooks, checked 2026-10-16):

Exit codeEffect on PreToolUse
0Success; a JSON permissionDecision on stdout (allow/deny/ask/defer) applies
2Blocks the call; stderr becomes the reason
Anything else, including 1Non-blocking error; the tool runs

exit 1 does not block. A timed-out hook or a mistyped script path also lets the call through by default. "onFailure": "block" (v2.1.295+) flips that (same source).

Version 1 let two commands through

#!/usr/bin/env bash
# .claude/hooks/git-guard.sh (PreToolUse, matcher: Bash) v1
cmd=$(jq -r '.tool_input.command // ""')
if printf '%s' "$cmd" | grep -Eq 'git +(push +(-f|--force)|reset +--hard|clean +-[a-z]*f|branch +-D)'; then
  jq -n --arg c "$cmd" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"ask",permissionDecisionReason:("destructive git: "+$c)}}'
fi
exit 0

git push --force and npm test && git reset --hard HEAD~1 got ask. Two passed with no decision: git push origin +main (a + refspec is a force push) and git -C /repo reset --hard (an option between git and the subcommand).

Version 2 and results

g='git( +-C +[^ ]+)*'
grep -Eq "$g +(push( +[^ ]+)* +(-f|--force[a-z-]*|\+[^ ]+)|reset +--hard|clean +-[a-z]*f|branch +-D)"
Inputv2
git statusno decision
git push origin mainno decision
git push origin +mainask
git -C /repo reset --hardask
git push --force-with-leaseask
git branch -d topicno decision
git branch -D topicask

Regexes never cover everything. The docs call if filtering best-effort and point to permission rules for hard allow/deny (same source). See Permission Design for AI Agents.

Matchers

Letters, digits, _, -, spaces, , and | mean exact match; any other character makes it a regex. Edit.* also matches NotebookEdit; use ^Edit$ for an exact match (same source).

Matchers also accept MCP tool names (mcp__<server>__<tool>); for the server side, see MCP Servers 101 and Five Design Rules.

Don’t let the model judge

Our hooks decide on exit codes, git counts and regexes only. Through model generation upgrades, not one hook needed editing. We measured an LLM-judged Stop hook per category: two of three either blocked wrongly or caught nothing, so we cut it to one category.

One rule we kept: verify “done” with a Stop hook.

Injected context must earn its place

One hook added about 450 characters to every prompt. It reported “unknown” on every first prompt, so it did nothing. We removed it.

Session-start files bloat the same way: agent memory design.

Check reality after a reject

More than once, when the commit-message hook rejected a commit, lint-staged’s stash was not restored and edits vanished without showing in git status. Now: git status after a reject, git show --stat HEAD after a commit.

Promote only what recurs

Stop-hook blocks go to a jsonl log. Weekly, failures seen three or more times get promoted to a hook or lint rule, at most three per week, so the hooks don’t become a maintenance burden of their own.

For cutting the prose rules themselves, see Trim Your CLAUDE.md.

Summary