@xaccefy/pi-casefile 0.7.0 → 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.7.0",
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",
package/src/index.ts CHANGED
@@ -7,7 +7,6 @@
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";
@@ -122,6 +121,11 @@ const PromoteSchema = Type.Object(
122
121
  poc_path: Type.String({
123
122
  description: "Absolute path to the PoC script on disk",
124
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
+ }),
125
129
  disconfirmation_path: Type.Optional(
126
130
  Type.String({
127
131
  description:
@@ -248,7 +252,8 @@ const SCRATCHPAD_PHASES = [
248
252
 
249
253
  const ScratchpadPhaseSchema = Type.String({
250
254
  enum: [...SCRATCHPAD_PHASES],
251
- description: "Pipeline phase: recon | hunt | gapfil | trace | skeptic | validate | chain | patch | report",
255
+ description:
256
+ "Pipeline phase: recon | hunt | gapfil | trace | skeptic | validate | chain | patch | report",
252
257
  });
253
258
 
254
259
  const ScratchpadInitSchema = Type.Object(
@@ -269,7 +274,11 @@ const ScratchpadCheckpointSchema = Type.Object(
269
274
  {
270
275
  run_id: Type.String({ description: "Run identifier" }),
271
276
  phase: ScratchpadPhaseSchema,
272
- ids: Type.Optional(Type.Array(Type.String(), { description: "Key IDs produced by this phase (case IDs, finding IDs)" })),
277
+ ids: Type.Optional(
278
+ Type.Array(Type.String(), {
279
+ description: "Key IDs produced by this phase (case IDs, finding IDs)",
280
+ }),
281
+ ),
273
282
  summary: Type.Optional(Type.String({ description: "One-line summary of phase completion" })),
274
283
  },
275
284
  { additionalProperties: false },
@@ -279,7 +288,9 @@ const ScratchpadWriteSchema = Type.Object(
279
288
  {
280
289
  run_id: Type.String({ description: "Run identifier" }),
281
290
  phase: ScratchpadPhaseSchema,
282
- artifact_name: Type.String({ description: "Artifact filename (sanitized; path traversal is blocked)" }),
291
+ artifact_name: Type.String({
292
+ description: "Artifact filename (sanitized; path traversal is blocked)",
293
+ }),
283
294
  content: Type.String({ description: "Artifact content to write" }),
284
295
  },
285
296
  { additionalProperties: false },
@@ -415,9 +426,7 @@ class CasefileDashboard {
415
426
 
416
427
  if (this.records.length === 0) {
417
428
  lines.push("");
418
- lines.push(
419
- ` ${th.fg("dim", "No active security cases. Ask the agent to CaseAdd findings!")}`,
420
- );
429
+ lines.push(` ${th.fg("dim", "No security cases yet. Ask the agent to CaseAdd findings!")}`);
421
430
  } else {
422
431
  lines.push("");
423
432
  for (const r of this.records) {
@@ -520,11 +529,7 @@ export const XP_MODE_ENV = "PI_XP_MODE";
520
529
  export type XpMode = "on" | "off";
521
530
 
522
531
  export function getXpModeStatePath(): string {
523
- try {
524
- return join(dirname(getCasefilePath()), "xp-mode");
525
- } catch {
526
- return join(homedir(), ".pi", "xp-mode");
527
- }
532
+ return join(dirname(getCasefilePath()), "xp-mode");
528
533
  }
529
534
 
530
535
  export function readXpMode(
@@ -724,13 +729,14 @@ export default function casefileExtension(pi: ExtensionAPI) {
724
729
  name: "PromoteFinding",
725
730
  label: "Promote Finding",
726
731
  description:
727
- "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).",
728
733
  promptSnippet: "Run a PoC and promote an investigating case to confirmed",
729
734
  promptGuidelines: [
730
735
  "Use PromoteFinding when an investigating case has a concrete PoC script on disk and you are ready to prove it.",
731
736
  "The case must already have status='investigating' and non-empty poc, evidence, impact, severity, target, and disconfirmation fields.",
732
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).",
733
- "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.",
734
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.",
735
741
  "Do not use CaseUpdate to set status='confirmed' directly — it is rejected. Always use PromoteFinding.",
736
742
  ],
@@ -741,6 +747,26 @@ export default function casefileExtension(pi: ExtensionAPI) {
741
747
  // 30s (plus first-time image pull), so fail cheap when the case can't
742
748
  // advance anyway (missing, wrong status, missing required fields).
743
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
+
744
770
  const run = runPoc(params.poc_path as string, params.local !== true);
745
771
 
746
772
  // Fail closed without throwing: non-zero PoC must leave the case investigating.
@@ -758,6 +784,28 @@ export default function casefileExtension(pi: ExtensionAPI) {
758
784
  };
759
785
  }
760
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
+
761
809
  // Run disconfirmation script if provided — must exit NON-0 (finding survived the attempt to disprove).
762
810
  let disconfirmationRun: PocRun | undefined;
763
811
  if (params.disconfirmation_path) {
@@ -1105,7 +1153,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
1105
1153
  name: "CaseReport",
1106
1154
  label: "Write Case Report",
1107
1155
  description:
1108
- "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.",
1109
1157
  promptSnippet: "Generate a bounty-style markdown report from a case",
1110
1158
  promptGuidelines: [
1111
1159
  "Use CaseReport only for confirmed or already reported cases. Keep hypotheses and investigating cases in the ledger until proof is captured.",
@@ -1180,7 +1228,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
1180
1228
  content: [
1181
1229
  {
1182
1230
  type: "text",
1183
- text: `Scratchpad initialized for run ${cp.run_id}.\nCompleted phases: ${cp.completed_phases.length ? cp.completed_phases.join(", ") : "none"}\nDone: ${cp.done}`,
1231
+ text: `Scratchpad initialized for run ${cp.run_id}.\nCompleted phases: ${cp.completed_phases.length ? cp.completed_phases.join(", ") : "none"}`,
1184
1232
  },
1185
1233
  ],
1186
1234
  details: { checkpoint: cp },
@@ -1197,13 +1245,8 @@ export default function casefileExtension(pi: ExtensionAPI) {
1197
1245
  },
1198
1246
 
1199
1247
  renderResult(result, _opts, theme) {
1200
- const cp = (result.details as { checkpoint: { run_id: string; done: boolean } } | undefined)?.checkpoint;
1201
- return new Text(
1202
- theme.fg("success", "✓ ") +
1203
- `ScratchpadInit ${cp?.run_id ?? ""}${cp?.done ? " (done)" : ""}`,
1204
- 0,
1205
- 0,
1206
- );
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);
1207
1250
  },
1208
1251
  });
1209
1252
 
@@ -1243,8 +1286,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
1243
1286
  text:
1244
1287
  `Resume run ${cp.run_id}:\n` +
1245
1288
  `Completed phases: ${cp.completed_phases.length ? cp.completed_phases.join(", ") : "none"}\n` +
1246
- `Next phase: ${resume.next_phase ?? "none (run is done)"}\n` +
1247
- `Done: ${cp.done}`,
1289
+ `Next phase: ${resume.next_phase ?? "none (run is done)"}`,
1248
1290
  },
1249
1291
  ],
1250
1292
  details: { resume },
@@ -1288,11 +1330,10 @@ export default function casefileExtension(pi: ExtensionAPI) {
1288
1330
  parameters: ScratchpadCheckpointSchema,
1289
1331
 
1290
1332
  async execute(_id, params, _signal, _onUpdate, _ctx) {
1291
- const cp = scratchpad_checkpoint(
1292
- params.run_id as string,
1293
- params.phase as ScratchpadPhase,
1294
- { ids: params.ids as string[] | undefined, summary: params.summary as string | undefined },
1295
- );
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
+ });
1296
1337
  return {
1297
1338
  content: [
1298
1339
  {
@@ -1316,7 +1357,9 @@ export default function casefileExtension(pi: ExtensionAPI) {
1316
1357
  },
1317
1358
 
1318
1359
  renderResult(result, _opts, theme) {
1319
- const cp = (result.details as { checkpoint: { run_id: string; completed_phases: string[] } } | undefined)?.checkpoint;
1360
+ const cp = (
1361
+ result.details as { checkpoint: { run_id: string; completed_phases: string[] } } | undefined
1362
+ )?.checkpoint;
1320
1363
  return new Text(
1321
1364
  theme.fg("success", "✓ ") +
1322
1365
  `ScratchpadCheckpoint ${cp?.run_id ?? ""} — ${cp?.completed_phases.length ?? 0} phases done`,
@@ -1422,7 +1465,9 @@ export default function casefileExtension(pi: ExtensionAPI) {
1422
1465
  renderResult(result, _opts, theme) {
1423
1466
  const found = (result.details as { found?: boolean } | undefined)?.found;
1424
1467
  return new Text(
1425
- found ? theme.fg("success", "✓ ScratchpadRead") : theme.fg("warning", "↷ ScratchpadRead — not found"),
1468
+ found
1469
+ ? theme.fg("success", "✓ ScratchpadRead")
1470
+ : theme.fg("warning", "↷ ScratchpadRead — not found"),
1426
1471
  0,
1427
1472
  0,
1428
1473
  );
@@ -1444,10 +1489,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
1444
1489
  parameters: ScratchpadPhaseDoneSchema,
1445
1490
 
1446
1491
  async execute(_id, params, _signal, _onUpdate, _ctx) {
1447
- const done = scratchpad_phase_done(
1448
- params.run_id as string,
1449
- params.phase as ScratchpadPhase,
1450
- );
1492
+ const done = scratchpad_phase_done(params.run_id as string, params.phase as ScratchpadPhase);
1451
1493
  return {
1452
1494
  content: [
1453
1495
  {
@@ -1471,7 +1513,9 @@ export default function casefileExtension(pi: ExtensionAPI) {
1471
1513
  renderResult(result, _opts, theme) {
1472
1514
  const done = (result.details as { done?: boolean } | undefined)?.done;
1473
1515
  return new Text(
1474
- done ? theme.fg("success", "✓ ScratchpadPhaseDone — done") : theme.fg("warning", "↷ ScratchpadPhaseDone — not done"),
1516
+ done
1517
+ ? theme.fg("success", "✓ ScratchpadPhaseDone — done")
1518
+ : theme.fg("warning", "↷ ScratchpadPhaseDone — not done"),
1475
1519
  0,
1476
1520
  0,
1477
1521
  );
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),
package/src/scratchpad.ts CHANGED
@@ -47,8 +47,6 @@ export interface ScratchpadCheckpoint {
47
47
  phase_ids: Record<ScratchpadPhase, string[]>;
48
48
  /** Free-form summary per phase, set by checkpoint(). */
49
49
  phase_summaries: Record<ScratchpadPhase, string>;
50
- /** Whether the run is fully complete. */
51
- done: boolean;
52
50
  }
53
51
 
54
52
  export interface ScratchpadResume {
@@ -98,7 +96,9 @@ let scratchpadRootOverride: string | undefined;
98
96
  function detectWorkspaceRoot(): string {
99
97
  if (scratchpadRootOverride) return scratchpadRootOverride;
100
98
 
101
- const envs = ["XPI_SCRATCHPAD_ROOT", "PI_WORKSPACE_ROOT", "GITHUB_WORKSPACE", "PWD"];
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
102
  for (const e of envs) {
103
103
  const v = process.env[e];
104
104
  if (v) return resolve(v);
@@ -125,9 +125,24 @@ export function getScratchpadRoot(projectRoot?: string): string {
125
125
  return join(root, SCRATCHPAD_DIR);
126
126
  }
127
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
+
128
143
  /** The directory for a specific run. */
129
144
  export function getRunDir(runId: string, projectRoot?: string): string {
130
- return join(getScratchpadRoot(projectRoot), runId);
145
+ return join(getScratchpadRoot(projectRoot), sanitizeRunId(runId));
131
146
  }
132
147
 
133
148
  /** The state.json path for a run. */
@@ -146,7 +161,6 @@ function emptyCheckpoint(runId: string, projectRoot: string): ScratchpadCheckpoi
146
161
  completed_phases: [],
147
162
  phase_ids: {} as Record<ScratchpadPhase, string[]>,
148
163
  phase_summaries: {} as Record<ScratchpadPhase, string>,
149
- done: false,
150
164
  };
151
165
  }
152
166
 
@@ -323,37 +337,3 @@ export function scratchpad_clear(runId: string, projectRoot?: string): void {
323
337
  const runDir = getRunDir(runId, root);
324
338
  if (existsSync(runDir)) rmSync(runDir, { recursive: true, force: true });
325
339
  }
326
-
327
- /**
328
- * Clear the entire scratchpad directory (all runs). Used by `--fresh` with no
329
- * run ID. Use with care.
330
- */
331
- export function scratchpad_clear_all(projectRoot?: string): void {
332
- const root = projectRoot ?? detectWorkspaceRoot();
333
- const dir = getScratchpadRoot(root);
334
- if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
335
- }
336
-
337
- /**
338
- * List all run IDs in the scratchpad (for resume selection).
339
- */
340
- export function scratchpad_list_runs(projectRoot?: string): string[] {
341
- const root = projectRoot ?? detectWorkspaceRoot();
342
- const dir = getScratchpadRoot(root);
343
- if (!existsSync(dir)) return [];
344
- return readdirSync(dir, { withFileTypes: true })
345
- .filter((e) => e.isDirectory())
346
- .map((e) => e.name)
347
- .sort();
348
- }
349
-
350
- /**
351
- * Mark the run as fully done. Prevents resume from re-entering.
352
- */
353
- export function scratchpad_finish(runId: string, projectRoot?: string): ScratchpadCheckpoint {
354
- const root = projectRoot ?? detectWorkspaceRoot();
355
- const cp = readCheckpointRaw(runId, root) ?? scratchpad_init(runId, root);
356
- cp.done = true;
357
- writeCheckpointRaw(cp, root);
358
- return cp;
359
- }
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,21 +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
-
150
- **Adversarial disconfirmation (skeptic subagent):** For findings at severity >= high, a dedicated skeptic subagent independently re-reads the source and tries to disprove the finding BEFORE the exploit agent runs. The skeptic's \`disconfirmation_attempt\` is written into this \`disconfirmation\` field by the harness — 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. Self-disconfirmation still applies for findings below high severity.
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.
151
150
 
152
151
  ### 2. Production Path Verification (must be in impact field)
153
152
 
@@ -166,7 +165,7 @@ The **impact** field for CONFIRMED must explicitly answer:
166
165
 
167
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.
168
167
 
169
- ### 2. KILL at Validate stage
168
+ ### 3. KILL at Validate stage
170
169
 
171
170
  Documented intended behavior
172
171
  - Self-XSS / self-DoS only (attacker harms only their own session)
@@ -177,11 +176,11 @@ Documented intended behavior
177
176
  - PoC proves a code path exists but not that any victim asset is affected
178
177
  - Protections in production block the path and are not bypassed
179
178
 
180
- ### 3. Evidence-First Doctrine
179
+ ### 4. Evidence-First Doctrine
181
180
 
182
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.
183
182
 
184
- ### 4. Impact Gate
183
+ ### 5. Impact Gate
185
184
 
186
185
  Prove at least **one** real attacker-facing violation against a production-viable target:
187
186
 
@@ -196,7 +195,16 @@ Impact text must answer: *who is hurt, what is lost, how the attacker reaches it
196
195
 
197
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.
198
197
 
199
- ### 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
200
208
 
201
209
  1. Why this might NOT be a vulnerability.
202
210
  2. Alternative explanations for the observation.
@@ -204,7 +212,7 @@ If impact is theoretical, needs a second unproven bug, or is not yet reachable f
204
212
  4. What blocks a real attacker in production today and whether each is bypassed.
205
213
  5. Would a program triage reject this as informative/N/A?
206
214
 
207
- ### 6. Root Cause -> Boundary -> Impact
215
+ ### 7. Root Cause -> Boundary -> Impact
208
216
 
209
217
  \`\`\`
210
218
  Entry (attacker-controlled) -> Code path -> Trust boundary crossed -> Victim impact