Hooks block tool calls. CLAUDE.md rules ask nicely.
If you read one CLAUDE.md tuning thread on X this week, you saw the claim that hooks are deterministic and CLAUDE.md is advisory. That is true. What the thread did not show you is the JSON shape, the four-question test for deciding which surface a given rule belongs on, or the one architectural limit that means even a hook is not a guarantee.
This is that page. The exact JSON. A triage you can run on a single line. A worked rewrite. The limit. In that order.
1. Where the rule lives, who enforces it
The shorthand "advisory vs enforced" is the right intuition. Operationally the two surfaces differ on five things, and each row is a reason a CLAUDE.md rule that "should" have stopped a destructive command did not.
| Feature | CLAUDE.md rule | Hook (settings.json) |
|---|---|---|
| Where it lives | Inside the system prompt. Concatenated verbatim at session start. | In settings.json (user or repo). The harness reads it. The model never sees it. |
| Who enforces | The model. Reads the rule, decides whether to follow it on this turn. | The harness. Reads stdin (tool args), runs your shell script, blocks on exit 2 or permissionDecision: deny. |
| Cost when the rule never applies | Bytes of the rule × every turn of the session. CLAUDE.md fires unconditionally. | Zero tokens. The settings.json entry and the shell script never enter the model context. |
| Shape of an enforced rule | Natural language. Subject to interpretation by the model on every turn. | A shell predicate: regex on tool_input.command, file path matcher, git status, MCP call, etc. |
| What happens on conflict with the user | Model usually follows the most recent instruction. CLAUDE.md loses on session-end disputes. | Hook still fires. The user can override with permissionDecision: ask or with a --no-hook flag, but the default is hard-stop. |
| Best fit | Identity, stack, prose style, naming preferences, soft prohibitions where context matters. | Pre-flight gates on a specific tool event: secret scanning, destructive-command refusal, formatter enforcement, .env protection. |
2. The exact JSON, both shapes
Most posts show a PreToolUse example and stop. That is the shape for one event. The other 27 hook events use a different decision field, and writing the PreToolUse JSON into a PostToolUse slot silently no-ops the block. Worth getting right once.
PreToolUse (and PermissionRequest): hookSpecificOutput.permissionDecision with values allow, deny, ask, defer.
Everything else (PostToolUse, PostToolUseFailure, PostToolBatch, UserPromptSubmit, UserPromptExpansion, Stop, SubagentStop, ConfigChange, PreCompact): top-level {"decision":"block","reason":"..."} with "block" as the only blocking value.
Exit code 2 with a human-readable string on stderr is the universal fallback for any event and is treated as a block. Exit 0 with the right JSON is preferred where the event documents it because the decision is explicit. Any other non-zero code logs an error and the session continues, except WorktreeCreate which aborts on any non-zero.
3. Four questions to apply to one CLAUDE.md line
Open your CLAUDE.md. Find a line that starts with never, always, or do not. Run it through these four questions. If you answer yes to all four, that line belongs on a hook. If you answer no to any one, keep it where it is and rewrite it for the surface it is on.
Is there a discrete tool event where this rule fires?
Bash, Edit, Write, Read, an MCP tool, a slash command. If the answer is 'on every turn' or 'when the model is thinking about X,' there is no tool event to attach to. The rule stays on CLAUDE.md as guidance. If the answer is a specific tool name, continue.
Can the trigger be matched by a shell predicate without semantic interpretation?
A regex on tool_input.command, a file path glob, a git status check, an env-var read. Things a shell script can answer with grep and jq. Rules of the shape 'prefer the simpler approach' fail this question because 'simpler' is not a predicate. Rules of the shape 'never write to .env' pass: file_path matches /\.env$/.
Do you actually want a hard block, or do you want the model to know the rule exists?
A hook blocks. The model sees only the reason string in tool_result and has to find another way. If you want the model to internalize the rule (because it bleeds into other decisions), you need both: hook to block, plus a short CLAUDE.md line with the why. Hook alone teaches the model nothing about the next decision.
Can the model trivially route around this hook with a different tool?
A Bash PreToolUse on 'rm -rf' does not stop an Edit that empties package.json's files array, or an MCP tool that wraps unlink. If the answer is yes, you need matchers on every tool that could achieve the same outcome, or you need to accept the hook as a tripwire and not a wall. Honest hooks list their bypass surface.
4. One rule, before and after
Take a specific CLAUDE.md line that fails on the model side and succeeds on the harness side. The triage is yes/yes/both/yes, so this is a clean migration candidate.
CLAUDE.md line → CLAUDE.md + PreToolUse hook
# CLAUDE.md (excerpt) ## Never - Never run `rm -rf` without a scoped path. We lost a node_modules cache in March and the rebuild blew the weekly quota.
- Bytes bill on every turn, including turns where no Bash call happens.
- The model can ignore the rule. We have logs of it ignoring the rule.
- No actual stop. The rule is a sign on the wall.
Note the "after" CLAUDE.md still mentions the rule, in one sentence, inside the Stack section. That is deliberate: a hook without an explanation in the system prompt produces a model that keeps trying the same blocked command with cosmetic variations until it hits the turn budget. The CLAUDE.md line is no longer the enforcement, but it is still the explanation.
5. The limit no other post writes down honestly
Hooks enforce at the tool boundary. They do not enforce intent.
A PreToolUse hook with matcher: "Bash" blocks Bash. It does not block an Edit that deletes the file contents directly. It does not block a Write that overwrites a file with empty bytes. It does not block an MCP tool that wraps the same filesystem call. The model decides which tool to invoke next, and if one path is denied it will try a sibling.
The defenses are layered, in this order:
- Multi-tool matchers. For a "never destroy this directory" rule, register PreToolUse on Bash and Edit and Write and any MCP tool that could touch the path. The matcher is a regex; you can match multiple tools in one entry.
- Hook plus prompt. Pair the hook with a one-line CLAUDE.md mention of the rule and the past incident. The model uses the why to avoid isomorphic retries.
- Hooks for the bypass surface, not just the obvious tool. If you only hook Bash on rm, the bypass is find -delete. If you only hook Edit on .env, the bypass is Write on .env.local. Map the surface.
A hook is a wall, not a moat. CLAUDE.md is the sign on the wall. You usually want both.
6. What the analyzer flags as a hook candidate
CCMD's analyzer is one TypeScript file, src/lib/analyzer.ts. It runs locally when you paste a file into the textarea on ccmd.dev. It does not invoke a hook for you, that is your 30-minute exercise. What it does do is flag the line shapes that correlate with hook-migration candidates so you know which lines are worth the exercise.
Three findings stack up on a line that "should" be a hook:
- aspirational (line 130 to 191 of the analyzer): the line starts with never, always, or must and has no escape clause.
- missing_why (line 226 to 240): the prohibition has no follow-up explaining the reason or the past incident.
- bloat (line 148 to 159): the line is over 28 words, which correlates with rules the model silently ignores.
If a single line trips two of those three and you can name a concrete tool event where it would fire, that is the line to move to a hook. The rest stay on CLAUDE.md, shortened, with a why.
Want a second pair of eyes on which rules to migrate?
Bring your CLAUDE.md and your settings.json. 15 minutes, free, we walk through which lines should be hooks, which should stay, and what the matcher set should look like.
Frequently asked questions
What is the actual JSON a PreToolUse hook returns to block a tool call?
An exit-0 hook writes this to stdout: { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "<short string Claude sees" } }. The harness reads it, denies the tool, and feeds permissionDecisionReason back to the model as a tool error. The model never makes the call. The alternative is exit code 2 with a human-readable string on stderr, which the docs treat as equivalent for blocking, though the JSON form is preferred for PreToolUse and PermissionRequest because the decision field is explicit. Verified against the Anthropic hooks reference on 2026-05-21.
Why does the same JSON not work for PostToolUse or UserPromptSubmit?
PreToolUse and PermissionRequest are the only events that use the hookSpecificOutput.permissionDecision shape with values allow, deny, ask, defer. Every other event (PostToolUse, PostToolUseFailure, PostToolBatch, UserPromptSubmit, UserPromptExpansion, Stop, SubagentStop, ConfigChange, PreCompact) uses a top-level { "decision": "block", "reason": "..." }. Writing the wrong shape silently no-ops the hook on exit 0. If you copy a PreToolUse example into a PostToolUse slot, the block never fires and you assume the rule is enforced when it is not.
Can a CLAUDE.md rule ever beat a hook for blocking?
No, not for blocking. CLAUDE.md is text in the system prompt. The model reads it, decides whether to follow it, and writes its next action. On a long session with thousands of tokens of CLAUDE.md and surrounding context, individual lines silently get skipped; this is the entire reason CLAUDE.md rules drift. A hook is harness-level enforcement. It runs the shell script with full access to the tool args, exit code 2 or permissionDecision: deny, and the call is stopped regardless of what the model wanted. CLAUDE.md wins for style and identity (which is not blocking), it loses for any rule of the shape "must never happen."
Then why keep CLAUDE.md at all?
Three reasons hooks cannot replace. (1) Hooks need a discrete tool event to attach to. Rules about prose style, identity, persona, or general preferences have no tool boundary, so there is no PreToolUse to match. (2) Hooks need a deterministic match condition (a regex on tool_input, a file existence check, a git status query). Rules that depend on semantic interpretation ("prefer the simpler approach") cannot be expressed as a shell predicate. (3) The model still benefits from knowing the rule exists. A hook that blocks a Bash call without the model seeing the rationale in CLAUDE.md gets retried with a sibling command. Keeping the rule in CLAUDE.md as well, with a one-line "why," teaches the model not to try.
Why does the page say the model can route around a hook?
Tool-level enforcement gates the tool, not the intent. A PreToolUse on Bash that blocks rm -rf does not block a Write to .gitkeep, an Edit that deletes a file path entry from package.json, or an MCP tool that wraps a filesystem call. The model decides which tool to use next; if one path is blocked, it can pick a different path that achieves the same outcome. The defense is layered hooks (matchers on Bash, Edit, Write, and any MCP tool you grant) plus an honest CLAUDE.md line explaining why the rule exists. Tool-level enforcement is a wall, not a moat.
What exit codes do other than 0 and 2 do?
From the docs: exit 0 is success and stdout is parsed for JSON output (or treated as added context for UserPromptSubmit, UserPromptExpansion, SessionStart). Exit 2 is the blocking error: stdout/JSON is ignored and stderr is fed back to Claude. Any other non-zero exit is a non-blocking error: the transcript shows a hook-error notice plus the first line of stderr and the session continues. There is one exception: WorktreeCreate aborts on any non-zero code. So 0 means proceed (with optional JSON instruction), 2 means stop, anything else means log and proceed.
Where does CCMD's analyzer fit into this decision?
The analyzer is the cheap pre-filter. It runs locally when you paste a file into ccmd.dev and flags six line shapes that correlate with hook-migration candidates: aspirational absolutes ("never X") with no escape clause, prohibitions with no "why," duplicates, vague terms, cache-busting timestamps, and lines over 28 words that the model consistently ignores. The analyzer does not write the hook. It surfaces which lines are worth the 30-minute exercise of writing one. Aspirational + missing-why + has-a-tool-event is the pattern; it shows up in the line scan in src/lib/analyzer.ts around lines 124 to 191.
Is this the same in Codex (AGENTS.md) or Cursor?
Partly. AGENTS.md, .cursorrules, and .grokrules ship verbatim into the system prompt on every turn the same way CLAUDE.md does, so the advisory-vs-enforced distinction holds for the text side. The conditional/enforcement surface differs. Codex has skills but no PreToolUse hooks. Cursor has rule files and .cursorignore but no shell-trigger equivalent. Grok Build has tool gating. Claude Code currently has the deepest hook surface of the four hosts, which is why this triage matters most there. The principle (move blocking rules off the advisory surface) holds in all four.
Related: CLAUDE.md vs hooks vs skills, the token-cost asymmetry, why Claude skips rules in your CLAUDE.md, or paste your file on the analyzer.
Comments (••)
Leave a comment to see what others are saying.Public and anonymous. No signup.