@xaccefy/pi-casefile 0.9.0 → 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.
@@ -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;
@@ -86,6 +95,18 @@ const VULN_CLASSES = [
86
95
  export const SPECS: Record<SubmitStage, StageSpec> = {
87
96
  // schemas/stage-finding.json
88
97
  hunt: {
98
+ allowed: [
99
+ "vuln_class",
100
+ "file",
101
+ "line",
102
+ "endpoint",
103
+ "sink",
104
+ "entry_point",
105
+ "confidence",
106
+ "evidence",
107
+ "attacker_model",
108
+ "subsystem",
109
+ ],
89
110
  required: [
90
111
  { name: "vuln_class", type: "string", enum: VULN_CLASSES },
91
112
  { name: "sink", type: "string" },
@@ -98,6 +119,15 @@ export const SPECS: Record<SubmitStage, StageSpec> = {
98
119
  },
99
120
  // schemas/stage-trace.json
100
121
  trace: {
122
+ allowed: [
123
+ "trace_result",
124
+ "entry_point",
125
+ "call_chain",
126
+ "defenses_checked",
127
+ "attacker_model",
128
+ "impact_if_reachable",
129
+ "unreachable_reason",
130
+ ],
101
131
  required: [
102
132
  { name: "trace_result", type: "string", enum: ["REACHABLE", "UNREACHABLE"] },
103
133
  { name: "entry_point", type: "string" },
@@ -112,6 +142,14 @@ export const SPECS: Record<SubmitStage, StageSpec> = {
112
142
  },
113
143
  // schemas/stage-skeptic.json
114
144
  skeptic: {
145
+ allowed: [
146
+ "finding_id",
147
+ "verdict",
148
+ "reasoning",
149
+ "evidence_reviewed",
150
+ "disconfirmation_attempt",
151
+ "disproval_reason",
152
+ ],
115
153
  required: [
116
154
  { name: "finding_id", type: "string" },
117
155
  { name: "verdict", type: "string", enum: ["CONFIRMED", "DISPROVEN"] },
@@ -131,15 +169,30 @@ export const SPECS: Record<SubmitStage, StageSpec> = {
131
169
  },
132
170
  // schemas/stage-validation.json
133
171
  validate: {
172
+ allowed: [
173
+ "finding_id",
174
+ "status",
175
+ "technique_used",
176
+ "detection_method",
177
+ "poc_path",
178
+ "run_log",
179
+ "evidence_extracted",
180
+ "kill_reason",
181
+ "refinement_attempts",
182
+ ],
134
183
  required: [
135
184
  { name: "finding_id", type: "string" },
136
- { name: "status", type: "string", enum: ["confirmed", "killed", "reported"] },
185
+ {
186
+ name: "status",
187
+ type: "string",
188
+ enum: ["pending_confirmation", "killed", "reported"],
189
+ },
137
190
  { name: "technique_used", type: "string" },
138
191
  { name: "detection_method", type: "string" },
139
192
  ],
140
193
  conditional: [
141
194
  {
142
- when: { field: "status", equals: "confirmed" },
195
+ when: { field: "status", equals: "pending_confirmation" },
143
196
  require: ["poc_path", "run_log", "evidence_extracted"],
144
197
  },
145
198
  { when: { field: "status", equals: "killed" }, require: ["kill_reason"] },
@@ -147,6 +200,7 @@ export const SPECS: Record<SubmitStage, StageSpec> = {
147
200
  },
148
201
  // schemas/stage-chain.json
149
202
  chain: {
203
+ allowed: ["chains", "summary", "tokens_input", "tokens_output"],
150
204
  required: [
151
205
  { name: "chains", type: "array" },
152
206
  { name: "summary", type: "string" },
@@ -154,6 +208,16 @@ export const SPECS: Record<SubmitStage, StageSpec> = {
154
208
  },
155
209
  // schemas/stage-report.json
156
210
  report: {
211
+ allowed: [
212
+ "target",
213
+ "pipeline_status",
214
+ "total_tokens",
215
+ "findings",
216
+ "chains",
217
+ "coverage",
218
+ "summary",
219
+ "patches_applied",
220
+ ],
157
221
  required: [
158
222
  { name: "target", type: "string" },
159
223
  { name: "pipeline_status", type: "string", enum: ["complete", "partial", "aborted"] },
@@ -212,7 +276,11 @@ function statePath(runId: string): string {
212
276
  function readState(runId: string): SubmitState {
213
277
  const p = statePath(runId);
214
278
  if (!existsSync(p)) return { repairs: {}, accepted_findings: [] };
215
- const raw = JSON.parse(readFileSync(p, "utf8")) as Partial<SubmitState>;
279
+ assertSafeStateDirectory(projectRoot(), [".scratchpad", basename(dirname(p))]);
280
+ assertSafeRegularFile(p, "Pipeline state");
281
+ const raw = JSON.parse(
282
+ readSafeFile(p, "Pipeline state").toString("utf8"),
283
+ ) as Partial<SubmitState>;
216
284
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
217
285
  throw new Error(`Corrupt pipeline-submit state for ${runId}: root must be an object`);
218
286
  }
@@ -246,9 +314,8 @@ function readState(runId: string): SubmitState {
246
314
 
247
315
  function writeState(runId: string, state: SubmitState): void {
248
316
  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);
317
+ assertSafeStateDirectory(projectRoot(), [".scratchpad", basename(dirname(p))]);
318
+ writeSafeFileAtomic(p, JSON.stringify(state, null, 2));
252
319
  }
253
320
 
254
321
  /** Project root containing the scratchpad (file-existence checks resolve here). */
@@ -411,6 +478,11 @@ function validateStage(stage: SubmitStage, obj: Record<string, unknown>): string
411
478
  const spec = SPECS[stage];
412
479
  const errors: string[] = [];
413
480
 
481
+ const allowedFields = new Set(spec.allowed);
482
+ for (const name of Object.keys(obj)) {
483
+ if (!allowedFields.has(name)) errors.push(`${name}: unknown top-level field`);
484
+ }
485
+
414
486
  for (const field of spec.required) {
415
487
  const v = obj[field.name];
416
488
  if (field.type === "string") {
@@ -495,37 +567,20 @@ function validateStage(stage: SubmitStage, obj: Record<string, unknown>): string
495
567
  if (stage === "skeptic") {
496
568
  requireStringArray(errors, "evidence_reviewed", obj.evidence_reviewed, 1);
497
569
  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");
570
+ if (!(KILL_REASON_VALUES as readonly string[]).includes(obj.disproval_reason)) {
571
+ errors.push("disproval_reason: invalid value");
572
+ }
510
573
  }
511
574
  }
512
575
 
513
576
  if (stage === "validate") {
514
577
  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");
578
+ if (!(KILL_REASON_VALUES as readonly string[]).includes(obj.kill_reason)) {
579
+ errors.push("kill_reason: invalid value");
580
+ }
526
581
  }
527
- if (obj.status === "confirmed" && isNonEmptyString(obj.poc_path)) {
528
- // A validate submission asserting "confirmed" must point at a PoC file
582
+ if (obj.status === "pending_confirmation" && isNonEmptyString(obj.poc_path)) {
583
+ // A phase-1 validation submission must point at a PoC file
529
584
  // that actually exists in the project — same file-existence filter hunt
530
585
  // findings get. Otherwise fabricated run logs pass the stage gate.
531
586
  const raw = obj.poc_path as string;
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
+ }
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
 
@@ -215,17 +214,23 @@ function emptyCheckpoint(runId: string, projectRoot: string): ScratchpadCheckpoi
215
214
  }
216
215
 
217
216
  function ensureRunDirs(runDir: string): void {
218
- 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]);
219
221
  for (const phase of PHASE_ORDER) {
220
- const dir = join(runDir, PHASE_DIRS[phase]);
221
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
222
+ ensureSafeStateDirectory(projectRoot, [SCRATCHPAD_DIR, runName, PHASE_DIRS[phase]]);
222
223
  }
223
224
  }
224
225
 
225
226
  function readCheckpointRaw(runId: string, projectRoot?: string): ScratchpadCheckpoint | null {
227
+ const root = projectRoot ?? detectWorkspaceRoot();
226
228
  const statePath = getStatePath(runId, projectRoot);
227
- if (!existsSync(statePath)) return null;
228
- 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");
229
234
  const cp = JSON.parse(raw) as ScratchpadCheckpoint;
230
235
  if (typeof cp !== "object" || cp === null || Array.isArray(cp)) {
231
236
  throw new Error(`Corrupt scratchpad state for ${runId}: root must be an object`);
@@ -258,9 +263,7 @@ function writeCheckpointRaw(cp: ScratchpadCheckpoint, projectRoot?: string): voi
258
263
  cp.last_updated = new Date().toISOString();
259
264
  const statePath = getStatePath(cp.run_id, projectRoot);
260
265
  ensureRunDirs(getRunDir(cp.run_id, projectRoot));
261
- const tmp = `${statePath}.${process.pid}.${Date.now()}.tmp`;
262
- writeFileSync(tmp, JSON.stringify(cp, null, 2), "utf8");
263
- renameSync(tmp, statePath);
266
+ writeSafeFileAtomic(statePath, JSON.stringify(cp, null, 2));
264
267
  }
265
268
 
266
269
  // ── Public API ───────────────────────────────────────────────────────
@@ -309,7 +312,7 @@ export function scratchpad_write(
309
312
  const safeName = artifactFileName(artifactName);
310
313
  const dir = join(runDir, PHASE_DIRS[phase]);
311
314
  const filePath = join(dir, safeName);
312
- writeFileSync(filePath, content, "utf8");
315
+ writeSafeFileAtomic(filePath, content);
313
316
  return filePath;
314
317
  }
315
318
 
@@ -325,8 +328,11 @@ export function scratchpad_read(
325
328
  const root = projectRoot ?? detectWorkspaceRoot();
326
329
  const safeName = artifactFileName(artifactName);
327
330
  const filePath = join(getRunDir(runId, root), PHASE_DIRS[phase], safeName);
328
- if (!existsSync(filePath)) return null;
329
- 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");
330
336
  }
331
337
 
332
338
  /**
@@ -335,13 +341,16 @@ export function scratchpad_read(
335
341
  export function scratchpad_runs(projectRoot?: string): string[] {
336
342
  const root = getScratchpadRoot(projectRoot);
337
343
  if (!existsSync(root)) return [];
344
+ assertSafeStateDirectory(dirname(root), [SCRATCHPAD_DIR]);
338
345
  const out: string[] = [];
339
346
  for (const entry of readdirSync(root, { withFileTypes: true })) {
340
347
  if (!entry.isDirectory()) continue;
341
348
  const state = join(root, entry.name, "state.json");
342
- if (!existsSync(state)) continue;
349
+ if (!assertSafeRegularFile(state, "Scratchpad checkpoint")) continue;
343
350
  try {
344
- 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
+ };
345
354
  if (typeof cp.run_id !== "string") continue;
346
355
  if (getRunDir(cp.run_id, projectRoot) === join(root, entry.name)) {
347
356
  out.push(cp.run_id);
@@ -366,7 +375,10 @@ export function scratchpad_list(
366
375
  const root = projectRoot ?? detectWorkspaceRoot();
367
376
  const dir = join(getRunDir(runId, root), PHASE_DIRS[phase]);
368
377
  if (!existsSync(dir)) return [];
369
- 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
+ );
370
382
  }
371
383
 
372
384
  /**
@@ -437,5 +449,8 @@ export function scratchpad_phase_done(
437
449
  export function scratchpad_clear(runId: string, projectRoot?: string): void {
438
450
  const root = projectRoot ?? detectWorkspaceRoot();
439
451
  const runDir = getRunDir(runId, root);
440
- 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
+ }
441
456
  }