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.
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:
| Event | When it fires | Can it stop things? |
|---|---|---|
SessionStart | A session begins, resumes, clears or compacts | No; its plain output is added as context |
UserPromptSubmit | You send a prompt, before Claude reads it | Yes, it can reject the prompt; plain output becomes context |
PreToolUse | Before any tool call | Yes: exit 2, or a JSON deny |
PermissionRequest | Only when Claude Code would ask you | Yes, through its JSON decision; exit 2 is ignored here |
PostToolUse | After a tool call succeeds | No: “the tool already ran”; it can only tell Claude |
Stop | Claude finishes responding | Yes: blocking makes it keep going (capped at eight in a row) |
PreCompact | Before the context is compacted | Yes |
Notification | Claude Code sends a notification | No |
SessionEnd | The session ends | No; 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 0Then 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:
- Exit 1. It is the usual Unix failure code, and Claude Code treats it as a non-blocking error: “If your hook is meant to enforce a policy, use
exit 2.” - A timeout. Command hooks get 600 seconds by default, and one that times out “doesn’t block the tool call.” Claude Code 2.1.295’s changelog (October 8, 2026) adds
onFailure: "block"for command and HTTP hooks, so one that can’t start, times out or exits oddly blocks instead; the hooks reference doesn’t describe it yet. - A wrong path. “A mistyped path in
settings.jsonleaves the gate silently disabled,” so test a new gate once, as above. - The wrong event. A
PostToolUsehook fires after the command has run; it can only tell Claude about it.
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
| File | Applies to |
|---|---|
~/.claude/settings.json | Every project on your machine |
.claude/settings.json | The repository, for everyone who commits and pulls it |
.claude/settings.local.json | The repository, for you only |
| Managed policy settings | The whole organisation, set by an administrator |
A plugin’s hooks/hooks.json | While the plugin is enabled |
| A skill’s or subagent’s frontmatter | Once 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:
- Trust, per hook. “Non-managed hooks must be reviewed and trusted before they run.” Codex records the trust against a hash of the hook’s definition, so any edit sends it back for review, and
/hooksis where you review, trust or switch off a single hook (Claude Code’s/hooksonly lists them). A repository’s hooks also load only once the project itself is trusted. - What we saw. With Codex 0.160.0 on October 9, 2026, in a test repository with an uncommitted change, we gave
codex execthe hooks with-con the command line and--dangerously-bypass-hook-trust, OpenAI’s flag “for one-off automation that already vets hook sources outside Codex”. Every hook fired, and the guard stoppedgit reset --hard: “Command blocked by PreToolUse hook: Blocked by a hook: ‘git reset --hard’ deletes or overwrites work. Ask the user to run it.” The change survived. The same hooks committed in the repository’s.codex/hooks.jsonnever fired in those runs, not even a session-start hook, with the project marked trusted only by a-coverride and the trust check bypassed; Codex printed no warning, and the command went to the shell. Whatever the cause in a given setup, a Codex hook that isn’t loaded fails silently, so test that yours fires, for example with aSessionStarthook that writes a line to a file. Asked earlier to runrm -rf build, Codex had refused it on its own, before any hook came into it, with approvals set to never: “rm -f style commands are not permitted.” - Handlers. Command and MCP-tool hooks run; prompt and agent hooks are “parsed but skipped”, and there is no HTTP type.
- What fails open. A
PreToolUsehook returningask,continue: falseor the oldapproveis marked failed and the call goes ahead, and an exit 2 with nothing on standard error doesn’t block. OpenAI’s own framing: “Treat tool hooks as a useful guardrail, not a complete enforcement boundary.”
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.