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

> Source: <https://dev.to/eliran_kotzero_e1781dc487/hooks-in-claude-code-cursor-and-codex-enforce-what-rules-cant-4ni2>
> Published: 2026-11-11 07:16:06+00:00

*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.)*
