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.
- package/README.md +1 -0
- package/action/action.yml +5 -0
- package/action/sticky-comment.mjs +36 -8
- package/dist/cli.js +83 -40
- package/dist/config.d.ts +43 -1
- package/dist/config.js +30 -6
- package/dist/detect.d.ts +11 -1
- package/dist/detect.js +19 -3
- package/dist/evidence/ci.d.ts +44 -2
- package/dist/evidence/ci.js +96 -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 +102 -0
- package/dist/probe/queue.js +408 -0
- package/package.json +1 -1
package/dist/evidence/ci.js
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
|
+
/** Maintainer-applied PR label that opts a fork PR into sandbox probes. */
|
|
2
|
+
export const PROBE_LABEL = 'argus-probe';
|
|
3
|
+
/**
|
|
4
|
+
* GitHub `author_association` values trusted to run probes on fork PRs —
|
|
5
|
+
* repo members/owners/collaborators. CONTRIBUTOR, FIRST_TIME_CONTRIBUTOR,
|
|
6
|
+
* FIRST_TIMER, MANNEQUIN, and NONE are not.
|
|
7
|
+
*/
|
|
8
|
+
export function isTrustedAssociation(association) {
|
|
9
|
+
return (association === 'MEMBER' || association === 'OWNER' || association === 'COLLABORATOR');
|
|
10
|
+
}
|
|
1
11
|
const GH_API = 'https://api.github.com';
|
|
2
12
|
const MAX_CHECK_RUN_PAGES = 5;
|
|
3
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Shared GitHub GET scaffold — Bearer auth, API headers, 30s abort timeout,
|
|
15
|
+
* `ctx.err` on non-ok/timeout, undefined on failure. Reuse for any
|
|
16
|
+
* api.github.com read (fetchPrFiles in cli.ts paginates over it).
|
|
17
|
+
*/
|
|
18
|
+
export async function ghGet(url, token, ctx) {
|
|
4
19
|
const controller = new AbortController();
|
|
5
20
|
const timeout = setTimeout(() => controller.abort(), 30_000);
|
|
6
21
|
try {
|
|
@@ -21,18 +36,93 @@ async function ghGet(url, token, ctx) {
|
|
|
21
36
|
catch (e) {
|
|
22
37
|
if (e instanceof Error && e.name === 'AbortError') {
|
|
23
38
|
ctx.err(`evidence: github request timed out — ${url}`);
|
|
24
|
-
return undefined;
|
|
25
39
|
}
|
|
26
|
-
|
|
40
|
+
else {
|
|
41
|
+
// DNS/socket/protocol failures — same contract: undefined, never throw.
|
|
42
|
+
ctx.err(`evidence: github request failed — ${url} (${e.message})`);
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
27
45
|
}
|
|
28
46
|
finally {
|
|
29
47
|
clearTimeout(timeout);
|
|
30
48
|
}
|
|
31
49
|
}
|
|
32
|
-
/**
|
|
33
|
-
|
|
50
|
+
/**
|
|
51
|
+
* PR metadata for the evidence + probe lanes — the head SHA check-runs attach
|
|
52
|
+
* to, plus the fork/association/label signals the sandbox fork gate (KTD5)
|
|
53
|
+
* evaluates. One `/pulls/{pr}` request; undefined when the request fails.
|
|
54
|
+
*/
|
|
55
|
+
export async function fetchPrMeta(repo, pr, token, ctx) {
|
|
34
56
|
const data = (await ghGet(`${GH_API}/repos/${repo}/pulls/${pr}`, token, ctx));
|
|
35
|
-
|
|
57
|
+
if (data === undefined)
|
|
58
|
+
return undefined;
|
|
59
|
+
const labels = Array.isArray(data.labels)
|
|
60
|
+
? data.labels.map((l) => l?.name).filter((n) => typeof n === 'string')
|
|
61
|
+
: [];
|
|
62
|
+
// The timeline call only matters when the label is the deciding signal —
|
|
63
|
+
// forks from untrusted authors carrying the label. Skip it otherwise.
|
|
64
|
+
const needsTimeline = data.head?.repo?.fork === true &&
|
|
65
|
+
!isTrustedAssociation(data.author_association) &&
|
|
66
|
+
labels.includes(PROBE_LABEL);
|
|
67
|
+
const labelApprovedAt = needsTimeline
|
|
68
|
+
? await fetchLabelApprovedAt(repo, pr, token, ctx)
|
|
69
|
+
: undefined;
|
|
70
|
+
// The pulls payload has NO merge-base field — derive it from the compare
|
|
71
|
+
// API (merge_base_commit.sha). Base-branch tip is the fallback: it can
|
|
72
|
+
// contain fixes the PR never saw and misattribute them to the change.
|
|
73
|
+
const headSha = data.head?.sha;
|
|
74
|
+
const baseSha = data.base?.sha;
|
|
75
|
+
const mergeBase = headSha !== undefined && baseSha !== undefined
|
|
76
|
+
? await fetchMergeBase(repo, baseSha, headSha, token, ctx)
|
|
77
|
+
: undefined;
|
|
78
|
+
return {
|
|
79
|
+
headSha,
|
|
80
|
+
baseSha: mergeBase ?? baseSha,
|
|
81
|
+
// head.repo is null when the source fork was deleted — fail closed and
|
|
82
|
+
// treat it as a fork so the probe gate still applies.
|
|
83
|
+
isFork: data.head?.repo?.fork !== false,
|
|
84
|
+
authorAssociation: typeof data.author_association === 'string' ? data.author_association : undefined,
|
|
85
|
+
labels,
|
|
86
|
+
pushedAt: data.head?.repo?.pushed_at,
|
|
87
|
+
labelApprovedAt,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Merge base via the compare API — `GET /compare/{base}...{head}` returns
|
|
92
|
+
* `merge_base_commit.sha`, the commit the PR actually diverged from.
|
|
93
|
+
*/
|
|
94
|
+
async function fetchMergeBase(repo, base, head, token, ctx) {
|
|
95
|
+
const data = (await ghGet(`${GH_API}/repos/${repo}/compare/${base}...${head}`, token, ctx));
|
|
96
|
+
return data?.merge_base_commit?.sha;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Newest `labeled` event for `argus-probe` on the PR's issue events feed.
|
|
100
|
+
* The label on the payload proves it's currently applied; the event
|
|
101
|
+
* timestamp is what binds approval to the current head (a `synchronize`
|
|
102
|
+
* push after the label must not inherit it). Uses the *events* endpoint —
|
|
103
|
+
* not timeline — because it carries only state events (no comments), so a
|
|
104
|
+
* busy PR's `labeled` event isn't drowned past page one. Pages are
|
|
105
|
+
* oldest-first with no reverse sort, so we take the newest within a
|
|
106
|
+
* bounded 3-page scan; a still-busier PR fails closed.
|
|
107
|
+
*/
|
|
108
|
+
async function fetchLabelApprovedAt(repo, pr, token, ctx) {
|
|
109
|
+
let latest;
|
|
110
|
+
for (let page = 1; page <= 3; page++) {
|
|
111
|
+
const events = (await ghGet(`${GH_API}/repos/${repo}/issues/${pr}/events?per_page=100&page=${page}`, token, ctx));
|
|
112
|
+
if (!Array.isArray(events))
|
|
113
|
+
return undefined;
|
|
114
|
+
for (const e of events) {
|
|
115
|
+
if (e?.event === 'labeled' &&
|
|
116
|
+
e.label?.name === PROBE_LABEL &&
|
|
117
|
+
typeof e.created_at === 'string') {
|
|
118
|
+
if (latest === undefined || e.created_at > latest)
|
|
119
|
+
latest = e.created_at;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (events.length < 100)
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
return latest;
|
|
36
126
|
}
|
|
37
127
|
/** Check-runs on a commit — the consumer's own CI signal. */
|
|
38
128
|
export async function fetchCheckRuns(repo, sha, token, ctx) {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Sandbox } from '../config.js';
|
|
2
|
+
import { type PrMeta } from './ci.js';
|
|
3
|
+
/**
|
|
4
|
+
* Fork gate for the sandbox probe lane (KTD5). Evaluation order: the lane
|
|
5
|
+
* must be `enabled`; `allowForks: true` runs everywhere; same-repo PRs run
|
|
6
|
+
* unconditionally; fork PRs require a per-head `argus-probe` label or a
|
|
7
|
+
* trusted author_association. Fails closed — missing PR metadata, deleted
|
|
8
|
+
* forks (`isFork` forced true in fetchPrMeta), and stale labels all deny.
|
|
9
|
+
*/
|
|
10
|
+
export declare function mayProbePr(meta: PrMeta | undefined, sandbox: Sandbox): boolean;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { isTrustedAssociation, PROBE_LABEL } from './ci.js';
|
|
2
|
+
/**
|
|
3
|
+
* The label approves only the head it was applied to: the `labeled` event
|
|
4
|
+
* must postdate `head.repo.pushed_at`. A `synchronize` push after approval
|
|
5
|
+
* requires a fresh label — otherwise approval silently carries to code the
|
|
6
|
+
* maintainer never saw. Either timestamp missing → label doesn't approve
|
|
7
|
+
* (fail closed).
|
|
8
|
+
*/
|
|
9
|
+
function labelCoversHead(meta) {
|
|
10
|
+
return (meta.labelApprovedAt !== undefined &&
|
|
11
|
+
meta.pushedAt !== undefined &&
|
|
12
|
+
meta.labelApprovedAt > meta.pushedAt);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Fork gate for the sandbox probe lane (KTD5). Evaluation order: the lane
|
|
16
|
+
* must be `enabled`; `allowForks: true` runs everywhere; same-repo PRs run
|
|
17
|
+
* unconditionally; fork PRs require a per-head `argus-probe` label or a
|
|
18
|
+
* trusted author_association. Fails closed — missing PR metadata, deleted
|
|
19
|
+
* forks (`isFork` forced true in fetchPrMeta), and stale labels all deny.
|
|
20
|
+
*/
|
|
21
|
+
export function mayProbePr(meta, sandbox) {
|
|
22
|
+
if (!sandbox.enabled || meta === undefined)
|
|
23
|
+
return false;
|
|
24
|
+
if (sandbox.allowForks || !meta.isFork)
|
|
25
|
+
return true;
|
|
26
|
+
if (isTrustedAssociation(meta.authorAssociation))
|
|
27
|
+
return true;
|
|
28
|
+
return meta.labels.includes(PROBE_LABEL) && labelCoversHead(meta);
|
|
29
|
+
}
|
package/dist/evidence/link.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { RepoIndex } from '../index/scan.js';
|
|
2
2
|
import type { CheckRun } from './ci.js';
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* `reproduced` is emitted only by the B.2 probe stage — a sandboxed test probe
|
|
5
|
+
* demonstrated the defect (KTD4). `linkFindings` never produces it; the probe
|
|
6
|
+
* stage is the only writer and can only move `not_exercised` → `reproduced`.
|
|
7
|
+
*/
|
|
8
|
+
export type EvidenceStatus = 'exercised' | 'corroborated' | 'not_exercised' | 'inconclusive' | 'reproduced';
|
|
4
9
|
export interface Evidence {
|
|
5
10
|
status: EvidenceStatus;
|
|
6
11
|
detail: string;
|
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[];
|