Skip to content

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 · 10 min read

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:

EventFires
SessionStartWhen a session starts or resumes
UserPromptSubmitWhen you send a prompt, before Claude reads it
PreToolUseBefore a tool call. Can block it
PermissionRequestWhen a tool call needs a permission decision
PostToolUseAfter a tool call succeeds
PostToolUseFailureAfter a tool call fails
StopWhen Claude finishes responding
SubagentStopWhen a subagent finishes
PreCompactBefore the context is compacted
NotificationWhen 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:

{
  "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 codeWhat happens
0No objection. For PreToolUse this is not an approval: the normal permission flow still runs
2Block. On PreToolUse the call never runs, and stderr goes to Claude as the reason
Anything elseA 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:

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

Where that block can live:

FileScope
~/.claude/settings.jsonAll your projects
.claude/settings.jsonThis project. Commit it
.claude/settings.local.jsonThis project, just you
Managed policy settingsThe whole organization
A plugin's hooks/hooks.jsonWherever the plugin is on
Skill or subagent frontmatterWhile 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:

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

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:

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

{
  "$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:

MoveHowExample
BlockExit 2, reason on stderrblock-destructive
DenyExit 0, permissionDecision: "deny"guard-neon-sql
RewriteExit 0, updatedInputenforce-typecheck
RedactPostToolUse, updatedToolOutputenv-leak-detector-write

The deny style, from guard-neon-sql:

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

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

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). Pick your stack in the builder or run:

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. For how hooks fit with rules, skills and subagents, read the agentic layer and the Claude Code setup for Next.js.

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

Tool behavior is from the official docs, checked on October 4, 2026. Tools change. The docs win.