# Claude Code hooks, with 7 real guard scripts

> Claude Code hooks are scripts that run before or after a tool call. A PreToolUse hook that exits 2 blocks it. The settings.json shape, matchers and real guards.

*Updated 2026-10-04.*

Rules are requests. Hooks are code. A hook is a script Claude Code runs at a fixed point, like right before a tool call, outside the model. The model can't skip it, forget it, or talk its way past it.

If something must never happen in your repo, a prompt is the wrong tool. A `PreToolUse` hook is the right one.

## What are Claude Code hooks?

Claude Code hooks are commands you register in `settings.json`. Claude Code runs them when a lifecycle event fires. The two that matter most:

- **`PreToolUse`** runs before a tool call. It can block the call, rewrite it, or let it through.
- **`PostToolUse`** runs after a tool call succeeds. It can't undo anything. It can report a problem to Claude, or change what Claude sees.

There are many more events. These are the ones you are likely to use:

| Event | Fires |
|---|---|
| `SessionStart` | When a session starts or resumes |
| `UserPromptSubmit` | When you send a prompt, before Claude reads it |
| `PreToolUse` | Before a tool call. Can block it |
| `PermissionRequest` | When a tool call needs a permission decision |
| `PostToolUse` | After a tool call succeeds |
| `PostToolUseFailure` | After a tool call fails |
| `Stop` | When Claude finishes responding |
| `SubagentStop` | When a subagent finishes |
| `PreCompact` | Before the context is compacted |
| `Notification` | When Claude Code sends a notification |

Most hooks are `"type": "command"`: a shell command. Claude Code also supports `http`, `mcp_tool`, `prompt` and `agent` hooks. This page sticks to commands.

## How a hook works: JSON in, exit code out

Claude Code sends the hook a JSON payload on stdin. For a tool event it looks roughly like this:

```json
{
  "session_id": "abc123",
  "hook_event_name": "PreToolUse",
  "cwd": "/Users/you/my-app",
  "tool_name": "Bash",
  "tool_input": { "command": "rm -rf dist" }
}
```

`PostToolUse` also gets `tool_response`. The shape of `tool_input` changes per tool. `Bash` has `command`. `Edit` and `Write` have `file_path`.

The hook answers with an exit code:

| Exit code | What happens |
|---|---|
| `0` | No objection. For `PreToolUse` this is not an approval: the normal permission flow still runs |
| `2` | Block. On `PreToolUse` the call never runs, and stderr goes to Claude as the reason |
| Anything else | A non-blocking error. The action goes ahead and the transcript shows a hook error |

Exit 2 means different things per event. On `PostToolUse` the tool already ran, so stderr just goes to Claude. On `Stop` it keeps Claude working instead of stopping.

## Answering with JSON instead

For more control, exit 0 and print JSON to stdout. On `PreToolUse`:

- `permissionDecision`: `"allow"`, `"deny"` or `"ask"`, plus a `permissionDecisionReason`.
- `updatedInput`: replace the tool's input before it runs.
- `additionalContext`: text Claude reads alongside the result.

On `PostToolUse`, `updatedToolOutput` replaces what Claude sees, and `additionalContext` adds a note. Pick one style per hook: exit 2 with stderr, or exit 0 with JSON.

## Claude Code hooks in settings.json

Hooks live under a `hooks` key. Each event holds a list of groups. Each group has a `matcher` and the commands to run:

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-rm-rf.sh",
            "timeout": 30
          }
        ]
      }
    ]
  }
}
```

Where that block can live:

| File | Scope |
|---|---|
| `~/.claude/settings.json` | All your projects |
| `.claude/settings.json` | This project. Commit it |
| `.claude/settings.local.json` | This project, just you |
| Managed policy settings | The whole organization |
| A plugin's `hooks/hooks.json` | Wherever the plugin is on |
| Skill or subagent frontmatter | While that skill or subagent is active |

How matchers read:

- `"Bash"` or `"Edit|Write"`: plain names are an exact list. `Edit|Write` does not fire for `Bash`, even when Bash writes a file.
- Anything with other characters is a JavaScript regex: `"mcp__github__.*"` matches every tool from that MCP server.
- `"*"`, `""` or no matcher matches everything.
- Matching is case-sensitive.

Three more facts that save you an afternoon:

- **Use `$CLAUDE_PROJECT_DIR`.** It points at the project root, even after Claude runs `cd`. A relative path breaks the moment it does.
- **All matching hooks run in parallel.** If any one denies, the call is blocked.
- **`timeout` is in seconds.** Command hooks default to 10 minutes. Keep yours far below that.

Edits to settings files are normally picked up while Claude Code runs. Type `/hooks` to see what is registered.

## Copy this: a PreToolUse hook that blocks rm -rf

A generic example, not from the generator. Save as `.claude/hooks/no-rm-rf.sh` and run `chmod +x` on it:

```bash
#!/usr/bin/env bash
# Minimal example. It catches the obvious case and misses the clever ones.
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')

