argus-reviewer-e2e 0.1.2 → 0.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.
@@ -2,13 +2,15 @@
2
2
  const TEST_FILE_RE = /(?:^|\/)(?:tests?|e2e|__tests__|__spec__)\/|\.(?:test|spec|e2e-spec)\.[jt]sx?$/i;
3
3
  /** Check-run names that look like test jobs (lint/build/status lanes don't count). */
4
4
  const TEST_RUN_RE = /\b(test|tests|spec|specs|e2e|smoke|jest|vitest|pytest|rspec|mocha|ava|unittest)\b/i;
5
- /** Argus's own check-runs are the reporter, not consumer CI evidence. */
6
- const ARGUS_RUN_RE = /argus/i;
5
+ /** Argus's own check-runs are the reporter, not consumer CI evidence.
6
+ * Anchored to our own names — a consumer's `argus-unit-tests` lane must
7
+ * still count. */
8
+ const ARGUS_RUN_RE = /^argus(?:-reviewer)?[\s/]/i;
7
9
  /** Check-run conclusions that mean "didn't actually run" — never evidence. */
8
10
  const DID_NOT_RUN = new Set(['neutral', 'skipped', 'cancelled', 'action_required', 'stale', 'startup_failure']);
9
11
  /** Strip comment-hostile characters — check-run names are repo-controlled and reach the PR comment. */
