Codex hooks: block a command before it runs

Codex hooks run your own script at points in a session, such as before every shell command. Where hooks.json goes, a tested PreToolUse hook that blocks rm -r, force pushes and .env files, why a new hook does not run until you trust it in /hooks, the events, and background hooks.

Short answer: Put a PreToolUse hook with the matcher ^Bash$ in ~/.codex/hooks.json (or .codex/hooks.json in the project). Codex sends each shell command to your script as JSON on stdin; the script exits with code 2 and a reason on stderr to block it, or 0 to let it run. Then open /hooks in Codex and trust the hook: a new or changed hook is skipped until you do.

Rules and the sandbox decide what Codex may do in general. A hook is for the cases you want to judge yourself, with code: "never delete folders", "never force push", "never open .env". It runs on every matching call.

Where do Codex hooks go?

Codex reads hooks from hooks.json files or [hooks] tables in config.toml, next to each config layer. The four you will use:

FileApplies to
~/.codex/hooks.jsonYou, in every project
~/.codex/config.toml ([hooks])The same, in TOML
<repo>/.codex/hooks.jsonEveryone in this repository
<repo>/.codex/config.toml ([hooks])The same, in TOML

Unlike other settings, hooks add up: Codex runs every matching hook from every file, and a project file does not replace yours. A project's hooks load only when you trust the project. Keep one form per folder; if a folder has both hooks.json and [hooks], Codex merges them and warns.

A hook that blocks risky commands

The script, saved as .codex/hooks/check_bash.py in your repository:

#!/usr/bin/env python3
import json
import re
import sys

BLOCKED = [
    (r"\brm\s+(-\S+\s+)*(-[a-zA-Z]*[rR]|--recursive)", "rm -r is blocked here. Delete the files you mean by name."),
    (r"\bgit\s+push\b.*(--force\b|\s-f\b)", "Force pushes are blocked. Push normally or ask me."),
    (r"(^|[\s/])\.env\b", "Commands that touch .env files are blocked: they hold secrets."),
]

event = json.load(sys.stdin)
command = str(event.get("tool_input", {}).get("command", ""))

for pattern, reason in BLOCKED:
    if re.search(pattern, command):
        print(reason, file=sys.stderr)
        sys.exit(2)

sys.exit(0)

And .codex/hooks.json next to it:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "^Bash$",
        "hooks": [
          {
            "type": "command",
            "command": "/usr/bin/python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/check_bash.py\"",
            "statusMessage": "Checking command",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

The git rev-parse --show-toplevel part finds the script from the repository's root, so it works when Codex is started in a subfolder; a plain .codex/hooks/... path would not.

Tested with the JSON Codex sends, the script blocked rm -rf build, rm -f -r x, rm --recursive x, git push -f, git push --force-with-lease, cat .env and cat config/.env.local, and let rm notes.txt, git push origin main, grep -r TODO src and pip install python-dotenv through. A blocked command does not run, and Codex reports your reason.

Why does my new hook not run?

Because Codex has not been told to trust it. Codex keeps a fingerprint of each hook it trusts, and skips any hook that is new or changed since; at startup it warns you to open /hooks. There you can review it, trust it, or turn it off. Managed hooks set by an administrator are trusted by policy.

The other usual causes:

SymptomCauseFix
Hook in the project never runsThe project is not trustedTrust the project; your own ~/.codex hooks still load
Works in the root, not in a subfolderA relative path to the scriptResolve it from git rev-parse --show-toplevel
Hooks do nothing at allTurned off with [features] hooks = falseRemove that line; hooks are on by default
A rule slips through, such as rm -r written another wayA pattern list never catches every spellingTreat it as a guardrail, as OpenAI's docs say; the sandbox is the wall

Which events can a Codex hook run at?

EventWhenMatcher filters
SessionStartA session starts, resumes, is cleared or compactedstartup, resume, clear, compact, fork
UserPromptSubmitYou send a promptNot supported
PreToolUseBefore a tool runs; can block or rewrite itThe tool name (Bash, apply_patch or Edit/Write, an MCP tool)
PermissionRequestCodex is about to ask for approvalThe tool name
PostToolUseAfter a tool ran; cannot undo itThe tool name
PreCompact, PostCompactAround a compactionmanual, auto
SubagentStart, SubagentStopA subagent starts or stopsThe agent type
StopCodex is about to stop; can send it round againNot supported
InterruptYou interrupt a turnNot supported
SessionEndThe session endsother

A hook times out after 600 seconds unless you set timeout; SessionEnd and Interrupt get 1 second by default and 3 at most. Hosted tools such as web search do not go through hooks.

Can a hook run without slowing Codex down?

Yes: add "async": true to a command hook and it runs in the background while Codex carries on. That suits logging, notifications and anything else that does not need to answer. A background hook cannot block, approve or rewrite anything, so a guard like the one above must stay synchronous. Codex runs up to eight background hooks at a time per session and cancels unfinished ones when the session ends.

Where can you check this?

Can you write a hook without editing JSON by hand?

Config Studio for Codex has hooks as one of its surfaces: pick the event from the list above, the matcher, the command or an MCP tool, the timeout, the status line and the background switch, each explained, and it writes the entry into your own or the project's hooks.json. It reminds you that the hook runs only once you trust it in /hooks, and nothing is written to your computer until you run its install script yourself.

The tool

Do it in your browser.

Codex

Config Studio for Codex

Every file Codex reads, explained: config.toml, sandbox and approvals, AGENTS.md, rules, hooks, skills, MCP servers and the status line, with an editor that writes them for you.

Keep reading

More from the blog.

Every post