Rules are advice. An agent reads them, agrees with them, and then does something else at 11pm on a long task because the context window filled up and the rule scrolled away. Hooks are different: they run in the harness, outside the model, and they cannot be reasoned around.
Your guards are registered in .claude/settings.json, one script each in
.claude/hooks/. The exact set depends on what you picked. Team mode adds two.
A battery can add its own: Neon adds guard-neon-sql. env-leak-detector is
one guard split into two scripts, a before half and an after half.
The design rule behind all of them is the same: a guard should refuse the small set of actions whose cost is unrecoverable, and stay out of the way for everything else. A hook that fires on ordinary work gets disabled within a week, which is worse than never having it.
The mechanism, in four lines
A Claude Code hook is a program. It receives a JSON payload on stdin describing the tool call, and it answers with an exit code:
- exit 0: allow. Anything on stderr is informational.
- exit 2: block. stderr is shown to the agent as the reason.
PreToolUse runs before the tool and can prevent it. PostToolUse runs after,
so it cannot prevent anything. What it does instead is exit 2, which surfaces
the problem as a blocking error on the very next turn, while the agent still has
the context to fix it.
A hook can also answer with JSON on stdout and exit 0. PreToolUse can return
permissionDecision: "deny" (what guard-neon-sql does) or updatedInput to
run a corrected command instead (what enforce-typecheck does). PostToolUse
can return updatedToolOutput to change what the agent sees (what
env-leak-detector-write does to redact live secrets).
The payload is roughly:
{
"tool_name": "Bash",
"cwd": "/Users/you/project",
"tool_input": { "command": "rm -rf dist" }
}
For Edit / Write / MultiEdit, tool_input carries file_path plus
content, new_string, or an edits array. The shape differs per tool, which
is the first thing that catches people writing a new hook.
What the stack ships, and what each one refuses
| Hook | Event | Refuses |
|---|---|---|
block-destructive | PreToolUse Bash | rm -rf, DROP/TRUNCATE sent to a database client, an inline script (bun -e) or curl, git push --force, git reset --hard, git clean -f, dd, and > truncating a tracked file |
env-leak-detector | PreToolUse Bash, Read, Grep | A credential in a command, any read or send of .env.local (cat, grep, sed, curl -d @, the Read tool), bare env, echo $DATABASE_URL unless a sed really masks it, git add .env |
env-leak-detector-write | PostToolUse Edit, Bash, Read, Grep | A secret literal written into source, a live .env value inlined, a non-public process.env read in a "use client" file, a secret being logged. After a command or read, it redacts live values from the output (updatedToolOutput) |
enforce-typecheck | PreToolUse Bash | Nothing: it rewrites a bare tsc to bun run typecheck before it runs (updatedInput) |
auto-lint | PostToolUse Edit | Nothing: it formats the edited file and reports what Biome could not fix |
enforce-git-tracked | PreToolUse Bash (team) | A git commit while untracked files are loose |
enforce-doc-meta | PostToolUse Edit | A solution doc or plan missing its frontmatter |
plan-gate | PreToolUse Edit, Write, Bash (team) | An edit or shell write under src/ with no approved or in-progress plan in docs/plans/ |
enforce-git-tracked and plan-gate carry modes: [team], so they are only
emitted in team mode. Solo mode is one author and one clone, where neither
failure exists. The other six in this table are always on.
The wrong way to add one
#!/usr/bin/env bun
// blocks any command mentioning the production database
const payload = JSON.parse(await Bun.stdin.text());
if (payload.tool_input.command.includes("prod")) {
console.error("no");
process.exit(2);
}
Four defects, and every one of them will hurt within a day.
It crashes on malformed input. JSON.parse throws on empty stdin, and
tool_input.command throws when the tool was Read. You get to debug that while
every tool call in the session is failing.
It matches too much. improve-product, reproduce, prod-preview. Users
learn to ignore it, then turn it off.
The message teaches nothing. "no" says neither what was refused nor what to do instead, so the agent's next move is to try a variation.
It has no timeout. A hook that shells out and hangs hangs the session.
The right way
Copy the shape every hook in .claude/hooks/ already uses:
#!/usr/bin/env bun
import { readFileSync } from "node:fs";
const ALLOW = 0;
const BLOCK = 2;
interface Payload {
tool_name?: string;
cwd?: string;
tool_input?: Record<string, unknown>;
}
function readPayload(): Payload | null {
try {
const raw = readFileSync(0, "utf8");
if (raw.trim() === "") return null;
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
return parsed as Payload;
} catch {
return null;
}
}
/** Split a shell line so `build && rm -rf dist` is checked segment by segment. */
function segments(command: string): string[] {
return command.split(/\|\||&&|[;\n|]/g).map((s) => s.trim()).filter((s) => s.length > 0);
}
function main(): void {
const payload = readPayload();
if (payload === null) {
process.stderr.write("my-guard: unreadable hook payload, allowing.\n");
process.exit(ALLOW);
}
if (payload.tool_name !== undefined && payload.tool_name !== "Bash") process.exit(ALLOW);
const command = typeof payload.tool_input?.command === "string" ? payload.tool_input.command : "";
for (const segment of segments(command)) {
if (!/\bpsql\b[^\n]*\bprod\b/.test(segment)) continue;
process.stderr.write(
"BLOCKED by my-guard: psql against production.\n\n" +
`Segment: ${segment}\n\n` +
"Do this instead: run it against the branch database, or ask a human to run " +
"the statement with a transaction they can roll back.\n",
);
process.exit(BLOCK);
}
process.exit(ALLOW);
}
main();
Five properties worth copying literally: fail open on a bad payload (a guard that bricks every call is worse than the risk it covers); check the tool name before assuming the input shape; split on shell operators so a chained command cannot smuggle anything past; name the hook and the offending segment in the message; and always say what to do instead, because a block with no alternative is a block the agent will try to work around.
Wiring it up, and proving it works
Drop the script in .claude/hooks/, make it executable, and register it in
.claude/settings.json under the event and matcher. A matcher made of plain
tool names is an exact list: Edit|Write does not match MultiEdit, so name
every tool you mean (Edit|Write|MultiEdit).
Then, always:
bun run verify:hooks
That script attempts each blocked action and confirms it was stopped. Guards die
in exactly two silent ways (the settings file loses its wiring, or a script
stops parsing), and this is the only thing that notices either. Run it after any
edit under .claude/, and before a release.
Tightening an existing one
Say you want plan-gate to require that the edited file appears in the approved
plan's ## Files section, not merely that some approved plan exists. Keep the
plan body when you parse the file, then test the target path against it:
// in readPlans(), alongside title and status:
plans.push({ name, title, status, contradictory, body: text });
// in main(), replacing the "any open plan" test:
const open = plans.filter((plan) => OPEN_STATUSES.has(plan.status) && !plan.contradictory);
if (open.some((plan) => plan.body.includes(gated.path))) process.exit(ALLOW);
// otherwise block, naming the open plans that were checked
Make that change in its own commit, with the reason in the message, and re-run
bun run verify:hooks. Never loosen a guard in passing during other work. A
guard weakened inside an unrelated diff is a guard nobody agreed to weaken.