@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.
@@ -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 (!file || !vulnClass) return {};
635
+ if (!vulnClass) return {};
596
636
  for (const accepted of state.accepted_findings) {
597
637
  if (accepted.vuln_class !== vulnClass) continue;
598
- if (accepted.file !== file) continue;
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.file === "string" && typeof obj.vuln_class === "string") {
676
- state.accepted_findings.push({
677
- key,
678
- file: obj.file.replace(/^\.?\//, ""),
679
- line: typeof obj.line === "number" ? obj.line : undefined,
680
- vuln_class: obj.vuln_class,
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
- JSON.stringify(obj, null, 2),
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
- // 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".
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, env),
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
- // 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),
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. 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).
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 | boolean): PocRun {
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
- // Sanitize artifact name: no path traversal, no dot-only escape.
285
- const safeName = sanitizeName(artifactName, "artifact name");
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 = sanitizeName(artifactName, "artifact name");
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");