# Hooks in Codex

Codex runs command hooks from hooks.json or config.toml at events such as PreToolUse and PostToolUse; they can block, rewrite or observe tool calls.

Source: https://ai-sw-factory.mellicci.dev/fundamentals/hooks/codex

## At a glance

| | Codex |
|---|---|
| Term | Hooks; handlers are `command` or `mcp_tool` |
| Configured in | `hooks.json` or inline `[hooks]` in `config.toml`, in `~/.codex/` or `<repo>/.codex/` |
| Loads / runs | At lifecycle events such as `PreToolUse`, `PostToolUse` and `Stop`; new or changed hooks run only after you trust them in `/hooks` |
| Scope & precedence | Hooks from all layers run; higher layers don't replace lower ones. Project hooks load only in trusted projects |

## Build the scenario

| Step | What you write or run | Where |
|---|---|---|
| 1 | Write the three scripts, then `chmod +x` them | `.codex/hooks/` |
| 2 | Register them | `.codex/hooks.json` |
| 3 | Open Codex, run `/hooks`, review and trust the three hooks | TUI |

The file edit tool is `apply_patch`, which hooks can match as `apply_patch`, `Edit` or `Write`. Shell commands match as `Bash`. Register all three in `.codex/hooks.json`:

```json
{ "hooks": {
  "PreToolUse": [
    { "matcher": "Bash|apply_patch", "hooks": [{ "type": "command",
      "command": "\"$(git rev-parse --show-toplevel)/.codex/hooks/guard.sh\"" }] },
    { "hooks": [{ "type": "command",
      "command": "\"$(git rev-parse --show-toplevel)/.codex/hooks/log.sh\"" }] }],
  "PostToolUse": [
    { "matcher": "apply_patch", "hooks": [{ "type": "command",
      "command": "\"$(git rev-parse --show-toplevel)/.codex/hooks/format.sh\"" }] }]
} }
```

`guard.sh` reads the event from stdin. For `Bash` and `apply_patch`, `tool_input.command` holds the shell command or the patch text, so one pattern covers both.

```bash
#!/usr/bin/env bash
cmd=$(jq -r '.tool_input.command // ""')
deny() {
  jq -n --arg r "$1" '{hookSpecificOutput: {hookEventName: "PreToolUse",
    permissionDecision: "deny", permissionDecisionReason: $r}}'
  exit 0
}
grep -Eq '(^|[;&| ])rm +(-[a-zA-Z]*[rR]|--recursive)' <<<"$cmd" \
  && deny "Recursive rm is blocked; delete named files."
grep -Eq '(^|[^[:alnum:]_])\.env' <<<"$cmd" \
  && deny "Do not write .env* files."
exit 0
```

`log.sh` runs before every tool the hook path covers and appends one line:

```bash
#!/usr/bin/env bash
cd "$(git rev-parse --show-toplevel)" && mkdir -p .agent/logs
jq -c '{ts: (now | todate), tool: .tool_name, id: .tool_use_id, input: .tool_input}' \
  >> .agent/logs/tool-calls.jsonl
```

`format.sh` needs the changed files. The documentation doesn't specify the patch format inside `tool_input.command` or any field that lists the changed paths, so this script asks Git instead of parsing the patch. Exit code 2 with stderr replaces the tool result with your lint errors.

```bash
#!/usr/bin/env bash
cd "$(git rev-parse --show-toplevel)" || exit 0
rc=0
for f in $(git ls-files -m -o --exclude-standard -- '*.ts'); do
  pnpm prettier --write "$f" >/dev/null
  pnpm eslint --fix "$f" >&2 || rc=2
done
exit $rc
```

## Specifics

### Events

The fundamental events for this scenario; the documentation lists more (`PermissionRequest`, `PreCompact`, `PostCompact`, `SubagentStart`, `SubagentStop`, `Interrupt`).

