@xaccefy/pi-casefile 0.8.3 → 0.9.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 +4 -3
- package/package.json +1 -1
- package/skills/casefile/SKILL.md +4 -3
- package/src/index.ts +274 -215
- package/src/ledger.ts +393 -205
- package/src/pipeline-submit.ts +68 -13
- package/src/poc-runner.ts +167 -63
- package/src/scratchpad.ts +28 -4
- package/src/workflow.ts +79 -16
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, renameSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { existsSync, readFileSync, realpathSync, 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
|
|
|
@@ -120,6 +120,13 @@ export const SPECS: Record<SubmitStage, StageSpec> = {
|
|
|
120
120
|
],
|
|
121
121
|
conditional: [
|
|
122
122
|
{ when: { field: "verdict", equals: "DISPROVEN" }, require: ["disproval_reason"] },
|
|
123
|
+
{
|
|
124
|
+
// A CONFIRMED verdict must carry the skeptic's own failed disproof —
|
|
125
|
+
// the workflow makes it the case's disconfirmation. "Could not
|
|
126
|
+
// disprove" alone is not an attempt.
|
|
127
|
+
when: { field: "verdict", equals: "CONFIRMED" },
|
|
128
|
+
require: ["disconfirmation_attempt"],
|
|
129
|
+
},
|
|
123
130
|
],
|
|
124
131
|
},
|
|
125
132
|
// schemas/stage-validation.json
|
|
@@ -189,6 +196,7 @@ type FindingRef = {
|
|
|
189
196
|
key: string;
|
|
190
197
|
file: string;
|
|
191
198
|
line?: number;
|
|
199
|
+
endpoint?: string;
|
|
192
200
|
vuln_class: string;
|
|
193
201
|
};
|
|
194
202
|
|
|
@@ -516,6 +524,20 @@ function validateStage(stage: SubmitStage, obj: Record<string, unknown>): string
|
|
|
516
524
|
];
|
|
517
525
|
if (!allowed.includes(obj.kill_reason)) errors.push("kill_reason: invalid value");
|
|
518
526
|
}
|
|
527
|
+
if (obj.status === "confirmed" && isNonEmptyString(obj.poc_path)) {
|
|
528
|
+
// A validate submission asserting "confirmed" must point at a PoC file
|
|
529
|
+
// that actually exists in the project — same file-existence filter hunt
|
|
530
|
+
// findings get. Otherwise fabricated run logs pass the stage gate.
|
|
531
|
+
const raw = obj.poc_path as string;
|
|
532
|
+
const normalized = raw.replace(/^\.?\//, "");
|
|
533
|
+
const abs = isAbsolute(normalized) ? resolve(normalized) : resolve(projectRoot(), normalized);
|
|
534
|
+
const rel = relative(projectRoot(), abs);
|
|
535
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
536
|
+
errors.push("poc_path: must resolve inside the project root");
|
|
537
|
+
} else if (!existsSync(abs)) {
|
|
538
|
+
errors.push(`poc_path: "${raw}" does not exist under the project root`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
519
541
|
if (
|
|
520
542
|
obj.refinement_attempts !== undefined &&
|
|
521
543
|
(!Number.isInteger(obj.refinement_attempts) ||
|
|
@@ -579,6 +601,23 @@ function prefilterHunt(obj: Record<string, unknown>): string | null {
|
|
|
579
601
|
`Findings must reference files inside the target repository.`
|
|
580
602
|
);
|
|
581
603
|
}
|
|
604
|
+
// Symlink containment (same defense as the PoC runner): resolve() is
|
|
605
|
+
// lexical, and existsSync() dereferences symlinks — a workspace symlink to
|
|
606
|
+
// /etc (ln -s /etc etc-link) would otherwise pass both checks and let a
|
|
607
|
+
// "finding" point at host paths outside the project.
|
|
608
|
+
let real: string;
|
|
609
|
+
try {
|
|
610
|
+
real = realpathSync(abs);
|
|
611
|
+
} catch {
|
|
612
|
+
return `file-existence filter: "${file}" cannot be resolved under the project root (${root}).`;
|
|
613
|
+
}
|
|
614
|
+
const realRel = relative(root, real);
|
|
615
|
+
if (realRel.startsWith("..") || isAbsolute(realRel)) {
|
|
616
|
+
return (
|
|
617
|
+
`containment filter: "${file}" resolves through a symlink to outside the project root ` +
|
|
618
|
+
`(${real}). Symlinked files outside ${root} are rejected.`
|
|
619
|
+
);
|
|
620
|
+
}
|
|
582
621
|
if (!existsSync(abs)) {
|
|
583
622
|
return (
|
|
584
623
|
`file-existence filter: "${file}" does not exist under the project root ` +
|
|
@@ -590,12 +629,19 @@ function prefilterHunt(obj: Record<string, unknown>): string | null {
|
|
|
590
629
|
|
|
591
630
|
function dedupHunt(state: SubmitState, obj: Record<string, unknown>): { duplicateOf?: string } {
|
|
592
631
|
const file = typeof obj.file === "string" ? obj.file.replace(/^\.?\//, "") : undefined;
|
|
632
|
+
const endpoint = typeof obj.endpoint === "string" ? obj.endpoint.trim() : undefined;
|
|
593
633
|
const vulnClass = typeof obj.vuln_class === "string" ? obj.vuln_class : undefined;
|
|
594
634
|
const line = typeof obj.line === "number" ? obj.line : undefined;
|
|
595
|
-
if (!
|
|
635
|
+
if (!vulnClass) return {};
|
|
596
636
|
for (const accepted of state.accepted_findings) {
|
|
597
637
|
if (accepted.vuln_class !== vulnClass) continue;
|
|
598
|
-
|
|
638
|
+
// Live locator: same endpoint + class is the same finding (re-submissions
|
|
639
|
+
// after a repair must not be accepted repeatedly).
|
|
640
|
+
if (!file && endpoint !== undefined) {
|
|
641
|
+
if (accepted.endpoint === endpoint) return { duplicateOf: accepted.key };
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
if (!file || accepted.file !== file) continue;
|
|
599
645
|
if (
|
|
600
646
|
line !== undefined &&
|
|
601
647
|
accepted.line !== undefined &&
|
|
@@ -672,24 +718,33 @@ export function pipeline_submit(runId: string, stage: SubmitStage, output: unkno
|
|
|
672
718
|
duplicate_of: duplicateOf,
|
|
673
719
|
};
|
|
674
720
|
}
|
|
675
|
-
if (typeof obj.
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
721
|
+
if (typeof obj.vuln_class === "string") {
|
|
722
|
+
const isFileFinding = typeof obj.file === "string";
|
|
723
|
+
const isEndpointFinding = !isFileFinding && typeof obj.endpoint === "string";
|
|
724
|
+
if (isFileFinding || isEndpointFinding) {
|
|
725
|
+
state.accepted_findings.push({
|
|
726
|
+
key,
|
|
727
|
+
file: isFileFinding ? (obj.file as string).replace(/^\.?\//, "") : "",
|
|
728
|
+
line: typeof obj.line === "number" ? obj.line : undefined,
|
|
729
|
+
endpoint: isEndpointFinding ? (obj.endpoint as string).trim() : undefined,
|
|
730
|
+
vuln_class: obj.vuln_class,
|
|
731
|
+
});
|
|
732
|
+
}
|
|
682
733
|
writeState(runId, state);
|
|
683
734
|
}
|
|
684
735
|
}
|
|
685
736
|
|
|
686
737
|
// Submit stages are a subset of scratchpad phases, so the stage name IS
|
|
687
|
-
// the phase directory.
|
|
738
|
+
// the phase directory. The filename gets a content hash: distinct findings
|
|
739
|
+
// sharing one plausible id (the stable repair-bucket key) must not clobber
|
|
740
|
+
// each other's accepted artifact.
|
|
741
|
+
const json = JSON.stringify(obj, null, 2);
|
|
742
|
+
const contentHash = createHash("sha1").update(json).digest("hex").slice(0, 8);
|
|
688
743
|
const artifact = scratchpad_write(
|
|
689
744
|
runId,
|
|
690
745
|
stage,
|
|
691
|
-
`${key.replace(/[^a-zA-Z0-9._:-]/g, "_")}.json`,
|
|
692
|
-
|
|
746
|
+
`${key.replace(/[^a-zA-Z0-9._:-]/g, "_")}-${contentHash}.json`,
|
|
747
|
+
json,
|
|
693
748
|
);
|
|
694
749
|
return { verdict: "accepted", stage, errors: [], key, artifact };
|
|
695
750
|
}
|
package/src/poc-runner.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import {
|
|
3
4
|
copyFileSync,
|
|
4
5
|
existsSync,
|
|
6
|
+
mkdirSync,
|
|
5
7
|
mkdtempSync,
|
|
6
8
|
readFileSync,
|
|
7
9
|
realpathSync,
|
|
@@ -11,6 +13,7 @@ import {
|
|
|
11
13
|
import { tmpdir } from "node:os";
|
|
12
14
|
import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
13
15
|
|
|
16
|
+
import { evidenceNonceMatches, type PoCEvidence, parsePoCEvidence } from "./evidence.ts";
|
|
14
17
|
import { findWorkspaceRoot } from "./scratchpad.ts";
|
|
15
18
|
|
|
16
19
|
export type PocRun = {
|
|
@@ -40,12 +43,22 @@ export type PocRun = {
|
|
|
40
43
|
mode?: string;
|
|
41
44
|
/** Harness target used for this run. */
|
|
42
45
|
target?: string;
|
|
43
|
-
/**
|
|
44
|
-
* True when the run never started because of harness infrastructure
|
|
46
|
+
/** True when the run never started because of harness infrastructure
|
|
45
47
|
* failure (e.g. sandbox image pull failed) — set only by the runner,
|
|
46
|
-
* never derived from PoC-controlled output text.
|
|
47
|
-
*/
|
|
48
|
+
* never derived from PoC-controlled output text. */
|
|
48
49
|
infraError?: boolean;
|
|
50
|
+
/** Validated evidence.json written by the PoC to $PI_POC_EVIDENCE_DIR. */
|
|
51
|
+
evidence?: PoCEvidence;
|
|
52
|
+
/** SHA-256 of the evidence.json file (reproduction artifact). */
|
|
53
|
+
evidenceSha256?: string;
|
|
54
|
+
/** Absolute path of the PRESERVED copy of evidence.json (moved into the
|
|
55
|
+
* durable .pi/poc-evidence/ dir before the temp workspace is cleaned up) —
|
|
56
|
+
* lets the ledger's reproduction item stay artifact-backed and re-verifiable. */
|
|
57
|
+
evidencePath?: string;
|
|
58
|
+
/** Evidence contract failure (missing/invalid/nonce mismatch) — blocks the gate. */
|
|
59
|
+
evidenceError?: string;
|
|
60
|
+
/** The per-run nonce the evidence must be bound to (harness-generated). */
|
|
61
|
+
nonce?: string;
|
|
49
62
|
};
|
|
50
63
|
|
|
51
64
|
/**
|
|
@@ -117,6 +130,10 @@ const TIMEOUT_MS = 30_000;
|
|
|
117
130
|
function makeSentinel(): string {
|
|
118
131
|
return `__PI_POC_DONE_${Math.random().toString(36).slice(2, 12)}__`;
|
|
119
132
|
}
|
|
133
|
+
/** Per-run random nonce the PoC must echo in evidence.json (binds evidence to its run). */
|
|
134
|
+
function makeNonce(): string {
|
|
135
|
+
return `poc_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
|
|
136
|
+
}
|
|
120
137
|
/** First-use image downloads are slow — pull outside the run timeout. */
|
|
121
138
|
const PULL_TIMEOUT_MS = 300_000;
|
|
122
139
|
const MAX_BUFFER = 8 * 1024 * 1024;
|
|
@@ -286,10 +303,67 @@ function splitOutput(raw: string): { rawOutput: string; output: string; truncate
|
|
|
286
303
|
return { rawOutput: raw, output: raw.slice(0, OUTPUT_MAX_CHARS), truncated };
|
|
287
304
|
}
|
|
288
305
|
|
|
306
|
+
/**
|
|
307
|
+
* Read + validate the PoC's evidence.json from the harness-owned evidence
|
|
308
|
+
* dir. Missing, malformed, or nonce-mismatched evidence is a contract
|
|
309
|
+
* failure, not a verdict — surfaced as evidenceError on the run.
|
|
310
|
+
*/
|
|
311
|
+
function readEvidence(
|
|
312
|
+
evidenceDir: string,
|
|
313
|
+
nonce: string,
|
|
314
|
+
): { evidence?: PoCEvidence; evidenceSha256?: string; evidenceError?: string } {
|
|
315
|
+
const p = join(evidenceDir, "evidence.json");
|
|
316
|
+
if (!existsSync(p)) {
|
|
317
|
+
return {
|
|
318
|
+
evidenceError: `evidence.json missing in ${evidenceDir} — the PoC must write it to $PI_POC_EVIDENCE_DIR ({"nonce", "claim", "verify", "observations"})`,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
let raw: unknown;
|
|
322
|
+
try {
|
|
323
|
+
raw = JSON.parse(readFileSync(p, "utf8"));
|
|
324
|
+
} catch (e) {
|
|
325
|
+
return { evidenceError: `evidence.json unparseable: ${(e as Error).message}` };
|
|
326
|
+
}
|
|
327
|
+
const parsed = parsePoCEvidence(raw);
|
|
328
|
+
if (!parsed.ok) return { evidenceError: parsed.error };
|
|
329
|
+
if (!evidenceNonceMatches(parsed.evidence, nonce)) {
|
|
330
|
+
return {
|
|
331
|
+
evidenceError:
|
|
332
|
+
"evidence.json nonce mismatch — the file was not written by this run (copy-pasted evidence fails here)",
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
evidence: parsed.evidence,
|
|
337
|
+
evidenceSha256: createHash("sha256").update(readFileSync(p)).digest("hex"),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
289
341
|
function runProvenance(env?: Record<string, string>): Pick<PocRun, "mode" | "target"> {
|
|
290
342
|
return { mode: env?.PI_POC_MODE, target: env?.PI_POC_TARGET };
|
|
291
343
|
}
|
|
292
344
|
|
|
345
|
+
/**
|
|
346
|
+
* Copy the run's evidence.json into a durable harness-owned dir BEFORE the
|
|
347
|
+
* temp workspace is deleted. The ledger's reproduction item stores the SHA-256
|
|
348
|
+
* of this exact file, so the file must survive the run — otherwise the
|
|
349
|
+
* "artifact-backed" evidence item hashes a file that no longer exists.
|
|
350
|
+
* Returns the preserved path, or undefined when the file is missing.
|
|
351
|
+
*/
|
|
352
|
+
function preserveEvidence(evidenceDir: string, nonce: string): string | undefined {
|
|
353
|
+
const source = join(evidenceDir, "evidence.json");
|
|
354
|
+
if (!existsSync(source)) return undefined;
|
|
355
|
+
try {
|
|
356
|
+
const durableDir = join(getProjectRoot(), ".pi", "poc-evidence");
|
|
357
|
+
mkdirSync(durableDir, { recursive: true });
|
|
358
|
+
const dest = join(durableDir, `${nonce}.evidence.json`);
|
|
359
|
+
copyFileSync(source, dest);
|
|
360
|
+
return dest;
|
|
361
|
+
} catch {
|
|
362
|
+
// Best-effort: a preserved copy is an audit-trail improvement, not a gate.
|
|
363
|
+
return undefined;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
293
367
|
function outputWasComplete(result: { error?: Error; signal: string | null }): boolean {
|
|
294
368
|
return !result.error && result.signal === null;
|
|
295
369
|
}
|
|
@@ -423,6 +497,17 @@ function runSandboxed(
|
|
|
423
497
|
// Named container so a timed-out / killed client can still be cleaned up —
|
|
424
498
|
// `--rm` alone leaks the container when the CLI dies before the child exits.
|
|
425
499
|
const containerName = `poc-runner-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
|
|
500
|
+
// Evidence contract: harness-owned dir + per-run nonce. The PoC writes
|
|
501
|
+
// evidence.json into $PI_POC_EVIDENCE_DIR (/workspace/evidence inside the
|
|
502
|
+
// container); the nonce binds the file to this run.
|
|
503
|
+
const nonce = makeNonce();
|
|
504
|
+
const evidenceDir = join(workspaceDir, "evidence");
|
|
505
|
+
mkdirSync(evidenceDir, { recursive: true });
|
|
506
|
+
const runEnv = {
|
|
507
|
+
...env,
|
|
508
|
+
PI_POC_EVIDENCE_DIR: "/workspace/evidence",
|
|
509
|
+
PI_POC_NONCE: nonce,
|
|
510
|
+
};
|
|
426
511
|
|
|
427
512
|
try {
|
|
428
513
|
// Fail closed as a non-zero run (not a throw) when docker/images are
|
|
@@ -447,15 +532,17 @@ function runSandboxed(
|
|
|
447
532
|
const command = renderCommand(language.run, pocPath, true);
|
|
448
533
|
|
|
449
534
|
// Completion sentinel: wrap the command so the shell echoes a unique token
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
//
|
|
535
|
+
// ONLY when the PoC exited normally (rc < 128). A child killed by a signal
|
|
536
|
+
// (segfault, OOM under the sandbox limits) yields rc = 128+N — the sentinel
|
|
537
|
+
// is suppressed, so `completed` means "the script actually ran to
|
|
538
|
+
// completion", not merely "the container exited". Callers treat a missing
|
|
539
|
+
// sentinel as "not a verdict".
|
|
453
540
|
const sentinel = makeSentinel();
|
|
454
|
-
const wrapped = `${command}; rc=$?; echo '${sentinel}'; exit $rc`;
|
|
541
|
+
const wrapped = `${command}; rc=$?; if [ "$rc" -lt 128 ]; then echo '${sentinel}'; fi; exit $rc`;
|
|
455
542
|
|
|
456
543
|
const result = spawnSync(
|
|
457
544
|
"docker",
|
|
458
|
-
buildDockerArgs(language.image, wrapped, workspaceDir, containerName, network,
|
|
545
|
+
buildDockerArgs(language.image, wrapped, workspaceDir, containerName, network, runEnv),
|
|
459
546
|
{
|
|
460
547
|
encoding: "utf8",
|
|
461
548
|
timeout: TIMEOUT_MS,
|
|
@@ -467,6 +554,8 @@ function runSandboxed(
|
|
|
467
554
|
const raw = (result.stdout ?? "") + (result.stderr ?? "") + spawnErr;
|
|
468
555
|
const completed = raw.includes(sentinel);
|
|
469
556
|
const { rawOutput, output, truncated } = splitOutput(sanitizeOutput(raw.replace(sentinel, "")));
|
|
557
|
+
const preserved = preserveEvidence(evidenceDir, nonce);
|
|
558
|
+
const evidence = readEvidence(evidenceDir, nonce);
|
|
470
559
|
return {
|
|
471
560
|
path: pocPath,
|
|
472
561
|
exitCode: spawnExitCode(result),
|
|
@@ -477,6 +566,9 @@ function runSandboxed(
|
|
|
477
566
|
sandbox: true,
|
|
478
567
|
completed,
|
|
479
568
|
outputComplete: outputWasComplete(result),
|
|
569
|
+
nonce,
|
|
570
|
+
...evidence,
|
|
571
|
+
evidencePath: evidence.evidence ? preserved : undefined,
|
|
480
572
|
...runProvenance(env),
|
|
481
573
|
};
|
|
482
574
|
} finally {
|
|
@@ -496,49 +588,70 @@ function runSandboxed(
|
|
|
496
588
|
|
|
497
589
|
function runLocal(pocPath: string, language: PocLanguage, env?: Record<string, string>): PocRun {
|
|
498
590
|
const ranAt = new Date().toISOString();
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
const tokens = language.run.trim().split(/\s+/).filter(Boolean);
|
|
507
|
-
const interpreter = tokens.shift() ?? language.run.trim();
|
|
508
|
-
const args = tokens.map((tok) => renderCommand(tok, pocPath, false));
|
|
509
|
-
|
|
510
|
-
const result = spawnSync(interpreter, args, {
|
|
511
|
-
encoding: "utf8",
|
|
512
|
-
timeout: TIMEOUT_MS,
|
|
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,
|
|
519
|
-
});
|
|
520
|
-
|
|
521
|
-
// Local runs stay shell-free (space-containing paths stay single args), so
|
|
522
|
-
// there is no sentinel echo: "completed" is derived from the spawn result.
|
|
523
|
-
// A spawn error (interpreter missing) or a signal kill (timeout, SIGKILL)
|
|
524
|
-
// means the script never ran to completion — fail closed on those.
|
|
525
|
-
const spawnErr = result.error ? `\n[spawn error] ${result.error.message}` : "";
|
|
526
|
-
const completed = !result.error && result.signal === null;
|
|
527
|
-
const { rawOutput, output, truncated } = splitOutput(
|
|
528
|
-
sanitizeOutput((result.stdout ?? "") + (result.stderr ?? "") + spawnErr),
|
|
529
|
-
);
|
|
530
|
-
return {
|
|
531
|
-
path: pocPath,
|
|
532
|
-
exitCode: spawnExitCode(result),
|
|
533
|
-
output,
|
|
534
|
-
rawOutput,
|
|
535
|
-
truncated,
|
|
536
|
-
ranAt,
|
|
537
|
-
sandbox: false,
|
|
538
|
-
completed,
|
|
539
|
-
outputComplete: outputWasComplete(result),
|
|
540
|
-
...runProvenance(env),
|
|
591
|
+
// Evidence contract: harness-owned temp dir + per-run nonce, same as sandboxed.
|
|
592
|
+
const nonce = makeNonce();
|
|
593
|
+
const evidenceDir = mkdtempSync(join(tmpdir(), "poc-evidence-"));
|
|
594
|
+
const runEnv = {
|
|
595
|
+
...env,
|
|
596
|
+
PI_POC_EVIDENCE_DIR: evidenceDir,
|
|
597
|
+
PI_POC_NONCE: nonce,
|
|
541
598
|
};
|
|
599
|
+
|
|
600
|
+
try {
|
|
601
|
+
// The run template is `<interpreter> [flags...] {{file}}`. Split the static
|
|
602
|
+
// template on whitespace FIRST (builtins only, not user input), then render
|
|
603
|
+
// placeholders within each token. Passing the tokens to spawnSync with NO
|
|
604
|
+
// shell keeps a space-containing PoC path as one arg and keeps extra flags
|
|
605
|
+
// (e.g. `node --experimental-vm-modules {{file}}`) as separate args.
|
|
606
|
+
// Splitting after rendering would re-split a space-containing path.
|
|
607
|
+
const tokens = language.run.trim().split(/\s+/).filter(Boolean);
|
|
608
|
+
const interpreter = tokens.shift() ?? language.run.trim();
|
|
609
|
+
const args = tokens.map((tok) => renderCommand(tok, pocPath, false));
|
|
610
|
+
|
|
611
|
+
const result = spawnSync(interpreter, args, {
|
|
612
|
+
encoding: "utf8",
|
|
613
|
+
timeout: TIMEOUT_MS,
|
|
614
|
+
maxBuffer: MAX_BUFFER,
|
|
615
|
+
// Host runs get the harness env contract merged over the operator env;
|
|
616
|
+
// the spawn env is explicitly provided so PI_POC_MODE / PI_POC_TARGET
|
|
617
|
+
// reach the script without leaking through a shell. Same control-char
|
|
618
|
+
// rejection as the sandboxed path.
|
|
619
|
+
env: { ...process.env, ...sanitizePocEnv(runEnv) },
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
// Local runs stay shell-free (space-containing paths stay single args), so
|
|
623
|
+
// there is no sentinel echo: "completed" is derived from the spawn result.
|
|
624
|
+
// A spawn error (interpreter missing) or a signal kill (timeout, SIGKILL)
|
|
625
|
+
// means the script never ran to completion — fail closed on those.
|
|
626
|
+
const spawnErr = result.error ? `\n[spawn error] ${result.error.message}` : "";
|
|
627
|
+
const completed = !result.error && result.signal === null;
|
|
628
|
+
const { rawOutput, output, truncated } = splitOutput(
|
|
629
|
+
sanitizeOutput((result.stdout ?? "") + (result.stderr ?? "") + spawnErr),
|
|
630
|
+
);
|
|
631
|
+
const preserved = preserveEvidence(evidenceDir, nonce);
|
|
632
|
+
const evidence = readEvidence(evidenceDir, nonce);
|
|
633
|
+
return {
|
|
634
|
+
path: pocPath,
|
|
635
|
+
exitCode: spawnExitCode(result),
|
|
636
|
+
output,
|
|
637
|
+
rawOutput,
|
|
638
|
+
truncated,
|
|
639
|
+
ranAt,
|
|
640
|
+
sandbox: false,
|
|
641
|
+
completed,
|
|
642
|
+
outputComplete: outputWasComplete(result),
|
|
643
|
+
nonce,
|
|
644
|
+
...evidence,
|
|
645
|
+
evidencePath: evidence.evidence ? preserved : undefined,
|
|
646
|
+
...runProvenance(env),
|
|
647
|
+
};
|
|
648
|
+
} finally {
|
|
649
|
+
try {
|
|
650
|
+
rmSync(evidenceDir, { recursive: true, force: true });
|
|
651
|
+
} catch {
|
|
652
|
+
// Best-effort cleanup.
|
|
653
|
+
}
|
|
654
|
+
}
|
|
542
655
|
}
|
|
543
656
|
|
|
544
657
|
/**
|
|
@@ -547,8 +660,7 @@ function runLocal(pocPath: string, language: PocLanguage, env?: Record<string, s
|
|
|
547
660
|
* Language detection (in order):
|
|
548
661
|
* 1. Shebang line in the PoC file.
|
|
549
662
|
* 2. File extension (a .py PoC in a Node repo still runs under python).
|
|
550
|
-
* 3.
|
|
551
|
-
* 4. PI_POC_DEFAULT_LANGUAGE environment variable (a built-in language key).
|
|
663
|
+
* 3. PI_POC_DEFAULT_LANGUAGE environment variable (a built-in language key).
|
|
552
664
|
*
|
|
553
665
|
* Security:
|
|
554
666
|
* - PoC paths must be absolute and under the project workspace by default.
|
|
@@ -561,20 +673,12 @@ function runLocal(pocPath: string, language: PocLanguage, env?: Record<string, s
|
|
|
561
673
|
* `PI_POC_ALLOW_LOCAL=1` and is used only when Docker is unavailable
|
|
562
674
|
* (or when `PI_POC_FORCE_LOCAL=1` is also set). Without ALLOW the run
|
|
563
675
|
* 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).
|
|
567
676
|
*/
|
|
568
|
-
export function runPoc(pocPath: string, options?: PocRunOptions
|
|
677
|
+
export function runPoc(pocPath: string, options?: PocRunOptions): PocRun {
|
|
569
678
|
const normalized = validatePocPath(pocPath);
|
|
570
679
|
const { language } = resolveLanguage(normalized);
|
|
571
680
|
|
|
572
|
-
const opts: PocRunOptions =
|
|
573
|
-
typeof options === "boolean"
|
|
574
|
-
? options
|
|
575
|
-
? { network: "none" }
|
|
576
|
-
: { local: true }
|
|
577
|
-
: (options ?? {});
|
|
681
|
+
const opts: PocRunOptions = options ?? {};
|
|
578
682
|
|
|
579
683
|
// Host execution is gated by the OPERATOR, never by an agent-supplied flag.
|
|
580
684
|
// `local: true` means "network access needed":
|
package/src/scratchpad.ts
CHANGED
|
@@ -127,7 +127,7 @@ export function findWorkspaceRoot(envNames: string[], markers: string[]): string
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
/** Detect the scratchpad workspace root (override, env, then walk up). */
|
|
130
|
-
function detectWorkspaceRoot(): string {
|
|
130
|
+
export function detectWorkspaceRoot(): string {
|
|
131
131
|
if (scratchpadRootOverride) return scratchpadRootOverride;
|
|
132
132
|
return findWorkspaceRoot(
|
|
133
133
|
["XPI_SCRATCHPAD_ROOT", "PI_WORKSPACE_ROOT", "GITHUB_WORKSPACE"],
|
|
@@ -168,6 +168,23 @@ function runDirName(runId: string): string {
|
|
|
168
168
|
return `${safe.slice(0, 80)}-${suffix}`;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Artifact names get the same disambiguation as run dirs: sanitization is
|
|
173
|
+
* lossy ("a/b" and "a_b" both become "a_b"), so a changed name gets a content
|
|
174
|
+
* hash suffix — distinct inputs can no longer silently overwrite each other's
|
|
175
|
+
* file. Reads use the same mapping, so round-trips stay consistent.
|
|
176
|
+
*/
|
|
177
|
+
function artifactFileName(name: string): string {
|
|
178
|
+
const safe = sanitizeName(name, "artifact name");
|
|
179
|
+
if (safe === name) return safe;
|
|
180
|
+
const suffix = createHash("sha256").update(name).digest("hex").slice(0, 12);
|
|
181
|
+
return `${safe.slice(0, 80)}-${suffix}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Cap on a single scratchpad artifact (2 MiB) — a hallucinating or hostile
|
|
185
|
+
* subagent must not be able to fill the disk with unbounded writes. */
|
|
186
|
+
const MAX_ARTIFACT_BYTES = 2 * 1024 * 1024;
|
|
187
|
+
|
|
171
188
|
/** Pre-hash-suffix naming used by older scratchpad versions (sanitize only). */
|
|
172
189
|
function legacyRunDirName(runId: string): string {
|
|
173
190
|
return sanitizeName(runId, "run_id");
|
|
@@ -281,8 +298,15 @@ export function scratchpad_write(
|
|
|
281
298
|
const runDir = getRunDir(runId, root);
|
|
282
299
|
ensureRunDirs(runDir);
|
|
283
300
|
|
|
284
|
-
|
|
285
|
-
|
|
301
|
+
if (Buffer.byteLength(content, "utf8") > MAX_ARTIFACT_BYTES) {
|
|
302
|
+
throw new Error(
|
|
303
|
+
`Artifact too large (${Buffer.byteLength(content, "utf8")} bytes; max ${MAX_ARTIFACT_BYTES}): ${artifactName}`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Sanitize + disambiguate artifact name: no path traversal, no dot-only
|
|
308
|
+
// escape, and lossy sanitization cannot collide two distinct names.
|
|
309
|
+
const safeName = artifactFileName(artifactName);
|
|
286
310
|
const dir = join(runDir, PHASE_DIRS[phase]);
|
|
287
311
|
const filePath = join(dir, safeName);
|
|
288
312
|
writeFileSync(filePath, content, "utf8");
|
|
@@ -299,7 +323,7 @@ export function scratchpad_read(
|
|
|
299
323
|
projectRoot?: string,
|
|
300
324
|
): string | null {
|
|
301
325
|
const root = projectRoot ?? detectWorkspaceRoot();
|
|
302
|
-
const safeName =
|
|
326
|
+
const safeName = artifactFileName(artifactName);
|
|
303
327
|
const filePath = join(getRunDir(runId, root), PHASE_DIRS[phase], safeName);
|
|
304
328
|
if (!existsSync(filePath)) return null;
|
|
305
329
|
return readFileSync(filePath, "utf8");
|