Skip to content

Manual multi-model AI code review (agentic swarm + native Codex/Claude) - #671

Merged
MauroToscano merged 86 commits into
mainfrom
feat/manual-ai-review-tiers
Jun 18, 2026
Merged

Manual multi-model AI code review (agentic swarm + native Codex/Claude)#671
MauroToscano merged 86 commits into
mainfrom
feat/manual-ai-review-tiers

Conversation

@MauroToscano

@MauroToscano MauroToscano commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a single, manually triggered multi-model AI code-review system for PRs.
Comment /ai-review (or apply the ai-review label) and several models review
the PR; a structured, de-duplicated report is posted as a PR comment, alongside
independent native Codex and Claude reviews.

This replaces the previous always-on per-model workflows
(pr_review_{claude,codex,kimi}.yaml), which ran automatically on every PR —
review is now opt-in.

How it works

Swarm → dedup → verifier → report. Open-weight finder models review the PR
in parallel (review_lanes); their findings are merged, de-duplicated (a
path/text heuristic plus a conservative single-shot LLM dedup), and a verifier
model confirms/rejects each (verifier_lanes). A structured report is posted
with per-finding provenance (which models found it) and per-model
cost/token metrics.

Agentic via opencode. Each lane runs as an opencode agent in a read-only
sandbox
(review-ro: read/grep/glob only; bash/edit/write/patch/webfetch
denied, external_directory: deny), so it explores the repo (unchanged files,
call sites, tests) rather than judging a stuffed diff. Findings/verdicts are
returned through custom submit_findings / submit_verifications tools — a tool
call converges far more reliably than asking an agent to hand-write final JSON.

Plus native reviews. The same trigger also runs Codex (GPT) and
Claude (opus) in the vendors' own agentic harnesses (the org's reusable
pr_review_{codex,claude}.yml actions). They post their own independent
comments, outside the structured report.

Trigger (manual only)

/ai-review comment, or the ai-review label, from an OWNER / MEMBER /
COLLABORATOR. Never runs automatically on PR open.

Models

Structured swarm (open-weight; OpenRouter + direct MiniMax): GLM, Kimi,
Nemotron, MiniMax
MiniMax-M3 deduper → DeepSeek verifier. Native:
Codex (GPT) and Claude (opus). One generic prompt (general.md) for
every reviewer; lanes/verify.md for the verifier.

Security (pwn-request hardening)

The lane jobs hold provider secrets and execute repo code, so:

  • Fork PRs are refusedprepare checks head repo == base repo, so only
    same-repo branches (which require write access) reach the secret-bearing,
    code-executing steps.
  • Lane ids are validated ([A-Za-z0-9._-]) and passed via env, closing a
    matrix → shell injection.
  • Read-only agent sandbox (verified it cannot read /proc/self/environ to leak
    keys); harden-runner egress audit; malformed model JSON recovered via
    json-repair; OpenRouter calls retry transient failures.

The native Codex/Claude actions enforce their own protection — they only run
from a workflow that matches the repository's default branch.

Known gaps / notes

  • Soundness is a deliberate gap. The generic prompt targets general
    correctness and security, not soundness bugs (under-constrained AIRs,
    Fiat-Shamir/commitment/witness-soundness mistakes). A buzzword list does not
    help a model find those; real soundness review needs dedicated tooling and is
    deferred.
  • Native Claude and the /ai-review comment trigger only activate once this
    merges to main.
    claude-code-action refuses to run from an unmerged
    workflow, and issue_comment always uses the default-branch workflow. The
    label trigger works pre-merge for the swarm + Codex.
  • Tracked follow-up — pin actions to commit SHAs. Secret-bearing jobs use
    mutable action tags (step-security/harden-runner@v2,
    yetanotherco/actions/...@v1.0.0, actions/checkout@v4, …); a force-moved
    tag would run attacker code with the job's secrets. The egress allowlist now
    limits exfil even then, but pinning to full commit SHAs is the standard fix —
    deferred so it can be applied as a repo-wide convention.
  • A fuller hardening (run trusted runner code from the base ref, check out the
    PR only as read-only review data) is an optional future step; fork-blocking
    already lands on the same effective trust boundary.

Key files

  • .github/scripts/ai_review.py — orchestrator (prepare / context / agentic-lane
    / candidates / final-report)
  • .github/workflows/pr_ai_review.yaml — trigger, swarm matrix jobs, native
    review calls
  • .github/ai-review/matrix.json — finders / verifier / deduper
  • .github/ai-review/prompts/general.md, lanes/verify.md
  • .opencode/agent/review-ro.md, .opencode/tools/submit_*.ts
  • docs/ai-review.md — full design + lessons learned
  • .github/scripts/test_ai_review.py — offline unit tests (CI: pr_ai_review_tests.yaml)

@MauroToscano

Copy link
Copy Markdown
Contributor Author

/ai-review standard

@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown

AI Review (standard)

PR #671 · 18 changed files

Findings

Status Sev Location Finding Found by
confirmed high .github/scripts/ai_review.py:461 Agentic lanes receive the full CI environment including unused provider secrets kimi-correctness
openrouter/moonshotai/kimi-k2.7-code
uncertain high .github/scripts/ai_review.py:954 OpenRouter model names include provider prefix causing API failures nemotron-correctness
openrouter/nvidia/nemotron-3-ultra-550b-a55b
kimi-correctness
openrouter/moonshotai/kimi-k2.7-code
minimax-high
minimax/MiniMax-M3
confirmed high .github/scripts/ai_review.py:1493 parse_name_status vulnerable to IndexError on malformed git output nemotron-correctness
openrouter/nvidia/nemotron-3-ultra-550b-a55b
confirmed high .github/scripts/ai_review.py:1518 git_file_text returns empty string for zero budget, treated as valid content nemotron-correctness
openrouter/nvidia/nemotron-3-ultra-550b-a55b
confirmed high .github/workflows/pr_ai_review.yaml:117 Agentic review/verifier lanes check out default branch, not the PR, for /ai-review comment triggers glm-correctness
openrouter/z-ai/glm-5.2
confirmed high .github/workflows/pr_ai_review.yaml:142 CI installs and executes remote opencode installer without checksum verification kimi-correctness
openrouter/moonshotai/kimi-k2.7-code
confirmed medium .github/scripts/ai_review.py:215 cmd_context budget allocation divides remaining by 2 per file incorrectly nemotron-correctness
openrouter/nvidia/nemotron-3-ultra-550b-a55b
confirmed medium .github/scripts/ai_review.py:413 Agent timeouts can drop findings that were already written by the model minimax-high
minimax/MiniMax-M3
confirmed medium .github/scripts/ai_review.py:1578 JSON extraction from model responses is O(n²) over response length kimi-correctness
openrouter/moonshotai/kimi-k2.7-code
confirmed medium .github/scripts/ai_review.py:1668 post_or_update_comment crashes if the GitHub comment-listing response body is empty minimax-high
minimax/MiniMax-M3
confirmed medium docs/ai-review.md:155 Documentation matrix and configuration are inconsistent kimi-correctness
openrouter/moonshotai/kimi-k2.7-code
minimax-high
minimax/MiniMax-M3
confirmed low .github/scripts/ai_review.py:414 Timeout error message reports args.timeout even for the continuation call which uses a smaller cont_timeout glm-correctness
openrouter/z-ai/glm-5.2
confirmed low .github/scripts/ai_review.py:1666 post_or_update_comment only searches the first 100 issue comments kimi-correctness
openrouter/moonshotai/kimi-k2.7-code
confirmed low .github/scripts/ai_review.py:1684 write_github_outputs delimiter can collide with payload content minimax-high
minimax/MiniMax-M3
confirmed low .github/scripts/ai_review.py:1724 clean_path strips workspace prefix even for unrelated absolute paths kimi-correctness
openrouter/moonshotai/kimi-k2.7-code
minimax-high
minimax/MiniMax-M3
confirmed low .github/scripts/test_ai_review.py:1 Test suite is not wired into CI minimax-high
minimax/MiniMax-M3
confirmed low .github/workflows/pr_ai_review.yaml:267 PR-controlled TypeScript files are copied into opencode's user-level tool directory minimax-high
minimax/MiniMax-M3

