You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
/codex:setup --enable-review-gate reports success and returns "reviewGateEnabled": true,
but the flag can be written to a state root that the Stop hook never reads. The gate is
then silently inert: no error, no warning, and setup --json keeps affirming it is on.
The split-root mechanism itself is already described in #636 (broker/SessionEnd). This
issue is a different, user-facing consequence of it: an opt-in review gate that fails
open while reporting that it is armed. #676 covers the gate failing open on malformed
hook stdin; this is the same failure class reached through configuration instead.
Mechanism
scripts/lib/state.mjs:29-43 — the state root depends on CLAUDE_PLUGIN_DATA, with a $TMPDIR fallback. The slug and hash are identical either way; only the root differs.
The two sides of the gate resolve that root in different environments:
Side
Runs as
CLAUDE_PLUGIN_DATA
State root used
/codex:setup --enable-review-gate
Bash tool
often unset
$TMPDIR/codex-companion/<slug>-<hash>
stop-review-gate-hook.mjs:154
hook, spawned by the harness
set
$CLAUDE_PLUGIN_DATA/state/<slug>-<hash>
The plugin is aware of this and tries to bridge it — session-lifecycle-hook.mjs:80:
functionhandleSessionStart(input){appendEnvVar(SESSION_ID_ENV,input.session_id);appendEnvVar(TRANSCRIPT_PATH_ENV,input.transcript_path);appendEnvVar(PLUGIN_DATA_ENV,process.env[PLUGIN_DATA_ENV]);// <- propagates to the Bash tool}
But appendEnvVar is a no-op whenever process.env.CLAUDE_ENV_FILE is unset, and the
whole propagation never happens at all if the SessionStart hook did not run. Two
reachable paths to that:
Plugin installed mid-session (/plugin install + /reload-plugins). The plugin's SessionStart hook never ran for that session, so nothing was ever appended to CLAUDE_ENV_FILE. This is exactly the flow a first-time user follows — install the
plugin, then immediately run /codex:setup, which is what the setup command itself
instructs. Every Bash invocation for the rest of that session resolves to $TMPDIR.
$ cat "$TMPDIR/codex-companion/alfred-front-790ce51531842df0/state.json"{ "version": 1, "config": { "stopReviewGate": true }, "jobs": [] }
$ ls ~/.claude/plugins/data/codex-openai-codex/state/(empty — the root the Stop hook reads has no state at all)
Re-running the same command with the variable set writes a second, independent copy:
Same repo, same slug, same hash, two state files, one of them ignored by the hook.
Impact
The user explicitly opts into a review gate; the gate never fires. Failing open on a
quality gate is worse than failing closed, and here there is no signal at all.
setup --json is affirmatively misleading: reviewGateEnabled: true reflects the state
file the command just wrote, not the one the hook will read.
The $TMPDIR fallback is purged by macOS, so even a session where the propagation works
can lose the flag later without the user touching anything.
--disable-review-gate has the mirror-image problem: it can clear a flag in one root
while an armed one persists in the other.
Suggested fixes
Roughly in order of cost:
Report the resolved path. Add stateFile (and the resolved root) to the setup --json
payload and to the human-readable output. A one-line change that makes every split-root
bug in this class self-diagnosing.
Warn when the variable is absent. If CLAUDE_PLUGIN_DATA is unset while writing
config, emit a nextSteps warning rather than silently using $TMPDIR — configuration
is not throwaway state, unlike job files.
Store durable config outside the volatile root.stopReviewGate is small,
long-lived, per-workspace config; $TMPDIR is the wrong medium for it regardless of
which root wins. A stable location (e.g. under ~/.codex/) keyed by workspace hash
removes the divergence for config even if job state stays where it is.
Summary
/codex:setup --enable-review-gatereports success and returns"reviewGateEnabled": true,but the flag can be written to a state root that the
Stophook never reads. The gate isthen silently inert: no error, no warning, and
setup --jsonkeeps affirming it is on.The split-root mechanism itself is already described in #636 (broker/
SessionEnd). Thisissue is a different, user-facing consequence of it: an opt-in review gate that fails
open while reporting that it is armed. #676 covers the gate failing open on malformed
hook stdin; this is the same failure class reached through configuration instead.
Mechanism
scripts/lib/state.mjs:29-43— the state root depends onCLAUDE_PLUGIN_DATA, with a$TMPDIRfallback. The slug and hash are identical either way; only the root differs.The two sides of the gate resolve that root in different environments:
CLAUDE_PLUGIN_DATA/codex:setup --enable-review-gate$TMPDIR/codex-companion/<slug>-<hash>stop-review-gate-hook.mjs:154$CLAUDE_PLUGIN_DATA/state/<slug>-<hash>The plugin is aware of this and tries to bridge it —
session-lifecycle-hook.mjs:80:But
appendEnvVaris a no-op wheneverprocess.env.CLAUDE_ENV_FILEis unset, and thewhole propagation never happens at all if the
SessionStarthook did not run. Tworeachable paths to that:
/plugin install+/reload-plugins). The plugin'sSessionStarthook never ran for that session, so nothing was ever appended toCLAUDE_ENV_FILE. This is exactly the flow a first-time user follows — install theplugin, then immediately run
/codex:setup, which is what the setup command itselfinstructs. Every Bash invocation for the rest of that session resolves to
$TMPDIR.SessionStartkilled by its own 5s timeout (SessionStart hook is killed by its own 5s timeout, silently dropping CODEX_COMPANION_SESSION_ID #670) — same outcome in an ordinarysession, not just a mid-session install.
hooks/hooks.jsongivesStopa 900s timeout butSessionStartonly 5s, so path 2 isnot hypothetical.
Repro
Fresh session, plugin installed mid-session, on macOS, plugin 1.0.6,
codex-cli 0.149.1:Where the flag actually landed:
Re-running the same command with the variable set writes a second, independent copy:
Same repo, same slug, same hash, two state files, one of them ignored by the hook.
Impact
quality gate is worse than failing closed, and here there is no signal at all.
setup --jsonis affirmatively misleading:reviewGateEnabled: truereflects the statefile the command just wrote, not the one the hook will read.
$TMPDIRfallback is purged by macOS, so even a session where the propagation workscan lose the flag later without the user touching anything.
--disable-review-gatehas the mirror-image problem: it can clear a flag in one rootwhile an armed one persists in the other.
Suggested fixes
Roughly in order of cost:
stateFile(and the resolved root) to thesetup --jsonpayload and to the human-readable output. A one-line change that makes every split-root
bug in this class self-diagnosing.
CLAUDE_PLUGIN_DATAis unset while writingconfig, emit a
nextStepswarning rather than silently using$TMPDIR— configurationis not throwaway state, unlike job files.
stopReviewGateis small,long-lived, per-workspace config;
$TMPDIRis the wrong medium for it regardless ofwhich root wins. A stable location (e.g. under
~/.codex/) keyed by workspace hashremoves the divergence for config even if job state stays where it is.
Stophook check the fallback root when the primary hasno state file, and migrate it — also fixes the SessionEnd miss in SessionEnd cannot find the broker when CLAUDE_PLUGIN_DATA differs between spawn and teardown — same cwd, same hash, different state root #636.
Happy to send a PR for (1) and (2) if that direction is useful.
🤖 Generated with Claude Code