Read the script
#!/usr/bin/env node
/**
* codex-apply-patch-guard.mjs — PreToolUse `apply_patch` + `Bash` hook for Codex
* (GitLab #1913, #1914): runs the Edit/Write guards on every file a Codex patch touches.
*
* Codex writes files through its freeform `apply_patch` tool; the PreToolUse
* payload carries the patch text, not the `tool_input.file_path` the Edit/Write
* guards read. This adapter parses the patch (`scripts/lib/codex/apply-patch-parse.mjs`,
* which also records what of the payload shape is measured and what is not),
* turns each touched file into the Claude-shaped Edit or Write call the guards
* understand, and runs the four guards on it:
*
* enforce-scope → config-protection → pre-edit-sessions-ledger-guard → pre-tool-state-md-path-guard
*
* Mapping per file: Add → Write `{file_path, content}`; Delete → Write with
* `content: ''`; Update → a Write of the WHOLE file Codex would produce; Move →
* the source as a Delete AND the destination as a Write of the moved content
* (see `toToolCalls` for why whole-file Writes, not per-hunk Edits).
*
* The guards end through `emitAllow`/`emitDeny`, which call `process.exit`, so
* each runs as its own child process (`spawnSync(process.execPath, …)`) with
* the synthesized payload on stdin. The first deny envelope is forwarded with
* ` [apply_patch: <path>]` appended to its reason.
*
* ## Two routes (measured codex-cli 0.160.1, macOS, `codex exec`, 2026-11-02, #1914)
*
* 1. Tool route: matcher `apply_patch`, `tool_name: "apply_patch"`,
* `tool_input: { command: "<patch>" }` (`extractPatchText`).
* 2. Shell route: the model writes `apply_patch <<'EOF' … EOF` through its shell
* tool. Only the `Bash` matcher fires (`tool_name: "Bash"`, the heredoc in
* `tool_input.command`), no PostToolUse; Codex recognises the heredoc itself
* (tree-sitter query `(#any-of? @apply_name "apply_patch" "applypatch")`) and
* applies the patch without running a shell. So this hook is registered on
* `Bash` too: `extractShellApplyPatch` reads the heredoc and the patch runs
* the same path as on the tool route. A Bash command that does not name
* apply_patch allows at once after a cheap text test, with no guard child
* and no further module load. A command that may name it is read with the
* shared tokenizer in `scripts/lib/command-blocker.mjs` (loaded only then,
* #1917): every statement of the chain, every verb reading behind
* assignments and wrappers (`x=1`, `time`, `exec`, `sudo`, `nice -n 5`, both
* readings of an unknown option such as `sudo -X val`), `$( )` and backtick
* bodies, the string of `bash|sh|zsh|dash|ksh|su -c` and `eval`, and the
* literal data a shell reads from stdin (`printf 'S' | sh`, `sh <<< 'S'`,
* `printf 'S' | xargs -0 sh -c`, the blocker's `pipedShellData`, #2003). The
* command word counts after quotes, backslashes and `$'…'` are undone; a
* word left unexpanded (`apply_${p}atch`) counts when it carries the name or
* what remains of it. A quoted argument (`git commit -m "fix; apply_patch"`)
* or a heredoc body is not an invocation and allows; `command -v` and
* `which` look the name up and allow. Only the plain heredoc
* `apply_patch <<'EOF'` … `EOF` (optionally behind `command`/`env VAR=x`) is
* read; every other invocation (prefixed heredoc, pipe, file redirect,
* `cd … &&`, `bash -c`, a quoted or masked name, text after the heredoc)
* denies: Codex might still apply it internally, past the guards. A
* tokenizer that cannot load or throws denies too.
*
* CEILING (BV-004): the tokenizer reads the command text only. A name that
* exists only at run time (`$CMD < p.diff` with `CMD=apply_patch`, a name
* read from a file or variable, data piped into a shell from a file), an
* expansion pattern other than a
* bare `${x}`/`$x`/`$( )` inside the name, and another interpreter that runs
* it (`xargs apply_patch`, `find -exec`, `python -c`, `perl -e`) are not
* seen and allow. Revisit trigger: one such invocation in a real Codex
* session.
*
* MEASURED (codex-cli 0.160.1, 2026-11-02): a `Bash` PreToolUse deny stops
* Codex's INTERNAL heredoc application completely (file unchanged, no
* file_change event; the model gets "Command blocked by PreToolUse hook:
* <reason>"), for all three deny forms (permissionDecision "deny",
* `{"decision":"block"}`, exit 2 with stderr).
*
* CEILING: the shell route protects only with the guards the host profile
* switches on. Under profile `standard`, `pre-tool-state-md-path-guard` is
* off (STANDARD_OFF_HOOKS), so a heredoc patch on `.orchestrator/STATE.md`
* goes through, as with Claude Edit/Write.
*
* ## Fail closed
*
* Unlike the single-purpose guards (which allow on internal error), this hook
* is the ONLY route by which Codex file writes reach those guards. So every
* failure denies: unparsable stdin, an unrecognised `tool_input` shape, a patch
* outside the grammar, a guard that exits non-zero, times out or cannot be
* spawned, an exhausted time budget, and any internal error. A payload whose
* `tool_name` is not `apply_patch` allows only when its `tool_input` does not
* read as a patch (see `main`).
*
* Time: Codex gives the hook 5 s (`hooks/hooks-codex.json`). The children share
* a 4.5 s budget; a patch too large to judge in it is denied, not waved through,
* and the deny names the way out: split the patch. All `*** Update File:`
* sections of one path fold into one judged Write (`foldPatchFiles`, #1914),
* so the child count grows with the files a patch touches, not its hunks.
*
* ## Override
*
* SO_DISABLED_HOOKS=codex-apply-patch-guard (whole adapter)
* SO_DISABLED_HOOKS=<guard name> (one guard; honoured by the child)
* SO_HOOK_PROFILE=minimal|off
*/
import { readFileSync, writeSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';