Hooks in Claude Code, Cursor and Codex: enforce what rules can't
In short: a rule asks the agent to do something; a hook makes the tool do it. Claude Code, Cursor and Codex all run your scripts at fixed points: before a shell command, after a file edit, and when the agent wants to stop. That's where "never force-push", "format everything" and "run the tests before you say done" belong. Below are three small Python scripts that work in all three tools, the config for each, and the gotchas that make a hook quietly do nothing.
Why rules aren't enough
Instruction files are context. Claude Code's permissions docs put it plainly: "Permission rules are enforced by Claude Code, not by the model. Instructions in your prompt or CLAUDE.md shape what Claude tries to do, but they don't change what Claude Code allows." A long or crowded rules file makes it worse (see why a long CLAUDE.md gets ignored).
Hooks are the other half. The Claude Code hooks guide describes them as "deterministic control: certain actions always happen rather than relying on the LLM to choose to run them." Keep the rule as the explanation and add a hook for the handful of things that must happen every time:
- Block a command before it runs (force-push,
reset --hard,DROP TABLE). - Fix up after an edit (run the formatter, so style never comes up in review).
- Refuse "done" while the checks fail, and send the agent back to work.
The same three hooks in each tool
| Job | Claude Code | Cursor | Codex |
|---|---|---|---|
| Before a shell command | PreToolUse, matcher Bash | beforeShellExecution (or preToolUse, matcher Shell) | PreToolUse, matcher Bash |
| After the agent edits a file | PostToolUse, matcher Edit|Write | afterFileEdit | PostToolUse, matcher apply_patch (or Edit|Write) |
| When the agent wants to stop | Stop | stop | Stop |
| How to block | Exit 2 + reason on stderr, or JSON permissionDecision | JSON "permission": "deny", or exit 2 | Exit 2 + reason on stderr, or JSON permissionDecision |
| How to keep it working | Stop hook exits 2 | Return a followup_message | Stop hook exits 2 (becomes a new prompt) |
Codex's hook format is close to Claude Code's: the same core event names, the same matcher + hooks nesting and the same hookSpecificOutput JSON. Cursor has its own flatter format, and it also reads Claude Code's hooks (more on that below).
Where hooks live and when they run
| Claude Code | Cursor | Codex | |
|---|---|---|---|
| Project file | .claude/settings.json (.claude/settings.local.json for just you) | .cursor/hooks.json | .codex/hooks.json or [hooks] in .codex/config.toml |
| Personal file | ~/.claude/settings.json | ~/.cursor/hooks.json | ~/.codex/hooks.json or ~/.codex/config.toml |
| Before it runs | Interactive sessions hold back hooks until you accept the workspace trust dialog. -p and SDK runs treat the folder as trusted | Project hooks run only in a trusted workspace | Project hooks load only in a trusted project, and you review and trust each hook in /hooks. A changed hook is skipped until you trust it again |
| See what's loaded | /hooks (read-only) | Hooks tab in Customize, Hooks output channel | /hooks |
| Turn hooks off | "disableAllHooks": true | Remove the entries (the docs list no global switch) | hooks = false under [features], or disable one hook in /hooks |
All three merge hooks from every level instead of letting one file replace another, so a personal hook and a project hook for the same event both run. Hooks run with your user's permissions, so read the hooks in a repo you didn't write before you trust it. Claude Code's docs warn that claude -p runs a repository's committed hooks without asking.
Setup: one folder of scripts, three small configs
Put the scripts in the repo, outside any one tool's folder, so all three tools and all your teammates use the same code:
scripts/agent-hooks/guard_commands.py # before shell commands
scripts/agent-hooks/format_edited.py # after file edits
scripts/agent-hooks/require_checks.py # when the agent wants to stop
.claude/settings.json # Claude Code
.cursor/hooks.json # Cursor
.codex/hooks.json # Codex
Each script reads the event JSON on stdin and works out which tool called it. Cursor sends a cursor_version field with every hook and expects JSON back; Claude Code and Codex accept exit code 2 with the reason on stderr. They need only Python 3 and git.
Hook 1: block risky commands
scripts/agent-hooks/guard_commands.py:
#!/usr/bin/env python3
"""Block risky shell commands. Claude Code / Codex PreToolUse hook, Cursor beforeShellExecution hook."""
import json, re, shlex, sys
PATTERNS = [ # (regex, why), checked against each part of a command line
(r"\bgit\b.*\bpush\b.*\s(--force(?![\w-])|-f\b)", "force push (use --force-with-lease, and ask first)"),
(r"\bgit\b.*\breset\b.*\s--hard\b", "git reset --hard discards uncommitted work"),
(r"\bgit\b.*\bclean\b.*\s-[a-zA-Z]*f", "git clean -f deletes untracked files"),
(r"\bgit\b.*\s--no-verify\b", "--no-verify skips the repo's git hooks"),
(r"(?i)\b(drop\s+(table|database|schema)|truncate\s+table)\b", "destructive SQL"),
]
def risky_rm(part):
try:
words = shlex.split(part)
except ValueError:
words = part.split()
if not words or words[0].rsplit("/", 1)[-1] != "rm":
return None
flags = [w for w in words[1:] if w.startswith("-")]
if not any(f == "--recursive" or (not f.startswith("--") and "r" in f.lower()) for f in flags):
return None
for t in (w for w in words[1:] if not w.startswith("-")):
if t in (".", "*") or t.startswith(("/", "~", "$")) or ".." in t:
return f"recursive rm of {t} (too broad or outside the project)"
return None
data = json.load(sys.stdin)
command = data.get("command") or (data.get("tool_input") or {}).get("command") or ""
if isinstance(command, list):
command = " ".join(command)
parts = [p.strip() for p in re.split(r"&&|\|\||[;|\n]", command)]
parts = [re.sub(r"^((sudo|command)\s+|\w+=\S*\s+)+", "", p) for p in parts if p]
why = next((w for p in parts for rx, w in PATTERNS if re.search(rx, p)), None)
why = why or next((r for r in map(risky_rm, parts) if r), None)
if why is None and re.search(r"\b(curl|wget)\b.*\|\s*(sudo\s+)?(ba|z)?sh\b", command):
why = "piping a download into a shell"
cursor = "cursor_version" in data # Cursor sends this field to every hook
if why is None:
if cursor:
print(json.dumps({"permission": "allow"}))
sys.exit(0)
msg = f"Blocked by a project hook: {why}. Don't work around it. Tell the user the exact command and why you need it."
if cursor:
print(json.dumps({"permission": "deny", "user_message": f"Blocked: {why}", "agent_message": msg}))
sys.exit(0)
print(msg, file=sys.stderr)
sys.exit(2)
It splits the command line at &&, ||, ; and pipes, so cd src && rm -rf ../.. is caught too. It blocks force-pushes (but not --force-with-lease), reset --hard, clean -f, --no-verify, DROP/TRUNCATE in SQL, piping a download into a shell, and recursive rm of /, ~, $VAR, .., . or *. rm -rf node_modules dist still runs. The message tells the agent to stop and ask you, rather than just saying "blocked" and leaving it to try another way. Edit PATTERNS to match your own never-list.
Claude Code, in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR\"/scripts/agent-hooks/guard_commands.py" }
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR\"/scripts/agent-hooks/format_edited.py" }
]
}
],
"Stop": [
{
"hooks": [
{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR\"/scripts/agent-hooks/require_checks.py", "timeout": 300 }
]
}
]
}
}
A hook that exits 2 blocks the call before permission rules are checked, so the guard holds even if you allow Bash wholesale. The reverse isn't true: a hook can't approve a call that a deny or ask rule catches. Claude Code also offers an if field ("Bash(git *)") to skip spawning the script for unrelated commands, but its docs call that filter best-effort, so the script above checks every command itself.
Cursor, in .cursor/hooks.json (project hooks run from the project root, so the path is relative to it):
{
"version": 1,
"hooks": {
"beforeShellExecution": [
{ "command": "python3 scripts/agent-hooks/guard_commands.py", "failClosed": true }
],
"afterFileEdit": [
{ "command": "python3 scripts/agent-hooks/format_edited.py" }
],
"stop": [
{ "command": "python3 scripts/agent-hooks/require_checks.py", "timeout": 300, "loop_limit": 3 }
]
}
}
failClosed: true blocks the command if the script crashes or times out. Without it, Cursor lets the command through. For commands it doesn't block, the script returns allow, as Cursor's own block-git.sh example does. That doesn't auto-approve anything: a Cursor team member confirmed on the forum in March 2026 that only deny is enforced from these hooks today, and allow and ask are ignored, so your normal approval settings still apply. Rely on deny and nothing else.
Codex, in .codex/hooks.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "^Bash$",
"hooks": [
{ "type": "command", "command": "python3 \"$(git rev-parse --show-toplevel)/scripts/agent-hooks/guard_commands.py\"", "statusMessage": "Checking command" }
]
}
],
"PostToolUse": [
{
"matcher": "^apply_patch$",
"hooks": [
{ "type": "command", "command": "python3 \"$(git rev-parse --show-toplevel)/scripts/agent-hooks/format_edited.py\"" }
]
}
],
"Stop": [
{
"hooks": [
{ "type": "command", "command": "python3 \"$(git rev-parse --show-toplevel)/scripts/agent-hooks/require_checks.py\"", "timeout": 300 }
]
}
]
}
}
The Codex docs suggest resolving repo-local hook paths from the git root, because Codex may start in a subfolder. ^Bash$ covers both shell commands and unified exec sessions. After you add the file, open /hooks and trust the hooks, or they won't run.
Hook 2: format what the agent edits
scripts/agent-hooks/format_edited.py runs Prettier (from the repo's node_modules), ruff format or gofmt on edited files, and skips any formatter that isn't installed. Change FORMATTERS to your stack.
#!/usr/bin/env python3
"""Format files after the agent edits them. Claude Code / Codex PostToolUse hook, Cursor afterFileEdit hook."""
import json, os, pathlib, shutil, subprocess, sys
data = json.load(sys.stdin)
root = pathlib.Path(data.get("cwd") or (data.get("workspace_roots") or [os.getcwd()])[0])
root = pathlib.Path(subprocess.run(["git", "rev-parse", "--show-toplevel"], cwd=root,
capture_output=True, text=True).stdout.strip() or root).resolve()
prettier = root / "node_modules" / ".bin" / "prettier"
FORMATTERS = { # extension -> command; a formatter that isn't installed is skipped
(".js", ".jsx", ".ts", ".tsx", ".json", ".css", ".md"): [str(prettier), "--write"],
(".py",): ["ruff", "format"],
(".go",): ["gofmt", "-w"],
}
path = data.get("file_path") or (data.get("tool_input") or {}).get("file_path")
if path: # Claude Code Edit/Write and Cursor afterFileEdit name the file
files = [path]
else: # Codex apply_patch doesn't: format every modified or new file instead
out = subprocess.run(["git", "ls-files", "-z", "-m", "-o", "--exclude-standard"], cwd=root,
capture_output=True, text=True).stdout
files = [str(root / name) for name in out.split("\0") if name]
for f in files:
p = pathlib.Path(f)
if not p.is_file() or root not in p.resolve().parents:
continue
for exts, cmd in FORMATTERS.items():
if p.suffix in exts and shutil.which(cmd[0]):
subprocess.run(cmd + [str(p)], cwd=root, capture_output=True)
if "cursor_version" in data:
print("{}")
sys.exit(0)
- Claude Code sends the absolute path in
tool_input.file_pathforEditandWrite, and Cursor'safterFileEditsendsfile_path, so only that file is formatted. - Codex edits files with
apply_patchand sends the patch text, not a path, so the script formats every modified or new file git reports. That includes files you changed by hand. - It runs after the edit has happened and always exits 0, so it never blocks the agent.
- Claude Code doesn't run an
Edit|Writehook when a Bash command rewrites a file (ased -i, a codegen script). Its docs point to theFileChangedevent for that.
Hook 3: don't let the agent stop while checks fail
scripts/agent-hooks/require_checks.py runs one command when the agent tries to finish. If it fails, the agent gets the last 40 lines of output and is sent back to work. Set CHECK to your fastest meaningful check, because it runs every time the agent stops, including after a plain question.
#!/usr/bin/env python3
"""Stop hook: keep the agent working while the checks fail. Claude Code, Codex and Cursor."""
import json, os, subprocess, sys
CHECK = "npm test" # your fastest meaningful check, e.g. "npm run typecheck && npm test"
data = json.load(sys.stdin)
cursor = "cursor_version" in data
root = data.get("cwd") or (data.get("workspace_roots") or [os.getcwd()])[0]
def done():
if cursor:
print("{}")
sys.exit(0)
if data.get("stop_hook_active") or (cursor and data.get("status") != "completed"):
done() # already pushed back once this turn, or the run was aborted or errored
if not subprocess.run(["git", "status", "--porcelain"], cwd=root, capture_output=True, text=True).stdout:
done() # nothing changed, e.g. the agent only answered a question
result = subprocess.run(CHECK, shell=True, cwd=root, capture_output=True, text=True)
if result.returncode == 0:
done()
tail = "\n".join((result.stdout + result.stderr).strip().splitlines()[-40:])
msg = f"`{CHECK}` fails. Fix the code, not the tests, then finish. Output:\n{tail}"
if cursor:
print(json.dumps({"followup_message": msg}))
sys.exit(0)
print(msg, file=sys.stderr)
sys.exit(2)
Each tool keeps the agent going in its own way: Claude Code and Codex on exit code 2 (Codex turns the reason into a new prompt), Cursor when the hook returns a followup_message. Each also has a loop guard. Claude Code and Codex set stop_hook_active once a Stop hook has already sent the agent back, and the script then lets it stop, so you get one retry instead of an endless loop. Cursor caps follow-ups with loop_limit (5 by default; the config above sets 3). The script also skips the check when git status is clean. For the Claude Code details (the eight-block cap, testing a Stop hook by hand) see the "make done mean the tests passed" section of why a long CLAUDE.md gets ignored.
Using your Claude Code hooks in Cursor
Cursor loads hooks from .claude/settings.json, .claude/settings.local.json and ~/.claude/settings.json when Include Third-Party Plugins, Skills, and Other Configs is on (Cursor Settings → Agents → Third-Party Imports; on by default). It maps PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, PreCompact and UserPromptSubmit to its own events, maps the tool names Bash to Shell and Edit to Write, and accepts Claude Code's JSON responses. Notification and PermissionRequest aren't supported.
Two consequences:
- A repo with only
.claude/settings.jsonalready gets these hooks in Cursor. The scripts should work on that path too, because every Cursor hook input includescursor_version, but we tested only the native.cursor/hooks.jsoninput format. - If you also add
.cursor/hooks.json, Cursor runs the hooks from both files. For the guard that's harmless (anydenywins); for the formatter and the stop check it's double work. Pick one source per hook. Cursor-only options such asfailClosed,loop_limitandbeforeShellExecutiongo in the native file.
Why a hook silently does nothing
- Exit 1 isn't a block. In Claude Code, any exit code other than 0 or 2 is a non-blocking error and the action goes ahead; the docs say "If your hook is meant to enforce a policy, use exit 2." Cursor fails open on crashes, timeouts and exit codes other than 2 too.
- Failures fail open by default. A wrong path, a missing
python3or a timeout lets the command run. Set"failClosed": truein Cursor, or"onFailure": "block"on the handler in Claude Code (v2.1.295 or later). The Codex hooks page doesn't document an equivalent switch for command hooks. - The hook isn't trusted or isn't loaded. Check
/hooksin Claude Code and Codex, or the Hooks tab and output channel in Cursor. In Codex, editing a hook definition marks it for review again. - Wrong path for the tool. Cursor runs project hooks from the project root and user hooks from
~/.cursor/. Claude Code gives you$CLAUDE_PROJECT_DIR. Codex may start in a subfolder, hencegit rev-parse --show-toplevel. - Stray output. Claude Code reads stdout as JSON only if it's a single JSON object with nothing around it, and Codex treats plain text from a
Stophook that exits 0 as invalid. Print nothing, or exactly one object. - The work happened somewhere the hook doesn't watch. Codex doesn't hook hosted tools such as web search. Cursor cloud agents run project hooks but not
~/.cursor/hooks.json. Claude Code cloud sessions don't read your local~/.claude/settings.json. - Windows. Cursor runs hook commands through PowerShell on Windows, and
python3may not exist there. Usepy -3orpython; Codex also takes acommandWindowsoverride.
Hooks are guardrails, not a sandbox
The guard matches text. A Python one-liner that calls shutil.rmtree() on the same folder doesn't contain rm -rf, and a commit message that mentions push -f gets blocked by mistake. Codex's docs say it directly: "Treat tool hooks as a useful guardrail, not a complete enforcement boundary." For limits that must hold, use each tool's permission rules and sandbox. We cover those settings for secrets in how to stop Claude Code, Cursor and Codex reading your .env file. Use hooks to make the team's never-list and definition of done automatic, and keep a short security rule so the agent knows why.
Test a hook before you trust it
Pipe the JSON each tool sends into the exact command from your config and check the exit code and output:
# Claude Code / Codex shape: expect exit 2 and the reason on stderr
echo '{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}' \
| python3 scripts/agent-hooks/guard_commands.py; echo "exit $?"
# Cursor shape: expect {"permission": "deny", ...} and exit 0
echo '{"cursor_version":"x","command":"git reset --hard HEAD~3"}' \
| python3 scripts/agent-hooks/guard_commands.py; echo "exit $?"
We ran the three scripts this way on a sample repo, using each tool's documented input format and the exact command strings from the three configs: 27 commands through the guard for each tool, the formatter for a Claude Code edit, a Cursor edit and a Codex patch started from a subfolder, and the stop check with a clean tree, a passing change, a failing change and a repeat stop. All passed. We didn't run them inside Claude Code, Cursor or Codex, so watch /hooks or Cursor's Hooks output channel the first time each one fires.
A ten-minute setup
- Copy the three scripts into
scripts/agent-hooks/and setPATTERNS,FORMATTERSandCHECKfor your repo. - Add the config for each tool you use and commit it, so teammates and worktrees get the same hooks.
- Trust the hooks: accept the workspace trust dialog in Claude Code, trust the workspace in Cursor, and trust each hook in Codex's
/hooks. - Run the two test commands above, then ask the agent to force-push and confirm it's blocked.
- Leave one line per hook in your rules file ("force-pushes are blocked by a hook; ask me instead") so the agent understands the block instead of fighting it.
FAQ
What's the difference between a rule and a hook?
A rule (CLAUDE.md, AGENTS.md, a Cursor .mdc file) is text the model reads and usually follows. A hook is a script the tool itself runs at a fixed point, such as before a shell command or when the agent wants to stop, whatever the model decided. Use rules to explain what you want and hooks for the few things that must happen every time.
Do Claude Code hooks work in Cursor?
Yes, by default. Cursor loads hooks from .claude/settings.json, .claude/settings.local.json and ~/.claude/settings.json when Include Third-Party Plugins, Skills, and Other Configs is on, and maps PreToolUse, PostToolUse, Stop and the other main events to its own. Bash maps to Shell and Edit to Write. If you also add .cursor/hooks.json, hooks from both files run.
Does Codex support hooks?
Yes. Codex reads hooks from hooks.json or inline [hooks] tables in config.toml, in ~/.codex or a project's .codex folder. Project hooks load only in a trusted project, and every non-managed hook must be reviewed and trusted in /hooks before it runs. The events and JSON shapes follow Claude Code's closely: PreToolUse, PostToolUse, Stop, SessionStart and more.
How do I block a command in a Claude Code hook?
Register a PreToolUse hook with the matcher Bash. The script reads the command from tool_input.command on stdin, and to block it either exits with code 2 and writes the reason to stderr, or exits 0 and prints a JSON permissionDecision of "deny". Exit code 1 doesn't block: Claude Code treats it as a non-blocking error and runs the command.
Are hooks a security boundary?
No. A command hook matches text, so a script that does the same thing under another name gets through, and by default a hook that crashes or times out lets the action proceed. Codex's docs call tool hooks a useful guardrail, not a complete enforcement boundary. Use permission rules and the sandbox for hard limits, and hooks for team policy and checks.
Sources
- Claude Code docs: Hooks reference (events, exit codes, onFailure, workspace trust)
- Claude Code docs: Automate actions with hooks (guide)
- Claude Code docs: Configure permissions (hooks vs permission rules)
- Cursor docs: Hooks (hooks.json, failClosed, beforeShellExecution, afterFileEdit, stop)
- Cursor docs: Third Party Hooks (Claude Code hooks in Cursor)
- Codex docs (ChatGPT Learn): Hooks (hooks.json, trust review, PreToolUse, Stop)