if printf '%s' "$cmd" | grep -Eq 'rm +-(rf|fr)'; then
  echo "Blocked: recursive force delete. Remove the specific files you mean, or ask the user." >&2
  exit 2
fi

exit 0
```

Wire it with the `settings.json` block above. Test it without Claude by piping a payload in:

```bash
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf dist"}}' | .claude/hooks/no-rm-rf.sh
echo $?   # 2 means blocked
```

It works. It is also easy to get past.

## Why grep is not a guard

That script misses all of these:

- `rm -r -f dist` and `rm --recursive --force dist`
- `/bin/rm -rf dist`
- `bash -c "rm -rf dist"` and `sudo rm -rf dist`
- `find . -name dist -exec rm -rf {} +`
- `ls | xargs rm -rf`

And it blocks things it shouldn't, like a commit message that mentions `rm -rf`. A guard that cries wolf gets turned off within a week. Then you have nothing.

A real guard parses the shell line. Here is the top of `block-destructive`, the guard every generated repo ships:

```ts
#!/usr/bin/env bun
/**
 * block-destructive: PreToolUse / Bash
 *
 * Reads the Claude Code hook payload on stdin. Exit 0 allows the tool call,
 * exit 2 blocks it and shows stderr to the agent.
 *
 * The command is parsed, not grepped: quotes, `&&`, pipes, heredocs, `$(...)`,
 * `bash -c`, `xargs` and `find -exec` are all followed, so a commit message
 * that mentions "truncate" passes and `/bin/rm -rf` does not.
 */
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { isAbsolute, join, resolve } from "node:path";

const ALLOW = 0;
const BLOCK = 2;

interface Payload {
  tool_name?: string;
  cwd?: string;
  tool_input?: Record<string, unknown>;
}

