Rules and Lifecycle Hooks: Adding Checkpoints and Triggers to Codex
📚 Series Navigation: The previous article [23 Plugins] taught you how to install packaged capabilities into Codex. This article covers two deeper, user-controlled concepts—Rules (Rules) govern "which commands can run outside the sandbox," and Lifecycle Hooks (Hooks) govern "running a script automatically at a fixed checkpoint in the workflow." One is a gate, and the other is a trigger. Once configured, many tasks you used to monitor and type manually will no longer require your attention. The next article [25 Parallel Isolation with Worktrees] covers how to let multiple Codex agents work separately without interference.
First, let's reconstruct a real conversation from last month. A colleague and I were troubleshooting a flaky CI build:
Me: "This week, after letting Codex modify the code, I manually ran
ruff formatat least a dozen times." Colleague: "Didn't you write 'format after editing' inAGENTS.md?" Me: "I did. But it forgets to do it about one out of three times—that's a request, not a guarantee. One missed format, and the CI check rejects my build, forcing a rerun." Colleague: "Then add a lifecycle hook. When the event triggers, the script is guaranteed to run, regardless of whether the model remembers it."
That one sentence opened my eyes. I set up a PostToolUse hook, and since then, whenever Codex modifies a file, formatting runs automatically. I have not typed it manually once, and my builds have never been rejected by CI. This article explains rules and hooks, from concepts to setup and debugging.
By reading this article, you will get:
- An explanation of what rules and hooks govern, and why they deliver a "guarantee" that
AGENTS.mdcannot provide - How to write Rules (
.rulesfiles +prefix_rule) to "always allow" or "forbidden" commands, and how to verify them usingcodex execpolicy check - Where to place hooks, and which lifecycle "events" (
PreToolUse/PostToolUse/Stop/SessionStartetc.) can be hooked - The trust mechanism unique to Codex hooks—why new hooks do not run by default and require your review and approval in
/hooks - How hooks communicate with Codex (JSON via stdin, exit codes, JSON via stdout), and counter-intuitive differences from Claude Code
- Two real-world examples you can copy: auto-formatting after edits, and blocking dangerous commands; plus how to debug when hooks fail to trigger
⚠️ Specific commands, configuration options, default values, and paths mentioned below are subject to the official Codex documentation (Hooks / Rules). Rules (Rules) are officially marked as "experimental and subject to change"; configuration names and behaviors may adjust with versions, subject to your local installation.
01 Distinguish First: Rules Govern Gates, Hooks Govern Triggers
Let's clarify the division of labor first, as these terms can easily be confused by beginners.
- Rules (Rules) decide "whether this command is allowed to run outside the sandbox." It acts as a gate, monitoring the commands Codex wants to run and matching them against your rules to determine "allow / prompt / forbidden."
- Lifecycle Hooks (Hooks) handle "running a script automatically when the workflow hits a certain checkpoint." It acts as a trigger linked to specific lifecycle events, firing automatically whenever the event occurs, regardless of what Codex wants.
Analogy: A gated community's keycard reader vs. motion-activated hallway lights. The keycard gate holds a database of "who is allowed, who requires registration, and who is blocked"—this is the rule: every time someone swipes, it checks the database. The hallway light that turns on when someone walks by is a hook: it doesn't care who you are or where you are going; as long as the "motion detected" event occurs, the light turns on. One handles permissions, while the other handles automatic actions—do not mix them up.
How do they differ from the AGENTS.md file (project instructions, covered in Article 11)? The difference is fundamental:
What you write in
AGENTS.mdis a request; what you configure in rules or hooks is a guarantee.
Simply put:
- Writing "remember to format after editing" or "do not touch production databases" in
AGENTS.mdis a request you make to Codex. It will likely comply, but it evaluates it at each step, meaning it might miss or forget it. - Formatting configured as a hook, or blocks configured as rules, are guarantees. As long as the triggering condition is met, the action will execute (or block), regardless of the model's memory.
This is the essence of the conversation at the beginning: formatting written in AGENTS.md is a request, missed one-third of the time; configured as a hook, it is a guarantee, never missed.
Real-world scenarios where you should use guarantees:
- "I run
gh pr viewall the time; stop prompting me for approval every time."—use rules to configure an "always allow" path. - "Commands like
rm -rforgit push --forcemust be blocked under all circumstances."—use rules or hooks to block them deterministically, without relying on prompts. - "Format/lint after editing files."—use hooks, saving you from typing it manually or relying on model self-discipline.
💡 Summary in one sentence: Rules act as gates deciding whether a command is allowed to run, while hooks act as triggers executing scripts at specific lifecycle checkpoints; both upgrade
AGENTS.mdrequests into inevitable guarantees.
02 Rules (Rules): Setting Boundaries for Individual Commands
⚠️ Experimental, subject to change. Rules are experimental features, and their fields and behaviors follow the official docs.
Let's address the issues rules solve. As covered in Article 15, sandboxing is a broad boundary—allowing free movements inside the workspace and prompting for anything outside. But sometimes you need command-level control: "allow gh pr view even if it goes outside the workspace, and stop prompting me; block grep completely, forcing me to use rg." In these cases, adjusting the entire sandbox is too broad, and writing a rule is the correct approach.
Analogy: Handing a security guard an "allow / block" list. Sandboxing is the guard's default instruction ("prompt for registration for strangers"), while rules are two lists you hand him: one saying "allow these known visitors without prompting," and another saying "turn these away immediately." Following the list saves you from answering prompts and is safer than removing the gate entirely.
Practical use cases for rules:
- Whitelisting high-frequency read-only commands like
ghandmake testfor the team, saving dozens of approval clicks daily. - Blocking commands like
grepandfindto force the team to use modern alternatives likergandfd. - Enforcing company policies by declaring commands like
rm -rf /or modifications to~/.sshasforbiddenat the root.
What Rules Look Like and Where They Go
Rules are written in .rules files placed under the rules/ directory of a configuration scope, with ~/.codex/rules/default.rules being the most common for user-level configs. The syntax looks like Python, but is actually Starlark (a configuration language designed to execute safely without accessing your file system).
A simple rule example—"prompt me before running gh pr view outside the sandbox":
# Prompt me before executing commands matching `gh pr view`.
prefix_rule(
# The command prefix to match (specified argument by argument).
pattern = ["gh", "pr", "view"],
# The decision when matched: allow (run without prompt) / prompt (confirm) / forbidden (block).
decision = "prompt",
# Optional: justification for why this rule exists, which may display in approval prompts.
justification = "Allow viewing PRs, but require my approval",
# Optional "inline unit tests": commands that should and should not match, validated by Codex at load.
match = [
"gh pr view 7888",
"gh pr view --repo openai/codex",
],
not_match = [
# Does not match: pattern must be an exact prefix, and this moves "view" after the repo flag.
"gh pr --repo openai/codex view 7888",
],
)The fields inside prefix_rule are detailed below:
| Field | Role | Note |
|---|---|---|
pattern (Required) | Command prefix to match, list of arguments | Elements can be string literals like "pr", or alternatives like ["view", "list"] |
decision (Defaults to allow) | The action when matched | allow / prompt / forbidden, strictest wins |
justification (Optional) | Reason for the rule | May display in approval/rejection dialogs |
match / not_match (Optional) | Examples to validate the rule | Evaluated by Codex at load to catch syntax errors early |
decision has three options, governed by a priority rule—strictest wins (forbidden > prompt > allow):
| decision | Effect |
|---|---|
allow | Runs outside sandbox without prompting |
prompt | Prompts for confirmation on matching commands |
forbidden | Blocks command from running without prompting |
Remember to restart Codex after modifying .rules files to apply changes. You will also notice that when you select "always allow" in a TUI approval dialog, Codex writes that rule to ~/.codex/rules/default.rules automatically—meaning the file often grows on its own as you use the tool.
A Critical Safety Design: Compound Commands Are Split for Evaluation
This is highly important, as touched upon in Article 15. If Codex encounters compound commands like git add . && rm -rf /, it uses tree-sitter to split them into individual commands for evaluation, applying the strictest decision:
["bash", "-lc", "git add . && rm -rf /"]is split into two commands for evaluation:
["git", "add", "."]
["rm", "-rf", "/"]This is the key: even if you allowed git add, the rm -rf / command is blocked separately, preventing dangerous commands from slipping through behind allowed ones.
But be aware: only clean linear command chains are split. If a script uses redirects (>), variable substitutions ($(...)), environment assignments (FOO=bar), or wildcards (*), Codex does not split it, treating the entire block as a single bash -lc "<script>" call. Do not rely on rules to dissect arbitrary shell scripts—it dissects safely where possible and errs on the side of caution otherwise.
Verify Rules with execpolicy check
Do not guess. Test modified rules in the terminal using codex execpolicy check:
codex execpolicy check --pretty \
--rules ~/.codex/rules/default.rules \
-- gh pr view 7888 --json title,body,commentsIt outputs a JSON block detailing the strictest decision and which rules it matched (including the justification). I follow a habit when writing forbidden rules: run execpolicy check to verify it blocks what it should without blocking safe commands, then restart Codex. I once skipped this and wrote a loose forbidden rule that ended up blocking common git log commands, frustrating my team—execpolicy check would have caught it.
💡 Summary in one sentence: Rules use
prefix_rulein.rulesfiles to configure command prefix boundaries (allow/prompt/forbidden, strictest wins); compound commands are split safely to prevent bypasses; verify settings withcodex execpolicy checkand restart to apply.
03 Lifecycle Hooks: Available Checkpoints
Having covered rules, let's look at hooks. Hooks trigger at specific checkpoints in the Codex workflow—officially called events. To use hooks, identify where in the workflow you want actions to occur.
Recall the "agent loop" from Article 02—Codex runs in a cycle: "think → act → observe." Hook events are scattered along this loop. The diagram below shows where common events align:

