I stopped writing rules in CLAUDE.md.
CLAUDE.md is Claude Code's project memory file: where you stash conventions, build commands, and "always do X" rules that should apply to every session. Mine had four lines about Node versions.
Claude followed them. Most of the time.
The other times were the problem. Over a long task, Claude's attention drifts; a rule loaded at the start of the session may not influence what it does after 2 hrs. A subagent might spawn for a focused job without the project's CLAUDE.md in its context. A fresh worktree on a sibling repo wouldn't have the rule at all. Any of those, and pnpm build would quietly run on whatever Node was on PATH (22, in my case, not 20). Build succeeds. Wrong runtime. No error until deploy.
I tried the obvious fix: add the rule in more places. User-scope CLAUDE.md. System prompt. Every prompt. Adherence improved. The failure mode never went away. It just got rarer and harder to catch.
That's when it clicked. Asking Claude to follow a rule is not the same as making sure it does. More reminders weren't going to close that gap.
CLAUDE.md tells Claude what to do. A hook makes sure Claude does it.
TL;DR
Four hooks (~30 lines of bash each) I now run on every Claude Code session:
| # | Hook | What it enforces |
|---|---|---|
| 1 | nvm-guard |
Every Node command must use Node 20 |
| 2 | main-guard |
No git push to main/master |
| 3 | secret-scan |
No Stripe/AWS/GitHub keys in written files |
| 4 | auto-format |
Every file Claude edits gets formatted |
Code for all four below.
What hooks are
Hooks are shell scripts Claude Code runs at fixed lifecycle points: before a tool call (PreToolUse), after a tool call (PostToolUse), on session end (Stop).
The flow:
Claude wants to run something
↓
Hook receives JSON of what's about to happen
↓
Hook decides:
exit 0 → allow
exit 2 → block + stderr message returns to Claude
When a hook blocks, Claude reads the stderr and retries with the fix on its own. You don't have to intervene. Claude can't forget hooks. Claude can't be talked out of them. They just run.
Hook 1: Force Node 20
#!/usr/bin/env bash
# ~/.claude/hooks/nvm-guard.sh
set -euo pipefail
input="$(cat)"
tool_name="$(printf '%s' "$input" | jq -r '.tool_name // ""')"
command="$(printf '%s' "$input" | jq -r '.tool_input.command // ""')"
[[ "$tool_name" == "Bash" ]] || exit 0
[[ -n "$command" ]] || exit 0
# Match node-based commands at a command boundary
if ! printf '%s' "$command" | grep -qE \
'(^|[;&|(]|&&|\|\|)[[:space:]]*(pnpm|npm|yarn|bun|npx|node)([[:space:]]|$)'; then
exit 0
fi
# Allow if nvm use is already in the same invocation
if printf '%s' "$command" | grep -qE 'nvm[[:space:]]+use'; then exit 0; fi
if printf '%s' "$command" | grep -qE '^[[:space:]]*nvm[[:space:]]'; then exit 0; fi
cat >&2 <<'EOF'
Node-based command detected without `nvm use 20`.
Re-issue as a single Bash invocation:
source ~/.nvm/nvm.sh && nvm use 20 && <your original command>
EOF
exit 2
The block-then-retry, live:
● Bash(pnpm run dev)
⎿ PreToolUse:Bash hook blocked
Re-issue as: source ~/.nvm/nvm.sh && nvm use 20 && <your command>
● Bash(source ~/.nvm/nvm.sh && nvm use 20 && pnpm run dev)
⎿ Now using node v20.20.2
▲ Next.js 15.0.0
Two seconds slower. Zero wrong-version build mysteries.
Hook 2: Block pushes to main
We've all watched Claude type git push origin main and felt something cold pass through us. Easy to prevent.
#!/usr/bin/env bash
# ~/.claude/hooks/main-guard.sh
set -euo pipefail
input="$(cat)"
command="$(printf '%s' "$input" | jq -r '.tool_input.command // ""')"
if printf '%s' "$command" | grep -qE \
'git[[:space:]]+push.*[[:space:]](main|master)([[:space:]]|$|:)'; then
if [[ "${CLAUDE_ALLOW_MAIN_PUSH:-}" != "1" ]]; then
cat >&2 <<'EOF'
Push to main/master blocked.
Create a feature branch and PR, or relaunch with CLAUDE_ALLOW_MAIN_PUSH=1.
EOF
exit 2
fi
fi
exit 0
The escape-hatch env var matters. Block too cleanly and Claude invents creative workarounds. Document the escape, and it stops improvising.
Hook 3: Refuse to write secrets
Saved me twice. Once, Claude pasted a real STRIPE_SECRET_KEY into a JSDoc example. Caught before it hit disk.
#!/usr/bin/env bash
# ~/.claude/hooks/secret-scan.sh
set -euo pipefail
input="$(cat)"
tool_name="$(printf '%s' "$input" | jq -r '.tool_name // ""')"
content="$(printf '%s' "$input" | jq -r '.tool_input.content // .tool_input.new_string // ""')"
[[ "$tool_name" =~ ^(Write|Edit)$ ]] || exit 0
[[ -n "$content" ]] || exit 0
if printf '%s' "$content" | grep -qE \
-e 'sk_(live|test)_[A-Za-z0-9]{20,}' \
-e 'AKIA[0-9A-Z]{16}' \
-e 'ghp_[A-Za-z0-9]{36}' \
-e 'xox[baprs]-[A-Za-z0-9-]{10,}' \
-e '-----BEGIN [A-Z ]+ PRIVATE KEY-----'; then
cat >&2 <<'EOF'
Possible secret detected. Write blocked.
Use sk_test_fake_replace_me or move the real value to .env (gitignored).
EOF
exit 2
fi
exit 0
Add patterns for whatever your stack uses: internal API formats, OpenAI keys, anything distinctive.
Hook 4: Format on every edit
Silent PostToolUse. Claude writes, the file gets formatted before the next read. No more whitespace-noise diffs.
#!/usr/bin/env bash
# ~/.claude/hooks/auto-format.sh
set -euo pipefail
file="$(jq -r '.tool_input.file_path // ""')"
[[ -n "$file" && -f "$file" ]] || exit 0
case "$file" in
*.ts|*.tsx|*.js|*.jsx|*.json|*.md|*.css)
command -v prettier >/dev/null && prettier --write "$file" --log-level=silent || true ;;
*.go) command -v gofmt >/dev/null && gofmt -w "$file" || true ;;
*.py) command -v ruff >/dev/null && ruff format "$file" --quiet || true ;;
*.rs) command -v rustfmt >/dev/null && rustfmt "$file" --quiet || true ;;
esac
exit 0
Missing formatter? || true makes it a silent no-op. Claude doesn't even know the format happened.
Install
- Save each script to
~/.claude/hooks/andchmod +x ~/.claude/hooks/*.sh - Add to
~/.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "/Users/YOU/.claude/hooks/nvm-guard.sh" },
{ "type": "command", "command": "/Users/YOU/.claude/hooks/main-guard.sh" }
]
},
{
"matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": "/Users/YOU/.claude/hooks/secret-scan.sh" }]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": "/Users/YOU/.claude/hooks/auto-format.sh" }]
}
]
}
}
- Start a new Claude session. Run
/hooksto confirm they're loaded.
Three things to know:
-
matcheris regex on the tool name.Bash,Write|Edit,.*all work. - Paths must be absolute.
~won't expand inside settings.json. - Multiple hooks under one matcher run in order. First
exit 2blocks the rest.
Five more worth building
-
Test gate:
PostToolUseruns the relevant test on every file edit; exit 2 on red -
Diff cap:
PreToolUseblocks any single Write/Edit over N lines (forces small, reviewable changes) -
Dependency lock: block
pnpm add <pkg>unless<pkg>is on an allowlist -
Cost warn:
Stophook pings you if the session burned more than $X -
Forbidden flag guard: block
--no-verify,--force,--dangerously-skip-permissions
20-30 lines of bash each. Each one removes a class of mistakes you stop having to think about.
You wouldn't write "please don't drop the database" in a runbook and call it a control. You'd revoke the permission.
Same thing here.
Stop telling Claude rules. Start enforcing them.
All four scripts are MIT-licensed and copy-pasteable as-is. Drop your hook #5 in the comments.
Top comments (0)