Skip to content

fix: persist review gate config outside transient state - #731

Open
fscfede-beep wants to merge 2 commits into
openai:mainfrom
fscfede-beep:fix/durable-review-gate-config-684
Open

fscfede-beep wants to merge 2 commits into
openai:mainfrom
fscfede-beep:fix/durable-review-gate-config-684

Conversation

@fscfede-beep

Copy link
Copy Markdown

Summary

Fixes #684.

  • move durable workspace config to CODEX_HOME/plugin-cc/config/<workspace>.json
  • keep job state under the existing CLAUDE_PLUGIN_DATA / temp roots
  • make getConfig() prefer the durable config while retaining the existing state config as a legacy fallback
  • mirror setConfig() into the current legacy state for compatibility
  • use the same workspace slug/hash for durable config and transient state

This removes CLAUDE_PLUGIN_DATA from the authority boundary for stopReviewGate, so Setup and Stop can run with different plugin-data environments without silently disagreeing about whether the gate is enabled.

Validation

Windows 11 / Node 26.3.1:

  • test-first reproduction on main: enable under plugin-data root A, read under root B -> false (fail-open)
  • after the patch the same A→B read stays true, and disabling under B is observed under A
  • end-to-end integration: Setup enables the gate under root A; Stop runs under root B with the same CODEX_HOME and correctly returns a blocking review decision
  • targeted Stop/setup integration: 3 passed, 0 failed
  • state regression: 4 passed, 0 failed
  • node --check plugins/codex/scripts/lib/state.mjs
  • git diff --check

@fscfede-beep
fscfede-beep requested a review from a team September 4, 2026 18:05

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e123511d2e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/codex/scripts/lib/state.mjs

@sylvesterkaczmarek sylvesterkaczmarek left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving stopReviewGate out of transient plugin state fixes the authority boundary: Setup and Stop may legitimately run with different CLAUDE_PLUGIN_DATA, but the workspace policy must remain the same. Reusing one canonical workspace key for both paths avoids a second identity scheme, and preferring durable config while mirroring legacy state gives a reasonable compatibility transition. The cross-root integration test pins the actual fail-open bug rather than only the storage helper.

@sylvesterkaczmarek

Copy link
Copy Markdown

Thanks — giving the fixtures their own CODEX_HOME is the right cleanup. It keeps the cross-root regression isolated from developer state without weakening the durable review-gate behavior being tested.

Edo771977 pushed a commit to Edo771977/codex-plugin-cc that referenced this pull request Sep 17, 2026
Upstream PR openai#731 (fscfede-beep), two conflicts resolved.

The review-gate flag lived in the workspace state file under
CLAUDE_PLUGIN_DATA, so a different plugin data root (or a cleared state dir)
silently reverted the gate to off while /codex:setup still reported it as
enabled. It now lives in a durable per-workspace file under CODEX_HOME, with
the state copy kept in sync as a cache.

Conflicts: both in tests/state.test.mjs, both unions, and the second one cut
through the fork's last test again — its closing `});` is restored in the
resolution.

One change beyond the PR: writeDurableConfig() created the file with
fs.writeFileSync and a plain mkdirSync, while every other artifact this
module writes goes through ensurePrivateDir()/writeJsonFileAtomic(). It now
does too, so the new config file is 0600 like the rest and a torn write
cannot silently disable the gate on the next read.

Verified: node --check on state.mjs; tests/state.test.mjs,
tests/runtime.test.mjs and tests/commands.test.mjs 134/134.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
Edo771977 pushed a commit to Edo771977/codex-plugin-cc that referenced this pull request Sep 17, 2026
Adds openai#731, openai#737, openai#747 and openai#763 to "Differences From Upstream", and notes in
Requirements that Node no longer has to be on the system PATH now that the
hooks go through scripts/run-node.sh (including CODEX_COMPANION_NODE for
pinning one).

Verified: full npm test 254/254.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
@Edo771977

Copy link
Copy Markdown

Picked this up on a fork — the durable config it introduces is the one artifact in state.mjs that is written unprotected:

function writeDurableConfig(cwd, config) {
  const configFile = resolveConfigFile(cwd);
  fs.mkdirSync(path.dirname(configFile), { recursive: true });
  const nextConfig = { ...defaultState().config, ...(config ?? {}) };
  fs.writeFileSync(configFile, `${JSON.stringify(nextConfig, null, 2)}\n`, "utf8");
  return nextConfig;
}

Everything else the module creates goes through ensurePrivateDir() (0700) and writeJsonFileAtomic() (0600, write-then-rename), and there is a test asserting exactly that for the state dir, the state file, job files and log files. The new file under CODEX_HOME lands with the ambient umask instead, usually 0644.
Two consequences:
It is world-readable on a shared machine, unlike every other file the plugin writes. Only the review-gate flag lives there today, but resolveConfigFile() is the obvious home for anything else that has to survive a CLAUDE_PLUGIN_DATA change.
fs.writeFileSync is not atomic. readDurableConfig() swallows a parse error and returns null, so a write interrupted midway (a crash, a full disk) makes the next read fall back to the state copy — silently turning the review gate off rather than failing loudly. Since the point of this PR is that the flag should survive a state-dir change, the fallback is precisely what should not be reachable.
Both helpers are already imported in that file, so the fix is a two-line swap:

 function writeDurableConfig(cwd, config) {
   const configFile = resolveConfigFile(cwd);
-  fs.mkdirSync(path.dirname(configFile), { recursive: true });
+  ensurePrivateDir(path.dirname(configFile));
   const nextConfig = { ...defaultState().config, ...(config ?? {}) };
-  fs.writeFileSync(configFile, `${JSON.stringify(nextConfig, null, 2)}\n`, "utf8");
+  writeJsonFileAtomic(configFile, nextConfig);
   return nextConfig;
 }

The PR's tests pass unchanged with it.

@sylvesterkaczmarek sylvesterkaczmarek left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rechecked current a81f52f9. writeDurableConfig() still writes the persistent review-gate policy directly with writeFileSync, so an interrupted write can truncate/corrupt the only durable copy, and the file mode is left to the caller umask. Since this file contains persistent trust-root/review policy, please write via a same-directory temporary file and atomic rename, enforce a restrictive mode (for example 0600), and add coverage for permissions plus preservation of the previous config when a replacement write fails.

Edo771977 pushed a commit to Edo771977/codex-plugin-cc that referenced this pull request Sep 17, 2026
…eplacement

The review on openai#731 asks for exactly this coverage, and
this fork already carries the hardening it requests (ensurePrivateDir plus
writeJsonFileAtomic instead of mkdirSync plus writeFileSync), so the tests
belong here too.

- "the durable review-gate config is private" pins 0600 on the file and 0700
  on its directory. It discriminates: reverting writeDurableConfig() to the
  plain writeFileSync the PR shipped with fails it.
- "a durable config write that fails mid-write leaves the previous config
  intact" fails the replacement from inside writeJsonFileAtomic(), after it
  has created its temporary file, using a value whose toJSON() throws. It
  asserts the previous config still reads back enabled and that no temporary
  file is left beside it. This one does not discriminate against the naive
  implementation (which throws before touching the file either way); what it
  guards is the regression class where a future rewrite truncates the target
  before serializing, and the cleanup path of the atomic write.

Verified: full npm test 302/302; tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

/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

3 participants