GuidesHosting choices

Claude Code hooks: a guardrail that always runs, and in Codex

Claude Code hooks: the events, how a PreToolUse hook blocks a command, a tested guardrail script, and the same hook in Codex, which must trust it first.

October 9, 2026The Everpod team
The short answer

A hook is a command Claude Code runs at a fixed point in a session, whatever the model decides: before a tool runs, after a file is edited, when a turn ends. You declare it in a settings file, it gets the event as JSON on standard input, and on PreToolUse it can stop the tool call by exiting with code 2, with the reason on standard error. Anthropic’s own line for when to use one: “Put guardrails in hooks.”

Codex has hooks too, on by default since May 2026, with the same event names, the same JSON and the same exit-2 rule, and in our test the same script blocked the same command in Codex. The differences that bite: Codex runs a hook only after you have reviewed and trusted it in /hooks, and a hook Codex doesn’t load fails without a word, so check that yours fires.

What a hook is for

Everything else you give Claude Code is advice. Anthropic’s features overview puts it plainly: “An instruction like ‘never edit .env’ in CLAUDE.md or a skill is a request, not a guarantee. A PreToolUse hook that blocks the edit is enforcement. If a rule must hold every time, make it a hook rather than a prompt instruction.” A hook also costs no context unless it prints something the model is shown. The flip side is that a hook can’t judge; it runs the same code every time, so the jobs that suit it are mechanical: refuse a class of command, run the formatter after every edit, post a message when a session ends, add the current branch to every prompt.

Hooks sit beside permissions rather than replacing them. A hook that blocks wins over an allow rule (“A hook that exits with code 2 stops the tool call before permission rules are evaluated”), and the hooks guide says a denying PreToolUse hook “blocks the tool even in bypassPermissions mode or with --dangerously-skip-permissions.” It doesn’t work the other way: a hook that says “allow” can’t get past a deny or ask rule. So hooks tighten, permissions set the outer limit, and running without prompts is a separate decision that a guardrail hook makes safer.

The events you will use

Claude Code 2.1.295 has 33 hook events (the full list is in the hooks reference). Most hooks people write sit on these:

EventWhen it firesCan it stop things?
SessionStartA session begins, resumes, clears or compactsNo; its plain output is added as context
UserPromptSubmitYou send a prompt, before Claude reads itYes, it can reject the prompt; plain output becomes context
PreToolUseBefore any tool callYes: exit 2, or a JSON deny
PermissionRequestOnly when Claude Code would ask youYes, through its JSON decision; exit 2 is ignored here
PostToolUseAfter a tool call succeedsNo: “the tool already ran”; it can only tell Claude
StopClaude finishes respondingYes: blocking makes it keep going (capped at eight in a row)
PreCompactBefore the context is compactedYes
NotificationClaude Code sends a notificationNo
SessionEndThe session endsNo; it gets 1.5 seconds by default

A matcher narrows a tool event to tool names: Bash, Edit|Write, or a regular expression, which is unanchored, so Edit.* also catches NotebookEdit. Files you pull in with @ in a prompt never pass through a tool, so no PreToolUse hook sees them; Anthropic says to use a Read deny rule for those paths instead.

A guardrail, tested

This hook refuses shell commands that delete a folder tree or rewrite git history. It needs jq. Save it as .claude/hooks/block-destructive.sh and make it executable:

#!/usr/bin/env bash
# PreToolUse guardrail: refuse shell commands that delete a tree or rewrite history.
# Reads the hook's JSON on stdin; exit 2 with a reason on stderr blocks the call.
cmd=$(jq -r '.tool_input.command | if type == "array" then join(" ") else . end // empty')
if printf '%s' "$cmd" | grep -Eiq 'rm[[:space:]]+-[a-zA-Z]*(rf|fr)|git[[:space:]]+push.*(--force|[[:space:]]-f)|git[[:space:]]+reset[[:space:]]+--hard'; then
  echo "Blocked by a hook: '$cmd' deletes or overwrites work. Ask the user to run it." >&2
  exit 2
fi
exit 0

Then register it in .claude/settings.json, which you can commit so everyone on the repository gets it:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-destructive.sh" }
        ]
      }
    ]
  }
}

We ran it on October 9, 2026, with Claude Code 2.1.295, in a fresh repository holding a build folder. To make sure only the hook could stop it, we first allowed the exact command with a permission rule (--allowedTools "Bash(rm -rf build)"), then asked Claude, non-interactively, to run rm -rf build. The hook fired on PreToolUse:Bash, exited 2, and Claude received “Blocked by a hook: ‘rm -rf build’ deletes or overwrites work. Ask the user to run it.” as the tool’s result. Its answer: “I didn’t delete the build folder: a project hook (block-destructive.sh) blocked rm -rf build as destructive, so you’ll need to run it yourself.” The folder was still there.

