@claudexor/review 3.1.2 → 3.2.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,13 +1,15 @@
1
- import { parseUnifiedDiff, runCapture } from "@claudexor/core";
2
1
  import { preflightEvidence, writeDiffEvidence } from "@claudexor/context";
3
2
  import { HarnessRunSpec, ReviewFinding as ReviewFindingSchema } from "@claudexor/schema";
4
- import { existsSync, lstatSync, readlinkSync, realpathSync, statSync } from "node:fs";
5
- import { cp, mkdir, readdir, rm } from "node:fs/promises";
6
- import { tmpdir } from "node:os";
7
- import { dirname, isAbsolute, join, normalize, relative, resolve } from "node:path";
8
- import { appendLine, containsSecretLikeToken, ensureDir, newId, nowIso, readTextSafe, redactSecrets, sensitiveResourcePolicy, sha256, writeJson, writeText, } from "@claudexor/util";
9
- import { dedupeFindings, extractJsonBlocks, parseFindingsDetailed, } from "./findings.js";
10
- import { buildReviewPrompt } from "./reviewPrompt.js";
3
+ import { createHash } from "node:crypto";
4
+ import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
5
+ import { rm } from "node:fs/promises";
6
+ import { join, relative, resolve } from "node:path";
7
+ import { appendLine, containsSecretLikeToken, engineBuildIdentity, ensureDir, newId, nowIso, readTextSafe, redactSecrets, sha256, writeJson, writeText, } from "@claudexor/util";
8
+ import { dedupeFindings, extractJsonBlocks, parseFindingsDetailed, parseSealedReviewEnvelopeDetailed, sealedReviewTranscriptChunk, } from "./findings.js";
9
+ import { buildReviewPrompt, SEALED_REVIEW_OUTPUT_SCHEMA } from "./reviewPrompt.js";
10
+ import { sealedReviewTranscriptFromEvents } from "./sealedReviewEnvelope.js";
11
+ import { buildReviewerCandidateInventory, cleanupTemporaryReviewerWorkspaceBaseDir, copyReviewEvidencePacket, extractDiffPostimagePaths, isSameOrInside, prepareReviewerWorkspace, selectReviewerWorkspaceBaseDir, } from "./reviewerWorkspace.js";
12
+ export { extractDiffPostimagePaths as __testExtractDiffPostimagePaths } from "./reviewerWorkspace.js";
11
13
  import { buildRouteProof, classifyDiversity } from "./route.js";
12
14
  import { reviewerAuthMode, reviewerAuthSwitchFromEvent } from "./reviewRuntimeTypes.js";
13
15
  import { ReviewerCostKnowledge } from "./reviewerCostKnowledge.js";
@@ -18,8 +20,6 @@ const DEFAULT_REVIEWER_TRANSIENT_RETRY_POLICY = {
18
20
  initialDelayMs: 1_000,
19
21
  maxDelayMs: 10_000,
20
22
  };
21
- const BLOCKED_REVIEWER_RUNTIME_ROOTS = new Set("auth cache daemon home homes logs runs secrets state tmp workspaces".split(" "));
22
- const TEXT_EVIDENCE_SUFFIXES = [".md", ".txt", ".json", ".yaml", ".yml", ".patch"];
23
23
  const REVIEW_WAVE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
24
24
  function readExistingDiffEvidence(dir, diff) {
25
25
  const diffText = diff.endsWith("\n") ? diff : `${diff}\n`;
@@ -55,6 +55,26 @@ function reviewerInfo(reviewer, routeProofStatus, observedModel = null) {
55
55
  };
56
56
  }
