@expo/code-review-cli 0.6.0 → 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.
@@ -1,36 +1,271 @@
1
- import { execFile } from "node:child_process";
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
2
4
  import { promisify } from "node:util";
3
5
  const execFileAsync = promisify(execFile);
4
6
  /**
5
7
  * Run a command capturing stdout/stderr. Never interpolates a shell, so
6
8
  * arguments are passed verbatim and are not subject to shell injection.
9
+ *
10
+ * With `input`, the call routes through spawn so the text can be streamed to
11
+ * stdin; every other caller keeps the execFile path unchanged.
7
12
  */
8
13
  export async function run(command, args, options = {}) {
14
+ if (options.input !== undefined) {
15
+ return runWithInput(command, args, options, options.input);
16
+ }
9
17
  const check = options.check ?? true;
10
18
  try {
11
19
  const { stdout, stderr } = await execFileAsync(command, args, {
12
20
  cwd: options.cwd,
13
21
  maxBuffer: options.maxBuffer ?? 64 * 1024 * 1024,
14
22
  encoding: "utf8",
23
+ env: options.env,
24
+ timeout: options.timeout,
25
+ killSignal: options.killSignal,
15
26
  });
16
27
  return { stdout, stderr, code: 0 };
17
28
  }
18
29
  catch (error) {
19
30
  const err = error;
20
- if (!check) {
21
- return { stdout: err.stdout ?? "", stderr: err.stderr ?? "", code: err.code ?? 1 };
31
+ // Same timeout contract as runWithInput: a child our own `timeout` killed
32
+ // resolves with `timedOut: true` (even under check) instead of a generic
33
+ // throw, so callers see one shape regardless of which path ran.
34
+ const timedOut = options.timeout !== undefined && err.killed === true;
35
+ if (!check || timedOut) {
36
+ return {
37
+ stdout: err.stdout ?? "",
38
+ stderr: err.stderr ?? "",
39
+ code: err.code ?? 1,
40
+ signal: err.signal,
41
+ timedOut: timedOut || undefined,
42
+ };
22
43
  }
23
44
  throw new Error(`Command failed: ${command} ${args.join(" ")}\n${err.stderr ?? err.message ?? ""}`.trim());
24
45
  }
25
46
  }
47
+ /**
48
+ * Kill callbacks for children still running, so an interrupt or exit never
49
+ * orphans them. Detached children live in their own process group (see
50
+ * runWithInput), so SIGINT from Ctrl-C reaches only this process — without this,
51
+ * an aborted review leaves a credential-bearing `claude` running unbounded.
52
+ */
53
+ const liveChildKillers = new Set();
54
+ let childCleanupInstalled = false;
55
+ function installChildCleanup() {
56
+ if (childCleanupInstalled) {
57
+ return;
58
+ }
59
+ childCleanupInstalled = true;
60
+ const killAll = () => {
61
+ for (const kill of liveChildKillers) {
62
+ kill();
63
+ }
64
+ };
65
+ process.on("exit", killAll);
66
+ for (const signal of ["SIGINT", "SIGTERM"]) {
67
+ process.on(signal, () => {
68
+ killAll();
69
+ // Re-raise the conventional exit code; registering a handler suppressed
70
+ // Node's default termination.
71
+ process.exitCode = signal === "SIGINT" ? 130 : 143;
72
+ process.exit();
73
+ });
74
+ }
75
+ }
76
+ /**
77
+ * spawn-based variant that feeds `input` to the child's stdin. Collects
78
+ * stdout/stderr up to `maxBuffer`, enforces `timeout`/`killSignal` manually, and
79
+ * resolves the same RunResult shape (with `timedOut`/`overflowed` set).
80
+ *
81
+ * The deadline is enforced with our own timers rather than spawn's native
82
+ * `timeout`: spawn sends `killSignal` once, to the direct child only, with no
83
+ * SIGKILL escalation — a child that traps SIGTERM, or a shim wrapper
84
+ * (volta/mise/asdf) whose grandchild holds the work, would run unbounded. Here a
85
+ * child launched detached forms its own process group, we signal the whole group,
86
+ * and a grace timer escalates to SIGKILL.
87
+ */
88
+ function runWithInput(command, args, options, input) {
89
+ const check = options.check ?? true;
90
+ const maxBuffer = options.maxBuffer ?? 64 * 1024 * 1024;
91
+ return new Promise((resolve, reject) => {
92
+ const detached = process.platform !== "win32";
93
+ const child = spawn(command, args, {
94
+ cwd: options.cwd,
95
+ env: options.env,
96
+ detached,
97
+ });
98
+ let stdout = "";
99
+ let stderr = "";
100
+ let overflowed = false;
101
+ let timedOut = false;
102
+ let killTimer;
103
+ let graceTimer;
104
+ const clearTimers = () => {
105
+ if (killTimer)
106
+ clearTimeout(killTimer);
107
+ if (graceTimer)
108
+ clearTimeout(graceTimer);
109
+ };
110
+ // Signal the whole process group when detached so a shim wrapper's grandchild
111
+ // is killed too. On Windows there are no process groups — kill the tree via
112
+ // taskkill instead, for the same reason.
113
+ const killChild = (sig) => {
114
+ try {
115
+ if (process.platform === "win32" && child.pid !== undefined) {
116
+ // A no-op error handler is required: an async spawn failure (ENOENT/EPERM)
117
+ // has no other listener here and would otherwise throw unhandled and
118
+ // crash the parent, defeating the point of this cleanup path.
119
+ spawn(taskkillPath(), ["/pid", String(child.pid), "/T", "/F"]).on("error", () => { });
120
+ }
121
+ else if (detached && child.pid !== undefined) {
122
+ process.kill(-child.pid, sig);
123
+ }
124
+ else {
125
+ child.kill(sig);
126
+ }
127
+ }
128
+ catch {
129
+ // Already exited, or the group is gone — nothing to kill.
130
+ }
131
+ };
132
+ const emergencyKill = () => killChild("SIGKILL");
133
+ installChildCleanup();
134
+ liveChildKillers.add(emergencyKill);
135
+ if (options.timeout && options.timeout > 0) {
136
+ killTimer = setTimeout(() => {
137
+ timedOut = true;
138
+ killChild(options.killSignal ?? "SIGTERM");
139
+ graceTimer = setTimeout(() => killChild("SIGKILL"), 5000);
140
+ graceTimer.unref?.();
141
+ }, options.timeout);
142
+ killTimer.unref?.();
143
+ }
144
+ const cap = (current, chunk) => {
145
+ if (current.length >= maxBuffer) {
146
+ overflowed = true;
147
+ return current;
148
+ }
149
+ const next = current + chunk;
150
+ // Check AFTER appending too — a single oversized chunk must both flag the
151
+ // overflow and stay capped, not sail through because the pre-append length
152
+ // was still under the limit.
153
+ if (next.length > maxBuffer) {
154
+ overflowed = true;
155
+ return next.slice(0, maxBuffer);
156
+ }
157
+ return next;
158
+ };
159
+ child.stdout.setEncoding("utf8");
160
+ child.stderr.setEncoding("utf8");
161
+ child.stdout.on("data", (chunk) => {
162
+ stdout = cap(stdout, chunk);
163
+ });
164
+ child.stderr.on("data", (chunk) => {
165
+ stderr = cap(stderr, chunk);
166
+ });
167
+ // A late I/O error on either stream (e.g. the process group getting
168
+ // SIGKILLed mid-read) would otherwise throw unhandled and crash the
169
+ // parent; the close handler reports the real outcome regardless.
170
+ child.stdout.on("error", () => { });
171
+ child.stderr.on("error", () => { });
172
+ child.on("error", (error) => {
173
+ clearTimers();
174
+ liveChildKillers.delete(emergencyKill);
175
+ if (!check) {
176
+ // A spawn error (ENOENT/EACCES) fires before any stderr can be
177
+ // captured, so fall back to error.message rather than resolving
178
+ // with an unexplained empty stderr.
179
+ resolve({ stdout, stderr: stderr || error.message, code: 1, timedOut, overflowed });
180
+ return;
181
+ }
182
+ reject(new Error(`Command failed: ${command} ${args.join(" ")}\n${error.message}`.trim()));
183
+ });
184
+ child.on("close", (code, signal) => {
185
+ clearTimers();
186
+ liveChildKillers.delete(emergencyKill);
187
+ const exitCode = code ?? 1;
188
+ if (overflowed && check) {
189
+ reject(new Error(`Command output exceeded ${maxBuffer} bytes: ${command}`));
190
+ return;
191
+ }
192
+ if (exitCode !== 0 && check && !timedOut) {
193
+ reject(new Error(`Command failed: ${command} ${args.join(" ")}\n${stderr}`.trim()));
194
+ return;
195
+ }
196
+ resolve({
197
+ stdout,
198
+ stderr,
199
+ code: exitCode,
200
+ signal: signal ?? undefined,
201
+ timedOut,
202
+ overflowed,
203
+ });
204
+ });
205
+ child.stdin.on("error", () => {
206
+ // A child that exits before reading stdin (e.g. bad args) closes the pipe;
207
+ // ignore EPIPE — the close handler reports the real outcome.
208
+ });
209
+ child.stdin.end(input);
210
+ });
211
+ }
212
+ /**
213
+ * Memoized trusted resolutions for the host `git`/`gh` binaries, keyed by name so
214
+ * git()'s many callers share ONE which/where lookup instead of each spawning their
215
+ * own. See resolveTrustedTool.
216
+ */
217
+ const trustedToolResolutions = new Map();
218
+ /**
219
+ * Resolve `git`/`gh` to a trusted ABSOLUTE path, refusing any binary that resolves
220
+ * INSIDE the reviewed tree. Every git/gh spawn goes through this, never a bare name.
221
+ *
222
+ * A review has chdir'd into the untrusted PR-head tree (and `ecr review` of a local
223
+ * branch, plus `ecr ci`/doctor, operate on an untrusted checkout). libuv on Windows
224
+ * searches the child's cwd BEFORE PATH when the command is a BARE NAME, so a
225
+ * PR-committed `git.bat`/`gh.exe` at the repo root would win the lookup and run with
226
+ * ambient secrets (GH_TOKEN, model creds) in its environment. Spawning a resolved
227
+ * absolute path does no cwd search at all — the same property that fixed `claude`
228
+ * and `opencode`. resolveOnPath itself does the which/where lookup from tmpdir(), so
229
+ * the in-tree shim is never even FOUND on POSIX or Windows; pathInside is the backstop.
230
+ *
231
+ * Memoized per name: resolveOnPath is cwd-INDEPENDENT (it looks up from tmpdir), so a
232
+ * first call can never cache a cwd-tainted value, and the host binary is stable for
233
+ * the process. The caller's cwd is NOT changed — only the binary is resolved, so
234
+ * git/gh keep operating on their target tree. Throws (not null) so callers that
235
+ * assume a working git/gh fail loudly rather than silently spawning nothing.
236
+ */
237
+ export function resolveTrustedTool(name) {
238
+ let resolution = trustedToolResolutions.get(name);
239
+ if (!resolution) {
240
+ resolution = (async () => {
241
+ const resolved = await resolveOnPath(name);
242
+ if (!resolved) {
243
+ throw new Error(`The \`${name}\` CLI is not installed or not on PATH.`);
244
+ }
245
+ if (pathInside(resolved, process.cwd())) {
246
+ throw new Error(`refusing to run a \`${name}\` binary found inside the reviewed tree (${resolved}) — ` +
247
+ `install ${name} on the host and remove it from the repository.`);
248
+ }
249
+ return resolved;
250
+ })();
251
+ trustedToolResolutions.set(name, resolution);
252
+ }
253
+ return resolution;
254
+ }
255
+ /** Test-only: drop memoized git/gh resolutions so a test can re-resolve under a changed cwd/PATH. */
256
+ export function resetTrustedToolCache() {
257
+ trustedToolResolutions.clear();
258
+ }
26
259
  export async function git(args, cwd) {
27
- const { stdout } = await run("git", args, { cwd });
260
+ const gitPath = await resolveTrustedTool("git");
261
+ const { stdout } = await run(gitPath, args, { cwd });
28
262
  return stdout;
29
263
  }
30
264
  /** Resolve owner/repo from the current checkout via gh (for PR-targeting commands). */
31
265
  export async function resolveRepo(cwd) {
32
266
  try {
33
- const { stdout } = await run("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"], {
267
+ const gh = await resolveTrustedTool("gh");
268
+ const { stdout } = await run(gh, ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"], {
34
269
  cwd,
35
270
  });
36
271
  const repo = stdout.trim();
@@ -52,10 +287,44 @@ export async function repoRoot(cwd) {
52
287
  return null;
53
288
  }
54
289
  }
55
- /** Whether an executable is resolvable on PATH. */
56
- export async function onPath(command) {
57
- const { code } = await run(process.platform === "win32" ? "where" : "which", [command], {
290
+ /** Absolute path of an executable on PATH (first match), or null if unresolved. */
291
+ export async function resolveOnPath(command) {
292
+ // SECURITY: run the lookup from a trusted directory, never the inherited cwd.
293
+ // During a review the process has chdir'd into the untrusted PR-head tree, and
294
+ // Windows `where` searches the CURRENT DIRECTORY before PATH — a committed
295
+ // `claude.exe` at the repo root would win the lookup and be executed with the
296
+ // engine's credentials in its environment. tmpdir() is host-controlled.
297
+ const { stdout, code } = await run(process.platform === "win32" ? "where" : "which", [command], {
58
298
  check: false,
299
+ cwd: tmpdir(),
59
300
  });
60
- return code === 0;
301
+ if (code !== 0) {
302
+ return null;
303
+ }
304
+ return stdout.trim().split("\n")[0]?.trim() || null;
305
+ }
306
+ /**
307
+ * Whether `filePath` lies inside `dir` (after resolution). Used as the backstop
308
+ * that refuses to execute a binary resolved from inside the reviewed tree.
309
+ */
310
+ export function pathInside(filePath, dir) {
311
+ const rel = path.relative(path.resolve(dir), path.resolve(filePath));
312
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
313
+ }
314
+ /**
315
+ * Absolute path to Windows' `taskkill`, so a process-tree kill never spawns a BARE
316
+ * `taskkill` — during a review the cwd is the untrusted PR-head tree, and Windows
317
+ * resolves a bare name against the current directory before PATH, so a PR-committed
318
+ * `taskkill.exe`/`.bat` at the tree root could otherwise run in place of the real one
319
+ * (with ambient secrets in its env) on the timeout-kill path. An absolute path does no
320
+ * search at all. `taskkill` always lives in System32, and `SystemRoot` is set by
321
+ * Windows, never by the PR. Callers stay win32-guarded.
322
+ */
323
+ export function taskkillPath() {
324
+ const root = process.env.SystemRoot || process.env.windir || "C:\\Windows";
325
+ return path.join(root, "System32", "taskkill.exe");
326
+ }
327
+ /** Whether an executable is resolvable on PATH. */
328
+ export async function onPath(command) {
329
+ return (await resolveOnPath(command)) !== null;
61
330
  }
@@ -1,9 +1,41 @@
1
1
  import { createRequire } from "node:module";
2
2
  import path from "node:path";
3
3
  import { createOpencode } from "@opencode-ai/sdk";
4
+ import { pathInside, resolveOnPath } from "./exec.js";
4
5
  import { RateLimitWatch } from "./throttle.js";
5
6
  import { toolMap } from "./tools.js";
6
7
  import { errorMessage, sleep } from "./util.js";
8
+ /** Discriminant for the Claude Code CLI engine (see core/claude-code.ts). */
9
+ export const CLAUDE_CODE_ENGINE = "claude-code";
10
+ /**
11
+ * Resolve which engine an agent's pass dispatches to, and (when claude) which
12
+ * claude handle to run it against. The per-agent router (engineOf) wins; absent it
13
+ * the carrier's own single `engine` decides. When the pass is claude-routed the
14
+ * claude handle is the carrier itself (a claude-only run, where the carrier IS the
15
+ * claude handle) or its `.claude` field (any run that also drives OpenCode). Pure
16
+ * and side-effect-free so the seam's dispatch is unit-testable without spawning.
17
+ */
18
+ export function resolveEngineDispatch(handle, agent) {
19
+ const engine = handle.engineOf?.(agent) ?? handle.engine ?? "opencode";
20
+ if (engine !== CLAUDE_CODE_ENGINE) {
21
+ return { engine };
22
+ }
23
+ // A claude-only run: the carrier itself IS the claude handle. Cast is safe (and
24
+ // still needed) because `handle` is typed OpencodeHandle here regardless.
25
+ if (handle.engine === CLAUDE_CODE_ENGINE) {
26
+ return { engine, claudeHandle: handle };
27
+ }
28
+ // Mixed run: engineOf routed this agent to claude-code, so the carrier's `.claude`
29
+ // field must be set. If it isn't, that's an invariant violation in how the handle
30
+ // was assembled, not a runtime fluke — fail loudly here instead of letting a bad
31
+ // cast smuggle `undefined` past the type checker and crash deep inside
32
+ // runClaudePrompt with no clue what went wrong.
33
+ if (!handle.claude) {
34
+ throw new Error(`Agent "${agent}" is routed to the claude-code engine but this handle has no ` +
35
+ `.claude carrier — the handle was assembled inconsistently.`);
36
+ }
37
+ return { engine, claudeHandle: handle.claude };
38
+ }
7
39
  /** Sum token usage across attempts (for per-task/run totals). */
8
40
  export function addTokenUsage(into, from) {
9
41
  if (!from) {
@@ -158,11 +190,50 @@ export function opencodeBinSource() {
158
190
  const dir = bundledOpencodeBinDir();
159
191
  return { dir, pinned: dir !== null };
160
192
  }
193
+ /**
194
+ * Resolve the `opencode` binary the way we trust it: OUR bundled shim when the
195
+ * dependency resolves, else a PATH lookup from a trusted cwd (resolveOnPath, never
196
+ * the inherited one) with a refusal of any binary that resolves INSIDE the current
197
+ * tree. Null when unresolved or in-tree.
198
+ *
199
+ * `ecr doctor`/`ecr setup-auth` may run inside a cloned untrusted repo, so a bare
200
+ * `opencode` handed to execFile/spawn resolves against the inherited cwd — and Windows
201
+ * checks the current directory before PATH, letting a PR-committed `opencode` shim run
202
+ * with ambient secrets in its env. Every opencode spawn in those commands goes through
203
+ * this, mirroring resolveClaudeCli for the `claude` binary.
204
+ */
205
+ export async function resolveOpencodeCli() {
206
+ const bundled = bundledOpencodeBinDir();
207
+ if (bundled) {
208
+ // Our own dependency tree (require.resolve is relative to THIS module, not cwd), so
209
+ // it's trusted by construction — and NO in-tree refusal here: ecr's node_modules
210
+ // commonly sits under cwd when run from its own repo, which pathInside would then
211
+ // wrongly reject.
212
+ return path.join(bundled, "opencode");
213
+ }
214
+ // PATH fallback: resolved from a trusted cwd, and refused if it lands in-tree.
215
+ const cliPath = await resolveOnPath("opencode");
216
+ if (!cliPath || pathInside(cliPath, process.cwd())) {
217
+ return null;
218
+ }
219
+ return cliPath;
220
+ }
161
221
  /** Start an in-process OpenCode server with the given inline config. */
162
222
  export async function startOpencode(config) {
163
223
  // Make our pinned CLI win over any global install (see bundledOpencodeBinDir).
164
224
  // The SDK takes no `env`, so PATH is the only lever; it spreads `process.env` at
165
225
  // spawn time, so setting it here reaches the child.
226
+ //
227
+ // SECURITY residual (accepted, POSIX-only deployment): the SDK spawns a BARE
228
+ // `opencode` (cross-spawn `launch("opencode")`), which on Windows resolves the name
229
+ // against the current directory before PATH — during a review the cwd is the
230
+ // untrusted PR-head tree, so a PR-committed `opencode.exe` at its root could run in
231
+ // its place. We deliberately do NOT reimplement the SDK's server bootstrap to inject
232
+ // an absolute path here: on POSIX (the supported platform) `execvp` never searches
233
+ // the cwd, so the hijack cannot fire, and forking the launch would mean silently
234
+ // maintaining our own copy of it against SDK drift. The direct-spawn `opencode`
235
+ // callers we own (`ecr doctor`/`ecr setup-auth`) are still hardened via
236
+ // resolveOpencodeCli. Revisit if Windows becomes a supported target.
166
237
  const binDir = bundledOpencodeBinDir();
167
238
  if (binDir) {
168
239
  process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`;
@@ -257,27 +328,19 @@ export function formatUnknownModels(unknown, auths) {
257
328
  // Do NOT blame the token alone: the most common causes have nothing to do with
258
329
  // the credential's validity (see below). An earlier version of this message sent
259
330
  // us to re-issue two perfectly good tokens.
260
- const oauth = auth?.mode === "oauth";
261
- const deadOauth = oauth && provider === "anthropic";
331
+ // No anthropic special case here anymore: anthropic models never reach the
332
+ // OpenCode preflight (engineForModel routes every anthropic/… id to the Claude
333
+ // Code engine), so this message only ever names non-anthropic providers.
262
334
  return (`The OpenCode server does not offer the "${provider}" provider, even though this run ` +
263
335
  `supplied a ${auth?.mode ?? "configured"} credential for it. OpenCode drops a provider whose ` +
264
336
  `credential it could not use, which makes every ${provider} model look nonexistent: ` +
265
337
  `${refused.map((entry) => entry.model).join(", ")}.\n` +
266
338
  `The credential itself is often FINE. Check these in order:\n` +
267
- (deadOauth
268
- ? ` 1. anthropic OAuth cannot work through OpenCode at all. Anthropic does not permit ` +
269
- `Pro/Max subscription tokens in third-party tools, and OpenCode (since 1.3.0) ships no ` +
270
- `anthropic OAuth support — an oauth credential never registers the provider, no matter ` +
271
- `how valid the token is. Switch auth in .expo-code-review/config.jsonc to ` +
272
- `{ "mode": "api-key", "provider": "anthropic", "tokenEnv": "ANTHROPIC_API_KEY" } with a ` +
273
- `Console API key, or run with REVIEWER_MODEL set to a model you are logged into ` +
274
- `(e.g. REVIEWER_MODEL=openai/gpt-5.5).\n`
275
- : "") +
276
339
  (tokenEnv
277
- ? ` ${deadOauth ? "2" : "1"}. The credential is wrong for the mode. ` +
340
+ ? ` 1. The credential is wrong for the mode. ` +
278
341
  `auth.mode "api-key" expects a plain API key for ${provider}; an OAuth/subscription ` +
279
342
  `token is not an API key. A truncated or half-pasted ${tokenEnv} fails the same way.\n`
280
- : ` ${deadOauth ? "2" : "1"}. The credential is wrong for the configured auth.mode.\n`) +
343
+ : ` 1. The credential is wrong for the configured auth.mode.\n`) +
281
344
  `Providers the server does offer: ${refused[0].suggestions.join(", ") || "(none)"}.`);
282
345
  }
283
346
  const lines = unknown.map((entry) => entry.reason === "provider"
@@ -295,6 +358,10 @@ export function formatUnknownModels(unknown, auths) {
295
358
  * per-pass as before.
296
359
  */
297
360
  export async function assertModelsResolvable(handle, models, auths) {
361
+ if (handle.engine === CLAUDE_CODE_ENGINE) {
362
+ const { assertClaudeModels } = await import("./claude-code.js");
363
+ return assertClaudeModels(handle, models);
364
+ }
298
365
  let available;
299
366
  try {
300
367
  available = await fetchProviderModels(handle);
@@ -502,6 +569,14 @@ export class AgentTimeoutError extends Error {
502
569
  * message completes.
503
570
  */
504
571
  export async function promptAgent(handle, args) {
572
+ // A Claude handle never runs OpenCode's session/polling machinery: consumers
573
+ // use promptAndParse, but guard here too so a direct call can't run this code
574
+ // against a Claude handle. Dispatch is per-agent (see resolveEngineDispatch).
575
+ const dispatch = resolveEngineDispatch(handle, args.agent);
576
+ if (dispatch.engine === CLAUDE_CODE_ENGINE) {
577
+ const { runClaudePrompt } = await import("./claude-code.js");
578
+ return runClaudePrompt(dispatch.claudeHandle, args);
579
+ }
505
580
  const maxWaitMs = args.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
506
581
  // ONE deadline for the whole pass, shared by the first attempt and any stall
507
582
  // retry, so retrying a wedged request can never push the pass past its declared
@@ -628,7 +703,7 @@ export async function promptAgent(handle, args) {
628
703
  }
629
704
  }
630
705
  }
631
- const CORRECTIVE = "\n\nIMPORTANT: your previous reply could not be parsed. Reply with ONLY the single " +
706
+ export const CORRECTIVE = "\n\nIMPORTANT: your previous reply could not be parsed. Reply with ONLY the single " +
632
707
  "JSON object described above — no prose, no code fences, no partial output.";
633
708
  // Budget for a corrective "re-emit the JSON" reply — no fresh investigation, so
634
709
  // it should return almost immediately.
@@ -684,7 +759,7 @@ export function isTransientApiError(error) {
684
759
  * drop the whole pass with no retry, reported as a coverage gap. Non-transient
685
760
  * errors (incl. AgentTimeoutError) propagate immediately.
686
761
  */
687
- async function withTransientRetry(label, onActivity, fn) {
762
+ export async function withTransientRetry(label, onActivity, fn) {
688
763
  for (let attempt = 0;; attempt++) {
689
764
  try {
690
765
  return await fn();
@@ -712,6 +787,11 @@ async function withTransientRetry(label, onActivity, fn) {
712
787
  * the task instead of retrying a non-convergent run.
713
788
  */
714
789
  export async function promptAndParse(handle, args, parse) {
790
+ const dispatch = resolveEngineDispatch(handle, args.agent);
791
+ if (dispatch.engine === CLAUDE_CODE_ENGINE) {
792
+ const { claudeCodePromptAndParse } = await import("./claude-code.js");
793
+ return claudeCodePromptAndParse(dispatch.claudeHandle, args, parse);
794
+ }
715
795
  let cost = 0;
716
796
  let truncated = false;
717
797
  let model;
@@ -57,6 +57,15 @@ export function sanitizeUntrusted(input, maxLength = 4000) {
57
57
  }
58
58
  return out.trim();
59
59
  }
60
+ /**
61
+ * sanitizeUntrusted for a value that must stay on ONE line — a single-line bullet in a
62
+ * prompt. Collapsing newlines stops injected text from forging a standalone boundary
63
+ * line (e.g. a bare `EVIDENCE` fence delimiter) that the token-oriented
64
+ * sanitizeUntrusted does not itself remove.
65
+ */
66
+ export function flattenUntrusted(input, maxLength = 4000) {
67
+ return sanitizeUntrusted(input, maxLength).replace(/\s*\n\s*/g, " ");
68
+ }
60
69
  function withShared(config, rolePrompt) {
61
70
  return config.sharedPromptText
62
71
  ? `${config.sharedPromptText}\n\n---\n\n${rolePrompt}`
@@ -285,8 +294,16 @@ export function buildVerifierTask(finding, opts = {}) {
285
294
  `- line: ${finding.line ?? "(unspecified)"}`,
286
295
  `- severity: ${finding.severity}`,
287
296
  `- category: ${finding.category}`,
288
- `- title: ${finding.title}`,
289
- `- rationale: ${finding.rationale}`,
297
+ // title/rationale are LLM-authored over the untrusted diff (a reviewer may quote an
298
+ // adjacent malicious comment straight into them), and buildVerifierSystem is
299
+ // deliberately NOT wrapped in the shared injection-defense rules — so, like
300
+ // finding.file above, neutralize their prompt-boundary constructs rather than
301
+ // interpolating them raw. Flatten to one line too: these are single-line bullet
302
+ // values, so collapsing newlines stops injected text from forging a standalone
303
+ // boundary line (e.g. a bare `EVIDENCE` fence delimiter) that sanitizeUntrusted,
304
+ // which targets role/PR tokens, would not catch.
305
+ `- title: ${flattenUntrusted(finding.title)}`,
306
+ `- rationale: ${flattenUntrusted(finding.rationale)}`,
290
307
  ];
291
308
  if (finding.evidence) {
292
309
  lines.push("- code the finding claims is present (UNTRUSTED — verify it against the file):", "<<<EVIDENCE", finding.evidence, "EVIDENCE");
@@ -157,14 +157,33 @@ function renderSeveritySections(findings, link, idFor = fingerprintFinding) {
157
157
  }
158
158
  return out;
159
159
  }
160
+ /**
161
+ * Indent every line of a multi-line value to a list item's content column.
162
+ *
163
+ * Rationales embed a `<details>` block, and only indenting the first line let
164
+ * that HTML escape the list item: GitHub then treated the closing `</details>`
165
+ * as ending a top-level HTML block, and because the next finding's bullet
166
+ * followed after a single newline it was emitted as raw text instead of
167
+ * Markdown. Every finding after the first in a group rendered with visible
168
+ * `**` and backticks.
169
+ *
170
+ * Blank lines stay truly empty — trailing whitespace would make them
171
+ * non-blank and reopen the same class of parsing bug.
172
+ */
173
+ function indentContinuation(value, indent = " ") {
174
+ return value.split("\n").map((line) => (line.trim() === "" ? "" : `${indent}${line}`));
175
+ }
160
176
  function renderFindingLines(finding, link, id = fingerprintFinding(finding)) {
161
177
  const out = [
162
178
  `- **${finding.title}** — ${location(finding, link)} _(${finding.category})_ · \`id:${id}\``,
163
- ` ${finding.rationale}`,
179
+ ...indentContinuation(finding.rationale),
164
180
  ];
165
181
  if (finding.suggestion) {
166
- out.push(` _Suggestion:_ ${finding.suggestion}`);
182
+ out.push(...indentContinuation(`_Suggestion:_ ${finding.suggestion}`));
167
183
  }
184
+ // Separator so a rationale ending in `</details>` cannot swallow the next
185
+ // bullet. Findings are already loose list items, so this changes no spacing.
186
+ out.push("");
168
187
  return out;
169
188
  }
170
189
  /** Parse the fingerprints embedded in a previously-posted comment body. */