interface Finding {
  title: string;
  detail: string;
  instead: string;
}

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;
  }
}
```

*First 42 of 770 lines of `.claude/hooks/block-destructive.ts`.*

The rest of the file follows quotes, `&&`, pipes, heredocs, `$(...)`, `bash -c`, `xargs` and `find -exec`. It unwraps `sudo`, `env`, `timeout` and package runners like `npx`. It tracks `cd` through the line, so a relative path resolves where it will actually run.

When it blocks, it prints three parts to stderr: `BLOCKED by block-destructive:`, what it caught and why, and `Do this instead:`. Then it exits 2. If the payload is unreadable, it allows the call and says so. A guard that bricks every tool call is worse than the risk it covers.

## The wiring in a real repo

This is `.claude/settings.json` from a generated Next.js repo, quoted in full:

```json
{
  "$schema": "https://json.schemastore.org/claude-code-settings.json",
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "bun \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/auto-lint.ts"
          },
          {
            "type": "command",
            "command": "bun \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/enforce-doc-meta.ts"
          }
        ]
      },
      {
        "matcher": "Edit|Write|MultiEdit|Bash|Read|Grep",
        "hooks": [
          {
            "type": "command",
            "command": "bun \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/env-leak-detector-write.ts"
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bun \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-destructive.ts"
          },
          {
            "type": "command",
            "command": "bun \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/enforce-typecheck.ts"
          },
          {
            "type": "command",
            "command": "bun \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-neon-sql.ts"
          }
        ]
      },
      {
        "matcher": "Bash|Read|Grep",
        "hooks": [
          {
            "type": "command",
            "command": "bun \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/env-leak-detector.ts"
          }
        ]
      }
    ]
  },
  "extraKnownMarketplaces": {
    "compound-engineering-plugin": {
      "source": {
        "source": "github",
        "repo": "EveryInc/compound-engineering-plugin",
        "ref": "compound-engineering-v3.28.2"
      }
    }
  },
  "enabledPlugins": {
    "compound-engineering@compound-engineering-plugin": true
  }
}
```

*`.claude/settings.json`, as generated.*

Four groups, 7 scripts:

- **Before `Bash`:** `block-destructive`, `enforce-typecheck`, and `guard-neon-sql` (Neon battery only).
- **Before `Bash|Read|Grep`:** `env-leak-detector`. It stops a read of `.env.local`, an `echo $DATABASE_URL`, or a live key in a command.
- **After `Edit|Write|MultiEdit`:** `auto-lint` runs Biome on the one file that changed. `enforce-doc-meta` checks frontmatter on solution docs and plans.
- **After `Edit|Write|MultiEdit|Bash|Read|Grep`:** `env-leak-detector-write`. It swaps live secrets in output for `[redacted: KEY]`.

Every command starts from `"$CLAUDE_PROJECT_DIR"`, so the guards still fire after the agent runs `cd src`. The file also enables the Compound Engineering plugin, pinned to a release tag.

The full list in this repo:

- `auto-lint.ts`
- `block-destructive.ts`
- `enforce-doc-meta.ts`
- `enforce-typecheck.ts`
- `env-leak-detector-write.ts`
- `env-leak-detector.ts`
- `guard-neon-sql.ts`

Team mode adds two more: `enforce-git-tracked` (no commit while untracked files exist) and `plan-gate` (no writes under `src/` until a plan says `status: approved`).

## Block, deny, rewrite, redact

A hook can do more than say no. The generated guards use four moves:

| Move | How | Example |
|---|---|---|
| Block | Exit 2, reason on stderr | `block-destructive` |
| Deny | Exit 0, `permissionDecision: "deny"` | `guard-neon-sql` |
| Rewrite | Exit 0, `updatedInput` | `enforce-typecheck` |
| Redact | `PostToolUse`, `updatedToolOutput` | `env-leak-detector-write` |

The deny style, from `guard-neon-sql`:

```ts
#!/usr/bin/env bun
/**
 * guard-neon-sql: PreToolUse / Bash
 *
 * Reads the tool call on stdin, decides, and prints a Claude Code hook
 * decision. Uses only APIs shared by Bun and Node so it works whichever
 * runtime the generated repo uses.
 */
import { existsSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";

interface ToolCall {
  tool_name?: string;
  cwd?: string;
  tool_input?: { command?: string };
}

function deny(reason: string): never {
  process.stdout.write(
    JSON.stringify({
      hookSpecificOutput: {
        hookEventName: "PreToolUse",
        permissionDecision: "deny",
        permissionDecisionReason: reason,
      },
    }),
  );
  process.exit(0);
}
```

*First 29 of 537 lines of `.claude/hooks/guard-neon-sql.ts`.*

The rewrite style, from `enforce-typecheck`. A bare `tsc` reads the nearest `tsconfig.json` and reports different errors from the build. So the hook swaps it for the project's script before it runs:

```ts
#!/usr/bin/env bun
/**
 * enforce-typecheck: PreToolUse / Bash
 *
 * Rewrites a bare `tsc` into the project's typecheck script before it runs,
 * using the hook's `updatedInput`. Prints nothing and exits 0 when there is no
 * bare `tsc` in the command. Exit 2 only for a `tsc` it cannot rewrite safely.
 */
```

*First 8 of 457 lines of `.claude/hooks/enforce-typecheck.ts`.*

One catch: if two `PreToolUse` hooks both return `updatedInput` for the same call, the last one to finish wins. They run in parallel, so that order is random. Let one hook own each rewrite.

## Hooks and permission modes

`PreToolUse` hooks run before any permission check, in every permission mode. A hook that denies still blocks in `bypassPermissions` mode or with `--dangerously-skip-permissions`.

It doesn't work the other way. A hook that returns `"allow"` does not override deny rules in your settings. Hooks can tighten permissions. They can't loosen them.

They also run inside subagents. When a subagent calls a tool, the same `PreToolUse` and `PostToolUse` hooks fire, with `agent_id` and `agent_type` in the payload. Your guards cover the whole team of agents, not just the main thread.

## Prove your hooks still work

Guards die quietly. Someone edits `settings.json` and drops the wiring. A script stops parsing after a refactor. Nothing tells you.

Two habits fix that:

- **Pipe test payloads** into each script, like the `echo ... | script` check above. Check the exit code.
- **Run a self-test after any change to `.claude/`.** Every generated repo ships `bun run verify:hooks` (or the `pnpm` or `npm` equivalent). It runs each hook the way Claude Code does, from the command in `.claude/settings.json`, against cases it must block and cases it must allow. Block cases run again from `src/`. One wrong answer fails the script.

## Claude Code hooks best practices

- **Block only what you can't undo.** Deleted files, dropped tables, force pushes, leaked keys. Let the rest through.
- **Parse, don't grep.** Split on `&&`, `|`, `;` and follow `bash -c`, or a chained command smuggles anything past.
- **Fail open on bad input.** Empty stdin or broken JSON should allow, with a note on stderr.
- **Check `tool_name` first.** Then read the `tool_input` fields that tool actually has.
- **Name the hook in the message.** Say what was blocked and what to do instead. A bare "no" makes the agent try a variation.
- **Stay fast.** A hook runs on every matching call.
- **Never scan your own hook folder.** A guard that greps `.claude/hooks/` trips on its own patterns.
- **Commit the shared ones.** Team guards go in `.claude/settings.json`. Personal ones go in `.claude/settings.local.json`.
- **Change a guard in its own commit**, with the reason. Never loosen one in passing.

## Common mistakes

- **Exit 1 to block.** Only exit 2 blocks. Exit 1 is a non-blocking error, and the command runs.
- **Expecting `PostToolUse` to stop anything.** The tool already ran. The file is already written.
- **Relative script paths.** They break after `cd`. Use `"$CLAUDE_PROJECT_DIR"/.claude/hooks/...`.
- **Matching `Edit|Write` and calling it file protection.** Claude can also write files through `Bash`.
- **Half-JSON on stdout.** Output that looks like a JSON object but isn't valid becomes a hook error.
- **No timeout on a network call.** A hung hook stalls the session.
- **Assuming other tools run them.** The generated repo wires hooks for Claude Code only. Codex runs none of them out of the box, so its `AGENTS.md` rules are all it has. See [AGENTS.md examples](/guides/agents-md-examples).

## Get the guards without writing them

The generator writes these hooks, their wiring and the `verify:hooks` self-test into every repo. Neon adds `guard-neon-sql` ([Neon battery](/with/neon)). Pick your stack in the [builder](/build) or run:

```bash
npm create agentic-boilerplate@latest my-app
```

The full rundown of each guard, with the right and wrong way to write your own, is in the cookbook: [what each guard hook blocks](/cookbook/nextjs-vercel/guard-hooks-and-how-to-extend-them). For how hooks fit with rules, skills and subagents, read [the agentic layer](/docs/the-agentic-layer) and the [Claude Code setup for Next.js](/guides/claude-code-setup-nextjs).

## FAQ

### Can a Claude Code hook block a command?

Yes. A `PreToolUse` hook that exits with code 2 blocks the tool call, and its stderr goes to Claude as the reason. A hook can also exit 0 and print JSON with `permissionDecision: "deny"`.

### What is the difference between PreToolUse and PostToolUse?

`PreToolUse` runs before the tool call and can block or rewrite it. `PostToolUse` runs after the call succeeds, so it can't undo it, but it can report a problem or change what Claude sees.

### Where do Claude Code hooks go in settings.json?

Under a top-level `hooks` key, keyed by event name, as a list of groups with a `matcher` and a `hooks` array. Put shared hooks in `.claude/settings.json` and personal ones in `.claude/settings.local.json` or `~/.claude/settings.json`.

### Why is my Claude Code hook not firing?

Run `/hooks` and check it is listed under the right event. Check the matcher matches the tool name exactly, since matching is case-sensitive. Then pipe a sample payload into the script and check the exit code.

### Do hooks still run with --dangerously-skip-permissions?

Yes. `PreToolUse` hooks run before any permission check, and a hook that denies still blocks in bypass mode.

### Do Claude Code hooks work in Codex or Cursor?

The generated repo writes no Codex or Cursor hook config, so Codex runs none of these guards out of the box. Cursor can load Claude Code hooks from `.claude/settings.json` through its third-party hooks setting, but the guards are only tested under Claude Code.


## Sources

- [Claude Code docs: Hooks reference](https://code.claude.com/docs/en/hooks)
- [Claude Code docs: Automate actions with hooks](https://code.claude.com/docs/en/hooks-guide)
- [Claude Code docs: Extend Claude Code](https://code.claude.com/docs/en/features-overview)

## Generate it

[Build your repo](https://agenticboilerplate.com/build)

---

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
