@xaccefy/pi-casefile 0.9.4 → 0.10.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 +32 -67
- package/package.json +13 -16
- package/src/confirmation.ts +951 -0
- package/src/evidence.ts +131 -4
- package/src/harness-verify.ts +19 -42
- package/src/index.ts +362 -701
- package/src/ledger-internal.ts +435 -0
- package/src/ledger.ts +519 -1255
- package/src/oob-oracle.ts +279 -0
- package/src/poc-runner.ts +51 -12
- package/src/scratchpad.ts +92 -148
- package/src/workflow.ts +48 -325
- package/skills/casefile/SKILL.md +0 -44
- package/src/ledger-worker-entry.ts +0 -35
- package/src/ledger-worker.ts +0 -77
- package/src/pipeline-submit.ts +0 -797
package/src/scratchpad.ts
CHANGED
|
@@ -1,27 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Scratchpad — intermediate
|
|
2
|
+
* Scratchpad — intermediate working-notes store for a run.
|
|
3
3
|
*
|
|
4
4
|
* The casefile owns state transitions; the scratchpad owns artifacts.
|
|
5
|
-
*
|
|
6
|
-
* logs) instead of stuffing everything into casefile text fields
|
|
7
|
-
* on each other's output streams (which creates an echo chamber).
|
|
5
|
+
* The agent writes its outputs here (recon maps, trace outputs, verification
|
|
6
|
+
* logs) instead of stuffing everything into casefile text fields.
|
|
8
7
|
*
|
|
9
|
-
* Directory layout per
|
|
8
|
+
* Directory layout per run (one subdir per phase — see PHASE_DIRS):
|
|
10
9
|
* {project_root}/.scratchpad/{run_id}/
|
|
11
10
|
* recon/ — fingerprints, tech detection, surface maps
|
|
12
11
|
* hunt/ — per-class findings
|
|
13
|
-
* gapfil/ — gap-fill audit notes
|
|
12
|
+
* gapfil/ — gap-fill audit notes (legacy)
|
|
14
13
|
* trace/ — per-finding reachability traces
|
|
15
14
|
* skeptic/ — adversarial disproof attempts
|
|
16
15
|
* verify/ — PoC logs, run outputs (validate phase)
|
|
17
16
|
* chain/ — exploit-chain analysis
|
|
18
17
|
* patch/ — remediation work
|
|
19
18
|
* report/ — final report context
|
|
20
|
-
* state.json — checkpoint file
|
|
19
|
+
* state.json — legacy checkpoint file (read for context gating; the
|
|
20
|
+
* write-side checkpoint API was removed with the pipeline)
|
|
21
21
|
*
|
|
22
22
|
* Resume re-reads scratchpad artifacts; it does not re-run completed phases
|
|
23
23
|
* (idempotent). The `.scratchpad/` directory is preserved between runs;
|
|
24
|
-
*
|
|
24
|
+
* `scratchpad_clear()` clears it for a single run.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { createHash } from "node:crypto";
|
|
@@ -65,16 +65,13 @@ export interface ScratchpadCheckpoint {
|
|
|
65
65
|
|
|
66
66
|
export interface ScratchpadResume {
|
|
67
67
|
checkpoint: ScratchpadCheckpoint;
|
|
68
|
-
/** The next phase to run (or null if the run is done). */
|
|
69
|
-
next_phase: ScratchpadPhase | null;
|
|
70
68
|
/** Artifact references per phase: { trace: ["finding-abc.json", ...], ... } */
|
|
71
69
|
artifacts: Record<string, string[]>;
|
|
72
70
|
}
|
|
73
71
|
|
|
74
72
|
// ── Constants ────────────────────────────────────────────────────────
|
|
75
73
|
|
|
76
|
-
// All accepted artifact buckets. Some are legacy/manual-only
|
|
77
|
-
// scheduled by ScratchpadResume for new swarm runs.
|
|
74
|
+
// All accepted artifact buckets. Some are legacy/manual-only.
|
|
78
75
|
export const SCRATCHPAD_PHASES: ScratchpadPhase[] = [
|
|
79
76
|
"recon",
|
|
80
77
|
"hunt",
|
|
@@ -87,17 +84,6 @@ export const SCRATCHPAD_PHASES: ScratchpadPhase[] = [
|
|
|
87
84
|
"report",
|
|
88
85
|
];
|
|
89
86
|
|
|
90
|
-
// Active pipeline order for new/resumed runs.
|
|
91
|
-
export const PHASE_ORDER: ScratchpadPhase[] = [
|
|
92
|
-
"recon",
|
|
93
|
-
"hunt",
|
|
94
|
-
"trace",
|
|
95
|
-
"skeptic",
|
|
96
|
-
"validate",
|
|
97
|
-
"chain",
|
|
98
|
-
"report",
|
|
99
|
-
];
|
|
100
|
-
|
|
101
87
|
const PHASE_DIRS: Record<ScratchpadPhase, string> = {
|
|
102
88
|
recon: "recon",
|
|
103
89
|
hunt: "hunt",
|
|
@@ -173,59 +159,41 @@ function sanitizeName(name: string, label: string): string {
|
|
|
173
159
|
return safe;
|
|
174
160
|
}
|
|
175
161
|
|
|
176
|
-
function runDirName(runId: string): string {
|
|
177
|
-
const safe = sanitizeName(runId, "run_id");
|
|
178
|
-
if (safe === runId) return safe;
|
|
179
|
-
const suffix = createHash("sha256").update(runId).digest("hex").slice(0, 12);
|
|
180
|
-
return `${safe.slice(0, 80)}-${suffix}`;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
162
|
/**
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
163
|
+
* Sanitized name + disambiguation hash: sanitization is lossy ("a/b" and
|
|
164
|
+
* "a_b" both become "a_b"), so a CHANGED name gets a content hash suffix —
|
|
165
|
+
* distinct inputs can no longer silently overwrite each other's file.
|
|
166
|
+
* Reads use the same mapping, so round-trips stay consistent.
|
|
188
167
|
*/
|
|
189
|
-
function
|
|
190
|
-
const safe = sanitizeName(name,
|
|
168
|
+
function sanitizeWithHashSuffix(name: string, label: string): string {
|
|
169
|
+
const safe = sanitizeName(name, label);
|
|
191
170
|
if (safe === name) return safe;
|
|
192
171
|
const suffix = createHash("sha256").update(name).digest("hex").slice(0, 12);
|
|
193
172
|
return `${safe.slice(0, 80)}-${suffix}`;
|
|
194
173
|
}
|
|
195
174
|
|
|
175
|
+
function runDirName(runId: string): string {
|
|
176
|
+
return sanitizeWithHashSuffix(runId, "run_id");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function artifactFileName(name: string): string {
|
|
180
|
+
return sanitizeWithHashSuffix(name, "artifact name");
|
|
181
|
+
}
|
|
182
|
+
|
|
196
183
|
/** Cap on a single scratchpad artifact (2 MiB) — a hallucinating or hostile
|
|
197
184
|
* subagent must not be able to fill the disk with unbounded writes. */
|
|
198
185
|
const MAX_ARTIFACT_BYTES = 2 * 1024 * 1024;
|
|
199
186
|
|
|
200
|
-
/** Pre-hash-suffix naming used by older scratchpad versions (sanitize only). */
|
|
201
|
-
function legacyRunDirName(runId: string): string {
|
|
202
|
-
return sanitizeName(runId, "run_id");
|
|
203
|
-
}
|
|
204
|
-
|
|
205
187
|
/** The directory for a specific run. */
|
|
206
|
-
|
|
188
|
+
function getRunDir(runId: string, projectRoot?: string): string {
|
|
207
189
|
return join(getScratchpadRoot(projectRoot), runDirName(runId));
|
|
208
190
|
}
|
|
209
191
|
|
|
210
192
|
/** The state.json path for a run. */
|
|
211
|
-
|
|
193
|
+
function getStatePath(runId: string, projectRoot?: string): string {
|
|
212
194
|
return join(getRunDir(runId, projectRoot), "state.json");
|
|
213
195
|
}
|
|
214
196
|
|
|
215
|
-
function emptyCheckpoint(runId: string, projectRoot: string): ScratchpadCheckpoint {
|
|
216
|
-
const now = new Date().toISOString();
|
|
217
|
-
return {
|
|
218
|
-
run_id: runId,
|
|
219
|
-
project_root: projectRoot,
|
|
220
|
-
created_at: now,
|
|
221
|
-
last_updated: now,
|
|
222
|
-
last_phase_at: null,
|
|
223
|
-
completed_phases: [],
|
|
224
|
-
phase_ids: {} as Record<ScratchpadPhase, string[]>,
|
|
225
|
-
phase_summaries: {} as Record<ScratchpadPhase, string>,
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
|
|
229
197
|
function ensureRunDirs(runDir: string): void {
|
|
230
198
|
const scratchpadRoot = dirname(runDir);
|
|
231
199
|
const projectRoot = dirname(scratchpadRoot);
|
|
@@ -272,33 +240,8 @@ function readCheckpointRaw(runId: string, projectRoot?: string): ScratchpadCheck
|
|
|
272
240
|
return cp;
|
|
273
241
|
}
|
|
274
242
|
|
|
275
|
-
function writeCheckpointRaw(cp: ScratchpadCheckpoint, projectRoot?: string): void {
|
|
276
|
-
cp.last_updated = new Date().toISOString();
|
|
277
|
-
const statePath = getStatePath(cp.run_id, projectRoot);
|
|
278
|
-
ensureRunDirs(getRunDir(cp.run_id, projectRoot));
|
|
279
|
-
writeSafeFileAtomic(statePath, JSON.stringify(cp, null, 2));
|
|
280
|
-
}
|
|
281
|
-
|
|
282
243
|
// ── Public API ───────────────────────────────────────────────────────
|
|
283
244
|
|
|
284
|
-
/**
|
|
285
|
-
* Initialize a new scratchpad run. Creates the directory structure and writes
|
|
286
|
-
* an initial state.json. If the run already exists, returns the existing
|
|
287
|
-
* checkpoint (idempotent — safe to call on resume without --fresh).
|
|
288
|
-
*/
|
|
289
|
-
export function scratchpad_init(runId: string, projectRoot?: string): ScratchpadCheckpoint {
|
|
290
|
-
const root = projectRoot ?? detectWorkspaceRoot();
|
|
291
|
-
const runDir = getRunDir(runId, root);
|
|
292
|
-
ensureRunDirs(runDir);
|
|
293
|
-
|
|
294
|
-
const existing = readCheckpointRaw(runId, root);
|
|
295
|
-
if (existing) return existing;
|
|
296
|
-
|
|
297
|
-
const cp = emptyCheckpoint(runId, root);
|
|
298
|
-
writeCheckpointRaw(cp, root);
|
|
299
|
-
return cp;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
245
|
/**
|
|
303
246
|
* Write an artifact to a phase's subdirectory. Overwrites if the name exists.
|
|
304
247
|
* Returns the full path to the written artifact.
|
|
@@ -349,37 +292,80 @@ export function scratchpad_read(
|
|
|
349
292
|
}
|
|
350
293
|
|
|
351
294
|
/**
|
|
352
|
-
*
|
|
295
|
+
* A run directory discovered by direct scan — no state.json required. The slim
|
|
296
|
+
* extension surface (Write/Read/Clear only) never checkpoints, so discovery
|
|
297
|
+
* must not depend on state.json existing.
|
|
353
298
|
*/
|
|
354
|
-
export
|
|
299
|
+
export type DiscoveredScratchpadRun = {
|
|
300
|
+
/** Directory name under .scratchpad (the sanitized run id). */
|
|
301
|
+
dir: string;
|
|
302
|
+
/** Artifact file names per phase bucket, non-empty buckets only. */
|
|
303
|
+
phases: Record<string, string[]>;
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Scan the scratchpad root for run directories carrying artifacts. Unlike
|
|
308
|
+
* scratchpad_runs(), this lists runs WITHOUT a state.json checkpoint — the
|
|
309
|
+
* only writer of state.json (ScratchpadInit/Checkpoint) is no longer exposed
|
|
310
|
+
* as a tool, so directory scan is the primary discovery path.
|
|
311
|
+
*/
|
|
312
|
+
export function scratchpad_discover_artifacts(projectRoot?: string): DiscoveredScratchpadRun[] {
|
|
355
313
|
const root = getScratchpadRoot(projectRoot);
|
|
356
314
|
if (!existsSync(root)) return [];
|
|
357
315
|
assertSafeStateDirectory(dirname(root), [SCRATCHPAD_DIR]);
|
|
358
|
-
const out:
|
|
316
|
+
const out: DiscoveredScratchpadRun[] = [];
|
|
359
317
|
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
318
|
+
// Dirent.isDirectory() is lstat-based: a symlinked run dir reports as a
|
|
319
|
+
// symlink and is skipped here.
|
|
360
320
|
if (!entry.isDirectory()) continue;
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
} else if (join(root, legacyRunDirName(cp.run_id)) === join(root, entry.name)) {
|
|
371
|
-
// Runs created before the hash-suffix naming used sanitizeName(runId)
|
|
372
|
-
// as the directory; keep surfacing them in bundle discovery. Safe ids
|
|
373
|
-
// were never suffixed, so only legacy unsafe ids can land here.
|
|
374
|
-
out.push(cp.run_id);
|
|
321
|
+
const dir = entry.name;
|
|
322
|
+
const phases: Record<string, string[]> = {};
|
|
323
|
+
for (const phase of SCRATCHPAD_PHASES) {
|
|
324
|
+
const phaseDir = join(root, dir, PHASE_DIRS[phase]);
|
|
325
|
+
if (!existsSync(phaseDir)) continue;
|
|
326
|
+
try {
|
|
327
|
+
assertSafeStateDirectory(dirname(root), [SCRATCHPAD_DIR, dir, PHASE_DIRS[phase]]);
|
|
328
|
+
} catch {
|
|
329
|
+
continue;
|
|
375
330
|
}
|
|
376
|
-
|
|
377
|
-
|
|
331
|
+
const names = readdirSync(phaseDir).filter(
|
|
332
|
+
(f) =>
|
|
333
|
+
f !== "state.json" && assertSafeRegularFile(join(phaseDir, f), "Scratchpad artifact"),
|
|
334
|
+
);
|
|
335
|
+
if (names.length > 0) phases[phase] = names;
|
|
378
336
|
}
|
|
337
|
+
if (Object.keys(phases).length > 0) out.push({ dir, phases });
|
|
379
338
|
}
|
|
380
339
|
return out;
|
|
381
340
|
}
|
|
382
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Read an artifact from a DISCOVERED run directory (see
|
|
344
|
+
* scratchpad_discover_artifacts) by phase bucket key. Same safety checks as
|
|
345
|
+
* scratchpad_read, but addressed by directory name because the original run id
|
|
346
|
+
* is unrecoverable without a checkpoint.
|
|
347
|
+
*/
|
|
348
|
+
export function scratchpad_read_discovered(
|
|
349
|
+
dir: string,
|
|
350
|
+
phase: ScratchpadPhase,
|
|
351
|
+
artifactName: string,
|
|
352
|
+
projectRoot?: string,
|
|
353
|
+
): string | null {
|
|
354
|
+
const phaseDir = PHASE_DIRS[phase];
|
|
355
|
+
if (!phaseDir) return null;
|
|
356
|
+
if (!dir || dir === "." || dir === ".." || dir.includes("/") || dir.includes("\\")) return null;
|
|
357
|
+
const root = getScratchpadRoot(projectRoot);
|
|
358
|
+
const filePath = join(root, dir, phaseDir, artifactName);
|
|
359
|
+
if (existsSync(filePath)) {
|
|
360
|
+
assertSafeStateDirectory(dirname(root), [SCRATCHPAD_DIR, dir, phaseDir]);
|
|
361
|
+
}
|
|
362
|
+
if (!assertSafeRegularFile(filePath, "Scratchpad artifact")) return null;
|
|
363
|
+
return readSafeFile(filePath, "Scratchpad artifact").toString("utf8");
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* List all artifacts written for a phase.
|
|
368
|
+
*/
|
|
383
369
|
export function scratchpad_list(
|
|
384
370
|
runId: string,
|
|
385
371
|
phase: ScratchpadPhase,
|
|
@@ -395,68 +381,26 @@ export function scratchpad_list(
|
|
|
395
381
|
}
|
|
396
382
|
|
|
397
383
|
/**
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
* completed_phases.
|
|
402
|
-
*/
|
|
403
|
-
export function scratchpad_checkpoint(
|
|
404
|
-
runId: string,
|
|
405
|
-
phase: ScratchpadPhase,
|
|
406
|
-
data: { ids?: string[]; summary?: string },
|
|
407
|
-
projectRoot?: string,
|
|
408
|
-
): ScratchpadCheckpoint {
|
|
409
|
-
const root = projectRoot ?? detectWorkspaceRoot();
|
|
410
|
-
const cp = readCheckpointRaw(runId, root) ?? scratchpad_init(runId, root);
|
|
411
|
-
|
|
412
|
-
if (!cp.completed_phases.includes(phase)) {
|
|
413
|
-
cp.completed_phases.push(phase);
|
|
414
|
-
// Keep completed_phases in pipeline order for predictable resume.
|
|
415
|
-
cp.completed_phases.sort((a, b) => SCRATCHPAD_PHASES.indexOf(a) - SCRATCHPAD_PHASES.indexOf(b));
|
|
416
|
-
}
|
|
417
|
-
cp.last_phase_at = new Date().toISOString();
|
|
418
|
-
if (data.ids) cp.phase_ids[phase] = data.ids;
|
|
419
|
-
if (data.summary) cp.phase_summaries[phase] = data.summary;
|
|
420
|
-
|
|
421
|
-
writeCheckpointRaw(cp, root);
|
|
422
|
-
return cp;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
/**
|
|
426
|
-
* Read the checkpoint + all artifact references for resume.
|
|
427
|
-
* Returns null if the run doesn't exist.
|
|
384
|
+
* Read a legacy checkpoint (state.json) + artifact references for resume.
|
|
385
|
+
* Returns null if the run has no checkpoint — the write-side checkpoint API
|
|
386
|
+
* was removed with the pipeline; this only reads what older runs left behind.
|
|
428
387
|
*/
|
|
429
388
|
export function scratchpad_resume(runId: string, projectRoot?: string): ScratchpadResume | null {
|
|
430
389
|
const root = projectRoot ?? detectWorkspaceRoot();
|
|
431
390
|
const cp = readCheckpointRaw(runId, root);
|
|
432
391
|
if (!cp) return null;
|
|
433
392
|
|
|
434
|
-
// Find the next phase: the first phase in order not in completed_phases.
|
|
435
|
-
const next = PHASE_ORDER.find((p) => !cp.completed_phases.includes(p)) ?? null;
|
|
436
|
-
|
|
437
393
|
// Gather artifact listing per completed phase.
|
|
438
394
|
const artifacts: Record<string, string[]> = {};
|
|
439
395
|
for (const phase of cp.completed_phases) {
|
|
440
396
|
artifacts[phase] = scratchpad_list(runId, phase, root);
|
|
441
397
|
}
|
|
442
398
|
|
|
443
|
-
return { checkpoint: cp,
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
/**
|
|
447
|
-
* Check whether a phase has already been checkpointed (for idempotent re-run).
|
|
448
|
-
*/
|
|
449
|
-
export function scratchpad_phase_done(
|
|
450
|
-
runId: string,
|
|
451
|
-
phase: ScratchpadPhase,
|
|
452
|
-
projectRoot?: string,
|
|
453
|
-
): boolean {
|
|
454
|
-
const cp = readCheckpointRaw(runId, projectRoot);
|
|
455
|
-
return cp?.completed_phases.includes(phase) ?? false;
|
|
399
|
+
return { checkpoint: cp, artifacts };
|
|
456
400
|
}
|
|
457
401
|
|
|
458
402
|
/**
|
|
459
|
-
* Clear a specific run's scratchpad directory
|
|
403
|
+
* Clear a specific run's scratchpad directory — a fresh start for that one
|
|
460
404
|
* run. Does not touch other runs.
|
|
461
405
|
*/
|
|
462
406
|
export function scratchpad_clear(runId: string, projectRoot?: string): void {
|