@xaccefy/pi-casefile 0.8.1 → 0.8.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 +2 -2
- package/package.json +3 -5
- package/src/index.ts +206 -77
- package/src/ledger.ts +565 -243
- package/src/pipeline-submit.ts +212 -14
- package/src/poc-runner.ts +210 -24
- package/src/scratchpad.ts +78 -12
- package/src/workflow.ts +14 -12
package/src/pipeline-submit.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { createHash } from "node:crypto";
|
|
21
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
22
22
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
23
23
|
import { getRunDir, getScratchpadRoot, scratchpad_write } from "./scratchpad.ts";
|
|
24
24
|
|
|
@@ -204,19 +204,43 @@ function statePath(runId: string): string {
|
|
|
204
204
|
function readState(runId: string): SubmitState {
|
|
205
205
|
const p = statePath(runId);
|
|
206
206
|
if (!existsSync(p)) return { repairs: {}, accepted_findings: [] };
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
207
|
+
const raw = JSON.parse(readFileSync(p, "utf8")) as Partial<SubmitState>;
|
|
208
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
209
|
+
throw new Error(`Corrupt pipeline-submit state for ${runId}: root must be an object`);
|
|
210
|
+
}
|
|
211
|
+
const repairs = raw.repairs ?? {};
|
|
212
|
+
if (typeof repairs !== "object" || repairs === null || Array.isArray(repairs)) {
|
|
213
|
+
throw new Error(`Corrupt pipeline-submit state for ${runId}: repairs must be an object`);
|
|
214
|
+
}
|
|
215
|
+
for (const [k, v] of Object.entries(repairs)) {
|
|
216
|
+
if (typeof k !== "string" || typeof v !== "number" || !Number.isInteger(v) || v < 0) {
|
|
217
|
+
throw new Error(`Corrupt pipeline-submit state for ${runId}: invalid repair counter`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const accepted = raw.accepted_findings ?? [];
|
|
221
|
+
if (!Array.isArray(accepted)) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`Corrupt pipeline-submit state for ${runId}: accepted_findings must be an array`,
|
|
224
|
+
);
|
|
215
225
|
}
|
|
226
|
+
for (const [i, item] of accepted.entries()) {
|
|
227
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
228
|
+
throw new Error(
|
|
229
|
+
`Corrupt pipeline-submit state for ${runId}: accepted_findings[${i}] invalid`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
repairs: repairs as Record<string, number>,
|
|
235
|
+
accepted_findings: accepted as FindingRef[],
|
|
236
|
+
};
|
|
216
237
|
}
|
|
217
238
|
|
|
218
239
|
function writeState(runId: string, state: SubmitState): void {
|
|
219
|
-
|
|
240
|
+
const p = statePath(runId);
|
|
241
|
+
const tmp = `${p}.${process.pid}.${Date.now()}.tmp`;
|
|
242
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2), "utf8");
|
|
243
|
+
renameSync(tmp, p);
|
|
220
244
|
}
|
|
221
245
|
|
|
222
246
|
/** Project root containing the scratchpad (file-existence checks resolve here). */
|
|
@@ -272,6 +296,109 @@ function isNonEmptyString(v: unknown): v is string {
|
|
|
272
296
|
return typeof v === "string" && v.trim().length > 0;
|
|
273
297
|
}
|
|
274
298
|
|
|
299
|
+
function requireStringArray(errors: string[], path: string, value: unknown, minItems = 0): void {
|
|
300
|
+
if (!Array.isArray(value)) {
|
|
301
|
+
errors.push(`${path}: missing or not an array`);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (value.length < minItems) errors.push(`${path}: needs at least ${minItems} item(s)`);
|
|
305
|
+
value.forEach((item, i) => {
|
|
306
|
+
if (!isNonEmptyString(item)) errors.push(`${path}[${i}]: missing or empty string`);
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function asObject(value: unknown): Record<string, unknown> | undefined {
|
|
311
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
312
|
+
? (value as Record<string, unknown>)
|
|
313
|
+
: undefined;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function validateReport(errors: string[], obj: Record<string, unknown>): void {
|
|
317
|
+
const findingSeverities = ["info", "low", "medium", "high", "critical"];
|
|
318
|
+
if (Array.isArray(obj.findings)) {
|
|
319
|
+
obj.findings.forEach((item, i) => {
|
|
320
|
+
const finding = asObject(item);
|
|
321
|
+
if (!finding) {
|
|
322
|
+
errors.push(`findings[${i}]: not an object`);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
for (const field of ["id", "vuln_class", "severity", "status"] as const) {
|
|
326
|
+
if (!isNonEmptyString(finding[field]))
|
|
327
|
+
errors.push(`findings[${i}].${field}: missing or empty`);
|
|
328
|
+
}
|
|
329
|
+
if (!isNonEmptyString(finding.file) && !isNonEmptyString(finding.endpoint)) {
|
|
330
|
+
errors.push(`findings[${i}]: provide file or endpoint`);
|
|
331
|
+
}
|
|
332
|
+
if (isNonEmptyString(finding.severity) && !findingSeverities.includes(finding.severity)) {
|
|
333
|
+
errors.push(`findings[${i}].severity: invalid value`);
|
|
334
|
+
}
|
|
335
|
+
if (isNonEmptyString(finding.status) && !["confirmed", "reported"].includes(finding.status)) {
|
|
336
|
+
errors.push(`findings[${i}].status: invalid value`);
|
|
337
|
+
}
|
|
338
|
+
if (finding.chain_with !== undefined)
|
|
339
|
+
requireStringArray(errors, `findings[${i}].chain_with`, finding.chain_with);
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const coverage = asObject(obj.coverage);
|
|
344
|
+
if (coverage) {
|
|
345
|
+
const allowed = ["COVERED", "SKIPPED", "NOT_FOUND", "INCOMPLETE"];
|
|
346
|
+
for (const [key, value] of Object.entries(coverage)) {
|
|
347
|
+
if (!/^[a-z-]+$/.test(key)) errors.push(`coverage.${key}: invalid class key`);
|
|
348
|
+
if (typeof value !== "string" || !allowed.includes(value)) {
|
|
349
|
+
errors.push(`coverage.${key}: must be one of { ${allowed.join(" | ")} }`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (obj.chains !== undefined) {
|
|
355
|
+
if (!Array.isArray(obj.chains)) {
|
|
356
|
+
errors.push("chains: not an array");
|
|
357
|
+
} else {
|
|
358
|
+
obj.chains.forEach((item, i) => {
|
|
359
|
+
const chain = asObject(item);
|
|
360
|
+
if (!chain) {
|
|
361
|
+
errors.push(`chains[${i}]: not an object`);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (chain.title !== undefined && !isNonEmptyString(chain.title))
|
|
365
|
+
errors.push(`chains[${i}].title: missing or empty`);
|
|
366
|
+
if (chain.steps !== undefined)
|
|
367
|
+
requireStringArray(errors, `chains[${i}].steps`, chain.steps);
|
|
368
|
+
if (
|
|
369
|
+
chain.severity !== undefined &&
|
|
370
|
+
(!isNonEmptyString(chain.severity) ||
|
|
371
|
+
!(CHAIN_SEVERITIES as readonly string[]).includes(chain.severity))
|
|
372
|
+
) {
|
|
373
|
+
errors.push(`chains[${i}].severity: invalid value`);
|
|
374
|
+
}
|
|
375
|
+
if (chain.blocked_by_controls !== undefined) {
|
|
376
|
+
requireStringArray(errors, `chains[${i}].blocked_by_controls`, chain.blocked_by_controls);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (obj.patches_applied !== undefined) {
|
|
383
|
+
if (!Array.isArray(obj.patches_applied)) {
|
|
384
|
+
errors.push("patches_applied: not an array");
|
|
385
|
+
} else {
|
|
386
|
+
obj.patches_applied.forEach((item, i) => {
|
|
387
|
+
const patch = asObject(item);
|
|
388
|
+
if (!patch) {
|
|
389
|
+
errors.push(`patches_applied[${i}]: not an object`);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
for (const field of ["finding_id", "diff_summary", "re_attack_result"] as const) {
|
|
393
|
+
if (patch[field] !== undefined && !isNonEmptyString(patch[field])) {
|
|
394
|
+
errors.push(`patches_applied[${i}].${field}: missing or empty`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
275
402
|
function validateStage(stage: SubmitStage, obj: Record<string, unknown>): string[] {
|
|
276
403
|
const spec = SPECS[stage];
|
|
277
404
|
const errors: string[] = [];
|
|
@@ -334,10 +461,78 @@ function validateStage(stage: SubmitStage, obj: Record<string, unknown>): string
|
|
|
334
461
|
}
|
|
335
462
|
}
|
|
336
463
|
|
|
337
|
-
|
|
464
|
+
if (stage === "trace") {
|
|
465
|
+
requireStringArray(errors, "call_chain", obj.call_chain, 1);
|
|
466
|
+
if (Array.isArray(obj.defenses_checked)) {
|
|
467
|
+
obj.defenses_checked.forEach((item, i) => {
|
|
468
|
+
const defense = asObject(item);
|
|
469
|
+
if (!defense) {
|
|
470
|
+
errors.push(`defenses_checked[${i}]: not an object`);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (!isNonEmptyString(defense.defense))
|
|
474
|
+
errors.push(`defenses_checked[${i}].defense: missing or empty`);
|
|
475
|
+
if (!isNonEmptyString(defense.location))
|
|
476
|
+
errors.push(`defenses_checked[${i}].location: missing or empty`);
|
|
477
|
+
if (
|
|
478
|
+
!isNonEmptyString(defense.verdict) ||
|
|
479
|
+
!["bypassed", "blocked", "not-present"].includes(defense.verdict)
|
|
480
|
+
) {
|
|
481
|
+
errors.push(`defenses_checked[${i}].verdict: must be bypassed, blocked, or not-present`);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (stage === "skeptic") {
|
|
488
|
+
requireStringArray(errors, "evidence_reviewed", obj.evidence_reviewed, 1);
|
|
489
|
+
if (obj.verdict === "DISPROVEN" && isNonEmptyString(obj.disproval_reason)) {
|
|
490
|
+
const allowed = [
|
|
491
|
+
"unreachable",
|
|
492
|
+
"framework_protection",
|
|
493
|
+
"input_validation_blocks",
|
|
494
|
+
"requires_privilege_attacker_lacks",
|
|
495
|
+
"intended_behavior",
|
|
496
|
+
"overstated_impact",
|
|
497
|
+
"duplicate",
|
|
498
|
+
"test_artifact",
|
|
499
|
+
"out_of_scope",
|
|
500
|
+
];
|
|
501
|
+
if (!allowed.includes(obj.disproval_reason)) errors.push("disproval_reason: invalid value");
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (stage === "validate") {
|
|
506
|
+
if (obj.status === "killed" && isNonEmptyString(obj.kill_reason)) {
|
|
507
|
+
const allowed = [
|
|
508
|
+
"unreachable",
|
|
509
|
+
"framework_protection",
|
|
510
|
+
"input_validation_blocks",
|
|
511
|
+
"requires_privilege_attacker_lacks",
|
|
512
|
+
"poc_failed_3x",
|
|
513
|
+
"no_real_impact",
|
|
514
|
+
"intended_behavior",
|
|
515
|
+
"duplicate",
|
|
516
|
+
];
|
|
517
|
+
if (!allowed.includes(obj.kill_reason)) errors.push("kill_reason: invalid value");
|
|
518
|
+
}
|
|
519
|
+
if (
|
|
520
|
+
obj.refinement_attempts !== undefined &&
|
|
521
|
+
(!Number.isInteger(obj.refinement_attempts) ||
|
|
522
|
+
(obj.refinement_attempts as number) < 1 ||
|
|
523
|
+
(obj.refinement_attempts as number) > 3)
|
|
524
|
+
) {
|
|
525
|
+
errors.push("refinement_attempts: must be an integer from 1 to 3");
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
338
529
|
if (stage === "chain" && Array.isArray(obj.chains)) {
|
|
339
530
|
obj.chains.forEach((c, i) => {
|
|
340
|
-
const chain = c
|
|
531
|
+
const chain = asObject(c);
|
|
532
|
+
if (!chain) {
|
|
533
|
+
errors.push(`chains[${i}]: not an object`);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
341
536
|
if (!isNonEmptyString(chain.title)) errors.push(`chains[${i}].title: missing or empty`);
|
|
342
537
|
if (
|
|
343
538
|
!isNonEmptyString(chain.severity) ||
|
|
@@ -345,14 +540,17 @@ function validateStage(stage: SubmitStage, obj: Record<string, unknown>): string
|
|
|
345
540
|
) {
|
|
346
541
|
errors.push(`chains[${i}].severity: must be one of { ${CHAIN_SEVERITIES.join(" | ")} }`);
|
|
347
542
|
}
|
|
348
|
-
|
|
349
|
-
|
|
543
|
+
requireStringArray(errors, `chains[${i}].steps`, chain.steps, 2);
|
|
544
|
+
if (chain.blocked_by_controls !== undefined) {
|
|
545
|
+
requireStringArray(errors, `chains[${i}].blocked_by_controls`, chain.blocked_by_controls);
|
|
350
546
|
}
|
|
351
547
|
if (!isNonEmptyString(chain.narrative))
|
|
352
548
|
errors.push(`chains[${i}].narrative: missing or empty`);
|
|
353
549
|
});
|
|
354
550
|
}
|
|
355
551
|
|
|
552
|
+
if (stage === "report") validateReport(errors, obj);
|
|
553
|
+
|
|
356
554
|
return errors;
|
|
357
555
|
}
|
|
358
556
|
|
package/src/poc-runner.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
copyFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
realpathSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
statSync,
|
|
10
|
+
} from "node:fs";
|
|
3
11
|
import { tmpdir } from "node:os";
|
|
4
12
|
import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
13
|
|
|
@@ -17,8 +25,53 @@ export type PocRun = {
|
|
|
17
25
|
* a crash is NOT a verdict, and callers must not treat it as one.
|
|
18
26
|
*/
|
|
19
27
|
completed: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Sanitized but UNTRUNCATED output (capped only by the spawn maxBuffer).
|
|
30
|
+
* Marker presence/absence checks MUST run on this, never on `output`,
|
|
31
|
+
* which is sliced for display — a cheating script can print its marker
|
|
32
|
+
* past the 4000-char display window. Never persisted to the ledger.
|
|
33
|
+
*/
|
|
34
|
+
rawOutput?: string;
|
|
35
|
+
/** True when `output` was truncated for display (rawOutput has more). */
|
|
36
|
+
truncated?: boolean;
|
|
37
|
+
/** True iff child output capture was complete. False on maxBuffer/timeouts/spawn failures. */
|
|
38
|
+
outputComplete?: boolean;
|
|
39
|
+
/** Harness mode used for this run. */
|
|
40
|
+
mode?: string;
|
|
41
|
+
/** Harness target used for this run. */
|
|
42
|
+
target?: string;
|
|
43
|
+
/**
|
|
44
|
+
* True when the run never started because of harness infrastructure
|
|
45
|
+
* failure (e.g. sandbox image pull failed) — set only by the runner,
|
|
46
|
+
* never derived from PoC-controlled output text.
|
|
47
|
+
*/
|
|
48
|
+
infraError?: boolean;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Run-mode options for `runPoc`. Host execution is NEVER selectable by the
|
|
53
|
+
* agent alone: `local: true` only takes effect when the OPERATOR has set
|
|
54
|
+
* `PI_POC_ALLOW_LOCAL=1` (host is a fallback when Docker is unavailable;
|
|
55
|
+
* `PI_POC_FORCE_LOCAL=1` + ALLOW skips Docker on purpose). The default is a
|
|
56
|
+
* Docker sandbox; `network: "host"` gives the sandbox host networking while
|
|
57
|
+
* keeping the read-only FS / dropped caps / unprivileged user / resource limits.
|
|
58
|
+
*/
|
|
59
|
+
export type PocRunOptions = {
|
|
60
|
+
/** Docker sandbox networking: "none" (default) or "host" (live/network-dependent findings). */
|
|
61
|
+
network?: "none" | "host";
|
|
62
|
+
/** True to run on the host (no Docker). Requires operator opt-in PI_POC_ALLOW_LOCAL=1. */
|
|
63
|
+
local?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Extra environment variables merged into the run. The harness sets
|
|
66
|
+
* `PI_POC_MODE` ("poc" | "control" | "disconfirmation") and `PI_POC_TARGET`
|
|
67
|
+
* (the case target) so PoCs can be written once and parameterized per run.
|
|
68
|
+
*/
|
|
69
|
+
env?: Record<string, string>;
|
|
20
70
|
};
|
|
21
71
|
|
|
72
|
+
/** Operator-only opt-in for host execution (never agent-supplied). */
|
|
73
|
+
const LOCAL_EXEC_ENV = "PI_POC_ALLOW_LOCAL";
|
|
74
|
+
|
|
22
75
|
export type PocLanguage = {
|
|
23
76
|
/** Docker image used when running inside the sandbox. */
|
|
24
77
|
image: string;
|
|
@@ -160,27 +213,57 @@ function validatePocPath(pocPath: string): string {
|
|
|
160
213
|
}
|
|
161
214
|
|
|
162
215
|
const root = getProjectRoot();
|
|
216
|
+
// Operator escape hatch: PI_POC_ALLOW_ABSOLUTE=1 disables BOTH the lexical
|
|
217
|
+
// and the realpath containment checks (agent cannot set it).
|
|
218
|
+
const allowAbsolute = process.env.PI_POC_ALLOW_ABSOLUTE === "1";
|
|
163
219
|
// Use path.relative so prefix-sibling escapes like /tmp/proj vs /tmp/proj-evil are rejected.
|
|
164
220
|
// startsWith(`${root}/`) would accept /tmp/proj-evil when root is /tmp/proj.
|
|
165
221
|
const rel = relative(root, normalized);
|
|
166
222
|
const outsideWorkspace = rel === "" ? false : rel.startsWith("..") || isAbsolute(rel);
|
|
167
|
-
if (outsideWorkspace) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
`Set PI_POC_ALLOW_ABSOLUTE=1 to allow arbitrary absolute paths.`,
|
|
173
|
-
);
|
|
174
|
-
}
|
|
223
|
+
if (outsideWorkspace && !allowAbsolute) {
|
|
224
|
+
throw new Error(
|
|
225
|
+
`PoC path must be under the project workspace (${root}). ` +
|
|
226
|
+
`Set PI_POC_ALLOW_ABSOLUTE=1 to allow arbitrary absolute paths.`,
|
|
227
|
+
);
|
|
175
228
|
}
|
|
176
229
|
|
|
177
230
|
if (!existsSync(normalized)) {
|
|
178
231
|
throw new Error(`PoC not found on disk: ${pocPath}`);
|
|
179
232
|
}
|
|
180
233
|
|
|
234
|
+
// Symlink containment: the lexical checks above are defeated by a
|
|
235
|
+
// workspace file that is a symlink to a host path (e.g. $HOME/.env) —
|
|
236
|
+
// existsSync/copyFileSync/readFileSync all dereference. Resolve the real
|
|
237
|
+
// path and re-run the containment check on it, then require a regular
|
|
238
|
+
// file (FIFOs/devices/sockets are rejected).
|
|
239
|
+
let real: string;
|
|
240
|
+
try {
|
|
241
|
+
real = realpathSync(normalized);
|
|
242
|
+
} catch {
|
|
243
|
+
throw new Error(`PoC path cannot be resolved: ${pocPath}`);
|
|
244
|
+
}
|
|
245
|
+
if (!allowAbsolute) {
|
|
246
|
+
const realRel = relative(root, real);
|
|
247
|
+
const realOutside = realRel === "" ? false : realRel.startsWith("..") || isAbsolute(realRel);
|
|
248
|
+
if (realOutside) {
|
|
249
|
+
throw new Error(
|
|
250
|
+
`PoC path resolves outside the project workspace (${real}). ` +
|
|
251
|
+
`Symlinked files outside ${root} are rejected.`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const realStat = statSync(real);
|
|
256
|
+
if (!realStat.isFile()) {
|
|
257
|
+
throw new Error(`PoC path must resolve to a regular file (got ${pocPath})`);
|
|
258
|
+
}
|
|
259
|
+
|
|
181
260
|
return normalized;
|
|
182
261
|
}
|
|
183
262
|
|
|
263
|
+
/**
|
|
264
|
+
* Strip control chars / ANSI escapes from PoC output. Does NOT slice —
|
|
265
|
+
* truncation is display-only and must never hide content from marker checks.
|
|
266
|
+
*/
|
|
184
267
|
function sanitizeOutput(output: string): string {
|
|
185
268
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional
|
|
186
269
|
const nulls = /\x00/g;
|
|
@@ -194,8 +277,37 @@ function sanitizeOutput(output: string): string {
|
|
|
194
277
|
.replace(/\r/g, "\n")
|
|
195
278
|
.replace(nulls, "")
|
|
196
279
|
.replace(ansi, "")
|
|
197
|
-
.replace(ctrl, "")
|
|
198
|
-
|
|
280
|
+
.replace(ctrl, "");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Split sanitized output into the raw (whole) and display (sliced) halves. */
|
|
284
|
+
function splitOutput(raw: string): { rawOutput: string; output: string; truncated: boolean } {
|
|
285
|
+
const truncated = raw.length > OUTPUT_MAX_CHARS;
|
|
286
|
+
return { rawOutput: raw, output: raw.slice(0, OUTPUT_MAX_CHARS), truncated };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function runProvenance(env?: Record<string, string>): Pick<PocRun, "mode" | "target"> {
|
|
290
|
+
return { mode: env?.PI_POC_MODE, target: env?.PI_POC_TARGET };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function outputWasComplete(result: { error?: Error; signal: string | null }): boolean {
|
|
294
|
+
return !result.error && result.signal === null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Reject control characters in harness-supplied PoC env values. */
|
|
298
|
+
function sanitizePocEnv(env: Record<string, string>): Record<string, string> {
|
|
299
|
+
const out: Record<string, string> = {};
|
|
300
|
+
for (const [key, value] of Object.entries(env)) {
|
|
301
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
302
|
+
throw new Error(`Invalid PoC env key: ${key}`);
|
|
303
|
+
}
|
|
304
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional
|
|
305
|
+
if (/[\x00-\x1f\x7f]/.test(value)) {
|
|
306
|
+
throw new Error(`PoC env value for ${key} contains control characters`);
|
|
307
|
+
}
|
|
308
|
+
out[key] = value;
|
|
309
|
+
}
|
|
310
|
+
return out;
|
|
199
311
|
}
|
|
200
312
|
|
|
201
313
|
function buildDockerArgs(
|
|
@@ -203,14 +315,23 @@ function buildDockerArgs(
|
|
|
203
315
|
command: string,
|
|
204
316
|
workspaceDir: string,
|
|
205
317
|
containerName: string,
|
|
318
|
+
network: "none" | "host",
|
|
319
|
+
env?: Record<string, string>,
|
|
206
320
|
): string[] {
|
|
321
|
+
const envArgs: string[] = [];
|
|
322
|
+
for (const [key, value] of Object.entries(sanitizePocEnv(env ?? {}))) {
|
|
323
|
+
// Values are single tokens from the harness (PI_POC_MODE / PI_POC_TARGET);
|
|
324
|
+
// pass them as separate -e args so no shell quoting is involved.
|
|
325
|
+
envArgs.push("-e", `${key}=${value}`);
|
|
326
|
+
}
|
|
207
327
|
return [
|
|
208
328
|
"run",
|
|
209
329
|
"--rm",
|
|
210
330
|
"--name",
|
|
211
331
|
containerName,
|
|
212
332
|
"--network",
|
|
213
|
-
|
|
333
|
+
network,
|
|
334
|
+
...envArgs,
|
|
214
335
|
"--read-only",
|
|
215
336
|
"--cap-drop",
|
|
216
337
|
"ALL",
|
|
@@ -290,7 +411,12 @@ function ensureImage(image: string): void {
|
|
|
290
411
|
}
|
|
291
412
|
}
|
|
292
413
|
|
|
293
|
-
function runSandboxed(
|
|
414
|
+
function runSandboxed(
|
|
415
|
+
pocPath: string,
|
|
416
|
+
language: PocLanguage,
|
|
417
|
+
network: "none" | "host" = "none",
|
|
418
|
+
env?: Record<string, string>,
|
|
419
|
+
): PocRun {
|
|
294
420
|
const ranAt = new Date().toISOString();
|
|
295
421
|
const sourceName = basename(pocPath);
|
|
296
422
|
const workspaceDir = mkdtempSync(resolve(tmpdir(), "poc-runner-"));
|
|
@@ -311,6 +437,9 @@ function runSandboxed(pocPath: string, language: PocLanguage): PocRun {
|
|
|
311
437
|
ranAt,
|
|
312
438
|
sandbox: true,
|
|
313
439
|
completed: false,
|
|
440
|
+
infraError: true,
|
|
441
|
+
outputComplete: false,
|
|
442
|
+
...runProvenance(env),
|
|
314
443
|
};
|
|
315
444
|
}
|
|
316
445
|
copyFileSync(pocPath, `${workspaceDir}/${sourceName}`);
|
|
@@ -326,7 +455,7 @@ function runSandboxed(pocPath: string, language: PocLanguage): PocRun {
|
|
|
326
455
|
|
|
327
456
|
const result = spawnSync(
|
|
328
457
|
"docker",
|
|
329
|
-
buildDockerArgs(language.image, wrapped, workspaceDir, containerName),
|
|
458
|
+
buildDockerArgs(language.image, wrapped, workspaceDir, containerName, network, env),
|
|
330
459
|
{
|
|
331
460
|
encoding: "utf8",
|
|
332
461
|
timeout: TIMEOUT_MS,
|
|
@@ -337,14 +466,18 @@ function runSandboxed(pocPath: string, language: PocLanguage): PocRun {
|
|
|
337
466
|
const spawnErr = result.error ? `\n[spawn error] ${result.error.message}` : "";
|
|
338
467
|
const raw = (result.stdout ?? "") + (result.stderr ?? "") + spawnErr;
|
|
339
468
|
const completed = raw.includes(sentinel);
|
|
340
|
-
const output = sanitizeOutput(raw.replace(sentinel, ""));
|
|
469
|
+
const { rawOutput, output, truncated } = splitOutput(sanitizeOutput(raw.replace(sentinel, "")));
|
|
341
470
|
return {
|
|
342
471
|
path: pocPath,
|
|
343
472
|
exitCode: spawnExitCode(result),
|
|
344
473
|
output,
|
|
474
|
+
rawOutput,
|
|
475
|
+
truncated,
|
|
345
476
|
ranAt,
|
|
346
477
|
sandbox: true,
|
|
347
478
|
completed,
|
|
479
|
+
outputComplete: outputWasComplete(result),
|
|
480
|
+
...runProvenance(env),
|
|
348
481
|
};
|
|
349
482
|
} finally {
|
|
350
483
|
// Best-effort: remove any container still running after a timeout/kill.
|
|
@@ -361,7 +494,7 @@ function runSandboxed(pocPath: string, language: PocLanguage): PocRun {
|
|
|
361
494
|
}
|
|
362
495
|
}
|
|
363
496
|
|
|
364
|
-
function runLocal(pocPath: string, language: PocLanguage): PocRun {
|
|
497
|
+
function runLocal(pocPath: string, language: PocLanguage, env?: Record<string, string>): PocRun {
|
|
365
498
|
const ranAt = new Date().toISOString();
|
|
366
499
|
|
|
367
500
|
// The run template is `<interpreter> [flags...] {{file}}`. Split the static
|
|
@@ -378,6 +511,11 @@ function runLocal(pocPath: string, language: PocLanguage): PocRun {
|
|
|
378
511
|
encoding: "utf8",
|
|
379
512
|
timeout: TIMEOUT_MS,
|
|
380
513
|
maxBuffer: MAX_BUFFER,
|
|
514
|
+
// Host runs get the harness env contract merged over the operator env;
|
|
515
|
+
// the spawn env is explicitly provided so PI_POC_MODE / PI_POC_TARGET
|
|
516
|
+
// reach the script without leaking through a shell. Same control-char
|
|
517
|
+
// rejection as the sandboxed path.
|
|
518
|
+
env: env ? { ...process.env, ...sanitizePocEnv(env) } : undefined,
|
|
381
519
|
});
|
|
382
520
|
|
|
383
521
|
// Local runs stay shell-free (space-containing paths stay single args), so
|
|
@@ -386,14 +524,20 @@ function runLocal(pocPath: string, language: PocLanguage): PocRun {
|
|
|
386
524
|
// means the script never ran to completion — fail closed on those.
|
|
387
525
|
const spawnErr = result.error ? `\n[spawn error] ${result.error.message}` : "";
|
|
388
526
|
const completed = !result.error && result.signal === null;
|
|
389
|
-
const
|
|
527
|
+
const { rawOutput, output, truncated } = splitOutput(
|
|
528
|
+
sanitizeOutput((result.stdout ?? "") + (result.stderr ?? "") + spawnErr),
|
|
529
|
+
);
|
|
390
530
|
return {
|
|
391
531
|
path: pocPath,
|
|
392
532
|
exitCode: spawnExitCode(result),
|
|
393
533
|
output,
|
|
534
|
+
rawOutput,
|
|
535
|
+
truncated,
|
|
394
536
|
ranAt,
|
|
395
537
|
sandbox: false,
|
|
396
538
|
completed,
|
|
539
|
+
outputComplete: outputWasComplete(result),
|
|
540
|
+
...runProvenance(env),
|
|
397
541
|
};
|
|
398
542
|
}
|
|
399
543
|
|
|
@@ -408,17 +552,59 @@ function runLocal(pocPath: string, language: PocLanguage): PocRun {
|
|
|
408
552
|
*
|
|
409
553
|
* Security:
|
|
410
554
|
* - PoC paths must be absolute and under the project workspace by default.
|
|
411
|
-
* - Docker sandbox runs with
|
|
412
|
-
*
|
|
413
|
-
*
|
|
555
|
+
* - Docker sandbox runs with read-only root FS, dropped caps, no new
|
|
556
|
+
* privileges, an unprivileged user, and resource limits. Networking is
|
|
557
|
+
* `none` by default; `network: "host"` adds host networking for live
|
|
558
|
+
* findings WITHOUT giving up the FS/cap/user isolation.
|
|
559
|
+
* - Host execution (local: true) is NOT agent-selectable: the default is a
|
|
560
|
+
* host-network Docker sandbox. Bare host requires the operator's
|
|
561
|
+
* `PI_POC_ALLOW_LOCAL=1` and is used only when Docker is unavailable
|
|
562
|
+
* (or when `PI_POC_FORCE_LOCAL=1` is also set). Without ALLOW the run
|
|
563
|
+
* fails closed if Docker cannot start.
|
|
564
|
+
*
|
|
565
|
+
* Back-compat: `runPoc(path, true)` == sandboxed, `runPoc(path, false)` ==
|
|
566
|
+
* `{ local: true }` (host-network sandbox; host if operator-gated).
|
|
414
567
|
*/
|
|
415
|
-
export function runPoc(pocPath: string,
|
|
568
|
+
export function runPoc(pocPath: string, options?: PocRunOptions | boolean): PocRun {
|
|
416
569
|
const normalized = validatePocPath(pocPath);
|
|
417
570
|
const { language } = resolveLanguage(normalized);
|
|
418
571
|
|
|
419
|
-
|
|
420
|
-
|
|
572
|
+
const opts: PocRunOptions =
|
|
573
|
+
typeof options === "boolean"
|
|
574
|
+
? options
|
|
575
|
+
? { network: "none" }
|
|
576
|
+
: { local: true }
|
|
577
|
+
: (options ?? {});
|
|
578
|
+
|
|
579
|
+
// Host execution is gated by the OPERATOR, never by an agent-supplied flag.
|
|
580
|
+
// `local: true` means "network access needed":
|
|
581
|
+
// 1. Prefer a host-network Docker sandbox (isolation retained).
|
|
582
|
+
// 2. Fall back to bare host ONLY when Docker/image is unavailable AND the
|
|
583
|
+
// operator set PI_POC_ALLOW_LOCAL=1.
|
|
584
|
+
// 3. PI_POC_FORCE_LOCAL=1 + ALLOW lets the operator (or test harness)
|
|
585
|
+
// skip Docker and run on the host deliberately — still never agent-only.
|
|
586
|
+
if (opts.local === true) {
|
|
587
|
+
const allowLocal = process.env[LOCAL_EXEC_ENV] === "1";
|
|
588
|
+
const forceLocal = process.env.PI_POC_FORCE_LOCAL === "1";
|
|
589
|
+
if (forceLocal && allowLocal) {
|
|
590
|
+
return runLocal(normalized, language, opts.env);
|
|
591
|
+
}
|
|
592
|
+
const sandboxed = runSandboxed(normalized, language, "host", opts.env);
|
|
593
|
+
if (!sandboxed.infraError) {
|
|
594
|
+
return sandboxed;
|
|
595
|
+
}
|
|
596
|
+
if (allowLocal) {
|
|
597
|
+
return runLocal(normalized, language, opts.env);
|
|
598
|
+
}
|
|
599
|
+
return {
|
|
600
|
+
...sandboxed,
|
|
601
|
+
output:
|
|
602
|
+
sandboxed.output +
|
|
603
|
+
`\n[host execution blocked] local:true cannot run on the host without the operator's ` +
|
|
604
|
+
`${LOCAL_EXEC_ENV}=1 — an agent-supplied local flag alone cannot enable host execution. ` +
|
|
605
|
+
"Ask the operator to opt in, or use the default (isolated) sandbox if the PoC does not need network.",
|
|
606
|
+
};
|
|
421
607
|
}
|
|
422
608
|
|
|
423
|
-
return
|
|
609
|
+
return runSandboxed(normalized, language, opts.network ?? "none", opts.env);
|
|
424
610
|
}
|