| Event | Fires | Can block |
|---|---|---|
| `SessionStart` | Session starts or resumes, after clear or compact | Adds context only |
| `UserPromptSubmit` | Before a prompt goes to the model | Yes, blocks the prompt |
| `PreToolUse` | Before a tool runs | Yes, deny or rewrite |
| `PostToolUse` | After a tool produced output | Replaces the result; can't undo effects |
| `Stop` | A turn ends | `block` continues the turn with your reason |
| `SessionEnd` | Main thread ends | No, advisory |

### Configuration and matchers

| Item | Detail |
|---|---|
| Locations | `~/.codex/hooks.json`, `~/.codex/config.toml`, `<repo>/.codex/hooks.json`, `<repo>/.codex/config.toml`; plugins can bundle hooks |
| Shape | Event, then matcher group, then one or more handlers |
| TOML form | `[[hooks.PreToolUse]]` with `matcher`, then `[[hooks.PreToolUse.hooks]]` with `type` and `command` |
| Matcher | Regex on tool name for tool events; `"*"`, `""` or no `matcher` matches all |
| Ignored for | `UserPromptSubmit`, `Stop`, `Interrupt` |
| Both forms in one layer | Merged, with a startup warning |

### Input and output

Every command hook gets one JSON object on stdin.

| Field | Meaning |
|---|---|
| `session_id`, `cwd`, `hook_event_name`, `model` | Session, working directory, event, active model |
| `tool_name`, `tool_use_id` | Tool events: `Bash`, `apply_patch` or an MCP name such as `mcp__fs__read` |
| `tool_input` | `Bash` and `apply_patch` use `tool_input.command`; other tools send their arguments |
| `tool_response` | `PostToolUse` only |

Commands run with the session `cwd`, which can be a subdirectory; resolve scripts from the git root. Plain stdout is ignored for tool events.

### Blocking and decisions

| Event | Block with | Effect |
|---|---|---|
| `PreToolUse` | `permissionDecision: "deny"` plus reason, `{"decision":"block","reason":...}`, or exit 2 with stderr | Tool doesn't run |
| `PostToolUse` | `decision: "block"` or exit 2 with stderr | Feedback replaces the result; the tool already ran |
| `Stop` | `decision: "block"` or exit 2 | Continues the turn with your reason as the prompt |

Exit 0 with no output means continue. `permissionDecision: "allow"` with `updatedInput` rewrites a call; `"ask"` isn't supported yet. The documentation doesn't describe other exit codes.

### Scope, trust and limits

| Topic | Detail |
|---|---|
| Trust | Codex records a hash per hook; new or changed hooks are skipped until trusted in `/hooks` |
| Untrusted project | Project `.codex/` hooks are skipped; user and system hooks still load |
| Bypass | `--dangerously-bypass-hook-trust` skips the review for one invocation |
| Feature flag | `[features] hooks` is `true` by default; `false` turns hooks off |
| Timeout | `timeout` in seconds, default 600 (`SessionEnd`, `Interrupt`: 1, max 3) |
| Concurrency | Matching hooks for an event start concurrently |
| Async | `"async": true` can't block or rewrite anything |
| Coverage | Hosted tools such as `WebSearch` never run hooks; some tool paths can opt out |
| Inspect | `/hooks` lists sources, trust state and lets you disable individual hooks |

## Gotchas

- **Trust first.** An unreviewed or edited hook is skipped until you trust it in `/hooks`. Project hooks also need a trusted project.
- **Guardrail, not a boundary.** The documentation calls tool hooks a guardrail, not a complete enforcement boundary. Pair `guard.sh` with [sandboxing](https://ai-sw-factory.mellicci.dev/fundamentals/sandboxing) and the [security model](https://ai-sw-factory.mellicci.dev/fundamentals/security-model).
- **Regex is blunt.** The `.env` pattern also matches reads in a command and `.env.example`; shell tricks can hide a recursive `rm`.
- **Log and guard race.** Both run on `PreToolUse` at the same time, so `log.sh` also records calls that `guard.sh` denies.
- **Project hooks are repository code.** They run with your user rights; review changes to `.codex/hooks.json` and the scripts in pull requests.