57
57
  export async function reviewCandidate(input) {
58
+ const sourceRoot = resolve(input.cwd);
59
+ const sourceExists = existsSync(sourceRoot);
60
+ const canonicalSourceRoot = sourceExists ? realpathSync(sourceRoot) : sourceRoot;
61
+ const sourceEvidencePath = resolve(input.evidenceDir);
62
+ const sourceEvidenceRoot = existsSync(input.evidenceDir)
63
+ ? realpathSync(input.evidenceDir)
64
+ : sourceEvidencePath;
65
+ if (sourceExists && isSameOrInside(sourceEvidenceRoot, canonicalSourceRoot)) {
66
+ throw new Error("review evidence directory must not contain the candidate root");
67
+ }
68
+ const evidenceInsideLexicalSource = sourceExists && isSameOrInside(sourceRoot, sourceEvidencePath);
69
+ const evidenceInsideCanonicalSource = sourceExists && isSameOrInside(canonicalSourceRoot, sourceEvidenceRoot);
70
+ const evidencePathInSourceNamespace = evidenceInsideCanonicalSource
71
+ ? resolve(sourceRoot, relative(canonicalSourceRoot, sourceEvidenceRoot))
72
+ : null;
73
+ const candidateEvidenceExcludeRoots = evidenceInsideLexicalSource || evidenceInsideCanonicalSource
74
+ ? [
75
+ ...new Set([sourceEvidencePath, sourceEvidenceRoot, evidencePathInSourceNamespace].filter((path) => path !== null)),
76
+ ]
77
+ : [];
58
78
  const findingsByReviewer = input.reviewers.map(() => []);
59
79
  const reviewerFamilies = input.reviewers.map((reviewer) => reviewer.providerFamily);
60
80
  const routeProofs = input.reviewers.map((reviewer, index) => reviewerRouteProof(reviewer, null, "unavailable", reviewerFamilies.filter((_, otherIndex) => otherIndex !== index)));
@@ -72,12 +92,26 @@ export async function reviewCandidate(input) {
72
92
  throw new Error("sealed release review requires CLAUDEXOR_REVIEW_WAVE_ID UUID");
73
93
  }
74
94
  const frozenMetadata = input.frozenIdentity
75
- ? {
76
- candidate_sha: input.frozenIdentity.candidateSha,
77
- candidate_tree: input.frozenIdentity.candidateTree,
78
- packet_manifest_sha256: input.frozenIdentity.packetManifestSha256,
79
- ...(reviewWaveId ? { review_wave_id: reviewWaveId } : {}),
80
- }
95
+ ? (() => {
96
+ const runtime = engineBuildIdentity();
97
+ const runtimeEntry = realpathSync(runtime.entry);
98
+ const runtimeEntryStat = lstatSync(runtimeEntry);
99
+ if (!runtimeEntryStat.isFile() || runtimeEntryStat.isSymbolicLink()) {
100
+ throw new Error("sealed release review runtime entry is not a regular file");
101
+ }
102
+ return {
103
+ candidate_sha: input.frozenIdentity.candidateSha,
104
+ candidate_tree: input.frozenIdentity.candidateTree,
105
+ packet_manifest_sha256: input.frozenIdentity.packetManifestSha256,
106
+ review_runtime_version: runtime.version,
107
+ review_runtime_build_sha: runtime.sha,
108
+ review_runtime_entry: runtimeEntry,
109
+ review_runtime_entry_sha256: createHash("sha256")
110
+ .update(readFileSync(runtimeEntry))
111
+ .digest("hex"),
112
+ ...(reviewWaveId ? { review_wave_id: reviewWaveId } : {}),
113
+ };
114
+ })()
81
115
  : {};
82
116
  if (containsSecretLikeToken(input.diff || "(empty diff)\n")) {
83
117
  throw new Error("diff evidence contains a secret-like token; refusing to persist raw DIFF.patch");
@@ -100,7 +134,15 @@ export async function reviewCandidate(input) {
100
134
  ].filter(Boolean);
101
135
  throw new Error(`mandatory evidence preflight failed (${parts.join("; ")})`);
102
136
  }
137
+ const postimagePaths = extractDiffPostimagePaths(input.diff);
138
+ const candidateInventory = await buildReviewerCandidateInventory(input.cwd, postimagePaths, input.evidenceReadOnly === true);
103
139
  const artifactsBaseDir = input.artifactsDir ?? join(input.evidenceDir, "reviewer-artifacts");
140
+ if (input.evidenceReadOnly &&
141
+ input.frozenIdentity &&
142
+ existsSync(artifactsBaseDir) &&
143
+ readdirSync(artifactsBaseDir).length > 0) {
144
+ throw new Error("sealed release review requires a fresh empty artifacts directory");
145
+ }
104
146
  ensureDir(artifactsBaseDir);
105
147
  const persistentEvidenceDir = join(artifactsBaseDir, "evidence");
106
148
  await copyReviewEvidencePacket(input.evidenceDir, persistentEvidenceDir, input.evidenceReadOnly === true);
@@ -116,11 +158,11 @@ export async function reviewCandidate(input) {
116
158
  diff_path: persistentPatch.diffPath,
117
159
  summary_path: persistentPatch.summaryPath,
118
160
  diff_sha256: persistentPatch.diffSha256,
119
- review_subject: input.reviewSubject ?? "code",
161
+ candidate_inventory_mode: candidateInventory.mode,
162
+ candidate_inventory_reason: candidateInventory.reason,
120
163
  ...frozenMetadata,
121
164
  });
122
165
  const artifacts = input.reviewers.map(() => undefined);
123
- const preservePaths = extractDiffTouchedPaths(input.diff);
124
166
  const reviewerWorkspaceBaseDir = selectReviewerWorkspaceBaseDir(input.cwd, artifactsBaseDir, input.evidenceDir);
125
167
  const runReviewer = async (reviewer, index) => {
126
168
  if (input.signal?.aborted)
@@ -135,8 +177,9 @@ export async function reviewCandidate(input) {
135
177
  sourceEvidenceDir: persistentEvidenceDir,
136
178
  workspaceBaseDir: reviewerWorkspaceBaseDir,
137
179
  reviewerDirName: `${String(index + 1).padStart(2, "0")}-${safeFilePart(reviewer.adapter.id)}`,
138
- excludeRoots: [artifactsBaseDir],
139
- preservePaths,
180
+ excludeRoots: [artifactsBaseDir, ...candidateEvidenceExcludeRoots],
181
+ postimagePaths,
182
+ candidateCopyPaths: candidateInventory.copyPaths,
140
183
  preserveEvidenceBytes: input.evidenceReadOnly === true,
141
184
  });
