OpenAI Codexcodex-cli 0.154 · macOS, Linux, WSL, Windows
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
26 small wins to finish your pathNext lesson

Hooks: blocking a tool call

AGENTS.md is a request and a rule is a pattern. A hook is your program, run at a fixed moment, which can refuse.

Hooks fire on events. There are many, and they group by when they happen.

WhenEvents
During a turnPreToolUse, PermissionRequest, PostToolUse, UserPromptSubmit, Stop
Around compactionPreCompact, PostCompact
Around subagentsSubagentStart, SubagentStop
Session boundariesSessionStart, SessionEnd, Interrupt

Codex looks for them in hooks.json or an inline [hooks] table next to an active config layer. The four places worth knowing are ~/.codex/hooks.json, ~/.codex/config.toml, and the same two under a repository's .codex/.

What the program gets

Every command hook receives one JSON object on standard input. The shared fields are the session id, the transcript path, the working directory, the event name and the model, and turn events add the permission mode.

What it prints back

json
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Destructive command blocked by hook."
  }
}

Printing nothing means the hook has no opinion and the call carries on through the normal permission flow. A hook can refuse; staying quiet is not the same as approving.

A hook, for real

python
import json
import sys

BANNED = ("rm -rf", "git push --force")

call = json.loads(sys.stdin.read())
command = call.get("tool_input", {}).get("command", "")

It reads the whole JSON object off standard input and pulls out the command. Nothing here is specific to Codex except the shape of that object.

python
if any(bad in command for bad in BANNED):
    print(json.dumps({
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": "deny",
            "permissionDecisionReason": f"{command!r} is banned in this project.",
        }
    }))

And the decision. If the command is on the list it prints a refusal with a reason; otherwise it prints nothing and exits.

Run it

The panel beside this drives that file exactly as Codex would, with one harmless command and one that is on the list.

python
import contextlib, io, json, runpy, sys


def ask(command):
    call = {"tool_name": "Bash", "tool_input": {"command": command}}
    sys.stdin = io.StringIO(json.dumps(call))
    said = io.StringIO()
    with contextlib.redirect_stdout(said), contextlib.suppress(SystemExit):
        runpy.run_path("hook.py", run_name="__main__")
    return said.getvalue().strip() or "(nothing, so the call carries on)"

Codex would start the hook as a process and write the call to its standard input. This runs the same file in place with the same input, so it behaves the same way. Press Run.

Example
print("npm test:")
print(ask("npm test"))
print()
print("rm -rf build:")
print(ask("rm -rf build"))

The first produces nothing, and that silence is what lets ordinary work continue. The second comes back with a refusal and a reason, and the reason is what Codex shows the model, so it should say what to do instead.

Wiring it up

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "python3 .codex/hooks/hook.py" }
        ]
      }
    ]
  }
}

The matcher picks which tools it runs for. Shell commands match as Bash, patches match as apply_patch, Edit or Write, and MCP tools match their own names.

What hooks are not

  • Not a complete boundary. The documentation says to treat tool hooks as a useful guardrail, and some tool paths can opt out.
  • Not exclusive. Matching hooks from several files all run, concurrently, so one cannot stop another starting.
  • Not automatically trusted. A non-managed hook has to be reviewed and trusted before it runs.
Where the real boundary is
The enforced boundary is the sandbox from lesson 3. A hook is policy on top of it, and it is the right tool for project rules rather than for security you are relying on.
Try it yourself
  • Add a command of your own to the banned list in the panel and run it again.
  • Change the reason to something unhelpful and imagine what the agent would try next.
PreviousMCP servers

You understood something today that you didn't yesterday.