Status column reflects the verdict from the verifier: deepseek-verifier (openrouter/deepseek/deepseek-v4-pro).

AI-001: Agentic lanes receive the full CI environment including unused provider secrets
  • Status: confirmed
  • Severity: high
  • Location: .github/scripts/ai_review.py:461
  • Found by: kimi-correctness:openrouter/moonshotai/kimi-k2.7-code
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

run_opencode_agent passes env=dict(os.environ) to the opencode subprocess, so the LLM process inherits every secret in the job environment, including ANTHROPIC_API_KEY, OPENAI_API_KEY, MOONSHOT_API_KEY/KIMI_API_KEY, and MINIMAX_API_KEY. The workflow comment claims least privilege ('read-only repo access and the OpenRouter key only'), but the other provider keys are exported to the agent and could be exfiltrated by prompt injection or a compromised model.

Evidence

Workflow exports OPENROUTER_API_KEY plus ANTHROPIC_API_KEY, OPENAI_API_KEY, MOONSHOT_API_KEY, MINIMAX_API_KEY at lines 151–157 and 295–302. ai_review.py line 461 copies the entire environment, and the review-ro agent only disables bash/edit/write; it still runs code under the invoking user and any network-capable tool can leak the environment.

Suggested fix

Build an explicit allow-list environment for the subprocess containing only the key the lane actually needs (OPENROUTER_API_KEY for OpenRouter lanes, etc.) plus required non-sensitive variables such as PATH, HOME, AI_REVIEW_OUT.

AI-002: OpenRouter model names include provider prefix causing API failures
  • Status: uncertain
  • Severity: high
  • Location: .github/scripts/ai_review.py:954
  • Found by: nemotron-correctness:openrouter/nvidia/nemotron-3-ultra-550b-a55b, kimi-correctness:openrouter/moonshotai/kimi-k2.7-code, minimax-high:minimax/MiniMax-M3
  • Verified by: -
  • Rejected by: -

Claim

The matrix.json specifies model names with 'openrouter/' prefix (e.g., 'openrouter/z-ai/glm-5.2'), but openrouter_payload() sends this directly to the OpenRouter API which expects model slugs without the provider prefix (e.g., 'z-ai/glm-5.2').

Evidence

In matrix.json lines 6, 12, 18, 24, 30, 38, 52, 58, 64, 70, 76, 82, 89: all model values have 'openrouter/' prefix. In openrouter_payload() line 956, lane["model"] is used directly without stripping the prefix. However, llm_dedup_candidates() line 1081 correctly uses .removeprefix("openrouter/").

Suggested fix

Strip the 'openrouter/' prefix in openrouter_payload() before sending to API: model = lane["model"].removeprefix("openrouter/")

AI-003: parse_name_status vulnerable to IndexError on malformed git output
  • Status: confirmed
  • Severity: high
  • Location: .github/scripts/ai_review.py:1493
  • Found by: nemotron-correctness:openrouter/nvidia/nemotron-3-ultra-550b-a55b
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

parse_name_status assumes git diff --name-status output for rename/copy (R/C) has at least 3 tab-separated parts (status, old_path, new_path) without bounds checking.

Evidence

Lines 1500-1501: accesses parts[1] and parts[2] directly. If git output is malformed or a line has fewer tabs, this will raise IndexError.

Suggested fix

Add bounds checking: if len(parts) >= 3 for R/C status, else treat as regular status with parts[-1] as path.

AI-004: git_file_text returns empty string for zero budget, treated as valid content
  • Status: confirmed
  • Severity: high
  • Location: .github/scripts/ai_review.py:1518
  • Found by: nemotron-correctness:openrouter/nvidia/nemotron-3-ultra-550b-a55b
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

When max_chars <= 0, git_file_text returns ("", True) - an empty string with truncated=True. The caller in cmd_context checks 'if head_content is not None:' which treats empty string as valid content, causing empty file content to be included in context.

Evidence

Line 1518-1519: returns ("", True) for max_chars <= 0. Line 222-224: caller checks 'if head_content is not None:' and subtracts len(head_content) from remaining. Empty string has len 0, so remaining doesn't decrease, but empty content is still added to file_context.

Suggested fix

Return (None, False) for max_chars <= 0 to signal no content available, consistent with the subprocess.CalledProcessError case.

AI-005: Agentic review/verifier lanes check out default branch, not the PR, for /ai-review comment triggers
  • Status: confirmed
  • Severity: high
  • Location: .github/workflows/pr_ai_review.yaml:117
  • Found by: glm-correctness:openrouter/z-ai/glm-5.2
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

The openrouter-review and openrouter-verify jobs use actions/checkout@v4 with no ref: and no git fetch of the PR head. For the primary trigger (issue_comment from /ai-review standard|critical), actions/checkout checks out the repository default branch (GITHUB_SHA on issue_comment is the default branch tip), not the PR merge. The opencode agent then runs with cwd at the workspace root and explores default-branch files, while the diff in its prompt is the real PR diff. For changed files the agent therefore reads the BASE/default-branch version, contradicting the diff and producing or missing findings against the wrong code.

Evidence

Lines 116-117 (review) and 254-255 (verify): uses: actions/checkout@v4 with no ref:. The subsequent Install sandbox agent and Run agentic review lane steps run python3 .github/scripts/ai_review.py agentic-lane --repo . against that workspace. There is no step fetching refs/pull/<n>/head into the workspace (the context job fetches PR refs only into the separate subject checkout, used solely to build context.json). ai_review.py's run_opencode_agent runs opencode with cwd=str(repo) (the workspace root) and build_agentic_review_message passes only context.get('diff'), so the agent's only source of head-version content is the diff; all file exploration resolves against the default-branch checkout.

Suggested fix

Check out the PR head/merge explicitly in both agentic jobs, e.g. with: ref: refs/pull/${{ needs.prepare.outputs.pr_number }}/merge (and fetch-depth: 0 if needed), mirroring the context job's subject checkout. Alternatively fetch refs/pull/<n>/head and check it out. Verify the checkout matches the head SHA from prepare outputs.

AI-006: CI installs and executes remote opencode installer without checksum verification
  • Status: confirmed
  • Severity: high
  • Location: .github/workflows/pr_ai_review.yaml:142
  • Found by: kimi-correctness:openrouter/moonshotai/kimi-k2.7-code
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

The workflow pipes curl -fsSL https://opencode.ai/install | bash -s -- --version 1.16.2 directly into bash. There is no integrity check (hash, signature, or pinned GitHub release artifact), so a compromise of the opencode install server or a man-in-the-middle on the runner network can execute arbitrary code with workflow privileges.

Evidence

