@xaccefy/pi-casefile 0.6.2 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xaccefy/pi-casefile",
3
- "version": "0.6.2",
3
+ "version": "0.7.1",
4
4
  "description": "Offensive security case tracker for Pi Agent — bug bounties, CTFs, security audits",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -35,6 +35,7 @@
35
35
  "src/ledger.ts",
36
36
  "src/workflow.ts",
37
37
  "src/poc-runner.ts",
38
+ "src/scratchpad.ts",
38
39
  "src/sqlite-compat/index.ts",
39
40
  "skills",
40
41
  "README.md",
package/src/index.ts CHANGED
@@ -1,13 +1,12 @@
1
1
  /**
2
2
  * Casefile — offensive security case tracker for Pi.
3
3
  *
4
- * Tools: CaseAdd, CaseUpdate, PromoteFinding, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseReport
4
+ * Tools: CaseAdd, CaseUpdate, PromoteFinding, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseReport, ScratchpadInit, ScratchpadResume, ScratchpadCheckpoint, ScratchpadWrite, ScratchpadRead, ScratchpadPhaseDone, ScratchpadClear
5
5
  * Command: /casefile — interactive dashboard
6
6
  * Event: before_agent_start — injects cyber workflow (+ active case list) once per user prompt
7
7
  */
8
8
 
9
9
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
10
- import { homedir } from "node:os";
11
10
  import { dirname, join } from "node:path";
