@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/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_${Math.random().toString(36).slice(2, 12)}__`;
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}-${Math.random().toString(36).slice(2, 10)}`;
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
- // AFTER the PoC exits, preserving its exit code. If the container is
451
- // killed, times out, or the run never starts, the sentinel is absent
452
- // callers can then tell "script ran and failed" from "script crashed".
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, env),
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
- // The run template is `<interpreter> [flags...] {{file}}`. Split the static
501
- // template on whitespace FIRST (builtins only, not user input), then render
502
- // placeholders within each token. Passing the tokens to spawnSync with NO
503
- // shell keeps a space-containing PoC path as one arg and keeps extra flags
504
- // (e.g. `node --experimental-vm-modules {{file}}`) as separate args.
505
- // Splitting after rendering would re-split a space-containing path.
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. Project type markers in the workspace root (e.g., package.json, requirements.txt).
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 | boolean): PocRun {
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
- existsSync,
30
- mkdirSync,
31
- readdirSync,
32
- readFileSync,
33
- renameSync,
34
- rmSync,
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
- if (!existsSync(runDir)) mkdirSync(runDir, { recursive: true });
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
- const dir = join(runDir, PHASE_DIRS[phase]);
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 (!existsSync(statePath)) return null;
211
- const raw = readFileSync(statePath, "utf8");
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
- const tmp = `${statePath}.${process.pid}.${Date.now()}.tmp`;
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
- // Sanitize artifact name: no path traversal, no dot-only escape.
285
- const safeName = sanitizeName(artifactName, "artifact name");
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
- writeFileSync(filePath, content, "utf8");
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 = sanitizeName(artifactName, "artifact name");
329
+ const safeName = artifactFileName(artifactName);
303
330
  const filePath = join(getRunDir(runId, root), PHASE_DIRS[phase], safeName);
304
- if (!existsSync(filePath)) return null;
305
- return readFileSync(filePath, "utf8");
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 (!existsSync(state)) continue;
349
+ if (!assertSafeRegularFile(state, "Scratchpad checkpoint")) continue;
319
350
  try {
320
- const cp = JSON.parse(readFileSync(state, "utf8")) as { run_id?: unknown };
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
- return readdirSync(dir).filter((f) => f !== "state.json");
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)) rmSync(runDir, { recursive: true, force: true });
452
+ if (existsSync(runDir)) {
453
+ assertSafeStateDirectory(root, [SCRATCHPAD_DIR, runDirName(runId)]);
454
+ rmSync(runDir, { recursive: true, force: true });
455
+ }
417
456
  }