Skip to content

/codex:setup --enable-review-gate reports success but writes the flag to a state root the Stop hook never reads — the gate silently fails open #684

Description

@juanmaorta

Summary

/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:

function handleSessionStart(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:

  1. 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.
  2. SessionStart killed 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 ordinary
    session, not just a mid-session install.

hooks/hooks.json gives Stop a 900s timeout but SessionStart only 5s, so path 2 is
not hypothetical.

Repro

Fresh session, plugin installed mid-session, on macOS, plugin 1.0.6, codex-cli 0.149.1:

$ env | grep -E 'CLAUDE_PLUGIN_DATA|CLAUDE_ENV_FILE|CODEX_COMPANION'
(nothing)

$ node .../scripts/codex-companion.mjs setup --json --enable-review-gate
  "reviewGateEnabled": true,
  "actionsTaken": ["Enabled the stop-time review gate for /Users/me/Projects/alfred-front."],
  "nextSteps": []

Where the flag actually landed:

$ 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:

$ CLAUDE_PLUGIN_DATA=~/.claude/plugins/data/codex-openai-codex \
    node .../scripts/codex-companion.mjs setup --json --enable-review-gate
  "actionsTaken": ["Enabled the stop-time review gate for /Users/me/Projects/alfred-front."]

$ cat ~/.claude/plugins/data/codex-openai-codex/state/alfred-front-790ce51531842df0/state.json
{ "version": 1, "config": { "stopReviewGate": true }, "jobs": [] }

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:

  1. 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.
  2. 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.
  3. 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.
  4. Reconcile on read. Have the Stop hook check the fallback root when the primary has
    no 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions