[ci] code review: add Codex review job triggered by codex-review label#593
Conversation
Keep .claude/commands/review-pr.md + .claude/agents/*.md as the single source of truth for AI review: scripts/ci/gen_codex_review.py converts each agent markdown into a Codex subagent TOML (.codex/agents/<name>.toml, read-only sandbox) and emits the codex exec prompt. Reword the inline- comment instruction in review-pr.md to be tool-neutral so the same file drives both Claude (mcp__-prefixed tool names) and Codex. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Lbq87BJ7AiDFqs7srcP6v
Mirror of the claude-review flow on the same runner: label-gated pull_request_target job, trusted base-branch checkout of .claude/ and the converter, generated .codex/agents/ + trusted CLAUDE.md as AGENTS.md in the PR workspace, then headless `codex exec` (workspace-write sandbox with network for gh) reusing the bun inline-comment MCP server via -c overrides; env_vars forwards job env to the server by name only. Auth is stored ChatGPT OAuth from one-time `codex login --device-auth` (setup documented in the header). Concurrency groups now include the label name so codex and claude runs on one PR don't cancel each other. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Lbq87BJ7AiDFqs7srcP6v
| --repo ${{ github.repository }} \ | ||
| --pr-number ${{ github.event.pull_request.number }} \ | ||
| --prompt-out "${{ runner.temp }}/codex-prompt.md" | ||
| cp trusted-claude/CLAUDE.md pr-code/AGENTS.md |
There was a problem hiding this comment.
Blocker: CLAUDE.md does not exist in this repo, so this job fails on every run.
The base branch root (origin/master) contains only .claude and README.md — there is no CLAUDE.md anywhere in the tree, and this PR doesn't add one. Since GitHub Actions run: steps execute under bash -eo pipefail, this cp exits 1 ("No such file or directory") and aborts the Use trusted agent files step before RunCodexReview is ever reached. The manual verification (converter run against real .claude/) can't catch this because the cp is a separate shell step.
Options: add a real CLAUDE.md at repo root (cone-mode sparse-checkout already pulls top-level files), or have gen_codex_review.py emit AGENTS.md too, or cp ... || true if it's optional.
Note this also contradicts the header comment (lines 12-13) "Review instructions live only in .claude/" — that's true today, but this line installs an extra top-level instruction file from a nonexistent CLAUDE.md.
| # from trusted base-branch files, dropping whatever the PR author shipped. | ||
| - name: Use trusted agent files | ||
| run: | | ||
| rm -rf pr-code/.claude pr-code/.codex pr-code/AGENTS.md |
There was a problem hiding this comment.
Security: the trusted-files defense only strips root-level agent files. This removes pr-code/.claude, pr-code/.codex, and the root pr-code/AGENTS.md, but Codex discovers AGENTS.md hierarchically — a nested file like tzrec/.../AGENTS.md in the PR tree is still read as trusted developer instructions when the reviewer touches that subtree, bypassing the GHSA-f9x3-9rgg-92p7 regeneration this step is meant to enforce. Since the main codex exec runs --sandbox workspace-write with network_access=true (and workspace-write still permits filesystem-wide reads), an injected instruction could read the runner's persistent OAuth store (~/.codex/auth.json, ~/.claude/.credentials.json, per the setup notes above) and exfiltrate it.
Cheap hardening: strip tree-wide before regenerating, e.g.
find pr-code -name AGENTS.md -delete
find pr-code -type d \( -name .codex -o -name .claude \) -prune -exec rm -rf {} +(delete-then-recreate to keep the existing nesting protection).
| agent_md (Path): path to a .claude/agents/*.md file. | ||
| agents_dir (Path): output .codex/agents directory. | ||
| """ | ||
| fields, body = split_front_matter(agent_md.read_text()) |
There was a problem hiding this comment.
Minor: the agent bodies contain non-ASCII (e.g. the em-dash — in security-code-reviewer.md / performance-reviewer.md), so read_text()/write_text() here and at lines 63/83/85 should pass encoding="utf-8" — otherwise they inherit the platform-default locale and can raise UnicodeDecodeError on a non-UTF-8 runner. Also agents_dir.mkdir(parents=True) at line 78 will raise FileExistsError on a local rerun; exist_ok=True makes it idempotent (the CI rm -rf currently masks this).
Code review summaryNice, well-documented change — the trusted-file design is sound: converter + Must fix before this can run
Worth addressing
Lower priority (not blocking)
The |
Verified end-to-end against a real fork PR (5 subagents spawned from the
generated .codex/agents, inline + summary comments posted). Fixes found:
- Drop `cp trusted-claude/CLAUDE.md pr-code/AGENTS.md`: CLAUDE.md is not
tracked, so the base-branch checkout never contains it (the CI failure).
AGENTS.md injection is instead blocked wholesale via project_doc_max_bytes=0.
- default_tools_approval_mode="approve" for the MCP server: exec mode
auto-cancels MCP tool calls that would need approval ("user cancelled"),
which silently downgraded inline comments to a top-level fallback.
- shell_environment_policy.ignore_default_excludes=true: codex strips
*TOKEN* env vars from model-run commands by default, leaving gh
unauthenticated in the sandbox.
- --ignore-user-config + --ephemeral + HOME shim (CODEX_HOME kept real for
stored OAuth): without these, the runner user's personal config.toml,
global AGENTS.md, and ~/.agents skills leak into CI reviews and can
reroute the whole review (observed live).
- model_reasoning_effort=medium: default effort with no user config is none.
- Commit the trusted-file swap inside pr-code and fetch changes via
`gh pr diff` per the prompt: a fetch-depth-1 workspace shows the swap as
deletions and `git show` shows the whole tree as added, both misleading.
- Also drop PR-authored .agents/ (skill injection) and keep reviews static
(no test runs; generated protos are absent in the review checkout anyway).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Lbq87BJ7AiDFqs7srcP6v
|
Follow-up bd6def9 after a full end-to-end run of the codex-review job against a real fork PR (the first live run failed at the trusted-file step): the setup step no longer references untracked Verified end-to-end on the review runner: 5 subagents spawned from the generated |
… model Explicit utf-8 on all converter file I/O (agent bodies contain non-ASCII; platform-default locale could raise UnicodeDecodeError on a C-locale runner) and mkdir(exist_ok=True) so local reruns are idempotent. Document the label-gated trust model and the read-side residual of the workspace-write sandbox in the workflow header; the nested-AGENTS.md injection vector from the review is already closed wholesale by project_doc_max_bytes=0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Lbq87BJ7AiDFqs7srcP6v
|
Review triage (65a4a19): Fixed
Addressed differently
Not taken
|
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Lbq87BJ7AiDFqs7srcP6v
The codex job keeps project_doc_max_bytes=0 (guards against user-global and PR-authored AGENTS.md at any depth), so the canonical guide from alibaba#594 reaches codex deterministically via the generated prompt instead of the file mechanism. Guarded like the claude job's trusted restore: older base branches without AGENTS.md just get no guide section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Lbq87BJ7AiDFqs7srcP6v
…jobs
The two jobs had drifted: claude stripped {.claude, CLAUDE.md,
CLAUDE.local.md, AGENTS.md} at every depth, codex stripped a different
name set at root only and was alone in committing the swap. Both now run
scripts/ci/use_trusted_agent_files.sh from the trusted base checkout: one
strip union at every depth (adds .codex/.agents ambient-instruction dirs
to the claude job too), one trusted restore of .claude + root docs, one
workspace commit. The codex job passes its generated .codex/agents as an
overlay, staged in runner.temp so the strip cannot eat it.
Verified against a clone with planted nested AGENTS.md/CLAUDE.md/
CLAUDE.local.md/.agents-skill/.codex files: all removed, trusted state
restored, git status clean after the setup commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Lbq87BJ7AiDFqs7srcP6v
Flip the sandbox to its no-network default and allowlist exactly gh pr view/diff/comment through scripts/ci/codex_review.rules, installed per-run into an ephemeral CODEX_HOME (stored auth copied in, refreshes persisted back). Injected instructions can read but no longer exfiltrate: the only network path for model commands is an auditable PR comment, matching the claude job's --allowedTools boundary. MCP inline comments are unaffected (MCP servers run outside the sandbox). Proven live on the runner: allowlisted gh escapes the sandbox with network+token (exit 0), curl is DNS-blocked in the sandbox, and compound smuggling (gh pr view && curl) fails closed — the whole compound stays sandboxed unless every part matches an allow rule. Project-layer .codex/rules did not load even with a trust_level override, hence the user-layer install. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Lbq87BJ7AiDFqs7srcP6v
Drop project_doc_max_bytes=0 and the converter's --agents-md prompt embedding: both guarded vectors are now closed by construction — use_trusted_agent_files.sh strips PR-authored AGENTS.md at every depth and restores the trusted root copy, and the per-run CODEX_HOME contains no user-global AGENTS.md. Every AGENTS.md codex can see is trusted, so it loads the guide through the native project-doc mechanism, same as the claude job. Verified: codex quotes the guide verbatim with shell use forbidden, proving auto-load under the final flag set. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Lbq87BJ7AiDFqs7srcP6v
| git -C "$pr" add -A | ||
| git -C "$pr" -c user.name=ci -c user.email=ci@localhost \ | ||
| commit -q -m "ci: trusted review setup" |
There was a problem hiding this comment.
Bug: this aborts the claude-review job for any PR that doesn't modify instruction files. When the trusted-file swap is a no-op — the PR touches none of .claude/.codex/.agents/CLAUDE.md/AGENTS.md, and base==head for those — git add -A stages nothing and git commit exits 1 (nothing to commit, working tree clean). Under set -euo pipefail that fails the step and the whole job. The codex-review job is masked because its overlay always adds new .codex/agents/*.toml; and this PR itself is masked because it edits .claude/commands/review-pr.md, so the regression won't surface when testing on this PR. Guard the commit:
| git -C "$pr" add -A | |
| git -C "$pr" -c user.name=ci -c user.email=ci@localhost \ | |
| commit -q -m "ci: trusted review setup" | |
| git -C "$pr" add -A | |
| if ! git -C "$pr" diff --cached --quiet; then | |
| git -C "$pr" -c user.name=ci -c user.email=ci@localhost \ | |
| commit -q -m "ci: trusted review setup" | |
| fi |
| --ignore-user-config \ | ||
| --ephemeral \ | ||
| --sandbox workspace-write \ | ||
| -c shell_environment_policy.ignore_default_excludes=true \ |
There was a problem hiding this comment.
ignore_default_excludes=true disables Codex's built-in stripping of *TOKEN*/*KEY*/*SECRET* env vars from model-run commands, so GITHUB_TOKEN/GH_TOKEN become visible to every sandboxed command, not just the allowlisted gh calls. Because the gh pr comment rule in codex_review.rules constrains only the first three argv tokens and leaves --body free, a reviewer prompt-injected via the PR diff it must read can printenv GH_TOKEN in-sandbox and then gh pr comment … --body <token> to exfiltrate it.
This is at parity with the already-accepted claude-job exfil model, but it turns off a protection Codex gives for free. If you want Codex tighter than that baseline: authenticate gh out-of-band (echo "$GH_TOKEN" | gh auth login --with-token in the trusted step, then unset GH_TOKEN GITHUB_TOKEN) so the default exclusion can stay on — that removes the trivial printenv path (the token stays file-readable via hosts.yml, a smaller surface). Not a blocker; flagging since exfil-resistance is the whole point of this workflow.
| """ | ||
| fields, body = split_front_matter(agent_md.read_text(encoding="utf-8")) | ||
| name = fields.get("name", agent_md.stem) | ||
| # json.dumps escaping is valid for TOML basic strings. |
There was a problem hiding this comment.
Minor: this slightly overstates the guarantee. With default ensure_ascii=True, json.dumps encodes non-BMP characters as UTF-16 surrogate-pair escapes (e.g. 😀 → "😀"), and lone surrogates aren't valid Unicode scalar values in TOML basic strings — tomllib/Codex would reject the file. Harmless today since these agent files are ASCII-only per repo policy, but it would break silently if a name/description/body ever gained an emoji or astral char. Worth a "(ASCII inputs)" caveat, or a multi-line TOML literal.
Review summarySolid, carefully-reasoned PR — the trusted-file swap, sandbox + Should fix
Consider (non-blocking)
|
For PRs that touch no instruction files the swap leaves the tree identical, git commit exits 1 with nothing to commit, and set -e killed the claude-review job (the codex job was masked by its always-new overlay). Commit only when the staged diff is non-empty. Reproduced against a clean clone (exit 1), verified no-op now passes and a real diff still produces the setup commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Lbq87BJ7AiDFqs7srcP6v
json.dumps with default ensure_ascii=True turns astral chars into UTF-16 surrogate escapes, which TOML basic strings reject; latent while the agent files are ASCII-only. ensure_ascii=False emits raw UTF-8, which TOML accepts. Verified with an emoji/CJK agent file round-tripped through tomllib. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Lbq87BJ7AiDFqs7srcP6v
shell_environment_policy turned out to be inert in codex 0.142.4 — the prior ignore_default_excludes=true was a no-op, and neither explicit exclude lists nor inherit="core" strip anything (verified live: model commands printed the token under all three). So remove the token at the source: codex runs under env -u GITHUB_TOKEN -u GH_TOKEN, gh authenticates from a stored login in the shim HOME (gh auth login --with-token in the trusted step), and the MCP inline-comment server gets its env from a generated config.toml in the per-run CODEX_HOME — which also replaces the -c override forest and --ignore-user-config (the per-run config IS the only config). Model-run commands now cannot read the token at all, tighter than the claude job where allowlisted Bash still sees it. Verified live: gh pr view OK via stored login, GH_TOKEN/GITHUB_TOKEN unset in model commands, inline comment posted through the config-file MCP wiring. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Lbq87BJ7AiDFqs7srcP6v
|
Review triage, round 2 (cbcb275..c6c2fda) — all three findings were true issues: No-op Token exposure via json.dumps non-BMP surrogate escapes (consider) — true; fixed properly in 1bad992 with One CI-only residual to watch on first post-merge run: |
…base.sha pull_request_target takes the workflow definition from the base branch head, but pull_request.base.sha is frozen when the PR last synchronized — so any newly added trusted asset (here use_trusted_agent_files.sh, introduced after PR #20's base.sha) is missing from the trusted checkout and the job dies with exit 127. github.sha IS the base-branch head the workflow file came from, so definition and trusted assets are pinned to the same commit atomically. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Lbq87BJ7AiDFqs7srcP6v
…spawn In a fresh CODEX_HOME with no model configured, spawn_agent fails with "could not resolve the child model for service tier validation" and the review silently degrades to a single-agent pass (the session itself resolves a default model; only child resolution does not). Reproduced deterministically and verified: with model set, SpawnAgent returns an agent id. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Lbq87BJ7AiDFqs7srcP6v
…rdcoded Copy the top-level model* keys (model, model_context_window, model_reasoning_effort) from the runner's real ~/.codex/config.toml into the per-run config, failing loud when model= is unset since subagent spawn requires it; the workflow no longer pins a model name. Verified: extraction picks exactly the top-level keys (table-scoped model keys ignored, missing-model guard trips), assembled config parses, and a spawned subagent completes under the copied settings. Also tighten the workflow comments to single lines. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Lbq87BJ7AiDFqs7srcP6v
Summary
Adds an OpenAI Codex PR-review path to
code_review.yml, alongside the existing Claude flow: a collaborator adds thecodex-reviewlabel and the same self-hosted runner runs a headlesscodex execreview, posting inline comments through the existing bun MCP server and a summary viagh pr comment.Single source of truth for review content. No second set of review markdowns:
scripts/ci/gen_codex_review.pyconverts the trusted base-branch.claude/agents/*.mdinto Codex subagent TOMLs (.codex/agents/<name>.toml,sandbox_mode = "read-only"mirroring the Claude agents' read-only tools) and turns.claude/commands/review-pr.mdinto the exec prompt at runtime. The one shared wording change makes the inline-comment instruction tool-neutral so both CLIs resolve their own MCP tool naming.Design notes
.claude/+ converter are checked out frombase.sha; the PR's.claude/,.codex/, andAGENTS.mdare dropped, and trustedCLAUDE.mdis installed asAGENTS.md(Codex auto-reads it — supplies project context and blocks a PR-authored injection file).workspace-writewithsandbox_workspace_write.network_access=true(gh needs network), neverdanger-full-access, so runner home / stored credentials stay unreadable to the model.-coverrides;env_varsforwardsGITHUB_TOKEN/REPO_OWNER/REPO_NAME/PR_NUMBERto the server by name only — no secret values in argv or on-disk config.codex login --device-authon the runner (mirrorsclaude /login); noOPENAI_API_KEYsecret.Known limitations / follow-ups
pull_request_targetruns the base-branch workflow definition, so this job can only be exercised after merge: do the one-time runner setup in the yml header (install Codex CLI,codex login --device-auth), then add thecodex-reviewlabel to a test PR. Requires a recent Codex CLI (subagents +mcp_servers.*.env_vars).codex-reviewlabel must exist in the repo before first use.Verification
.claude/: 5 agent TOMLs generated, all parse withtomllib, prompt assembles with REPO/PR header.pre-commitpasses on all changed files.🤖 Generated with Claude Code
https://claude.ai/code/session_012Lbq87BJ7AiDFqs7srcP6v