Read the script
#!/usr/bin/env bash
# PreToolUse hook (Bash): Guard the launch shape of a Codex dispatch
# Exit code 2 = reject the tool call
#
# `codex-transport.md` § Progress step 1: a `start`/`resume` dispatch is launched with
# `Bash(run_in_background: true)` and NOTHING redirected — stderr left attached is what the
# task panel shows, so the adapter's 60 s `[CODEX_EXEC_PROGRESS]` lines appear there live.
# A launch that redirects or pipes (`> log 2>&1`, `| tail`), wraps in `nohup`/`setsid`, or
# backgrounds with a trailing `&` leaves the panel reading "No output yet" for the whole run
# — and a foreground launch (`run_in_background` unset) meets the tool ceiling (measured
# 2026-09-05, repeated 2026-09-22 — lessons L19). The adapter cannot tell a harness task
# file from a caller's redirection (fd1 and fd2 are the same regular file either way,
# measured), so the guard sits on the launch command, where the shape is still visible.
#
# Only `start` and `resume` are guarded: `alloc` and `cleanup` are short and may be piped
# or redirected freely, and any command that does not name the adapter passes untouched.
set -euo pipefail
# === Plugin-defers-to-local arbitration ===
# When running as a plugin hook, detect if identical local hook is installed
# and registered in project settings — if so, exit 0 to avoid double-fire.
# Dev-mode bypass: hooks/hooks.json at project root = plugin source repo (skip arbitration).
# Origin, not path identity, decides deferral — the rationale is written once, in
# hooks/pre-edit-guard.sh, and this block is the same block.
_SELF_NAME="$(basename "$0")"
_SELF_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd -P)" || _SELF_DIR=""
_LOCAL_DIR="$(cd "${CLAUDE_PROJECT_DIR:-/nonexistent}/.claude/hooks" 2>/dev/null && pwd -P)" || _LOCAL_DIR=""
_IS_PLUGIN_COPY=false
if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" ]]; then
case "$(dirname "$0")/" in "${CLAUDE_PLUGIN_ROOT%/}"/hooks/) _IS_PLUGIN_COPY=true ;; esac
elif [[ -n "$_SELF_DIR" && -n "$_LOCAL_DIR" && "$_SELF_DIR" != "$_LOCAL_DIR" ]]; then
_IS_PLUGIN_COPY=true
fi
if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]] \
&& [[ ! -f "${CLAUDE_PROJECT_DIR}/hooks/hooks.json" ]] \
&& [[ "$_IS_PLUGIN_COPY" == "true" ]] \
&& [[ -x "${_LOCAL_DIR}/${_SELF_NAME}" ]]; then
_SETTINGS_MATCH=false
for _sf in "${CLAUDE_PROJECT_DIR}/.claude/settings.json" \
"${CLAUDE_PROJECT_DIR}/.claude/settings.local.json"; do
if [[ -f "$_sf" ]]; then
if command -v jq &>/dev/null; then
jq -e '.hooks // {} | .. | strings | select(contains(".claude/hooks/'"${_SELF_NAME}"'"))' "$_sf" >/dev/null 2>&1 \
&& _SETTINGS_MATCH=true && break
else
grep -q "\.claude/hooks/${_SELF_NAME}" "$_sf" 2>/dev/null \
&& _SETTINGS_MATCH=true && break
fi
fi
done
if [[ "$_SETTINGS_MATCH" == "true" ]]; then
exit 0 # Defer to local hook
fi
fi
# Read stdin once and store it
stdin_data=$(cat)
# Without jq the command cannot be extracted: fail-open, exactly as pre-edit-guard does,
# and docs/hooks.md says so.
command_text=$(printf '%s' "$stdin_data" | jq -r '.tool_input.command // empty' 2>/dev/null || true)
background=$(printf '%s' "$stdin_data" | jq -r '.tool_input.run_in_background // false' 2>/dev/null || true)
if [[ -z "$command_text" ]]; then
exit 0
fi
# Cheap pre-filter: a command that never names the adapter has nothing to guard.
[[ "$command_text" == *codex-exec.js* ]] || exit 0
# The judgement runs in node — the runtime every dispatcher already needs for the adapter itself —
# because the unit judged is a SIMPLE COMMAND, and finding one needs a lexer that respects quotes:
# a review measured a bash-regex form both blocking `grep 'example; node …codex-exec.js start 2>&1'`
# (the `;` inside quotes split it) and passing `2> err node …` (spaced prefix redirect). The lexer
# below handles single/double quotes, backslashes, `#` comments, the list operators (`&&`, `||`,
# `;`, newline), pipes, a trailing `&`, subshell parens and redirection words (`>`, `>>`, `<`,
# `2>`, `2>&1`, `&>`, `<<`). Every top-level simple command whose command word is `node` running a
# path ending in `codex-exec.js` with a standalone `start` or `resume` word is a launch, and every
# launch is checked — not only the first, and a script word written through a shell variable is
# resolved from the assignments this same input makes. Known boundary, stated in docs/hooks.md: a
# launch hidden inside `bash -c '…'`, `eval`, `$(…)` or a backtick is a quoted word to this lexer
# and passes, and so does a locator held in a variable that an EARLIER tool call assigned; the
# guard is aimed at the launch shapes that were measured, not at parsing arbitrary shell.
# Without node the command cannot be judged: fail-open, like the jq case above.
GUARD_JS='
const src = require("fs").readFileSync(0, "utf8");
const background = process.argv[1] === "true";
// Every simple command records its own output handling and the GROUP it sits in: a redirect,
// pipe or `&` written after a closing `)` / `}` applies to every command inside that group, so a
// launch inherits its ancestors flags (a review measured `(node … start) 2>err` slipping past a
// per-command read). Operators inside quotes stay text; quoted command words still count, since
// bash runs `"node" "scripts/codex-exec.js" start` exactly like the bare spelling.
const cmds = [];
const root = { parent: null, redirect: false, pipe: false, bg: false };
const stack = [root];
let cur = null, word = "", inWord = false, quoted = false, closed = null;
const fresh = () => ({ words: [], redirect: false, pipe: false, bg: false, group: stack[stack.length - 1] });
const push = () => { if (inWord) { cur.words.push({ text: word, quoted }); word = ""; inWord = false; quoted = false; } };
const end = () => { push(); if (cur.words.length) { cmds.push(cur); closed = null; } cur = fresh(); };
const target = () => (cur.words.length === 0 && closed) ? closed : cur; // a `) 2>x` belongs to the group
cur = fresh();
let