# 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.

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:

```json
{
  "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

```ts
#!/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:

```ts
#!/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:

```bash
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:

```ts
// 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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
