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.
- package/README.md +30 -5
- package/action/action.yml +22 -0
- package/action/sticky-comment.mjs +287 -79
- package/dist/api.d.ts +1 -0
- package/dist/api.js +1 -0
- package/dist/cli.d.ts +67 -0
- package/dist/cli.js +463 -86
- package/dist/config.d.ts +105 -2
- package/dist/config.js +136 -9
- package/dist/debug.d.ts +1 -0
- package/dist/debug.js +9 -3
- package/dist/detect.d.ts +11 -1
- package/dist/detect.js +19 -3
- package/dist/evidence/ci.d.ts +47 -2
- package/dist/evidence/ci.js +98 -6
- package/dist/evidence/gate.d.ts +10 -0
- package/dist/evidence/gate.js +29 -0
- package/dist/evidence/link.d.ts +6 -1
- package/dist/evidence/link.js +7 -5
- package/dist/executor/sandbox.d.ts +105 -0
- package/dist/executor/sandbox.js +231 -0
- package/dist/probe/author.d.ts +50 -0
- package/dist/probe/author.js +149 -0
- package/dist/probe/harness.d.ts +30 -0
- package/dist/probe/harness.js +99 -0
- package/dist/probe/queue.d.ts +105 -0
- package/dist/probe/queue.js +446 -0
- package/dist/review/adjudicate.d.ts +63 -0
- package/dist/review/adjudicate.js +111 -0
- package/dist/review/secrets.d.ts +88 -0
- package/dist/review/secrets.js +220 -0
- package/dist/review/triage.d.ts +76 -0
- package/dist/review/triage.js +163 -0
- package/dist/trust.d.ts +50 -0
- package/dist/trust.js +103 -0
- package/dist/vision/cost.d.ts +14 -1
- package/dist/vision/cost.js +13 -0
- package/dist/vision/decisions.d.ts +95 -0
- package/dist/vision/decisions.js +232 -0
- package/package.json +3 -2
package/dist/evidence/link.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
27
|
-
const cur = queue
|
|
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>;
|