Two limits come with a hook like this. It matches text, so a command it doesn’t recognise (a find -delete, a script that does the deleting) goes through; it guards against mistakes, not against an agent set on getting round it. And Claude Code has its own if field to narrow a hook with permission-rule syntax, but the reference calls it “best-effort” and says to use “the permission system rather than a hook to enforce a hard allow or deny.” A hook that runs on every Bash call and decides for itself, like this one, avoids that.

Blocking: exit codes, JSON, and the ways a hook lets things through

Exit 0 means the hook has nothing to say: “staying silent doesn’t approve it,” and the call carries on through the normal permission flow. Exit 2 blocks, and the text on standard error becomes the reason Claude sees; even a JSON “allow” can’t override an exit 2. For finer control, exit 0 and print JSON instead: on PreToolUse, hookSpecificOutput.permissionDecision takes allow, deny, ask or defer, and updatedInput rewrites the tool’s arguments before it runs. When several hooks disagree, deny beats defer, which beats ask, which beats allow. Pick one style per hook.

Four ways a hook you meant as a gate lets the call through:

All the hooks that match an event run in parallel, and every one runs to completion, so one hook’s deny doesn’t stop another hook’s side effects.

Where hooks live

FileApplies to
~/.claude/settings.jsonEvery project on your machine
.claude/settings.jsonThe repository, for everyone who commits and pulls it
.claude/settings.local.jsonThe repository, for you only
Managed policy settingsThe whole organisation, set by an administrator
A plugin’s hooks/hooks.jsonWhile the plugin is enabled
A skill’s or subagent’s frontmatterOnce the skill is used, or while the subagent runs

Hooks from every level add up rather than replacing each other, and the same handler defined in two settings files runs once. Edits are picked up without a restart. /hooks opens a read-only list of what is configured and where each comes from. To switch them all off, set "disableAllHooks": true (an administrator’s managed hooks stay on), or pass --settings '{"disableAllHooks": true}' for one run; “There is no way to disable an individual hook while keeping it in the configuration.” Hooks can also be HTTP endpoints, MCP tool calls, a prompt to a model, or a subagent, but command hooks are the ones Anthropic recommends for anything that has to work: agent hooks are experimental.

Codex’s hooks

OpenAI announced Codex hooks as generally available on May 14, 2026, and they are on unless you set [features] hooks = false in config.toml. The design follows Claude Code’s closely: twelve events with the same names where they overlap (PreToolUse, PermissionRequest, PostToolUse, UserPromptSubmit, Stop, SessionStart and the rest, plus an Interrupt of its own), the same JSON on standard input, the same hookSpecificOutput.permissionDecision: "deny", and the same exit 2 with a reason on standard error. Codex reads them from hooks.json or [hooks] tables beside its config files, the useful ones being ~/.codex/hooks.json and <repo>/.codex/hooks.json. The script above works unchanged: in our test Codex handed it the same tool_name (Bash) and tool_input.command as Claude Code does. Codex has no $CLAUDE_PROJECT_DIR, so its docs suggest finding the repository root with git:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "\"$(git rev-parse --show-toplevel)\"/.claude/hooks/block-destructive.sh" }
        ]
      }
    ]
  }
}

What differs:

Codex also keeps two older mechanisms: notify, which runs a program when a turn completes, and rules files, which allow, prompt for or forbid commands by prefix and are the closer match for a command denylist.

Before you run someone else’s repository

“Command hooks execute shell commands with your full user permissions,” Anthropic warns, and a repository can ship them in .claude/settings.json. In an interactive session Claude Code holds back every settings-file hook until you accept the folder’s trust dialog. In claude -p and SDK runs there is no dialog: Claude Code “treats the folder as trusted, so hooks committed in a repository’s .claude/settings.json run in a folder you’ve never trusted.” Anthropic’s advice before scripting claude -p over a repository you didn’t write: review its .claude/ settings, start with --bare, or pass --settings '{"disableAllHooks": true}'. Codex’s per-hook trust covers this case by default. The same caution holds for plugins and skills, which can carry hooks of their own.

Run Claude Code, Codex, OpenCode, Pi, Hermes or OpenClaw on an always-on developer pod.

A developer pod is a cloud computer of your own with your pick of Claude Code, Codex, OpenCode, Pi, Hermes and OpenClaw installed, reached only over your own Tailscale network. From $24 a month, built in about ten minutes.