142
185
  const reviewerPatch = input.evidenceReadOnly
@@ -152,34 +195,42 @@ export async function reviewCandidate(input) {
152
195
  persistent_diff_path: persistentPatch.diffPath,
153
196
  persistent_summary_path: persistentPatch.summaryPath,
154
197
  diff_sha256: persistentPatch.diffSha256,
155
- review_subject: input.reviewSubject ?? "code",
198
+ candidate_inventory_mode: candidateInventory.mode,
199
+ candidate_inventory_reason: candidateInventory.reason,
156
200
  ...frozenMetadata,
157
201
  });
158
- const runtimePrompt = buildReviewPrompt(input.candidateLabel, reviewerWorkspace.root, reviewerWorkspace.evidenceDir, reviewerPatch, input.evidenceReadOnly === true, input.reviewSubject ?? "code");
202
+ const runtimePrompt = buildReviewPrompt(input.candidateLabel, reviewerWorkspace.root, reviewerWorkspace.evidenceDir, reviewerPatch, {
203
+ sealed: input.evidenceReadOnly === true,
204
+ candidateInventoryMode: candidateInventory.mode,
205
+ });
159
206
  spec = HarnessRunSpec.parse({
160
207
  session_id: newId("rev"),
161
208
  intent: "review",
162
209
  prompt: runtimePrompt,
163
210
  cwd: reviewerWorkspace.root,
164
211
  access: "readonly",
212
+ ...(input.evidenceReadOnly && input.frozenIdentity
213
+ ? {
214
+ external_context_policy: "live",
215
+ tool_permission_policy: { web: "live", allow: [], deny: [] },
216
+ }
217
+ : {}),
165
218
  model_hint: reviewer.requestedModel ?? null,
166
219
  effort_hint: reviewer.requestedEffort ?? null,
167
220
  auth_preference: reviewer.authPreference ?? "auto",
168
221
  env_inheritance: input.envInheritance ?? "mirror_native",
222
+ ...(input.evidenceReadOnly && input.frozenIdentity
223
+ ? { output_schema: SEALED_REVIEW_OUTPUT_SCHEMA }
224
+ : {}),
169
225
  ...(input.env ? { env: input.env } : {}),
170
226
  });
171
- writeText(artifact.promptPath, redactSecrets(`Persistent local replay evidence:
172
- - evidence_dir: ${persistentEvidenceDir}
173
- - candidate_root: ${reviewerWorkspace.root}
174
- - source_candidate_root: ${input.cwd}
175
- - source_candidate_evidence_dir: ${input.evidenceDir}
176
- - diff_path: ${persistentPatch.diffPath}
177
- - diff_sha256: ${persistentPatch.diffSha256}
178
-
179
- Runtime prompt used during review follows. Its candidate-tree paths may be transient after orchestrator cleanup; use the durable replay paths above for audit/replay.
180
-
181
- ${runtimePrompt}
182
- `));
227
+ writeText(artifact.promptPath, spec.prompt);
228
+ updateReviewerMetadata(artifact, {
229
+ session_id: spec.session_id,
230
+ external_context_policy: spec.external_context_policy,
231
+ tool_web_policy: spec.tool_permission_policy.web,
232
+ submitted_prompt_sha256: createHash("sha256").update(spec.prompt).digest("hex"),
233
+ });
183
234
  }
