You write “NEVER do X” in CLAUDE.md. Claude follows it.. most of the time. The times it doesn’t are where things get ugly. Here’s the mental model that made it click.
You’ve seen this. Clear instruction in CLAUDE.md. Claude follows it for days. Then one session it just.. doesn’t. Not on something weird — on the exact thing you told it not to do.
Claude is actually good at following instructions. Really good. But “really good” and “100%” are different numbers, and for certain categories of mistakes, anything less than 100% is a number you don’t want to live with.
The mistake is treating all Claude Code instructions the same way. “Prefer functional components” and “never commit secrets” are both instructions, but they have completely different failure costs. One produces a style inconsistency. The other produces a security incident.
Claude Code gives you two layers for controlling behavior. Once you see them clearly, a lot of the “where does this go” decisions just sort themselves out.
CLAUDE.md and .claude/rules/*.mdfiles are the instruction layer. Claude reads them at session start. Follows them with high reliability — but not certainty. These are suggestions toa language model, not hard constraints it’s bound by. Cultural norms, not laws.
Good for:
Hooks are shell scripts that fire at specific points in Claude Code’s lifecycle. They don’t suggest anything — they enforce. A hook that blocks writes to .envfiles will block them every single time, no matter how good Claude’s reasoning is for why it should write to one.
−# CLAUDE.md (probabilistic)−NEVER write to .env files.−These contain secrets and should−be managed manually.
+# Hook (deterministic)+case "$BASENAME" in+ .env|.env.*)+ deny "Blocked: sensitive file"+esac
Good for:
Simple question: what happens when it fails?
If Claude ignores the instruction 5% of the time, and that 5% is just.. mildly annoying — a style inconsistency, a less-than-ideal architecture choice — keep it in CLAUDE.md. The occasional miss is something you correct and move on from.
If that 5% means you’re actively cleaning something up — a committed secret, a force-pushed branch, a corrupted config — that’s a hook. Hooks don’t have failure rates. They’re shell scripts. They run or they don’t.
The practical breakdown:
.env, credentials.json, *.pemThese are from my workspace. Hooks that replaced CLAUDE.md instructions after I learned this the hard way.
First one I wrote. Replaced “NEVER write to .env files” in CLAUDE.md. It’s a PreToolUse hook that fires before any Write or Edit.
#!/bin/bash
# Guard: Block Write/Edit to sensitive files
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name')
if [ "$TOOL" = "Write" ] || [ "$TOOL" = "Edit" ]; then
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
else
exit 0
fi
BASENAME=$(basename "$FILE")
case "$BASENAME" in
.env|.env.*|credentials.json|*.pem|*.key)
echo '{"hookSpecificOutput":{
"hookEventName":"PreToolUse",
"permissionDecision":"deny",
"permissionDecisionReason":"Blocked: sensitive file. Manage manually."
}}'
;;
esac
exit 0Ten lines of bash. No AI reasoning. No prompt engineering. No hoping Claude remembers. Just works, every time.
This one goes further — doesn’t just block writes to sensitive files, it catches sensitive content going into any file. Regex matching against known API key patterns.
#!/bin/bash
# Guard: Block writes containing potential secrets
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name')
if [ "$TOOL" = "Write" ]; then
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // ""')
elif [ "$TOOL" = "Edit" ]; then
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.new_string // ""')
else
exit 0
fi
PATTERNS=(
'sk-[a-zA-Z0-9]{20,}' # AI provider API keys
'AKIA[0-9A-Z]{16}' # AWS access keys
'ghp_[a-zA-Z0-9]{36}' # GitHub personal tokens
'xoxb-[0-9]+-[0-9]+' # Slack bot tokens
'sk_live_[a-zA-Z0-9]{24,}' # Stripe live keys
)
for PATTERN in "${PATTERNS[@]}"; do
if echo "$CONTENT" | grep -qE "$PATTERN"; then
MATCH=$(echo "$CONTENT" | grep -oE "$PATTERN" | head -1)
PREVIEW="${MATCH:0:8}..."
echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Blocked: hardcoded secret detected ($PREVIEW). Use env vars.\"}}"
exit 0
fi
done
exit 0Catches the mistake at the exact moment Claude tries to write the file. Not after a commit. Not in CI. Not in code review two days later. Right then.
These overlap on purpose: one hook blocks writes to sensitive files, another blocks writes containing sensitivecontent. If Claude somehow writes an API key to a source file instead of .env, the secret scanner still catches it. Layer your hooks.
This one’s softer — instead of hard-denying, it returns an ask decision that prompts for confirmation. Sometimes you want to break the convention. That should be a conscious choice, not an impossible one.
#!/bin/bash
# Check commit messages for conventional commits format
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
# Only check git commit commands
if ! echo "$COMMAND" | grep -qE 'git\s+commit\s+.*-m\s'; then
exit 0
fi
# Extract the commit message (handles direct and heredoc styles)
MSG=$(echo "$COMMAND" | sed -n 's/.*-m[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)
FIRST_LINE=$(echo "$MSG" | head -1)
if echo "$FIRST_LINE" | grep -qE \
'^(feat|fix|chore|docs|style|refactor|test|perf|ci|build|revert)(\(.+\))?!?: .+'; then
exit 0 # Matches — allow silently
fi
echo '{"hookSpecificOutput":{
"hookEventName":"PreToolUse",
"permissionDecision":"ask",
"permissionDecisionReason":"Commit message doesn'"'"'t follow conventional commits. Proceed anyway?"
}}'
exit 0Three permission decisions — deny, ask, and allow— give you a spectrum. Secrets get denied. Commit format gets questioned. Everything else flows through.
When Claude’s context window fills up, it “compacts” — summarizes the conversation and throws away details. Necessary, but decisions and insights can just.. disappear. A PreCompact hook grabs them first.
#!/bin/bash
# PreCompact: Extract decisions before memory compaction
INPUT=$(cat)
TRANSCRIPT=$(echo "$INPUT" | jq -r '.transcript_path // ""')
# Note: field names vary by hook event — check Claude Code docs for your event type
if [ -z "$TRANSCRIPT" ] || [ ! -f "$TRANSCRIPT" ]; then
exit 0
fi
PATTERNS='decided to|chose |went with|trade-off|because |lesson:|insight:'
MATCHES=$(grep -iE "$PATTERNS" "$TRANSCRIPT" 2>/dev/null \
| sort -u | head -30)
if [ -z "$MATCHES" ]; then
exit 0
fi
TIMESTAMP=$(date '+%Y%m%d-%H%M%S')
FILEPATH="notes/compaction-extracts/${TIMESTAMP}.md"
mkdir -p "$(dirname "$FILEPATH")"
{
echo "# Compaction Extract — $(date '+%Y-%m-%d %H:%M')"
echo ""
while IFS= read -r line; do
[ -n "$line" ] && echo "- $line"
done <<< "$MATCHES"
} > "$FILEPATH"
echo "Extracted insights to $FILEPATH"
exit 0Purely additive — doesn’t block anything, just creates a side-channel capture. Hook fires, greps the transcript for decision-language, saves what it finds. Zero cost when there’s nothing to grab.
Not every hook is a guard. Some are silent corrections — they intercept a command and hand back a better version. Claude never knows they fired.
One injects --draft into every gh pr create command. PRs always start as drafts, triggering a Vercel preview before anyone reviews. Uses the updatedInput response to swap the command before execution:
# Inject --draft right after "gh pr create"
FIXED=$(echo "$COMMAND" | sed -E 's/(gh[[:space:]]+pr[[:space:]]+create)/\1 --draft/')
echo "{
\"hookSpecificOutput\": {
\"permissionDecision\": \"allow\",
\"updatedInput\": {
\"command\": $(printf '%s' "$FIXED" | jq -Rs .)
}
}
}"Another watches for rsync commands without --dry-runand asks if you want the flag added. Not a block — an offer. Because rsync to a dev server is recoverable, but rsync to production without a dry run first is how you learn what rsync can delete.
This is a third permission level beyond deny and allow. updatedInputlets a hook say “yes, but differently.” The command runs, just not the way Claude originally wrote it.
There’s a gap between “done working” and “session closed” where things slip through. You’ve committed, maybe pushed. You’re reaching for Ctrl-C. This is exactly when hooks earn their keep.
Three hooks fire at session-end. One scans your uncommitted files for TODO and FIXME — only the ones you introduced this session, not the hundreds already in the codebase. Finds any? Blocks with file names, line numbers, the actual text. Fix it or convert it to a tracked issue.
Another runs the linter, but only if you touched source files in the last 5 minutes. A 30-second timeout prevents a slow linter from trapping you in a session you’re trying to leave.
The third is the most interesting — it detects whether meaningful changes happened that haven’t been captured to persistent memory. If real work happened, it writes a marker file. Next session picks it up and kicks off knowledge capture automatically. This one never blocks. Deliberate. Knowledge capture needs a fresh context window and focused attention — forcing it at session-end when you’re trying to leave would produce garbage.
The design principle: blocking hooks catch things you can fix right now. Non-blocking hooks queue things that need a different context. Match the hook to the moment.
There’s a practical reason for this split beyond failure costs: instruction fatigue.
Frontier models reliably follow roughly 150–200 discrete instructions. Past that, things start getting dropped. The more you pack into CLAUDE.md, the less reliably any single instruction gets followed.
I learned this the slow way. My CLAUDE.md grew over months — style rules, security rules, workflow rules, project context, tool routing. At some point it crossed a line where Claude would consistently miss instructions near the bottom of the file. Not because they were less important. Because the file was just too long.
Every instruction you move to a hook is one fewer instruction competing for Claude’s attention in CLAUDE.md. Moving security rules to hooks doesn’t just make them deterministic — it also makes your remaining CLAUDE.md instructions more reliable by reducing the total count.
Three-part fix:
.claude/rules/*.md) for focused topics. Each file loads into context, but the separation helps Claude parse them as discrete chunks rather than one wall of text.The goal is a lean CLAUDE.md — project identity, key context, and the instructions that genuinely need to be ambient. Everything else goes to the layer where it actually works best.
PreToolUse hook from scratch: blocking git push --forceto protected branches. Classic footgun in AI-assisted development.
Hooks live in .claude/settings.json (shared with your team) or .claude/settings.local.json (just you). Each hook specifies when it fires and what script to run.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/guard-force-push.sh"
}
]
}
]
}
}The matcher field filters which tool calls trigger your hook. "Bash" fires on shell commands."Write|Edit" fires on file operations. You can match specific tools or use a regex pattern.
Hook scripts get a JSON payload on stdin with the tool name and input parameters. They talk back by printing JSON to stdout. The contract:
{"hookSpecificOutput":{"permissionDecision":"deny",...}} = block it{"hookSpecificOutput":{"permissionDecision":"ask",...}} = prompt for confirmation#!/bin/bash
# Guard: Block force pushes to protected branches
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
# Only care about git push commands
if ! echo "$COMMAND" | grep -qE 'git\s+push'; then
exit 0
fi
# Check for force flags
if ! echo "$COMMAND" | grep -qE '(--force|-f|\+)'; then
exit 0
fi
# Check if pushing to a protected branch
PROTECTED="main|master|production|release"
if echo "$COMMAND" | grep -qE "($PROTECTED)"; then
echo '{"hookSpecificOutput":{
"hookEventName":"PreToolUse",
"permissionDecision":"deny",
"permissionDecisionReason":"Blocked: force push to protected branch."
}}'
exit 0
fi
# Force push to feature branches — ask, don't block
echo '{"hookSpecificOutput":{
"hookEventName":"PreToolUse",
"permissionDecision":"ask",
"permissionDecisionReason":"Force pushing to a branch. Are you sure?"
}}'
exit 0That’s it. Shell script reads JSON, checks a condition, prints a decision. No framework, no dependencies beyond jq. Test it from the command line by piping in sample payloads.
Start with one hook. The sensitive file guard is the best first hook because it’s simple, immediately valuable, and teaches you the full pattern: config, script, JSON response. Once you’ve built one, the second takes five minutes.
My workspace runs about 30 hooks across all lifecycle events. Sounds like a lot, but most are 10 to 120 lines — pretty short. They cover:
Meanwhile CLAUDE.md stays focused on what it’s actually good at: telling Claude who it is in this project, what the architecture looks like, and how I like to work. Doesn’t carry security rules anymore. Those live in code that can’t be talked around.
Shorter CLAUDE.md. Stronger security posture. Way less time reviewing output for the kind of mistakes that hooks now catch before they happen.
Next time you catch yourself writing “ALWAYS” or “NEVER” in CLAUDE.md, pause. Preference or requirement? If “most of the time” is fine, leave it in instructions. If it isn’t — write a hook. Ten lines of bash beats ten words of emphasis, every time.