Codex hooks generator
Pick what the Codex agent should and shouldn't do, and copy a hooks.json (or the same hooks for config.toml) plus one bash script per hook. Our tests run every generated script under bash with sample hook input, so a blocked command really is blocked and a normal one still runs.
By Jake Marsh. Event names, input fields and output fields checked against OpenAI's Codex hooks docs on 2026-11-02. Runs in your browser and sends nothing anywhere. Every event is listed in the Codex hooks reference; for Cursor, use the Cursor hooks.json generator.
1. Install
Run in a terminal. The scripts need jq.
mkdir -p ~/.codex/hooks
# save each script below into that folder, then:
chmod +x ~/.codex/hooks/guard-shell.sh ~/.codex/hooks/protect-env.sh ~/.codex/hooks/notify.sh
command -v jq >/dev/null || echo 'Install jq first: brew install jq (macOS) or apt install jq'
# then start codex and run /hooks to review and trust the new hooks2. hooks.json
Save as ~/.codex/hooks.json. Then open /hooks in Codex and trust the new hooks.
{
"description": "Made with taskhold.com/tools/codex-hooks",
"hooks": {
"PreToolUse": [
{
"matcher": "^Bash$",
"hooks": [
{
"type": "command",
"command": "bash ~/.codex/hooks/guard-shell.sh",
"timeout": 10,
"statusMessage": "Checking command"
}
]
},
{
"matcher": "^(Bash|apply_patch)$",
"hooks": [
{
"type": "command",
"command": "bash ~/.codex/hooks/protect-env.sh",
"timeout": 10,
"statusMessage": "Checking for secrets files"
}
]
}
],
"PermissionRequest": [
{
"hooks": [
{
"type": "command",
"command": "bash ~/.codex/hooks/notify.sh",
"timeout": 10
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "bash ~/.codex/hooks/notify.sh",
"timeout": 10
}
]
}
]
}
}3. guard-shell.sh
Save as ~/.codex/hooks/guard-shell.sh
#!/bin/bash
# guard-shell.sh: Codex PreToolUse (Bash) hook. Made with taskhold.com/tools/codex-hooks.
# Reads the hook's JSON from stdin; needs jq.
# Blocks: git push --force / -f, git reset --hard, rm -rf / or ~.
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')
if printf '%s' "$cmd" | grep -qE '(git push.*(--force|-f( |$)))|(git reset --hard)|(rm -[a-z]*r[a-z]*f?[a-z]* +(/|~)( |$))'; then
jq -cn '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "This command is blocked by a hook. Use a safer alternative or ask me to run it."}}'
fi
exit 0
4. protect-env.sh
Save as ~/.codex/hooks/protect-env.sh
#!/bin/bash
# protect-env.sh: Codex PreToolUse (Bash, apply_patch) hook. Made with taskhold.com/tools/codex-hooks.
# Reads the hook's JSON from stdin; needs jq.
# Denies commands and patches that name a secrets file. A guardrail, not a sandbox:
# a command that reaches a file without naming it gets through.
input=$(cat)
text=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')
hit=$(printf '%s' "$text" | tr -s ' \t\n"=<>;|&()`'"'" '\n' | while IFS= read -r word || [ -n "$word" ]; do
name=${word##*/}
case "$name" in
(.env.example|.env.sample|.env.template) ;;
(.env|.env.*|*.pem|*.key|id_rsa|id_ed25519) printf '%s' "$word"; break ;;
esac
done)
if [ -n "$hit" ]; then
jq -cn --arg p "$hit" '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: ("Kept away from the agent by a hook: " + $p + ". Ask me if you need a value from it.")}}'
fi
exit 0
5. notify.sh
Save as ~/.codex/hooks/notify.sh
#!/bin/bash
# notify.sh: Codex Stop and PermissionRequest hook. Made with taskhold.com/tools/codex-hooks.
# Reads the hook's JSON from stdin; needs jq.
input=$(cat)
event=$(printf '%s' "$input" | jq -r '.hook_event_name // empty')
if [ "$event" = "PermissionRequest" ]; then
tool=$(printf '%s' "$input" | jq -r '.tool_name // "a tool"')
msg="Waiting for your approval ($tool)"
else
last=$(printf '%s' "$input" | jq -r '.last_assistant_message // empty' | tr '\n' ' ' | cut -c1-120)
msg=${last:-"Codex finished a turn"}
fi
{
osascript -e 'on run argv' -e 'display notification (item 2 of argv) with title (item 1 of argv)' -e 'end run' "Codex" "$msg" 2>/dev/null
{ afplay /System/Library/Sounds/Glass.aiff || paplay /usr/share/sounds/freedesktop/stereo/complete.oga; } >/dev/null 2>&1 &
} || true
# Stop needs JSON on stdout; PermissionRequest prints nothing so the normal approval prompt shows.
[ "$event" = "Stop" ] && echo '{}'
exit 0
What each recipe uses
PreToolUsewith matcher^Bash$gets the command intool_input.command. To refuse it, the script returnspermissionDecisiondeny insidehookSpecificOutput, with apermissionDecisionReasonthe agent reads. No output means carry on.- The env guard also matches
apply_patch, whosetool_input.commandis the patch text, so a patch that edits .env is refused too. .env.example and other templates stay readable. PostToolUseonapply_patchruns after an edit. The script reads the*** Add File:and*** Update File:lines of the patch and formats those files.PermissionRequestruns just before Codex asks for approval. The notify script prints nothing, so you still get the normal prompt; it only tells you one is waiting.Stopruns when a turn ends and must print JSON. Returning{"decision":"block","reason":"..."}makes the reason Codex's next prompt, which is how the tests recipe sends it back with the last lines of the failure.
Exit code 2 with a reason on stderr also blocks a tool call or continues a turn. The scripts print JSON instead, so the reason is explicit and every other command runs as usual.
When a hook doesn't run
- Open
/hooksin Codex: new or edited hooks are skipped until you trust them there. - Check jq is installed:
jq --version. - Run a script by hand with sample input, for example
echo '{"tool_input":{"command":"git push -f"}}' | bash ~/.codex/hooks/guard-shell.sh. - Project hooks load only when the repo's .codex/ layer is trusted; user hooks in ~/.codex still run.
- Hooks are on by default; check config.toml doesn't set
hooks = falseunder [features].
Where Taskhold goes further
Hooks act on one Codex session at a time. Taskhold gives Codex, Claude Code, Cursor and other MCP clients one task list: each agent reports its steps and progress there, and asks its questions on a board you can answer from your phone. Try the live board demo without an account, or connect Codex from the Codex setup page.
Questions
- Where do Codex hooks go?
- ~/.codex/hooks.json, or [hooks] tables in ~/.codex/config.toml, apply to every project. .codex/hooks.json or .codex/config.toml in a repo apply to that project once its .codex/ layer is trusted. Every matching hook from every layer runs.
- Why doesn't my new hook run?
- Codex skips new and changed hooks until you review and trust them with /hooks, and trust is tied to the hook's hash, so editing a hook means approving it again. [features] hooks = false in config.toml turns all hooks off.
- Can the tests hook loop forever?
- No. Codex sets stop_hook_active when a turn is already running because of a Stop hook, and the generated script lets the turn stop in that case. So a failing test run sends Codex back once per turn.
- Does the env guard stop every way of reading a .env file?
- No. It denies shell commands and patches that name a .env file, a .pem or .key file or an SSH key. A command that reaches the file without naming it gets through. Codex's docs call tool hooks a guardrail, not a complete enforcement boundary.
- Does the page send my settings anywhere?
- No. The page builds the text in your browser and makes no requests with what you choose or type.
Sources
Checked 2026-11-02.
See every agent's progress on one board.
Taskhold gives Codex, Claude Code, Cursor and other MCP clients one task list with steps, progress bars and questions you answer from your phone.