This diagram outlines the loop: SessionStart occurs at session launch, your prompts trigger UserPromptSubmit, and the workflow enters the tool loop—triggering PreToolUse before running a tool and PostToolUse after, with Stop firing at the end of the turn. Attach your hook to the event matching the step where you want it to trigger.
Codex supports several events (including compression events PreCompact/PostCompact, subagent lifecycles SubagentStart/SubagentStop, and approvals PermissionRequest), but beginners can cover 90% of use cases with these four:
| Event | When It Triggers | Typical Use Case |
|---|---|---|
PreToolUse | Before a tool executes | Blocking or rewriting commands (can abort operations) |
PostToolUse | After a tool runs | Auto-formatting or running lint on modified files |
Stop | At the end of a Codex turn | Asking the agent to run another turn (e.g., to fix failing tests) |
SessionStart | When a session starts or resumes | Injecting repository context (like recent commits) into the prompt |
A fifth useful event is PermissionRequest (triggered before prompting for approvals), allowing you to automate approval or denial—often combined with rules from Section 02 to whitelist command classes.
A trick to remember these: focus on the Pre and Post prefixes—Pre is "before," so only it can block an action before it occurs; Post is "after," firing after the tool has run, meaning it cannot undo side effects but can perform cleanup (formatting, logging, or passing results back to the model for review).

