A Codex PreToolUse hook that blocks force pushes and --no-verify, and what happens when the hook itself breaks
Codex hooks are on by default in 0.163.0. Here's a 40-line guard, the config that wires it in, the trust step that silently skips it, and the failures that let a command through.
Part of the hooks reading guide

An agent that can run shell commands will, sooner or later, type git push --force or tack --no-verify onto a commit because a pre-commit check was in its way. A sentence in AGENTS.md asking it not to is a request. A PreToolUse hook is a check that runs before the command does.
As of Codex 0.163.0 (15 October 2026), codex features list shows hooks as stable and on. Below is a small guard script, the config.toml that registers it, the step that makes Codex skip it if you forget, and the places where it won’t help. If you’ve built the same thing for Claude Code, the Claude Code version
will look familiar, but several details differ.
Where hooks live
Per the Codex hooks documentation
, Codex loads hooks from four places: ~/.codex/hooks.json, ~/.codex/config.toml, <repo>/.codex/hooks.json and <repo>/.codex/config.toml. All matching hooks from all of them run. A repo-level hook doesn’t replace a user-level one, and matching command hooks for the same event run concurrently, so one hook can’t stop another from starting.
Project hooks load only when the project’s .codex/ layer is trusted. Use either hooks.json or inline TOML in a given layer, not both: Codex merges them and warns at startup.
Each hook has three levels. An event (PreToolUse), a matcher group that filters by tool name, and one or more handlers. For shell commands the tool name is Bash.
The config
Put this in <repo>/.codex/config.toml:
[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = '/usr/bin/python3 "$(git rev-parse --show-toplevel)/.codex/hooks/guard.py"'
timeout = 10
statusMessage = "Checking Bash command"
Two choices here are deliberate. The git rev-parse call resolves the script from the repo root, because the docs warn that Codex may start in a subdirectory and the command runs from the session’s working directory. And timeout = 10 replaces the default, which the docs give as 600 seconds. A hung hook that holds up the agent for ten minutes, then fails open, is worse than one that times out quickly.
The guard
The hook receives one JSON object on stdin. For PreToolUse it carries tool_name, tool_use_id, turn_id and tool_input, and for a Bash call the command is in tool_input.command. To deny, print a hookSpecificOutput object with permissionDecision set to deny and a reason. Save this as .codex/hooks/guard.py:
#!/usr/bin/env python3
"""Codex PreToolUse guard: deny a short list of commands, fail closed on bad input."""
import json
import re
import sys
RULES = [
(r"\bgit\s+push\b.*(--force\b|--force-with-lease\b|\s-f\b)", "Force pushes are blocked. Push normally or ask the user."),
(r"\bgit\s+(commit|push)\b.*--no-verify\b", "--no-verify skips the repo's hooks. Fix the failing check instead."),
(r"\bgit\s+reset\s+--hard\b", "git reset --hard discards work. Use git stash or ask the user."),
(r"\brm\s+-[a-zA-Z]*r[a-zA-Z]*f?\s+(/|~|\$HOME)(\s|$)", "Recursive delete of a root or home path is blocked."),
]
def deny(reason: str) -> None:
json.dump(
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
},
sys.stdout,
)
sys.exit(0)
try:
event = json.load(sys.stdin)
command = event["tool_input"]["command"]
if not isinstance(command, str):
raise TypeError("command is not a string")
except Exception as exc: # malformed payload: refuse rather than wave it through
deny(f"Guard could not read the tool call ({exc.__class__.__name__}). Blocked.")
for pattern, reason in RULES:
if re.search(pattern, command):
deny(reason)
Write the reason as an instruction, not a verdict. “Push normally or ask the user” gives the agent somewhere to go. “Forbidden” gives it nothing to do next.
The except block matters more than it looks. Without it, a payload the script can’t parse crashes the script, and a crash is an error exit, which isn’t a block (more on that below).
Run it
The script can be tested without Codex, because the contract is just JSON in and JSON out. Feed it the payloads Codex would send:
t() { printf '%s' "$1" | python3 .codex/hooks/guard.py; echo " [exit=$?]"; }
t '{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}'
t '{"tool_name":"Bash","tool_input":{"command":"git commit -m wip --no-verify"}}'
t '{"tool_name":"Bash","tool_input":{"command":"rm -rf ~"}}'
t '{"tool_name":"Bash","tool_input":{"command":"git push origin feature/x"}}'
t 'not json'
It prints:
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "Force pushes are blocked. Push normally or ask the user."}} [exit=0]
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "--no-verify skips the repo's hooks. Fix the failing check instead."}} [exit=0]
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "Recursive delete of a root or home path is blocked."}} [exit=0]
[exit=0]
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "Guard could not read the tool call (JSONDecodeError). Blocked."}} [exit=0]
The plain push to a feature branch prints nothing and exits 0, which Codex treats as allow. That’s the output of the script alone. The registration, the trust step and how Codex handles a deny are documented behaviour from the hooks page, not something observed in a live session.
Exit code 2 is the other way to block. The docs say it blocks PreToolUse and takes the reason from stderr. JSON on stdout with exit 0 is the cleaner route, because the reason is structured and the script never has to choose between two channels.
The trust step
Open Codex in the repo and nothing happens. That’s the point to check, not to assume the hook is working.
The docs say user, project and plugin hooks must be reviewed and trusted before they run, and that trust is tied to the hook’s current hash. A new hook is skipped until trusted, and so is an edited one. Codex prints a startup warning when review is needed. Run /hooks in the CLI to see the source of each hook, trust it, or disable it.
There are two consequences. First, every edit to the guard or its config invalidates trust, so a teammate who pulls your change gets an unguarded session until they run /hooks. Say so in the PR. Second, --dangerously-bypass-hook-trust runs enabled hooks for a single invocation without persisted trust. Reserve it for CI jobs where the hook files come from a commit you’ve already reviewed.
For a team, the docs describe managed hooks: a requirements.toml with [hooks] and managed_dir, optionally allow_managed_hooks_only = true, which ignores user, project, session and plugin hooks entirely. Managed hooks are trusted by policy, so there’s no /hooks step for developers. Codex doesn’t ship the scripts, though; your own tooling has to put them in the directory.
Where it fails open
The documentation is blunt about this: tool hooks are a guardrail, not a complete enforcement boundary, and hook timeouts and errors can fail without blocking. Treat that as the design constraint.
A script that crashes exits with status 1. Python’s own behaviour is easy to confirm:
python3 -c "1/0" 2>/dev/null; echo "exit=$?"
exit=1
Per the docs, only exit code 2 blocks a PreToolUse call. Status 1 is an error, so the command goes ahead. That’s why the guard catches its own parsing failures and denies explicitly, and why a timeout is a hole, not a block. Test the failure path before trusting the hook: put sys.exit(1) at the top, ask Codex to run a harmless ls, and watch it run.
Coverage has gaps too. PreToolUse fires for shell commands, apply_patch, MCP tools and other local function tools, but not for hosted tools such as web search. write_stdin doesn’t re-run the hook when it sends input to a unified-exec session that’s already running, so a command typed into a live shell isn’t inspected a second time. Unsupported output fields don’t block either: PreToolUse supports only systemMessage among the common output fields, and returning continue, stopReason or suppressOutput marks the hook as failed and lets the tool call proceed.
Then there’s the regex. It matches substrings, so echo git push --force is denied. That’s a false positive, and cheap. The expensive direction is evasion: a command assembled from variables, or a script that runs the push itself, never matches. The guard stops the agent’s reflexes, not a determined workaround. If the risk is real, the sandbox and the repository’s branch protection are the boundary; the hook is the speed bump in front of them.
Make it yours
For apply_patch, the same hook shape works with matcher = "^apply_patch$" (or Edit|Write, which the docs say also match it). The input still reports tool_name: "apply_patch". A guard on edits might refuse paths such as .github/workflows/ or .codex/, so an agent can’t rewrite the rules that govern it.
If you’d rather ask than refuse, the docs describe a PermissionRequest event that runs when Codex is about to prompt for approval. A hook there can answer allow or deny on your behalf, and when hooks disagree any deny wins. That suits a rule such as “always allow git status”, though a deny-only PreToolUse guard is the safer first step.
A repo-level guard also pairs with the commit-time checks described in the Husky and Claude Code commit gate
. The --no-verify rule above exists to protect exactly those: Git’s hooks only help if the agent can’t skip them.
The first 15 minutes
Copy the config and script into .codex/ in a throwaway repo. Run the five payload checks above and confirm the output matches. Start Codex, run /hooks, and trust the hook. Then ask the agent to run git push --force in the throwaway repo and read what it reports. If the command runs anyway, the hook isn’t loaded, and /hooks will say why.