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.
| When | Events |
|---|---|
| During a turn | PreToolUse, PermissionRequest, PostToolUse, UserPromptSubmit, Stop |
| Around compaction | PreCompact, PostCompact |
| Around subagents | SubagentStart, SubagentStop |
| Session boundaries | SessionStart, 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
{
"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
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.
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.
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.
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
{
"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.
- 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.
You understood something today that you didn't yesterday.