This diagram shows the four events along a session lifecycle: Session Start / Pre-Tool / Post-Tool / Session End stack along the main axis, with hook scripts attached to each checkpoint. When the workflow hits a checkpoint, the attached script triggers automatically, regardless of the model. This is the nature of hook triggers.
Here is a common pitfall: unlike Claude Code, returning decision: "block" in Stop events does not abort the turn; instead, it tells Codex "do not stop, run another turn," passing your reason as the next user prompt. Similarly, returning decision: "block" in PostToolUse does not undo the command, but passes feedback back to the model. block in Stop/PostToolUse acts as "continue / retry," not "abort" in Codex. Do not apply Claude Code assumptions here.
💡 Summary in one sentence: Hooks attach to lifecycle events in Codex; beginners should focus on
PreToolUse(before, can block),PostToolUse(after, clean up),Stop(end of turn, can retry), andSessionStart(launch); noteblockinStop/PostToolUseretry/continue rather than aborting.
04 Where Hooks Go, and Restricting Scope with matchers
Let's look at how to write hooks and where to place them.
Hook Locations
Codex scans for hooks in two forms: separate hooks.json files, or inline [hooks] blocks in config.toml. The four common locations are:
| Config File | Scope | Shared with Team? |
|---|---|---|
~/.codex/hooks.json | All your projects | No, local to your machine |
~/.codex/config.toml | All your projects | No |
<repo>/.codex/hooks.json | Current project only | Yes, committed to Git |
<repo>/.codex/config.toml | Current project only | Yes |
The logic matches configuration files: rules for the whole team (like "auto-format on save") go into the project .codex/ committed to Git; personal preferences go into ~/.codex/. Two details:
- Hooks from all scopes are loaded and run together—upper configuration levels do not override hooks in lower levels; they stack.
- Do not write both
hooks.jsonand inline[hooks]in the same config level; Codex will merge them but raise a warning at startup. Use one format per level. - Project-level hooks only load if the
.codex/directory is "trusted" (covered in the next section); in untrusted projects, only user-level and system-level hooks load.
Hook Configuration Format
Here is a minimal example—"run a script after Codex executes a Bash command", written in the project .codex/hooks.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/usr/bin/python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/post_tool_use.py\"",
"timeout": 30,
"statusMessage": "Reviewing Bash output"
}
]
}
]
}
}Breaking down the three nesting levels:
"PostToolUse"—The event to hook (here: after a tool runs)."matcher": "Bash"—Restricting scope to a specific tool (here: only afterBash).- The inner
hooksarray—The action to run:"type": "command"specifies executing a shell command, and"command"defines the command.
Here are several Codex-specific defaults you should watch out for:
timeoutis in "seconds," not milliseconds; it defaults to 600 seconds if omitted.- Currently, only
type: "command"handlers execute;promptandagenttypes parse but are skipped;async: trueis also skipped as async hooks are not yet supported. - Multiple matching hooks on the same event run concurrently—a blocking hook will not prevent another hook on the same event from starting. This is another difference from Claude Code: do not assume a
PreToolUseblocker runs to completion before other hooks start. - The working directory of the command is the session's active
cwd. For repository hooks, avoid relative paths like.codex/hooks/...because Codex might launch in a subdirectory—use absolute paths via$(git rev-parse --show-toplevel)instead (as in the example). - For Windows overrides, add the
command_windows(orcommandWindowsin TOML) field.
⚠️ Highlighting differences from Claude Code to avoid issues: Codex hook timeouts are in seconds (Claude Code uses milliseconds), it does not have the
$CLAUDE_PROJECT_DIRvariable (use Git root instead), and lacks adisableAllHooksswitch (to disable hooks, set[features] hooks = false). Do not copy notes blindly.
Writing as inline TOML in config.toml is equivalent:
[[hooks.PostToolUse]]
matcher = "^Bash$"
[[hooks.PostToolUse.hooks]]
type = "command"
command = '/usr/bin/python3 "$(git rev-parse --show-toplevel)/.codex/hooks/post_tool_use.py"'
timeout = 30
statusMessage = "Reviewing Bash output"matcher: Restricting Hook Executions
matcher is the most important field to understand in hook configurations. Simply put: without it, the hook fires on "every" occurrence of the event; with it, you restrict its scope.
Analogy: Motion-activated lights—you must target the sensor. If the sensor covers the whole floor, it turns on when neighbors walk by, wasting power. You must restrict the sensor area to "just in front of your door." matcher restricts this sensor area.
Unlike Claude Code, the Codex matcher is a regular expression (regex) string. For tool events (PreToolUse/PostToolUse), it matches the tool name. See the table for patterns:
| Matcher Pattern | Meaning | Example |
|---|---|---|
"Bash" | Matches the Bash tool | Triggers only after running Bash commands |
"^apply_patch$" | Exact regex match | Triggers only after editing files |
"Edit|Write" | Aliases for file editing (| is regex OR) | Triggers after editing or writing files |
"mcp__filesystem__.*" | Regex matching MCP tools | Triggers on tools from this MCP server |
"*" / "" / Omitted | Matches all | Fires on every occurrence of the event |
Key pitfalls:
- The file-editing tool is named
apply_patch, notEdit/Write. Codex edits files usingapply_patchunder the hood—you can useEdit,Write, orapply_patchas matching aliases in thematcher, but thetool_namepassed in stdin is always"apply_patch". Parseapply_patchwhen checking tool names in your script. - Not all events support
matcher.UserPromptSubmitandStopignorematcherbecause they do not have tool names to filter, firing on every occurrence.SubagentStopusesmatcherto matchagent_type.SessionStartusesmatcherto match the startup mode (startup/resume/clear/compact). PreToolUsecannot block all commands. The docs state: it is a "guardrail, not an airtight enforcement boundary"—it blocks simple shell calls,apply_patch, and MCP tools, but cannot intercept complex shell calls (viaunified_exec) orWebSearch. Do not rely onPreToolUseas your sole security boundary; treat it as an extra gate.
💡 Summary in one sentence: Hooks are written in
hooks.jsonorconfig.tomlunder~/.codex/or project.codex/paths; configurations have three levels (event, matcher, action); notetimeoutis in seconds (default 600), the editing tool isapply_patch,matcheruses regex, and use Git roots for paths.
05 The Trust Mechanism: Why New Hooks Do Not Run by Default
This is the biggest difference between Codex and Claude Code hooks, and the most common reason why new hooks fail to trigger: they do not run by default because Codex does not trust them.
Let's address why this gate exists. Hooks run as shell scripts with your full user permissions, meaning they can delete files or connect to networks. If you clone a repository with malicious hooks and they run automatically, it runs arbitrary code on your machine. Thus, Codex introduces a "review and approve" step.
Analogy: Approving permissions for a new phone App. When you install an App, it cannot access the camera or send notifications until you click "allow"—without your permission, it cannot access them. Codex hook trust works this way: non-managed command hooks must be explicitly trusted by you before they run; otherwise, they are skipped.
The official rules:
- Codex records trust based on the SHA-256 hash of the hook definition. New or modified hooks are marked as "pending review" and skipped until trusted. Modifying a single character changes the hash, requiring re-authorization.
- Use
/hooksto inspect hook sources, approve new/modified hooks, or disable non-managed hooks. - If pending hooks exist at startup, Codex prints a warning telling you to run
/hooks.
Therefore, the first step after configuring a hook is: save → start Codex (see the warning about pending hooks) → run /hooks → find your hook, inspect it, and trust it. Only then will it execute. I once forgot this and spent hours debugging a formatting hook, only to realize it was skipped because it wasn't trusted.
/hooksExpected: Opens the hook browser, listing hooks from all sources, marking them as "review" (pending), trusted, or "managed." Managed hooks (from MDM, enterprise configs, etc.) follow admin policies, and cannot be disabled in this screen.
A bypass option: in CI environments where hook files are reviewed externally, start Codex with --dangerously-bypass-hook-trust to skip trust checks. The name dangerously warns you—do not run this on your daily machine, as it disables a safety gate.
⚠️ Connecting to security (Article 16): always inspect command lines in
/hooksbefore approving them, and do not trust hooks copied blindly from unknown plugins or repositories. The trust check is your final line of defense; clicking approve is your decision.
💡 Summary in one sentence: Codex hooks have a trust mechanism—non-managed hooks do not run until approved via
/hooks(recorded by hash, requiring re-approval on edits); this is the primary difference from Claude Code and the common cause of hooks failing to trigger.
06 How Hooks Communicate with Codex: stdin, Exit Codes, and stdout
This section explains how scripts inspect commands and block executions—all handled via standard input/output.
The mechanism is simple: Codex passes event data as JSON to your script's stdin → the script executes → the script returns instructions via exit codes and stdout.
Input: JSON via stdin
When an event triggers, Codex passes event details as a JSON block to your script's standard input (stdin). All hooks receive these common fields:
| Field | Meaning |
|---|---|
session_id | Active session ID |
cwd | Active working directory |
hook_event_name | Event name |
transcript_path | Session transcript file path (can be null) |
model | Active model slug (Codex extension, useful for model-specific logic) |
permission_mode | Active permissions mode (omitted on some special events) |
Tool events also receive tool_name (e.g., "Bash", "apply_patch") and tool_input (tool parameters; Bash and apply_patch use tool_input.command for commands). For example, before running a Bash command, the PreToolUse hook receives a JSON block like this:
{
"session_id": "abc123",
"cwd": "/Users/sarah/myproject",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf /tmp/x"
}
}This contains what Codex wants to do, using which tool, and with what arguments. Your script can extract tool_input.command from this JSON to decide whether to block it.
Output: Instructions via Exit Codes
The script signals its decision to Codex using exit codes:
| Exit Code | Meaning | Effect |
|---|---|---|
0 | Proceed | Command execution continues; empty outputs count as success |
2 | Special signal | Action depends on the event (see below) |
| Other | Undefined | Evaluated as execution failure |
Focus on exit 2, which behaves differently depending on the event:
- On
PreToolUse/UserPromptSubmit:exit 2+ printing a reason to stderr = blocks the tool call or prompt. - On
PostToolUse/Stop/SubagentStop:exit 2+ stderr does not block (the tool has already executed); instead, it passes feedback back—PostToolUseuses it to replace tool output for review, whileStopuses it to request another turn (detailed in Section 03).
Thus, only Pre events can block executions; exit 2 in Post/Stop retries or redirects, and cannot undo what has run.
Advanced: Finer Controls via stdout JSON
Exit codes only provide binary signals. To specify reasons or inject context, return exit 0 and print a JSON block to stdout. Two common formats:
1. Blocking with a reason in PreToolUse—return permissionDecision: "deny":
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "This command touches the production database, blocked."
}
}Codex also accepts the legacy format
{"decision": "block", "reason": "..."}, which behaves identically. The new format is preferred.
2. Injecting context in SessionStart—return additionalContext to feed developer instructions into the prompt:
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "Read coding guidelines before editing."
}
}Key output requirements to keep in mind:
- Plain text printed to stdout in
PreToolUseis ignored—useadditionalContextto inject context orpermissionDecision/exit 2to block. SessionStartandUserPromptSubmitaccept plain text in stdout to append to the prompt (these are exceptions); butStopandSubagentStoprequire JSON, treating plain text as invalid.PreToolUsedoes not supportcontinueorstopReasonfields; if returned, Codex marks the hook as failed and runs the tool anyway—do not usecontinue: falseto block tool calls.
💡 Summary in one sentence: Hooks communicate via stdin JSON, exit codes, and stdout JSON; note
exit 2blocks inPreevents and retries inPost/Stopevents; the editing tool isapply_patch; plain text onPreToolUsestdout is ignored.
07 Two Real-World Examples
Here are two common hook setups you can copy directly, specifying events, matchers, and locations.
Example 1: Auto-formatting Files After Edits (PostToolUse)
This solves the "forgetting to format" issue from the beginning. It is safe and recommended for every project.
Step 1: Write formatting logic in .codex/hooks/format.py (reads JSON from stdin, extracts path, runs formatter). Minimal python skeleton:
#!/usr/bin/env python3
import json, subprocess, sys
data = json.load(sys.stdin)
# apply_patch contains patch data in tool_input.command; in practice,
# running formatting on the workspace is reliable:
subprocess.run(["ruff", "format", "."])Step 2: Register it in the project .codex/hooks.json, attaching it to apply_patch on PostToolUse (matcher can use Edit|Write or apply_patch):
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "/usr/bin/python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/format.py\"",
"timeout": 30,
"statusMessage": "Formatting modified files"
}
]
}
]
}
}Step 3: Start Codex and run /hooks to trust the hook (as covered in Section 05). Codex now runs formatting automatically after editing files. Replace ruff format with prettier --write ., gofmt -w ., or black . as needed.
Example 2: Blocking Dangerous Commands (PreToolUse + Script)
This uses exit 2 to block execution. For complex checks, separating logic into a script is cleaner than writing it inline.
Step 1: Save this check script to .codex/hooks/block-dangerous.py:
#!/usr/bin/env python3
import json, sys
data = json.load(sys.stdin)
command = data.get("tool_input", {}).get("command", "")
if "rm -rf" in command:
print("Blocked: rm -rf detected, command aborted.", file=sys.stderr) # stderr passed back to Codex
sys.exit(2) # exit 2 blocks execution
sys.exit(0) # allow other commands to run normal approval flowsStep 2: Register it in .codex/hooks.json, attaching it to Bash on PreToolUse:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/usr/bin/python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/block-dangerous.py\"",
"statusMessage": "Checking Bash commands"
}
]
}
]
}
}Step 3: Run /hooks to trust it. Codex now blocks any Bash commands containing rm -rf, returning the reason to the model.
⚠️ Rules and hooks can both block commands—
forbiddenrules are lightweight (a single line in a file), while hooks are flexible (executing custom logic on JSON details). For simple prefixes, prefer rules; for complex checks (like "blockrmonly on specific directories"), use hooks. Do not configure the same check in both systems.
Comparing the two examples:
| Dimension | Example 1: Auto-formatter | Example 2: Command Blocker |
|---|---|---|
| Event | PostToolUse (after) | PreToolUse (before) |
| matcher | Edit|Write (= apply_patch) | Bash |
| Mechanism | Runs formatter, does not abort | exit 2 blocks execution |
| Risk | None | Risk of blocking safe commands if regex is loose |
| Can it use rules? | No (rules only govern command permissions) | Yes, prefer rules for simple prefix blocks |
💡 Summary in one sentence: Choose hooks based on risk—formatting (
PostToolUse, safe, recommended for all) vs. blocking (PreToolUse+ script +exit 2); define script paths relative to the Git root, and approve hooks in/hooks; prefer rules for simple command blocks.
08 Hands-on: Write a Hook and Watch It Trigger in 5 Minutes
Practice makes perfect. Below, we configure a safe hook—logging Bash commands to a file after execution. This does not touch your code, and you can verify it directly.
Platform setup: The script uses Python, compatible with macOS, Linux, and Windows (on Windows, replace
python3withpy -3or usecommand_windows). The steps below assume macOS / Linux.
Step 1: Create a test directory, and write the script and configuration
In a temporary directory (not a production project), initialize Git (required for the Git path helpers) and create files:
git initThe script .codex/hooks/log-bash.py:
#!/usr/bin/env python3
import json, sys, os
data = json.load(sys.stdin)
command = data.get("tool_input", {}).get("command", "")
log = os.path.expanduser("~/codex-bash-log.txt")
with open(log, "a") as f:
f.write(command + "\n")The configuration .codex/hooks.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/usr/bin/python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/log-bash.py\"",
"statusMessage": "Logging Bash command"
}
]
}
]
}
}Step 2: Start Codex and approve the hook
codexIn the session, run /hooks:
/hooksExpected: The hook browser lists your new PostToolUse hook, marked as "review" (pending). Select it and click trust (Section 05). The hook status changes to trusted, meaning it is active.
Step 3: Run a Bash command to trigger it
In the chat, ask it to run a safe command:
Check files in the active directory using ls.It calls the Bash tool to run ls. This triggers the PostToolUse hook. (Hooks run silently, so no terminal logs will output).
Step 4: Verify the log file
Open a separate terminal and check the log:
cat ~/codex-bash-log.txtExpected: The file contains the ls command you ran. Seeing the command logged means the hook executed automatically.
Step 5: Cleanup
To clean up, delete .codex/hooks.json (removing it from config disables it) and remove the log file: rm ~/codex-bash-log.txt.
This walks you through the flow: writing config → approving in /hooks → triggering event → checking effects.
💡 Summary in one sentence: Practice with a safe logging hook—save to
.codex/hooks.json, approve in/hooks, run a command to trigger, and verify the log file; this confirms the trust check unique to Codex.
09 Troubleshooting Hooks
If a hook fails to trigger, walk through these checks:
| Issue | Cause / Check |
|---|---|
| Hook does not trigger | ① Did you approve it in /hooks? This is the most common issue (Section 05); ② modifying a hook file changes its hash, requiring re-approval; ③ check if you matched the correct event / matcher |
Hook does not appear in /hooks | ① Syntax error in JSON (no trailing commas or comments allowed); ② check filename / path (hooks.json or inline [hooks] under config.toml); ③ conflicting definitions in the same config level; ④ restart Codex |
| Command not found / script missing | Path error. Commands run in the session's active cwd; use $(git rev-parse --show-toplevel) to build absolute paths, rather than relative paths |
| Failed to block command | ① Check if you hooked PreToolUse (actions cannot be blocked in PostToolUse); ② PreToolUse cannot block complex shells or WebSearch (Section 04); ③ use exit 2 or permissionDecision: "deny"; stdout prints do not block |
| Hook times out | timeout is in seconds (default 600), not milliseconds; adjust for long-running scripts |
Two debugging tools:
1. Pipe mock JSON into your script. Test your script in isolation by feeding it JSON in the terminal:
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/x"}}' | python3 .codex/hooks/block-dangerous.py
echo $? # should output 2 for blocking scripts2. Check rules with execpolicy check. Use it to verify rules without guessing.
To disable all hooks temporarily, set the feature switch in config.toml:
[features]
hooks = false💡 Summary in one sentence: If a hook fails, verify it is approved in
/hooksfirst, then check matchers, events, and paths (using Git roots); verifyPreToolUseandexit 2are used for blocking; useexecpolicy checkto test rules.
10 Summary
We have explored rules and hooks—the gates and triggers you can configure in Codex: rules govern which commands run outside the sandbox, while hooks run scripts at lifecycle checkpoints, turning requests into guarantees.
Let's review the key points:
| Objective | Method / Tool | Key Takeaway |
|---|---|---|
| Allow / block specific commands | Rules prefix_rule | allow/prompt/forbidden, strictest wins; test with execpolicy check |
| Hook checkpoints | Lifecycle events | Pre blocks, Post cleans up, Stop retries, SessionStart preloads |
| Write a hook | hooks.json / config.toml | Event + matcher (regex) + action; timeout in seconds |
| Activate new hooks | /hooks approval | Codex-specific: non-managed hooks require review and trust; re-approvals required on edits |
| Hook communication | stdin / exit codes / stdout | exit 2 blocks in Pre, retries in Post/Stop |
| Debugging | Diagnostic flow | Check trust approvals first, then verify matchers, events, and paths |
You should now be able to: distinguish between rules and hooks and explain how they enforce guarantees rather than requests; write prefix_rule definitions and test them with execpolicy check; attach hooks to PreToolUse/PostToolUse/Stop/SessionStart events; write hooks using matchers and approve them in /hooks; and handle hook communication via standard input/output (exit codes, apply_patch, block retries, and seconds-based timeouts). You can now use hooks to solve recurring tasks like auto-formatting permanently.
💡 Summary in one sentence: Rules act as gates, hooks act as triggers; Codex requires explicit trust approvals, uses regex matchers, seconds-based timeouts, and the
apply_patchalias, which are key differences from other tools.
The next article [25 Parallel Isolation with Worktrees]—currently you run one Codex task at a time. As development speeds up, you will want to use one Codex to fix a bug and another to write a new feature concurrently without conflicts. Running multiple sessions in the same directory risks overwriting changes. The next article covers Git worktrees—providing isolated workspaces for tasks to let multiple Codex agents run in parallel safely. Think about it: if you want to work on two tasks at once, can you do it without waiting for one to finish?