# Codex hooks examples: 10 copy-paste hooks.json recipes

Ten working Codex CLI hooks: block rm -rf and force pushes, log every command, rewrite a command, redact secrets, notify, and call a policy endpoint.

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](https://developers.openai.com/codex/hooks). For events, trust, managed hooks and what changed across releases, see the [Codex hooks reference](/blog/codex-cli-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:

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "~/.codex/hooks/guard.sh", "timeout": 10, "statusMessage": "Checking command" }
        ]
      }
    ]
  }
}
```

Facts the recipes rely on:

- `matcher` is a regex on the tool name. `Bash` covers shell and `exec_command`. `apply_patch` covers file edits (aliases `Edit`, `Write`). MCP tools look like `mcp__server__tool`.
- PreToolUse input carries `tool_name`, `tool_use_id`, `tool_input`, plus `session_id`, `cwd`, `model`, `turn_id`. For Bash and `apply_patch` the text is in `tool_input.command`.
- PostToolUse adds `tool_response`.
- The default timeout is 600 seconds. Set a short one on anything in the tool path.
- `async: true` runs 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**.

```json
{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash",
        "hooks": [ { "type": "command", "command": "~/.codex/hooks/deny-destructive.sh", "timeout": 10 } ] }
    ]
  }
}
```

```bash
#!/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**.

```json
{
  "hooks": {
    "PreToolUse": [
      { "matcher": "^apply_patch$",
        "hooks": [ { "type": "command", "command": "~/.codex/hooks/protect-files.sh", "timeout": 10 } ] }
    ]
  }
}
```

```bash
#!/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).

```json
{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash",
        "hooks": [ { "type": "command", "command": "~/.codex/hooks/log-command.sh", "async": true } ] }
    ]
  }
}
```

```bash
#!/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.

```json
{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash",
        "hooks": [ { "type": "command", "command": "~/.codex/hooks/safer-push.sh", "timeout": 10 } ] }
    ]
  }
}
```

```bash
#!/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":

```json
{
  "hooks": {
    "PermissionRequest": [
      { "matcher": "Bash",
        "hooks": [ { "type": "command", "command": "~/.codex/hooks/network-approval.sh", "timeout": 10 } ] }
    ]
  }
}
```

```bash
#!/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).

```json
{
  "hooks": {
    "PostToolUse": [
      { "matcher": "Bash",
        "hooks": [ { "type": "command", "command": "~/.codex/hooks/redact.sh", "timeout": 10 } ] }
    ]
  }
}
```

```bash
#!/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).

```json
{
  "hooks": {
    "PostToolUse": [
      { "matcher": "^apply_patch$",
        "hooks": [ { "type": "command", "command": "~/.codex/hooks/notify.sh", "async": true } ] }
    ]
  }
}
```

```bash
#!/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.

```json
{
  "hooks": {
    "PreToolUse": [
      { "matcher": ".*",
        "hooks": [ { "type": "command", "command": "~/.codex/hooks/policy.sh", "timeout": 5, "statusMessage": "Policy check" } ] }
    ]
  }
}
```

```bash
#!/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**.

```json
{
  "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).

```json
{
  "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:

```bash
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:

1. Start Codex and open `/hooks`. Review and trust the new hook.
2. Ask the agent to run something the hook should catch. The reason you wrote comes back as the tool error.
3. Validate the JSON in `hooks.json` with `jq . ~/.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



## Where to read more

- [OpenAI Codex hooks docs](https://developers.openai.com/codex/hooks): events, input fields, output contracts, trust
- [Codex hooks reference](/blog/codex-cli-hooks-reference): the full reference and what changed across releases
- [Codex CLI's control model](/controls/codex-cli): approvals, sandbox, permission profiles, `requirements.toml`
- [openai/codex on GitHub](https://github.com/openai/codex): source and release notes