184
235
  catch (err) {
185
236
  const failedAt = nowIso();
@@ -210,9 +261,13 @@ ${runtimePrompt}
210
261
  let routeModel;
211
262
  let routeSource = "unavailable";
212
263
  let reviewerError = null;
264
+ let sealedProjectionError = null;
213
265
  try {
214
- const out = await collectReviewerOutput(reviewer, spec, reviewerTimeoutMs, input.transientRetryPolicy ?? DEFAULT_REVIEWER_TRANSIENT_RETRY_POLICY, artifact, input.onReviewerEvent, input.signal);
266
+ const out = await collectReviewerOutput(reviewer, spec, reviewerTimeoutMs, input.evidenceReadOnly && input.frozenIdentity
267
+ ? { maxRetries: 0, initialDelayMs: 0, maxDelayMs: 0 }
268
+ : (input.transientRetryPolicy ?? DEFAULT_REVIEWER_TRANSIENT_RETRY_POLICY), artifact, input.onReviewerEvent, input.signal, input.evidenceReadOnly === true);
215
269
  text = out.text;
270
+ sealedProjectionError = out.sealedProjectionError ?? null;
216
271
  streamObservedModel = out.observedModel;
217
272
  routeModel = out.observedModel;
218
273
  routeSource = out.observedSource;
@@ -228,6 +283,9 @@ ${runtimePrompt}
228
283
  if (typeof partial?.partialText === "string" && partial.partialText.trim() !== "") {
229
284
  text = partial.partialText;
230
285
  }
286
+ if (typeof partial?.partialSealedProjectionError === "string") {
287
+ sealedProjectionError = partial.partialSealedProjectionError;
288
+ }
231
289
  reviewerSpend.recordPartial(index, partial);
232
290
  if (partial?.partialObservedModel) {
233
291
  streamObservedModel = partial.partialObservedModel;
@@ -242,8 +300,24 @@ ${runtimePrompt}
242
300
  const proof = reviewerRouteProof(reviewer, routeModel ?? null, routeSource, reviewerFamilies.filter((_, i) => i !== index));
243
301
  routeProofs[index] = proof;
244
302
  const info = reviewerInfo(reviewer, proof.status, streamObservedModel ?? null);
245
- const jsonBlocks = extractJsonBlocks(text);
303
+ const sealedParse = input.evidenceReadOnly
304
+ ? parseSealedReviewEnvelopeDetailed(text, info)
305
+ : null;
306
+ const jsonBlocks = sealedParse?.blocks ?? extractJsonBlocks(text);
246
307
  writeJson(artifact.parsedPath, redactValue(jsonBlocks));
308
+ if (sealedParse && (sealedProjectionError || sealedParse.error)) {
309
+ const detail = sealedProjectionError ?? sealedParse.error ?? "invalid sealed review output";
310
+ writeParseError(artifact, {
311
+ error: "invalid_sealed_review_envelope",
312
+ detail,
313
+ malformed: sealedParse.malformed,
314
+ text_sha256: sha256(text),
315
+ ...(reviewerError ? { reviewer_error: reviewerError } : {}),
316
+ });
317
+ findingsByReviewer[index]?.push(...sealedParse.findings);
318
+ findingsByReviewer[index]?.push(insufficientEvidenceFinding(info, `Invalid sealed review envelope: ${detail}.`));
319
+ return;
320
+ }
247
321
  if (reviewerError && (text.trim() === "" || jsonBlocks.length === 0)) {
248
322
  findingsByReviewer[index]?.push(insufficientEvidenceFinding(info, `Reviewer failed: ${reviewerError}`));
249
323
  return;
@@ -253,7 +327,7 @@ ${runtimePrompt}
253
327
  findingsByReviewer[index]?.push(insufficientEvidenceFinding(info, "Reviewer produced no parseable JSON findings."));
254
328
  return;
255
329
  }
256
- const parsed = parseFindingsDetailed(text, info);
330
+ const parsed = sealedParse ?? parseFindingsDetailed(text, info);
257
331
  const parseError = {};
258
332
  let parsedFindingsRecorded = false;
259
333
  const recordParsedFindings = () => {
@@ -262,7 +336,7 @@ ${runtimePrompt}
262
336
  findingsByReviewer[index]?.push(...parsed.findings);
263
337
  parsedFindingsRecorded = true;
264
338
  };
265
- if (parsed.malformed > 0) {
339
+ if (parsed.malformed > 0 && !sealedParse?.error) {
266
340
  Object.assign(parseError, {
267
341
  error: "malformed_findings",
268
342
  malformed: parsed.malformed,
@@ -342,7 +416,7 @@ ${runtimePrompt}
342
416
  await cleanupTemporaryReviewerWorkspaceBaseDir(reviewerWorkspaceBaseDir, artifactsBaseDir);
343
417
  }
344
418
  }
345
- async function collectReviewerOutput(reviewer, spec, timeoutMs, transientRetryPolicy, artifact, onReviewerEvent, signal) {
419
+ async function collectReviewerOutput(reviewer, spec, timeoutMs, transientRetryPolicy, artifact, onReviewerEvent, signal, sealed = false) {
346
420
  const controller = new AbortController();
347
421
  spec.extra["abortSignal"] = controller.signal;
348
422
  const startMs = Date.now();
@@ -371,6 +445,8 @@ async function collectReviewerOutput(reviewer, spec, timeoutMs, transientRetryPo
371
445
  let observedSource = "unavailable";
372
446
  let observedAuthMode = null;
373
447
  let currentAuthMode = null;
448
+ const observedAuthModes = new Set();
449
+ const ignoredSettings = new Set();
374
450
  let costUsd = 0;
375
451
  let costEstimated = false;
376
452
  let cashUsd = 0;
@@ -390,9 +466,20 @@ async function collectReviewerOutput(reviewer, spec, timeoutMs, transientRetryPo
390
466
  let lastError = null;
391
467
  let attemptObservedModel;
392
468
  let attemptObservedSource = "unavailable";
469
+ const sealedMessageEvents = [];
393
470
  for await (const ev of iter) {
394
471
  const eventTime = nowIso();
395
- appendLine(artifact.eventsPath, JSON.stringify(redactValue(ev)));
472
+ const persistedEvent = redactValue(ev);
473
+ appendLine(artifact.eventsPath, JSON.stringify(persistedEvent));
474
+ const ignored = ev.payload?.["ignored_settings"];
475
+ if (Array.isArray(ignored)) {
476
+ for (const item of ignored)
477
+ if (typeof item === "string")
478
+ ignoredSettings.add(item);
479
+ if (ignoredSettings.size > 0) {
480
+ updateReviewerMetadata(artifact, { ignored_settings: [...ignoredSettings] });
481
+ }
482
+ }
396
483
  if (ev.transient)
397
484
  sawTransient = true;
398
485
  if (ev.type === "error") {
@@ -413,8 +500,11 @@ async function collectReviewerOutput(reviewer, spec, timeoutMs, transientRetryPo
413
500
  });
414
501
  }
415
502
  const disclosedAuthMode = reviewerAuthMode(ev.credential_route);
416
- if (disclosedAuthMode)
503
+ if (disclosedAuthMode) {
417
504
  currentAuthMode = disclosedAuthMode;
505
+ observedAuthModes.add(disclosedAuthMode);
506
+ updateReviewerMetadata(artifact, { auth_modes: [...observedAuthModes] });
507
+ }
418
508
  costKnowledge.observeEvent(currentAuthMode);
419
509
  if (!observedAuthMode) {
420
510
  observedAuthMode = disclosedAuthMode;
@@ -454,11 +544,24 @@ async function collectReviewerOutput(reviewer, spec, timeoutMs, transientRetryPo
454
544
  unknown_usd: unknownUsd,
455
545
  });
456
546
  }
547
+ if (sealed && ev.type === "message" && ev.final === true) {
548
+ sealedMessageEvents.push(persistedEvent);
549
+ }
457
550
  if (ev.type === "message" && ev.text && ev.payload?.["auth_switched"] !== true) {
458
- const safeText = redactSecrets(ev.text);
459
- text += safeText + "\n";
460
- partialText += safeText + "\n";
461
- appendLine(artifact.transcriptPath, safeText);
551
+ const safeText = sealedReviewTranscriptChunk(persistedEvent);
552
+ if (safeText !== null) {
553
+ if (sealed) {
554
+ // Keep the long-running transcript visibly active for monitors. A
555
+ // clean completion replaces these progress bytes with the exact
556
+ // typed-final projection used by the sealed parser and sealer.
557
+ appendLine(artifact.transcriptPath, safeText);
558
+ }
559
+ else {
560
+ text += safeText;
561
+ partialText += safeText;
562
+ appendLine(artifact.transcriptPath, safeText);
563
+ }
564
+ }
462
565
  }
463
566
  if (ev.observed_model) {
464
567
  observedModel = ev.observed_model;
@@ -478,6 +581,17 @@ async function collectReviewerOutput(reviewer, spec, timeoutMs, transientRetryPo
478
581
  if (isCancelled()) {
479
582
  throw new Error("Reviewer cancelled");
480
583
  }
584
+ let sealedProjectionError;
585
+ if (sealed) {
586
+ try {
587
+ text = sealedReviewTranscriptFromEvents(sealedMessageEvents);
588
+ partialText = text;
589
+ writeText(artifact.transcriptPath, text);
590
+ }
591
+ catch (error) {
592
+ sealedProjectionError = error instanceof Error ? error.message : String(error);
593
+ }
594
+ }
481
595
  if (sawTransient &&
482
596
  text.trim() === "" &&
483
597
  nativeTry < transientRetryPolicy.maxRetries &&
@@ -511,7 +625,9 @@ async function collectReviewerOutput(reviewer, spec, timeoutMs, transientRetryPo
511
625
  return consumeOnce(nativeTry + 1);
512
626
  }
513
627
  if (sawError && !timedOut) {
514
- throw new Error(`Reviewer emitted error event: ${lastError ?? "unknown error"}`);
628
+ throw Object.assign(new Error(`Reviewer emitted error event: ${lastError ?? "unknown error"}`), {
629
+ ...(sealedProjectionError ? { partialSealedProjectionError: sealedProjectionError } : {}),
630
+ });
515
631
  }
516
632
  if (!timedOut && !isCancelled()) {
517
633
  const completedTime = nowIso();
@@ -537,6 +653,7 @@ async function collectReviewerOutput(reviewer, spec, timeoutMs, transientRetryPo
537
653
  costKnowledge.finishAttempt();
538
654
  return {
539
655
  text,
656
+ ...(sealedProjectionError ? { sealedProjectionError } : {}),
540
657
  observedModel: attemptObservedModel,
541
658
  observedSource: attemptObservedSource,
542
659
  costUsd,
@@ -670,126 +787,6 @@ async function collectReviewerOutput(reviewer, spec, timeoutMs, transientRetryPo
670
787
  });
671
788
  }
672
789
  }
673
- function selectReviewerWorkspaceBaseDir(sourceRoot, artifactsBaseDir, sourceEvidenceDir) {
674
- const durableBase = join(artifactsBaseDir, "workspaces");
675
- if (!isSameOrInside(sourceRoot, durableBase) && !isSameOrInside(sourceEvidenceDir, durableBase)) {
676
- return durableBase;
677
- }
678
- return join(tmpdir(), `claudexor-review-workspaces-${newId("ws")}`);
679
- }
680
- function isTemporaryReviewerWorkspaceBaseDir(baseDir) {
681
- const resolved = resolve(baseDir);
682
- const rel = relative(tmpdir(), resolved);
683
- return (isSameOrInside(tmpdir(), resolved) &&
684
- rel.split(/[\\/]+/)[0]?.startsWith("claudexor-review-workspaces-") === true);
685
- }
686
- async function prepareReviewerWorkspace(input) {
687
- const sourceRoot = resolve(input.sourceRoot);
688
- const workspaceBaseDir = resolve(input.workspaceBaseDir);
689
- const root = join(workspaceBaseDir, input.reviewerDirName);
690
- if (!existsSync(sourceRoot)) {
691
- throw new Error(`candidate root does not exist: ${sourceRoot}`);
692
- }
693
- if (isSameOrInside(sourceRoot, root)) {
694
- throw new Error(`reviewer workspace must be outside candidate root: ${root}`);
695
- }
696
- try {
697
- await rm(root, { recursive: true, force: true });
698
- await mkdir(root, { recursive: true, mode: 0o700 });
699
- const excludeRoots = input.excludeRoots.map((p) => resolve(p));
700
- const resolvedSourceRoot = realpathSync(sourceRoot);
701
- await cp(sourceRoot, root, {
702
- recursive: true,
703
- dereference: false,
704
- filter: (sourcePath) => shouldCopyReviewerPath(sourceRoot, resolvedSourceRoot, sourcePath, excludeRoots, input.preservePaths),
705
- });
706
- const sourceEvidenceDir = resolve(input.sourceEvidenceDir);
707
- const evidenceDir = join(root, ".claudexor-review-evidence");
708
- if (existsSync(sourceEvidenceDir)) {
709
- const resolvedSourceEvidenceDir = realpathSync(sourceEvidenceDir);
710
- const evidenceExcludeRoots = excludeRoots.filter((root) => !isSameOrInside(root, sourceEvidenceDir));
711
- await rm(evidenceDir, { recursive: true, force: true });
712
- await cp(sourceEvidenceDir, evidenceDir, input.preserveEvidenceBytes
713
- ? { recursive: true, dereference: false }
714
- : {
715
- recursive: true,
716
- dereference: false,
717
- filter: (sourcePath) => shouldCopyReviewerPath(sourceEvidenceDir, resolvedSourceEvidenceDir, sourcePath, evidenceExcludeRoots),
718
- });
719
- }
720
- await mkdir(evidenceDir, { recursive: true, mode: 0o700 });
721
- await initializeReviewerWorkspaceGit(root);
722
- return { root, evidenceDir };
723
- }
724
- catch (err) {
725
- await rm(root, { recursive: true, force: true });
726
- throw err;
727
- }
728
- }
729
- async function copyReviewEvidencePacket(sourceEvidenceDir, persistentEvidenceDir, preserveBytes = false) {
730
- const source = resolve(sourceEvidenceDir);
731
- const target = resolve(persistentEvidenceDir);
732
- await rm(target, { recursive: true, force: true });
733
- if (!existsSync(source)) {
734
- await mkdir(target, { recursive: true, mode: 0o700 });
735
- return;
736
- }
737
- if (preserveBytes) {
738
- await mkdir(dirname(target), { recursive: true, mode: 0o700 });
739
- await cp(source, target, { recursive: true, dereference: false });
740
- return;
741
- }
742
- await mkdir(target, { recursive: true, mode: 0o700 });
743
- const resolvedSource = realpathSync(source);
744
- for (const entry of await readdir(source, { withFileTypes: true })) {
745
- const sourcePath = join(source, entry.name);
746
- if (!shouldCopyEvidencePacketPath(source, resolvedSource, sourcePath, target)) {
747
- continue;
748
- }
749
- await copyReviewEvidenceEntry(source, resolvedSource, sourcePath, join(target, entry.name), target);
750
- }
751
- }
752
- async function copyReviewEvidenceEntry(sourceEvidenceDir, resolvedSourceEvidenceDir, sourcePath, targetPath, targetEvidenceDir) {
753
- const stat = lstatSync(sourcePath);
754
- if (stat.isDirectory()) {
755
- await mkdir(targetPath, { recursive: true, mode: 0o700 });
756
- for (const entry of await readdir(sourcePath, { withFileTypes: true })) {
757
- const childSource = join(sourcePath, entry.name);
758
- if (!shouldCopyEvidencePacketPath(sourceEvidenceDir, resolvedSourceEvidenceDir, childSource, targetEvidenceDir)) {
759
- continue;
760
- }
761
- await copyReviewEvidenceEntry(sourceEvidenceDir, resolvedSourceEvidenceDir, childSource, join(targetPath, entry.name), targetEvidenceDir);
762
- }
763
- return;
764
- }
765
- if (stat.isFile() && shouldTextSanitizeEvidenceFile(sourcePath)) {
766
- const raw = readTextSafe(sourcePath);
767
- if (raw === null)
768
- throw new Error(`could not read review evidence file: ${sourcePath}`);
769
- const text = shouldFailClosedEvidenceFile(sourcePath) ? raw : redactSecrets(raw);
770
- if (containsSecretLikeToken(text)) {
771
- throw new Error(`review evidence file contains a secret-like token: ${relative(sourceEvidenceDir, sourcePath)}`);
772
- }
773
- writeText(targetPath, text);
774
- return;
775
- }
776
- await mkdir(dirname(targetPath), { recursive: true, mode: 0o700 });
777
- await cp(sourcePath, targetPath, { recursive: false, dereference: false });
778
- }
779
- function shouldTextSanitizeEvidenceFile(path) {
780
- return TEXT_EVIDENCE_SUFFIXES.some((extension) => path.toLowerCase().endsWith(extension));
781
- }
782
- function shouldFailClosedEvidenceFile(path) {
783
- return path.toLowerCase().endsWith(".patch");
784
- }
785
- function shouldCopyEvidencePacketPath(sourceEvidenceDir, resolvedSourceEvidenceDir, sourcePath, targetEvidenceDir) {
786
- const resolvedSourcePath = resolve(sourcePath);
787
- if (isSameOrInside(resolvedSourcePath, targetEvidenceDir))
788
- return false;
789
- if (isSameOrInside(targetEvidenceDir, resolvedSourcePath))
790
- return false;
791
- return shouldCopyReviewerPath(sourceEvidenceDir, resolvedSourceEvidenceDir, resolvedSourcePath, [targetEvidenceDir], new Set(), false);
792
- }
793
790
  async function cleanupReviewerWorkspace(workspace, artifact) {
794
791
  try {
795
792
  await rm(workspace.root, { recursive: true, force: true });
@@ -802,188 +799,6 @@ async function cleanupReviewerWorkspace(workspace, artifact) {
802
799
  });
803
800
  }
804
801
  }
805
- async function cleanupTemporaryReviewerWorkspaceBaseDir(workspaceBaseDir, artifactsBaseDir) {
806
- if (!isTemporaryReviewerWorkspaceBaseDir(workspaceBaseDir))
807
- return;
808
- try {
809
- await rm(workspaceBaseDir, { recursive: true, force: true });
810
- }
811
- catch (err) {
812
- try {
813
- writeJson(join(artifactsBaseDir, "reviewer-workspace-base-cleanup-error.json"), {
814
- reviewer_workspace_base_cleanup: "failed",
815
- workspace_base_dir: workspaceBaseDir,
816
- error: redactSecrets(err instanceof Error ? err.message : String(err)),
817
- });
818
- }
819
- catch {
820
- // Do not let cleanup telemetry hide the review result or the original error.
821
- }
822
- }
823
- }
824
- function shouldCopyReviewerPath(sourceRoot, resolvedSourceRoot, sourcePath, excludeRoots, preservePaths = new Set(), enforceContentPolicy = true) {
825
- const resolvedSourcePath = resolve(sourcePath);
826
- if (!isCopyableReviewerSymlink(sourceRoot, resolvedSourceRoot, resolvedSourcePath, excludeRoots)) {
827
- return false;
828
- }
829
- if (excludeRoots.some((root) => isSameOrInside(root, resolvedSourcePath)))
830
- return false;
831
- const rel = relative(sourceRoot, resolvedSourcePath);
832
- if (!rel)
833
- return true;
834
- const parts = rel.split(/[\\/]+/);
835
- if (sensitiveResourcePolicy.classifyPath(rel).sensitive) {
836
- return false;
837
- }
838
- if (parts[0] === ".claudexor") {
839
- return (isCopyableReviewerClaudexorPath(rel, parts, preservePaths) &&
840
- (!enforceContentPolicy || reviewerFileContentAllowed(resolvedSourcePath)));
841
- }
842
- if (parts.some((part) => [".git", ".adversarial-review", ".turbo", "node_modules"].includes(part))) {
843
- return false;
844
- }
845
- if (parts.some((part) => [".next", ".cache", "coverage", "dist"].includes(part)) &&
846
- !isPreservedReviewerPath(rel, preservePaths)) {
847
- return false;
848
- }
849
- return (!rel.endsWith(".tsbuildinfo") &&
850
- (!enforceContentPolicy || reviewerFileContentAllowed(resolvedSourcePath)));
851
- }
852
- function reviewerFileContentAllowed(path) {
853
- let targetStat;
854
- try {
855
- targetStat = statSync(path);
856
- }
857
- catch {
858
- return false;
859
- }
860
- if (!targetStat.isFile())
861
- return true;
862
- const content = readTextSafe(path);
863
- return content !== null && !sensitiveResourcePolicy.containsSensitiveContent(content);
864
- }
865
- function isCopyableReviewerClaudexorPath(rel, parts, preservePaths) {
866
- if (parts.length === 1) {
867
- return true;
868
- }
869
- if (parts.length === 2 && parts[1] === "config.yaml") {
870
- return true;
871
- }
872
- const runtimeRoot = parts[1]?.toLowerCase();
873
- if (runtimeRoot && BLOCKED_REVIEWER_RUNTIME_ROOTS.has(runtimeRoot))
874
- return false;
875
- return isPreservedReviewerPath(rel, preservePaths);
876
- }
877
- function isPreservedReviewerPath(rel, preservePaths) {
878
- const normalized = normalizeReviewerRelativePath(rel);
879
- if (!normalized)
880
- return false;
881
- if (preservePaths.has(normalized))
882
- return true;
883
- const prefix = `${normalized}/`;
884
- for (const preserved of preservePaths) {
885
- if (preserved.startsWith(prefix))
886
- return true;
887
- }
888
- return false;
889
- }
890
- export function __testExtractDiffTouchedPaths(diff) {
891
- return extractDiffTouchedPaths(diff);
892
- }
893
- function extractDiffTouchedPaths(diff) {
894
- const paths = new Set();
895
- for (const file of parseUnifiedDiff(diff).files) {
896
- if (file.oldPath)
897
- addReviewerPreservePath(paths, file.oldPath);
898
- if (file.newPath)
899
- addReviewerPreservePath(paths, file.newPath);
900
- }
901
- return paths;
902
- }
903
- function addReviewerPreservePath(paths, value) {
904
- const normalized = normalizeReviewerRelativePath(value);
905
- if (normalized)
906
- paths.add(normalized);
907
- }
908
- function normalizeReviewerRelativePath(value) {
909
- if (!value || value === "/dev/null" || isAbsolute(value))
910
- return null;
911
- const normalized = normalize(value).replace(/\\/g, "/");
912
- if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) {
913
- return null;
914
- }
915
- return normalized;
916
- }
917
- function isCopyableReviewerSymlink(sourceRoot, resolvedSourceRoot, sourcePath, excludeRoots) {
918
- let stat;
919
- try {
920
- stat = lstatSync(sourcePath);
921
- }
922
- catch {
923
- return false;
924
- }
925
- if (!stat.isSymbolicLink())
926
- return true;
927
- let linkTarget = "";
928
- let resolvedTarget = "";
929
- let targetKind = "other";
930
- try {
931
- linkTarget = readlinkSync(sourcePath);
932
- resolvedTarget = realpathSync(sourcePath);
933
- const targetStat = statSync(sourcePath);
934
- targetKind = targetStat.isDirectory() ? "directory" : targetStat.isFile() ? "file" : "other";
935
- }
936
- catch {
937
- return false;
938
- }
939
- return sensitiveResourcePolicy.assessSymlink({
940
- sourceRoot,
941
- canonicalSourceRoot: resolvedSourceRoot,
942
- sourcePath,
943
- linkTarget,
944
- resolvedTargetPath: resolvedTarget,
945
- targetKind,
946
- allowedTargetKinds: ["file", "directory"],
947
- excludedRoots: excludeRoots,
948
- relocationRoot: sourceRoot,
949
- }).allowed;
950
- }
951
- async function initializeReviewerWorkspaceGit(root) {
952
- const noHooks = ["-c", "core.hooksPath=/dev/null"];
953
- await runGitOrThrow("init", root, ["-c", "init.templateDir=", ...noHooks, "init"]);
954
- for (const [key, value] of [
955
- ["user.email", "claudexor-review@example.invalid"],
956
- ["user.name", "Claudexor Review"],
957
- ]) {
958
- await runGitOrThrow(`config ${key}`, root, [...noHooks, "config", key, value]);
959
- }
960
- await runGitOrThrow("add", root, [...noHooks, "add", "-A", "--force"]);
961
- await runGitOrThrow("commit", root, [
962
- ...noHooks,
963
- "commit",
964
- "--allow-empty",
965
- "--no-verify",
966
- "--no-gpg-sign",
967
- "-m",
968
- "review baseline",
969
- ]);
970
- }
971
- async function runGitOrThrow(label, cwd, args) {
972
- const gitEnv = Object.fromEntries(Object.keys(process.env)
973
- .filter((key) => key.startsWith("GIT_"))
974
- .map((key) => [key, null]));
975
- gitEnv.GIT_CONFIG_NOSYSTEM = "1";
976
- const result = await runCapture("git", args, { cwd, env: gitEnv, timeoutMs: 60_000 });
977
- if (result.code === 0)
978
- return;
979
- const detail = redactSecrets((result.stderr || result.stdout || `exit ${result.code}`).trim());
980
- throw new Error(`failed to prepare reviewer workspace (${label}): ${detail}`);
981
- }
982
- function isSameOrInside(parent, target) {
983
- const rel = relative(resolve(parent), resolve(target));
984
- const firstPart = rel.split(/[\\/]+/)[0];
985
- return rel === "" || (!!rel && firstPart !== ".." && !isAbsolute(rel));
986
- }
987
802
  function createReviewerArtifactContext(baseDir, index, reviewer) {
988
803
  const dir = join(baseDir, `${String(index + 1).padStart(2, "0")}-${safeFilePart(reviewer.adapter.id)}`);
989
804
  ensureDir(dir);
@@ -1012,8 +827,11 @@ function createReviewerArtifactContext(baseDir, index, reviewer) {
1012
827
  return ctx;
1013
828
  }
1014
829
  function safeFilePart(value) {
1015
- const safe = value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
1016
- return safe || "reviewer";
830
+ const safe = value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+/, "");
831
+ let end = safe.length;
832
+ while (end > 0 && safe[end - 1] === "-")
833
+ end -= 1;
834
+ return safe.slice(0, end) || "reviewer";
1017
835
  }
1018
836
  function emitReviewerProgress(artifact, reviewer, onReviewerEvent, patch) {
1019
837
  const event = {