Skip to content

What each guard hook blocks, and how to extend one without breaking your session

Claude Code hooks stop the mistakes that cost the most. Here is what each one refuses, how the exit codes work, and the safe way to add a rule of your own.

Next.js on Vercel5 min readships at docs/solutions/nextjs-vercel/guard-hooks-and-how-to-extend-them.md

Tags: claude-code · hooks · safety · security · tooling

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

HookEventRefuses
block-destructivePreToolUse Bashrm -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-detectorPreToolUse Bash, Read, GrepA 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-writePostToolUse Edit, Bash, Read, GrepA 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-typecheckPreToolUse BashNothing: it rewrites a bare tsc to bun run typecheck before it runs (updatedInput)
auto-lintPostToolUse EditNothing: it formats the edited file and reports what Biome could not fix
enforce-git-trackedPreToolUse Bash (team)A git commit while untracked files are loose
enforce-doc-metaPostToolUse EditA solution doc or plan missing its frontmatter
plan-gatePreToolUse 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.