Home › Articles

Hooks in Claude Code, Cursor and Codex: enforce what rules can't

Updated 2026-11-01

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:

The same three hooks in each tool

JobClaude CodeCursorCodex
Before a shell commandPreToolUse, matcher BashbeforeShellExecution (or preToolUse, matcher Shell)PreToolUse, matcher Bash
After the agent edits a filePostToolUse, matcher Edit|WriteafterFileEditPostToolUse, matcher apply_patch (or Edit|Write)
When the agent wants to stopStopstopStop
How to blockExit 2 + reason on stderr, or JSON permissionDecisionJSON "permission": "deny", or exit 2Exit 2 + reason on stderr, or JSON permissionDecision
How to keep it workingStop hook exits 2Return a followup_messageStop 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 CodeCursorCodex
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 runsInteractive sessions hold back hooks until you accept the workspace trust dialog. -p and SDK runs treat the folder as trustedProject hooks run only in a trusted workspaceProject 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": trueRemove 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)

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:

Why a hook silently does nothing

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

  1. Copy the three scripts into scripts/agent-hooks/ and set PATTERNS, FORMATTERS and CHECK for your repo.
  2. Add the config for each tool you use and commit it, so teammates and worktrees get the same hooks.
  3. Trust the hooks: accept the workspace trust dialog in Claude Code, trust the workspace in Cursor, and trust each hook in Codex's /hooks.
  4. Run the two test commands above, then ask the agent to force-push and confirm it's blocked.
  5. 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

Official docs, checked 2026-11-01.

Get the free rules sampler + new tips by email

Confirm your email and we'll send you straight to the sampler download. Then one short email every two weeks covering tool format changes (Cursor, Claude Code, Codex, Windsurf, Copilot), new rules and kit updates. No spam.

We store only your email, with our email provider Resend. See the privacy policy.

Unsubscribe anytime from any email. Rather skip email? Get the sampler on GitHub or download the zip directly.