@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.
- package/README.md +9 -6
- package/package.json +13 -4
- package/skills/casefile/SKILL.md +3 -3
- package/src/evidence.ts +501 -0
- package/src/harness-verify.ts +693 -0
- package/src/index.ts +202 -94
- package/src/ledger-worker-entry.ts +35 -0
- package/src/ledger-worker.ts +77 -0
- package/src/ledger.ts +491 -59
- package/src/pipeline-submit.ts +88 -33
- package/src/poc-runner.ts +67 -20
- package/src/safe-state.ts +108 -0
- package/src/scratchpad.ts +39 -24
- package/src/workflow.ts +25 -20
package/src/pipeline-submit.ts
CHANGED
|
@@ -18,8 +18,15 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { createHash } from "node:crypto";
|
|
21
|
-
import { existsSync,
|
|
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
|
-
{
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
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
|
-
|
|
499
|
-
"
|
|
500
|
-
|
|
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
|
-
|
|
516
|
-
"
|
|
517
|
-
|
|
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 === "
|
|
528
|
-
// A
|
|
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_${
|
|
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_${
|
|
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
|
-
): {
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
357
|
-
|
|
379
|
+
const projectRoot = getProjectRoot();
|
|
380
|
+
const durableDir = ensureSafeStateDirectory(projectRoot, [".pi", "poc-evidence"]);
|
|
358
381
|
const dest = join(durableDir, `${nonce}.evidence.json`);
|
|
359
|
-
|
|
382
|
+
writeSafeFileExclusive(dest, bytes);
|
|
360
383
|
return dest;
|
|
361
384
|
} catch {
|
|
362
|
-
//
|
|
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}-${
|
|
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
|
|
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
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
writeFileSync,
|
|
36
|
-
} from "node:fs";
|
|
37
|
-
import { dirname, join, resolve } from "node:path";
|
|
31
|
+
assertSafeRegularFile,
|
|
32
|
+
assertSafeStateDirectory,
|
|
33
|
+
ensureSafeStateDirectory,
|
|
34
|
+
readSafeFile,
|
|
35
|
+
writeSafeFileAtomic,
|
|
36
|
+
} from "./safe-state.ts";
|
|
38
37
|
|
|
39
38
|
// ── Types ────────────────────────────────────────────────────────────
|
|
40
39
|
|
|
@@ -215,17 +214,23 @@ function emptyCheckpoint(runId: string, projectRoot: string): ScratchpadCheckpoi
|
|
|
215
214
|
}
|
|
216
215
|
|
|
217
216
|
function ensureRunDirs(runDir: string): void {
|
|
218
|
-
|
|
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
|
-
|
|
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 (
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
329
|
-
|
|
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 (!
|
|
349
|
+
if (!assertSafeRegularFile(state, "Scratchpad checkpoint")) continue;
|
|
343
350
|
try {
|
|
344
|
-
const cp = JSON.parse(
|
|
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
|
-
|
|
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))
|
|
452
|
+
if (existsSync(runDir)) {
|
|
453
|
+
assertSafeStateDirectory(root, [SCRATCHPAD_DIR, runDirName(runId)]);
|
|
454
|
+
rmSync(runDir, { recursive: true, force: true });
|
|
455
|
+
}
|
|
441
456
|
}
|