12
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
12
  import { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
@@ -46,6 +45,17 @@ import {
46
45
  writeCaseReport,
47
46
  } from "./ledger.ts";
48
47
  import { type PocRun, runPoc } from "./poc-runner.ts";
48
+ import {
49
+ type ScratchpadPhase,
50
+ type ScratchpadResume,
51
+ scratchpad_checkpoint,
52
+ scratchpad_clear,
53
+ scratchpad_init,
54
+ scratchpad_phase_done,
55
+ scratchpad_read,
56
+ scratchpad_resume,
57
+ scratchpad_write,
58
+ } from "./scratchpad.ts";
49
59
  import { STATIC_CYBER_WORKFLOW } from "./workflow.ts";
50
60
 
51
61
  // ── Schemas ───────────────────────────────────────────────────────────
@@ -111,6 +121,11 @@ const PromoteSchema = Type.Object(
111
121
  poc_path: Type.String({
112
122
  description: "Absolute path to the PoC script on disk",
113
123
  }),
124
+ verification_marker: Type.String({
125
+ minLength: 1,
126
+ description:
127
+ "A unique string the PoC must print to stdout to prove exploitation actually occurred. The gate checks the PoC output contains this marker — exit code 0 alone is NOT sufficient. The marker should be specific to the finding (e.g. 'VULN_CONFIRMED_<case-id>') and only printed after the PoC has verified the exploit worked (e.g. after extracting data, receiving a callback, seeing the payload reflected). This prevents fluke exit 0 and mocked PoCs from passing the gate.",
128
+ }),
114
129
  disconfirmation_path: Type.Optional(
115
130
  Type.String({
116
131
  description:
@@ -216,6 +231,95 @@ const ReportSchema = Type.Object(
216
231
  { additionalProperties: false },
217
232
  );
218
233
 
234
+ // ── Tool: Scratchpad ─────────────────────────────────────────────────
235
+ //
236
+ // The scratchpad is the pipeline's crash-recoverable artifact store.
237
+ // The casefile owns state transitions; the scratchpad owns artifacts
238
+ // (recon maps, trace outputs, verification logs). Resume re-reads
239
+ // artifacts; it does not re-run completed phases (idempotent).
240
+
241
+ const SCRATCHPAD_PHASES = [
242
+ "recon",
243
+ "hunt",
244
+ "gapfil",
245
+ "trace",
246
+ "skeptic",
247
+ "validate",
248
+ "chain",
249
+ "patch",
250
+ "report",
251
+ ] as const;
252
+
253
+ const ScratchpadPhaseSchema = Type.String({
254
+ enum: [...SCRATCHPAD_PHASES],
255
+ description:
256
+ "Pipeline phase: recon | hunt | gapfil | trace | skeptic | validate | chain | patch | report",
257
+ });
258
+
259
+ const ScratchpadInitSchema = Type.Object(
260
+ {
261
+ run_id: Type.String({ description: "Unique run identifier for this pipeline run" }),
262
+ },
263
+ { additionalProperties: false },
264
+ );
265
+
266
+ const ScratchpadResumeSchema = Type.Object(
267
+ {
268
+ run_id: Type.String({ description: "Run identifier to resume" }),
269
+ },
270
+ { additionalProperties: false },
271
+ );
272
+
273
+ const ScratchpadCheckpointSchema = Type.Object(
274
+ {
275
+ run_id: Type.String({ description: "Run identifier" }),
276
+ phase: ScratchpadPhaseSchema,
277
+ ids: Type.Optional(
278
+ Type.Array(Type.String(), {
279
+ description: "Key IDs produced by this phase (case IDs, finding IDs)",
280
+ }),
281
+ ),
282
+ summary: Type.Optional(Type.String({ description: "One-line summary of phase completion" })),
283
+ },
284
+ { additionalProperties: false },
285
+ );
286
+
287
+ const ScratchpadWriteSchema = Type.Object(
288
+ {
289
+ run_id: Type.String({ description: "Run identifier" }),
290
+ phase: ScratchpadPhaseSchema,
291
+ artifact_name: Type.String({
292
+ description: "Artifact filename (sanitized; path traversal is blocked)",
293
+ }),
294
+ content: Type.String({ description: "Artifact content to write" }),
295
+ },
296
+ { additionalProperties: false },
297
+ );
298
+
299
+ const ScratchpadReadSchema = Type.Object(
300
+ {
301
+ run_id: Type.String({ description: "Run identifier" }),
302
+ phase: ScratchpadPhaseSchema,
303
+ artifact_name: Type.String({ description: "Artifact filename to read" }),
304
+ },
305
+ { additionalProperties: false },
306
+ );
307
+
308
+ const ScratchpadPhaseDoneSchema = Type.Object(
309
+ {
310
+ run_id: Type.String({ description: "Run identifier" }),
311
+ phase: ScratchpadPhaseSchema,
312
+ },
313
+ { additionalProperties: false },
314
+ );
315
+
316
+ const ScratchpadClearSchema = Type.Object(
317
+ {
318
+ run_id: Type.String({ description: "Run identifier to clear (deletes that run only)" }),
319
+ },
320
+ { additionalProperties: false },
321
+ );
322
+
219
323
  interface Theme {
220
324
  fg(color: string, text: string): string;
221
325
  bold(text: string): string;
@@ -322,9 +426,7 @@ class CasefileDashboard {
322
426
 
323
427
  if (this.records.length === 0) {
324
428
  lines.push("");
325
- lines.push(
326
- ` ${th.fg("dim", "No active security cases. Ask the agent to CaseAdd findings!")}`,
327
- );
429
+ lines.push(` ${th.fg("dim", "No security cases yet. Ask the agent to CaseAdd findings!")}`);
328
430
  } else {
329
431
  lines.push("");
330
432
  for (const r of this.records) {
@@ -427,11 +529,7 @@ export const XP_MODE_ENV = "PI_XP_MODE";
427
529
  export type XpMode = "on" | "off";
428
530
 
429
531
  export function getXpModeStatePath(): string {
430
- try {
431
- return join(dirname(getCasefilePath()), "xp-mode");
432
- } catch {
433
- return join(homedir(), ".pi", "xp-mode");
434
- }
532
+ return join(dirname(getCasefilePath()), "xp-mode");
435
533
  }
436
534
 
437
535
  export function readXpMode(
@@ -631,13 +729,14 @@ export default function casefileExtension(pi: ExtensionAPI) {
631
729
  name: "PromoteFinding",
632
730
  label: "Promote Finding",
633
731
  description:
634
- "Run an on-disk PoC script (Docker sandbox or local) and, on exit 0, promote an investigating case to confirmed. Optionally run a disconfirmation script that must exit non-0 (finding survived the attempt to disprove).",
732
+ "Run an on-disk PoC script (Docker sandbox or local) and, on exit 0 + verification marker present in output, promote an investigating case to confirmed. The verification_marker proves the exploit actually worked — exit code 0 alone is NOT sufficient. Optionally run a disconfirmation script that must exit non-0 (finding survived the attempt to disprove).",
635
733
  promptSnippet: "Run a PoC and promote an investigating case to confirmed",
636
734
  promptGuidelines: [
637
735
  "Use PromoteFinding when an investigating case has a concrete PoC script on disk and you are ready to prove it.",
638
736
  "The case must already have status='investigating' and non-empty poc, evidence, impact, severity, target, and disconfirmation fields.",
639
737
  "By default, the PoC runs in `docker run --rm --network none`. Use local:true to run on the host (e.g. for network-dependent bugs).",
640
- "Only exit code 0 promotes the case to confirmed.",
738
+ "Promotion requires BOTH exit code 0 AND the verification_marker appearing in the PoC output. The marker is a string you choose (e.g. 'VULN_CONFIRMED_<case-id>') that the PoC prints ONLY after it has verified the exploit worked — after extracting data, receiving a callback, seeing the payload reflected, etc. Do NOT print the marker unconditionally or before the exploit check.",
739
+ "The marker check prevents fluke exit 0 (script crashed before real logic) and mocked PoCs (script ran but didn't actually exploit the target) from passing the gate.",
641
740
  "Optionally provide disconfirmation_path to a script that tries to disprove the finding. If the disconfirmation script exits 0, the finding is considered disproven and promotion is blocked.",
642
741
  "Do not use CaseUpdate to set status='confirmed' directly — it is rejected. Always use PromoteFinding.",
643
742
  ],
@@ -648,6 +747,26 @@ export default function casefileExtension(pi: ExtensionAPI) {
648
747
  // 30s (plus first-time image pull), so fail cheap when the case can't
649
748
  // advance anyway (missing, wrong status, missing required fields).
650
749
  assertPromotable(params.id as string);
750
+
751
+ // Reject empty/whitespace markers BEFORE any PoC run — it's a param
752
+ // error, so fail cheap instead of burning a (up to 30s) sandboxed run.
753
+ const marker = (params.verification_marker as string | undefined)?.trim();
754
+ if (!marker) {
755
+ return {
756
+ content: [
757
+ {
758
+ type: "text",
759
+ text:
760
+ "verification_marker is empty or whitespace. " +
761
+ "A non-empty marker printed only AFTER the PoC confirms exploitation is required — " +
762
+ "exit code 0 alone is not sufficient. Case remains investigating.",
763
+ },
764
+ ],
765
+ isError: true,
766
+ details: { record: getCaseById(params.id as string) },
767
+ };
768
+ }
769
+
651
770
  const run = runPoc(params.poc_path as string, params.local !== true);
652
771
 
653
772
  // Fail closed without throwing: non-zero PoC must leave the case investigating.
@@ -665,6 +784,28 @@ export default function casefileExtension(pi: ExtensionAPI) {
665
784
  };
666
785
  }
667
786
 
787
+ // Verification marker check: exit code 0 alone is NOT sufficient.
788
+ // The PoC must print the verification_marker to stdout, proving the
789
+ // exploit actually worked — not just that the script ran. This blocks
790
+ // fluke exit 0 (crash before real logic) and mocked PoCs that don't
791
+ // actually exploit the target.
792
+ if (!(run.output ?? "").includes(marker)) {
793
+ const record = getCaseById(params.id as string);
794
+ return {
795
+ content: [
796
+ {
797
+ type: "text",
798
+ text:
799
+ `PoC exited 0 but the verification marker "${marker}" was NOT found in the output.\n` +
800
+ `This means the script ran but did not prove exploitation. The marker must be printed only AFTER the PoC verifies the exploit worked (data extracted, callback received, payload reflected, etc.).\n` +
801
+ `Do not print the marker unconditionally — print it only when the exploit is confirmed.\n\nOutput:\n${run.output}`,
802
+ },
803
+ ],
804
+ isError: true,
805
+ details: { record, run, markerMissing: true },
806
+ };
807
+ }
808
+
668
809
  // Run disconfirmation script if provided — must exit NON-0 (finding survived the attempt to disprove).
669
810
  let disconfirmationRun: PocRun | undefined;
670
811
  if (params.disconfirmation_path) {
@@ -1012,7 +1153,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
1012
1153
  name: "CaseReport",
1013
1154
  label: "Write Case Report",
1014
1155
  description:
1015
- "Generate a markdown report from a confirmed or reported case under the project report directory. Hypothesis/investigating/blocked/killed cases are rejected — promote to confirmed first.",
1156
+ "Generate a markdown report from a confirmed or reported case under the casefile report directory (next to the casefile DB). Hypothesis/investigating/blocked/killed cases are rejected — promote to confirmed first.",
1016
1157
  promptSnippet: "Generate a bounty-style markdown report from a case",
1017
1158
  promptGuidelines: [
1018
1159
  "Use CaseReport only for confirmed or already reported cases. Keep hypotheses and investigating cases in the ledger until proof is captured.",
@@ -1066,6 +1207,362 @@ export default function casefileExtension(pi: ExtensionAPI) {
1066
1207
  },
1067
1208
  });
1068
1209
 
1210
+ // ── Tool: ScratchpadInit ──
1211
+
1212
+ pi.registerTool({
1213
+ name: "ScratchpadInit",
1214
+ label: "Init Scratchpad",
1215
+ description:
1216
+ "Initialize a crash-recoverable artifact store for a pipeline run. Creates the directory structure and an initial state.json checkpoint. Idempotent — safe to call on resume without --fresh; returns the existing checkpoint if the run already exists.",
1217
+ promptSnippet: "Initialize the pipeline artifact store for a run",
1218
+ promptGuidelines: [
1219
+ "Call ScratchpadInit once at the start of a pipeline run (or on resume before ScratchpadResume).",
1220
+ "The run_id is arbitrary but should be unique per pipeline run — typically <target>-<timestamp>.",
1221
+ "On resume, ScratchpadInit returns the existing checkpoint without wiping it; pair with ScratchpadResume to skip completed phases.",
1222
+ ],
1223
+ parameters: ScratchpadInitSchema,
1224
+
1225
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
1226
+ const cp = scratchpad_init(params.run_id as string);
1227
+ return {
1228
+ content: [
1229
+ {
1230
+ type: "text",
1231
+ text: `Scratchpad initialized for run ${cp.run_id}.\nCompleted phases: ${cp.completed_phases.length ? cp.completed_phases.join(", ") : "none"}`,
1232
+ },
1233
+ ],
1234
+ details: { checkpoint: cp },
1235
+ };
1236
+ },
1237
+
1238
+ renderCall(args, theme) {
1239
+ return new Text(
1240
+ theme.fg("toolTitle", theme.bold("ScratchpadInit ")) +
1241
+ theme.fg("dim", (args.run_id as string) ?? ""),
1242
+ 0,
1243
+ 0,
1244
+ );
1245
+ },
1246
+
1247
+ renderResult(result, _opts, theme) {
1248
+ const cp = (result.details as { checkpoint: { run_id: string } } | undefined)?.checkpoint;
1249
+ return new Text(`${theme.fg("success", "✓ ")}ScratchpadInit ${cp?.run_id ?? ""}`, 0, 0);
1250
+ },
1251
+ });
1252
+
1253
+ // ── Tool: ScratchpadResume ──
1254
+
1255
+ pi.registerTool({
1256
+ name: "ScratchpadResume",
1257
+ label: "Resume Scratchpad",
1258
+ description:
1259
+ "Read the checkpoint and artifact listing for a pipeline run to decide where to resume. Returns the next phase to run (or null if done) and which phases already completed. Returns null if the run does not exist.",
1260
+ promptSnippet: "Check pipeline resume state — which phases are done",
1261
+ promptGuidelines: [
1262
+ "Call ScratchpadResume at pipeline start to determine where to resume. If it returns a checkpoint, skip completed phases (check ScratchpadPhaseDone before each dispatch) and continue from next_phase.",
1263
+ "If ScratchpadResume returns null, the run has no checkpoint — call ScratchpadInit to start fresh.",
1264
+ "Use ScratchpadPhaseDone before dispatching each stage to avoid re-running completed phases (idempotent resume).",
1265
+ ],
1266
+ parameters: ScratchpadResumeSchema,
1267
+
1268
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
1269
+ const resume = scratchpad_resume(params.run_id as string);
1270
+ if (!resume) {
1271
+ return {
1272
+ content: [
1273
+ {
1274
+ type: "text",
1275
+ text: `No scratchpad found for run ${params.run_id}. Call ScratchpadInit to start a new run.`,
1276
+ },
1277
+ ],
1278
+ details: { resume: null },
1279
+ };
1280
+ }
1281
+ const cp = resume.checkpoint;
1282
+ return {
1283
+ content: [
1284
+ {
1285
+ type: "text",
1286
+ text:
1287
+ `Resume run ${cp.run_id}:\n` +
1288
+ `Completed phases: ${cp.completed_phases.length ? cp.completed_phases.join(", ") : "none"}\n` +
1289
+ `Next phase: ${resume.next_phase ?? "none (run is done)"}`,
1290
+ },
1291
+ ],
1292
+ details: { resume },
1293
+ };
1294
+ },
1295
+
1296
+ renderCall(args, theme) {
1297
+ return new Text(
1298
+ theme.fg("toolTitle", theme.bold("ScratchpadResume ")) +
1299
+ theme.fg("dim", (args.run_id as string) ?? ""),
1300
+ 0,
1301
+ 0,
1302
+ );
1303
+ },
1304
+
1305
+ renderResult(result, _opts, theme) {
1306
+ const resume = (result.details as { resume: ScratchpadResume | null } | undefined)?.resume;
1307
+ if (!resume) return new Text(theme.fg("warning", "↷ ScratchpadResume — no run found"), 0, 0);
1308
+ return new Text(
1309
+ theme.fg("success", "✓ ") +
1310
+ `ScratchpadResume ${resume.checkpoint.run_id} → next: ${resume.next_phase ?? "done"}`,
1311
+ 0,
1312
+ 0,
1313
+ );
1314
+ },
1315
+ });
1316
+
1317
+ // ── Tool: ScratchpadCheckpoint ──
1318
+
1319
+ pi.registerTool({
1320
+ name: "ScratchpadCheckpoint",
1321
+ label: "Checkpoint Phase",
1322
+ description:
1323
+ "Mark a pipeline phase as complete in the scratchpad state.json. Records the completion timestamp, key IDs, and an optional summary. Idempotent — re-checkpointing a phase overwrites its summary/IDs without duplicating the completed_phases entry.",
1324
+ promptSnippet: "Record a pipeline phase as complete",
1325
+ promptGuidelines: [
1326
+ "Call ScratchpadCheckpoint after every phase completes: ScratchpadCheckpoint(run_id, phase, { ids, summary }).",
1327
+ "ids are the key case/finding IDs the phase produced — used by resume to reconstruct state.",
1328
+ "Keep completed_phases in pipeline order; the checkpoint sorts automatically.",
1329
+ ],
1330
+ parameters: ScratchpadCheckpointSchema,
1331
+
1332
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
1333
+ const cp = scratchpad_checkpoint(params.run_id as string, params.phase as ScratchpadPhase, {
1334
+ ids: params.ids as string[] | undefined,
1335
+ summary: params.summary as string | undefined,
1336
+ });
1337
+ return {
1338
+ content: [
1339
+ {
1340
+ type: "text",
1341
+ text:
1342
+ `Phase ${params.phase} checkpointed for run ${cp.run_id}.\n` +
1343
+ `Completed phases: ${cp.completed_phases.join(", ")}`,
1344
+ },
1345
+ ],
1346
+ details: { checkpoint: cp },
1347
+ };
1348
+ },
1349
+
1350
+ renderCall(args, theme) {
1351
+ return new Text(
1352
+ theme.fg("toolTitle", theme.bold("ScratchpadCheckpoint ")) +
1353
+ theme.fg("dim", `${args.run_id ?? ""} ${args.phase ?? ""}`),
1354
+ 0,
1355
+ 0,
1356
+ );
1357
+ },
1358
+
1359
+ renderResult(result, _opts, theme) {
1360
+ const cp = (
1361
+ result.details as { checkpoint: { run_id: string; completed_phases: string[] } } | undefined
1362
+ )?.checkpoint;
1363
+ return new Text(
1364
+ theme.fg("success", "✓ ") +
1365
+ `ScratchpadCheckpoint ${cp?.run_id ?? ""} — ${cp?.completed_phases.length ?? 0} phases done`,
1366
+ 0,
1367
+ 0,
1368
+ );
1369
+ },
1370
+ });
1371
+
1372
+ // ── Tool: ScratchpadWrite ──
1373
+
1374
+ pi.registerTool({
1375
+ name: "ScratchpadWrite",
1376
+ label: "Write Artifact",
1377
+ description:
1378
+ "Write an intermediate artifact (recon map, trace output, verification log) to a phase's subdirectory in the scratchpad. Overwrites if the name exists. Artifact names are sanitized — path traversal is blocked.",
1379
+ promptSnippet: "Save a pipeline artifact to the scratchpad",
1380
+ promptGuidelines: [
1381
+ "Agents write artifacts to the scratchpad, not to each other's output files (prevents an echo chamber).",
1382
+ "The casefile owns state transitions; the scratchpad owns artifacts. Use ScratchpadWrite for bulky intermediate outputs, not CaseUpdate.",
1383
+ ],
1384
+ parameters: ScratchpadWriteSchema,
1385
+
1386
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
1387
+ const path = scratchpad_write(
1388
+ params.run_id as string,
1389
+ params.phase as ScratchpadPhase,
1390
+ params.artifact_name as string,
1391
+ params.content as string,
1392
+ );
1393
+ return {
1394
+ content: [
1395
+ {
1396
+ type: "text",
1397
+ text: `Artifact written: ${params.artifact_name} → ${path}`,
1398
+ },
1399
+ ],
1400
+ details: { path, artifact_name: params.artifact_name },
1401
+ };
1402
+ },
1403
+
1404
+ renderCall(args, theme) {
1405
+ return new Text(
1406
+ theme.fg("toolTitle", theme.bold("ScratchpadWrite ")) +
1407
+ theme.fg("dim", `${args.run_id ?? ""}/${args.phase ?? ""}/${args.artifact_name ?? ""}`),
1408
+ 0,
1409
+ 0,
1410
+ );
1411
+ },
1412
+
1413
+ renderResult(result, _opts, theme) {
1414
+ const name = (result.details as { artifact_name?: string } | undefined)?.artifact_name;
1415
+ return new Text(theme.fg("success", `✓ ScratchpadWrite ${name ?? ""}`), 0, 0);
1416
+ },
1417
+ });
1418
+
1419
+ // ── Tool: ScratchpadRead ──
1420
+
1421
+ pi.registerTool({
1422
+ name: "ScratchpadRead",
1423
+ label: "Read Artifact",
1424
+ description:
1425
+ "Read an artifact from a phase's subdirectory in the scratchpad. Returns null if the artifact is missing. Use to resume a phase from a prior run's intermediate output.",
1426
+ promptSnippet: "Read a pipeline artifact from the scratchpad",
1427
+ promptGuidelines: [
1428
+ "On resume, ScratchpadRead retrieves a prior phase's intermediate output so the next phase can proceed without re-running it.",
1429
+ "Returns null for missing artifacts — treat as 'not yet produced' rather than an error.",
1430
+ ],
1431
+ parameters: ScratchpadReadSchema,
1432
+
1433
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
1434
+ const content = scratchpad_read(
1435
+ params.run_id as string,
1436
+ params.phase as ScratchpadPhase,
1437
+ params.artifact_name as string,
1438
+ );
1439
+ if (content === null) {
1440
+ return {
1441
+ content: [
1442
+ {
1443
+ type: "text",
1444
+ text: `Artifact not found: ${params.artifact_name} in ${params.phase}/`,
1445
+ },
1446
+ ],
1447
+ details: { artifact_name: params.artifact_name, found: false },
1448
+ };
1449
+ }
1450
+ return {
1451
+ content: [{ type: "text", text: content }],
1452
+ details: { artifact_name: params.artifact_name, found: true, length: content.length },
1453
+ };
1454
+ },
1455
+
1456
+ renderCall(args, theme) {
1457
+ return new Text(
1458
+ theme.fg("toolTitle", theme.bold("ScratchpadRead ")) +
1459
+ theme.fg("dim", `${args.run_id ?? ""}/${args.phase ?? ""}/${args.artifact_name ?? ""}`),
1460
+ 0,
1461
+ 0,
1462
+ );
1463
+ },
1464
+
1465
+ renderResult(result, _opts, theme) {
1466
+ const found = (result.details as { found?: boolean } | undefined)?.found;
1467
+ return new Text(
1468
+ found
1469
+ ? theme.fg("success", "✓ ScratchpadRead")
1470
+ : theme.fg("warning", "↷ ScratchpadRead — not found"),
1471
+ 0,
1472
+ 0,
1473
+ );
1474
+ },
1475
+ });
1476
+
1477
+ // ── Tool: ScratchpadPhaseDone ──
1478
+
1479
+ pi.registerTool({
1480
+ name: "ScratchpadPhaseDone",
1481
+ label: "Phase Done?",
1482
+ description:
1483
+ "Check whether a phase has already been checkpointed in the scratchpad — for idempotent re-run. Returns true if the phase is complete; skip re-dispatching it on resume.",
1484
+ promptSnippet: "Check if a pipeline phase is already complete",
1485
+ promptGuidelines: [
1486
+ "Call ScratchpadPhaseDone before dispatching each stage to avoid re-running completed phases on resume.",
1487
+ "A completed phase with a checkpoint is a no-op on re-run — skip it and continue to the next incomplete phase.",
1488
+ ],
1489
+ parameters: ScratchpadPhaseDoneSchema,
1490
+
1491
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
1492
+ const done = scratchpad_phase_done(params.run_id as string, params.phase as ScratchpadPhase);
1493
+ return {
1494
+ content: [
1495
+ {
1496
+ type: "text",
1497
+ text: `Phase ${params.phase} for run ${params.run_id}: ${done ? "DONE (skip on resume)" : "not done"}`,
1498
+ },
1499
+ ],
1500
+ details: { phase: params.phase, done },
1501
+ };
1502
+ },
1503
+
1504
+ renderCall(args, theme) {
1505
+ return new Text(
1506
+ theme.fg("toolTitle", theme.bold("ScratchpadPhaseDone ")) +
1507
+ theme.fg("dim", `${args.run_id ?? ""} ${args.phase ?? ""}`),
1508
+ 0,
1509
+ 0,
1510
+ );
1511
+ },
1512
+
1513
+ renderResult(result, _opts, theme) {
1514
+ const done = (result.details as { done?: boolean } | undefined)?.done;
1515
+ return new Text(
1516
+ done
1517
+ ? theme.fg("success", "✓ ScratchpadPhaseDone — done")
1518
+ : theme.fg("warning", "↷ ScratchpadPhaseDone — not done"),
1519
+ 0,
1520
+ 0,
1521
+ );
1522
+ },
1523
+ });
1524
+
1525
+ // ── Tool: ScratchpadClear ──
1526
+
1527
+ pi.registerTool({
1528
+ name: "ScratchpadClear",
1529
+ label: "Clear Run",
1530
+ description:
1531
+ "Clear a single pipeline run's scratchpad directory. Used by --fresh for one run. Does not touch other runs. The run must be re-initialized with ScratchpadInit afterward.",
1532
+ promptSnippet: "Clear one pipeline run's artifacts",
1533
+ promptGuidelines: [
1534
+ "Use ScratchpadClear to force a fresh start for a single run (--fresh). It deletes that run's directory only.",
1535
+ "After clearing, call ScratchpadInit to recreate the directory structure before writing artifacts.",
1536
+ ],
1537
+ parameters: ScratchpadClearSchema,
1538
+
1539
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
1540
+ scratchpad_clear(params.run_id as string);
1541
+ return {
1542
+ content: [
1543
+ {
1544
+ type: "text",
1545
+ text: `Scratchpad cleared for run ${params.run_id}. Call ScratchpadInit to start a new run.`,
1546
+ },
1547
+ ],
1548
+ details: { run_id: params.run_id, cleared: true },
1549
+ };
1550
+ },
1551
+
1552
+ renderCall(args, theme) {
1553
+ return new Text(
1554
+ theme.fg("toolTitle", theme.bold("ScratchpadClear ")) +
1555
+ theme.fg("dim", (args.run_id as string) ?? ""),
1556
+ 0,
1557
+ 0,
1558
+ );
1559
+ },
1560
+
1561
+ renderResult(_result, _opts, theme) {
1562
+ return new Text(theme.fg("success", "✓ ScratchpadClear"), 0, 0);
1563
+ },
1564
+ });
1565
+
1069
1566
  // ── Command: /casefile ──
1070
1567
 
1071
1568
  pi.registerCommand("casefile", {
package/src/ledger.ts CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { createHash, randomUUID } from "node:crypto";
14
14
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
15
- import { dirname, join, resolve } from "node:path";
15
+ import { basename, dirname, join, resolve } from "node:path";
16
16
  import { DatabaseSync } from "./sqlite-compat/index.ts";
17
17
 
18
18
  // ── Types ────────────────────────────────────────────────────────────
@@ -226,8 +226,14 @@ function stableShortId(input: string): string {
226
226
  }
227
227
 
228
228
  function detectWorkspaceRoot(): string {
229
- const envs = ["CASEFILE_WORKSPACE_ROOT", "PI_WORKSPACE_ROOT", "GITHUB_WORKSPACE", "PWD"];
230
- for (const e of envs) if (process.env[e]) return resolve(process.env[e]!);
229
+ // PWD is deliberately excluded: it is shell-set, can be stale or forged in
230
+ // spawned processes, and disagree with the real cwd. Explicit overrides only,
231
+ // then walk up from the actual cwd.
232
+ const envs = ["CASEFILE_WORKSPACE_ROOT", "PI_WORKSPACE_ROOT", "GITHUB_WORKSPACE"];
233
+ for (const e of envs) {
234
+ const v = process.env[e]?.trim();
235
+ if (v) return resolve(v);
236
+ }
231
237
 
232
238
  let curr = resolve(process.cwd());
233
239
  for (let i = 0; i < 20; i++) {
@@ -241,7 +247,10 @@ function detectWorkspaceRoot(): string {
241
247
 
242
248
  export function getCasefilePath(): string {
243
249
  if (ledgerPathOverride) return ledgerPathOverride;
244
- if (process.env.PI_CASEFILE_PATH) return resolve(process.env.PI_CASEFILE_PATH.trim());
250
+ // Trim BEFORE the truthiness check: a whitespace-only value must not
251
+ // "pass" and resolve to the process cwd ("" resolves to cwd).
252
+ const envPath = process.env.PI_CASEFILE_PATH?.trim();
253
+ if (envPath) return resolve(envPath);
245
254
  return join(detectWorkspaceRoot(), ".pi", "casefile.db");
246
255
  }
247
256
 
@@ -622,19 +631,17 @@ function findDuplicateCaseInDb(
622
631
  const endpoint = normalizeMatchText(candidate.endpoint);
623
632
  const bugClass = normalizeMatchText(candidate.bugClass);
624
633
 
625
- // Pre-filter by normalized title in SQL so we don't load the whole ledger into
626
- // JS just to find a duplicate. normalizeMatchText lowercases + collapses
627
- // whitespace, so we match against lower(title) with the same normalization.
628
- // The full title/target/endpoint/bugClass match still runs in JS below to catch
629
- // whitespace/case differences the SQL LIKE can't express exactly.
630
- const sqlTitle = `%${title}%`;
634
+ // No SQL pre-filter: candidate rows are compared in JS against
635
+ // normalizeMatchText (lowercase + whitespace-collapse). SQLite's lower() is
636
+ // ASCII-only and LIKE can't collapse whitespace, so any SQL pre-filter would
637
+ // silently drop rows the JS comparator would call duplicates (e.g. stored
638
+ // "SQL Injection" vs candidate "SQL Injection", or non-ASCII case variants).
639
+ // Case ledgers are small (hundreds of rows); a full non-killed scan is cheap.
631
640
  const rows = excludeId
632
641
  ? (db
633
- .prepare("SELECT * FROM cases WHERE status != 'killed' AND id != ? AND lower(title) LIKE ?")
634
- .all(excludeId, sqlTitle) as any[])
635
- : (db
636
- .prepare("SELECT * FROM cases WHERE status != 'killed' AND lower(title) LIKE ?")
637
- .all(sqlTitle) as any[]);
642
+ .prepare("SELECT * FROM cases WHERE status != 'killed' AND id != ?")
643
+ .all(excludeId) as any[])
644
+ : (db.prepare("SELECT * FROM cases WHERE status != 'killed'").all() as any[]);
638
645
 
639
646
  for (const row of rows) {
640
647
  if (
@@ -922,7 +929,7 @@ export function promoteFindingResult(
922
929
  }
923
930
 
924
931
  const newEvidence =
925
- (current.evidence ? current.evidence + "\n\n" : "") +
932
+ (current.evidence ? `${current.evidence}\n\n` : "") +
926
933
  `### PoC Execution Capture (${verification.ranAt})\n` +
927
934
  `- **Exit Code:** ${verification.exitCode}\n` +
928
935
  `- **Sandbox:** ${verification.sandbox ? "yes" : "no"}\n` +
@@ -1171,7 +1178,7 @@ function mapRowsWithLinks(db: DatabaseSync, rows: any[]): CaseRecord[] {
1171
1178
  const linkMap = new Map<string, { id: string; kind: string }[]>();
1172
1179
  for (const l of links) {
1173
1180
  if (!linkMap.has(l.source_id)) linkMap.set(l.source_id, []);
1174
- linkMap.get(l.source_id)!.push({ id: l.target_id, kind: l.kind });
1181
+ linkMap.get(l.source_id)?.push({ id: l.target_id, kind: l.kind });
1175
1182
  }
1176
1183
  return rows.map((row) => mapRow(row, linkMap.get(row.id) ?? []));
1177
1184
  }
@@ -1323,14 +1330,14 @@ export function writeCaseReport(id: string): { path: string; record: CaseRecord
1323
1330
  current.pocVerified
1324
1331
  ? mdSection(
1325
1332
  "PoC Verification Log",
1326
- `### PoC Run Verification\n- **Timestamp:** ${current.pocVerified.ranAt}\n- **Path:** \`${current.pocVerified.path}\`\n- **Sandbox:** ${current.pocVerified.sandbox ? "yes" : "no"}\n- **Exit Code:** ${current.pocVerified.exitCode}\n\n#### Output\n\`\`\`\n${current.pocVerified.output ?? ""}\n\`\`\``,
1333
+ `### PoC Run Verification\n- **Timestamp:** ${current.pocVerified.ranAt}\n- **Script:** \`${basename(current.pocVerified.path)}\`\n- **Sandbox:** ${current.pocVerified.sandbox ? "yes" : "no"}\n- **Exit Code:** ${current.pocVerified.exitCode}\n\n#### Output\n\`\`\`\n${current.pocVerified.output ?? ""}\n\`\`\``,
1327
1334
  )
1328
1335
  : undefined,
1329
1336
  mdSection("Disconfirmation Attempt", current.disconfirmation),
1330
1337
  current.disconfirmationVerified
1331
1338
  ? mdSection(
1332
1339
  "Disconfirmation Verification Log",
1333
- `### Disconfirmation Run Verification\n- **Timestamp:** ${current.disconfirmationVerified.ranAt}\n- **Path:** \`${current.disconfirmationVerified.path}\`\n- **Sandbox:** ${current.disconfirmationVerified.sandbox ? "yes" : "no"}\n- **Exit Code:** ${current.disconfirmationVerified.exitCode} (non-zero = finding survived the attempt to disprove)\n\n#### Output\n\`\`\`\n${current.disconfirmationVerified.output ?? ""}\n\`\`\``,
1340
+ `### Disconfirmation Run Verification\n- **Timestamp:** ${current.disconfirmationVerified.ranAt}\n- **Script:** \`${basename(current.disconfirmationVerified.path)}\`\n- **Sandbox:** ${current.disconfirmationVerified.sandbox ? "yes" : "no"}\n- **Exit Code:** ${current.disconfirmationVerified.exitCode} (non-zero = finding survived the attempt to disprove)\n\n#### Output\n\`\`\`\n${current.disconfirmationVerified.output ?? ""}\n\`\`\``,
1334
1341
  )
1335
1342
  : undefined,
1336
1343
  mdSection("Impact", current.impact),
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Scratchpad — intermediate artifact store for pipeline runs.
3
+ *
4
+ * The casefile owns state transitions; the scratchpad owns artifacts.
5
+ * Agents write their outputs here (recon maps, trace outputs, verification
6
+ * logs) instead of stuffing everything into casefile text fields or relying
7
+ * on each other's output streams (which creates an echo chamber).
8
+ *
9
+ * Directory layout per pipeline run:
10
+ * {project_root}/.scratchpad/{run_id}/
11
+ * recon/ — fingerprints, tech detection, surface maps
12
+ * trace/ — per-finding reachability traces
13
+ * verify/ — PoC logs, run outputs
14
+ * state.json — checkpoint file with phase completion + key IDs
15
+ *
16
+ * Resume re-reads scratchpad artifacts; it does not re-run completed phases
17
+ * (idempotent). The `.scratchpad/` directory is preserved between runs;
18
+ * `--fresh` clears it via scratchpad_clear().
19
+ */
20
+
21
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
22
+ import { dirname, join, resolve } from "node:path";
23
+
24
+ // ── Types ────────────────────────────────────────────────────────────
25
+
26
+ export type ScratchpadPhase =
27
+ | "recon"
28
+ | "hunt"
29
+ | "gapfil"
30
+ | "trace"
31
+ | "skeptic"
32
+ | "validate"
33
+ | "chain"
34
+ | "patch"
35
+ | "report";
36
+
37
+ export interface ScratchpadCheckpoint {
38
+ run_id: string;
39
+ project_root: string;
40
+ created_at: string;
41
+ last_updated: string;
42
+ /** Ordered list of phases that have completed (in pipeline order). */
43
+ completed_phases: ScratchpadPhase[];
44
+ /** ISO timestamp of the last phase completion. */
45
+ last_phase_at: string | null;
46
+ /** Key IDs produced by each phase — case IDs, finding IDs, etc. */
47
+ phase_ids: Record<ScratchpadPhase, string[]>;
48
+ /** Free-form summary per phase, set by checkpoint(). */
49
+ phase_summaries: Record<ScratchpadPhase, string>;
50
+ }
51
+
52
+ export interface ScratchpadResume {
53
+ checkpoint: ScratchpadCheckpoint;
54
+ /** The next phase to run (or null if the run is done). */
55
+ next_phase: ScratchpadPhase | null;
56
+ /** Artifact references per phase: { trace: ["finding-abc.json", ...], ... } */
57
+ artifacts: Record<string, string[]>;
58
+ }
59
+
60
+ // ── Constants ────────────────────────────────────────────────────────
61
+
62
+ const PHASE_ORDER: ScratchpadPhase[] = [
63
+ "recon",
64
+ "hunt",
65
+ "gapfil",
66
+ "trace",
67
+ "skeptic",
68
+ "validate",
69
+ "chain",
70
+ "patch",
71
+ "report",
72
+ ];
73
+
74
+ const PHASE_DIRS: Record<ScratchpadPhase, string> = {
75
+ recon: "recon",
76
+ hunt: "hunt",
77
+ gapfil: "gapfil",
78
+ trace: "trace",
79
+ skeptic: "skeptic",
80
+ validate: "verify",
81
+ chain: "chain",
82
+ patch: "patch",
83
+ report: "report",
84
+ };
85
+
86
+ const SCRATCHPAD_DIR = ".scratchpad";
87
+
88
+ // ── Helpers ──────────────────────────────────────────────────────────
89
+
90
+ let scratchpadRootOverride: string | undefined;
91
+
92
+ /**
93
+ * Detect the workspace root by walking up for a .git dir or package.json,
94
+ * matching the ledger's detectWorkspaceRoot() heuristic.
95
+ */
96
+ function detectWorkspaceRoot(): string {
97
+ if (scratchpadRootOverride) return scratchpadRootOverride;
98
+
99
+ // PWD is deliberately excluded (shell-set, can be stale/forged); explicit
100
+ // overrides only, then walk up from the real cwd.
101
+ const envs = ["XPI_SCRATCHPAD_ROOT", "PI_WORKSPACE_ROOT", "GITHUB_WORKSPACE"];
102
+ for (const e of envs) {
103
+ const v = process.env[e];
104
+ if (v) return resolve(v);
105
+ }
106
+
107
+ let curr = resolve(process.cwd());
108
+ for (let i = 0; i < 20; i++) {
109
+ if (existsSync(join(curr, ".git")) || existsSync(join(curr, "package.json"))) return curr;
110
+ const parent = dirname(curr);
111
+ if (parent === curr) break;
112
+ curr = parent;
113
+ }
114
+ return resolve(process.cwd());
115
+ }
116
+
117
+ /** Override the scratchpad root (for testing). Pass undefined to reset. */
118
+ export function setScratchpadRoot(path: string | undefined): void {
119
+ scratchpadRootOverride = path ? resolve(path) : undefined;
120
+ }
121
+
122
+ /** The top-level scratchpad directory for a given project root. */
123
+ export function getScratchpadRoot(projectRoot?: string): string {
124
+ const root = projectRoot ?? detectWorkspaceRoot();
125
+ return join(root, SCRATCHPAD_DIR);
126
+ }
127
+
128
+ /**
129
+ * Sanitize a run_id into a single safe directory name. Unlike artifact names,
130
+ * run_ids arrive from the agent and were never sanitized — `..`/`/` would let
131
+ * join() escape .scratchpad, turning ScratchpadClear("..") into a recursive
132
+ * delete of the project root. Same allowlist as artifact names, plus rejection
133
+ * of dot-only results (.", "..", or a sanitized empty string).
134
+ */
135
+ function sanitizeRunId(runId: string): string {
136
+ const safe = runId.replace(/[^a-zA-Z0-9._-]/g, "_");
137
+ if (!safe || /^\.*$/.test(safe)) {
138
+ throw new Error(`Invalid run_id: "${runId}" — nothing left after sanitization`);
139
+ }
140
+ return safe;
141
+ }
142
+
143
+ /** The directory for a specific run. */
144
+ export function getRunDir(runId: string, projectRoot?: string): string {
145
+ return join(getScratchpadRoot(projectRoot), sanitizeRunId(runId));
146
+ }
147
+
148
+ /** The state.json path for a run. */
149
+ export function getStatePath(runId: string, projectRoot?: string): string {
150
+ return join(getRunDir(runId, projectRoot), "state.json");
151
+ }
152
+
153
+ function emptyCheckpoint(runId: string, projectRoot: string): ScratchpadCheckpoint {
154
+ const now = new Date().toISOString();
155
+ return {
156
+ run_id: runId,
157
+ project_root: projectRoot,
158
+ created_at: now,
159
+ last_updated: now,
160
+ last_phase_at: null,
161
+ completed_phases: [],
162
+ phase_ids: {} as Record<ScratchpadPhase, string[]>,
163
+ phase_summaries: {} as Record<ScratchpadPhase, string>,
164
+ };
165
+ }
166
+
167
+ function ensureRunDirs(runDir: string): void {
168
+ if (!existsSync(runDir)) mkdirSync(runDir, { recursive: true });
169
+ for (const phase of PHASE_ORDER) {
170
+ const dir = join(runDir, PHASE_DIRS[phase]);
171
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
172
+ }
173
+ }
174
+
175
+ function readCheckpointRaw(runId: string, projectRoot?: string): ScratchpadCheckpoint | null {
176
+ const statePath = getStatePath(runId, projectRoot);
177
+ if (!existsSync(statePath)) return null;
178
+ try {
179
+ const raw = readFileSync(statePath, "utf8");
180
+ const cp = JSON.parse(raw) as ScratchpadCheckpoint;
181
+ // Backfill maps for phases not yet checkpointed (defensive).
182
+ if (!cp.phase_ids) cp.phase_ids = {} as Record<ScratchpadPhase, string[]>;
183
+ if (!cp.phase_summaries) cp.phase_summaries = {} as Record<ScratchpadPhase, string>;
184
+ return cp;
185
+ } catch {
186
+ return null;
187
+ }
188
+ }
189
+
190
+ function writeCheckpointRaw(cp: ScratchpadCheckpoint, projectRoot?: string): void {
191
+ cp.last_updated = new Date().toISOString();
192
+ const statePath = getStatePath(cp.run_id, projectRoot);
193
+ ensureRunDirs(getRunDir(cp.run_id, projectRoot));
194
+ writeFileSync(statePath, JSON.stringify(cp, null, 2), "utf8");
195
+ }
196
+
197
+ // ── Public API ───────────────────────────────────────────────────────
198
+
199
+ /**
200
+ * Initialize a new scratchpad run. Creates the directory structure and writes
201
+ * an initial state.json. If the run already exists, returns the existing
202
+ * checkpoint (idempotent — safe to call on resume without --fresh).
203
+ */
204
+ export function scratchpad_init(runId: string, projectRoot?: string): ScratchpadCheckpoint {
205
+ const root = projectRoot ?? detectWorkspaceRoot();
206
+ const runDir = getRunDir(runId, root);
207
+ ensureRunDirs(runDir);
208
+
209
+ const existing = readCheckpointRaw(runId, root);
210
+ if (existing) return existing;
211
+
212
+ const cp = emptyCheckpoint(runId, root);
213
+ writeCheckpointRaw(cp, root);
214
+ return cp;
215
+ }
216
+
217
+ /**
218
+ * Write an artifact to a phase's subdirectory. Overwrites if the name exists.
219
+ * Returns the full path to the written artifact.
220
+ */
221
+ export function scratchpad_write(
222
+ runId: string,
223
+ phase: ScratchpadPhase,
224
+ artifactName: string,
225
+ content: string,
226
+ projectRoot?: string,
227
+ ): string {
228
+ const root = projectRoot ?? detectWorkspaceRoot();
229
+ const runDir = getRunDir(runId, root);
230
+ ensureRunDirs(runDir);
231
+
232
+ // Sanitize artifact name: no path traversal.
233
+ const safeName = artifactName.replace(/[^a-zA-Z0-9._-]/g, "_");
234
+ const dir = join(runDir, PHASE_DIRS[phase]);
235
+ const filePath = join(dir, safeName);
236
+ writeFileSync(filePath, content, "utf8");
237
+ return filePath;
238
+ }
239
+
240
+ /**
241
+ * Read an artifact. Returns null if missing.
242
+ */
243
+ export function scratchpad_read(
244
+ runId: string,
245
+ phase: ScratchpadPhase,
246
+ artifactName: string,
247
+ projectRoot?: string,
248
+ ): string | null {
249
+ const root = projectRoot ?? detectWorkspaceRoot();
250
+ const safeName = artifactName.replace(/[^a-zA-Z0-9._-]/g, "_");
251
+ const filePath = join(getRunDir(runId, root), PHASE_DIRS[phase], safeName);
252
+ if (!existsSync(filePath)) return null;
253
+ return readFileSync(filePath, "utf8");
254
+ }
255
+
256
+ /**
257
+ * List all artifacts written for a phase.
258
+ */
259
+ export function scratchpad_list(
260
+ runId: string,
261
+ phase: ScratchpadPhase,
262
+ projectRoot?: string,
263
+ ): string[] {
264
+ const root = projectRoot ?? detectWorkspaceRoot();
265
+ const dir = join(getRunDir(runId, root), PHASE_DIRS[phase]);
266
+ if (!existsSync(dir)) return [];
267
+ return readdirSync(dir).filter((f) => f !== "state.json");
268
+ }
269
+
270
+ /**
271
+ * Mark a phase as complete. Records the completion timestamp, key IDs, and an
272
+ * optional summary in state.json. Idempotent: re-checkpointing a phase
273
+ * overwrites its previous summary/IDs but does not duplicate the entry in
274
+ * completed_phases.
275
+ */
276
+ export function scratchpad_checkpoint(
277
+ runId: string,
278
+ phase: ScratchpadPhase,
279
+ data: { ids?: string[]; summary?: string },
280
+ projectRoot?: string,
281
+ ): ScratchpadCheckpoint {
282
+ const root = projectRoot ?? detectWorkspaceRoot();
283
+ const cp = readCheckpointRaw(runId, root) ?? scratchpad_init(runId, root);
284
+
285
+ if (!cp.completed_phases.includes(phase)) {
286
+ cp.completed_phases.push(phase);
287
+ // Keep completed_phases in pipeline order for predictable resume.
288
+ cp.completed_phases.sort((a, b) => PHASE_ORDER.indexOf(a) - PHASE_ORDER.indexOf(b));
289
+ }
290
+ cp.last_phase_at = new Date().toISOString();
291
+ if (data.ids) cp.phase_ids[phase] = data.ids;
292
+ if (data.summary) cp.phase_summaries[phase] = data.summary;
293
+
294
+ writeCheckpointRaw(cp, root);
295
+ return cp;
296
+ }
297
+
298
+ /**
299
+ * Read the checkpoint + all artifact references for resume.
300
+ * Returns null if the run doesn't exist.
301
+ */
302
+ export function scratchpad_resume(runId: string, projectRoot?: string): ScratchpadResume | null {
303
+ const root = projectRoot ?? detectWorkspaceRoot();
304
+ const cp = readCheckpointRaw(runId, root);
305
+ if (!cp) return null;
306
+
307
+ // Find the next phase: the first phase in order not in completed_phases.
308
+ const next = PHASE_ORDER.find((p) => !cp.completed_phases.includes(p)) ?? null;
309
+
310
+ // Gather artifact listing per completed phase.
311
+ const artifacts: Record<string, string[]> = {};
312
+ for (const phase of cp.completed_phases) {
313
+ artifacts[phase] = scratchpad_list(runId, phase, root);
314
+ }
315
+
316
+ return { checkpoint: cp, next_phase: next, artifacts };
317
+ }
318
+
319
+ /**
320
+ * Check whether a phase has already been checkpointed (for idempotent re-run).
321
+ */
322
+ export function scratchpad_phase_done(
323
+ runId: string,
324
+ phase: ScratchpadPhase,
325
+ projectRoot?: string,
326
+ ): boolean {
327
+ const cp = readCheckpointRaw(runId, projectRoot);
328
+ return cp?.completed_phases.includes(phase) ?? false;
329
+ }
330
+
331
+ /**
332
+ * Clear a specific run's scratchpad directory. Used by `--fresh` for a single
333
+ * run. Does not touch other runs.
334
+ */
335
+ export function scratchpad_clear(runId: string, projectRoot?: string): void {
336
+ const root = projectRoot ?? detectWorkspaceRoot();
337
+ const runDir = getRunDir(runId, root);
338
+ if (existsSync(runDir)) rmSync(runDir, { recursive: true, force: true });
339
+ }
package/src/workflow.ts CHANGED
@@ -17,6 +17,16 @@ Think like a real external attacker, not a code reviewer. Technical bugs are che
17
17
 
18
18
  Every lead starts HYPOTHESIS. Nothing reaches CONFIRMED without a proven attacker path and demonstrated impact against a real production target or faithful replica.
19
19
 
20
+ ## Tool Reference
21
+
22
+ **Casefile (state tracking):** CaseAdd, CaseUpdate, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseReport, PromoteFinding
23
+
24
+ **Scratchpad (pipeline artifacts):** ScratchpadInit, ScratchpadResume, ScratchpadCheckpoint, ScratchpadWrite, ScratchpadRead, ScratchpadPhaseDone, ScratchpadClear
25
+
26
+ **Web lookup (research):** web_search, web_fetch, exploit_search, context7, deepwiki, http_request
27
+
28
+ **Subagent dispatch:** \`subagent({agent: "auditor"|"tracer"|"skeptic"|"exploit"|"chain", task: "..."})\` — use this to dispatch specialist agents. Do NOT do the specialist work yourself.
29
+
20
30
  ## Case Lifecycle (State Machine)
21
31
 
22
32
  \`\`\`
@@ -47,7 +57,7 @@ RECON -> HYPOTHESIS --+
47
57
  | Advance To | Required Case Fields | Must Exist on Disk |
48
58
  |-----------|---------------------|--------------------|
49
59
  | HYPOTHESIS -> INVESTIGATING | evidence (observations or initial findings), confidence | Notes on what was observed |
50
- | INVESTIGATING -> **CONFIRMED** | evidence, poc (steps/script), **impact (see below for content requirements)**, severity, **target (host/repo/scope this affects)**, **disconfirmation (your documented attempt to disprove the finding)** | PoC script + run.log exit 0. Optionally, disconfirmation script run.log exit non-0 (finding survived the attempt to disprove). |
60
+ | INVESTIGATING -> **CONFIRMED** | evidence, poc (steps/script), **impact (see below for content requirements)**, severity, **target (host/repo/scope this affects)**, **disconfirmation (your documented attempt to disprove the finding)** | PoC script + exit 0 + **verification_marker present in output** (proves the exploit actually worked, not just that the script ran). Optionally, disconfirmation script run.log exit non-0 (finding survived the attempt to disprove). |
51
61
  | Any -> KILLED | assumptions (why it died) | --- |
52
62
  | CONFIRMED -> REPORTED | Only after CaseReport(id) succeeds | Report file |
53
63
 
@@ -109,22 +119,20 @@ Before promoting to CONFIRMED, the following must be fully answered and document
109
119
 
110
120
  If you cannot name a concrete attacker who gains something they should not have -> do **not** confirm. Stay INVESTIGATING or KILL with documented reason.
111
121
 
112
- ### 1. Disconfirmation Attempt (mandatory field before CONFIRMED)
122
+ ### 1. Disconfirmation (mandatory before CONFIRMED)
113
123
 
114
- Before promoting, you must actively attempt to disprove your own finding.
115
- This is not a formality --- the attempt is documented in the \`disconfirmation\`
116
- field and verified by the optional \`disconfirmation_path\` in PromoteFinding.
124
+ Before promoting to CONFIRMED, the finding must survive an attempt to disprove it. There are two tiers, gated on the auditor's \`confidence\` (severity doesn't exist yet — the exploit agent assigns it only after the PoC runs):
125
+
126
+ **\`confidence: high\` → skeptic subagent (MANDATORY):** You MUST dispatch a skeptic subagent via \`subagent({agent: "skeptic", task: "..."})\` BEFORE the exploit agent runs. The skeptic independently re-reads the source (or re-probes live), verifies the finding is in scope per the program's scope instruction, and tries to disprove it. The skeptic's \`disconfirmation_attempt\` is written into the case's \`disconfirmation\` field — it satisfies this gate and is stronger than self-disconfirmation because a separate agent produced it. If the skeptic says DISPROVEN, the finding is killed directly — no tie-breaker. Do NOT skip this step. Do NOT self-disconfirm high-confidence findings.
127
+
128
+ **Below confidence high → self-disconfirmation:** You must actively attempt to disprove your own finding. Document the attempt in the \`disconfirmation\` field. This is not a formality.
117
129
 
118
130
  **What a disconfirmation attempt looks like:**
119
131
 
120
- - Reproduce the finding under different conditions (different auth, different
121
- config, different network position). If it fails, you disproved the scope.
122
- - Check if the behavior is intentional by testing against documentation or
123
- by trying to get the same result on a known-baseline endpoint.
124
- - Attempt to trigger protections (WAF, CSP, CSRF, rate limits) that would
125
- block the path in production.
126
- - Try to prove the root cause is wrong: can the same behavior be triggered
127
- without the attacker-controlled input you identified?
132
+ - Reproduce the finding under different conditions (different auth, different config, different network position). If it fails, you disproved the scope.
133
+ - Check if the behavior is intentional by testing against documentation or by trying to get the same result on a known-baseline endpoint.
134
+ - Attempt to trigger protections (WAF, CSP, CSRF, rate limits) that would block the path in production.
135
+ - Try to prove the root cause is wrong: can the same behavior be triggered without the attacker-controlled input you identified?
128
136
 
129
137
  **Document the attempt in \`disconfirmation\` field.** Must include:
130
138
  1. What you tried to do to disprove the finding
@@ -133,19 +141,12 @@ field and verified by the optional \`disconfirmation_path\` in PromoteFinding.
133
141
  4. Why you believe the disconfirmation attempt was valid
134
142
 
135
143
  **Strong disconfirmation that passes the gate:**
136
- "Attempted to read /api/users/123 as user B after confirming user A owns
137
- record 123. The endpoint returned 403 for user B, confirming the IDOR
138
- protection works as expected. However, when we modified the request to
139
- include the X-Override-User header seen in admin traffic, the endpoint
140
- returned user A's data. The protection is bypassed via the admin header."
144
+ "Attempted to read /api/users/123 as user B after confirming user A owns record 123. The endpoint returned 403 for user B, confirming the IDOR protection works as expected. However, when we modified the request to include the X-Override-User header seen in admin traffic, the endpoint returned user A's data. The protection is bypassed via the admin header."
141
145
 
142
146
  **Weak disconfirmation:**
143
147
  "Tried to disprove. Could not."
144
148
 
145
- If the disconfirmation script (disconfirmation_path) exits 0, the finding
146
- is considered disproven and promotion is blocked. If you cannot write a
147
- meaningful disconfirmation script, you may not understand the finding well
148
- enough to promote it.
149
+ If the disconfirmation script (\`disconfirmation_path\` in PromoteFinding) exits 0, the finding is considered disproven and promotion is blocked. If you cannot write a meaningful disconfirmation script, you may not understand the finding well enough to promote it.
149
150
 
150
151
  ### 2. Production Path Verification (must be in impact field)
151
152
 
@@ -164,7 +165,7 @@ The **impact** field for CONFIRMED must explicitly answer:
164
165
 
165
166
  You must name the **specific target host/repo** in the target field. If the finding only works on a dev instance with non-default config, document that honestly and consider whether it's KILL-worthy.
166
167
 
167
- ### 2. KILL at Validate stage
168
+ ### 3. KILL at Validate stage
168
169
 
169
170
  Documented intended behavior
170
171
  - Self-XSS / self-DoS only (attacker harms only their own session)
@@ -175,11 +176,11 @@ Documented intended behavior
175
176
  - PoC proves a code path exists but not that any victim asset is affected
176
177
  - Protections in production block the path and are not bypassed
177
178
 
178
- ### 3. Evidence-First Doctrine
179
+ ### 4. Evidence-First Doctrine
179
180
 
180
181
  Every claim must be traceable to observed/reproduced behavior, source code, or documented platform behavior. If evidence is insufficient: state uncertainty and propose the next experiment. Never assume success where verification is incomplete.
181
182
 
182
- ### 4. Impact Gate
183
+ ### 5. Impact Gate
183
184
 
184
185
  Prove at least **one** real attacker-facing violation against a production-viable target:
185
186
 
@@ -194,7 +195,16 @@ Impact text must answer: *who is hurt, what is lost, how the attacker reaches it
194
195
 
195
196
  If impact is theoretical, needs a second unproven bug, or is not yet reachable from the attacker's position -> stay INVESTIGATING (chain it) or KILL.
196
197
 
197
- ### 5. Adversarial Self-Review
198
+ **Severity is derived from PROVEN impact, not guessed.** Do not set severity until the PoC has exited 0 and the output demonstrates the impact. Map severity to what the PoC output actually shows:
199
+ - **critical** = RCE, account takeover, or direct fund theft — proven in PoC output
200
+ - **high** = sensitive data read/write, privilege escalation, SSRF to internal services — proven in PoC output
201
+ - **medium** = limited data exposure, XSS on sensitive page, IDOR on non-critical resources — proven in PoC output
202
+ - **low** = info leak, open redirect, self-only impact with a victim path — proven but minimal harm
203
+ - **info** = best-practice gap, no demonstrated impact
204
+
205
+ "Could lead to" / "may allow" / "theoretically" = NOT proven. Drop to the level the PoC output actually demonstrates. Under-claiming is safe; over-claiming gets the finding rejected at triage.
206
+
207
+ ### 6. Adversarial Self-Review
198
208
 
199
209
  1. Why this might NOT be a vulnerability.
200
210
  2. Alternative explanations for the observation.
@@ -202,7 +212,7 @@ If impact is theoretical, needs a second unproven bug, or is not yet reachable f
202
212
  4. What blocks a real attacker in production today and whether each is bypassed.
203
213
  5. Would a program triage reject this as informative/N/A?
204
214
 
205
- ### 6. Root Cause -> Boundary -> Impact
215
+ ### 7. Root Cause -> Boundary -> Impact
206
216
 
207
217
  \`\`\`
208
218
  Entry (attacker-controlled) -> Code path -> Trust boundary crossed -> Victim impact