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

Open

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

Activity

  1. zengzheqing commented on Aug 30, 2026

    @zengzheqing

    One coupling that may matter for triage: the CLAUDE_PLUGIN_DATA-unset case is not arbitrary — the plugin's own SessionStart hook is what normally prevents it.

    scripts/session-lifecycle-hook.mjs, in handleSessionStart:

    appendEnvVar(PLUGIN_DATA_ENV, process.env[PLUGIN_DATA_ENV]);

    That appends export CLAUDE_PLUGIN_DATA=... to $CLAUDE_ENV_FILE, so from the second session onward the Bash tool inherits the same root the Stop hook uses, and setup and the hook agree.

    This narrows the reproduction: the split is reliably reachable in the session where the plugin is installed — or more generally whenever SessionStart has not run with the plugin present. /reload-plugins does not re-fire SessionStart, so reloading is not enough.

    Measured on macOS, plugin 1.0.6, Claude Code 2.1.251:

    • install plugin mid-session, then node -e 'console.log(process.env.CLAUDE_PLUGIN_DATA ?? "UNSET")' → UNSET; setup --enable-review-gate writes $TMPDIR/codex-companion/<slug>-<hash>/state.json
    • restart, same command → ~/.claude/plugins/data/codex-openai-codex; setup and the hook then resolve the same file

    The coupling worth noting: #562 asks for that same export to be removed, as a per-plugin scoping leak. If it is removed without also giving setup an explicit path to the plugin data root, this issue stops being an install-window bug and becomes permanent behaviour — setup would always write to $TMPDIR and the gate would always be inert. The two probably need to be fixed together, or setup needs to fail loudly when CLAUDE_PLUGIN_DATA is unset rather than falling back silently.

  2. stephan-nb commented on Sep 8, 2026

    @stephan-nb

    A third user-facing consequence of the split root described in #636, in the opposite direction to this issue: here the hook read the real state root and blocked, while the slash commands read the $TMPDIR fallback and reported an empty, gate-disabled state.

    So /codex:status said Review gate: disabled and No jobs recorded yet while the file the hook was reading held stopReviewGate: true and 50 job records, and /codex:cancel <id> answered No job found for an ID the hook had printed seconds earlier. Thirteen cancel attempts, none of which could ever have worked. The two commands meant to diagnose a blocked session are unusable exactly when they are needed, and they report a state that contradicts reality with no indication a fallback was taken.

    Detail:

    Summary

    resolveStateDir() in scripts/lib/state.mjs falls back to $TMPDIR/codex-companion when the
    plugin data directory cannot be resolved. That fallback is taken in the slash-command context but
    not in the hook context, so /codex:status and /codex:cancel operate on an empty state file
    while the Stop hook operates on the real one. Every user-facing diagnostic then contradicts the
    hook that is blocking the session.

    Environment

    • openai-codex Claude Code plugin 1.0.6
    • Codex CLI 0.153.0, Claude Code 2.1.236, Node v26.8.1
    • macOS 15.7.7 (Darwin 24.6.0)
    • Plugin installed from the marketplace, codex@openai-codex, install path
      ~/.claude/plugins/cache/openai-codex/codex/1.0.6

    What was observed

    With the gate blocking session end and naming task-mtrlaazr-gomnha:

    /codex:status reported:

    Session runtime: direct startup
    Review gate: disabled
    No jobs recorded yet
    

    While ~/.claude/plugins/data/codex-openai-codex/state/<workspace>/state.json held
    config.stopReviewGate: true and 50 job records, five of them non-terminal.

    /codex:cancel task-mtrlaazr-gomnha answered:

    No job found for "task-mtrlaazr-gomnha"
    

    for an ID the hook had printed seconds earlier. Thirteen cancel attempts were made; none of them
    could ever have worked, because the command was reading a different file.

    Steps to reproduce

    1. Enable the stop-time review gate and let a job reach a non-terminal state that blocks stop
      (see the unreconciled-job-records report).
    2. Run /codex:status and compare with
      jq '{config, jobs: (.jobs | length)}' ~/.claude/plugins/data/codex-openai-codex/state/<workspace>/state.json.

    The two disagree on both the gate flag and the job count.

    Expected

    Hook and command contexts resolve the same state directory for the same workspace, so
    /codex:status reflects what the gate reads and /codex:cancel acts on the jobs the gate is
    blocking on.

    Actual

    The command context silently uses the $TMPDIR/codex-companion fallback. It reports an empty,
    gate-disabled state that looks healthy, which actively misleads: the operator concludes there is
    no job and no gate, while the gate blocks every stop attempt.

    Impact

    The two commands intended to diagnose and clear a blocked session are unusable exactly when they
    are needed, and worse than unusable — they report a state that contradicts reality with no
    indication that a fallback was taken.

    Suggested fix

    Two parts, independently useful:

    1. Resolve the state directory identically in both contexts, or pass the resolved path from the
      hook into the command surface rather than re-deriving it.
    2. Never fall back silently. If resolveStateDir() cannot resolve the plugin data directory, say
      so in the command output, including which path it settled on. A visible
      "using fallback state directory <path>" line would have shortened this from thirteen failed
      cancels to one.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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