@xaccefy/pi-casefile 0.8.3 → 0.9.1
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 +9 -5
- package/package.json +13 -4
- package/skills/casefile/SKILL.md +4 -3
- package/src/evidence.ts +501 -0
- package/src/harness-verify.ts +693 -0
- package/src/index.ts +391 -224
- package/src/ledger-worker-entry.ts +35 -0
- package/src/ledger-worker.ts +77 -0
- package/src/ledger.ts +853 -233
- package/src/pipeline-submit.ts +153 -43
- package/src/poc-runner.ts +216 -65
- package/src/safe-state.ts +108 -0
- package/src/scratchpad.ts +67 -28
- package/src/workflow.ts +91 -23
package/src/poc-runner.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
3
|
import {
|
|
3
4
|
copyFileSync,
|
|
4
5
|
existsSync,
|
|
6
|
+
lstatSync,
|
|
7
|
+
mkdirSync,
|
|
5
8
|
mkdtempSync,
|
|
6
9
|
readFileSync,
|
|
7
10
|
realpathSync,
|
|
@@ -11,6 +14,8 @@ import {
|
|
|
11
14
|
import { tmpdir } from "node:os";
|
|
12
15
|
import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
13
16
|
|
|
17
|
+
import { evidenceNonceMatches, type PoCEvidence, parsePoCEvidence } from "./evidence.ts";
|
|
18
|
+
import { ensureSafeStateDirectory, writeSafeFileExclusive } from "./safe-state.ts";
|
|
14
19
|
import { findWorkspaceRoot } from "./scratchpad.ts";
|
|
15
20
|
|
|
16
21
|
export type PocRun = {
|
|
@@ -40,12 +45,22 @@ export type PocRun = {
|
|
|
40
45
|
mode?: string;
|
|
41
46
|
/** Harness target used for this run. */
|
|
42
47
|
target?: string;
|
|
43
|
-
/**
|
|
44
|
-
* True when the run never started because of harness infrastructure
|
|
48
|
+
/** True when the run never started because of harness infrastructure
|
|
45
49
|
* failure (e.g. sandbox image pull failed) — set only by the runner,
|
|
46
|
-
* never derived from PoC-controlled output text.
|
|
47
|
-
*/
|
|
50
|
+
* never derived from PoC-controlled output text. */
|
|
48
51
|
infraError?: boolean;
|
|
52
|
+
/** Validated evidence.json written by the PoC to $PI_POC_EVIDENCE_DIR. */
|
|
53
|
+
evidence?: PoCEvidence;
|
|
54
|
+
/** SHA-256 of the evidence.json file (reproduction artifact). */
|
|
55
|
+
evidenceSha256?: string;
|
|
56
|
+
/** Absolute path of the PRESERVED copy of evidence.json (moved into the
|
|
57
|
+
* durable .pi/poc-evidence/ dir before the temp workspace is cleaned up) —
|
|
58
|
+
* lets the ledger's reproduction item stay artifact-backed and re-verifiable. */
|
|
59
|
+
evidencePath?: string;
|
|
60
|
+
/** Evidence contract failure (missing/invalid/nonce mismatch) — blocks the gate. */
|
|
61
|
+
evidenceError?: string;
|
|
62
|
+
/** The per-run nonce the evidence must be bound to (harness-generated). */
|
|
63
|
+
nonce?: string;
|
|
49
64
|
};
|
|
50
65
|
|
|
51
66
|
/**
|
|
@@ -71,6 +86,7 @@ export type PocRunOptions = {
|
|
|
71
86
|
|
|
72
87
|
/** Operator-only opt-in for host execution (never agent-supplied). */
|
|
73
88
|
const LOCAL_EXEC_ENV = "PI_POC_ALLOW_LOCAL";
|
|
89
|
+
const EVIDENCE_MAX_BYTES = 256 * 1024;
|
|
74
90
|
|
|
75
91
|
export type PocLanguage = {
|
|
76
92
|
/** Docker image used when running inside the sandbox. */
|
|
@@ -115,7 +131,11 @@ const OUTPUT_MAX_CHARS = 4000;
|
|
|
115
131
|
const TIMEOUT_MS = 30_000;
|
|
116
132
|
/** Completion sentinel echoed after the PoC command inside the sandbox shell. */
|
|
117
133
|
function makeSentinel(): string {
|
|
118
|
-
return `__PI_POC_DONE_${
|
|
134
|
+
return `__PI_POC_DONE_${randomBytes(16).toString("hex")}__`;
|
|
135
|
+
}
|
|
136
|
+
/** Per-run random nonce the PoC must echo in evidence.json (binds evidence to its run). */
|
|
137
|
+
function makeNonce(): string {
|
|
138
|
+
return `poc_${randomBytes(24).toString("hex")}`;
|
|
119
139
|
}
|
|
120
140
|
/** First-use image downloads are slow — pull outside the run timeout. */
|
|
121
141
|
const PULL_TIMEOUT_MS = 300_000;
|
|
@@ -286,10 +306,115 @@ function splitOutput(raw: string): { rawOutput: string; output: string; truncate
|
|
|
286
306
|
return { rawOutput: raw, output: raw.slice(0, OUTPUT_MAX_CHARS), truncated };
|
|
287
307
|
}
|
|
288
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Read + validate the PoC's evidence.json from the harness-owned evidence
|
|
311
|
+
* dir. Missing, malformed, or nonce-mismatched evidence is a contract
|
|
312
|
+
* failure, not a verdict — surfaced as evidenceError on the run.
|
|
313
|
+
*/
|
|
314
|
+
function readEvidence(
|
|
315
|
+
evidenceDir: string,
|
|
316
|
+
nonce: string,
|
|
317
|
+
): {
|
|
318
|
+
evidence?: PoCEvidence;
|
|
319
|
+
evidenceSha256?: string;
|
|
320
|
+
evidenceBytes?: Buffer;
|
|
321
|
+
evidenceError?: string;
|
|
322
|
+
} {
|
|
323
|
+
const p = join(evidenceDir, "evidence.json");
|
|
324
|
+
if (!existsSync(p)) {
|
|
325
|
+
return {
|
|
326
|
+
evidenceError: `evidence.json missing in ${evidenceDir} — the PoC must write it to $PI_POC_EVIDENCE_DIR ({"nonce", "claim", "verify", "observations"})`,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
const file = lstatSync(p);
|
|
330
|
+
if (file.isSymbolicLink() || !file.isFile()) {
|
|
331
|
+
return { evidenceError: "evidence.json must be a regular, non-symlink file" };
|
|
332
|
+
}
|
|
333
|
+
if (file.size > EVIDENCE_MAX_BYTES) {
|
|
334
|
+
return {
|
|
335
|
+
evidenceError: `evidence.json too large (${file.size} bytes; max ${EVIDENCE_MAX_BYTES})`,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
let bytes: Buffer;
|
|
339
|
+
let raw: unknown;
|
|
340
|
+
try {
|
|
341
|
+
bytes = readFileSync(p);
|
|
342
|
+
if (bytes.byteLength > EVIDENCE_MAX_BYTES) {
|
|
343
|
+
return {
|
|
344
|
+
evidenceError: `evidence.json too large (${bytes.byteLength} bytes; max ${EVIDENCE_MAX_BYTES})`,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
raw = JSON.parse(bytes.toString("utf8"));
|
|
348
|
+
} catch (e) {
|
|
349
|
+
return { evidenceError: `evidence.json unparseable: ${(e as Error).message}` };
|
|
350
|
+
}
|
|
351
|
+
const parsed = parsePoCEvidence(raw);
|
|
352
|
+
if (!parsed.ok) return { evidenceError: parsed.error };
|
|
353
|
+
if (!evidenceNonceMatches(parsed.evidence, nonce)) {
|
|
354
|
+
return {
|
|
355
|
+
evidenceError:
|
|
356
|
+
"evidence.json nonce mismatch — the file was not written by this run (copy-pasted evidence fails here)",
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
evidence: parsed.evidence,
|
|
361
|
+
evidenceSha256: createHash("sha256").update(bytes).digest("hex"),
|
|
362
|
+
evidenceBytes: bytes,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
289
366
|
function runProvenance(env?: Record<string, string>): Pick<PocRun, "mode" | "target"> {
|
|
290
367
|
return { mode: env?.PI_POC_MODE, target: env?.PI_POC_TARGET };
|
|
291
368
|
}
|
|
292
369
|
|
|
370
|
+
/**
|
|
371
|
+
* Copy the run's evidence.json into a durable harness-owned dir BEFORE the
|
|
372
|
+
* temp workspace is deleted. The ledger's reproduction item stores the SHA-256
|
|
373
|
+
* of this exact file, so the file must survive the run — otherwise the
|
|
374
|
+
* "artifact-backed" evidence item hashes a file that no longer exists.
|
|
375
|
+
* Returns the preserved path, or undefined when the file is missing.
|
|
376
|
+
*/
|
|
377
|
+
function preserveEvidence(bytes: Buffer, nonce: string): string | undefined {
|
|
378
|
+
try {
|
|
379
|
+
const projectRoot = getProjectRoot();
|
|
380
|
+
const durableDir = ensureSafeStateDirectory(projectRoot, [".pi", "poc-evidence"]);
|
|
381
|
+
const dest = join(durableDir, `${nonce}.evidence.json`);
|
|
382
|
+
writeSafeFileExclusive(dest, bytes);
|
|
383
|
+
return dest;
|
|
384
|
+
} catch {
|
|
385
|
+
// Preservation is part of the gate: readAndPreserveEvidence surfaces this
|
|
386
|
+
// as evidenceError, so confirmation cannot depend on an ephemeral file.
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function readAndPreserveEvidence(
|
|
392
|
+
evidenceDir: string,
|
|
393
|
+
nonce: string,
|
|
394
|
+
): {
|
|
395
|
+
evidence?: PoCEvidence;
|
|
396
|
+
evidenceSha256?: string;
|
|
397
|
+
evidencePath?: string;
|
|
398
|
+
evidenceError?: string;
|
|
399
|
+
} {
|
|
400
|
+
const read = readEvidence(evidenceDir, nonce);
|
|
401
|
+
if (!read.evidence || !read.evidenceSha256 || !read.evidenceBytes) {
|
|
402
|
+
return { evidenceError: read.evidenceError ?? "evidence.json validation failed" };
|
|
403
|
+
}
|
|
404
|
+
const evidencePath = preserveEvidence(read.evidenceBytes, nonce);
|
|
405
|
+
if (!evidencePath) {
|
|
406
|
+
return {
|
|
407
|
+
evidenceError:
|
|
408
|
+
"evidence.json was valid but could not be preserved in the durable evidence store",
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
return {
|
|
412
|
+
evidence: read.evidence,
|
|
413
|
+
evidenceSha256: read.evidenceSha256,
|
|
414
|
+
evidencePath,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
293
418
|
function outputWasComplete(result: { error?: Error; signal: string | null }): boolean {
|
|
294
419
|
return !result.error && result.signal === null;
|
|
295
420
|
}
|
|
@@ -422,7 +547,18 @@ function runSandboxed(
|
|
|
422
547
|
const workspaceDir = mkdtempSync(resolve(tmpdir(), "poc-runner-"));
|
|
423
548
|
// Named container so a timed-out / killed client can still be cleaned up —
|
|
424
549
|
// `--rm` alone leaks the container when the CLI dies before the child exits.
|
|
425
|
-
const containerName = `poc-runner-${process.pid}-${
|
|
550
|
+
const containerName = `poc-runner-${process.pid}-${randomBytes(8).toString("hex")}`;
|
|
551
|
+
// Evidence contract: harness-owned dir + per-run nonce. The PoC writes
|
|
552
|
+
// evidence.json into $PI_POC_EVIDENCE_DIR (/workspace/evidence inside the
|
|
553
|
+
// container); the nonce binds the file to this run.
|
|
554
|
+
const nonce = makeNonce();
|
|
555
|
+
const evidenceDir = join(workspaceDir, "evidence");
|
|
556
|
+
mkdirSync(evidenceDir, { recursive: true });
|
|
557
|
+
const runEnv = {
|
|
558
|
+
...env,
|
|
559
|
+
PI_POC_EVIDENCE_DIR: "/workspace/evidence",
|
|
560
|
+
PI_POC_NONCE: nonce,
|
|
561
|
+
};
|
|
426
562
|
|
|
427
563
|
try {
|
|
428
564
|
// Fail closed as a non-zero run (not a throw) when docker/images are
|
|
@@ -447,15 +583,17 @@ function runSandboxed(
|
|
|
447
583
|
const command = renderCommand(language.run, pocPath, true);
|
|
448
584
|
|
|
449
585
|
// Completion sentinel: wrap the command so the shell echoes a unique token
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
//
|
|
586
|
+
// ONLY when the PoC exited normally (rc < 128). A child killed by a signal
|
|
587
|
+
// (segfault, OOM under the sandbox limits) yields rc = 128+N — the sentinel
|
|
588
|
+
// is suppressed, so `completed` means "the script actually ran to
|
|
589
|
+
// completion", not merely "the container exited". Callers treat a missing
|
|
590
|
+
// sentinel as "not a verdict".
|
|
453
591
|
const sentinel = makeSentinel();
|
|
454
|
-
const wrapped = `${command}; rc=$?; echo '${sentinel}'; exit $rc`;
|
|
592
|
+
const wrapped = `${command}; rc=$?; if [ "$rc" -lt 128 ]; then echo '${sentinel}'; fi; exit $rc`;
|
|
455
593
|
|
|
456
594
|
const result = spawnSync(
|
|
457
595
|
"docker",
|
|
458
|
-
buildDockerArgs(language.image, wrapped, workspaceDir, containerName, network,
|
|
596
|
+
buildDockerArgs(language.image, wrapped, workspaceDir, containerName, network, runEnv),
|
|
459
597
|
{
|
|
460
598
|
encoding: "utf8",
|
|
461
599
|
timeout: TIMEOUT_MS,
|
|
@@ -467,6 +605,7 @@ function runSandboxed(
|
|
|
467
605
|
const raw = (result.stdout ?? "") + (result.stderr ?? "") + spawnErr;
|
|
468
606
|
const completed = raw.includes(sentinel);
|
|
469
607
|
const { rawOutput, output, truncated } = splitOutput(sanitizeOutput(raw.replace(sentinel, "")));
|
|
608
|
+
const evidence = readAndPreserveEvidence(evidenceDir, nonce);
|
|
470
609
|
return {
|
|
471
610
|
path: pocPath,
|
|
472
611
|
exitCode: spawnExitCode(result),
|
|
@@ -477,6 +616,8 @@ function runSandboxed(
|
|
|
477
616
|
sandbox: true,
|
|
478
617
|
completed,
|
|
479
618
|
outputComplete: outputWasComplete(result),
|
|
619
|
+
nonce,
|
|
620
|
+
...evidence,
|
|
480
621
|
...runProvenance(env),
|
|
481
622
|
};
|
|
482
623
|
} finally {
|
|
@@ -496,49 +637,68 @@ function runSandboxed(
|
|
|
496
637
|
|
|
497
638
|
function runLocal(pocPath: string, language: PocLanguage, env?: Record<string, string>): PocRun {
|
|
498
639
|
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),
|
|
640
|
+
// Evidence contract: harness-owned temp dir + per-run nonce, same as sandboxed.
|
|
641
|
+
const nonce = makeNonce();
|
|
642
|
+
const evidenceDir = mkdtempSync(join(tmpdir(), "poc-evidence-"));
|
|
643
|
+
const runEnv = {
|
|
644
|
+
...env,
|
|
645
|
+
PI_POC_EVIDENCE_DIR: evidenceDir,
|
|
646
|
+
PI_POC_NONCE: nonce,
|
|
541
647
|
};
|
|
648
|
+
|
|
649
|
+
try {
|
|
650
|
+
// The run template is `<interpreter> [flags...] {{file}}`. Split the static
|
|
651
|
+
// template on whitespace FIRST (builtins only, not user input), then render
|
|
652
|
+
// placeholders within each token. Passing the tokens to spawnSync with NO
|
|
653
|
+
// shell keeps a space-containing PoC path as one arg and keeps extra flags
|
|
654
|
+
// (e.g. `node --experimental-vm-modules {{file}}`) as separate args.
|
|
655
|
+
// Splitting after rendering would re-split a space-containing path.
|
|
656
|
+
const tokens = language.run.trim().split(/\s+/).filter(Boolean);
|
|
657
|
+
const interpreter = tokens.shift() ?? language.run.trim();
|
|
658
|
+
const args = tokens.map((tok) => renderCommand(tok, pocPath, false));
|
|
659
|
+
|
|
660
|
+
const result = spawnSync(interpreter, args, {
|
|
661
|
+
encoding: "utf8",
|
|
662
|
+
timeout: TIMEOUT_MS,
|
|
663
|
+
maxBuffer: MAX_BUFFER,
|
|
664
|
+
// Host runs get the harness env contract merged over the operator env;
|
|
665
|
+
// the spawn env is explicitly provided so PI_POC_MODE / PI_POC_TARGET
|
|
666
|
+
// reach the script without leaking through a shell. Same control-char
|
|
667
|
+
// rejection as the sandboxed path.
|
|
668
|
+
env: { ...process.env, ...sanitizePocEnv(runEnv) },
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
// Local runs stay shell-free (space-containing paths stay single args), so
|
|
672
|
+
// there is no sentinel echo: "completed" is derived from the spawn result.
|
|
673
|
+
// A spawn error (interpreter missing) or a signal kill (timeout, SIGKILL)
|
|
674
|
+
// means the script never ran to completion — fail closed on those.
|
|
675
|
+
const spawnErr = result.error ? `\n[spawn error] ${result.error.message}` : "";
|
|
676
|
+
const completed = !result.error && result.signal === null;
|
|
677
|
+
const { rawOutput, output, truncated } = splitOutput(
|
|
678
|
+
sanitizeOutput((result.stdout ?? "") + (result.stderr ?? "") + spawnErr),
|
|
679
|
+
);
|
|
680
|
+
const evidence = readAndPreserveEvidence(evidenceDir, nonce);
|
|
681
|
+
return {
|
|
682
|
+
path: pocPath,
|
|
683
|
+
exitCode: spawnExitCode(result),
|
|
684
|
+
output,
|
|
685
|
+
rawOutput,
|
|
686
|
+
truncated,
|
|
687
|
+
ranAt,
|
|
688
|
+
sandbox: false,
|
|
689
|
+
completed,
|
|
690
|
+
outputComplete: outputWasComplete(result),
|
|
691
|
+
nonce,
|
|
692
|
+
...evidence,
|
|
693
|
+
...runProvenance(env),
|
|
694
|
+
};
|
|
695
|
+
} finally {
|
|
696
|
+
try {
|
|
697
|
+
rmSync(evidenceDir, { recursive: true, force: true });
|
|
698
|
+
} catch {
|
|
699
|
+
// Best-effort cleanup.
|
|
700
|
+
}
|
|
701
|
+
}
|
|
542
702
|
}
|
|
543
703
|
|
|
544
704
|
/**
|
|
@@ -547,8 +707,7 @@ function runLocal(pocPath: string, language: PocLanguage, env?: Record<string, s
|
|
|
547
707
|
* Language detection (in order):
|
|
548
708
|
* 1. Shebang line in the PoC file.
|
|
549
709
|
* 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).
|
|
710
|
+
* 3. PI_POC_DEFAULT_LANGUAGE environment variable (a built-in language key).
|
|
552
711
|
*
|
|
553
712
|
* Security:
|
|
554
713
|
* - PoC paths must be absolute and under the project workspace by default.
|
|
@@ -561,20 +720,12 @@ function runLocal(pocPath: string, language: PocLanguage, env?: Record<string, s
|
|
|
561
720
|
* `PI_POC_ALLOW_LOCAL=1` and is used only when Docker is unavailable
|
|
562
721
|
* (or when `PI_POC_FORCE_LOCAL=1` is also set). Without ALLOW the run
|
|
563
722
|
* 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
723
|
*/
|
|
568
|
-
export function runPoc(pocPath: string, options?: PocRunOptions
|
|
724
|
+
export function runPoc(pocPath: string, options?: PocRunOptions): PocRun {
|
|
569
725
|
const normalized = validatePocPath(pocPath);
|
|
570
726
|
const { language } = resolveLanguage(normalized);
|
|
571
727
|
|
|
572
|
-
const opts: PocRunOptions =
|
|
573
|
-
typeof options === "boolean"
|
|
574
|
-
? options
|
|
575
|
-
? { network: "none" }
|
|
576
|
-
: { local: true }
|
|
577
|
-
: (options ?? {});
|
|
728
|
+
const opts: PocRunOptions = options ?? {};
|
|
578
729
|
|
|
579
730
|
// Host execution is gated by the OPERATOR, never by an agent-supplied flag.
|
|
580
731
|
// `local: true` means "network access needed":
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
lstatSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
realpathSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
13
|
+
|
|
14
|
+
function isWithin(root: string, candidate: string): boolean {
|
|
15
|
+
const rel = relative(root, candidate);
|
|
16
|
+
return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function validateComponent(component: string): void {
|
|
20
|
+
if (!component || component === "." || component === ".." || basename(component) !== component) {
|
|
21
|
+
throw new Error(`Unsafe state path component: ${component}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function verifyDirectory(path: string, canonicalRoot: string): void {
|
|
26
|
+
const stat = lstatSync(path);
|
|
27
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
28
|
+
throw new Error(`State directory must be a real directory, not a symlink: ${path}`);
|
|
29
|
+
}
|
|
30
|
+
const canonical = realpathSync(path);
|
|
31
|
+
if (!isWithin(canonicalRoot, canonical)) {
|
|
32
|
+
throw new Error(`State directory resolves outside its workspace: ${path}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Create and verify a state-directory chain beneath a trusted workspace root.
|
|
38
|
+
* Every child component is checked with lstat so a pre-planted symlink cannot
|
|
39
|
+
* redirect ledger, evidence, or scratchpad writes outside the workspace.
|
|
40
|
+
*/
|
|
41
|
+
export function ensureSafeStateDirectory(root: string, components: string[]): string {
|
|
42
|
+
const canonicalRoot = realpathSync(root);
|
|
43
|
+
let current = resolve(root);
|
|
44
|
+
for (const component of components) {
|
|
45
|
+
validateComponent(component);
|
|
46
|
+
current = join(current, component);
|
|
47
|
+
if (!existsSync(current)) {
|
|
48
|
+
try {
|
|
49
|
+
mkdirSync(current, { mode: 0o700 });
|
|
50
|
+
} catch (error) {
|
|
51
|
+
// A concurrent creator is acceptable only if the postcondition below
|
|
52
|
+
// proves it created a real in-workspace directory.
|
|
53
|
+
if (!existsSync(current)) throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
verifyDirectory(current, canonicalRoot);
|
|
57
|
+
}
|
|
58
|
+
return current;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Verify an existing state-directory chain without creating anything. */
|
|
62
|
+
export function assertSafeStateDirectory(root: string, components: string[]): string {
|
|
63
|
+
const canonicalRoot = realpathSync(root);
|
|
64
|
+
let current = resolve(root);
|
|
65
|
+
for (const component of components) {
|
|
66
|
+
validateComponent(component);
|
|
67
|
+
current = join(current, component);
|
|
68
|
+
if (!existsSync(current)) throw new Error(`State directory does not exist: ${current}`);
|
|
69
|
+
verifyDirectory(current, canonicalRoot);
|
|
70
|
+
}
|
|
71
|
+
return current;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Return false for a missing file; reject symlinks and special files. */
|
|
75
|
+
export function assertSafeRegularFile(path: string, label = "State file"): boolean {
|
|
76
|
+
if (!existsSync(path)) return false;
|
|
77
|
+
const stat = lstatSync(path);
|
|
78
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
79
|
+
throw new Error(`${label} must be a regular, non-symlink file: ${path}`);
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function readSafeFile(path: string, label = "State file"): Buffer {
|
|
85
|
+
if (!assertSafeRegularFile(path, label)) throw new Error(`${label} does not exist: ${path}`);
|
|
86
|
+
return readFileSync(path);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Write through an exclusive temporary file and atomically rename it into
|
|
91
|
+
* place. An existing symlink is rejected, and rename replaces the directory
|
|
92
|
+
* entry rather than following a destination changed after the check.
|
|
93
|
+
*/
|
|
94
|
+
export function writeSafeFileAtomic(path: string, data: string | Buffer): void {
|
|
95
|
+
assertSafeRegularFile(path);
|
|
96
|
+
const tmp = join(dirname(path), `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
|
|
97
|
+
try {
|
|
98
|
+
writeFileSync(tmp, data, { flag: "wx", mode: 0o600 });
|
|
99
|
+
renameSync(tmp, path);
|
|
100
|
+
} finally {
|
|
101
|
+
if (existsSync(tmp)) rmSync(tmp, { force: true });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Create a new immutable state file; collisions and symlinks fail closed. */
|
|
106
|
+
export function writeSafeFileExclusive(path: string, data: string | Buffer): void {
|
|
107
|
+
writeFileSync(path, data, { flag: "wx", mode: 0o600 });
|
|
108
|
+
}
|
package/src/scratchpad.ts
CHANGED
|
@@ -25,16 +25,15 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { createHash } from "node:crypto";
|
|
28
|
+
import { existsSync, readdirSync, rmSync } from "node:fs";
|
|
29
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
28
30
|
import {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
writeFileSync,
|
|
36
|
-
} from "node:fs";
|
|
37
|
-
import { dirname, join, resolve } from "node:path";
|
|
31
|
+
assertSafeRegularFile,
|
|
32
|
+
assertSafeStateDirectory,
|
|
33
|
+
ensureSafeStateDirectory,
|
|
34
|
+
readSafeFile,
|
|
35
|
+
writeSafeFileAtomic,
|
|
36
|
+
} from "./safe-state.ts";
|
|
38
37
|
|
|
39
38
|
// ── Types ────────────────────────────────────────────────────────────
|
|
40
39
|
|
|
@@ -127,7 +126,7 @@ export function findWorkspaceRoot(envNames: string[], markers: string[]): string
|
|
|
127
126
|
}
|
|
128
127
|
|
|
129
128
|
/** Detect the scratchpad workspace root (override, env, then walk up). */
|
|
130
|
-
function detectWorkspaceRoot(): string {
|
|
129
|
+
export function detectWorkspaceRoot(): string {
|
|
131
130
|
if (scratchpadRootOverride) return scratchpadRootOverride;
|
|
132
131
|
return findWorkspaceRoot(
|
|
133
132
|
["XPI_SCRATCHPAD_ROOT", "PI_WORKSPACE_ROOT", "GITHUB_WORKSPACE"],
|
|
@@ -168,6 +167,23 @@ function runDirName(runId: string): string {
|
|
|
168
167
|
return `${safe.slice(0, 80)}-${suffix}`;
|
|
169
168
|
}
|
|
170
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Artifact names get the same disambiguation as run dirs: sanitization is
|
|
172
|
+
* lossy ("a/b" and "a_b" both become "a_b"), so a changed name gets a content
|
|
173
|
+
* hash suffix — distinct inputs can no longer silently overwrite each other's
|
|
174
|
+
* file. Reads use the same mapping, so round-trips stay consistent.
|
|
175
|
+
*/
|
|
176
|
+
function artifactFileName(name: string): string {
|
|
177
|
+
const safe = sanitizeName(name, "artifact name");
|
|
178
|
+
if (safe === name) return safe;
|
|
179
|
+
const suffix = createHash("sha256").update(name).digest("hex").slice(0, 12);
|
|
180
|
+
return `${safe.slice(0, 80)}-${suffix}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Cap on a single scratchpad artifact (2 MiB) — a hallucinating or hostile
|
|
184
|
+
* subagent must not be able to fill the disk with unbounded writes. */
|
|
185
|
+
const MAX_ARTIFACT_BYTES = 2 * 1024 * 1024;
|
|
186
|
+
|
|
171
187
|
/** Pre-hash-suffix naming used by older scratchpad versions (sanitize only). */
|
|
172
188
|
function legacyRunDirName(runId: string): string {
|
|
173
189
|
return sanitizeName(runId, "run_id");
|
|
@@ -198,17 +214,23 @@ function emptyCheckpoint(runId: string, projectRoot: string): ScratchpadCheckpoi
|
|
|
198
214
|
}
|
|
199
215
|
|
|
200
216
|
function ensureRunDirs(runDir: string): void {
|
|
201
|
-
|
|
217
|
+
const scratchpadRoot = dirname(runDir);
|
|
218
|
+
const projectRoot = dirname(scratchpadRoot);
|
|
219
|
+
const runName = basename(runDir);
|
|
220
|
+
ensureSafeStateDirectory(projectRoot, [SCRATCHPAD_DIR, runName]);
|
|
202
221
|
for (const phase of PHASE_ORDER) {
|
|
203
|
-
|
|
204
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
222
|
+
ensureSafeStateDirectory(projectRoot, [SCRATCHPAD_DIR, runName, PHASE_DIRS[phase]]);
|
|
205
223
|
}
|
|
206
224
|
}
|
|
207
225
|
|
|
208
226
|
function readCheckpointRaw(runId: string, projectRoot?: string): ScratchpadCheckpoint | null {
|
|
227
|
+
const root = projectRoot ?? detectWorkspaceRoot();
|
|
209
228
|
const statePath = getStatePath(runId, projectRoot);
|
|
210
|
-
if (
|
|
211
|
-
|
|
229
|
+
if (existsSync(statePath)) {
|
|
230
|
+
assertSafeStateDirectory(root, [SCRATCHPAD_DIR, runDirName(runId)]);
|
|
231
|
+
}
|
|
232
|
+
if (!assertSafeRegularFile(statePath, "Scratchpad checkpoint")) return null;
|
|
233
|
+
const raw = readSafeFile(statePath, "Scratchpad checkpoint").toString("utf8");
|
|
212
234
|
const cp = JSON.parse(raw) as ScratchpadCheckpoint;
|
|
213
235
|
if (typeof cp !== "object" || cp === null || Array.isArray(cp)) {
|
|
214
236
|
throw new Error(`Corrupt scratchpad state for ${runId}: root must be an object`);
|
|
@@ -241,9 +263,7 @@ function writeCheckpointRaw(cp: ScratchpadCheckpoint, projectRoot?: string): voi
|
|
|
241
263
|
cp.last_updated = new Date().toISOString();
|
|
242
264
|
const statePath = getStatePath(cp.run_id, projectRoot);
|
|
243
265
|
ensureRunDirs(getRunDir(cp.run_id, projectRoot));
|
|
244
|
-
|
|
245
|
-
writeFileSync(tmp, JSON.stringify(cp, null, 2), "utf8");
|
|
246
|
-
renameSync(tmp, statePath);
|
|
266
|
+
writeSafeFileAtomic(statePath, JSON.stringify(cp, null, 2));
|
|
247
267
|
}
|
|
248
268
|
|
|
249
269
|
// ── Public API ───────────────────────────────────────────────────────
|
|
@@ -281,11 +301,18 @@ export function scratchpad_write(
|
|
|
281
301
|
const runDir = getRunDir(runId, root);
|
|
282
302
|
ensureRunDirs(runDir);
|
|
283
303
|
|
|
284
|
-
|
|
285
|
-
|
|
304
|
+
if (Buffer.byteLength(content, "utf8") > MAX_ARTIFACT_BYTES) {
|
|
305
|
+
throw new Error(
|
|
306
|
+
`Artifact too large (${Buffer.byteLength(content, "utf8")} bytes; max ${MAX_ARTIFACT_BYTES}): ${artifactName}`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Sanitize + disambiguate artifact name: no path traversal, no dot-only
|
|
311
|
+
// escape, and lossy sanitization cannot collide two distinct names.
|
|
312
|
+
const safeName = artifactFileName(artifactName);
|
|
286
313
|
const dir = join(runDir, PHASE_DIRS[phase]);
|
|
287
314
|
const filePath = join(dir, safeName);
|
|
288
|
-
|
|
315
|
+
writeSafeFileAtomic(filePath, content);
|
|
289
316
|
return filePath;
|
|
290
317
|
}
|
|
291
318
|
|
|
@@ -299,10 +326,13 @@ export function scratchpad_read(
|
|
|
299
326
|
projectRoot?: string,
|
|
300
327
|
): string | null {
|
|
301
328
|
const root = projectRoot ?? detectWorkspaceRoot();
|
|
302
|
-
const safeName =
|
|
329
|
+
const safeName = artifactFileName(artifactName);
|
|
303
330
|
const filePath = join(getRunDir(runId, root), PHASE_DIRS[phase], safeName);
|
|
304
|
-
if (
|
|
305
|
-
|
|
331
|
+
if (existsSync(filePath)) {
|
|
332
|
+
assertSafeStateDirectory(root, [SCRATCHPAD_DIR, runDirName(runId), PHASE_DIRS[phase]]);
|
|
333
|
+
}
|
|
334
|
+
if (!assertSafeRegularFile(filePath, "Scratchpad artifact")) return null;
|
|
335
|
+
return readSafeFile(filePath, "Scratchpad artifact").toString("utf8");
|
|
306
336
|
}
|
|
307
337
|
|
|
308
338
|
/**
|
|
@@ -311,13 +341,16 @@ export function scratchpad_read(
|
|
|
311
341
|
export function scratchpad_runs(projectRoot?: string): string[] {
|
|
312
342
|
const root = getScratchpadRoot(projectRoot);
|
|
313
343
|
if (!existsSync(root)) return [];
|
|
344
|
+
assertSafeStateDirectory(dirname(root), [SCRATCHPAD_DIR]);
|
|
314
345
|
const out: string[] = [];
|
|
315
346
|
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
316
347
|
if (!entry.isDirectory()) continue;
|
|
317
348
|
const state = join(root, entry.name, "state.json");
|
|
318
|
-
if (!
|
|
349
|
+
if (!assertSafeRegularFile(state, "Scratchpad checkpoint")) continue;
|
|
319
350
|
try {
|
|
320
|
-
const cp = JSON.parse(
|
|
351
|
+
const cp = JSON.parse(readSafeFile(state, "Scratchpad checkpoint").toString("utf8")) as {
|
|
352
|
+
run_id?: unknown;
|
|
353
|
+
};
|
|
321
354
|
if (typeof cp.run_id !== "string") continue;
|
|
322
355
|
if (getRunDir(cp.run_id, projectRoot) === join(root, entry.name)) {
|
|
323
356
|
out.push(cp.run_id);
|
|
@@ -342,7 +375,10 @@ export function scratchpad_list(
|
|
|
342
375
|
const root = projectRoot ?? detectWorkspaceRoot();
|
|
343
376
|
const dir = join(getRunDir(runId, root), PHASE_DIRS[phase]);
|
|
344
377
|
if (!existsSync(dir)) return [];
|
|
345
|
-
|
|
378
|
+
assertSafeStateDirectory(root, [SCRATCHPAD_DIR, runDirName(runId), PHASE_DIRS[phase]]);
|
|
379
|
+
return readdirSync(dir).filter(
|
|
380
|
+
(f) => f !== "state.json" && assertSafeRegularFile(join(dir, f), "Scratchpad artifact"),
|
|
381
|
+
);
|
|
346
382
|
}
|
|
347
383
|
|
|
348
384
|
/**
|
|
@@ -413,5 +449,8 @@ export function scratchpad_phase_done(
|
|
|
413
449
|
export function scratchpad_clear(runId: string, projectRoot?: string): void {
|
|
414
450
|
const root = projectRoot ?? detectWorkspaceRoot();
|
|
415
451
|
const runDir = getRunDir(runId, root);
|
|
416
|
-
if (existsSync(runDir))
|
|
452
|
+
if (existsSync(runDir)) {
|
|
453
|
+
assertSafeStateDirectory(root, [SCRATCHPAD_DIR, runDirName(runId)]);
|
|
454
|
+
rmSync(runDir, { recursive: true, force: true });
|
|
455
|
+
}
|
|
417
456
|
}
|