Course · Codex · Extending Codex
Gate a risky move with a hook
What’s sacred in budgetcli (the course’s running example, a small self-hosted personal-finance API in Python and FastAPI), inventoried honestly: this repo has no production database. The ledger is 18 lines of in-memory Python, app/services/ledger.py, and every balance the API reports flows through it. The raw records live outside the repo - bank statements under ~/budget-data/statements/, extending the constructed data directory approvals & sandbox set up. Two invariants follow. Raw statements are records, not working files: corrections happen in the importer, never in the history. And the money path - app/importers/ plus app/services/ - never changes untested.
A rule cannot hold either invariant. “Never rewrite a raw statement” would sit in AGENTS.md, in context, on every session - exactly where the money rule sat while the float parse survived chapter after chapter. Nothing argued the bug in, and nothing stopped it. Count the routes to the forbidden rewrite: a one-off python -c one-liner, an in-place sed, a script written earlier and run now - all shell - plus a fourth, the agent’s own edit tools writing the file directly. A rule has to be remembered, correctly, against every one of them, every session, forever.
The fence won’t hold it either, and this track’s own history is the proof. Under plain workspace-write, ~/budget-data sits outside the writable roots and the sandbox itself says no. But budget-build deliberately widened the fence - writable_roots = ["~/budget-data"] - because the import work needed it. Inside that profile, nothing stands between the model and the raw records. Under enough pressure a rule and a permission both fail open. A hook is the one mechanism here built to fail closed.
By the end of this lesson, the shell routes to that rewrite die at one wall, the script route is exposed as genuinely invisible to it, and a second matcher covers the edit-tool route - while the money path gets its own gate: the suite runs after every edit-tool edit to app/importers/ or app/services/, including a direct edit to ledger.py mid-session. (A gate catches only what its matcher names; each one below is scoped to its routes, and that scoping is part of the design.)
What a hook actually is
Section titled “What a hook actually is”A hook is a shell command Codex runs at a matching lifecycle point. It is deterministic and independent of the model’s reasoning - but it is not a complete security boundary. Hooks can require trust review before they run, and some tool paths bypass the normal hook route; the docs’ own framing is to treat tool hooks as a useful guardrail, not complete enforcement. Use hooks as defense-in-depth alongside sandboxing, CI, and file permissions. The full event list and payload detail are on the Codex hooks docs.
The way a hook talks back is the second half of the concept. On PreToolUse, a deny stops the tool call before it runs: the documented form is a JSON output with permissionDecision: "deny" and a reason, with decision: "block" as a legacy alias and exit code 2 plus stderr doing the same job. Hook output can also add context or feedback the model reads - more on that in the last lesson.
The right moment to fire
Section titled “The right moment to fire”Hooks attach to lifecycle events, and Codex documents a family of them. SessionStart when a session opens; UserPromptSubmit when you send a message; PreToolUse, PermissionRequest, and PostToolUse around tool calls; PreCompact/PostCompact around compaction; SubagentStart/SubagentStop around spawned agents; Interrupt when you interrupt a turn. Stop fires when a turn completes - it is a turn-level event that can block or ask the model to continue, not a session-end signal. Session teardown is SessionEnd, which runs when the main thread ends and never runs for subagents.
Picking the event is most of the design. For the statements wall we want PreToolUse: it fires before a tool call runs, the only moment you can stop a write from ever happening. A hook that fires after the fact can log a violation. Only one that fires before it can prevent one.
Wire it up
Section titled “Wire it up”Hooks are configured in a hooks.json file - at ~/.codex/ for every session, or in the repo’s .codex/ - or inline as [[hooks.EventName]] arrays in config.toml. You name the event, a matcher that narrows which tool calls trigger it, and the command to run.
.codex/hooks.json:
{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "./.codex/hooks/protect-statements.sh" } ] }, { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "./.codex/hooks/protect-statements-edit.sh" } ] } ] }}The matcher is a regex on the tool name. Bash narrows the first entry to shell calls - sed, a one-off script, anything that could rewrite a file - so the hook isn’t woken for every file read; it is deliberately not scoped to one command shape, firing on any shell call and letting the script itself decide. That is what lets one script catch every shell route you counted above. The Edit|Write entry covers the fourth route - the agent’s own edit tools touching a statement file - with a sibling script that checks the payload’s file path instead of a command string (the payload field is version-sensitive; verify against the hooks docs). MCP tools match by their full mcp__server__tool names, if you ever need those. Every route gets its wall by naming the tools that can travel it.
Codex hands the hook a JSON payload: session_id, transcript_path, cwd, hook_event_name, model, permission_mode, with turn_id on turn-scoped events and tool_name/tool_input describing the call (tool_response too, after the fact). The script greps the command out of that payload:
#!/bin/bashinput=$(cat)# Fail shut: if jq is missing, exit 2 rather than matching nothing and allowing.command -v jq >/dev/null 2>&1 || { echo "Blocked: jq is required to inspect this command." >&2; exit 2; }# Field path below is version-sensitive; verify against the hooks docs.command=$(echo "$input" | jq -r '.tool_input.command')
# Refuse shell commands that touch raw statement files at all.# Reading statements is what Codex's file tools are for; shell plus# a raw CSV means rewriting, moving, or "fixing" history.if echo "$command" | grep -Eq 'budget-data/[^ ]*\.csv'; then echo "Blocked: raw statements are records, not working files. Fix the importer, not the history." >&2 exit 2fiexit 0Everything in that script is plain shell - nothing in it is AI, which is the whole point. The exit 2 and the stderr message are the deny contract from the top of the lesson, doing their job.
Trace it by hand before you trust it
Section titled “Trace it by hand before you trust it”Here’s the ground truth, worked out the obvious way, before you watch it run. Take the three routes from the opening and check each command against the pattern the script greps for - budget-data/ followed by a .csv filename - exactly the way the script does:
| Attempt | Command (shape) | Contains a match? | Verdict |
|---|---|---|---|
| A one-off rewrite | python -c "fix the dates in ~/budget-data/statements/card.csv in place" |
Yes - path and file | Blocked |
| A shell edit | sed -i '' 's/2024/24/g' ~/budget-data/statements/card.csv |
Yes - same | Blocked |
| Running a script | python scripts/fixup_dates.py |
No - the rewrite lives inside the file, not the command | Not blocked by this hook |
Sit with that third row before you read on. It isn’t a bug in the script - it is the shape of what a hook can and can’t see. The write you meant to stop sits in a file on disk; the command line names only the interpreter.
Watch the wall hold
Section titled “Watch the wall hold”Codex is asked to tidy up March’s statement and reaches for a rewrite - the first row from the table above. An illustrative reconstruction; your run supplies the wording:
> normalize the dates in March's card statement
• Ran python -c "rewrite ~/budget-data/statements/card.csv with ISO dates in place"
⊘ Blocked by PreToolUse hook (protect-statements.sh): Blocked: raw statements are records, not working files. Fix the importer, not the history.
Understood - I won't modify the raw file. The importer already parses these statements, so I'll add the normalization there and re-import from the untouched copy.Same result had it reached for the sed from row two - the matcher doesn’t care which route produced the command, only what the command says. Two routes, one wall, because the wall checks the words, not the door they came through. Call it the gate you can’t talk down: a rule can be reasoned past by a plausible-sounding excuse, and a permission knows tool names, not content. This script reads the string and answers yes or no, the same way every time.
Watch what the rejection did, too. The deny reason is feedback the model reads: it didn’t get stuck, it understood why, and rerouted to the correct fix - change the code that reads history, not the history. The gate didn’t just stop a bad action; it steered the next one.
What this hook cannot do
Section titled “What this hook cannot do”Row three matters more than it looks. The hook sees only the text of the shell command Codex is about to run - not the contents of files that command references, not the outcome, not the intent behind it. python scripts/fixup_dates.py names no path at all, even when the script it runs contains the exact rewrite you’re trying to stop. A hook checks the command, not the plan.
It also over-blocks, on purpose: a shell command that merely reads a statement gets refused too. That’s a deliberate trade - reading is what Codex’s file tools are for, and a shell command touching raw records is presumptively rewriting them. String gates over-match and under-match at once; write them knowing both directions.
Neither fact is a reason to skip the gate. Two of three routes are covered by one small script, free, forever - and the third is why scripts get reviewed before they run, and why the raw records sit outside git. The hook is defense in depth: under plain workspace-write the sandbox already blocks these writes, and inside the widened budget-build fence this script is the only thing standing guard. Pair the layers; don’t swap them.
The other shape: run the money tests after the money math changes
Section titled “The other shape: run the money tests after the money math changes”PreToolUse blocks. Its sibling PostToolUse fires after a tool runs - it cannot prevent the action or undo its effects. But its decision: "block" (or exit 2) is still load-bearing: it replaces the tool result with the hook’s feedback, and the model continues from that message. Perfect for “always do this when the money math changes”:
{ "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "./.codex/hooks/money-tests.sh" } ] } ] }}#!/bin/bash# .codex/hooks/money-tests.sh - runs after every file edit Codex makes.input=$(cat)# Money path only: an edit's payload names the file it touched.if ! echo "$input" | grep -q 'app/importers/\|app/services/'; then exit 0fiif ! pytest -q >/tmp/money-tests.out 2>&1; then echo "Money tests failed after this edit - fix before continuing:" >&2 tail -20 /tmp/money-tests.out >&2 exit 2fiThe suite is the ten green tests this course has assembled in its labeled continuation - the shipped ledger and import tests, the date regression, the categorizer wiring pins, and the 0.29 -> 29 Decimal pin from the subagents chapter. That pin is the tripwire: illustrative reconstruction of the failure it exists to catch -
• Edited app/importers/csv.py a "tidy-up" that walks the amount parse back toward a float
⊘ PostToolUse hook (money-tests.sh): money tests failed 0.29 should parse to 29 cents; got 28
The Decimal pin caught that edit. Restoring the parse and re-running.The edit is already on disk when this fires - PostToolUse cannot undo it. But the failing pin becomes the model’s next input, and the fix lands inside the same turn. This is the chapter’s spine made mechanical: the float shipped originally because nothing stopped it, and nothing argued. Now every edit-tool edit to app/importers/ or app/services/ - including a direct edit to ledger.py mid-session, whose balance() the ledger tests pin - meets the suite immediately, whether or not anyone remembered to run it. Be as precise about what this gate does not catch as the statements wall was: the matcher names Edit|Write, so a shell command that rewrites ledger.py (sed -i, a python -c rewrite) fires no hook. Pair the gates with the fence - under budget-audit the sandbox itself refuses those writes - and count the routes for every guarantee you wire.
You’ve now built something categorically different from the MCP server in the last lesson. A server widened what Codex can do, and charged rent for it. A hook overrules Codex, for free, on the one condition it can express - and stays blind to everything it can’t. That difference is sharp enough to name precisely, and knowing when to reach for a hook instead of a rule or an approval setting is the last lesson of this chapter.