Codex hooks examples: 10 copy-paste hooks.json recipes
A Codex hook is a command Codex runs at a lifecycle event, with the event as JSON on stdin. Below are ten complete hooks.json recipes for the OpenAI Codex CLI: block, rewrite, log, notify, redact, and call an outside service. Each says what it returns.
Event names, fields and output shapes come from OpenAI’s Codex hooks docs. For events, trust, managed hooks and what changed across releases, see the Codex hooks reference. This page is the cookbook.
The shape every example uses
Codex loads hooks from ~/.codex/hooks.json, <repo>/.codex/hooks.json, or inline [hooks] tables in either config.toml. All matching hooks from all layers run; a repo layer does not replace your user layer. Repo hooks load only when the project’s .codex/ layer is trusted.
The structure is event, then matcher group, then handler list:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "~/.codex/hooks/guard.sh", "timeout": 10, "statusMessage": "Checking command" }
]
}
]
}
}
Facts the recipes rely on:
matcheris a regex on the tool name.Bashcovers shell andexec_command.apply_patchcovers file edits (aliasesEdit,Write). MCP tools look likemcp__server__tool.- PreToolUse input carries
tool_name,tool_use_id,tool_input, plussession_id,cwd,model,turn_id. For Bash andapply_patchthe text is intool_input.command. - PostToolUse adds
tool_response. - The default timeout is 600 seconds. Set a short one on anything in the tool path.
async: trueruns the hook in the background. It cannot block, approve or rewrite.
The scripts use bash and jq. Save each one under ~/.codex/hooks/ and chmod +x it. To combine recipes, merge their entries into one hooks.json.
1. Block rm -rf and git push –force
Purpose: stop two commands you never want an agent to run. Returns: block.
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [ { "type": "command", "command": "~/.codex/hooks/deny-destructive.sh", "timeout": 10 } ] }
]
}
}
#!/usr/bin/env bash
# ~/.codex/hooks/deny-destructive.sh
cmd=$(jq -r '.tool_input.command // empty')
if echo "$cmd" | grep -qE 'rm[[:space:]]+-[a-zA-Z]*r[a-zA-Z]*f|rm[[:space:]]+-[a-zA-Z]*f[a-zA-Z]*r|git[[:space:]]+push.*(--force([[:space:]]|$)|[[:space:]]-f([[:space:]]|$))'; then
jq -n --arg r "Blocked: recursive force delete or force push. Ask the user to run it." \
'{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $r}}'
fi
exit 0
No output means the call proceeds. The shorter form also works: echo "reason" >&2; exit 2. A pattern list catches the obvious cases only. bash -c "$(echo cm0gLXJm | base64 -d)" walks past it.
2. Block edits to .env and key files
Purpose: keep apply_patch away from secrets. Returns: block.
{
"hooks": {
"PreToolUse": [
{ "matcher": "^apply_patch$",
"hooks": [ { "type": "command", "command": "~/.codex/hooks/protect-files.sh", "timeout": 10 } ] }
]
}
}
#!/usr/bin/env bash
# ~/.codex/hooks/protect-files.sh
patch=$(jq -r '.tool_input.command // empty')
if echo "$patch" | grep -qE '^\*\*\* (Add|Update|Delete) File: .*(\.env|\.pem|id_rsa|id_ed25519)'; then
echo "Blocked: this patch touches a secrets file." >&2
exit 2
fi
exit 0
The *** Update File: header is how Codex patches name their target files. Exit 2 with the reason on stderr is the documented short form of deny.
3. Log every shell command to a file
Purpose: an append-only record of what ran. Returns: log (no decision).
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [ { "type": "command", "command": "~/.codex/hooks/log-command.sh", "async": true } ] }
]
}
}
#!/usr/bin/env bash
# ~/.codex/hooks/log-command.sh
jq -c '{ts: (now | todate), session: .session_id, cwd: .cwd, cmd: .tool_input.command}' \
>> ~/.codex/command-log.jsonl
async: true keeps it off the critical path. Codex runs at most eight async hooks at once per session and cancels unfinished ones at session end. If you need every line guaranteed, drop async.
4. Rewrite a command: force push becomes force-with-lease
Purpose: correct the command instead of refusing it. Returns: rewrite. Use this instead of the push half of recipe 1, not alongside it.
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [ { "type": "command", "command": "~/.codex/hooks/safer-push.sh", "timeout": 10 } ] }
]
}
}
#!/usr/bin/env bash
# ~/.codex/hooks/safer-push.sh
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command // empty')
if echo "$cmd" | grep -qE 'git[[:space:]]+push.*--force([[:space:]]|$)'; then
new=$(echo "$cmd" | sed -E 's/--force([[:space:]]|$)/--force-with-lease\1/')
echo "$input" | jq --arg c "$new" \
'{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: (.tool_input + {command: $c})}}'
fi
exit 0
updatedInput is accepted only with permissionDecision: "allow", and for Bash it must carry a string command. Merging over the original tool_input keeps any other fields intact.
5. Approval for network calls
Purpose: network access goes through a person. Returns: allow / deny / leave to you.
A PreToolUse hook cannot ask. OpenAI’s docs list ask as parsed but not supported: Codex marks the hook run failed and the tool call continues. The approval has to come from Codex’s own approval flow. Leave sandbox_workspace_write.network_access unset so network is not granted up front, and network use becomes an approval request. PermissionRequest fires when Codex is about to ask, “such as a shell escalation or managed-network approval”:
{
"hooks": {
"PermissionRequest": [
{ "matcher": "Bash",
"hooks": [ { "type": "command", "command": "~/.codex/hooks/network-approval.sh", "timeout": 10 } ] }
]
}
}
#!/usr/bin/env bash
# ~/.codex/hooks/network-approval.sh
input=$(cat)
echo "$input" | jq -c '{ts: (now | todate), cmd: .tool_input.command, why: .tool_input.description}' \
>> ~/.codex/approval-log.jsonl
if [ -n "$CI" ]; then
jq -n '{hookSpecificOutput: {hookEventName: "PermissionRequest", decision: {behavior: "deny", message: "No approver in CI."}}}'
fi
exit 0
On a laptop it logs and returns nothing, so Codex shows you the normal prompt. In CI it denies, because nobody is there to answer. Any matching deny wins over other hooks’ allows.
6. Redact secrets in tool output
Purpose: keep tokens that a command printed out of the model’s context. Returns: rewrite (of the result).
{
"hooks": {
"PostToolUse": [
{ "matcher": "Bash",
"hooks": [ { "type": "command", "command": "~/.codex/hooks/redact.sh", "timeout": 10 } ] }
]
}
}
#!/usr/bin/env bash
# ~/.codex/hooks/redact.sh
out=$(jq -r '.tool_response | if type == "string" then . else tojson end')
pat='(sk-[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9]{36}|AKIA[0-9A-Z]{16})'
if echo "$out" | grep -qE "$pat"; then
clean=$(echo "$out" | sed -E "s/$pat/[REDACTED]/g")
jq -n --arg r "Output contained credentials and was redacted:
$clean" '{decision: "block", reason: $r}'
fi
exit 0
On decision: "block", Codex “replaces the tool result with that feedback” and continues. The command already ran; only what the model reads changes. The docs also warn that hook output over about 2,500 tokens spills to a temp file, so keep secrets out of what the hook itself prints.
7. PostToolUse notifier
Purpose: a desktop notification whenever Codex edits a file. Returns: log (no decision).
{
"hooks": {
"PostToolUse": [
{ "matcher": "^apply_patch$",
"hooks": [ { "type": "command", "command": "~/.codex/hooks/notify.sh", "async": true } ] }
]
}
}
#!/usr/bin/env bash
# ~/.codex/hooks/notify.sh
files=$(jq -r '.tool_input.command // ""' | grep -oE '^\*\*\* (Add|Update|Delete) File: .*' | sed 's/^\*\*\* //' | head -3)
if command -v osascript >/dev/null; then
osascript -e "display notification \"$files\" with title \"Codex edited files\""
elif command -v notify-send >/dev/null; then
notify-send "Codex edited files" "$files"
fi
Swap the notification for a Slack webhook curl if you want it on your phone.
8. Call an external policy endpoint
Purpose: one decision service for every machine you run Codex on. Returns: block / allow, decided by the server.
{
"hooks": {
"PreToolUse": [
{ "matcher": ".*",
"hooks": [ { "type": "command", "command": "~/.codex/hooks/policy.sh", "timeout": 5, "statusMessage": "Policy check" } ] }
]
}
}
#!/usr/bin/env bash
# ~/.codex/hooks/policy.sh
# POLICY_URL returns {"decision":"allow"|"deny","reason":"..."}
resp=$(curl -sS --max-time 4 -X POST -H 'content-type: application/json' \
--data-binary @- "${POLICY_URL:?set POLICY_URL}") || { echo "Policy service unreachable." >&2; exit 2; }
if [ "$(echo "$resp" | jq -r '.decision')" = "deny" ]; then
echo "$resp" | jq '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: .reason}}'
fi
exit 0
The script forwards Codex’s own stdin to the server, so the service sees tool_name, tool_input, session_id and cwd. It fails closed: no answer means exit 2. Change that to exit 0 if an outage should not stop work. Codex runs commands with the session cwd, so export POLICY_URL in the shell that launches Codex.
9. Block one MCP tool by name
Purpose: deny a dangerous MCP tool outright. Returns: block.
{
"hooks": {
"PreToolUse": [
{ "matcher": "^mcp__github__(delete|merge)_.*$",
"hooks": [ { "type": "command", "command": "echo 'This MCP tool is disabled for agents.' >&2; exit 2" } ] }
]
}
}
The matcher does the work, so the hook never reads stdin. Replace github with the server name as it appears in your tool list.
10. Add context at session start
Purpose: give the model the repo state before the first turn. Returns: context (no decision).
{
"hooks": {
"SessionStart": [
{ "matcher": "startup|resume",
"hooks": [ { "type": "command", "command": "git status --short --branch 2>/dev/null | head -40", "timeout": 10 } ] }
]
}
}
For SessionStart, plain text on stdout “is added as extra developer context.” No JSON needed. PreToolUse and PostToolUse ignore plain text, so this trick only works here and on UserPromptSubmit.
Testing a hook
Run the script by hand with a fake event before Codex ever sees it:
echo '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"rm -rf build"}}' \
| ~/.codex/hooks/deny-destructive.sh; echo "exit=$?"
You should see the deny JSON and exit=0, or a stderr reason and exit=2. Then:
- Start Codex and open
/hooks. Review and trust the new hook. - Ask the agent to run something the hook should catch. The reason you wrote comes back as the tool error.
- Validate the JSON in
hooks.jsonwithjq . ~/.codex/hooks.json. A typo there means nothing loads.
Known gotchas
Hook never fires. Check trust first. A non-managed hook is skipped until you trust it in /hooks, and trust is tied to the hook’s hash, so every edit sends it back to review. Repo-level hooks also need the project .codex/ layer trusted. --dangerously-bypass-hook-trust skips persisted trust for one run.
Hooks off entirely. [features] hooks = false in config.toml disables them. Check it after an update.
Hosted tools are invisible. Web search and other hosted tools do not use the local hook path. No hook sees them.
Returning ask, continue or stopReason from PreToolUse. Not supported there. Codex marks the hook failed and runs the tool anyway. Only deny, the legacy {"decision": "block"}, exit 2, and allow with updatedInput act.
Async hooks cannot enforce. Recipes 3 and 7 are async on purpose. Never put a deny in an async hook.
Default timeout is 600 seconds. A slow policy hook stalls every tool call. Set timeout explicitly.
Plain text from PreToolUse is ignored. Print JSON or use exit 2.
Limits
These hooks are local. Each one lives on the machine where the agent runs, so each developer’s copy drifts. They see what Codex sends: tool name, input, output. They do not know who is behind the session. Regex hooks miss obfuscated commands. OpenAI’s own docs call hooks “a useful guardrail, not a complete enforcement boundary.” Some tool paths can opt out of hooks, and in cloud orchestration a PreToolUse error or timeout can let the tool run. The logs in recipes 3 and 5 sit in a file the agent can also write to.
If you want one set of rules across Codex and your other agents, ACP installs a hook like these for you. Two Codex limits carry over. Codex cannot show a hook’s ask as a prompt, so ACP never sends one: a held call is blocked, you approve it on the console or from the email, then ask Codex to retry. And hosted tools such as web search never reach any hook. If ACP is unreachable, the hook Codex uses fails open unless you set ACP_FAIL_MODE=closed or write closed to ~/.acp/failmode.
Frequently asked questions
codex hooks examples: what does a minimal hooks.json look like?
An object with a hooks key, then event name, then a list of matcher groups, each with a hooks list of handlers. A handler is {“type”: “command”, “command”: “…”} plus optional timeout, statusMessage and async. Put it in ~/.codex/hooks.json or
codex hook: how do I block a command?
In a PreToolUse hook, print {“hookSpecificOutput”: {“hookEventName”: “PreToolUse”, “permissionDecision”: “deny”, “permissionDecisionReason”: “…”}}, or exit with code 2 and write the reason to stderr. The model sees the reason and the command does not run.
codex agent hooks: can a hook ask for approval instead of blocking?
Not from PreToolUse. OpenAI’s docs list ask as parsed but not supported yet: Codex marks the hook run as failed and the tool call continues. Approval comes from Codex’s own approval flow, and a PermissionRequest hook can allow or deny that prompt or leave it to you.
Why is my Codex hook not firing?
Usually trust. A non-managed hook does not run until you review and trust it in /hooks, and editing the hook changes its hash and sends it back to review. Hosted tools such as web search never fire hooks.
Where to read more
- OpenAI Codex hooks docs: events, input fields, output contracts, trust
- Codex hooks reference: the full reference and what changed across releases
- Codex CLI’s control model: approvals, sandbox, permission profiles,
requirements.toml - openai/codex on GitHub: source and release notes
Running Codex? One command adds a hook for shell, apply_patch and MCP calls. Each call is recorded, and a force-push, destructive SQL or curl | sh is blocked until you approve it on the console or from the email.
curl -fsSL https://agenticcontrolplane.com/install.sh | bash
irm https://agenticcontrolplane.com/install.ps1 | iex
What the installer writes · Codex guide · free up to 5 agents