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/config.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Trust } from './trust.js';
|
|
1
2
|
export interface ProviderRules {
|
|
2
3
|
only?: string[];
|
|
3
4
|
ignore?: string[];
|
|
@@ -10,6 +11,41 @@ export interface Target {
|
|
|
10
11
|
url: string;
|
|
11
12
|
readyTimeoutMs: number;
|
|
12
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Sandbox probe lane (Phase B.2, KTD5): runs authored test probes against
|
|
16
|
+
* `not_exercised` code-review findings inside a hardened Docker container.
|
|
17
|
+
* Opt-in — `enabled` defaults to false. Fork PRs are gated by `allowForks`,
|
|
18
|
+
* the `argus-probe` label, or a trusted author_association (see
|
|
19
|
+
* `evidence/gate.ts`).
|
|
20
|
+
*/
|
|
21
|
+
export interface Sandbox {
|
|
22
|
+
/** Master switch for the probe lane. Default false. */
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Docker image probes run in — trusted maintainer config (a custom image
|
|
26
|
+
* extends the sandbox's trusted computing base). Default undefined →
|
|
27
|
+
* resolved at probe time as `node:<host Node major>-slim`, because native
|
|
28
|
+
* `node_modules` are ABI-bound to the Node version that installed them.
|
|
29
|
+
*/
|
|
30
|
+
image: string | undefined;
|
|
31
|
+
/** Max probes authored/executed per code-review run. Default 3. */
|
|
32
|
+
maxProbes: number;
|
|
33
|
+
/** Hard wall-clock timeout per probe in milliseconds. Default 120_000. */
|
|
34
|
+
timeoutMs: number;
|
|
35
|
+
/** Container memory limit (`--memory`). Default '2g'. */
|
|
36
|
+
memory: string;
|
|
37
|
+
/** Container CPU limit (`--cpus`). Default '2'. */
|
|
38
|
+
cpus: string;
|
|
39
|
+
/** Container PID limit (`--pids-limit`). Default 256. */
|
|
40
|
+
pidsLimit: number;
|
|
41
|
+
/**
|
|
42
|
+
* When true, probes run on fork PRs without further approval. When false,
|
|
43
|
+
* fork PRs require a head-bound `argus-probe` label (the labeled event must
|
|
44
|
+
* postdate the head's pushed_at) or a MEMBER/OWNER/COLLABORATOR
|
|
45
|
+
* author_association. Same-repo PRs are unaffected either way.
|
|
46
|
+
*/
|
|
47
|
+
allowForks: boolean;
|
|
48
|
+
}
|
|
13
49
|
export interface Config {
|
|
14
50
|
model: string;
|
|
15
51
|
escalation_model: string;
|
|
@@ -24,6 +60,13 @@ export interface Config {
|
|
|
24
60
|
* PR diffs and post findings. Defaults to the primary `model` if not set.
|
|
25
61
|
*/
|
|
26
62
|
code_model: string | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* OpenRouter Decisions API model for typed adjudication (Jev). Defaults
|
|
65
|
+
* to the pinned `typesafe/jev-1.13-20260917` — alias slugs like
|
|
66
|
+
* `~typesafe/jev-latest` drift silently and thresholds are calibrated
|
|
67
|
+
* to a version. Set to `''` to disable adjudication (regex-only mode).
|
|
68
|
+
*/
|
|
69
|
+
decisionModel: string | undefined;
|
|
27
70
|
/**
|
|
28
71
|
* Hard budget for the `argus-reviewer code-review` lane. When set, the
|
|
29
72
|
* review stops early if the cumulative OpenRouter cost exceeds this cap.
|
|
@@ -112,14 +155,74 @@ export interface Config {
|
|
|
112
155
|
* the app or the expectation is wrong.
|
|
113
156
|
*/
|
|
114
157
|
heal: 'local' | 'a0' | undefined;
|
|
158
|
+
/**
|
|
159
|
+
* Sandbox probe lane for `code-review` (Phase B.2). Always populated after
|
|
160
|
+
* `resolveConfig` — `enabled: false` by default so the lane is opt-in.
|
|
161
|
+
*/
|
|
162
|
+
sandbox: Sandbox;
|
|
163
|
+
/**
|
|
164
|
+
* Code-review policy knobs. Always populated after `resolveConfig`.
|
|
165
|
+
* `secretsThreshold`: Jev `noul` probability at/above which a
|
|
166
|
+
* secret-shaped diff literal is reported as a finding (below →
|
|
167
|
+
* suppressed but audit-recorded). Default 0.3 — tune after dogfooding.
|
|
168
|
+
* `maxComments`: cap on inline review comments posted per run
|
|
169
|
+
* (default 20) — overflow is summarized count-only in the sticky.
|
|
170
|
+
* `severityGate`: consumer-facing alias over `severity` — 'bug'
|
|
171
|
+
* fails on bugs only, 'risk' fails on bug|risk. Unset → `severity`
|
|
172
|
+
* list is authoritative.
|
|
173
|
+
* `triage`: Jev pre-review lane — 'off' no call, 'annotate' (default)
|
|
174
|
+
* records risk/deep-review/area into the report + sticky, 'route'
|
|
175
|
+
* additionally swaps the code model to `lowRiskModel` on low-risk
|
|
176
|
+
* diffs. Jev routes/annotates, never gates — coverage is constant.
|
|
177
|
+
* `lowRiskModel`: the cheap code-model slug 'route' falls to; unset →
|
|
178
|
+
* route keeps `code_model` (annotate-equivalent).
|
|
179
|
+
* `findingThreshold`: P(false-positive) required to suppress a nit/q
|
|
180
|
+
* finding after Jev adjudication — 1.0 (default) is annotate-only,
|
|
181
|
+
* lowering it suppresses progressively more low-confidence nits.
|
|
182
|
+
* bug/risk are never suppressed.
|
|
183
|
+
*/
|
|
184
|
+
review: {
|
|
185
|
+
secretsThreshold: number;
|
|
186
|
+
maxComments: number;
|
|
187
|
+
severityGate: 'bug' | 'risk' | undefined;
|
|
188
|
+
triage: 'off' | 'annotate' | 'route';
|
|
189
|
+
lowRiskModel: string | undefined;
|
|
190
|
+
findingThreshold: number;
|
|
191
|
+
};
|
|
115
192
|
}
|
|
116
|
-
export type ConfigInput = Partial<Omit<Config, 'provider'>> & {
|
|
193
|
+
export type ConfigInput = Partial<Omit<Config, 'provider' | 'sandbox' | 'review'>> & {
|
|
117
194
|
provider?: Partial<ProviderRules>;
|
|
195
|
+
sandbox?: Partial<Sandbox>;
|
|
196
|
+
review?: Partial<Config['review']>;
|
|
118
197
|
};
|
|
119
198
|
export declare const DEFAULT_RECORD_STEP_CAP = 40;
|
|
199
|
+
export declare const DEFAULT_SANDBOX: Sandbox;
|
|
120
200
|
export declare function defineConfig(input: ConfigInput): ConfigInput;
|
|
201
|
+
/**
|
|
202
|
+
* Which severities fail the review status. `review.severityGate` is the
|
|
203
|
+
* consumer-facing alias over `severity` — 'risk' fails on bug|risk,
|
|
204
|
+
* 'bug' on bugs only; unset → the `severity` list is authoritative.
|
|
205
|
+
*/
|
|
206
|
+
export declare function resolveBlockSeverities(config: Config): string[];
|
|
207
|
+
/**
|
|
208
|
+
* Inline-comment cap: `ARGUS_MAX_COMMENTS` (the action's `max-comments`
|
|
209
|
+
* input) wins when it parses as a non-negative integer — it's set by the
|
|
210
|
+
* workflow author, so an untrusted PR config can't reach it (`review`
|
|
211
|
+
* isn't on the untrusted allowlist). Anything else → `review.maxComments`.
|
|
212
|
+
*/
|
|
213
|
+
export declare function resolveMaxComments(env: Record<string, string | undefined>, config: Config): number;
|
|
121
214
|
export declare function resolveConfig(input?: ConfigInput): Config;
|
|
122
|
-
export
|
|
215
|
+
export interface LoadConfigOpts {
|
|
216
|
+
/**
|
|
217
|
+
* Required — there is no default. Every call site must state the
|
|
218
|
+
* checkout's trust so a missed or future caller can't silently execute
|
|
219
|
+
* config code on a hostile tree (see src/trust.ts).
|
|
220
|
+
*/
|
|
221
|
+
trust: Trust;
|
|
222
|
+
/** Human-readable note on security-relevant load decisions (e.g. ctx.err). */
|
|
223
|
+
note?: (line: string) => void;
|
|
224
|
+
}
|
|
225
|
+
export declare function loadConfig(cwd: string, opts: LoadConfigOpts): Promise<Config>;
|
|
123
226
|
/**
|
|
124
227
|
* Provider slugs the harness recognizes for `provider.only/ignore/order`
|
|
125
228
|
* (KTD4). Unknown slugs warn but do not fail — OpenRouter's catalog changes
|
package/dist/config.js
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
import { pathToFileURL } from 'node:url';
|
|
2
|
+
import { JEV_DEFAULT_MODEL } from './vision/decisions.js';
|
|
2
3
|
export const DEFAULT_RECORD_STEP_CAP = 40;
|
|
4
|
+
export const DEFAULT_SANDBOX = {
|
|
5
|
+
enabled: false,
|
|
6
|
+
image: undefined,
|
|
7
|
+
maxProbes: 3,
|
|
8
|
+
timeoutMs: 120_000,
|
|
9
|
+
memory: '2g',
|
|
10
|
+
cpus: '2',
|
|
11
|
+
pidsLimit: 256,
|
|
12
|
+
allowForks: false,
|
|
13
|
+
};
|
|
3
14
|
const defaults = {
|
|
4
15
|
model: 'google/gemini-2.5-flash-lite',
|
|
5
16
|
escalation_model: 'moonshotai/kimi-k2.5',
|
|
6
17
|
grounding_model: undefined,
|
|
7
18
|
code_model: 'deepseek/deepseek-v4.1-flash',
|
|
19
|
+
decisionModel: JEV_DEFAULT_MODEL,
|
|
8
20
|
codeReviewBudgetUsd: undefined,
|
|
9
21
|
provider: {
|
|
10
22
|
ignore: ['siliconflow', 'novitaai', 'atlascloud', 'streamlake', 'chutes'],
|
|
@@ -27,28 +39,138 @@ const defaults = {
|
|
|
27
39
|
recordStepCap: DEFAULT_RECORD_STEP_CAP,
|
|
28
40
|
a0: undefined,
|
|
29
41
|
heal: 'local',
|
|
42
|
+
sandbox: { ...DEFAULT_SANDBOX },
|
|
43
|
+
review: {
|
|
44
|
+
secretsThreshold: 0.3,
|
|
45
|
+
maxComments: 20,
|
|
46
|
+
severityGate: undefined,
|
|
47
|
+
triage: 'annotate',
|
|
48
|
+
lowRiskModel: undefined,
|
|
49
|
+
findingThreshold: 1.0,
|
|
50
|
+
},
|
|
30
51
|
};
|
|
31
52
|
export function defineConfig(input) {
|
|
32
53
|
return input;
|
|
33
54
|
}
|
|
55
|
+
/** Positive-integer config values fall back to their default, floored. */
|
|
56
|
+
function posInt(v, dflt) {
|
|
57
|
+
return v !== undefined && Number.isFinite(v) && v >= 1 ? Math.floor(v) : dflt;
|
|
58
|
+
}
|
|
59
|
+
/** Probability config values (must be in [0,1]) fall back to their default. */
|
|
60
|
+
function prob01(v, dflt) {
|
|
61
|
+
return v !== undefined && Number.isFinite(v) && v >= 0 && v <= 1 ? v : dflt;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Which severities fail the review status. `review.severityGate` is the
|
|
65
|
+
* consumer-facing alias over `severity` — 'risk' fails on bug|risk,
|
|
66
|
+
* 'bug' on bugs only; unset → the `severity` list is authoritative.
|
|
67
|
+
*/
|
|
68
|
+
export function resolveBlockSeverities(config) {
|
|
69
|
+
if (config.review.severityGate === 'risk')
|
|
70
|
+
return ['bug', 'risk'];
|
|
71
|
+
if (config.review.severityGate === 'bug')
|
|
72
|
+
return ['bug'];
|
|
73
|
+
return config.severity ?? ['bug'];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Inline-comment cap: `ARGUS_MAX_COMMENTS` (the action's `max-comments`
|
|
77
|
+
* input) wins when it parses as a non-negative integer — it's set by the
|
|
78
|
+
* workflow author, so an untrusted PR config can't reach it (`review`
|
|
79
|
+
* isn't on the untrusted allowlist). Anything else → `review.maxComments`.
|
|
80
|
+
*/
|
|
81
|
+
export function resolveMaxComments(env, config) {
|
|
82
|
+
const raw = env.ARGUS_MAX_COMMENTS?.trim();
|
|
83
|
+
// ^\d+$ — Number() would also accept '0x10', '1e2', ' 4 ', 'Infinity'.
|
|
84
|
+
if (raw !== undefined && /^\d+$/.test(raw)) {
|
|
85
|
+
return Number(raw);
|
|
86
|
+
}
|
|
87
|
+
return config.review.maxComments;
|
|
88
|
+
}
|
|
34
89
|
export function resolveConfig(input = {}) {
|
|
35
90
|
const provider = { ...defaults.provider, ...(input.provider ?? {}) };
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
91
|
+
// Wrong-typed sandbox values (e.g. `sandbox: true`, `enabled: 'yes'`,
|
|
92
|
+
// `memory: 2048`) degrade silently to defaults — the lane is opt-in and a
|
|
93
|
+
// mis-typed flag must never feed docker argv or self-enable.
|
|
94
|
+
const raw = typeof input.sandbox === 'object' && input.sandbox !== null ? input.sandbox : {};
|
|
95
|
+
const sandbox = { ...defaults.sandbox, ...raw };
|
|
96
|
+
sandbox.enabled = raw.enabled === true;
|
|
97
|
+
sandbox.allowForks = raw.allowForks === true;
|
|
98
|
+
sandbox.image = typeof raw.image === 'string' && raw.image !== '' ? raw.image : undefined;
|
|
99
|
+
sandbox.memory =
|
|
100
|
+
typeof raw.memory === 'string' && raw.memory !== '' ? raw.memory : DEFAULT_SANDBOX.memory;
|
|
101
|
+
sandbox.cpus = typeof raw.cpus === 'string' && raw.cpus !== '' ? raw.cpus : DEFAULT_SANDBOX.cpus;
|
|
102
|
+
sandbox.maxProbes = posInt(sandbox.maxProbes, DEFAULT_SANDBOX.maxProbes);
|
|
103
|
+
sandbox.timeoutMs = posInt(sandbox.timeoutMs, DEFAULT_SANDBOX.timeoutMs);
|
|
104
|
+
sandbox.pidsLimit = posInt(sandbox.pidsLimit, DEFAULT_SANDBOX.pidsLimit);
|
|
105
|
+
const rawReview = typeof input.review === 'object' && input.review !== null ? input.review : {};
|
|
106
|
+
const review = { ...defaults.review, ...rawReview };
|
|
107
|
+
// Thresholds must be probabilities — anything else (NaN, >1,
|
|
108
|
+
// negative) would silently suppress or flood the Jev lanes.
|
|
109
|
+
review.secretsThreshold = prob01(review.secretsThreshold, defaults.review.secretsThreshold);
|
|
110
|
+
review.maxComments =
|
|
111
|
+
typeof review.maxComments === 'number' &&
|
|
112
|
+
Number.isInteger(review.maxComments) &&
|
|
113
|
+
review.maxComments >= 0
|
|
114
|
+
? review.maxComments
|
|
115
|
+
: defaults.review.maxComments;
|
|
116
|
+
if (review.severityGate !== 'bug' && review.severityGate !== 'risk') {
|
|
117
|
+
review.severityGate = undefined;
|
|
118
|
+
}
|
|
119
|
+
if (review.triage !== 'off' && review.triage !== 'annotate' && review.triage !== 'route') {
|
|
120
|
+
review.triage = defaults.review.triage;
|
|
121
|
+
}
|
|
122
|
+
if (typeof review.lowRiskModel !== 'string' || review.lowRiskModel === '') {
|
|
123
|
+
review.lowRiskModel = undefined;
|
|
124
|
+
}
|
|
125
|
+
review.findingThreshold = prob01(review.findingThreshold, defaults.review.findingThreshold);
|
|
126
|
+
const resolved = { ...defaults, ...input, provider, sandbox, review };
|
|
127
|
+
resolved.recordStepCap = posInt(resolved.recordStepCap, DEFAULT_RECORD_STEP_CAP);
|
|
42
128
|
if (resolved.heal !== 'a0')
|
|
43
129
|
resolved.heal = 'local';
|
|
130
|
+
// '' is the documented opt-out — an empty slug would send a broken
|
|
131
|
+
// model id to the decisions endpoint on every adjudication call.
|
|
132
|
+
if (resolved.decisionModel === '')
|
|
133
|
+
resolved.decisionModel = undefined;
|
|
44
134
|
return resolved;
|
|
45
135
|
}
|
|
46
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Config keys honored on untrusted checkouts — policy-free fields only.
|
|
138
|
+
* Everything else (exec-bearing fields, model/budget/provider selection,
|
|
139
|
+
* severity/verdict policy, credentials maps, network endpoints, write
|
|
140
|
+
* locations) is ignored: the review policy over hostile code must not be
|
|
141
|
+
* authored by that code.
|
|
142
|
+
*/
|
|
143
|
+
const UNTRUSTED_CONFIG_KEYS = new Set(['logLevel', 'sourceGlobs']);
|
|
144
|
+
function filterUntrustedConfig(input) {
|
|
145
|
+
const out = {};
|
|
146
|
+
for (const [key, value] of Object.entries(input)) {
|
|
147
|
+
if (UNTRUSTED_CONFIG_KEYS.has(key))
|
|
148
|
+
out[key] = value;
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
export async function loadConfig(cwd, opts) {
|
|
47
153
|
const fs = await import('node:fs/promises');
|
|
48
154
|
const path = await import('node:path');
|
|
155
|
+
const untrusted = opts.trust === 'untrusted';
|
|
49
156
|
const names = ['argus-reviewer.config', 'vision-e2e.config'];
|
|
50
157
|
for (const name of names) {
|
|
51
|
-
|
|
158
|
+
if (untrusted) {
|
|
159
|
+
// Surface skipped .ts candidates — otherwise a hostile config (or a
|
|
160
|
+
// legit consumer debugging "why is my config ignored") is invisible.
|
|
161
|
+
try {
|
|
162
|
+
if ((await fs.stat(path.join(cwd, `${name}.ts`))).isFile()) {
|
|
163
|
+
opts.note?.(`config: ${name}.ts ignored — untrusted checkouts load JSON config only`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// no .ts candidate — nothing to note
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// .ts is tried before .json, so an untrusted checkout must skip the .ts
|
|
171
|
+
// candidate *before* it can shadow a committed .json — importing it
|
|
172
|
+
// executes arbitrary code beside the runner's secrets (#58).
|
|
173
|
+
for (const ext of untrusted ? ['.json'] : ['.ts', '.json']) {
|
|
52
174
|
const file = path.join(cwd, `${name}${ext}`);
|
|
53
175
|
try {
|
|
54
176
|
const stat = await fs.stat(file);
|
|
@@ -56,7 +178,12 @@ export async function loadConfig(cwd) {
|
|
|
56
178
|
continue;
|
|
57
179
|
if (ext === '.json') {
|
|
58
180
|
const raw = await fs.readFile(file, 'utf8');
|
|
59
|
-
|
|
181
|
+
const parsed = JSON.parse(raw);
|
|
182
|
+
if (untrusted) {
|
|
183
|
+
opts.note?.(`config: ${name}.json loaded untrusted — honoring ${[...UNTRUSTED_CONFIG_KEYS].join(', ')} only`);
|
|
184
|
+
return resolveConfig(filterUntrustedConfig(parsed));
|
|
185
|
+
}
|
|
186
|
+
return resolveConfig(parsed);
|
|
60
187
|
}
|
|
61
188
|
// Always transpile .ts to a temp .mjs rather than importing natively:
|
|
62
189
|
// Node's built-in type stripping resolves the module type from the
|
package/dist/debug.d.ts
CHANGED
package/dist/debug.js
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { liveLog } from './live.js';
|
|
3
3
|
const DEBUG = process.env.ARGUS_DEBUG === '1' || process.env.ARGUS_DEBUG === 'true';
|
|
4
|
-
// debug() has no config access — it
|
|
5
|
-
|
|
4
|
+
// debug() has no config access — it defaults to the conventional cache dir,
|
|
5
|
+
// and callers that resolve a config set the real live dir once it's known
|
|
6
|
+
// (otherwise debug lines and stage lines would split across two dirs).
|
|
7
|
+
const DEFAULT_LIVE_DIR = join(process.cwd(), '.argus-reviewer-cache');
|
|
8
|
+
let liveDir = DEFAULT_LIVE_DIR;
|
|
9
|
+
export function setLiveDir(dir) {
|
|
10
|
+
liveDir = dir ?? DEFAULT_LIVE_DIR;
|
|
11
|
+
}
|
|
6
12
|
function toMsg(arg) {
|
|
7
13
|
if (typeof arg === 'string')
|
|
8
14
|
return arg;
|
|
@@ -18,7 +24,7 @@ export function debug(kind, ...args) {
|
|
|
18
24
|
if (!DEBUG)
|
|
19
25
|
return; // keep debug() free of fs work on the hot path
|
|
20
26
|
for (const arg of args) {
|
|
21
|
-
liveLog(
|
|
27
|
+
liveLog(liveDir, kind, 'debug', toMsg(arg));
|
|
22
28
|
const prefix = `[argus-reviewer:${kind}]`;
|
|
23
29
|
if (typeof arg === 'string') {
|
|
24
30
|
console.error(`${prefix} ${arg}`);
|
package/dist/detect.d.ts
CHANGED
|
@@ -7,8 +7,18 @@ export interface ExecResult {
|
|
|
7
7
|
code: number;
|
|
8
8
|
stdout: string;
|
|
9
9
|
stderr: string;
|
|
10
|
+
/**
|
|
11
|
+
* The timeout kill fired — execFile killed the process for exceeding
|
|
12
|
+
* timeoutMs (`err.killed`). Without this a timed-out command is
|
|
13
|
+
* indistinguishable from a nonzero exit.
|
|
14
|
+
*/
|
|
15
|
+
timedOut?: boolean;
|
|
16
|
+
/** Signal the process was terminated by, when killed (e.g. 'SIGTERM'). */
|
|
17
|
+
signal?: string | undefined;
|
|
10
18
|
}
|
|
11
|
-
export type ExecFn = (cmd: string, args: string[], timeoutMs: number
|
|
19
|
+
export type ExecFn = (cmd: string, args: string[], timeoutMs: number,
|
|
20
|
+
/** Extra env merged over process.env — keeps secrets out of `ps`/`/proc` argv. */
|
|
21
|
+
env?: Record<string, string>) => Promise<ExecResult>;
|
|
12
22
|
export declare const defaultExec: ExecFn;
|
|
13
23
|
export type ProbeFn = (url: string, timeoutMs: number) => Promise<boolean>;
|
|
14
24
|
/**
|
package/dist/detect.js
CHANGED
|
@@ -2,12 +2,28 @@ import { execFile } from 'node:child_process';
|
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { readdir, readFile } from 'node:fs/promises';
|
|
5
|
-
export const defaultExec = (cmd, args, timeoutMs) => new Promise((resolve) => {
|
|
6
|
-
|
|
5
|
+
export const defaultExec = (cmd, args, timeoutMs, env) => new Promise((resolve) => {
|
|
6
|
+
// 4 MiB headroom — the sandbox caps output itself after capture, and a
|
|
7
|
+
// chatty probe hitting execFile's 1 MiB default would error instead of
|
|
8
|
+
// reaching the harness classifier.
|
|
9
|
+
execFile(cmd, args, {
|
|
10
|
+
timeout: timeoutMs,
|
|
11
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
12
|
+
...(env !== undefined ? { env: { ...process.env, ...env } } : {}),
|
|
13
|
+
}, (err, stdout, stderr) => {
|
|
7
14
|
if (err) {
|
|
8
15
|
// stderr is '' (not undefined) on spawn ENOENT — fall back to the
|
|
9
16
|
// error message so callers can distinguish "missing" from "failed".
|
|
10
|
-
resolve({
|
|
17
|
+
resolve({
|
|
18
|
+
code: 1,
|
|
19
|
+
stdout: String(stdout),
|
|
20
|
+
stderr: String(stderr) || err.message,
|
|
21
|
+
// killed is also true on maxBuffer overflow — that's an output
|
|
22
|
+
// problem, not a timeout; classify by the error code.
|
|
23
|
+
timedOut: err.killed === true &&
|
|
24
|
+
err.code !== 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
|
|
25
|
+
signal: typeof err.signal === 'string' ? err.signal : undefined,
|
|
26
|
+
});
|
|
11
27
|
}
|
|
12
28
|
else {
|
|
13
29
|
resolve({ code: 0, stdout: String(stdout), stderr: String(stderr) });
|
package/dist/evidence/ci.d.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
interface Ctx {
|
|
2
2
|
err: (line: string) => void;
|
|
3
3
|
}
|
|
4
|
+
/** Maintainer-applied PR label that opts a fork PR into sandbox probes. */
|
|
5
|
+
export declare const PROBE_LABEL = "argus-probe";
|
|
6
|
+
/**
|
|
7
|
+
* GitHub `author_association` values trusted to run probes on fork PRs —
|
|
8
|
+
* repo members/owners/collaborators. CONTRIBUTOR, FIRST_TIME_CONTRIBUTOR,
|
|
9
|
+
* FIRST_TIMER, MANNEQUIN, and NONE are not.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isTrustedAssociation(association: string | undefined): boolean;
|
|
4
12
|
export interface CheckRun {
|
|
5
13
|
name: string;
|
|
6
14
|
/** GitHub check-run conclusion once status === 'completed'; undefined while pending. */
|
|
@@ -9,10 +17,47 @@ export interface CheckRun {
|
|
|
9
17
|
url: string | undefined;
|
|
10
18
|
}
|
|
11
19
|
export interface PrMeta {
|
|
20
|
+
/** PR head SHA — the commit the PR's check-runs are attached to. */
|
|
12
21
|
headSha: string | undefined;
|
|
22
|
+
/** PR base SHA — the merge base probes run against for the double-run. */
|
|
23
|
+
baseSha: string | undefined;
|
|
24
|
+
/** `head.repo.fork` — true when the PR head branch lives in a fork. */
|
|
25
|
+
isFork: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Raw `author_association` for the PR (OWNER, MEMBER, COLLABORATOR,
|
|
28
|
+
* CONTRIBUTOR, FIRST_TIME_CONTRIBUTOR, FIRST_TIMER, MANNEQUIN, NONE).
|
|
29
|
+
*/
|
|
30
|
+
authorAssociation: string | undefined;
|
|
31
|
+
/** Names of labels currently applied to the PR. */
|
|
32
|
+
labels: string[];
|
|
33
|
+
/**
|
|
34
|
+
* `head.repo.pushed_at` — the freshest push timestamp the probe label gate
|
|
35
|
+
* can compare against (head commits arrive via pushes; comparing against
|
|
36
|
+
* this is what binds label approval to the current head).
|
|
37
|
+
*/
|
|
38
|
+
pushedAt: string | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* Timestamp of the newest `argus-probe` `labeled` event on the PR, from the
|
|
41
|
+
* issue timeline — undefined when the label was never applied or the
|
|
42
|
+
* timeline fetch failed (the gate fails closed either way).
|
|
43
|
+
*/
|
|
44
|
+
labelApprovedAt: string | undefined;
|
|
45
|
+
/** PR title/body — triage state only (untrusted text; feeds Jev, never gates). */
|
|
46
|
+
title: string | undefined;
|
|
47
|
+
body: string | undefined;
|
|
13
48
|
}
|
|
14
|
-
/**
|
|
15
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Shared GitHub GET scaffold — Bearer auth, API headers, 30s abort timeout,
|
|
51
|
+
* `ctx.err` on non-ok/timeout, undefined on failure. Reuse for any
|
|
52
|
+
* api.github.com read (fetchPrFiles in cli.ts paginates over it).
|
|
53
|
+
*/
|
|
54
|
+
export declare function ghGet(url: string, token: string, ctx: Ctx): Promise<unknown | undefined>;
|
|
55
|
+
/**
|
|
56
|
+
* PR metadata for the evidence + probe lanes — the head SHA check-runs attach
|
|
57
|
+
* to, plus the fork/association/label signals the sandbox fork gate (KTD5)
|
|
58
|
+
* evaluates. One `/pulls/{pr}` request; undefined when the request fails.
|
|
59
|
+
*/
|
|
60
|
+
export declare function fetchPrMeta(repo: string, pr: string, token: string, ctx: Ctx): Promise<PrMeta | undefined>;
|
|
16
61
|
/** Check-runs on a commit — the consumer's own CI signal. */
|
|
17
62
|
export declare function fetchCheckRuns(repo: string, sha: string, token: string, ctx: Ctx): Promise<CheckRun[] | undefined>;
|
|
18
63
|
export {};
|
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,95 @@ 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
|
+
title: typeof data.title === 'string' ? data.title : undefined,
|
|
89
|
+
body: typeof data.body === 'string' ? data.body : undefined,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Merge base via the compare API — `GET /compare/{base}...{head}` returns
|
|
94
|
+
* `merge_base_commit.sha`, the commit the PR actually diverged from.
|
|
95
|
+
*/
|
|
96
|
+
async function fetchMergeBase(repo, base, head, token, ctx) {
|
|
97
|
+
const data = (await ghGet(`${GH_API}/repos/${repo}/compare/${base}...${head}`, token, ctx));
|
|
98
|
+
return data?.merge_base_commit?.sha;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Newest `labeled` event for `argus-probe` on the PR's issue events feed.
|
|
102
|
+
* The label on the payload proves it's currently applied; the event
|
|
103
|
+
* timestamp is what binds approval to the current head (a `synchronize`
|
|
104
|
+
* push after the label must not inherit it). Uses the *events* endpoint —
|
|
105
|
+
* not timeline — because it carries only state events (no comments), so a
|
|
106
|
+
* busy PR's `labeled` event isn't drowned past page one. Pages are
|
|
107
|
+
* oldest-first with no reverse sort, so we take the newest within a
|
|
108
|
+
* bounded 3-page scan; a still-busier PR fails closed.
|
|
109
|
+
*/
|
|
110
|
+
async function fetchLabelApprovedAt(repo, pr, token, ctx) {
|
|
111
|
+
let latest;
|
|
112
|
+
for (let page = 1; page <= 3; page++) {
|
|
113
|
+
const events = (await ghGet(`${GH_API}/repos/${repo}/issues/${pr}/events?per_page=100&page=${page}`, token, ctx));
|
|
114
|
+
if (!Array.isArray(events))
|
|
115
|
+
return undefined;
|
|
116
|
+
for (const e of events) {
|
|
117
|
+
if (e?.event === 'labeled' &&
|
|
118
|
+
e.label?.name === PROBE_LABEL &&
|
|
119
|
+
typeof e.created_at === 'string') {
|
|
120
|
+
if (latest === undefined || e.created_at > latest)
|
|
121
|
+
latest = e.created_at;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (events.length < 100)
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
return latest;
|
|
36
128
|
}
|
|
37
129
|
/** Check-runs on a commit — the consumer's own CI signal. */
|
|
38
130
|
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;
|