@xaccefy/pi-casefile 0.9.0 → 0.9.2

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,8 +18,15 @@
18
18
  */
19
19
 
20
20
  import { createHash } from "node:crypto";
21
- import { existsSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
22
- import { dirname, isAbsolute, join, relative, resolve } from "node:path";
21
+ import { existsSync, realpathSync } from "node:fs";
22
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
23
+ import { KILL_REASON_VALUES } from "./ledger.ts";
24
+ import {
25
+ assertSafeRegularFile,
26
+ assertSafeStateDirectory,
27
+ readSafeFile,
28
+ writeSafeFileAtomic,
29
+ } from "./safe-state.ts";
23
30
  import { getRunDir, getScratchpadRoot, scratchpad_write } from "./scratchpad.ts";
24
31
 
25
32
  // ── Types ────────────────────────────────────────────────────────────
@@ -45,6 +52,8 @@ export type SubmitResult = {
45
52
  };
46
53
 
47
54
  type StageSpec = {
55
+ /** Exact top-level field allowlist; mirrors additionalProperties:false. */
56
+ allowed: readonly string[];
48
57
  /** Fields that must be present and non-empty. */
49
58
  required: {
50
59
  name: string;
@@ -60,34 +69,25 @@ type StageSpec = {
60
69
 
61
70
  // ── Stage specs (mirror of schemas/*.json semantics) ─────────────────
62
71
 
63
- const VULN_CLASSES = [
64
- "injection",
65
- "xss",
66
- "idor",
67
- "bola",
68
- "path-traversal",
69
- "ssrf",
70
- "command-injection",
71
- "deserialization",
72
- "auth-bypass",
73
- "privilege-escalation",
74
- "business-logic",
75
- "race-condition",
76
- "xxe",
77
- "ssti",
78
- "open-redirect",
79
- "information-disclosure",
80
- "crypto-weakness",
81
- "other",
82
- ] as const;
83
-
84
72
  // Exported for test/pipeline-submit-schema-parity.test.ts (drift guard
85
73
  // against schemas/*.json — the two are kept as mirrors of each other).
86
74
  export const SPECS: Record<SubmitStage, StageSpec> = {
87
75
  // schemas/stage-finding.json
88
76
  hunt: {
77
+ allowed: [
78
+ "vuln_class",
79
+ "file",
80
+ "line",
81
+ "endpoint",
82
+ "sink",
83
+ "entry_point",
84
+ "confidence",
85
+ "evidence",
86
+ "attacker_model",
87
+ "subsystem",
88
+ ],
89
89
  required: [
90
- { name: "vuln_class", type: "string", enum: VULN_CLASSES },
90
+ { name: "vuln_class", type: "string" },
91
91
  { name: "sink", type: "string" },
92
92
  { name: "entry_point", type: "string" },
93
93
  { name: "confidence", type: "string", enum: ["low", "medium", "high"] },
@@ -98,8 +98,18 @@ export const SPECS: Record<SubmitStage, StageSpec> = {
98
98
  },
99
99
  // schemas/stage-trace.json
100
100
  trace: {
101
+ allowed: [
102
+ "trace_result",
103
+ "entry_point",
104
+ "call_chain",
105
+ "defenses_checked",
106
+ "attacker_model",
107
+ "impact_if_reachable",
108
+ "unreachable_reason",
109
+ "uncertainty_reason",
110
+ ],
101
111
  required: [
102
- { name: "trace_result", type: "string", enum: ["REACHABLE", "UNREACHABLE"] },
112
+ { name: "trace_result", type: "string", enum: ["REACHABLE", "UNREACHABLE", "UNDETERMINED"] },
103
113
  { name: "entry_point", type: "string" },
104
114
  { name: "call_chain", type: "array", minItems: 1 },
105
115
  { name: "defenses_checked", type: "array" },
@@ -108,13 +118,23 @@ export const SPECS: Record<SubmitStage, StageSpec> = {
108
118
  conditional: [
109
119
  { when: { field: "trace_result", equals: "REACHABLE" }, require: ["impact_if_reachable"] },
110
120
  { when: { field: "trace_result", equals: "UNREACHABLE" }, require: ["unreachable_reason"] },
121
+ { when: { field: "trace_result", equals: "UNDETERMINED" }, require: ["uncertainty_reason"] },
111
122
  ],
112
123
  },
113
124
  // schemas/stage-skeptic.json
114
125
  skeptic: {
126
+ allowed: [
127
+ "finding_id",
128
+ "verdict",
129
+ "reasoning",
130
+ "evidence_reviewed",
131
+ "disconfirmation_attempt",
132
+ "disproval_reason",
133
+ "uncertainty_reason",
134
+ ],
115
135
  required: [
116
136
  { name: "finding_id", type: "string" },
117
- { name: "verdict", type: "string", enum: ["CONFIRMED", "DISPROVEN"] },
137
+ { name: "verdict", type: "string", enum: ["CONFIRMED", "DISPROVEN", "UNDETERMINED"] },
118
138
  { name: "reasoning", type: "string" },
119
139
  { name: "evidence_reviewed", type: "array", minItems: 1 },
120
140
  ],
@@ -127,19 +147,35 @@ export const SPECS: Record<SubmitStage, StageSpec> = {
127
147
  when: { field: "verdict", equals: "CONFIRMED" },
128
148
  require: ["disconfirmation_attempt"],
129
149
  },
150
+ { when: { field: "verdict", equals: "UNDETERMINED" }, require: ["uncertainty_reason"] },
130
151
  ],
131
152
  },
132
153
  // schemas/stage-validation.json
133
154
  validate: {
155
+ allowed: [
156
+ "finding_id",
157
+ "status",
158
+ "technique_used",
159
+ "detection_method",
160
+ "poc_path",
161
+ "run_log",
162
+ "evidence_extracted",
163
+ "kill_reason",
164
+ "refinement_attempts",
165
+ ],
134
166
  required: [
135
167
  { name: "finding_id", type: "string" },
136
- { name: "status", type: "string", enum: ["confirmed", "killed", "reported"] },
168
+ {
169
+ name: "status",
170
+ type: "string",
171
+ enum: ["pending_confirmation", "killed", "reported"],
172
+ },
137
173
  { name: "technique_used", type: "string" },
138
174
  { name: "detection_method", type: "string" },
139
175
  ],
140
176
  conditional: [
141
177
  {
142
- when: { field: "status", equals: "confirmed" },
178
+ when: { field: "status", equals: "pending_confirmation" },
143
179
  require: ["poc_path", "run_log", "evidence_extracted"],
144
180
  },
145
181
  { when: { field: "status", equals: "killed" }, require: ["kill_reason"] },
@@ -147,6 +183,7 @@ export const SPECS: Record<SubmitStage, StageSpec> = {
147
183
  },
148
184
  // schemas/stage-chain.json
149
185
  chain: {
186
+ allowed: ["chains", "summary", "tokens_input", "tokens_output"],
150
187
  required: [
151
188
  { name: "chains", type: "array" },
152
189
  { name: "summary", type: "string" },
@@ -154,6 +191,16 @@ export const SPECS: Record<SubmitStage, StageSpec> = {
154
191
  },
155
192
  // schemas/stage-report.json
156
193
  report: {
194
+ allowed: [
195
+ "target",
196
+ "pipeline_status",
197
+ "total_tokens",
198
+ "findings",
199
+ "chains",
200
+ "coverage",
201
+ "summary",
202
+ "patches_applied",
203
+ ],
157
204
  required: [
158
205
  { name: "target", type: "string" },
159
206
  { name: "pipeline_status", type: "string", enum: ["complete", "partial", "aborted"] },
@@ -183,7 +230,7 @@ const CHAIN_SEVERITIES = ["low", "medium", "high", "critical"] as const;
183
230
  /**
184
231
  * Test/mock/example paths carry no real findings (mirrors VVAH S5). Exception
185
232
  * from VVAH deliberately not copied: hardcoded-creds-in-test-files — the
186
- * auditor can submit those under vuln_class "other"+bugClass documentation;
233
+ * auditor can submit those under the precise class it decides fits the issue;
187
234
  * the gate errs on filtering noise.
188
235
  */
189
236
 
@@ -212,7 +259,11 @@ function statePath(runId: string): string {
212
259
  function readState(runId: string): SubmitState {
213
260
  const p = statePath(runId);
214
261
  if (!existsSync(p)) return { repairs: {}, accepted_findings: [] };
215
- const raw = JSON.parse(readFileSync(p, "utf8")) as Partial<SubmitState>;
262
+ assertSafeStateDirectory(projectRoot(), [".scratchpad", basename(dirname(p))]);
263
+ assertSafeRegularFile(p, "Pipeline state");
264
+ const raw = JSON.parse(
265
+ readSafeFile(p, "Pipeline state").toString("utf8"),
266
+ ) as Partial<SubmitState>;
216
267
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
217
268
  throw new Error(`Corrupt pipeline-submit state for ${runId}: root must be an object`);
218
269
  }
@@ -246,9 +297,8 @@ function readState(runId: string): SubmitState {
246
297
 
247
298
  function writeState(runId: string, state: SubmitState): void {
248
299
  const p = statePath(runId);
249
- const tmp = `${p}.${process.pid}.${Date.now()}.tmp`;
250
- writeFileSync(tmp, JSON.stringify(state, null, 2), "utf8");
251
- renameSync(tmp, p);
300
+ assertSafeStateDirectory(projectRoot(), [".scratchpad", basename(dirname(p))]);
301
+ writeSafeFileAtomic(p, JSON.stringify(state, null, 2));
252
302
  }
253
303
 
254
304
  /** Project root containing the scratchpad (file-existence checks resolve here). */
@@ -352,7 +402,7 @@ function validateReport(errors: string[], obj: Record<string, unknown>): void {
352
402
  if (coverage) {
353
403
  const allowed = ["COVERED", "SKIPPED", "NOT_FOUND", "INCOMPLETE"];
354
404
  for (const [key, value] of Object.entries(coverage)) {
355
- if (!/^[a-z-]+$/.test(key)) errors.push(`coverage.${key}: invalid class key`);
405
+ if (!key.trim() || /[\r\n]/.test(key)) errors.push(`coverage.${key}: invalid class key`);
356
406
  if (typeof value !== "string" || !allowed.includes(value)) {
357
407
  errors.push(`coverage.${key}: must be one of { ${allowed.join(" | ")} }`);
358
408
  }
@@ -407,10 +457,23 @@ function validateReport(errors: string[], obj: Record<string, unknown>): void {
407
457
  }
408
458
  }
409
459
 
460
+ function resolveProjectPath(input: string): { abs: string; rel: string } {
461
+ const root = projectRoot();
462
+ const trimmed = input.trim();
463
+ const relativeInput = trimmed.replace(/^\.\//, "");
464
+ const abs = isAbsolute(trimmed) ? resolve(trimmed) : resolve(root, relativeInput);
465
+ return { abs, rel: relative(root, abs) };
466
+ }
467
+
410
468
  function validateStage(stage: SubmitStage, obj: Record<string, unknown>): string[] {
411
469
  const spec = SPECS[stage];
412
470
  const errors: string[] = [];
413
471
 
472
+ const allowedFields = new Set(spec.allowed);
473
+ for (const name of Object.keys(obj)) {
474
+ if (!allowedFields.has(name)) errors.push(`${name}: unknown top-level field`);
475
+ }
476
+
414
477
  for (const field of spec.required) {
415
478
  const v = obj[field.name];
416
479
  if (field.type === "string") {
@@ -495,43 +558,24 @@ function validateStage(stage: SubmitStage, obj: Record<string, unknown>): string
495
558
  if (stage === "skeptic") {
496
559
  requireStringArray(errors, "evidence_reviewed", obj.evidence_reviewed, 1);
497
560
  if (obj.verdict === "DISPROVEN" && isNonEmptyString(obj.disproval_reason)) {
498
- const allowed = [
499
- "unreachable",
500
- "framework_protection",
501
- "input_validation_blocks",
502
- "requires_privilege_attacker_lacks",
503
- "intended_behavior",
504
- "overstated_impact",
505
- "duplicate",
506
- "test_artifact",
507
- "out_of_scope",
508
- ];
509
- if (!allowed.includes(obj.disproval_reason)) errors.push("disproval_reason: invalid value");
561
+ if (!(KILL_REASON_VALUES as readonly string[]).includes(obj.disproval_reason)) {
562
+ errors.push("disproval_reason: invalid value");
563
+ }
510
564
  }
511
565
  }
512
566
 
513
567
  if (stage === "validate") {
514
568
  if (obj.status === "killed" && isNonEmptyString(obj.kill_reason)) {
515
- const allowed = [
516
- "unreachable",
517
- "framework_protection",
518
- "input_validation_blocks",
519
- "requires_privilege_attacker_lacks",
520
- "poc_failed_3x",
521
- "no_real_impact",
522
- "intended_behavior",
523
- "duplicate",
524
- ];
525
- if (!allowed.includes(obj.kill_reason)) errors.push("kill_reason: invalid value");
569
+ if (!(KILL_REASON_VALUES as readonly string[]).includes(obj.kill_reason)) {
570
+ errors.push("kill_reason: invalid value");
571
+ }
526
572
  }
527
- if (obj.status === "confirmed" && isNonEmptyString(obj.poc_path)) {
528
- // A validate submission asserting "confirmed" must point at a PoC file
573
+ if (obj.status === "pending_confirmation" && isNonEmptyString(obj.poc_path)) {
574
+ // A phase-1 validation submission must point at a PoC file
529
575
  // that actually exists in the project — same file-existence filter hunt
530
576
  // findings get. Otherwise fabricated run logs pass the stage gate.
531
577
  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);
578
+ const { abs, rel } = resolveProjectPath(raw);
535
579
  if (rel.startsWith("..") || isAbsolute(rel)) {
536
580
  errors.push("poc_path: must resolve inside the project root");
537
581
  } else if (!existsSync(abs)) {
@@ -581,26 +625,24 @@ function validateStage(stage: SubmitStage, obj: Record<string, unknown>): string
581
625
  function prefilterHunt(obj: Record<string, unknown>): string | null {
582
626
  const file = typeof obj.file === "string" ? obj.file : undefined;
583
627
  if (!file) return null; // live target: endpoint locator, nothing to filter
584
- const normalized = file.replace(/^\.?\//, "");
585
- const segments = normalized.split("/");
586
- if (segments.some((s) => TEST_SEGMENT_RE.test(s)) || TEST_FILE_RE.test(normalized)) {
587
- return (
588
- `test-path filter: "${file}" matches test/fixture/mock paths — findings in ` +
589
- `test code are noise. If this is a deliberately-shipped test credential, ` +
590
- `re-submit documenting why it ships to production.`
591
- );
592
- }
593
628
  const root = projectRoot();
594
- const abs = isAbsolute(normalized) ? resolve(normalized) : resolve(root, normalized);
629
+ const { abs, rel } = resolveProjectPath(file);
595
630
  // Containment: resolved path must stay inside the project, otherwise a
596
631
  // "finding" can point at ../ or absolute files outside the target repo.
597
- const rel = relative(root, abs);
598
632
  if (rel.startsWith("..") || isAbsolute(rel)) {
599
633
  return (
600
634
  `containment filter: "${file}" resolves outside the project root (${root}). ` +
601
635
  `Findings must reference files inside the target repository.`
602
636
  );
603
637
  }
638
+ const segments = rel.split("/");
639
+ if (segments.some((s) => TEST_SEGMENT_RE.test(s)) || TEST_FILE_RE.test(rel)) {
640
+ return (
641
+ `test-path filter: "${file}" matches test/fixture/mock paths — findings in ` +
642
+ `test code are noise. If this is a deliberately-shipped test credential, ` +
643
+ `re-submit documenting why it ships to production.`
644
+ );
645
+ }
604
646
  // Symlink containment (same defense as the PoC runner): resolve() is
605
647
  // lexical, and existsSync() dereferences symlinks — a workspace symlink to
606
648
  // /etc (ln -s /etc etc-link) would otherwise pass both checks and let a
@@ -628,7 +670,10 @@ function prefilterHunt(obj: Record<string, unknown>): string | null {
628
670
  }
629
671
 
630
672
  function dedupHunt(state: SubmitState, obj: Record<string, unknown>): { duplicateOf?: string } {
631
- const file = typeof obj.file === "string" ? obj.file.replace(/^\.?\//, "") : undefined;
673
+ const file =
674
+ typeof obj.file === "string"
675
+ ? resolveProjectPath(obj.file).rel.replace(/^\.\//, "")
676
+ : undefined;
632
677
  const endpoint = typeof obj.endpoint === "string" ? obj.endpoint.trim() : undefined;
633
678
  const vulnClass = typeof obj.vuln_class === "string" ? obj.vuln_class : undefined;
634
679
  const line = typeof obj.line === "number" ? obj.line : undefined;
@@ -724,7 +769,9 @@ export function pipeline_submit(runId: string, stage: SubmitStage, output: unkno
724
769
  if (isFileFinding || isEndpointFinding) {
725
770
  state.accepted_findings.push({
726
771
  key,
727
- file: isFileFinding ? (obj.file as string).replace(/^\.?\//, "") : "",
772
+ file: isFileFinding
773
+ ? resolveProjectPath(obj.file as string).rel.replace(/^\.\//, "")
774
+ : "",
728
775
  line: typeof obj.line === "number" ? obj.line : undefined,
729
776
  endpoint: isEndpointFinding ? (obj.endpoint as string).trim() : undefined,
730
777
  vuln_class: obj.vuln_class,
package/src/poc-runner.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { createHash } from "node:crypto";
2
+ import { createHash, randomBytes } from "node:crypto";
3
3
  import {
4
4
  copyFileSync,
5
5
  existsSync,
6
+ lstatSync,
6
7
  mkdirSync,
7
8
  mkdtempSync,
8
9
  readFileSync,
@@ -14,6 +15,7 @@ import { tmpdir } from "node:os";
14
15
  import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
15
16
 
16
17
  import { evidenceNonceMatches, type PoCEvidence, parsePoCEvidence } from "./evidence.ts";
18
+ import { ensureSafeStateDirectory, writeSafeFileExclusive } from "./safe-state.ts";
17
19
  import { findWorkspaceRoot } from "./scratchpad.ts";
18
20
 
19
21
  export type PocRun = {
@@ -84,6 +86,7 @@ export type PocRunOptions = {
84
86
 
85
87
  /** Operator-only opt-in for host execution (never agent-supplied). */
86
88
  const LOCAL_EXEC_ENV = "PI_POC_ALLOW_LOCAL";
89
+ const EVIDENCE_MAX_BYTES = 256 * 1024;
87
90
 
88
91
  export type PocLanguage = {
89
92
  /** Docker image used when running inside the sandbox. */
@@ -128,11 +131,11 @@ const OUTPUT_MAX_CHARS = 4000;
128
131
  const TIMEOUT_MS = 30_000;
129
132
  /** Completion sentinel echoed after the PoC command inside the sandbox shell. */
130
133
  function makeSentinel(): string {
131
- return `__PI_POC_DONE_${Math.random().toString(36).slice(2, 12)}__`;
134
+ return `__PI_POC_DONE_${randomBytes(16).toString("hex")}__`;
132
135
  }
133
136
  /** Per-run random nonce the PoC must echo in evidence.json (binds evidence to its run). */
134
137
  function makeNonce(): string {
135
- return `poc_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
138
+ return `poc_${randomBytes(24).toString("hex")}`;
136
139
  }
137
140
  /** First-use image downloads are slow — pull outside the run timeout. */
138
141
  const PULL_TIMEOUT_MS = 300_000;
@@ -311,16 +314,37 @@ function splitOutput(raw: string): { rawOutput: string; output: string; truncate
311
314
  function readEvidence(
312
315
  evidenceDir: string,
313
316
  nonce: string,
314
- ): { evidence?: PoCEvidence; evidenceSha256?: string; evidenceError?: string } {
317
+ ): {
318
+ evidence?: PoCEvidence;
319
+ evidenceSha256?: string;
320
+ evidenceBytes?: Buffer;
321
+ evidenceError?: string;
322
+ } {
315
323
  const p = join(evidenceDir, "evidence.json");
316
324
  if (!existsSync(p)) {
317
325
  return {
318
326
  evidenceError: `evidence.json missing in ${evidenceDir} — the PoC must write it to $PI_POC_EVIDENCE_DIR ({"nonce", "claim", "verify", "observations"})`,
319
327
  };
320
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;
321
339
  let raw: unknown;
322
340
  try {
323
- raw = JSON.parse(readFileSync(p, "utf8"));
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"));
324
348
  } catch (e) {
325
349
  return { evidenceError: `evidence.json unparseable: ${(e as Error).message}` };
326
350
  }
@@ -334,7 +358,8 @@ function readEvidence(
334
358
  }
335
359
  return {
336
360
  evidence: parsed.evidence,
337
- evidenceSha256: createHash("sha256").update(readFileSync(p)).digest("hex"),
361
+ evidenceSha256: createHash("sha256").update(bytes).digest("hex"),
362
+ evidenceBytes: bytes,
338
363
  };
339
364
  }
340
365
 
@@ -349,21 +374,47 @@ function runProvenance(env?: Record<string, string>): Pick<PocRun, "mode" | "tar
349
374
  * "artifact-backed" evidence item hashes a file that no longer exists.
350
375
  * Returns the preserved path, or undefined when the file is missing.
351
376
  */
352
- function preserveEvidence(evidenceDir: string, nonce: string): string | undefined {
353
- const source = join(evidenceDir, "evidence.json");
354
- if (!existsSync(source)) return undefined;
377
+ function preserveEvidence(bytes: Buffer, nonce: string): string | undefined {
355
378
  try {
356
- const durableDir = join(getProjectRoot(), ".pi", "poc-evidence");
357
- mkdirSync(durableDir, { recursive: true });
379
+ const projectRoot = getProjectRoot();
380
+ const durableDir = ensureSafeStateDirectory(projectRoot, [".pi", "poc-evidence"]);
358
381
  const dest = join(durableDir, `${nonce}.evidence.json`);
359
- copyFileSync(source, dest);
382
+ writeSafeFileExclusive(dest, bytes);
360
383
  return dest;
361
384
  } catch {
362
- // Best-effort: a preserved copy is an audit-trail improvement, not a gate.
385
+ // Preservation is part of the gate: readAndPreserveEvidence surfaces this
386
+ // as evidenceError, so confirmation cannot depend on an ephemeral file.
363
387
  return undefined;
364
388
  }
365
389
  }
366
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
+
367
418
  function outputWasComplete(result: { error?: Error; signal: string | null }): boolean {
368
419
  return !result.error && result.signal === null;
369
420
  }
@@ -496,7 +547,7 @@ function runSandboxed(
496
547
  const workspaceDir = mkdtempSync(resolve(tmpdir(), "poc-runner-"));
497
548
  // Named container so a timed-out / killed client can still be cleaned up —
498
549
  // `--rm` alone leaks the container when the CLI dies before the child exits.
499
- const containerName = `poc-runner-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
550
+ const containerName = `poc-runner-${process.pid}-${randomBytes(8).toString("hex")}`;
500
551
  // Evidence contract: harness-owned dir + per-run nonce. The PoC writes
501
552
  // evidence.json into $PI_POC_EVIDENCE_DIR (/workspace/evidence inside the
502
553
  // container); the nonce binds the file to this run.
@@ -554,8 +605,7 @@ function runSandboxed(
554
605
  const raw = (result.stdout ?? "") + (result.stderr ?? "") + spawnErr;
555
606
  const completed = raw.includes(sentinel);
556
607
  const { rawOutput, output, truncated } = splitOutput(sanitizeOutput(raw.replace(sentinel, "")));
557
- const preserved = preserveEvidence(evidenceDir, nonce);
558
- const evidence = readEvidence(evidenceDir, nonce);
608
+ const evidence = readAndPreserveEvidence(evidenceDir, nonce);
559
609
  return {
560
610
  path: pocPath,
561
611
  exitCode: spawnExitCode(result),
@@ -568,7 +618,6 @@ function runSandboxed(
568
618
  outputComplete: outputWasComplete(result),
569
619
  nonce,
570
620
  ...evidence,
571
- evidencePath: evidence.evidence ? preserved : undefined,
572
621
  ...runProvenance(env),
573
622
  };
574
623
  } finally {
@@ -628,8 +677,7 @@ function runLocal(pocPath: string, language: PocLanguage, env?: Record<string, s
628
677
  const { rawOutput, output, truncated } = splitOutput(
629
678
  sanitizeOutput((result.stdout ?? "") + (result.stderr ?? "") + spawnErr),
630
679
  );
631
- const preserved = preserveEvidence(evidenceDir, nonce);
632
- const evidence = readEvidence(evidenceDir, nonce);
680
+ const evidence = readAndPreserveEvidence(evidenceDir, nonce);
633
681
  return {
634
682
  path: pocPath,
635
683
  exitCode: spawnExitCode(result),
@@ -642,7 +690,6 @@ function runLocal(pocPath: string, language: PocLanguage, env?: Record<string, s
642
690
  outputComplete: outputWasComplete(result),
643
691
  nonce,
644
692
  ...evidence,
645
- evidencePath: evidence.evidence ? preserved : undefined,
646
693
  ...runProvenance(env),
647
694
  };
648
695
  } finally {
@@ -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
+ }