Lines 137–146 and the identical block at 281–290 run the installer from the network. The preceding harden-runner step uses egress-policy: audit, not block, so the curl is permitted and its content is trusted implicitly.

Suggested fix

Download a pinned release archive from a trusted source (e.g. GitHub releases) and verify its SHA-256 checksum before installing. Alternatively vendor the installer in the repository.

AI-008: cmd_context budget allocation divides remaining by 2 per file incorrectly
  • Status: confirmed
  • Severity: medium
  • Location: .github/scripts/ai_review.py:215
  • Found by: nemotron-correctness:openrouter/nvidia/nemotron-3-ultra-550b-a55b
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

The remaining budget is divided by 2 for each file's head content, then the (already reduced) remaining is divided by 2 again for base content. This causes unequal allocation and can starve base content for later files.

Evidence

Lines 222-227: head_content gets remaining//2, then remaining decreases by len(head_content). Then base_content gets max(0, remaining//2). For first file with large head, base gets very little. The per-file budget should be calculated upfront.

Suggested fix

Calculate per_file_budget = max_file_chars // max(1, len(non_deleted_files)) upfront, then allocate per_file_budget // 2 to head and base for each file.

AI-009: Agent timeouts can drop findings that were already written by the model
  • Status: confirmed
  • Severity: medium
  • Location: .github/scripts/ai_review.py:413
  • Found by: minimax-high:minimax/MiniMax-M3
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

When run_opencode_agent raises subprocess.TimeoutExpired, the handler at line 413–414 sets status="error" and writes the lane result WITHOUT first reading the submit_findings/submit_verifications file. If the model successfully called the submit tool (which writes $AI_REVIEW_OUT) just before the opencode process was killed by the timeout, the written findings/verifications are silently discarded — only the error status reaches the artifact, and candidates.json/final-issues.json never sees them.

Evidence

In cmd_agentic_lane (lines 340–418), read_submission(submit_path, ...) is only called inside the try block after run_opencode_agent returns. The except subprocess.TimeoutExpired branch jumps directly to writing base_result with the error message and never inspects submit_path. Because submit_findings.ts uses synchronous writeFileSync, the file is durable by the time the opencode process is killed; the fix is to read the submission in the except branch before reporting the error.

Suggested fix

In cmd_agentic_lane, move the read_submission(submit_path, ...) (and subsequent lane_items / extract_json fallback) into a finally block, or duplicate the read+assign into the TimeoutExpired branch, so that submissions written before the kill are surfaced rather than lost.

AI-013: JSON extraction from model responses is O(n²) over response length
  • Status: confirmed
  • Severity: medium
  • Location: .github/scripts/ai_review.py:1578
  • Found by: kimi-correctness:openrouter/moonshotai/kimi-k2.7-code
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