10
12
  export function sanitizeForComment(s, max = 80) {
11
- return s.replace(/[|\r\n<>]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
13
+ return s.replace(/[|\r\n<>`]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
12
14
  }
13
15
  export function isTestFile(path) {
14
16
  return TEST_FILE_RE.test(path);
@@ -23,8 +25,8 @@ export function testReachableFiles(index) {
23
25
  const roots = index.entries.filter((e) => isTestFile(e.path)).map((e) => e.path);
24
26
  const seen = new Set(roots);
25
27
  const queue = [...roots];
26
- while (queue.length > 0) {
27
- const cur = queue.shift();
28
+ for (let i = 0; i < queue.length; i++) {
29
+ const cur = queue[i];
28
30
  for (const dep of byPath.get(cur)?.imports ?? []) {
29
31
  if (!seen.has(dep)) {
30
32
  seen.add(dep);
@@ -0,0 +1,105 @@
1
+ import type { Sandbox } from '../config.js';
2
+ import { type ExecFn } from '../detect.js';
3
+ /**
4
+ * Docker sandbox for the B.2 probe lane — the security boundary around
5
+ * executing PR-contributed probe code on the consumer's runner.
6
+ *
7
+ * Invariants (KTD2 / R4): no ambient env, no network, read-only root,
8
+ * non-root, all capabilities dropped, no-new-privileges, resource + PID
9
+ * limits, `.git` masked inside the container, exactly one writable bind
10
+ * mount (the probes scratch dir), and a wall-clock timeout enforced by
11
+ * daemon-side `docker rm -f` — killing the `docker` CLI alone does not
12
+ * guarantee the container dies when its PID 1 traps the forwarded signal.
13
+ */
14
+ /** Output cap per stream — probe output is attacker-controlled. */
15
+ export declare const SANDBOX_OUTPUT_CAP: number;
16
+ /** Filesystem path inside the container where the workspace lands. */
17
+ export declare const CONTAINER_WORKDIR = "/work";
18
+ /** Name of the single writable mount — lives under reportDir/probes-out. */
19
+ export declare const SCRATCH_DIR_NAME = "probes-out";
20
+ export interface SandboxRunOptions {
21
+ /** Absolute host path of the PR checkout — bind-mounted at /work:ro. */
22
+ workdir: string;
23
+ /**
24
+ * Absolute host path of the writable scratch dir. Must resolve (after
25
+ * realpath) to a strict descendant of workdir — a checked-in symlink must
26
+ * never turn this into a writable bind of an arbitrary host path.
27
+ */
28
+ scratchDir: string;
29
+ /** In-container command argv. */
30
+ cmd: string[];
31
+ /** Resolved image (`config.sandbox.image` or `node:<host major>-slim`). */
32
+ image: string;
33
+ /** Container name suffix — becomes `argus-probe-<pid>-<name>`. */
34
+ name: string;
35
+ timeoutMs: number;
36
+ memory: string;
37
+ cpus: string;
38
+ pidsLimit: number;
39
+ /**
40
+ * Extra host dirs bind-mounted read-only at the given container path —
41
+ * the base run borrows head's node_modules since a git worktree has none.
42
+ */
43
+ roMounts?: {
44
+ host: string;
45
+ container: string;
46
+ }[] | undefined;
47
+ /** Extra container paths (under /work) masked with tmpfs. */
48
+ masks?: string[] | undefined;
49
+ exec?: ExecFn;
50
+ }
51
+ export interface SandboxRunResult {
52
+ /** -1 when the run never started (path check, spawn failure). */
53
+ exitCode: number;
54
+ stdout: string;
55
+ stderr: string;
56
+ durationMs: number;
57
+ timedOut: boolean;
58
+ }
59
+ /** Strict C0/control-byte strip — applied before output leaves the module. */
60
+ export declare function stripControlChars(s: string): string;
61
+ /**
62
+ * Resolve the probe image: an explicit `sandbox.image` always wins;
63
+ * otherwise pin to the host's own Node major so native `node_modules`
64
+ * (ABI-bound to the installing Node) load inside the container.
65
+ */
66
+ export declare function resolveSandboxImage(image: string | undefined): string;
67
+ /**
68
+ * Filesystem safety check before any `docker run`: the scratch dir must
69
+ * realpath-resolve to a strict descendant of the real workdir. Rejects a
70
+ * reportDir symlinked out of the tree or configured outside the checkout —
71
+ * both would turn the rw bind-mount into a write primitive on the host.
72
+ */
73
+ export declare function checkSandboxPaths(workdir: string, scratchDir: string): Promise<{
74
+ ok: true;
75
+ realWork: string;
76
+ realScratch: string;
77
+ relMount: string;
78
+ } | {
79
+ ok: false;
80
+ reason: string;
81
+ }>;
82
+ /**
83
+ * Docker is usable AND resolves the runner's workspace path — bind-mount
84
+ * sources are evaluated by the daemon, so a remote/containerized daemon can
85
+ * silently mount an empty dir. The smoke run uses the SAME hardening
86
+ * profile as probe runs (plus `--pull always` and an entrypoint override) —
87
+ * an availability check that ran the image unhardened would bypass every
88
+ * invariant this module exists to enforce.
89
+ */
90
+ export declare function dockerAvailable(exec: ExecFn, image: string, workdir: string): Promise<boolean>;
91
+ /** How `.git` presents on disk — dir (normal checkout), file (worktree), or absent. */
92
+ type GitMode = 'dir' | 'file' | 'absent';
93
+ /**
94
+ * The full `docker run` argv (KTD2). Every flag is pinned here — this is the
95
+ * single place the sandbox boundary lives. `name` is the full container name.
96
+ */
97
+ export declare function buildSandboxArgv(opts: Pick<SandboxRunOptions, 'image' | 'cmd' | 'memory' | 'cpus' | 'pidsLimit' | 'roMounts' | 'masks'>, name: string, checked: {
98
+ realWork: string;
99
+ realScratch: string;
100
+ relMount: string;
101
+ }, gitMode: GitMode, secretFiles: string[]): string[];
102
+ export declare function runProbeInSandbox(opts: SandboxRunOptions): Promise<SandboxRunResult>;
103
+ /** Limits convenience — what `runProbeInSandbox` needs from config.sandbox. */
104
+ export declare function sandboxLimits(sandbox: Sandbox): Pick<SandboxRunOptions, 'timeoutMs' | 'memory' | 'cpus' | 'pidsLimit'>;
105
+ export {};
@@ -0,0 +1,231 @@
1
+ import { realpath, stat } from 'node:fs/promises';
2
+ import { isAbsolute, join, relative, resolve } from 'node:path';
3
+ import { defaultExec } from '../detect.js';
4
+ /**
5
+ * Docker sandbox for the B.2 probe lane — the security boundary around
6
+ * executing PR-contributed probe code on the consumer's runner.
7
+ *
8
+ * Invariants (KTD2 / R4): no ambient env, no network, read-only root,
9
+ * non-root, all capabilities dropped, no-new-privileges, resource + PID
10
+ * limits, `.git` masked inside the container, exactly one writable bind
11
+ * mount (the probes scratch dir), and a wall-clock timeout enforced by
12
+ * daemon-side `docker rm -f` — killing the `docker` CLI alone does not
13
+ * guarantee the container dies when its PID 1 traps the forwarded signal.
14
+ */
15
+ /** Output cap per stream — probe output is attacker-controlled. */
16
+ export const SANDBOX_OUTPUT_CAP = 32 * 1024;
17
+ /** Filesystem path inside the container where the workspace lands. */
18
+ export const CONTAINER_WORKDIR = '/work';
19
+ /** Name of the single writable mount — lives under reportDir/probes-out. */
20
+ export const SCRATCH_DIR_NAME = 'probes-out';
21
+ /** Strict C0/control-byte strip — applied before output leaves the module. */
22
+ export function stripControlChars(s) {
23
+ // eslint-disable-next-line no-control-regex
24
+ return s.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
25
+ }
26
+ /**
27
+ * Resolve the probe image: an explicit `sandbox.image` always wins;
28
+ * otherwise pin to the host's own Node major so native `node_modules`
29
+ * (ABI-bound to the installing Node) load inside the container.
30
+ */
31
+ export function resolveSandboxImage(image) {
32
+ return image ?? `node:${process.versions.node.split('.')[0]}-slim`;
33
+ }
34
+ /**
35
+ * Filesystem safety check before any `docker run`: the scratch dir must
36
+ * realpath-resolve to a strict descendant of the real workdir. Rejects a
37
+ * reportDir symlinked out of the tree or configured outside the checkout —
38
+ * both would turn the rw bind-mount into a write primitive on the host.
39
+ */
40
+ export async function checkSandboxPaths(workdir, scratchDir) {
41
+ try {
42
+ const realWork = await realpath(resolve(workdir));
43
+ const realScratch = await realpath(resolve(scratchDir));
44
+ const rel = relative(realWork, realScratch);
45
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
46
+ return { ok: false, reason: `scratch dir ${scratchDir} is not inside ${workdir}` };
47
+ }
48
+ return { ok: true, realWork, realScratch, relMount: rel };
49
+ }
50
+ catch (e) {
51
+ return { ok: false, reason: `path check failed: ${e.message}` };
52
+ }
53
+ }
54
+ /**
55
+ * Docker is usable AND resolves the runner's workspace path — bind-mount
56
+ * sources are evaluated by the daemon, so a remote/containerized daemon can
57
+ * silently mount an empty dir. The smoke run uses the SAME hardening
58
+ * profile as probe runs (plus `--pull always` and an entrypoint override) —
59
+ * an availability check that ran the image unhardened would bypass every
60
+ * invariant this module exists to enforce.
61
+ */
62
+ export async function dockerAvailable(exec, image, workdir) {
63
+ const name = containerName('avail');
64
+ let probe;
65
+ try {
66
+ const version = await exec('docker', ['version', '--format', '{{.Server.Version}}'], 10_000);
67
+ if (version.code !== 0)
68
+ return false;
69
+ probe = await exec('docker', [
70
+ 'run',
71
+ '--rm',
72
+ '--name',
73
+ name,
74
+ '--pull',
75
+ 'always',
76
+ '--network',
77
+ 'none',
78
+ '--read-only',
79
+ '--cap-drop',
80
+ 'ALL',
81
+ '--security-opt',
82
+ 'no-new-privileges',
83
+ '--user',
84
+ '65534:65534',
85
+ '--entrypoint',
86
+ 'test',
87
+ '-v',
88
+ `${workdir}:${CONTAINER_WORKDIR}:ro`,
89
+ image,
90
+ '-d',
91
+ CONTAINER_WORKDIR,
92
+ ],
93
+ // Cold-pull headroom: on a daemon that has never seen the image,
94
+ // `--pull always` downloads it inside this call — 60s under-runs a
95
+ // first pull and falsely reports docker as unusable.
96
+ 300_000);
97
+ }
98
+ catch {
99
+ // Spawn failure (no docker binary, daemon gone) → unavailable, and the
100
+ // named container may still be half-created — remove it best-effort.
101
+ await forceRemove(exec, name);
102
+ return false;
103
+ }
104
+ if (probe.timedOut === true)
105
+ await forceRemove(exec, name);
106
+ return probe.code === 0;
107
+ }
108
+ /**
109
+ * Container name — shared by `docker run --name` and the `rm -f` teardown.
110
+ * Includes the pid so two concurrent review jobs on one daemon can't
111
+ * collide names (or force-remove each other's containers).
112
+ */
113
+ function containerName(name) {
114
+ return `argus-probe-${process.pid}-${name}`;
115
+ }
116
+ /**
117
+ * The full `docker run` argv (KTD2). Every flag is pinned here — this is the
118
+ * single place the sandbox boundary lives. `name` is the full container name.
119
+ */
120
+ export function buildSandboxArgv(opts, name, checked, gitMode, secretFiles) {
121
+ const argv = [
122
+ 'run',
123
+ '--rm',
124
+ '--name',
125
+ name,
126
+ // Mutable tags on a persistent runner can be poisoned by earlier jobs —
127
+ // always resolve from the registry (daemon-side network; the container
128
+ // itself stays offline).
129
+ '--pull',
130
+ 'always',
131
+ '--network',
132
+ 'none',
133
+ '--read-only',
134
+ '--cap-drop',
135
+ 'ALL',
136
+ '--security-opt',
137
+ 'no-new-privileges',
138
+ '--user',
139
+ '65534:65534',
140
+ '--pids-limit',
141
+ String(opts.pidsLimit),
142
+ '--memory',
143
+ opts.memory,
144
+ '--cpus',
145
+ opts.cpus,
146
+ '--tmpfs',
147
+ '/tmp:rw,nosuid,nodev,noexec',
148
+ '-v',
149
+ `${checked.realWork}:${CONTAINER_WORKDIR}:ro`,
150
+ ];
151
+ // Shadow .git — actions/checkout persists a base64 GITHUB_TOKEN in
152
+ // .git/config; PR code must not read it. tmpfs masks a *directory*; a
153
+ // worktree's `.git` pointer file (or any root-level secret file staged by
154
+ // earlier steps) is masked with a read-only /dev/null bind.
155
+ if (gitMode === 'dir')
156
+ argv.push('--tmpfs', `${CONTAINER_WORKDIR}/.git`);
157
+ for (const f of gitMode === 'file' ? ['.git', ...secretFiles] : secretFiles) {
158
+ argv.push('-v', `/dev/null:${CONTAINER_WORKDIR}/${f}:ro`);
159
+ }
160
+ for (const mask of opts.masks ?? [])
161
+ argv.push('--tmpfs', `${CONTAINER_WORKDIR}/${mask}`);
162
+ for (const m of opts.roMounts ?? [])
163
+ argv.push('-v', `${m.host}:${m.container}:ro`);
164
+ argv.push('-v', `${checked.realScratch}:${CONTAINER_WORKDIR}/${checked.relMount}:rw`, '-w', CONTAINER_WORKDIR,
165
+ // Declared env allowlist only — no host env is inherited. HOME=/tmp
166
+ // because `nobody`'s passwd home is /nonexistent on a read-only root.
167
+ '--env', 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', '--env', 'HOME=/tmp', '--env', 'npm_config_cache=/tmp/.npm', opts.image, ...opts.cmd);
168
+ return argv;
169
+ }
170
+ /** Daemon-side kill — the non-ignorable half of the R4 wall-clock bound. */
171
+ async function forceRemove(exec, name) {
172
+ await exec('docker', ['rm', '-f', name], 15_000).catch(() => undefined);
173
+ }
174
+ /**
175
+ * Run one command in the hardened container. On timeout the docker client
176
+ * is killed first, then `docker rm -f` guarantees teardown — SIGKILL via
177
+ * the daemon is non-ignorable, which is what makes the R4 wall-clock bound
178
+ * real. Path-check and spawn failures return a `exitCode: -1` result
179
+ * carrying the reason; this function never throws past its callers.
180
+ */
181
+ /** Workspace-root files that commonly carry credentials — masked when present. */
182
+ const SECRET_FILE_NAMES = ['.env', '.npmrc', '.netrc', '.git-credentials'];
183
+ export async function runProbeInSandbox(opts) {
184
+ const exec = opts.exec ?? defaultExec;
185
+ const checked = await checkSandboxPaths(opts.workdir, opts.scratchDir);
186
+ if (!checked.ok) {
187
+ return { exitCode: -1, stdout: '', stderr: checked.reason, durationMs: 0, timedOut: false };
188
+ }
189
+ const gitStat = await stat(join(checked.realWork, '.git')).catch(() => undefined);
190
+ const gitMode = gitStat === undefined ? 'absent' : gitStat.isDirectory() ? 'dir' : 'file';
191
+ const secretFiles = (await Promise.all(SECRET_FILE_NAMES.map(async (f) => (await stat(join(checked.realWork, f)).catch(() => undefined))?.isFile() === true
192
+ ? f
193
+ : undefined))).filter((f) => f !== undefined);
194
+ const name = containerName(opts.name);
195
+ const argv = buildSandboxArgv(opts, name, checked, gitMode, secretFiles);
196
+ const started = Date.now();
197
+ let res;
198
+ try {
199
+ res = await exec('docker', argv, opts.timeoutMs);
200
+ }
201
+ catch (e) {
202
+ await forceRemove(exec, name);
203
+ return {
204
+ exitCode: -1,
205
+ stdout: '',
206
+ stderr: e.message,
207
+ durationMs: Date.now() - started,
208
+ timedOut: false,
209
+ };
210
+ }
211
+ const durationMs = Date.now() - started;
212
+ if (res.timedOut === true) {
213
+ await forceRemove(exec, name);
214
+ }
215
+ return {
216
+ exitCode: res.code,
217
+ stdout: stripControlChars(res.stdout).slice(0, SANDBOX_OUTPUT_CAP),
218
+ stderr: stripControlChars(res.stderr).slice(0, SANDBOX_OUTPUT_CAP),
219
+ durationMs,
220
+ timedOut: res.timedOut === true,
221
+ };
222
+ }
223
+ /** Limits convenience — what `runProbeInSandbox` needs from config.sandbox. */
224
+ export function sandboxLimits(sandbox) {
225
+ return {
226
+ timeoutMs: sandbox.timeoutMs,
227
+ memory: sandbox.memory,
228
+ cpus: sandbox.cpus,
229
+ pidsLimit: sandbox.pidsLimit,
230
+ };
231
+ }
@@ -0,0 +1,50 @@
1
+ import type { JsonSchema, Message } from '../vision/openrouter.js';
2
+ import type { Harness } from './harness.js';
3
+ /**
4
+ * Probe authoring (KTD7): one bounded model call per `not_exercised`
5
+ * finding produces a single test file asserting the *correct* behavior —
6
+ * so the defect's presence fails the test on head while the base checkout
7
+ * passes. The file is written on the HOST (inside the ro workspace mount),
8
+ * so `filename` and `content` are validated hard: the model's input includes
9
+ * PR-controlled file contents, and a prompt-injected traversal or secret
10
+ * read would escape the sandbox's whole purpose.
11
+ */
12
+ export declare const PROBE_SCHEMA: JsonSchema;
13
+ export interface ProbeTarget {
14
+ file?: string | undefined;
15
+ line?: number | undefined;
16
+ severity: string;
17
+ message: string;
18
+ }
19
+ export interface AuthoredProbe {
20
+ filename: string;
21
+ content: string;
22
+ reasoning: string;
23
+ /** Import specifiers extracted at parse time — checked against the write path in the queue. */
24
+ imports: string[];
25
+ }
26
+ export type ProbeParseResult = {
27
+ ok: true;
28
+ probe: AuthoredProbe;
29
+ } | {
30
+ ok: false;
31
+ reason: string;
32
+ };
33
+ /**
34
+ * Basename-only, forced test extension — no separators, no `..`. The write
35
+ * happens on the host, so this is the traversal boundary.
36
+ */
37
+ export declare const PROBE_FILENAME_RE: RegExp;
38
+ /** Probe files are bounded — a runaway generation is rejected, not truncated. */
39
+ export declare const PROBE_CONTENT_CAP: number;
40
+ export declare function parseProbe(raw: string): ProbeParseResult;
41
+ /**
42
+ * Verify every relative import in a probe resolves inside the repo, given
43
+ * the probe's repo-relative write path. `../../etc/passwd` from a shallow
44
+ * dir escapes the checkout — reject.
45
+ */
46
+ export declare function probeImportsSafe(probe: AuthoredProbe, relProbePath: string): boolean;
47
+ export declare function buildProbeMessages(target: ProbeTarget, fileContents: string | undefined, exemplarTest: {
48
+ path: string;
49
+ content: string;
50
+ } | undefined, harness: Harness): Message[];
@@ -0,0 +1,149 @@
1
+ import { posix } from 'node:path';
2
+ import ts from 'typescript';
3
+ /**
4
+ * Probe authoring (KTD7): one bounded model call per `not_exercised`
5
+ * finding produces a single test file asserting the *correct* behavior —
6
+ * so the defect's presence fails the test on head while the base checkout
7
+ * passes. The file is written on the HOST (inside the ro workspace mount),
8
+ * so `filename` and `content` are validated hard: the model's input includes
9
+ * PR-controlled file contents, and a prompt-injected traversal or secret
10
+ * read would escape the sandbox's whole purpose.
11
+ */
12
+ export const PROBE_SCHEMA = {
13
+ name: 'argus-probe',
14
+ strict: true,
15
+ schema: {
16
+ type: 'object',
17
+ properties: {
18
+ filename: {
19
+ type: 'string',
20
+ description: 'Bare test filename matching the repo convention, e.g. probe-xyz.test.ts',
21
+ },
22
+ content: { type: 'string', description: 'Complete test file contents' },
23
+ reasoning: { type: 'string' },
24
+ },
25
+ required: ['filename', 'content', 'reasoning'],
26
+ additionalProperties: false,
27
+ },
28
+ };
29
+ /**
30
+ * Basename-only, forced test extension — no separators, no `..`. The write
31
+ * happens on the host, so this is the traversal boundary.
32
+ */
33
+ export const PROBE_FILENAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]*\.test\.[jt]sx?$/;
34
+ /** Probe files are bounded — a runaway generation is rejected, not truncated. */
35
+ export const PROBE_CONTENT_CAP = 32 * 1024;
36
+ /** Reads of secret-looking env vars are forbidden — defense in depth on the stripped container env. */
37
+ const SECRET_ENV_RE = /process\.env\s*(?:\.|\[)\s*['"]?\w*(KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)/i;
38
+ /**
39
+ * Import specifiers via the TypeScript scanner (same seam as the index) —
40
+ * a regex misses bare side-effect imports like `import '/abs/x'`. Returns
41
+ * undefined when the scanner itself fails — fail closed, never silently
42
+ * skip the import check.
43
+ */
44
+ function importSpecifiers(content) {
45
+ try {
46
+ const info = ts.preProcessFile(content, true, true);
47
+ return [...info.importedFiles, ...info.referencedFiles].map((f) => f.fileName);
48
+ }
49
+ catch {
50
+ return undefined;
51
+ }
52
+ }
53
+ export function parseProbe(raw) {
54
+ let parsed;
55
+ try {
56
+ parsed = JSON.parse(raw);
57
+ }
58
+ catch {
59
+ return { ok: false, reason: 'unparseable model response' };
60
+ }
61
+ if (typeof parsed.filename !== 'string' || !PROBE_FILENAME_RE.test(parsed.filename)) {
62
+ return { ok: false, reason: `unsafe filename: ${String(parsed.filename)}` };
63
+ }
64
+ if (typeof parsed.content !== 'string' || parsed.content.trim() === '') {
65
+ return { ok: false, reason: 'missing or empty content' };
66
+ }
67
+ if (parsed.content.length > PROBE_CONTENT_CAP) {
68
+ return { ok: false, reason: `content exceeds ${PROBE_CONTENT_CAP} bytes` };
69
+ }
70
+ if (SECRET_ENV_RE.test(parsed.content)) {
71
+ return { ok: false, reason: 'probe reads secret-looking env vars' };
72
+ }
73
+ const imports = importSpecifiers(parsed.content);
74
+ if (imports === undefined) {
75
+ return { ok: false, reason: 'probe content failed import scanning' };
76
+ }
77
+ for (const spec of imports) {
78
+ // Reject anything that isn't a bare specifier or plain relative path:
79
+ // absolute paths, drive letters, UNC, and scheme-prefixed specifiers
80
+ // (`file:///etc/passwd` is a host read, `data:` smuggles a module).
81
+ // `node:` builtins stay allowed — node-test probes need `node:test`.
82
+ if (spec.startsWith('/') ||
83
+ /^[a-zA-Z]:/.test(spec) ||
84
+ spec.startsWith('\\\\') ||
85
+ (/^[a-z][a-z0-9+.-]*:/i.test(spec) && !spec.startsWith('node:'))) {
86
+ return { ok: false, reason: `unsafe import: ${spec}` };
87
+ }
88
+ }
89
+ return {
90
+ ok: true,
91
+ probe: {
92
+ filename: parsed.filename,
93
+ content: parsed.content,
94
+ reasoning: typeof parsed.reasoning === 'string' ? parsed.reasoning : '',
95
+ imports,
96
+ },
97
+ };
98
+ }
99
+ /**
100
+ * Verify every relative import in a probe resolves inside the repo, given
101
+ * the probe's repo-relative write path. `../../etc/passwd` from a shallow
102
+ * dir escapes the checkout — reject.
103
+ */
104
+ export function probeImportsSafe(probe, relProbePath) {
105
+ // Repo-relative paths are always `/`-separated — compute in posix so a
106
+ // Windows host can't turn `../x` into `..\x` and slip the `../` check.
107
+ const dir = posix.dirname(relProbePath);
108
+ return probe.imports.every((spec) => {
109
+ if (!spec.startsWith('.'))
110
+ return true; // bare package specifier — resolved from node_modules
111
+ const resolved = posix.normalize(posix.join(dir, spec));
112
+ return resolved !== '..' && !resolved.startsWith('../') && !posix.isAbsolute(resolved);
113
+ });
114
+ }
115
+ export function buildProbeMessages(target, fileContents, exemplarTest, harness) {
116
+ const exemplar = exemplarTest === undefined
117
+ ? '(no existing test file was available as an exemplar — follow standard idiom)'
118
+ : `Exemplar test file (${exemplarTest.path}) — match its imports, naming, and assertion style:\n\n${exemplarTest.content}`;
119
+ const implicated = fileContents === undefined
120
+ ? `(file contents unavailable; the finding references ${target.file ?? 'unknown'})`
121
+ : `Contents of ${target.file}:\n\n${fileContents}`;
122
+ return [
123
+ {
124
+ role: 'system',
125
+ content: [
126
+ {
127
+ type: 'text',
128
+ text: `You author a single failing test — a "probe" — that reproduces a suspected defect ` +
129
+ `found by code review. The probe runs under ${harness.kind} in an offline sandbox. ` +
130
+ `Write a self-contained ${harness.kind} test that asserts the CORRECT behavior: if the ` +
131
+ `defect is real, the test fails; if the code is fine, it passes. Import only repo ` +
132
+ `modules via relative paths and packages the repo already uses. No network, no ` +
133
+ `filesystem outside the repo, no timers, no process.env reads. filename must be a ` +
134
+ `bare filename ending in .test.ts/.test.js matching the exemplar's convention.`,
135
+ },
136
+ ],
137
+ },
138
+ {
139
+ role: 'user',
140
+ content: [
141
+ {
142
+ type: 'text',
143
+ text: `Finding (${target.severity}) in ${target.file ?? 'unknown'}${target.line ? `:${target.line}` : ''}:\n` +
144
+ `${target.message}\n\n${implicated}\n\n${exemplar}`,
145
+ },
146
+ ],
147
+ },
148
+ ];
149
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Test-harness detection + result classification for the B.2 probe lane
3
+ * (KTD3). v1 is deliberately Node-only: vitest, jest, and `node --test`.
4
+ *
5
+ * Two implementation details carry the weight:
6
+ * - Positional file args are *filters* intersected with the consumer's
7
+ * configured include/testMatch/roots, so probes must live where the
8
+ * runner already looks (the queue writes them beside an exemplar test).
9
+ * - Invocations bypass `npx` — as `nobody` on a read-only root there is no
10
+ * writable HOME, so the installed binary is invoked directly and each
11
+ * runner's cache is redirected into the /tmp tmpfs.
12
+ */
13
+ export type ProbeOutcome = 'failed-test' | 'load-error' | 'not-collected' | 'clean';
14
+ export interface Harness {
15
+ kind: 'vitest' | 'jest' | 'node-test';
16
+ /** In-container argv running exactly one probe file (path relative to /work). */
17
+ runCmd(probeRelPath: string): string[];
18
+ /** Map captured output to the report vocabulary. */
19
+ classify(res: {
20
+ exitCode: number;
21
+ stdout: string;
22
+ stderr: string;
23
+ }): ProbeOutcome;
24
+ }
25
+ /**
26
+ * Detect the consumer's test harness from package.json. Returns undefined
27
+ * when no supported harness exists — the probe lane degrades to a detail
28
+ * note, not a failure.
29
+ */
30
+ export declare function detectHarness(cwd: string): Promise<Harness | undefined>;