argus-reviewer-e2e 0.1.2 → 0.1.3

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.
@@ -0,0 +1,408 @@
1
+ import { chmod, mkdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
2
+ import { dirname, isAbsolute, join, posix, relative } from 'node:path';
3
+ import { DEFAULT_SANDBOX } from '../config.js';
4
+ import { defaultExec } from '../detect.js';
5
+ import { mayProbePr } from '../evidence/gate.js';
6
+ import { isTestFile } from '../evidence/link.js';
7
+ import { buildProbeMessages, parseProbe, probeImportsSafe, PROBE_SCHEMA, } from './author.js';
8
+ import { detectHarness } from './harness.js';
9
+ import { checkSandboxPaths, dockerAvailable, resolveSandboxImage, runProbeInSandbox, SANDBOX_OUTPUT_CAP, SCRATCH_DIR_NAME, stripControlChars, sandboxLimits, } from '../executor/sandbox.js';
10
+ /** Pure selection: not_exercised findings at blocking severities, capped. */
11
+ export function selectProbeTargets(findings, severityGates, maxProbes) {
12
+ return findings
13
+ .filter((f) => f.evidence.status === 'not_exercised' &&
14
+ f.file !== undefined &&
15
+ isSafeRepoPath(f.file) &&
16
+ severityGates.includes(f.severity ?? ''))
17
+ .slice(0, Math.max(0, maxProbes));
18
+ }
19
+ /**
20
+ * Repo-relative path gate for anything model- or index-derived that is read
21
+ * or written on the HOST: no absolute paths, no `..` escapes, no backslashes.
22
+ * The write side is already basename-bound (PROBE_FILENAME_RE); this is the
23
+ * read-side and exemplar-dir boundary — a prompt-injected `../../.env`
24
+ * finding.file or a crafted index entry must never reach fs calls.
25
+ */
26
+ export function isSafeRepoPath(p) {
27
+ if (isAbsolute(p) || p.includes('\\'))
28
+ return false;
29
+ // posix normalize — on win32, normalize() turns `../x` into `..\x` and
30
+ // the startsWith('../') check would miss it.
31
+ const n = posix.normalize(p);
32
+ return n !== '..' && !n.startsWith('../') && !posix.isAbsolute(n);
33
+ }
34
+ /**
35
+ * Nearest existing test file to the finding's file — same directory first,
36
+ * then same top-level segment, then any test file. The exemplar sets the
37
+ * probe's write location so the consumer's own include/roots cover it.
38
+ * Index paths are filtered through isSafeRepoPath — a committed/crafted
39
+ * index could otherwise aim the host write outside the checkout.
40
+ */
41
+ export function findExemplarTest(index, findingFile) {
42
+ const tests = index?.entries.map((e) => e.path).filter((p) => isTestFile(p) && isSafeRepoPath(p)) ?? [];
43
+ if (tests.length === 0)
44
+ return undefined;
45
+ const dir = dirname(findingFile);
46
+ const same = tests.find((t) => dirname(t) === dir);
47
+ if (same !== undefined)
48
+ return same;
49
+ const top = findingFile.split('/')[0];
50
+ return tests.find((t) => t.split('/')[0] === top) ?? tests[0];
51
+ }
52
+ /**
53
+ * Ensure `baseSha` is fetchable and checked out as a detached worktree at
54
+ * `wtDir`. Stale registrations from killed runs are pruned first — a
55
+ * leftover `probes-base` would otherwise fail `worktree add` and silently
56
+ * degrade every later run to head-only. The auth header rides env config
57
+ * (GIT_CONFIG_*) so the token never appears in the git argv/`ps`.
58
+ * Returns the worktree path, or undefined when the base cannot be
59
+ * materialized — probes then run head-only and can never mark `reproduced`.
60
+ */
61
+ async function addBaseWorktree(exec, cwd, wtDir, baseSha, token) {
62
+ await exec('git', ['-C', cwd, 'worktree', 'remove', '--force', wtDir], 30_000);
63
+ await exec('git', ['-C', cwd, 'worktree', 'prune'], 15_000);
64
+ const have = await exec('git', ['-C', cwd, 'cat-file', '-e', `${baseSha}^{commit}`], 15_000);
65
+ if (have.code !== 0) {
66
+ // Shallow PR checkouts lack the base — fetch it. Auth rides env config
67
+ // like actions/checkout's extraheader, but env keeps the token out of
68
+ // the process argv where co-tenant jobs could scrape it via /proc.
69
+ const env = token === undefined
70
+ ? undefined
71
+ : {
72
+ GIT_CONFIG_COUNT: '1',
73
+ GIT_CONFIG_KEY_0: 'http.https://github.com/.extraheader',
74
+ GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${token}`).toString('base64')}`,
75
+ };
76
+ const fetched = await exec('git', ['-C', cwd, 'fetch', '--depth', '1', 'origin', baseSha], 60_000, env);
77
+ if (fetched.code !== 0)
78
+ return undefined;
79
+ }
80
+ const added = await exec('git', ['-C', cwd, 'worktree', 'add', '--detach', wtDir, baseSha], 60_000);
81
+ return added.code === 0 ? wtDir : undefined;
82
+ }
83
+ async function removeBaseWorktree(exec, cwd, wtDir) {
84
+ const res = await exec('git', ['-C', cwd, 'worktree', 'remove', '--force', wtDir], 30_000).catch(() => ({ code: 1, stdout: '', stderr: 'exec failed' }));
85
+ if (res.code !== 0) {
86
+ // rm fallback AND prune — without prune the stale .git/worktrees entry
87
+ // makes the next `worktree add` fail and every later run degrades to
88
+ // head-only.
89
+ await rm(wtDir, { recursive: true, force: true }).catch(() => undefined);
90
+ await exec('git', ['-C', cwd, 'worktree', 'prune'], 15_000).catch(() => undefined);
91
+ }
92
+ }
93
+ /** Read a repo file for the prompt — windowed around the finding's line, hard-capped. */
94
+ const FILE_WINDOW_LINES = 300;
95
+ const FILE_CAP_BYTES = 64 * 1024;
96
+ const EXEMPLAR_CAP_BYTES = 32 * 1024;
97
+ function windowContent(content, line) {
98
+ if (content.length <= FILE_CAP_BYTES)
99
+ return content;
100
+ if (line === undefined)
101
+ return `${content.slice(0, FILE_CAP_BYTES)}\n…[truncated]`;
102
+ const lines = content.split('\n');
103
+ const lo = Math.max(0, line - 1 - FILE_WINDOW_LINES);
104
+ const hi = Math.min(lines.length, line - 1 + FILE_WINDOW_LINES);
105
+ const windowed = lines.slice(lo, hi).join('\n');
106
+ const capped = windowed.length > FILE_CAP_BYTES ? windowed.slice(0, FILE_CAP_BYTES) : windowed;
107
+ return `${lo > 0 ? `…[${lo} earlier lines omitted]\n` : ''}${capped}${hi < lines.length ? `\n…[${lines.length - hi} later lines omitted]` : ''}`;
108
+ }
109
+ /** One bounded authoring call; returns the validated probe or undefined. */
110
+ async function authorProbe(o, target, harness, exemplarPath, indexPaths) {
111
+ // target.file is model-emitted — require index membership so a
112
+ // prompt-injected path can't make the host read (and exfil) arbitrary
113
+ // files into the authoring prompt.
114
+ const fileTrusted = target.file !== undefined && indexPaths?.has(target.file) === true;
115
+ const [rawFile, rawExemplar] = await Promise.all([
116
+ fileTrusted
117
+ ? readFile(join(o.cwd, target.file), 'utf8').catch(() => undefined)
118
+ : Promise.resolve(undefined),
119
+ exemplarPath === undefined
120
+ ? Promise.resolve(undefined)
121
+ : readFile(join(o.cwd, exemplarPath), 'utf8').catch(() => undefined),
122
+ ]);
123
+ const fileContents = rawFile === undefined ? undefined : windowContent(rawFile, target.line);
124
+ const exemplar = exemplarPath === undefined || rawExemplar === undefined
125
+ ? undefined
126
+ : {
127
+ path: exemplarPath,
128
+ content: rawExemplar.length > EXEMPLAR_CAP_BYTES
129
+ ? `${rawExemplar.slice(0, EXEMPLAR_CAP_BYTES)}\n…[truncated]`
130
+ : rawExemplar,
131
+ };
132
+ let response;
133
+ try {
134
+ response = await o.client.complete({
135
+ model: o.model,
136
+ messages: buildProbeMessages({
137
+ file: target.file,
138
+ line: target.line,
139
+ severity: target.severity ?? 'bug',
140
+ message: target.message ?? '',
141
+ }, fileContents, exemplar, harness),
142
+ schema: PROBE_SCHEMA,
143
+ kind: 'code',
144
+ ...(o.provider !== undefined ? { provider: o.provider } : {}),
145
+ });
146
+ }
147
+ catch (e) {
148
+ return { probe: undefined, reason: `authoring call failed: ${e.message}`, costUsd: 0, tokens: 0 };
149
+ }
150
+ o.ledger.recordCall(response.cost);
151
+ o.calls?.push(response.cost);
152
+ const parsed = parseProbe(response.content);
153
+ if (!parsed.ok) {
154
+ // Failed parses still cost the call — carry the spend on the record.
155
+ return { probe: undefined, reason: parsed.reason, costUsd: response.cost.costUsd, tokens: response.cost.tokens };
156
+ }
157
+ return { probe: parsed.probe, costUsd: response.cost.costUsd, tokens: response.cost.tokens };
158
+ }
159
+ function record(target, probe, outcome, detail, extra = {}) {
160
+ return {
161
+ file: probe?.filename ?? '(none)',
162
+ findingFile: target.file,
163
+ findingLine: target.line,
164
+ outcome,
165
+ durationMs: extra.durationMs ?? 0,
166
+ costUsd: extra.costUsd ?? 0,
167
+ tokens: extra.tokens ?? 0,
168
+ detail,
169
+ headOutcome: extra.headOutcome,
170
+ baseOutcome: extra.baseOutcome,
171
+ output: extra.output,
172
+ };
173
+ }
174
+ /** `timedOut` or the never-ran `-1` sentinel → infra error; else the harness classifies. */
175
+ function outcomeOf(harness, r) {
176
+ return r.timedOut || r.exitCode === -1 ? 'error' : harness.classify(r);
177
+ }
178
+ function probeOutput(stdout, stderr) {
179
+ // Strip control chars + ANSI before capping — attacker-influenced probe
180
+ // output lands in code-review.json and the sticky comment verbatim.
181
+ const combined = stripControlChars(`${stdout}\n${stderr}`)
182
+ // eslint-disable-next-line no-control-regex
183
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
184
+ .trim();
185
+ return combined === '' ? undefined : combined.slice(0, SANDBOX_OUTPUT_CAP);
186
+ }
187
+ /**
188
+ * Run the probe lane. Mutates `findings` evidence in place for reproduced
189
+ * results and returns the per-probe audit records for code-review.json.
190
+ * Returns undefined only when the lane is disabled entirely; otherwise a
191
+ * result carrying records and (when it bowed out early) a skipReason the
192
+ * report surface can render.
193
+ */
194
+ export async function runProbeLane(findings, o) {
195
+ const log = o.log ?? (() => undefined);
196
+ if (!o.sandbox.enabled)
197
+ return undefined;
198
+ const skip = (reason) => {
199
+ log(`probes: skipped — ${reason}`);
200
+ return { records: [], skipReason: reason };
201
+ };
202
+ // For fork PRs the config file ships in the PR's own tree — attacker-set
203
+ // image/limits/allowForks would self-approve the gate. Forks always run
204
+ // on DEFAULT_SANDBOX and approve only via trusted author or the label.
205
+ const sandbox = o.meta?.isFork ? { ...DEFAULT_SANDBOX, enabled: true } : o.sandbox;
206
+ const targets = selectProbeTargets(findings, o.severityGates, sandbox.maxProbes);
207
+ if (targets.length === 0)
208
+ return { records: [] };
209
+ if (!mayProbePr(o.meta, sandbox)) {
210
+ return skip('fork gate (needs argus-probe label on this head or allowForks)');
211
+ }
212
+ // Cheap check first: repos without a supported harness skip before docker
213
+ // ever runs (a possible image pull would be wasted work).
214
+ const harness = await detectHarness(o.cwd);
215
+ if (harness === undefined) {
216
+ return skip('no supported test harness (vitest/jest/node --test)');
217
+ }
218
+ const image = resolveSandboxImage(sandbox.image);
219
+ const exec = o.exec ?? defaultExec;
220
+ if (!(await dockerAvailable(exec, image, o.cwd))) {
221
+ return skip('docker unavailable or cannot see the workspace');
222
+ }
223
+ // Validate reportDir's ancestry BEFORE mkdir — a symlinked reportDir must
224
+ // not create dirs at an arbitrary host path.
225
+ const dirCheck = await checkSandboxPaths(o.cwd, o.reportDir);
226
+ if (!dirCheck.ok)
227
+ return skip(`report dir unsafe — ${dirCheck.reason}`);
228
+ const scratchDir = join(o.reportDir, SCRATCH_DIR_NAME);
229
+ // The single writable mount must actually be writable by nobody (65534).
230
+ // mkdir can reject (EACCES on reportDir, ENOTDIR on a file) — degrade to
231
+ // a skip, not a lane crash.
232
+ try {
233
+ await mkdir(scratchDir, { recursive: true, mode: 0o777 });
234
+ await chmod(scratchDir, 0o777);
235
+ }
236
+ catch (e) {
237
+ return skip(`scratch dir unusable — ${e.message}`);
238
+ }
239
+ const scratchCheck = await checkSandboxPaths(o.cwd, scratchDir);
240
+ if (!scratchCheck.ok)
241
+ return skip(scratchCheck.reason);
242
+ const indexPaths = o.index === undefined ? undefined : new Set(o.index.entries.map((e) => e.path));
243
+ // The double-run needs a merge-base checkout. Started eagerly so the
244
+ // fetch overlaps the first authoring call; failures don't block the lane —
245
+ // head results still record, but without base nothing can upgrade to
246
+ // `reproduced`.
247
+ const wtDir = join(o.reportDir, 'probes-base');
248
+ const basePromise = (o.meta?.baseSha === undefined
249
+ ? Promise.resolve(undefined)
250
+ : addBaseWorktree(exec, o.cwd, wtDir, o.meta.baseSha, o.token)).catch(() => undefined);
251
+ const records = [];
252
+ let baseDir;
253
+ try {
254
+ for (let i = 0; i < targets.length; i++) {
255
+ const target = targets[i];
256
+ // Authoring stops at the budget edge but must NOT flag the ledger —
257
+ // `budgetExceeded` flips the review verdict, and KTD8 pins the lane
258
+ // to never change verdict. The spend still records on the ledger.
259
+ if (o.budgetUsd !== undefined && o.ledger.visionCostUsd >= o.budgetUsd) {
260
+ log('probes: authoring budget reached — remaining findings stay not_exercised');
261
+ break;
262
+ }
263
+ const exemplarPath = target.file === undefined ? undefined : findExemplarTest(o.index, target.file);
264
+ const authored = await authorProbe(o, target, harness, exemplarPath, indexPaths);
265
+ if (authored.probe === undefined) {
266
+ records.push(record(target, undefined, 'error', `authoring failed: ${authored.reason}`, {
267
+ costUsd: authored.costUsd,
268
+ tokens: authored.tokens,
269
+ }));
270
+ continue;
271
+ }
272
+ // The host write uses a forced argus-probe- prefix + exclusive create —
273
+ // a model-chosen name can never overwrite (then delete) a real test.
274
+ const probe = authored.probe;
275
+ const safeFilename = `argus-probe-${probe.filename}`;
276
+ const relProbe = exemplarPath === undefined ? safeFilename : join(dirname(exemplarPath), safeFilename);
277
+ if (!isSafeRepoPath(relProbe)) {
278
+ records.push(record(target, probe, 'error', `unsafe probe path: ${relProbe}`));
279
+ continue;
280
+ }
281
+ // `..` imports are legit (tests/ → ../src/x) but must resolve inside
282
+ // the checkout from the probe's write location.
283
+ if (!probeImportsSafe(probe, relProbe)) {
284
+ records.push(record(target, probe, 'error', 'relative import escapes repo'));
285
+ continue;
286
+ }
287
+ // Write on the host — the ro workspace mount exposes it to head, and
288
+ // a copy into the base worktree makes the double-run symmetric.
289
+ baseDir ??= await basePromise;
290
+ let wroteHead = false;
291
+ try {
292
+ await writeFile(join(o.cwd, relProbe), probe.content, { encoding: 'utf8', flag: 'wx' });
293
+ wroteHead = true;
294
+ if (baseDir !== undefined) {
295
+ await mkdir(dirname(join(baseDir, relProbe)), { recursive: true });
296
+ await writeFile(join(baseDir, relProbe), probe.content, 'utf8');
297
+ }
298
+ }
299
+ catch (e) {
300
+ // Only remove what WE created — on EEXIST the path holds a real
301
+ // consumer file and rm would delete it.
302
+ if (wroteHead)
303
+ await rm(join(o.cwd, relProbe), { force: true }).catch(() => undefined);
304
+ records.push(record(target, probe, 'error', `probe write failed: ${e.message}`));
305
+ continue;
306
+ }
307
+ try {
308
+ // Head and base runs are independent — distinct workdirs, scratch
309
+ // dirs, and container names — so they run concurrently. The base
310
+ // worktree has no node_modules (worktrees carry tracked files
311
+ // only), so head's deps are bind-mounted ro — without it every
312
+ // vitest/jest base run load-errors and `reproduced` can never fire.
313
+ const nodeModules = join(o.cwd, 'node_modules');
314
+ const hasNodeModules = (await stat(nodeModules).catch(() => undefined))?.isDirectory() === true;
315
+ const baseScratch = baseDir === undefined ? undefined : join(baseDir, SCRATCH_DIR_NAME);
316
+ if (baseScratch !== undefined) {
317
+ await mkdir(baseScratch, { recursive: true, mode: 0o777 });
318
+ await chmod(baseScratch, 0o777).catch(() => undefined);
319
+ }
320
+ // probes-base sits inside the head run's ro mount — mask it so a
321
+ // head probe can't detect/read the base tree and condition on it.
322
+ // Both sides realpath'd — a symlinked checkout must not silently
323
+ // drop the mask.
324
+ const realWt = baseDir === undefined ? undefined : await realpath(baseDir).catch(() => undefined);
325
+ const wtMask = realWt === undefined ? undefined : relative(scratchCheck.realWork, realWt);
326
+ const [head, base] = await Promise.all([
327
+ runProbeInSandbox({
328
+ workdir: o.cwd,
329
+ scratchDir,
330
+ cmd: harness.runCmd(relProbe),
331
+ image,
332
+ name: `head-${i}`,
333
+ exec,
334
+ masks: wtMask !== undefined && isSafeRepoPath(wtMask) ? [wtMask] : [],
335
+ ...sandboxLimits(sandbox),
336
+ }),
337
+ baseDir === undefined || baseScratch === undefined
338
+ ? Promise.resolve(undefined)
339
+ : runProbeInSandbox({
340
+ workdir: baseDir,
341
+ scratchDir: baseScratch,
342
+ cmd: harness.runCmd(relProbe),
343
+ image,
344
+ name: `base-${i}`,
345
+ exec,
346
+ roMounts: hasNodeModules
347
+ ? [{ host: nodeModules, container: '/work/node_modules' }]
348
+ : [],
349
+ ...sandboxLimits(sandbox),
350
+ }),
351
+ ]);
352
+ const headOutcome = outcomeOf(harness, head);
353
+ const baseOutcome = base === undefined ? undefined : outcomeOf(harness, base);
354
+ const output = probeOutput(head.stdout, head.stderr) ??
355
+ (base === undefined ? undefined : probeOutput(base.stdout, base.stderr));
356
+ const extra = {
357
+ headOutcome,
358
+ baseOutcome,
359
+ durationMs: head.durationMs,
360
+ costUsd: authored.costUsd,
361
+ tokens: authored.tokens,
362
+ output,
363
+ };
364
+ let outcome;
365
+ let detail;
366
+ if (headOutcome === 'failed-test' && baseOutcome === 'clean') {
367
+ outcome = 'reproduced';
368
+ detail = `reproduced by Argus probe ${probe.filename} (fails on head, clean on base)`;
369
+ target.evidence = { ...target.evidence, status: 'reproduced', detail };
370
+ }
371
+ else if (headOutcome !== 'failed-test') {
372
+ outcome = headOutcome;
373
+ detail = `head outcome: ${headOutcome}`;
374
+ }
375
+ else if (baseOutcome === undefined) {
376
+ outcome = 'error';
377
+ detail = 'fails on head but base checkout unavailable — unverified';
378
+ }
379
+ else if (baseOutcome === 'failed-test') {
380
+ outcome = 'load-error';
381
+ detail = 'fails on both head and base — probe bug or pre-existing defect';
382
+ }
383
+ else {
384
+ // Base run produced no test verdict (infra error / load-error /
385
+ // not-collected) — report it accurately, never claim "fails on both".
386
+ outcome = 'error';
387
+ detail = `fails on head but base run inconclusive (${baseOutcome}) — unverified`;
388
+ }
389
+ records.push(record(target, probe, outcome, detail, extra));
390
+ }
391
+ finally {
392
+ await rm(join(o.cwd, relProbe), { force: true }).catch(() => undefined);
393
+ if (baseDir !== undefined) {
394
+ await rm(join(baseDir, relProbe), { force: true }).catch(() => undefined);
395
+ }
396
+ }
397
+ }
398
+ }
399
+ finally {
400
+ // The worktree may exist even when the loop never assigned baseDir
401
+ // (e.g. every probe failed authoring) — resolve the promise here so the
402
+ // teardown still runs.
403
+ const bd = baseDir ?? (await basePromise);
404
+ if (bd !== undefined)
405
+ await removeBaseWorktree(exec, o.cwd, wtDir);
406
+ }
407
+ return { records };
408
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "argus-reviewer-e2e",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "argus-reviewer — open-source vision-model E2E testing harness. Record, replay, heal, and assert with any OpenRouter model under a hard budget cap.",
5
5
  "type": "module",
6
6
  "license": "MIT",