DEV Community

Cover image for Stop telling Claude Code rules. Enforce them with hooks.
Michael Krisna
Michael Krisna

Posted on

Stop telling Claude Code rules. Enforce them with hooks.

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Missing formatter? || true makes it a silent no-op. Claude doesn't even know the format happened.


Install

  1. Save each script to ~/.claude/hooks/ and chmod +x ~/.claude/hooks/*.sh
  2. 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" }]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. Start a new Claude session. Run /hooks to confirm they're loaded.

Three things to know:

  • matcher is 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 2 blocks the rest.

Five more worth building

  • Test gate: PostToolUse runs the relevant test on every file edit; exit 2 on red
  • Diff cap: PreToolUse blocks 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: Stop hook 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)