When the model response is not fenced in triple backticks, extract_json iterates over every character and calls decoder.raw_decode from each [ or { position. Each raw_decode call scans forward until it fails or succeeds, leading to quadratic time on long model outputs. Long reasoning-model outputs could cause lane timeouts or high CPU spikes.

Evidence

Lines 1578–1586: for idx, char in enumerate(text): if char not in '[{': continue; parsed, _ = decoder.raw_decode(text[idx:]). There is no early termination or skip past the consumed span. extract_json can be called on responses up to the model's output token limit.

Suggested fix

For non-fenced text, scan for the first valid JSON value and use its end index to continue scanning, or use a single pass with a streaming decoder.

AI-015: post_or_update_comment crashes if the GitHub comment-listing response body is empty
  • Status: confirmed
  • Severity: medium
  • Location: .github/scripts/ai_review.py:1668
  • Found by: minimax-high:minimax/MiniMax-M3
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

post_or_update_comment calls reversed(comments) where comments comes from github_json(...). github_json returns None when the HTTP body is empty (line 1659: json.loads(raw) if raw else None). Passing None to reversed raises TypeError and aborts the report job — failing the workflow even when the comment was already posted previously or the PR has no existing comments.

Evidence

github_json line 1659 explicitly returns None on empty body; post_or_update_comment does not guard against it. The endpoint normally returns a JSON array, but transient GitHub issues, GHE differences, or pagination responses can legitimately produce an empty body and would crash the script with no fallback.

Suggested fix

Coerce comments to [] when github_json returns None, e.g. comments = github_json(...) or [] before the for comment in reversed(comments) loop.

Reviewer Lanes

Lane Model Prompt Status Findings
glm-correctness openrouter/z-ai/glm-5.2 correctness success 3
kimi-correctness openrouter/moonshotai/kimi-k2.7-code correctness success 8
minimax-high minimax/MiniMax-M3 correctness success 9
minimax-max minimax/MiniMax-M3 correctness success 0
nemotron-correctness openrouter/nvidia/nemotron-3-ultra-550b-a55b correctness success 9

Verification Lanes

Lane Model Status Confirmed Rejected Uncertain
deepseek-verifier openrouter/deepseek/deepseek-v4-pro success 16 7 1
Discarded candidates (7) — rejected by the verifier
  • Agentic lane prompts omit file_context, leaving agents no head-version file contents besides the diff (.github/scripts/ai_review.py:0, found by glm-correctness:openrouter/z-ai/glm-5.2, nemotron-correctness:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — build_agentic_review_message (lines 632-642) and build_agentic_verification_message (lines 645-670) omit file_context by design — the agentic lanes use opencode's read/grep/glob tools to explore the repo themselves. That's the whole point of agentic lanes vs non-agentic ones. The non-agentic format_review_prompt includes file_context because those lanes can't explore. The issue is not the omission per se, but the checkout being the wrong branch (AI-005). The file_context omission itself is intentional and correct.
  • extract_json prefers LAST valid JSON which may not be model's final answer (.github/scripts/ai_review.py:1603, found by nemotron-correctness:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — choose_json_candidate at line 1614 deliberately returns the LAST dict containing the required key. The code comment at lines 1608-1610 explains the rationale: 'Models narrate, quote code arrays, or emit a draft before the final answer; the earlier blob is not the result.' This is an explicit design choice with documented reasoning. The claim that 'the last one might be a malformed intermediate' is speculative — the code acknowledges the tradeoff but chooses the last as more likely to be the final answer for the models being used. This is not a bug, it's a pragmatic heuristic.
  • No network egress block for LLM agent jobs despite harden-runner usage (.github/workflows/pr_ai_review.yaml:113, found by kimi-correctness:openrouter/moonshotai/kimi-k2.7-code) — egress-policy: audit at line 113 is the correct setting for a job that requires outbound network access for its core function (calling the OpenRouter API). Blocking egress would break the workflow entirely. The security measures are at the agent level: review-ro.md denies bash, write, webfetch, websearch, and enforces external_directory: deny (line 22). The agent also has no write permissions. The claim conflates network egress for legitimate API calls with a missing security boundary — the harden-runner step is for auditing, not blocking, when network access is required.
  • cmd_agentic_lane always returns 0, so the workflow's lane-error fallback is unreachable (.github/scripts/ai_review.py:417, found by minimax-high:minimax/MiniMax-M3) — cmd_agentic_lane catches all exceptions (lines 413-416) and returns 0 (line 418). The workflow's lane-error step at lines 172-179 checks shell exit code $status -ne 0, which catches shell-level failures (e.g., the outer 'timeout 2200s' command killing Python with exit 124), not Python-level exceptions. Python errors are intentionally handled internally — they produce a JSON result with status:error that gets uploaded as an artifact. The lane-error step is a safety net for unhandled crashes (segfault, OOM kill), which Python's except Exception wouldn't catch. This is a deliberate design, not a bug.
  • Session ID may be None for opencode continuation (.github/scripts/ai_review.py:470, found by nemotron-correctness:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — Line 470: meta['session_id'] = opencode_session_id(out) or session_id can be None. However, at line 360, the continuation is guarded by 'if not sub['submitted'] and meta.get('session_id'):' — this condition checks for truthiness of session_id. If session_id is None (falsy), the continuation branch is NOT entered. So None session_id cannot cause a broken continuation call. The guard is correct.
  • SSE comment stripping only handles colon-prefixed lines (.github/scripts/ai_review.py:915, found by nemotron-correctness:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — strip_sse_comments at line 916 strips lines starting with ':' which are SSE comment lines (the standard SSE keep-alive mechanism). OpenRouter's non-streaming chat/completions API returns a complete JSON response, not an SSE event stream. The ':' lines in the response body are keep-alive noise, not SSE fields like 'event:' or 'retry:'. The function correctly handles the OpenRouter response format. The claim about other SSE field types is irrelevant for this API endpoint.
  • Verifier prompt includes found_by field which may bias verification (.github/scripts/ai_review.py:996, found by nemotron-correctness:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — format_verification_prompt at line 1005 includes found_by in compact_candidates. However, the workflow only uses agentic-lane, which calls build_agentic_verification_message (lines 645-670) — that function does NOT include found_by in the compact candidates list (compare lines 648-657 which only include issue_id, severity, title, file, line, claim, evidence). So for the actual workflow path, found_by is not exposed to verifiers. Even for the non-agentic path, including found_by as context for verification is a deliberate design choice, not a bug.

Raw lane outputs, candidates, final issues, and model metrics are uploaded as workflow artifacts.

response_format={type: json_object} was added in the hardening commit and
turned out to be the cause of empty model responses: it routes to
structured-output providers and makes reasoning models (minimax-m3, glm,
mimo) reason until truncated at max_tokens without ever emitting content
(observed reasoning_tokens=33989, completion_tokens=32000, findings=0).

Make response_format opt-in per lane and rely on the existing extract_json
parser, matching the request shape that works locally. Also capture
finish_reason in the lane result so truncation is visible in the report.
Without forced JSON mode the model occasionally emits invalid JSON (e.g.
unescaped quotes when a finding quotes code), which strict json.loads
rejects all-or-nothing, dropping a whole review to zero findings.

Add an optional json-repair fallback in extract_json: try strict parsing
first, and only on failure fall back to repair, flagging it as a parse
warning so invalid output stays visible. Install json-repair in the
review/verifier lane steps. Verified against real lane output: recovers
all 6 findings that strict parsing dropped.
@MauroToscano

Copy link
Copy Markdown
Contributor Author

Codex Code Review

Found issues:

  1. High: PR-controlled checkout runs with provider secrets
    .github/workflows/pr_ai_review.yaml and line 254 check out the default event ref, which for the pull_request labeled trigger is the PR merge ref. The jobs then copy .opencode tools from that checkout and run .github/scripts/ai_review.py while passing OPENROUTER_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY, MOONSHOT_API_KEY, and MINIMAX_API_KEY into the step at line 151 and line 295. A PR that changes .github/scripts/ai_review.py, .opencode/tools/*.ts, or the agent config can execute arbitrary code in those secret-bearing steps once the label trigger runs. Use a trusted/base checkout for runner code, prompts, matrix, agent, and tools; check out the PR only as read-only review subject data.
  2. High: PR-controlled matrix values are interpolated into shell
    The lane matrix is read from the checked-out repo in prepare and emitted as workflow matrix data, then ${{ matrix.lane.id }} is inserted directly into shell assignments at line 161 and line 305. If the label-triggered workflow runs against PR-modified matrix data, a crafted lane id containing shell syntax or command substitution can execute in the secret-bearing lane step. Load the matrix from a trusted ref and/or pass lane ids through environment variables with strict validation before using them in shell paths.

Applied these changes

  1. Refuse fork PRs — prepare now rejects any PR where head repo ≠ base repo (pr_is_from_fork). Only same-repo branches reach the secret-bearing steps, and pushing a
    same-repo branch requires write access — i.e., exactly your "org members / repo collaborators" boundary, enforced where it matters. (pull_request already withholds
    secrets from forks; this also covers the issue_comment path, which does get secrets on any PR once merged to main.)
  2. Kill the shell injection — lane ids validated to [A-Za-z0-9._-] in prepare (at the source) and passed via the LANE_ID env var instead of raw ${{ }} (at the
    sink).

A previous commit put a literal ${{ }} inside a comment in the lane run blocks.
GitHub evaluates expressions everywhere in a workflow file (including comments),
and an empty ${{ }} is invalid -> startup_failure, so no run could be created
(the label trigger silently produced nothing). Reword the comment to drop the
token.
@github-actions

Copy link
Copy Markdown

Codex Code Review

Finding

High - .github/workflows/pr_ai_review.yaml:25 / .github/workflows/pr_ai_review.yaml:43: the pull_request label path does not reject fork PRs before checkout, and actions/checkout without ref on a pull_request event checks out the PR merge commit. That means runner/.github/scripts/ai_review.py prepare at line 53 is PR-controlled code, so a fork PR can replace the very script that is supposed to enforce pr_is_from_fork() and emit should_run=true with arbitrary matrix outputs. Move the fork/same-repo gate into the workflow if for the pull_request arm, or checkout trusted base/default-branch runner code for prepare before evaluating PR-controlled code.

Verification

Ran python3 .github/scripts/test_ai_review.py: 39 tests passed.

Codex (correctly) flagged that pr_is_from_fork() runs inside ai_review.py, which
on the pull_request (label) arm is checked out FROM the PR merge commit — so a
fork PR could replace prepare and bypass the gate, emitting should_run=true with
arbitrary matrix outputs. The check was in the wrong (untrusted) layer for that
arm.

Fix: gate the pull_request arm in the workflow `if` using the trusted event
context (head.repo.full_name == base.repo.full_name), evaluated before any
checkout, so a fork PR's prepare job never starts. The issue_comment arm runs
prepare from the default branch (trusted), so its pr_is_from_fork check is
trustworthy there; the Python check stays as that arm's gate + defense-in-depth.
Docs/comments updated to explain the layering.
An independent opus security review confirmed the pwn-request hole is closed but
flagged hardening worth doing:

- F1: the trusted same-repo gate was enforced in only one place (prepare.if);
  downstream jobs that hold provider secrets / the write token and run
  PR-controlled ai_review.py were protected only transitively. Replicate the
  same-repo if-gate on openrouter-review, candidates, openrouter-verify, and
  final-report so it is no longer a single point of failure.
- F2: model-supplied finding text (claim/evidence/suggested_fix/title) is now
  HTML-escaped before going into the posted comment, preventing markup/link
  injection into the bot comment (md_escape routes through html_escape).
- F4: submit_findings/submit_verifications refuse to write unless AI_REVIEW_OUT
  is the expected lane-*.submit.json basename.

Skipped F6 (SHA-pinning the first-party org reusable workflows) — it mainly adds
update-management friction for marginal benefit when the same org owns both repos.
Tests pass (incl. existing fork/lane-id guards).
- Replace the stale 'Multiple Prompts Versus One Prompt' section (and its
  per-model multi-prompt 'Initial policy' table listing models not in the matrix)
  with a short note: one generic general.md for all reviewers.
- Add-a-model playbook: 'tier' -> review_lanes/verifier_lanes; 'run the tier' ->
  'run the review'.
- Update the example provenance lane ids to current ones (nemotron/glm/
  deepseek-verifier instead of minimax-correctness/glm-standard/qwen-standard).
- Document the operational caveat: native Claude + the /ai-review comment trigger
  only activate after merge to the default branch (claude-code-action's
  default-branch guard; issue_comment uses the default-branch workflow).
…acing surface

There is one flow, so the standard/critical naming was vestigial where users see it:
- Docs: present a single `/ai-review` command and `ai-review` label; the old
  `/ai-review standard|critical` forms and `ai-review-standard/-critical` labels
  still work (tolerant parser + allowlist) but are no longer advertised as a choice.
- Report title: `## AI Review (critical)` -> `## AI Review` (the marker stays
  `<!-- ai-review:critical -->`, invisible, so existing comments still update).
- Created the canonical `ai-review` label.

Parser, label allowlist, and the internal matrix key (`critical`) are unchanged —
back-compat preserved, just not surfaced as two options.
#5: the opencode installer was fetched unpinned (curl|bash) and run in a step
holding all provider secrets. Now fetch it to a file, verify a pinned sha256
(fail-closed if the script changes), then run it.

#4: harden-runner egress-policy audit only logged egress. Switch the lane jobs
to 'block' with an allowlist harvested from a real run's harden-runner audit
(GitHub Actions infra, opencode install/binary/catalog at opencode.ai + *.github
usercontent + models.dev, pip + npm, and the model APIs openrouter.ai +
api.minimax.io). A compromised dep/installer can no longer exfiltrate to an
arbitrary host. Trade-off: adding a new direct provider requires adding its host
to allowed-endpoints, or that lane is blocked.

Validating with a run next.
@github-actions

Copy link
Copy Markdown

Codex Code Review

No substantive issues found in the PR diff.

I reviewed the new AI review workflow, Python orchestration, opencode tools, prompts, docs, and removed workflows. The fork/same-repo secret boundary is explicitly handled, lane IDs are constrained before shell/path use, model text is mostly escaped before comments, and the runner avoids obvious shell injection in the lane paths.

Verification run:
python3 .github/scripts/test_ai_review.py passed: 39 tests.
python3 -m py_compile .github/scripts/ai_review.py .github/scripts/test_ai_review.py passed.

#2, #3)

The single-shot review/verify path is unreachable — the workflow only runs
agentic-lane (+ prepare/context/candidates/lane-error/report). Remove it:
- run-lane/verify-lane subparsers + dispatch, cmd_run_lane/cmd_verify_lane,
  run_review_lane/run_verifier_lane (-161 lines). openrouter_chat, lane_base_result,
  and infer_tier_from_lane stay (the deduper + agentic path + lane-error use them).
- Drop the 5 tests that covered the dead path (they were inflating apparent
  coverage of code the workflow no longer runs). 34 tests remain, all live paths.
- general.md now flags dead/unreachable code under simplicity, so future PRs get
  called out for it.

Not adding agentic-path unit tests: cmd_agentic_lane shells out to opencode and is
impractical to test in isolation; its parsing/salvage helpers (read_submission,
extract_json, dedup) are already covered.
@github-actions

Copy link
Copy Markdown

Codex Code Review

Findings:

  • High - unpinned json-repair is later imported in secret-bearing steps. pr_ai_review.yaml installs the latest PyPI json-repair, and ai_review.py imports it when the later lane step runs with provider API keys in the environment. A compromised or hijacked package release could execute import-time code and exfiltrate those secrets through the allowed model endpoints. Pin the exact version with hashes, vendor the small repair dependency, or avoid importing optional third-party code in the secret-bearing lane process. Same issue in the verifier install at pr_ai_review.yaml.

  • Low - model-supplied locations are not escaped in detail sections. ai_review.py and ai_review.py interpolate format_location(issue) directly inside Markdown backticks, while file ultimately comes from model/tool output. A backtick or newline in the reported path can break out of the code span and inject Markdown into the posted PR comment. Use md_escape(format_location(issue)) or otherwise sanitize the location everywhere it is rendered.

Tests run: python3 .github/scripts/test_ai_review.py passed.

@MauroToscano

Copy link
Copy Markdown
Contributor Author

Codex Code Review

Findings:

  • High - unpinned json-repair is later imported in secret-bearing steps. pr_ai_review.yaml installs the latest PyPI json-repair, and ai_review.py imports it when the later lane step runs with provider API keys in the environment. A compromised or hijacked package release could execute import-time code and exfiltrate those secrets through the allowed model endpoints. Pin the exact version with hashes, vendor the small repair dependency, or avoid importing optional third-party code in the secret-bearing lane process. Same issue in the verifier install at pr_ai_review.yaml.
  • Low - model-supplied locations are not escaped in detail sections. ai_review.py and ai_review.py interpolate format_location(issue) directly inside Markdown backticks, while file ultimately comes from model/tool output. A backtick or newline in the reported path can break out of the code span and inject Markdown into the posted PR comment. Use md_escape(format_location(issue)) or otherwise sanitize the location everywhere it is rendered.

Tests run: python3 .github/scripts/test_ai_review.py passed.

Fixed

High (reviewer): json-repair was pip-installed unpinned, then imported in the
lane step that holds the provider keys — a hijacked release could run import-time
code with the secrets. Pin it to ==0.61.0 with sha256 hashes via a requirements
file + --require-hashes (pip only honors --hash there, not on the CLI; verified
locally incl. a wrong-hash negative test). Same in both lane installs.

Low (reviewer): format_location(issue) was interpolated raw inside markdown
code-spans in two detail sections, and file comes from model/tool output — a
backtick or newline could break out and inject markdown. Add format_location_code
(strips backticks/newlines; HTML is already literal inside a code span) and use it
at both sites. The table cell already used md_escape.
@github-actions

Copy link
Copy Markdown

Codex Code Review

Findings

  • Medium - Lane subprocess failures are reported as successful lanes: ai_review.py calls run_opencode_agent, which records proc.returncode in metadata at ai_review.py, but cmd_agentic_lane never turns a non-zero return code into status: error. If opencode fails due to auth, provider outage, install/runtime error, etc., the lane can end as status: success with zero findings plus a parse warning. That masks reviewer failures in the final report and contradicts the comment at ai_review.py. Treat non-zero return codes as lane errors unless a valid submit_* result was already written.

  • Low - Dead single-shot prompt formatting path remains: ai_review.py defines format_review_prompt, format_verification_prompt, format_changed_files, and format_file_context, but the new opencode flow uses build_agentic_review_message / build_agentic_verification_message instead, and rg only finds these formatters referenced by each other. Remove the unused old path to avoid maintaining two prompt construction mechanisms.

Verification

Ran python3 -m unittest discover -s .github/scripts -p 'test_ai_review.py': 34 tests passed.

The validation run's minimax lane (and the new dead-code prompt) caught leftovers
from the earlier single-shot removal: format_review_prompt / format_verification_prompt
were only called by the deleted run_*_lane, and format_changed_files / format_file_context
only by those — all now dead. Removed the cluster (-81 lines) plus the now-unused
textwrap import. Full unused-function scan confirms no remaining orphans; 34 tests pass.
@github-actions

Copy link
Copy Markdown

Codex Code Review

Findings

  • Low - ai_review.py: llm_dedup_candidates() references DEDUP_SYSTEM, but that constant is never defined. Because this is inside a broad except Exception, the configured LLM dedup path silently no-ops whenever it would run, leaving the deduper effectively dead despite the matrix/docs enabling it. Define the prompt constant or remove the dead path, and add a test that exercises llm_dedup_candidates() with an API key and multiple issues.

I didn’t find safety/security issues in the changed automation beyond that. The added unit tests pass locally with python3 .github/scripts/test_ai_review.py.

…reviewer)

cmd_agentic_lane left status=success when opencode failed (auth/outage/402/crash)
but no findings were submitted — masking reviewer failures as 'success with 0
findings' (exactly what the OpenRouter 402 lanes did last run). Add opencode_failed()
and, when nothing was submitted, mark the lane status=error if opencode reported a
failure — either a non-zero exit OR an 'error' event (a 402 exits 0 but emits an
error event, so the return-code check alone misses it). Applied to both the review
and verify not-submitted branches; a valid submit_* result still keeps success.

(The dead single-shot formatters the same review flagged were already removed in
b7fb33a.) +1 test; 35 pass.
…riage

Triaging ALL lane findings across the experiment runs surfaced a real regression I
introduced: the dead-code commit b7fb33a swept away the module-level DEDUP_SYSTEM
constant (it sat between format_file_context and the next def, so the 'delete to
next def' boundary took it). llm_dedup_candidates references it inside a
try/except Exception: return candidates, so every run NameError'd and silently
returned candidates unchanged — the LLM dedup has been a no-op (this is the early
'61 -> 61, merged nothing'). Restored the constant; added a regression test that
fails if it's missing or the dedup no-ops.

Also from the same triage:
- Remove unused 'import urllib.parse' (dead import).
- Remove MOONSHOT_API_KEY from the lane env — kimi goes via OpenRouter, the /kimi
  command was retired, so it was dead config (and an unstripped key).
- Add a concurrency group (cancel-in-progress) so rapid re-triggers can't race and
  post duplicate report comments.

36 tests pass. Lesson: name-anchored 'delete to next def' is unsafe for module
constants between functions — audited both dead-code commits; DEDUP_SYSTEM was the
only collateral.
@github-actions

Copy link
Copy Markdown

Codex Code Review

High: secret-bearing lane jobs execute PR-controlled code.

In .github/workflows/pr_ai_review.yaml:146-215 and :323-399, the jobs check out refs/pull/.../merge, copy .opencode tools from that checkout, then run .github/scripts/ai_review.py from the same PR checkout while provider secrets are in the environment. The fork gate prevents external fork PRs, but any same-repo branch can change ai_review.py or .opencode/tools/*.ts and execute arbitrary code with OPENROUTER_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY, and MINIMAX_API_KEY.

If same-repo write access is not intentionally equivalent to provider-secret access, this is a secret exfiltration path. Use a trusted runner checkout for orchestration code and tools in the secret-bearing jobs, and keep the PR checkout only as review data, e.g. run runner/.github/scripts/ai_review.py, copy runner/.opencode/..., and pass --repo . pointing at the PR checkout.

Verification: python3 .github/scripts/test_ai_review.py passes; git diff --check passes.

cmd_context fetched base content using the new path, which doesn't exist at the
base ref for a rename/copy — so renamed files silently lost their base-side
context in the review. Use old_path for the base fetch when present.
- id-token (OIDC) scoped to only the native Claude job (it's the only one that
  needs it); removed from workflow-wide permissions so the internal jobs don't
  carry it. Codex job gets contents/PR/issues write only.
- post_or_update_comment now paginates all comment pages, so it finds the existing
  report on busy PRs (>100 comments) instead of posting a duplicate.
- apply_dedup_clusters keeps the richest evidence/suggested_fix across merged
  duplicates instead of always discarding the others'.
- clean_path tolerates a trailing slash in GITHUB_WORKSPACE.

Deliberately left (design/graceful/rare, per review): scoped_provider_env unknown-
provider fallback, cmd_context per-file budget (graceful + agent explores),
extract_json fallback heuristic, parse_name_status git-quoting, submit-unset.
After collapsing standard/critical into a single flow, 'critical' lingered as
internal naming (matrix key, tier output, the tier=='critical' gate, job names,
the comment marker). There are no tiers, so remove the concept entirely:
- matrix.json flattened to {review_lanes, verifier_lanes, deduper} (no tier key).
- prepare reads the flat matrix; parse_review_trigger returns the PR number (or
  None); parse_tier_command/label -> is_review_command/is_review_label (bool).
- Drop tier from lane_base_result/build_candidates/build_final_issues, remove
  infer_tier_from_lane, and the comment marker is a fixed REVIEW_COMMENT_MARKER
  ('<!-- ai-review -->'), not tier-keyed.
- Native jobs renamed codex-critical-review/claude-critical-review ->
  codex-review/claude-review and no longer gated on tier (they run on the one
  flow); dropped the tier workflow output + the tier in the artifact name.
- The native-reviews note in the report now always shows.

Note: the comment marker changed, so the next run posts a fresh report comment
on #671 once (the old marker won't match); harmless. 36 tests pass.
…privilege

- Report shows a loud 'all N reviewers failed' banner when review lanes ran but
  none succeeded, instead of implying a clean PR (high finding).
- Remove the now-dead moonshotai/ PROVIDER_KEYS mapping (MOONSHOT_API_KEY is gone
  and no lane uses that prefix).
- Least-privilege: default workflow permissions are now read-only; only final-report
  (posts the comment) and the native review jobs request write/id-token. The
  internal prepare/context/candidates jobs no longer carry issues/PR write.
From the local opus reviewers' findings:
- name_status now uses --find-renames --find-copies, matching the diff body, so
  rename/copy detection is consistent (the copy branch in parse_name_status was
  otherwise unreachable, and a heavy-edit rename could mismatch the diff).
- The context job now carries the same same-repo if-gate as the other downstream
  jobs (defense-in-depth consistency; it checks out and runs PR code).

Reviewers found no critical/high regressions, no dangling tier refs, and verified
the supply-chain pins (incl. json-repair hashes vs PyPI). Remaining notes: pin the
native reusable workflows to a SHA (already tracked in the PR), and openrouter_chat
is now deduper-only (harmless defensive generality).
@github-actions

Copy link
Copy Markdown

Codex Code Review

Findings

  • Medium: Partial reviewer outages can be reported as a clean structured review. The candidates job runs with always(), ignores lane artifact download failures, and builds candidates from whatever JSON happens to exist. render_report() only warns when it sees review lane results and none succeeded, so missing matrix legs are invisible. If one lane uploads a success with zero findings while other lanes fail before upload, the final comment can say “No non-rejected structured findings were reported” without surfacing that most reviewers never ran. Fix by requiring needs.openrouter-review.result == 'success', or pass expected lane IDs into report generation and synthesize error rows for missing lane artifacts. .github/workflows/pr_ai_review.yaml, .github/scripts/ai_review.py

  • Low: The YAML trigger and Python trigger parser disagree on accepted labels. The workflow only starts for exact lowercase ai-review, ai-review-standard, or ai-review-critical, but is_review_label() accepts any case-insensitive ai-review* label and the tests assert AI-Review works. In practice those labels will never reach the script because the workflow if blocks them first. Make the YAML predicate match the parser, or narrow the parser/tests/docs to the exact labels. .github/workflows/pr_ai_review.yaml, .github/scripts/ai_review.py

Verification

Ran python3 .github/scripts/test_ai_review.py: 36 tests passed. pytest is not installed here, but the added CI uses unittest directly.

From the in-flight run's findings (glm + Codex):
- docs: drop the removed <tier> artifact-path segment; the matrix is flat now, not
  'keyed critical for backward compatibility' (both stale after the de-tier).
- workflow label gate: use startsWith(label, 'ai-review') instead of an exact-list
  contains(), matching is_review_label's prefix behavior in ai_review.py (the list
  rejected ad-hoc ai-review* labels the Python parser accepts).

Noted (deferred): Codex flagged that a *partial* reviewer outage (some lane
artifacts missing) isn't banner-flagged — only a total outage is; the per-lane
Reviewer Lanes table still shows it. Fuller expected-vs-present check is a follow-up.
@github-actions

Copy link
Copy Markdown

AI Review

PR #671 · 14 changed files

Findings

Status Sev Location Finding Found by
confirmed low .github/scripts/ai_review.py extract_json ignores bare (non-fenced) JSON whenever any fenced block is present glm
openrouter/z-ai/glm-5.2
confirmed low .github/scripts/ai_review.py:282 cmd_lane_error hard-requires the context file, so a missing context download cascades into no lane result minimax
minimax/MiniMax-M3
confirmed low .github/scripts/ai_review.py:295 getattr on argparse attribute is unnecessary minimax
minimax/MiniMax-M3
confirmed low .github/scripts/ai_review.py:463 scoped_provider_env only strips the four hardcoded provider secrets kimi
openrouter/moonshotai/kimi-k2.7-code
confirmed low .github/scripts/ai_review.py:1397 Binary file detection only checks the first 4096 bytes for null kimi
openrouter/moonshotai/kimi-k2.7-code
confirmed low .github/workflows/pr_ai_review.yaml:276 Deduper JSON is interpolated into a single-quoted shell argument kimi
openrouter/moonshotai/kimi-k2.7-code
confirmed low docs/ai-review.md docs/ai-review.md artifact path references removed &lt;tier&gt; segment glm
openrouter/z-ai/glm-5.2

Status column reflects the verdict from the verifier: deepseek-verifier (openrouter/deepseek/deepseek-v4-pro).

AI-003: extract_json ignores bare (non-fenced) JSON whenever any fenced block is present
  • Status: confirmed
  • Severity: low
  • Location: .github/scripts/ai_review.py
  • Found by: glm:openrouter/z-ai/glm-5.2
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

When the model output contains at least one ```json fenced block, the bare-JSON parsing branch (the else) never runs, and the repair loop iterates `fenced or [text]` so `text` is only repaired when there are zero fences. A model that emits a malformed/wrong-key fenced draft followed by a correct bare final object (or vice-versa) will miss the valid JSON and report a parse error / zero findings.

Evidence

In extract_json: if fenced: ... else: &lt;bare JSONDecoder loop&gt;. The bare loop is mutually exclusive with fenced parsing. Then for block in fenced or [text]: repaired = repair_malformed_json(...) — when fenced is non-empty, text (which may hold the valid bare JSON) is never passed to repair_malformed_json. So a correct bare object is unreachable as soon as any fence exists.

Suggested fix

Always also scan the raw text for bare JSON objects/arrays (or add the bare candidates alongside fenced ones), and include text in the repair loop regardless of fenced. Alternatively, run the bare-JSON scan in addition to the fenced scan, then let choose_json_candidate pick the best candidate.

AI-004: cmd_lane_error hard-requires the context file, so a missing context download cascades into no lane result
  • Status: confirmed
  • Severity: low
  • Location: .github/scripts/ai_review.py:282
  • Found by: minimax:minimax/MiniMax-M3
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

cmd_lane_error reads the context.json (line 282) before writing any result. If the context download failed earlier in the lane job, the lane-error fallback itself raises FileNotFoundError, the bash script's set +e lets it continue, and the lane artifact is uploaded without a lane JSON. The candidates step then skips this lane and the "no findings" result is indistinguishable from a silent infrastructure failure.</claim>
<evidence>cmd_lane_error (lines 280-286) calls read_json(pathlib.Path(args.context)) without a guard. The lane job's "Download review context" step is a separate action and may fail (e.g., artifact retention expired, transient 5xx). When it does, the upstream cmd_agentic_lane never runs, so cmd_lane_error is what the workflow calls, and it cannot write anything because the context read raises.</evidence>
<suggested_fix>Have cmd_lane_error build the result with only the lane id/model/prompt (not the context), and skip the context read entirely — those fields come from --lane-json and the matrix, both of which are available in the lane-error step.

Evidence

Suggested fix

AI-006: getattr on argparse attribute is unnecessary
  • Status: confirmed
  • Severity: low
  • Location: .github/scripts/ai_review.py:295
  • Found by: minimax:minimax/MiniMax-M3
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

Line 295 uses getattr(args, "deduper", None) to read the --deduper argument, but argparse always sets the attribute on the Namespace (defaulting to None if no default was given). The getattr is dead defensiveness.</claim>
<evidence>Line 295: deduper = json.loads(args.deduper) if getattr(args, "deduper", None) else None — the argparse declaration on line 100 has no default, so argparse sets args.deduper to None when --deduper is omitted. getattr(args, "deduper", None) is equivalent to args.deduper.</evidence>
<suggested_fix>Replace getattr(args, "deduper", None) with args.deduper.

Evidence

Suggested fix

AI-007: scoped_provider_env only strips the four hardcoded provider secrets
  • Status: confirmed
  • Severity: low
  • Location: .github/scripts/ai_review.py:463
  • Found by: kimi:openrouter/moonshotai/kimi-k2.7-code
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

scoped_provider_env removes OPENROUTER_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY, and MINIMAX_API_KEY. If a new direct provider is added (e.g. Google/Gemini, a new minimax key, etc.) with a different environment variable, that secret is not stripped and leaks into the opencode subprocess env.

Evidence

.github/scripts/ai_review.py lines 455-474 define PROVIDER_KEYS with exactly four prefixes; unknown providers keep the full environment.

Suggested fix

Document that adding a provider requires updating PROVIDER_KEYS, or centralize provider->env-var mapping in matrix.json so scoped_provider_env can strip every non-needed secret automatically.

AI-010: Binary file detection only checks the first 4096 bytes for null
  • Status: confirmed
  • Severity: low
  • Location: .github/scripts/ai_review.py:1397
  • Found by: kimi:openrouter/moonshotai/kimi-k2.7-code
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

git_file_text checks b'\x00' in result.stdout[:4096] to decide a file is binary. A binary file with no early null byte will be partially decoded and included in the review context, wasting budget and possibly producing mojibake.

Evidence

.github/scripts/ai_review.py line 1397: if b"\x00" in result.stdout[:4096]:. This misses nulls beyond 4 KiB.

Suggested fix

Check the whole content up to the requested max_chars for null bytes, or rely on git's own binary detection (e.g. git diff --numstat output starting with '-') to skip binary files before reading them.

AI-011: Deduper JSON is interpolated into a single-quoted shell argument
  • Status: confirmed
  • Severity: low
  • Location: .github/workflows/pr_ai_review.yaml:276
  • Found by: kimi:openrouter/moonshotai/kimi-k2.7-code
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

The workflow passes the deduper JSON to the candidates step as a single shell-quoted argument. Although the current matrix JSON contains no single quotes, this pattern is fragile: any future single quote or shell metacharacter in the matrix would break the command line and could inject shell tokens.

Evidence

.github/workflows/pr_ai_review.yaml line 276: --deduper '${{ needs.prepare.outputs.deduper }}'. The output is produced by json.dumps(..., separators=(',', ':')).

Suggested fix

Write the deduper JSON to a file (or use a heredoc/env var) and have ai_review.py read it from a filename, rather than embedding it directly into the shell command.

AI-012: docs/ai-review.md artifact path references removed `<tier>` segment
  • Status: confirmed
  • Severity: low
  • Location: docs/ai-review.md
  • Found by: glm:openrouter/z-ai/glm-5.2
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

The 'Evaluation Artifacts' section lists ai-review-final-&lt;tier&gt;-&lt;pr-number&gt;/ but the tier concept was removed — the workflow uploads ai-review-final-${{ needs.prepare.outputs.pr_number }} (no tier) and build_final_issues/candidates no longer carry a tier.

Evidence

Workflow pr_ai_review.yaml final-report upload step: name: ai-review-final-${{ needs.prepare.outputs.pr_number }}. The doc's artifact tree shows ai-review-final-&lt;tier&gt;-&lt;pr-number&gt;/ with &lt;tier&gt; still present, which no longer matches any produced artifact name.

Suggested fix

Update the artifact path in docs/ai-review.md to ai-review-final-&lt;pr-number&gt;/ (drop &lt;tier&gt;).

Reviewer Lanes

Lane Model Prompt Status Findings
glm openrouter/z-ai/glm-5.2 general success 3
kimi openrouter/moonshotai/kimi-k2.7-code general success 5
minimax minimax/MiniMax-M3 general success 6
nemotron openrouter/nvidia/nemotron-3-ultra-550b-a55b general success 0

Verification Lanes

Lane Model Status Confirmed Rejected Uncertain
deepseek-verifier openrouter/deepseek/deepseek-v4-pro success 7 6 0

Native Codex and Claude reviews run separately and post their own comments. They are not included in this structured provenance report.

Discarded candidates (6) — rejected by the verifier
  • Verifier lanes receive the full PR diff but are told not to invent new findings (.github/scripts/ai_review.py:723, found by minimax:minimax/MiniMax-M3) — The verifier IS instructed to inspect cited code with read/grep/glob tools (line 719-720: 'Use your read/grep/glob tools to inspect the cited code before deciding. Do not invent new findings.'). Having the PR diff helps the verifier understand what actually changed without needing to read every file — it provides context for evaluating whether a candidate finding is relevant to the PR. This is a deliberate design choice, not a waste. The verifier also has file_context available through the context download. The finding's claim that the diff is 'never used' ignores that the verifier can cross-reference findings against the diff.
  • submit tools only validate the basename of AI_REVIEW_OUT, allowing directory traversal (.opencode/tools/submit_findings.ts:35, found by kimi:openrouter/moonshotai/kimi-k2.7-code) — AI_REVIEW_OUT is set exclusively by the orchestrator (ai_review.py line 366: submit_path = pathlib.Path(args.out).with_name(f'lane-{lane[id]}.submit.json').resolve()) using validated inputs (lane id checked against [A-Za-z0-9._-] at line 171). The agent sandbox denies bash/edit/write/webfetch, so the LLM cannot modify process.env or the filesystem to influence AI_REVIEW_OUT. The basename check is defense-in-depth that works correctly for its stated purpose: preventing the LLM from writing to unexpected filenames if AI_REVIEW_OUT were somehow influenced. No attacker-controlled path component exists to exploit. Too speculative.
  • cmd_lane_error can overwrite a valid lane result with an error record (.github/scripts/ai_review.py:285, found by minimax:minimax/MiniMax-M3) — The TimeoutExpired handler (lines 438-448) only updates base_result in memory but does NOT write it — the write happens at line 451 after the try/except. If SIGKILL prevents line 451, NO result is written at the out path, so lane-error creates the first (and only) result there. There is no 'valid prior result' to overwrite because none was written. If a valid result was written AND the process then exits non-zero (a contradiction — the function always returns 0 at line 452), that would be a different bug entirely. The workflow correctly treats any non-zero exit as an error and lane-error correctly writes an error record. The claimed 'overwrite' scenario requires the process to both successfully write AND exit non-zero, which cannot happen in this code path.
  • format_verifier_label strips closing parenthesis from generated labels (.github/scripts/ai_review.py:1119, found by kimi:openrouter/moonshotai/kimi-k2.7-code) — Line 1119: return ', '.join(v for v in verifiers if v.strip(' ()')). The call to v.strip(' ()') is used as a FILTER predicate (truthiness check), NOT as a transformation applied to v. The generator expression iterates v from verifiers, filters with if v.strip(' ()'), and the values yielded are the ORIGINAL v, not the stripped result. The test at lines 594-598 confirms this: self.assertEqual(label, 'deepseek-verifier (openrouter/deepseek/deepseek-v4-pro)') — the closing parenthesis IS preserved. The finding misreads the Python generator syntax.
  • render_report only surfaces a loud "all reviewers failed" warning when 0 of N lanes succeed (.github/scripts/ai_review.py:1144, found by minimax:minimax/MiniMax-M3) — The Reviewer Lanes table (lines 1248-1258) already shows per-lane status including error/skipped states, so partial failures ARE visible. A separate 'X of Y reviewers failed' warning would add noise to the report for a condition that's already legible from the table. The code deliberately only fires a big warning for total outage (0 of N), which is a reasonable UX choice: partial failure still produces findings from successful lanes, while total failure produces nothing. This is a design preference, not a defect.
  • docs claim deduper is 'keyed critical for backward compatibility' but matrix uses variant low (docs/ai-review.md, found by glm:openrouter/z-ai/glm-5.2, minimax:minimax/MiniMax-M3) — The docs line 114 says: 'a deduper (keyed critical for backward compatibility)'. This refers to the key name in the JSON structure — the doc is attempting to explain that in the old tiered design, the deduper was under a critical key (e.g., {'critical': {'deduper': ...}}), and the current flat JSON structure keeps compatibility by still having a deduper key. The phrasing is confusing but the claim that the 'matrix uses variant low' and 'there is no critical key anywhere' is not quite right — the doc describes the field name 'critical' in the context of backward compatibility with the prior tiered structure, not a missing feature. The matrix.json flat structure is the new design; the doc's parenthetical '(keyed critical for backward compatibility)' is explaining the naming provenance, not claiming a missing field. This is a documentation clarity issue, not a factual error.

Raw lane outputs, candidates, final issues, and model metrics are uploaded as workflow artifacts.

- Drop the unnecessary getattr(args, 'deduper', None) -> args.deduper (the
  candidates subparser always defines it). Pure cleanup.
- Pass the deduper JSON via a DEDUPER_JSON env var instead of single-quote shell
  interpolation, matching the LANE_JSON/LANE_ID pattern (defense-in-depth; the
  source is matrix.json so not exploitable, but consistent).

Left by design/risk: scoped_provider_env unknown-provider (design), extract_json
bare-JSON selection (tested fallback), cmd_lane_error context dep (edge), binary
null-scan window (heuristic). The <tier> doc path was already fixed in 2dec6f4.
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.

3 participants