@expo/code-review-cli 0.5.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,8 @@ import { prepareAuth } from "./auth.js";
3
3
  import { coordinate } from "./coordinator.js";
4
4
  import { writeRunLog } from "./log.js";
5
5
  import { filterNoise, writePatchWorkspace } from "./noise.js";
6
- import { addTokenUsage, AgentTimeoutError, assertModelsResolvable, buildOpencodeConfig, CROSS_CUTTING_AGENT, promptAndParse, startOpencode, } from "./opencode.js";
6
+ import { addTokenUsage, AgentTimeoutError, assertModelsResolvable, buildOpencodeConfig, CLAUDE_CODE_ENGINE, CROSS_CUTTING_AGENT, promptAndParse, startOpencode, } from "./opencode.js";
7
+ import { buildEngineMap, claudeTemperatureNote, claudeTokenCredential, startClaudeCode, } from "./claude-code.js";
7
8
  import { routeAgents } from "./router.js";
8
9
  import { buildCrossCuttingSystem, buildCrossCuttingTask, buildReviewerSystem, buildReviewerTask, NO_TOOLS_INSTRUCTION, } from "./prompts.js";
9
10
  import { fingerprintFinding, parseReviewerOutput } from "./schema.js";
@@ -33,25 +34,42 @@ function makeRunId() {
33
34
  * thin wrappers that supply a Source and render the result.
34
35
  */
35
36
  /**
36
- * Max concurrent reviewer calls: an explicit config value wins; otherwise 3 when a
37
- * subscription (oauth) credential is configured, else 6. One ChatGPT account
38
- * handles six parallel streams poorly — requests get parked server-side (the
39
- * stall signature seen on eas-cli#4084), and several PRs may be reviewing on the
40
- * same credential at once — so subscription runs trade a little wall-clock for a
41
- * lot of reliability. Exported for tests.
37
+ * Max concurrent reviewer calls: an explicit config value wins; otherwise 3 when the
38
+ * run leans on a subscription credential, else 6. One subscription account handles
39
+ * six parallel streams poorly — requests get parked server-side (the stall signature
40
+ * seen on eas-cli#4084), and several PRs may be reviewing on the same credential at
41
+ * once — so subscription runs trade a little wall-clock for a lot of reliability.
42
+ *
43
+ * A subscription run is any of: an oauth (ChatGPT/Codex) entry; OR the Claude Code
44
+ * engine being in use (an `anthropic/…` model) on an OAUTH credential — a subscription
45
+ * token OR the local `claude` login fallback (no forwardable token). An anthropic
46
+ * credential that classifies as an API KEY is metered per-request and does NOT force
47
+ * the cap. Exported for tests.
42
48
  */
43
- export function effectiveConcurrency(config) {
49
+ export function effectiveConcurrency(config, env = process.env) {
44
50
  if (config.chunk.concurrency) {
45
51
  return config.chunk.concurrency;
46
52
  }
47
- return config.auth.some((entry) => entry.mode === "oauth") ? 3 : 6;
53
+ if (config.auth.some((entry) => entry.mode === "oauth")) {
54
+ return 3;
55
+ }
56
+ if (buildEngineMap(config).usesClaude) {
57
+ const entry = config.auth.find((auth) => auth.provider === "anthropic");
58
+ const credential = claudeTokenCredential(entry, env);
59
+ // No forwardable token ⇒ the `claude` login (a subscription) covers the run; an
60
+ // "sk-ant-oat…" token is a subscription too. Either caps; an API key does not.
61
+ if (!credential || credential.kind === "oauth") {
62
+ return 3;
63
+ }
64
+ }
65
+ return 6;
48
66
  }
49
67
  export async function runReview(source, options) {
50
68
  const { config } = options;
51
69
  const started = Date.now();
52
70
  const runId = makeRunId();
53
71
  const progress = options.onProgress ?? (() => { });
54
- const runsRoot = path.join(config.configDir, ".runs");
72
+ const runsRoot = options.runsDir ?? path.join(config.configDir, ".runs");
55
73
  const runDir = path.join(runsRoot, runId);
56
74
  const logPath = path.join(runsRoot, "reviews.jsonl");
57
75
  // Fail fast on an invalid explicit selection before doing any work. Routing
@@ -97,18 +115,39 @@ export async function runReview(source, options) {
97
115
  });
98
116
  return output;
99
117
  }
100
- // Prepare auth BEFORE the chdir below: it doesn't depend on the working directory,
101
- // and doing it first means nothing that can throw sits between the chdir and the
102
- // guarded blocks so a prepareAuth failure can't leak the worktree or leave cwd
103
- // pointing at it.
104
- const auth = await prepareAuth(config);
105
- // Read the PR-head tree (not the current checkout) when the source can materialize
106
- // it, so the agents' surrounding-source reads and the verifier's re-reads see the
107
- // versions that match the diff. Config is already fully loaded in memory, so the
108
- // chdir doesn't affect it; run-log/patch paths are absolute; gh/git calls already
109
- // ran above. Fails soft to the current directory.
118
+ // Materialize the PR-head tree (not the current checkout) when the source can, so
119
+ // the agents' surrounding-source reads and the verifier's re-reads see the versions
120
+ // that match the diff. Config is already fully loaded in memory, so the chdir below
121
+ // doesn't affect it; run-log/patch paths are absolute; gh/git calls already ran
122
+ // above. Failure policy is MODE-DEPENDENT (see resolveReadRoot): CI fails closed —
123
+ // with a base-SHA checkout the fallback tree is pre-PR content, and silently
124
+ // reviewing/verifying that drops real findings while a local run falls back to
125
+ // the user's own checkout with a warning.
126
+ // Resolve each agent's engine BEFORE prepareAuth/readRoot, so the per-engine
127
+ // startup below can't leak the readRoot worktree or a temp auth dir holding a
128
+ // live credential (buildEngineMap only inspects config, so nothing needs cleanup
129
+ // yet at this point). Nothing throws here anymore — one run may drive BOTH the
130
+ // Claude Code CLI engine and OpenCode at once, inferred per agent from its model.
131
+ //
132
+ // Scope the engine set to the SELECTED agents so a run whose passes never touch
133
+ // Claude doesn't start (and fail on a missing CLI/token for) the Claude Code
134
+ // engine. An explicit `--agents` subset is known here; routing picks from the full
135
+ // roster later (its router needs an engine up first), so a routed/all run keeps
136
+ // the full roster and can drive either engine.
137
+ const { engineOf, modelOf, usesOpencode, usesClaude } = buildEngineMap(config, explicitAgents ?? config.agents);
110
138
  const originalCwd = process.cwd();
111
- const readRoot = (await source.prepareReadRootAsync?.()) ?? null;
139
+ const readRoot = await resolveReadRoot(source, options.mode, progress);
140
+ // Prepare auth BEFORE the chdir below: it doesn't depend on the working directory,
141
+ // and doing it after readRoot but before chdir means a prepareAuth failure can't
142
+ // leave cwd pointing at the worktree — it only has the worktree itself to release.
143
+ let auth;
144
+ try {
145
+ auth = await prepareAuth(config);
146
+ }
147
+ catch (error) {
148
+ await readRoot?.cleanup();
149
+ throw error;
150
+ }
112
151
  const restoreCwd = async () => {
113
152
  if (readRoot) {
114
153
  process.chdir(originalCwd);
@@ -119,10 +158,21 @@ export async function runReview(source, options) {
119
158
  progress("Reviewing the PR-head tree (so reads match the PR, not the checkout).");
120
159
  process.chdir(readRoot.dir);
121
160
  }
122
- progress("Starting OpenCode server…");
123
- let handle = null;
161
+ const starting = [
162
+ usesClaude ? "Claude Code engine" : null,
163
+ usesOpencode ? "OpenCode server" : null,
164
+ ]
165
+ .filter(Boolean)
166
+ .join(" + ");
167
+ progress(`Starting ${starting}…`);
168
+ // Start each engine the run actually uses. OpenCode first so a claude failure can
169
+ // close it. Two separate try blocks keep the precise per-engine error messages.
170
+ let opencodeHandle = null;
171
+ let claudeHandle = null;
124
172
  try {
125
- handle = await startOpencode(buildOpencodeConfig(config));
173
+ if (usesOpencode) {
174
+ opencodeHandle = await startOpencode(buildOpencodeConfig(config));
175
+ }
126
176
  }
127
177
  catch (error) {
128
178
  await auth.cleanup();
@@ -130,11 +180,71 @@ export async function runReview(source, options) {
130
180
  throw new Error(`Failed to start the OpenCode server. Ensure the \`opencode\` CLI is installed and ` +
131
181
  `model credentials are configured (\`ecr doctor\` checks both).\n${errorMessage(error)}`);
132
182
  }
133
- // Preflight: a model id the server can't resolve would otherwise fail EVERY pass
134
- // identically — N indistinguishable coverage gaps, after spending the run's budget
135
- // discovering the same fixable thing N times. Throw once, up front, naming the fix.
136
183
  try {
137
- await assertModelsResolvable(handle, [...config.agents.map((agent) => agent.model), config.coordinator.model], config.auth);
184
+ if (usesClaude) {
185
+ claudeHandle = await startClaudeCode(config);
186
+ }
187
+ }
188
+ catch (error) {
189
+ opencodeHandle?.close();
190
+ await auth.cleanup();
191
+ await restoreCwd();
192
+ throw new Error(`Failed to start the Claude Code engine. Ensure the \`claude\` CLI is installed and ` +
193
+ `logged into a Max/Team subscription (\`ecr doctor\` checks both).\n${errorMessage(error)}`);
194
+ }
195
+ // Build the single carrier handle so every downstream `handle` call is unchanged;
196
+ // per-agent dispatch happens inside via engineOf. When OpenCode is in use the
197
+ // carrier is the opencode handle (claude reached via `.claude`); a claude-only run
198
+ // uses the claude handle itself as the carrier (engineOf maps every id → claude).
199
+ const engineFn = (agent) => engineOf[agent] ?? "opencode";
200
+ let handle;
201
+ if (opencodeHandle) {
202
+ handle = opencodeHandle;
203
+ handle.claude = claudeHandle ?? undefined;
204
+ handle.engineOf = engineFn;
205
+ const closeOpencode = opencodeHandle.close;
206
+ handle.close = () => {
207
+ closeOpencode();
208
+ claudeHandle?.close(); // claude close is a noop, but keep the composition explicit
209
+ };
210
+ }
211
+ else {
212
+ handle = claudeHandle; // claude-only: the carrier is the claude handle itself
213
+ handle.engineOf = engineFn;
214
+ }
215
+ // The claude-code engine has no temperature control (the CLI exposes no flag), so
216
+ // surface a tuned-but-ignored temperature once, here, instead of letting the config
217
+ // divergence pass silently. Applies whenever any pass is claude-routed.
218
+ if (usesClaude) {
219
+ const note = claudeTemperatureNote(config, engineOf);
220
+ if (note) {
221
+ progress(`Note: ${note}`);
222
+ }
223
+ }
224
+ // Preflight: a model id an engine can't resolve would otherwise fail EVERY pass
225
+ // routed to it identically — N indistinguishable coverage gaps, after spending the
226
+ // run's budget rediscovering the same fixable thing. Throw once, up front, naming
227
+ // the fix. Split per engine so each engine only ever sees its own model subset: the
228
+ // OpenCode server never receives an anthropic-claude id (unknown-provider error) and
229
+ // assertClaudeModels never receives a non-anthropic id (its foreign-id throw).
230
+ const modelsFor = (eng) => [
231
+ ...new Set(Object.keys(engineOf)
232
+ .filter((id) => engineOf[id] === eng)
233
+ .map((id) => modelOf[id])),
234
+ ];
235
+ try {
236
+ if (opencodeHandle) {
237
+ const models = modelsFor("opencode");
238
+ if (models.length > 0) {
239
+ await assertModelsResolvable(opencodeHandle, models, config.auth);
240
+ }
241
+ }
242
+ if (claudeHandle) {
243
+ const models = modelsFor("claude-code");
244
+ if (models.length > 0) {
245
+ await assertModelsResolvable(claudeHandle, models, config.auth);
246
+ }
247
+ }
138
248
  }
139
249
  catch (error) {
140
250
  handle.close();
@@ -163,6 +273,10 @@ export async function runReview(source, options) {
163
273
  // log, reported in the log line, and surfaced as a coverage note when it happens.
164
274
  const agentModels = {};
165
275
  const substituted = new Set();
276
+ // The buckets whose model was substituted, so the coverage note can name each
277
+ // one's OWN engine (a mixed run may substitute on either side, for different
278
+ // reasons — a CLI usage-limit downgrade vs. OpenCode's silent default fallback).
279
+ const substitutedBuckets = new Set();
166
280
  const trackModel = (bucket, configured, actual) => {
167
281
  if (!actual) {
168
282
  return;
@@ -170,6 +284,7 @@ export async function runReview(source, options) {
170
284
  agentModels[bucket] = actual;
171
285
  if (configured && actual !== configured) {
172
286
  substituted.add(`${bucket}: configured ${configured}, ran ${actual}`);
287
+ substitutedBuckets.add(bucket);
173
288
  }
174
289
  };
175
290
  try {
@@ -439,10 +554,20 @@ export async function runReview(source, options) {
439
554
  for (const line of substituted) {
440
555
  progress(` ⚠ model substituted — ${line}`);
441
556
  }
557
+ const subEngines = new Set([...substitutedBuckets].map((bucket) => engineOf[bucket] ?? "opencode"));
558
+ const why = [];
559
+ if (subEngines.has(CLAUDE_CODE_ENGINE)) {
560
+ why.push("The Claude Code CLI answered with a different model than configured (usage-limit " +
561
+ "downgrades do this on a subscription), so these findings may come from a weaker " +
562
+ "model than intended — check the configured model ids and the subscription's limits.");
563
+ }
564
+ if (subEngines.has("opencode")) {
565
+ why.push("OpenCode silently falls back to a default model when the configured id is empty or " +
566
+ "unusable, so these findings may come from a different (possibly much weaker) model " +
567
+ "than intended — check the agents' `model`, `coordinator.model`, REVIEWER_MODEL, and the provider credential.");
568
+ }
442
569
  incomplete.push(`Some passes did not run on the configured model (${[...substituted].join("; ")}). ` +
443
- `OpenCode silently falls back to a default model when the configured id is empty or ` +
444
- `unusable, so these findings may come from a different (possibly much weaker) model ` +
445
- `than intended — check the agents' \`model\`, \`coordinator.model\`, REVIEWER_MODEL, and the provider credential.`);
570
+ why.join(" "));
446
571
  }
447
572
  // Note: routine noise filtering (lockfiles, generated, binary) is expected and
448
573
  // NOT a coverage gap — it stays in the run log (filteredFiles), not the
@@ -536,10 +661,22 @@ export async function runReview(source, options) {
536
661
  // Surface provider throttling as a fact about the run: passes already waited or
537
662
  // backed off, but the operator should still SEE that it happened (a run that
538
663
  // was rate-limited is slower and may carry partial passes — that's the cause).
539
- await handle.rateLimit.check();
540
- if (handle.rateLimit.events > 0) {
541
- progress(` ⚠ provider rate-limited this run ${handle.rateLimit.events} time(s) ` +
542
- `(429s in the OpenCode server log) — passes waited it out rather than failing`);
664
+ // Sum both engines' watches; name a cause only for an engine whose watch fired
665
+ // (claude events arrive via note() in runClaudePrompt; opencode via its server log).
666
+ await opencodeHandle?.rateLimit.check();
667
+ const rlOpencode = opencodeHandle?.rateLimit.events ?? 0;
668
+ const rlClaude = claudeHandle?.rateLimit.events ?? 0;
669
+ const rlTotal = rlOpencode + rlClaude;
670
+ if (rlTotal > 0) {
671
+ const causes = [];
672
+ if (rlClaude > 0) {
673
+ causes.push("subscription rate/usage limits reported by the Claude Code CLI");
674
+ }
675
+ if (rlOpencode > 0) {
676
+ causes.push("429s in the OpenCode server log");
677
+ }
678
+ progress(` ⚠ provider rate-limited this run ${rlTotal} time(s) (${causes.join("; ")}) ` +
679
+ `— passes waited it out rather than failing`);
543
680
  }
544
681
  // Every pass says which model actually answered it — in the job log, the step
545
682
  // summary table, and the run log — so a wrong or substituted model is always
@@ -561,7 +698,12 @@ export async function runReview(source, options) {
561
698
  agentFindings,
562
699
  coverageNotes,
563
700
  verifierDropped,
564
- ...(handle.rateLimit.events > 0 ? { rateLimitEvents: handle.rateLimit.events } : {}),
701
+ ...(rlTotal > 0
702
+ ? {
703
+ rateLimitEvents: rlTotal,
704
+ rateLimitByEngine: { opencode: rlOpencode, claudeCode: rlClaude },
705
+ }
706
+ : {}),
565
707
  durationMs: Date.now() - started,
566
708
  decision: output.decision,
567
709
  findingCount: output.findings.length,
@@ -586,7 +728,7 @@ export async function runReview(source, options) {
586
728
  throw error;
587
729
  }
588
730
  finally {
589
- handle?.close();
731
+ handle.close();
590
732
  await auth.cleanup();
591
733
  await restoreCwd();
592
734
  }
@@ -668,6 +810,39 @@ export function reconcileSummary(summary, remaining) {
668
810
  "this summary was written, so it may mention issues no longer listed below._\n\n" +
669
811
  summary);
670
812
  }
813
+ /**
814
+ * Resolve the tree the review reads from, applying the mode's trust policy:
815
+ *
816
+ * - `null` from the source means "nothing to materialize" — reviewing the current
817
+ * checkout is intended (local diffs, or `--pr` without a repo). Never an error.
818
+ * - A materialization FAILURE (throw) is fatal in CI: the checkout there is the
819
+ * trusted BASE tree, and falling back to it would silently review and verify
820
+ * pre-PR file contents (dropping real findings with no trace in the output).
821
+ * The throw propagates to `ecr ci`'s catch, which posts the one terminal
822
+ * "not reviewed" comment.
823
+ * - The same failure in local mode degrades softly to the user's own checkout —
824
+ * the user is the trust principal there and sees the warning directly.
825
+ *
826
+ * Exported for tests.
827
+ */
828
+ export async function resolveReadRoot(source, mode, progress) {
829
+ if (!source.prepareReadRootAsync) {
830
+ return null;
831
+ }
832
+ try {
833
+ return await source.prepareReadRootAsync();
834
+ }
835
+ catch (error) {
836
+ if (mode === "ci") {
837
+ throw new Error(`Could not materialize the PR-head tree to review (and the CI checkout is the ` +
838
+ `trusted base, so reviewing it instead would silently review the wrong ` +
839
+ `contents): ${errorMessage(error)}`);
840
+ }
841
+ progress(`Could not materialize the PR-head tree (${errorMessage(error)}); ` +
842
+ `reading the current checkout instead — file contents may not match the PR.`);
843
+ return null;
844
+ }
845
+ }
671
846
  /** Capitalize the first letter (coverage notes read as sentences). */
672
847
  function capitalize(text) {
673
848
  return text.length > 0 ? text[0].toUpperCase() + text.slice(1) : text;
@@ -112,7 +112,12 @@ export function extractJsonObject(text) {
112
112
  lastError = error;
113
113
  }
114
114
  }
115
- throw new Error(`Could not extract JSON from model response: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
115
+ throw new Error(lastError === undefined
116
+ ? // No candidate was even tried: the response held no {...} block at all —
117
+ // empty output, or prose/pseudo-tool-call text with no JSON in it.
118
+ `Could not extract JSON from model response: no JSON object found in ` +
119
+ `${text.trim() === "" ? "an EMPTY response" : `a ${text.length}-char response with no {...}`}`
120
+ : `Could not extract JSON from model response: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
116
121
  }
117
122
  /** The router's choice of which agent ids to run. */
118
123
  export const RouteOutputSchema = z.object({
@@ -0,0 +1,120 @@
1
+ import { readdir, realpath, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+ /**
4
+ * Ambient runtime configuration the OpenCode server (and the Claude-compatible
5
+ * loaders inside it) discovers from its project directory. The review core chdirs
6
+ * into a materialized PR-HEAD worktree before starting the server, so every one
7
+ * of these is attacker-writable in a PR: a plugin or MCP definition is arbitrary
8
+ * code execution in a process holding the model credential and a comment-capable
9
+ * GH_TOKEN; a `.env` can repoint a provider base URL; instruction files inject
10
+ * system-level prompts. OPENCODE_CONFIG_CONTENT (how ECR passes its own config)
11
+ * MERGES with project config rather than replacing it, so deleting these from the
12
+ * throwaway worktree is the only isolation that doesn't depend on OpenCode
13
+ * semantics.
14
+ *
15
+ * Exact-name entries match files or directories at any depth; `.env` is matched
16
+ * as a prefix (`.env`, `.env.local`, …). The PR's CHANGES to these files are
17
+ * still reviewed — their diffs are inlined in the task prompt — but the reviewer
18
+ * can no longer open their full head contents, and a finding citing one will
19
+ * fail verification (a documented tradeoff of the scrub approach).
20
+ */
21
+ export const AMBIENT_RUNTIME_CONFIG_NAMES = new Set([
22
+ "opencode.json",
23
+ "opencode.jsonc",
24
+ ".opencode",
25
+ "AGENTS.md",
26
+ "CLAUDE.md",
27
+ ".claude",
28
+ ".mcp.json",
29
+ ".cursor",
30
+ ".cursorrules",
31
+ ]);
32
+ /** Names never descended into (and never scrubbed as a unit — `.git` is the worktree link). */
33
+ const SKIP_DIRS = new Set([".git", "node_modules"]);
34
+ /** Whether a directory entry is ambient runtime config that must not reach the model runtime. */
35
+ export function isAmbientRuntimeConfig(name) {
36
+ return AMBIENT_RUNTIME_CONFIG_NAMES.has(name) || name === ".env" || name.startsWith(".env.");
37
+ }
38
+ /**
39
+ * Remove ambient runtime config from a THROWAWAY materialized tree, at every
40
+ * depth. Must only ever run on a tree ECR created and will delete (a worktree or
41
+ * extracted archive) — never on the user's checkout. Returns the repo-relative
42
+ * paths removed so callers can log them.
43
+ */
44
+ export async function scrubAmbientRuntimeConfig(root) {
45
+ const removed = [];
46
+ const walk = async (dir) => {
47
+ const entries = await readdir(dir, { withFileTypes: true });
48
+ for (const entry of entries) {
49
+ const full = path.join(dir, entry.name);
50
+ if (isAmbientRuntimeConfig(entry.name)) {
51
+ await rm(full, { recursive: true, force: true });
52
+ removed.push(path.relative(root, full));
53
+ continue;
54
+ }
55
+ if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
56
+ await walk(full);
57
+ }
58
+ }
59
+ };
60
+ await walk(root);
61
+ return removed.sort();
62
+ }
63
+ /**
64
+ * Remove symlinks whose fully resolved target lies outside the materialized tree.
65
+ *
66
+ * The model runtime's read tools are path-scoped by the LITERAL path argument
67
+ * (buildClaudeArgs' permission rules, and equally OpenCode's project-root
68
+ * containment), but the fs layer underneath follows symlinks — so a PR-committed
69
+ * link (`docs/notes.md -> ~/.claude/.credentials.json`) passes the in-tree check
70
+ * and reads the out-of-tree target. Git can only materialize regular files,
71
+ * directories, and symlinks, so stripping escaping symlinks here closes the whole
72
+ * class.
73
+ *
74
+ * Fail closed: a link whose target cannot be resolved (broken, or a chain that
75
+ * leaves the tree at any hop) is removed too — the target could come into
76
+ * existence later, and a broken link has no legitimate review value. In-tree
77
+ * links survive (realpath resolves chains, so an in-tree alias of an in-tree
78
+ * file is provably contained). Unlike the config scrub, this walk descends into
79
+ * node_modules (a committed one is attacker content); `.git` stays skipped — in
80
+ * a worktree it is an ECR-created gitdir link, not PR content.
81
+ *
82
+ * Must only ever run on a tree ECR created and will delete. Returns the
83
+ * repo-relative paths removed so callers can log them.
84
+ */
85
+ export async function removeEscapingSymlinks(root) {
86
+ // realpath the boundary itself: tmpdir-based roots are often behind symlinks
87
+ // (macOS /var -> /private/var), and containment must compare resolved paths.
88
+ const boundary = await realpath(root);
89
+ const removed = [];
90
+ const walk = async (dir) => {
91
+ const entries = await readdir(dir, { withFileTypes: true });
92
+ for (const entry of entries) {
93
+ const full = path.join(dir, entry.name);
94
+ if (entry.isSymbolicLink()) {
95
+ let contained = false;
96
+ try {
97
+ const target = await realpath(full);
98
+ contained = target === boundary || target.startsWith(boundary + path.sep);
99
+ }
100
+ catch {
101
+ // Unresolvable link: leave `contained` false (fail closed).
102
+ }
103
+ if (!contained) {
104
+ await rm(full, { force: true });
105
+ // Relative to the RESOLVED boundary — the walk runs there, and the
106
+ // caller's `root` may itself sit behind a symlink (macOS /var).
107
+ removed.push(path.relative(boundary, full));
108
+ }
109
+ // In-tree directory links are kept but never descended: their contents
110
+ // are walked once via the real path, and descending would loop on cycles.
111
+ continue;
112
+ }
113
+ if (entry.isDirectory() && entry.name !== ".git") {
114
+ await walk(full);
115
+ }
116
+ }
117
+ };
118
+ await walk(boundary);
119
+ return removed.sort();
120
+ }
@@ -56,6 +56,16 @@ export class RateLimitWatch {
56
56
  constructor(file = opencodeLogFile()) {
57
57
  this.file = file;
58
58
  }
59
+ /**
60
+ * Record rate-limit evidence directly, for an engine that has no log file to
61
+ * scan (the Claude Code CLI surfaces limits per-invocation, not in a log the
62
+ * way the OpenCode server does). Feeds the same `events`/`recentlyLimited`
63
+ * signals `check()` would.
64
+ */
65
+ note(count = 1) {
66
+ this.events += count;
67
+ this.lastSeenAt = Date.now();
68
+ }
59
69
  /** Scan newly-appended log lines for rate-limit evidence. */
60
70
  async check() {
61
71
  try {
@@ -5,6 +5,23 @@ export function sleep(ms) {
5
5
  export function errorMessage(error) {
6
6
  return error instanceof Error ? error.message : String(error);
7
7
  }
8
+ /**
9
+ * A failure reason safe to post into a PUBLIC PR comment (see `ecr ci`'s failure
10
+ * notices). `run` (core/exec.ts) throws "Command failed: <cmd> <argv>\n<stderr>" (and
11
+ * "Command output exceeded …"), which embeds a subprocess's full command line, its
12
+ * stderr, and absolute runner paths — CI-internal detail of no use to a PR author and
13
+ * exactly the kind of thing that must not leak into an attacker-visible artifact. Those
14
+ * shapes collapse to a generic pointer at the workflow log; our own (argv/path-free)
15
+ * error messages pass through, since they are the actionable ones the fail-fast design
16
+ * means to surface. Every call site still writes the FULL reason to the job's stderr.
17
+ */
18
+ export function publicFailureReason(error) {
19
+ const message = errorMessage(error);
20
+ if (/^Command (failed|output exceeded)\b/.test(message)) {
21
+ return "a subprocess (gh, git, or the model CLI) failed — see the workflow logs for details";
22
+ }
23
+ return message;
24
+ }
8
25
  /** Collapse whitespace + lowercase — for tolerant code matching / fingerprinting. */
9
26
  export function normalizeCode(text) {
10
27
  return text.replace(/\s+/g, " ").trim().toLowerCase();
@@ -1,5 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { pathInside } from "./exec.js";
3
4
  import { parseVerdict } from "./schema.js";
4
5
  import { addTokenUsage, promptAndParse, VERIFIER_AGENT } from "./opencode.js";
5
6
  import { buildVerifierSystem, buildVerifierTask } from "./prompts.js";
@@ -50,9 +51,20 @@ export function matchEvidence(evidence, content) {
50
51
  }
51
52
  /** Read the cited file and grade the evidence against it (see matchEvidence). */
52
53
  async function evidencePresence(finding, cwd) {
54
+ // finding.file is an unconstrained, LLM-authored string produced over untrusted PR
55
+ // content, so a prompt-injected finding could point it at a host secret. path.resolve
56
+ // IGNORES cwd when finding.file is already absolute (e.g. ~/.claude/.credentials.json),
57
+ // and `..` segments escape upward — either would make this raw readFile reach outside
58
+ // the reviewed tree with the host user's privileges, and the present/absent grading
59
+ // would leak a content-oracle back into the review. Confine the read to cwd (the
60
+ // materialized PR-head tree); anything outside is uncheckable, never read.
61
+ const resolved = path.resolve(cwd, finding.file);
62
+ if (!pathInside(resolved, cwd)) {
63
+ return "unknown";
64
+ }
53
65
  let content;
54
66
  try {
55
- content = await readFile(path.resolve(cwd, finding.file), "utf8");
67
+ content = await readFile(resolved, "utf8");
56
68
  }
57
69
  catch {
58
70
  return "unknown";