Hooks in Claude Code, Cursor and Codex: enforce what rules can't A developer published a guide showing how to use hooks in Claude Code, Cursor and Codex to enforce agent behavior that instruction files like CLAUDE.md cannot guarantee, such as blocking force-pushes, auto-formatting edited files, and requiring tests to pass before the agent stops. The writeup provides three shared Python scripts placed outside any single tool's folder, per-tool configuration for each editor, and notes on failure modes like workspace trust dialogs and hooks silently doing nothing. It emphasizes that permission rules are enforced by the tool rather than the model, making hooks the deterministic complement to prompt-based rules. Originally published on KitQuarry https://kitquarry.com/articles/claude-code-cursor-codex-hooks . Written by Eliran Kotzero for KitQuarry. 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. 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 https://kitquarry.com/articles/why-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: reset --hard , DROP TABLE . | 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 or Write one regex, see config below | 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 . | | 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. 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. scripts/agent-hooks/guard commands.py : bash /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 https://forum.cursor.com/t/beforeshellexecution-returns-permission-ask-but-sandboxed-agent-shell-still-runs-the-command-sandbox-true/155438 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. 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. bash /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 tool input.file path for Edit and Write , and afterFileEdit sends file path , so only that file is formatted. apply patch and 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. Edit|Write hook when a Bash command rewrites a file a sed -i , a codegen script . Its docs point to the FileChanged event for that. 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. bash /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 https://kitquarry.com/articles/why-long-claude-md-gets-ignored . 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: .claude/settings.json already gets these hooks in Cursor. The scripts should work on that path too, because every Cursor hook input includes cursor version , but we tested only the native .cursor/hooks.json input format. deny wins ; for the formatter and the stop check it's double work. Pick one source per hook. Cursor-only options such as failClosed , loop limit and beforeShellExecution go in the native file. python3 or a timeout lets the command run. Set "failClosed": true in 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. /hooks in Claude Code and Codex, or the Hooks tab and output channel in Cursor. In Codex, editing a hook definition marks it for review again. ~/.cursor/ . Claude Code gives you $CLAUDE PROJECT DIR . Codex may start in a subfolder, hence git rev-parse --show-toplevel . Stop hook that exits 0 as invalid. Print nothing, or exactly one object. ~/.cursor/hooks.json . Claude Code cloud sessions don't read your local ~/.claude/settings.json . py -3 or python ; Codex also takes a commandWindows override. 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 https://kitquarry.com/articles/stop-ai-agents-reading-env-files . Use hooks to make the team's never-list and definition of done automatic, and keep a short security rule https://kitquarry.com/articles/ai-coding-agent-security-rules so the agent knows why. 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. scripts/agent-hooks/ and set PATTERNS , FORMATTERS and CHECK for your repo. 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. 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. 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. 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. 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. Official docs, checked 2026-11-01. If you want a starting point, KitQuarry's free sampler is on GitHub: ai-coding-rules-sampler https://github.com/elirankotzero/ai-coding-rules-sampler . It has a short CLAUDE.md/AGENTS.md, security rules and three skills plan-feature, review-changes, record-decisions for Claude Code, Cursor, Codex and Windsurf. MIT, free. I made it.