@xaccefy/pi-casefile 0.7.6 → 0.8.0

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/src/ledger.ts CHANGED
@@ -11,11 +11,20 @@
11
11
  */
12
12
 
13
13
  import { createHash, randomUUID } from "node:crypto";
14
- import { type Dirent, existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
14
+ import {
15
+ type Dirent,
16
+ existsSync,
17
+ mkdirSync,
18
+ readdirSync,
19
+ readFileSync,
20
+ statSync,
21
+ writeFileSync,
22
+ } from "node:fs";
15
23
  import { basename, dirname, join, resolve } from "node:path";
16
24
  import {
25
+ findWorkspaceRoot,
17
26
  getScratchpadRoot,
18
- type ScratchpadPhase,
27
+ PHASE_ORDER,
19
28
  scratchpad_read,
20
29
  scratchpad_resume,
21
30
  } from "./scratchpad.ts";
@@ -42,6 +51,65 @@ export type CaseSeverity = (typeof SEVERITY_VALUES)[number];
42
51
  export const PRIORITY_VALUES = ["P0", "P1", "P2", "P3", "P4"] as const;
43
52
  export type CasePriority = (typeof PRIORITY_VALUES)[number];
44
53
 
54
+ /** Cap on hashed evidence artifacts (10 MiB) — keeps readFileSync bounded. */
55
+ const EVIDENCE_ARTIFACT_MAX_BYTES = 10 * 1024 * 1024;
56
+
57
+ /** Role-typed evidence roles (Black-cat style). cleanup = engagement cleanup item. */
58
+ export const EVIDENCE_ROLE_VALUES = [
59
+ "observation",
60
+ "reproduction",
61
+ "impact",
62
+ "refutation",
63
+ "cleanup",
64
+ ] as const;
65
+ export type EvidenceRole = (typeof EVIDENCE_ROLE_VALUES)[number];
66
+
67
+ /**
68
+ * One artifact-backed evidence record. The case's `evidence` prose is a
69
+ * summary; the load-bearing chain is these items: role + artifact SHA-256.
70
+ * A confirmed finding must trace back to a reproduction item recorded by the
71
+ * PoC gate itself (not agent prose).
72
+ */
73
+ export type EvidenceItem = {
74
+ id: string;
75
+ caseId: string;
76
+ role: EvidenceRole;
77
+ /** Basename of the artifact backing this evidence (path-leak guard: full path never stored). */
78
+ artifactPath?: string;
79
+ /** SHA-256 of the artifact file. */
80
+ sha256?: string;
81
+ summary: string;
82
+ createdAt: string;
83
+ };
84
+
85
+ /**
86
+ * Coverage scope of a tested verdict:
87
+ * - `wide` — the verdict is a property of the whole deployment/account/host,
88
+ * recorded ONCE and applied to every asset of that deployment (do NOT
89
+ * re-test per asset; a wide cell covers all assets in the case).
90
+ * - `local` — specific to this one asset (endpoint, resource, service).
91
+ */
92
+ export const COVERAGE_SCOPE_VALUES = ["wide", "local"] as const;
93
+ export type CoverageScope = (typeof COVERAGE_SCOPE_VALUES)[number];
94
+
95
+ /**
96
+ * One tested (asset × attack-class) cell. The note's existence marks the cell
97
+ * tested — for both outcomes (found or clean). Clean results are just as
98
+ * load-bearing: they are what make "every class is COVERED" machine-checkable.
99
+ */
100
+ export type CoverageItem = {
101
+ id: string;
102
+ caseId: string;
103
+ asset: string;
104
+ /** Attack class tested (e.g. sql-injection, xss, idor, ssti, ...). */
105
+ class: string;
106
+ scope: CoverageScope;
107
+ /** Short note: techniques tried · result · key gap (injected into later context). */
108
+ note: string;
109
+ testedBy?: string;
110
+ createdAt: string;
111
+ };
112
+
45
113
  /** Typed relationship kinds for CaseLink. Input values accepted by the tool. */
46
114
  export const LINK_KIND_VALUES = [
47
115
  "duplicate",
@@ -107,6 +175,8 @@ export type CaseRecord = {
107
175
  tags?: string[];
108
176
  /** Explicit assumptions or unknowns to avoid overstating exploitability. */
109
177
  assumptions?: string[];
178
+ /** Falsification conditions — what would disprove this hypothesis (required on new cases). */
179
+ disproveIf?: string[];
110
180
  /** Agent's documented attempt to disprove the finding (required before CONFIRMED). */
111
181
  disconfirmation?: string;
112
182
  /** Verification of an on-disk PoC run (set only by promoteFindingResult). */
@@ -125,12 +195,22 @@ export type CaseRecord = {
125
195
  output?: string;
126
196
  sandbox: boolean;
127
197
  };
198
+ /** Verification of a control-target run (set only by promoteFindingResult; anti-cheat gate). */
199
+ controlVerified?: {
200
+ path: string;
201
+ exitCode: number;
202
+ ranAt: string;
203
+ output?: string;
204
+ sandbox: boolean;
205
+ };
128
206
  /** ISO timestamp when CaseContext first wrote the context bundle. */
129
207
  reportedAt?: string;
130
208
  /** Path to the final report file (set by writeCaseContext; the reporter agent writes the file). */
131
209
  reportPath?: string;
132
- /** Flat list of linked case IDs (back-compat; derived from linkedCases). */
133
- linkedCaseIds: string[];
210
+ /** Role-typed, artifact-backed evidence items (separate table). */
211
+ evidenceItems: EvidenceItem[];
212
+ /** Tested (asset × attack-class) coverage cells (separate table). */
213
+ coverageItems: CoverageItem[];
134
214
  /** Linked cases with their relationship kind, from this case's perspective. */
135
215
  linkedCases: { id: string; kind: string }[];
136
216
  createdAt: string;
@@ -156,14 +236,16 @@ export type CaseInput = {
156
236
  blockers?: string[];
157
237
  tags?: string[];
158
238
  assumptions?: string[];
239
+ /** Falsification conditions — what would disprove this hypothesis (required on new cases). */
240
+ disproveIf?: string[];
159
241
  /** Agent's documented attempt to disprove the finding (required before CONFIRMED). */
160
242
  disconfirmation?: string;
161
243
  };
162
244
 
163
245
  type NormalizedCaseInput = Partial<CaseInput> & {
164
- linkedCaseIds?: string[];
165
246
  pocVerified?: CaseRecord["pocVerified"];
166
247
  disconfirmationVerified?: CaseRecord["disconfirmationVerified"];
248
+ controlVerified?: CaseRecord["controlVerified"];
167
249
  reportedAt?: string;
168
250
  reportPath?: string;
169
251
  };
@@ -234,21 +316,12 @@ function stableShortId(input: string): string {
234
316
  function detectWorkspaceRoot(): string {
235
317
  // PWD is deliberately excluded: it is shell-set, can be stale or forged in
236
318
  // spawned processes, and disagree with the real cwd. Explicit overrides only,
237
- // then walk up from the actual cwd.
238
- const envs = ["CASEFILE_WORKSPACE_ROOT", "PI_WORKSPACE_ROOT", "GITHUB_WORKSPACE"];
239
- for (const e of envs) {
240
- const v = process.env[e]?.trim();
241
- if (v) return resolve(v);
242
- }
243
-
244
- let curr = resolve(process.cwd());
245
- for (let i = 0; i < 20; i++) {
246
- if (existsSync(join(curr, ".git"))) return curr;
247
- const parent = dirname(curr);
248
- if (parent === curr) break;
249
- curr = parent;
250
- }
251
- return resolve(process.cwd());
319
+ // then walk up from the actual cwd (.git only — the ledger predates its
320
+ // package.json, unlike the scratchpad).
321
+ return findWorkspaceRoot(
322
+ ["CASEFILE_WORKSPACE_ROOT", "PI_WORKSPACE_ROOT", "GITHUB_WORKSPACE"],
323
+ [".git"],
324
+ );
252
325
  }
253
326
 
254
327
  export function getCasefilePath(): string {
@@ -347,6 +420,43 @@ function getDb(): DatabaseSync {
347
420
  if (!caseCols.some((c) => c.name === "disconfirmation_verified_json")) {
348
421
  db.exec("ALTER TABLE cases ADD COLUMN disconfirmation_verified_json TEXT");
349
422
  }
423
+ if (!caseCols.some((c) => c.name === "disprove_if_json")) {
424
+ db.exec("ALTER TABLE cases ADD COLUMN disprove_if_json TEXT");
425
+ }
426
+ if (!caseCols.some((c) => c.name === "control_verified_json")) {
427
+ db.exec("ALTER TABLE cases ADD COLUMN control_verified_json TEXT");
428
+ }
429
+
430
+ // Role-typed, artifact-backed evidence items (Black-cat style evidence chain).
431
+ db.exec(`
432
+ CREATE TABLE IF NOT EXISTS evidence_items (
433
+ id TEXT PRIMARY KEY,
434
+ case_id TEXT NOT NULL,
435
+ role TEXT NOT NULL,
436
+ artifact_path TEXT,
437
+ sha256 TEXT,
438
+ summary TEXT NOT NULL,
439
+ created_at TEXT NOT NULL,
440
+ FOREIGN KEY (case_id) REFERENCES cases(id) ON DELETE CASCADE
441
+ )
442
+ `);
443
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_evidence_items_case ON evidence_items(case_id)`);
444
+
445
+ // Coverage matrix: tested (asset × attack-class) cells with wide/local scope.
446
+ db.exec(`
447
+ CREATE TABLE IF NOT EXISTS coverage_items (
448
+ id TEXT PRIMARY KEY,
449
+ case_id TEXT NOT NULL,
450
+ asset TEXT NOT NULL,
451
+ class TEXT NOT NULL,
452
+ scope TEXT NOT NULL CHECK (scope IN ('wide', 'local')),
453
+ note TEXT NOT NULL,
454
+ tested_by TEXT,
455
+ created_at TEXT NOT NULL,
456
+ FOREIGN KEY (case_id) REFERENCES cases(id) ON DELETE CASCADE
457
+ )
458
+ `);
459
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_coverage_items_case ON coverage_items(case_id)`);
350
460
 
351
461
  // Indexes
352
462
  db.exec(`CREATE INDEX IF NOT EXISTS idx_cases_status ON cases(status)`);
@@ -359,7 +469,12 @@ function getDb(): DatabaseSync {
359
469
  }
360
470
 
361
471
  // Helper to map DB row to CaseRecord
362
- function mapRow(row: any, linkedCases: { id: string; kind: string }[] = []): CaseRecord {
472
+ function mapRow(
473
+ row: any,
474
+ linkedCases: { id: string; kind: string }[] = [],
475
+ evidenceItems: EvidenceItem[] = [],
476
+ coverageItems: CoverageItem[] = [],
477
+ ): CaseRecord {
363
478
  /** Safely parse a JSON column; returns [] for arrays, undefined for objects. */
364
479
  const safeParseArray = (raw: unknown): string[] => {
365
480
  if (!raw) return [];
@@ -400,18 +515,41 @@ function mapRow(row: any, linkedCases: { id: string; kind: string }[] = []): Cas
400
515
  blockers: safeParseArray(row.blockers_json),
401
516
  tags: safeParseArray(row.tags_json),
402
517
  assumptions: safeParseArray(row.assumptions_json),
518
+ disproveIf: safeParseArray(row.disprove_if_json),
403
519
  disconfirmation: row.disconfirmation || undefined,
404
520
  pocVerified: safeParseObject(row.poc_verified_json),
405
521
  disconfirmationVerified: safeParseObject(row.disconfirmation_verified_json),
522
+ controlVerified: safeParseObject(row.control_verified_json),
406
523
  reportedAt: row.reported_at || undefined,
407
524
  reportPath: row.report_path || undefined,
525
+ evidenceItems,
526
+ coverageItems,
408
527
  linkedCases,
409
- linkedCaseIds: linkedCases.map((l) => l.id),
410
528
  createdAt: row.created_at,
411
529
  updatedAt: row.updated_at,
412
530
  };
413
531
  }
414
532
 
533
+ /** Batch-fetch per-case item tables (evidence / coverage) for a set of ids. */
534
+ function fetchItemMap<T extends { caseId: string }>(
535
+ db: DatabaseSync,
536
+ table: "evidence_items" | "coverage_items",
537
+ ids: string[],
538
+ ): Map<string, T[]> {
539
+ if (ids.length === 0) return new Map();
540
+ const placeholders = ids.map(() => "?").join(",");
541
+ const rows = db
542
+ .prepare(`SELECT * FROM ${table} WHERE case_id IN (${placeholders}) ORDER BY created_at`)
543
+ .all(...ids) as T[];
544
+ const map = new Map<string, T[]>();
545
+ for (const row of rows) {
546
+ const bucket = map.get(row.caseId);
547
+ if (bucket) bucket.push(row);
548
+ else map.set(row.caseId, [row]);
549
+ }
550
+ return map;
551
+ }
552
+
415
553
  // ── Read operations ──────────────────────────────────────────────────
416
554
 
417
555
  export function readCasefile(): CaseRecord[] {
@@ -431,7 +569,17 @@ export function readCasefile(): CaseRecord[] {
431
569
  linkMap.get(link.source_id)?.push({ id: link.target_id, kind: link.kind });
432
570
  }
433
571
 
434
- return rows.map((row: any) => mapRow(row, linkMap.get(row.id) ?? []));
572
+ const ids = rows.map((r: any) => r.id);
573
+ const evidenceMap = fetchItemMap<EvidenceItem>(db, "evidence_items", ids);
574
+ const coverageMap = fetchItemMap<CoverageItem>(db, "coverage_items", ids);
575
+ return rows.map((row: any) =>
576
+ mapRow(
577
+ row,
578
+ linkMap.get(row.id) ?? [],
579
+ evidenceMap.get(row.id) ?? [],
580
+ coverageMap.get(row.id) ?? [],
581
+ ),
582
+ );
435
583
  }
436
584
 
437
585
  /**
@@ -455,10 +603,18 @@ export function getCaseById(id: string): CaseRecord | undefined {
455
603
 
456
604
  const linkStmt = db.prepare("SELECT target_id, kind FROM case_links WHERE source_id = ?");
457
605
  const links = linkStmt.all(id) as { target_id: string; kind: string }[];
606
+ const evidence = db
607
+ .prepare("SELECT * FROM evidence_items WHERE case_id = ? ORDER BY created_at")
608
+ .all(id) as EvidenceItem[];
609
+ const coverage = db
610
+ .prepare("SELECT * FROM coverage_items WHERE case_id = ? ORDER BY created_at")
611
+ .all(id) as CoverageItem[];
458
612
 
459
613
  return mapRow(
460
614
  row,
461
615
  links.map((l) => ({ id: l.target_id, kind: l.kind })),
616
+ evidence,
617
+ coverage,
462
618
  );
463
619
  }
464
620
 
@@ -466,6 +622,15 @@ export function getCaseById(id: string): CaseRecord | undefined {
466
622
 
467
623
  function validateCase(record: CaseRecord): void {
468
624
  if (!record.title.trim()) throw new Error("Case title cannot be empty");
625
+ // Falsification conditions are load-bearing: they are required at creation
626
+ // and must not be erasable later (CaseUpdate({ disproveIf: [] }) would wipe
627
+ // the hypothesis's falsifiability). Re-check on every write.
628
+ if (record.status !== "reported" && !(record.disproveIf ?? []).some((d) => d.trim())) {
629
+ throw new Error(
630
+ "Cases require disproveIf — falsification conditions (what would disprove this hypothesis). " +
631
+ "They cannot be cleared once set.",
632
+ );
633
+ }
469
634
  // Keep this gate in lockstep with promoteFindingResult: a case may only be
470
635
  // CONFIRMED when it has evidence, a PoC, demonstrated impact, and a severity.
471
636
  if (
@@ -502,6 +667,28 @@ function validateCase(record: CaseRecord): void {
502
667
  }
503
668
  }
504
669
 
670
+ /**
671
+ * Kill-reason vocabulary — a kill must name one of these (or carry refutation
672
+ * evidence). Single source of truth: the ledger gate AND the injected workflow
673
+ * text (workflow.ts imports this) must not drift apart.
674
+ */
675
+ export const KILL_REASON_VALUES = [
676
+ "intended_behavior",
677
+ "duplicate",
678
+ "framework_protection",
679
+ "exploit_unreliable",
680
+ "insufficient_impact",
681
+ "environmental_issue",
682
+ "not_applicable",
683
+ "out_of_scope",
684
+ "skeptic-disproven",
685
+ "no_attack_path",
686
+ "refuted",
687
+ ] as const;
688
+ export type KillReason = (typeof KILL_REASON_VALUES)[number];
689
+
690
+ const KILL_REASON_PATTERN = new RegExp(`\\b(${KILL_REASON_VALUES.join("|")})\\b`, "i");
691
+
505
692
  function validateTransition(
506
693
  from: CaseStatus,
507
694
  to: CaseStatus,
@@ -521,9 +708,28 @@ function validateTransition(
521
708
  );
522
709
  }
523
710
 
524
- if (to === "killed") return;
525
711
  if (to === "blocked") return;
526
712
 
713
+ if (to === "killed") {
714
+ // Black-cat rule: a kill must be justified. Valid iff (a) a refutation
715
+ // evidence item exists for this case, or (b) the update states a kill
716
+ // reason from the KILLED catalog vocabulary (matches workflow.ts).
717
+ const items = current ? listEvidenceItems(current.id) : [];
718
+ if (!items.some((e) => e.role === "refutation")) {
719
+ const text = [update.nextStep, (update.assumptions ?? []).join(" "), update.evidence]
720
+ .filter(Boolean)
721
+ .join(" ");
722
+ if (!KILL_REASON_PATTERN.test(text)) {
723
+ throw new Error(
724
+ "Cannot kill without justification: add refutation evidence (EvidenceAdd role=refutation) " +
725
+ "or state a kill reason in assumptions/nextStep (intended_behavior, duplicate, " +
726
+ "framework_protection, out_of_scope, skeptic-disproven, no_attack_path, ...)",
727
+ );
728
+ }
729
+ }
730
+ return;
731
+ }
732
+
527
733
  type Rule = (u: CaseUpdate, current?: CaseRecord) => string | null;
528
734
 
529
735
  // Transition rules must consult both the update payload AND the current record.
@@ -577,6 +783,14 @@ function validateNewCaseInput(input: CaseInput): void {
577
783
  "New cases must start as hypothesis or investigating; promote with CaseUpdate after validation",
578
784
  );
579
785
  }
786
+ // Black-cat style falsification: a hypothesis that cannot name what would
787
+ // disprove it is not a hypothesis yet. Required at creation (update later).
788
+ if (!input.disproveIf?.length || !input.disproveIf.some((d) => d.trim())) {
789
+ throw new Error(
790
+ "New cases require disproveIf — falsification conditions (what would disprove this hypothesis). " +
791
+ "Update them later via CaseUpdate if the picture changes.",
792
+ );
793
+ }
580
794
  if (input.status === "investigating") {
581
795
  if (!input.evidence) {
582
796
  throw new Error("New investigating cases require evidence (source→sink trace)");
@@ -613,16 +827,19 @@ function buildRecord(input: NormalizedCaseInput, existing?: CaseRecord): CaseRec
613
827
  blockers: normalizeList(input.blockers ?? existing?.blockers),
614
828
  tags: normalizeList(input.tags ?? existing?.tags),
615
829
  assumptions: normalizeList(input.assumptions ?? existing?.assumptions),
830
+ disproveIf: normalizeList(input.disproveIf ?? existing?.disproveIf),
616
831
  pocVerified: input.pocVerified ?? existing?.pocVerified,
617
832
  disconfirmation:
618
833
  input.disconfirmation !== undefined
619
834
  ? normalizeText(input.disconfirmation)
620
835
  : existing?.disconfirmation,
621
836
  disconfirmationVerified: input.disconfirmationVerified ?? existing?.disconfirmationVerified,
837
+ controlVerified: input.controlVerified ?? existing?.controlVerified,
622
838
  reportedAt: input.reportedAt ?? existing?.reportedAt,
623
839
  reportPath: input.reportPath ?? existing?.reportPath,
840
+ evidenceItems: existing?.evidenceItems ?? [],
841
+ coverageItems: existing?.coverageItems ?? [],
624
842
  linkedCases: existing?.linkedCases ?? [],
625
- linkedCaseIds: existing?.linkedCaseIds ?? [],
626
843
  createdAt: existing?.createdAt ?? timestamp,
627
844
  updatedAt: timestamp,
628
845
  };
@@ -877,13 +1094,13 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
877
1094
  id, title, status, confidence, severity, priority, target, endpoint, bugClass,
878
1095
  summary, evidence, impact, nextStep, poc, remediation,
879
1096
  references_json, blockers_json, tags_json, assumptions_json, poc_verified_json,
880
- disconfirmation, disconfirmation_verified_json,
1097
+ disconfirmation, disconfirmation_verified_json, disprove_if_json, control_verified_json,
881
1098
  reported_at, report_path, created_at, updated_at
882
1099
  ) VALUES (
883
1100
  ?, ?, ?, ?, ?, ?, ?, ?, ?,
884
1101
  ?, ?, ?, ?, ?, ?,
885
1102
  ?, ?, ?, ?, ?,
886
- ?, ?,
1103
+ ?, ?, ?, ?,
887
1104
  ?, ?, ?, ?
888
1105
  )
889
1106
  ON CONFLICT(id) DO UPDATE SET
@@ -908,6 +1125,8 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
908
1125
  poc_verified_json = excluded.poc_verified_json,
909
1126
  disconfirmation = excluded.disconfirmation,
910
1127
  disconfirmation_verified_json = excluded.disconfirmation_verified_json,
1128
+ disprove_if_json = excluded.disprove_if_json,
1129
+ control_verified_json = excluded.control_verified_json,
911
1130
  reported_at = excluded.reported_at,
912
1131
  report_path = excluded.report_path,
913
1132
  created_at = excluded.created_at,
@@ -937,6 +1156,8 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
937
1156
  record.pocVerified ? JSON.stringify(record.pocVerified) : null,
938
1157
  record.disconfirmation || null,
939
1158
  record.disconfirmationVerified ? JSON.stringify(record.disconfirmationVerified) : null,
1159
+ JSON.stringify(record.disproveIf),
1160
+ record.controlVerified ? JSON.stringify(record.controlVerified) : null,
940
1161
  record.reportedAt || null,
941
1162
  record.reportPath || null,
942
1163
  record.createdAt,
@@ -944,6 +1165,206 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
944
1165
  );
945
1166
  }
946
1167
 
1168
+ // ── Evidence items ──────────────────────────────────────────────────
1169
+
1170
+ function insertEvidenceItem(db: DatabaseSync, item: EvidenceItem): void {
1171
+ db.prepare(
1172
+ `INSERT INTO evidence_items (id, case_id, role, artifact_path, sha256, summary, created_at)
1173
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
1174
+ ).run(
1175
+ item.id,
1176
+ item.caseId,
1177
+ item.role,
1178
+ item.artifactPath ?? null,
1179
+ item.sha256 ?? null,
1180
+ item.summary,
1181
+ item.createdAt,
1182
+ );
1183
+ }
1184
+
1185
+ /**
1186
+ * Add a role-typed evidence item. Artifact path is hashed (SHA-256) and only
1187
+ * its basename is stored — the full path is never persisted (path-leak guard).
1188
+ */
1189
+ export function addEvidenceItemResult(
1190
+ caseId: string,
1191
+ input: { role: EvidenceRole; summary: string; artifactPath?: string },
1192
+ ): EvidenceItem {
1193
+ const db = getDb();
1194
+ const current = getCaseById(caseId);
1195
+ if (!current) throw new Error(`Case not found: ${caseId}`);
1196
+ if (current.status === "killed" || current.status === "reported") {
1197
+ throw new Error(`Cannot add evidence to terminal case ${caseId} (${current.status})`);
1198
+ }
1199
+ if (!(EVIDENCE_ROLE_VALUES as readonly string[]).includes(input.role)) {
1200
+ throw new Error(
1201
+ `Invalid evidence role: ${input.role}. Roles: ${EVIDENCE_ROLE_VALUES.join(", ")}`,
1202
+ );
1203
+ }
1204
+ const summary = normalizeText(input.summary);
1205
+ if (!summary) throw new Error("Evidence summary must not be empty");
1206
+
1207
+ let artifactPath: string | undefined;
1208
+ let sha256: string | undefined;
1209
+ if (input.artifactPath) {
1210
+ if (!existsSync(input.artifactPath)) {
1211
+ throw new Error(`Evidence artifact not found on disk: ${input.artifactPath}`);
1212
+ }
1213
+ const stat = statSync(input.artifactPath);
1214
+ if (!stat.isFile()) {
1215
+ throw new Error(`Evidence artifact is not a regular file: ${input.artifactPath}`);
1216
+ }
1217
+ if (stat.size > EVIDENCE_ARTIFACT_MAX_BYTES) {
1218
+ throw new Error(
1219
+ `Evidence artifact too large (${stat.size} bytes; max ${EVIDENCE_ARTIFACT_MAX_BYTES}): ${input.artifactPath}`,
1220
+ );
1221
+ }
1222
+ artifactPath = basename(input.artifactPath);
1223
+ sha256 = createHash("sha256").update(readFileSync(input.artifactPath)).digest("hex");
1224
+ }
1225
+
1226
+ const item: EvidenceItem = {
1227
+ id: `ev_${stableShortId(`${caseId}\n${summary}\n${randomUUID()}`)}`,
1228
+ caseId,
1229
+ role: input.role,
1230
+ artifactPath,
1231
+ sha256,
1232
+ summary,
1233
+ createdAt: new Date().toISOString(),
1234
+ };
1235
+ insertEvidenceItem(db, item);
1236
+ return item;
1237
+ }
1238
+
1239
+ export function listEvidenceItems(caseId: string): EvidenceItem[] {
1240
+ const db = getDb();
1241
+ return db
1242
+ .prepare("SELECT * FROM evidence_items WHERE case_id = ? ORDER BY created_at")
1243
+ .all(caseId) as EvidenceItem[];
1244
+ }
1245
+
1246
+ // ── Coverage items ──────────────────────────────────────────────────
1247
+
1248
+ function insertCoverageItem(db: DatabaseSync, item: CoverageItem): void {
1249
+ db.prepare(
1250
+ `INSERT INTO coverage_items (id, case_id, asset, class, scope, note, tested_by, created_at)
1251
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
1252
+ ).run(
1253
+ item.id,
1254
+ item.caseId,
1255
+ item.asset,
1256
+ item.class,
1257
+ item.scope,
1258
+ item.note,
1259
+ item.testedBy ?? null,
1260
+ item.createdAt,
1261
+ );
1262
+ }
1263
+
1264
+ /**
1265
+ * Record a tested (asset × attack-class) cell. The cell's existence marks the
1266
+ * class tested for that asset — found OR clean. `scope: wide` means the verdict
1267
+ * is a property of the whole deployment: recorded once, applies to every asset
1268
+ * of the deployment (do NOT re-test per asset).
1269
+ */
1270
+ export function recordCoverageResult(
1271
+ caseId: string,
1272
+ input: {
1273
+ asset: string;
1274
+ class: string;
1275
+ scope: CoverageScope;
1276
+ note: string;
1277
+ testedBy?: string;
1278
+ },
1279
+ ): CoverageItem {
1280
+ const db = getDb();
1281
+ const current = getCaseById(caseId);
1282
+ if (!current) throw new Error(`Case not found: ${caseId}`);
1283
+ if (current.status === "killed" || current.status === "reported") {
1284
+ throw new Error(`Cannot record coverage on terminal case ${caseId} (${current.status})`);
1285
+ }
1286
+ if (!(COVERAGE_SCOPE_VALUES as readonly string[]).includes(input.scope)) {
1287
+ throw new Error(
1288
+ `Invalid coverage scope: ${input.scope}. Scope must be one of: ${COVERAGE_SCOPE_VALUES.join(", ")}`,
1289
+ );
1290
+ }
1291
+ const asset = normalizeText(input.asset);
1292
+ const attackClass = normalizeText(input.class);
1293
+ const note = normalizeText(input.note);
1294
+ if (!asset) throw new Error("Coverage asset must not be empty");
1295
+ if (!attackClass) throw new Error("Coverage class must not be empty");
1296
+ if (!note) throw new Error("Coverage note must not be empty");
1297
+
1298
+ const item: CoverageItem = {
1299
+ id: `cov_${stableShortId(`${caseId}\n${asset}\n${attackClass}\n${input.scope}\n${randomUUID()}`)}`,
1300
+ caseId,
1301
+ asset,
1302
+ class: attackClass,
1303
+ scope: input.scope,
1304
+ note,
1305
+ testedBy: input.testedBy ? normalizeText(input.testedBy) : undefined,
1306
+ createdAt: new Date().toISOString(),
1307
+ };
1308
+ insertCoverageItem(db, item);
1309
+ return item;
1310
+ }
1311
+
1312
+ export function listCoverage(caseId: string): CoverageItem[] {
1313
+ const db = getDb();
1314
+ return db
1315
+ .prepare("SELECT * FROM coverage_items WHERE case_id = ? ORDER BY created_at")
1316
+ .all(caseId) as CoverageItem[];
1317
+ }
1318
+
1319
+ export type CoverageSummary = {
1320
+ items: CoverageItem[];
1321
+ /** Cells grouped per asset (wide cells repeated under every later asset they cover). */
1322
+ byAsset: Record<string, CoverageItem[]>;
1323
+ assets: string[];
1324
+ classes: string[];
1325
+ };
1326
+
1327
+ /**
1328
+ * Machine-checkable coverage view: which (asset × class) cells are tested.
1329
+ * A `wide` cell covers every asset recorded after it — a class with a wide
1330
+ * clean verdict must NOT be re-tested per asset (that is the wide semantics).
1331
+ */
1332
+ export function coverageSummary(caseId: string): CoverageSummary {
1333
+ const items = listCoverage(caseId);
1334
+ const byAsset: Record<string, CoverageItem[]> = {};
1335
+ const assets: string[] = [];
1336
+ const classes: string[] = [];
1337
+
1338
+ for (const item of items) {
1339
+ if (!assets.includes(item.asset)) assets.push(item.asset);
1340
+ if (!classes.includes(item.class)) classes.push(item.class);
1341
+ if (!byAsset[item.asset]) byAsset[item.asset] = [];
1342
+ byAsset[item.asset].push(item);
1343
+ }
1344
+ // Wide cells: a deployment-wide verdict covers every asset in the case. A
1345
+ // local cell for the same class on the same asset is more specific and wins
1346
+ // (the agent re-tested after the wide verdict — record shows both).
1347
+ const wideByClass = new Map<string, CoverageItem>();
1348
+ for (const item of items) {
1349
+ if (item.scope === "wide") {
1350
+ const prev = wideByClass.get(item.class);
1351
+ if (!prev || item.createdAt >= prev.createdAt) wideByClass.set(item.class, item);
1352
+ }
1353
+ }
1354
+ for (const [cls, wide] of wideByClass) {
1355
+ for (const asset of assets) {
1356
+ if (!byAsset[asset]) byAsset[asset] = [];
1357
+ const cells = byAsset[asset];
1358
+ const hasLocal = cells.some((c) => c.class === cls && c.scope === "local");
1359
+ const hasWide = cells.some((c) => c.class === cls && c.scope === "wide");
1360
+ if (!hasLocal && !hasWide) {
1361
+ cells.push({ ...wide, asset, note: `${wide.note} (wide verdict covers this asset)` });
1362
+ }
1363
+ }
1364
+ }
1365
+ return { items, byAsset, assets, classes };
1366
+ }
1367
+
947
1368
  export function addCaseResult(input: CaseInput): CaseAddResult {
948
1369
  const db = getDb();
949
1370
  validateNewCaseInput(input);
@@ -983,49 +1404,24 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
983
1404
  throw new Error("Cannot mutate a reported case; file a follow-up case instead");
984
1405
  }
985
1406
 
986
- const optionalFields = [
987
- "title",
988
- "target",
989
- "endpoint",
990
- "bugClass",
991
- "summary",
992
- "evidence",
993
- "impact",
994
- "nextStep",
995
- "poc",
996
- "remediation",
997
- "disconfirmation",
998
- ] as const;
999
- const optionalPatch: Record<string, unknown> = {};
1000
- for (const field of optionalFields) {
1001
- if (field in update && update[field] !== undefined) {
1002
- optionalPatch[field] = update[field];
1003
- }
1004
- }
1005
-
1006
- let next = buildRecord(
1007
- {
1008
- ...optionalPatch,
1009
- status: update.status ?? current.status,
1010
- confidence: update.confidence ?? current.confidence,
1011
- severity: update.severity ?? current.severity,
1012
- priority: update.priority ?? current.priority,
1013
- references: update.references ?? current.references,
1014
- blockers: update.blockers ?? current.blockers,
1015
- tags: update.tags ?? current.tags,
1016
- assumptions: update.assumptions ?? current.assumptions,
1017
- },
1018
- current,
1019
- );
1407
+ // buildRecord resolves every field as `update.x ?? current.x`; the patch
1408
+ // construction is the update itself.
1409
+ let next = buildRecord(update, current);
1020
1410
 
1021
1411
  if (update.status && update.status !== current.status) {
1022
1412
  validateTransition(current.status, next.status, update, current);
1023
1413
  }
1024
1414
 
1025
- // Demoting off confirmed invalidates prior PoC + disconfirmation verification
1026
- // re-promote required. Both verification artifacts must be re-earned together.
1415
+ // Demoting off confirmed invalidates prior PoC + disconfirmation + control
1416
+ // verification — re-promote required. All three artifacts must be re-earned
1417
+ // together (a stale control run must not survive a demote/re-promote cycle).
1027
1418
  if (current.status === "confirmed" && next.status === "investigating") {
1028
- next = { ...next, pocVerified: undefined, disconfirmationVerified: undefined };
1419
+ next = {
1420
+ ...next,
1421
+ pocVerified: undefined,
1422
+ disconfirmationVerified: undefined,
1423
+ controlVerified: undefined,
1424
+ };
1029
1425
  }
1030
1426
 
1031
1427
  validateCase(next);
@@ -1044,8 +1440,9 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
1044
1440
  if (
1045
1441
  k === "updatedAt" ||
1046
1442
  k === "createdAt" ||
1047
- k === "linkedCaseIds" ||
1048
- k === "linkedCases"
1443
+ k === "linkedCases" ||
1444
+ k === "evidenceItems" ||
1445
+ k === "coverageItems"
1049
1446
  ) {
1050
1447
  acc[k] = "";
1051
1448
  } else {
@@ -1083,6 +1480,8 @@ export type PocVerification = {
1083
1480
  ranAt: string;
1084
1481
  output?: string;
1085
1482
  sandbox: boolean;
1483
+ /** True iff the script ran to completion (not a spawn error / signal kill / timeout). */
1484
+ completed?: boolean;
1086
1485
  };
1087
1486
 
1088
1487
  /**
@@ -1128,6 +1527,8 @@ export function promoteFindingResult(
1128
1527
  id: string,
1129
1528
  verification: PocVerification,
1130
1529
  disconfirmationVerification?: PocVerification,
1530
+ controlVerification?: PocVerification,
1531
+ marker?: string,
1131
1532
  ): CaseUpdateResult {
1132
1533
  const db = getDb();
1133
1534
  const current = assertPromotable(id);
@@ -1137,6 +1538,62 @@ export function promoteFindingResult(
1137
1538
  );
1138
1539
  }
1139
1540
 
1541
+ // Anti-cheat, enforced at the ledger level (not just the tool): a live
1542
+ // finding (any non-sandboxed run — `sandbox` must be explicitly true to
1543
+ // skip the control; undefined/false from JS callers fails closed) must carry
1544
+ // a control-target verification that COMPLETED and, when the marker is known
1545
+ // to the caller, did NOT print the marker in its output. Checking presence
1546
+ // alone is not enough: a control run that crashed (completed: false) or that
1547
+ // printed the marker proves nothing about target-dependence.
1548
+ const isLive = verification.sandbox !== true;
1549
+ if (isLive) {
1550
+ const controlOk =
1551
+ controlVerification?.completed === true &&
1552
+ (!marker || !(controlVerification.output ?? "").includes(marker));
1553
+ if (!controlOk) {
1554
+ throw new Error(
1555
+ "Live findings (non-sandboxed PoC run) require a valid controlVerification: a control-target " +
1556
+ "run of the same PoC that COMPLETED (completed: true)" +
1557
+ (marker ? ` and whose output does not contain the marker "${marker}"` : "") +
1558
+ ". PromoteFinding requires control_path for local:true findings.",
1559
+ );
1560
+ }
1561
+ }
1562
+
1563
+ // Evidence-chain closure pre-check BEFORE any DB write: the observation item
1564
+ // must already exist. Checking first means a failed promote (missing
1565
+ // observation) writes nothing — no phantom reproduction item is left on an
1566
+ // investigating case, and a retry sees the real error, not a PK conflict.
1567
+ if (!current.evidenceItems.some((e) => e.role === "observation")) {
1568
+ throw new Error(
1569
+ "Evidence chain incomplete: CONFIRMED requires an observation evidence item " +
1570
+ "(EvidenceAdd role=observation — the initial signal, artifact-backed) in addition to " +
1571
+ "the auto-recorded reproduction item. Add the observation item and retry promotion.",
1572
+ );
1573
+ }
1574
+
1575
+ // Machine-recorded reproduction evidence: the PoC gate itself writes the
1576
+ // artifact-backed evidence item — confirmation is anchored to a real file
1577
+ // with its SHA-256, not to agent prose in the evidence field.
1578
+ let pocSha256: string | undefined;
1579
+ try {
1580
+ pocSha256 = createHash("sha256").update(readFileSync(verification.path)).digest("hex");
1581
+ } catch {
1582
+ pocSha256 = undefined;
1583
+ }
1584
+ const reproductionItem: EvidenceItem = {
1585
+ id: `ev_${stableShortId(`${id}\nreproduction\n${verification.ranAt}`)}`,
1586
+ caseId: id,
1587
+ role: "reproduction",
1588
+ artifactPath: basename(verification.path),
1589
+ sha256: pocSha256,
1590
+ summary: `PoC run exit ${verification.exitCode} (sandbox: ${verification.sandbox}) — verification marker present in output`,
1591
+ createdAt: verification.ranAt,
1592
+ };
1593
+ insertEvidenceItem(db, reproductionItem);
1594
+ // Attach to the record being validated (current was fetched pre-insert).
1595
+ current.evidenceItems = [...(current.evidenceItems ?? []), reproductionItem];
1596
+
1140
1597
  const newEvidence =
1141
1598
  (current.evidence ? `${current.evidence}\n\n` : "") +
1142
1599
  `### PoC Execution Capture (${verification.ranAt})\n` +
@@ -1152,6 +1609,9 @@ export function promoteFindingResult(
1152
1609
  if (disconfirmationVerification) {
1153
1610
  update.disconfirmationVerified = disconfirmationVerification;
1154
1611
  }
1612
+ if (controlVerification) {
1613
+ update.controlVerified = controlVerification;
1614
+ }
1155
1615
 
1156
1616
  const next = buildRecord(update, current);
1157
1617
  validateCase(next);
@@ -1160,53 +1620,241 @@ export function promoteFindingResult(
1160
1620
  return { record: next, changed: true };
1161
1621
  }
1162
1622
 
1163
- // ── Link operations ──────────────────────────────────────────────────
1623
+ // ── Chain suggestions ───────────────────────────────────────────────
1624
+
1625
+ /** Automated exploit-chain patterns (ported shape from CyberStrike chain.ts). */
1626
+ const CHAIN_PATTERN_VALUES = [
1627
+ "credential_endpoint",
1628
+ "info_disclosure_ssrf",
1629
+ "redirect_oauth",
1630
+ "idor_data_leak",
1631
+ "xss_csrf",
1632
+ "ssti_rce",
1633
+ "race_condition_business",
1634
+ ] as const;
1635
+ export type ChainPattern = (typeof CHAIN_PATTERN_VALUES)[number];
1636
+
1637
+ export type ChainSuggestion = {
1638
+ pattern: ChainPattern;
1639
+ sourceId: string;
1640
+ targetId?: string;
1641
+ sourceTitle: string;
1642
+ targetTitle?: string;
1643
+ rationale: string;
1644
+ confidence: number;
1645
+ /** Suggested CaseLink kind when the agent links the pair. */
1646
+ suggestedKind?: CaseLinkKind;
1647
+ };
1164
1648
 
1165
- export function linkCasesResult(sourceId: string, targetId: string, kind?: string): CaseLinkResult {
1166
- const db = getDb();
1167
- if (sourceId === targetId) {
1168
- throw new Error("Cannot link a case to itself");
1649
+ // Word-boundary anchored so "admin" does not match "administration" and
1650
+ // "update" does not match "updated" — substring matching over-mines pairs.
1651
+ const CHAIN_CLASS_RE = {
1652
+ credential: /\b(credential|password|api[ -]?key|token|secret|leak|dump|exposure)\b/i,
1653
+ authEndpoint: /\b(auth|login|sso|signup|account|admin|endpoint|api)\b/i,
1654
+ redirect: /\b(open redirect|redirect)\b/i,
1655
+ oauth: /\b(oauth|callback|redirect_uri|sso|saml|openid|authorize)\b/i,
1656
+ xss: /\b(xss|cross-?site.?script)\b/i,
1657
+ stateChange:
1658
+ /\b(POST|PUT|DELETE|PATCH|create|update|delete|transfer|payment|invite|admin|state.?chang)\b/i,
1659
+ idor: /\b(idor|bola|object reference|broken access)\b/i,
1660
+ userData:
1661
+ /\b(user|users|profile|account|accounts|email|phone|address|personal|private|settings|data)\b/i,
1662
+ ssti: /\b(ssti|template injection|template render)\b/i,
1663
+ race: /\b(race|toctou|concurrent)\b/i,
1664
+ payment: /\b(payment|transfer|order|checkout|cart|purchase|balance|credit|withdraw|deposit)\b/i,
1665
+ infoDisclosure: /\b(info disclosure|information disclosure|leak|exposure|debug)\b/i,
1666
+ ssrf: /\b(ssrf|server-?side request)\b/i,
1667
+ } satisfies Record<string, RegExp>;
1668
+
1669
+ /** Multi-label second-level suffixes — *.co.uk must not false-pair via last-2 labels. */
1670
+ const SECOND_LEVEL_SUFFIXES = new Set([
1671
+ "co",
1672
+ "com",
1673
+ "org",
1674
+ "net",
1675
+ "gov",
1676
+ "ac",
1677
+ "edu",
1678
+ "mil",
1679
+ "ltd",
1680
+ "me",
1681
+ "tv",
1682
+ "info",
1683
+ "biz",
1684
+ ]);
1685
+
1686
+ function eTLDPlus1(host: string): string {
1687
+ const parts = host.split(".");
1688
+ if (parts.length >= 3 && SECOND_LEVEL_SUFFIXES.has(parts[parts.length - 2] ?? "")) {
1689
+ return parts.slice(-3).join(".");
1169
1690
  }
1170
- const resolvedKind: CaseLinkKind =
1171
- kind && (LINK_KIND_VALUES as readonly string[]).includes(kind)
1172
- ? (kind as CaseLinkKind)
1173
- : DEFAULT_LINK_KIND;
1691
+ return parts.slice(-2).join(".");
1692
+ }
1693
+
1694
+ function chainText(c: CaseRecord): string {
1695
+ return [c.title, c.bugClass ?? "", c.evidence ?? ""].join(" ");
1696
+ }
1697
+
1698
+ function hasChainClass(c: CaseRecord, re: RegExp): boolean {
1699
+ return re.test(chainText(c));
1700
+ }
1701
+
1702
+ /** Same asset or related (same eTLD+1) — chains only pair cases on one target. */
1703
+ function sameAssetOrRelated(a: CaseRecord, b: CaseRecord): boolean {
1704
+ const ta = (a.target ?? "").toLowerCase().trim();
1705
+ const tb = (b.target ?? "").toLowerCase().trim();
1706
+ if (!ta || !tb) return false;
1707
+ if (ta === tb) return true;
1708
+ if (ta.includes(tb) || tb.includes(ta)) return true;
1709
+ return eTLDPlus1(ta) === eTLDPlus1(tb);
1710
+ }
1711
+
1712
+ /**
1713
+ * Scan non-terminal cases for exploitable chains (CyberStrike-style detection
1714
+ * over XPI's case records). Emits ranked suggestions; the agent decides
1715
+ * whether to CaseLink or open an escalation case.
1716
+ */
1717
+ export function suggestChains(caseId?: string): ChainSuggestion[] {
1718
+ // Pair over ALL non-terminal cases; the caseId filter narrows the RESULTS
1719
+ // to suggestions involving that case (filtering the inputs first would drop
1720
+ // unlinked partner cases and kill cross-case pairing).
1721
+ const cases = readCasefile().filter((c) => c.status !== "killed" && c.status !== "reported");
1722
+ const suggestions: ChainSuggestion[] = [];
1723
+ const seen = new Set<string>();
1724
+ const confirmed = (c: CaseRecord) => c.status === "confirmed";
1725
+ const confidenceFor = (a: CaseRecord, b?: CaseRecord) => {
1726
+ const both = confirmed(a) && (!b || confirmed(b));
1727
+ const one = confirmed(a) || (b ? confirmed(b) : false);
1728
+ return both ? 90 : one ? 75 : 55;
1729
+ };
1730
+ const add = (
1731
+ pattern: ChainPattern,
1732
+ a: CaseRecord,
1733
+ b: CaseRecord | undefined,
1734
+ rationale: string,
1735
+ kind?: CaseLinkKind,
1736
+ ) => {
1737
+ const key = b ? `${pattern}:${[a.id, b.id].sort().join("+")}` : `${pattern}:${a.id}`;
1738
+ if (seen.has(key)) return;
1739
+ seen.add(key);
1740
+ suggestions.push({
1741
+ pattern,
1742
+ sourceId: a.id,
1743
+ targetId: b?.id,
1744
+ sourceTitle: a.title,
1745
+ targetTitle: b?.title,
1746
+ rationale,
1747
+ confidence: confidenceFor(a, b),
1748
+ suggestedKind: kind,
1749
+ });
1750
+ };
1751
+
1752
+ // Pair rules as data: (classifier A, classifier B, rationale, link kind).
1753
+ // One loop replaces seven copy-pasted pair loops.
1754
+ const PAIR_RULES: Array<{
1755
+ pattern: Exclude<ChainPattern, "ssti_rce">;
1756
+ a: RegExp;
1757
+ b: RegExp;
1758
+ rationale: (a: CaseRecord, b: CaseRecord) => string;
1759
+ kind?: CaseLinkKind;
1760
+ }> = [
1761
+ {
1762
+ pattern: "credential_endpoint",
1763
+ a: CHAIN_CLASS_RE.credential,
1764
+ b: CHAIN_CLASS_RE.authEndpoint,
1765
+ kind: "depends-on",
1766
+ rationale: (a, b) =>
1767
+ `Use leaked credential "${a.title}" to authenticate against "${b.title}" → account takeover`,
1768
+ },
1769
+ {
1770
+ pattern: "redirect_oauth",
1771
+ a: CHAIN_CLASS_RE.redirect,
1772
+ b: CHAIN_CLASS_RE.oauth,
1773
+ rationale: (a, b) =>
1774
+ `Chain open redirect "${a.title}" into OAuth flow "${b.title}" to steal access tokens`,
1775
+ },
1776
+ {
1777
+ pattern: "xss_csrf",
1778
+ a: CHAIN_CLASS_RE.xss,
1779
+ b: CHAIN_CLASS_RE.stateChange,
1780
+ rationale: (a, b) =>
1781
+ `Use XSS "${a.title}" to drive state-changing "${b.title}" (CSRF bypass / victim-action)`,
1782
+ },
1783
+ {
1784
+ pattern: "idor_data_leak",
1785
+ a: CHAIN_CLASS_RE.idor,
1786
+ b: CHAIN_CLASS_RE.userData,
1787
+ rationale: (a, b) => `Use IDOR "${a.title}" to enumerate user data via "${b.title}"`,
1788
+ },
1789
+ {
1790
+ pattern: "race_condition_business",
1791
+ a: CHAIN_CLASS_RE.race,
1792
+ b: CHAIN_CLASS_RE.payment,
1793
+ rationale: (a, b) =>
1794
+ `Use race condition "${a.title}" on financial endpoint "${b.title}" (double-spend / bypass)`,
1795
+ },
1796
+ {
1797
+ pattern: "info_disclosure_ssrf",
1798
+ a: CHAIN_CLASS_RE.infoDisclosure,
1799
+ b: CHAIN_CLASS_RE.ssrf,
1800
+ rationale: (a, b) =>
1801
+ `Use internal URL/config from "${a.title}" as SSRF target via "${b.title}"`,
1802
+ },
1803
+ ];
1804
+
1805
+ for (const rule of PAIR_RULES) {
1806
+ const aCases = cases.filter((c) => rule.a.test(chainText(c)));
1807
+ const bCases = cases.filter((c) => rule.b.test(chainText(c)));
1808
+ for (const a of aCases) {
1809
+ for (const b of bCases) {
1810
+ if (a.id === b.id || !sameAssetOrRelated(a, b)) continue;
1811
+ add(rule.pattern, a, b, rule.rationale(a, b), rule.kind);
1812
+ }
1813
+ }
1814
+ }
1815
+
1816
+ // SSTI → RCE (single-case escalation)
1817
+ for (const s of cases.filter((c) => hasChainClass(c, CHAIN_CLASS_RE.ssti))) {
1818
+ add("ssti_rce", s, undefined, `Escalate SSTI "${s.title}" to RCE via template-engine gadgets`);
1819
+ }
1820
+
1821
+ const scoped = caseId
1822
+ ? suggestions.filter((s) => s.sourceId === caseId || s.targetId === caseId)
1823
+ : suggestions;
1824
+ return scoped.sort((a, b) => b.confidence - a.confidence);
1825
+ }
1826
+
1827
+ // ── Link operations ──────────────────────────────────────────────────
1828
+
1829
+ /** Both cases must exist and be mutable (not killed/reported). */
1830
+ function assertMutablePair(
1831
+ sourceId: string,
1832
+ targetId: string,
1833
+ verb: "link" | "unlink",
1834
+ ): { source: CaseRecord; target: CaseRecord } {
1174
1835
  const source = getCaseById(sourceId);
1175
1836
  const target = getCaseById(targetId);
1176
1837
  if (!source) throw new Error(`Case not found: ${sourceId}`);
1177
1838
  if (!target) throw new Error(`Case not found: ${targetId}`);
1178
1839
  if (source.status === "killed" || source.status === "reported") {
1179
- throw new Error(`Cannot link terminal case ${sourceId} (${source.status})`);
1840
+ throw new Error(`Cannot ${verb} terminal case ${sourceId} (${source.status})`);
1180
1841
  }
1181
1842
  if (target.status === "killed" || target.status === "reported") {
1182
- throw new Error(`Cannot link terminal case ${targetId} (${target.status})`);
1183
- }
1184
-
1185
- const checkStmt = db.prepare("SELECT kind FROM case_links WHERE source_id = ? AND target_id = ?");
1186
- const existing = checkStmt.get(sourceId, targetId) as { kind: string } | undefined;
1187
-
1188
- if (existing) {
1189
- return {
1190
- source,
1191
- target,
1192
- changed: false,
1193
- reason: "Cases are already linked",
1194
- kind: existing.kind,
1195
- };
1843
+ throw new Error(`Cannot ${verb} terminal case ${targetId} (${target.status})`);
1196
1844
  }
1845
+ return { source, target };
1846
+ }
1197
1847
 
1198
- // Atomic insert both directions: source→target keeps the stated kind, the
1199
- // reverse row stores the inverse so each case lists the edge from its own
1200
- // perspective.
1201
- const inverseKind = LINK_KIND_INVERSE[resolvedKind];
1848
+ /** Run a case_links mutation + updated_at touch inside one transaction. */
1849
+ function withLinkTx(
1850
+ db: DatabaseSync,
1851
+ sourceId: string,
1852
+ targetId: string,
1853
+ mutate: (db: DatabaseSync) => void,
1854
+ ): void {
1202
1855
  db.exec("BEGIN");
1203
1856
  try {
1204
- const linkStmt = db.prepare(
1205
- "INSERT INTO case_links (source_id, target_id, kind) VALUES (?, ?, ?)",
1206
- );
1207
- linkStmt.run(sourceId, targetId, resolvedKind);
1208
- linkStmt.run(targetId, sourceId, inverseKind);
1209
-
1857
+ mutate(db);
1210
1858
  const now = new Date().toISOString();
1211
1859
  const updateTimeStmt = db.prepare("UPDATE cases SET updated_at = ? WHERE id = ?");
1212
1860
  updateTimeStmt.run(now, sourceId);
@@ -1220,82 +1868,82 @@ export function linkCasesResult(sourceId: string, targetId: string, kind?: strin
1220
1868
  }
1221
1869
  throw err;
1222
1870
  }
1871
+ }
1223
1872
 
1224
- const finalSource = getCaseById(sourceId)!;
1225
- const finalTarget = getCaseById(targetId)!;
1226
- return { source: finalSource, target: finalTarget, changed: true, kind: resolvedKind };
1873
+ function existingLinkKind(
1874
+ db: DatabaseSync,
1875
+ sourceId: string,
1876
+ targetId: string,
1877
+ ): string | undefined {
1878
+ return (
1879
+ db
1880
+ .prepare("SELECT kind FROM case_links WHERE source_id = ? AND target_id = ?")
1881
+ .get(sourceId, targetId) as { kind: string } | undefined
1882
+ )?.kind;
1227
1883
  }
1228
1884
 
1229
- export function unlinkCasesResult(sourceId: string, targetId: string): CaseLinkResult {
1885
+ export function linkCasesResult(sourceId: string, targetId: string, kind?: string): CaseLinkResult {
1230
1886
  const db = getDb();
1231
- const source = getCaseById(sourceId);
1232
- const target = getCaseById(targetId);
1233
- if (!source) throw new Error(`Case not found: ${sourceId}`);
1234
- if (!target) throw new Error(`Case not found: ${targetId}`);
1235
- if (source.status === "killed" || source.status === "reported") {
1236
- throw new Error(`Cannot unlink terminal case ${sourceId} (${source.status})`);
1887
+ if (sourceId === targetId) {
1888
+ throw new Error("Cannot link a case to itself");
1237
1889
  }
1238
- if (target.status === "killed" || target.status === "reported") {
1239
- throw new Error(`Cannot unlink terminal case ${targetId} (${target.status})`);
1890
+ const resolvedKind: CaseLinkKind =
1891
+ kind && (LINK_KIND_VALUES as readonly string[]).includes(kind)
1892
+ ? (kind as CaseLinkKind)
1893
+ : DEFAULT_LINK_KIND;
1894
+ const { source, target } = assertMutablePair(sourceId, targetId, "link");
1895
+
1896
+ const existing = existingLinkKind(db, sourceId, targetId);
1897
+ if (existing) {
1898
+ return { source, target, changed: false, reason: "Cases are already linked", kind: existing };
1240
1899
  }
1241
1900
 
1242
- const checkStmt = db.prepare("SELECT kind FROM case_links WHERE source_id = ? AND target_id = ?");
1243
- const existing = checkStmt.get(sourceId, targetId) as { kind: string } | undefined;
1901
+ // Atomic insert both directions: source→target keeps the stated kind, the
1902
+ // reverse row stores the inverse so each case lists the edge from its own
1903
+ // perspective.
1904
+ const inverseKind = LINK_KIND_INVERSE[resolvedKind];
1905
+ withLinkTx(db, sourceId, targetId, (tx) => {
1906
+ const linkStmt = tx.prepare(
1907
+ "INSERT INTO case_links (source_id, target_id, kind) VALUES (?, ?, ?)",
1908
+ );
1909
+ linkStmt.run(sourceId, targetId, resolvedKind);
1910
+ linkStmt.run(targetId, sourceId, inverseKind);
1911
+ });
1912
+
1913
+ return {
1914
+ source: getCaseById(sourceId)!,
1915
+ target: getCaseById(targetId)!,
1916
+ changed: true,
1917
+ kind: resolvedKind,
1918
+ };
1919
+ }
1244
1920
 
1921
+ export function unlinkCasesResult(sourceId: string, targetId: string): CaseLinkResult {
1922
+ const db = getDb();
1923
+ const { source, target } = assertMutablePair(sourceId, targetId, "unlink");
1924
+
1925
+ const existing = existingLinkKind(db, sourceId, targetId);
1245
1926
  if (!existing) {
1246
1927
  return { source, target, changed: false, reason: "Cases are not linked", kind: "related" };
1247
1928
  }
1248
1929
 
1249
- db.exec("BEGIN");
1250
- try {
1251
- const unlinkStmt = db.prepare(
1930
+ withLinkTx(db, sourceId, targetId, (tx) => {
1931
+ tx.prepare(
1252
1932
  "DELETE FROM case_links WHERE (source_id = ? AND target_id = ?) OR (source_id = ? AND target_id = ?)",
1253
- );
1254
- unlinkStmt.run(sourceId, targetId, targetId, sourceId);
1255
-
1256
- const now = new Date().toISOString();
1257
- const updateTimeStmt = db.prepare("UPDATE cases SET updated_at = ? WHERE id = ?");
1258
- updateTimeStmt.run(now, sourceId);
1259
- updateTimeStmt.run(now, targetId);
1260
- db.exec("COMMIT");
1261
- } catch (err) {
1262
- try {
1263
- db.exec("ROLLBACK");
1264
- } catch {
1265
- // ignore
1266
- }
1267
- throw err;
1268
- }
1933
+ ).run(sourceId, targetId, targetId, sourceId);
1934
+ });
1269
1935
 
1270
- const finalSource = getCaseById(sourceId)!;
1271
- const finalTarget = getCaseById(targetId)!;
1272
- return { source: finalSource, target: finalTarget, changed: true, kind: existing.kind };
1936
+ return {
1937
+ source: getCaseById(sourceId)!,
1938
+ target: getCaseById(targetId)!,
1939
+ changed: true,
1940
+ kind: existing,
1941
+ };
1273
1942
  }
1274
1943
 
1275
1944
  // ── Search & Queries ─────────────────────────────────────────────────
1276
1945
 
1277
- // Searchable text columns (excludes ids/timestamps/JSON arrays for performance + signal).
1278
- const SEARCH_COLUMNS = [
1279
- "title",
1280
- "summary",
1281
- "evidence",
1282
- "impact",
1283
- "target",
1284
- "endpoint",
1285
- "bugClass",
1286
- "poc",
1287
- ] as const;
1288
-
1289
- const FIELD_COLUMN: Record<CaseSearchField, string> = {
1290
- title: "title",
1291
- summary: "summary",
1292
- evidence: "evidence",
1293
- impact: "impact",
1294
- target: "target",
1295
- endpoint: "endpoint",
1296
- bugClass: "bugClass",
1297
- poc: "poc",
1298
- };
1946
+ // Search field names double as their column names (SEARCH_FIELD_VALUES above).
1299
1947
 
1300
1948
  function severityRank(s: CaseSeverity): number {
1301
1949
  return SEVERITY_VALUES.indexOf(s);
@@ -1354,12 +2002,12 @@ function buildCaseWhere(options: CaseSearchOptions): {
1354
2002
  if (query) {
1355
2003
  const likeParam = `%${query}%`;
1356
2004
  if (options.field) {
1357
- where.push(`lower(${FIELD_COLUMN[options.field]}) LIKE ?`);
2005
+ where.push(`lower(${options.field}) LIKE ?`);
1358
2006
  params.push(likeParam);
1359
2007
  } else {
1360
- const ors = SEARCH_COLUMNS.map((c) => `lower(${c}) LIKE ?`).join(" OR ");
2008
+ const ors = SEARCH_FIELD_VALUES.map((c) => `lower(${c}) LIKE ?`).join(" OR ");
1361
2009
  where.push(`(${ors})`);
1362
- for (let i = 0; i < SEARCH_COLUMNS.length; i++) params.push(likeParam);
2010
+ for (let i = 0; i < SEARCH_FIELD_VALUES.length; i++) params.push(likeParam);
1363
2011
  }
1364
2012
  }
1365
2013
 
@@ -1465,7 +2113,7 @@ export function formatCaseDetail(record: CaseRecord): string {
1465
2113
  if (
1466
2114
  !val ||
1467
2115
  (Array.isArray(val) && !val.length) ||
1468
- ["id", "createdAt", "updatedAt", "linkedCaseIds"].includes(key)
2116
+ ["id", "createdAt", "updatedAt"].includes(key)
1469
2117
  )
1470
2118
  continue;
1471
2119
  const label = key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, " $1");
@@ -1500,17 +2148,8 @@ function mdSection(title: string, body?: string): string {
1500
2148
  // pipeline artifacts (recon entry points, traces, skeptic verdicts, PoC logs)
1501
2149
  // from any scratchpad run that produced this case.
1502
2150
 
1503
- const CONTEXT_PHASES: ScratchpadPhase[] = [
1504
- "recon",
1505
- "hunt",
1506
- "gapfil",
1507
- "trace",
1508
- "skeptic",
1509
- "validate",
1510
- "chain",
1511
- "patch",
1512
- "report",
1513
- ];
2151
+ // Context bundles cover every pipeline phase (imported from the scratchpad
2152
+ // where the canonical order lives).
1514
2153
 
1515
2154
  /** Per-artifact content cap for the context bundle (generous; artifacts are small). */
1516
2155
  const MAX_ARTIFACT_CHARS = 100_000;
@@ -1521,9 +2160,13 @@ function buildCompleteRecord(current: CaseRecord): string {
1521
2160
  if (v === undefined || v === null || v === "") continue;
1522
2161
  let display = typeof v === "object" ? JSON.stringify(v, null, 2) : String(v);
1523
2162
  // Path-leak guard: the verification objects carry the researcher's local
1524
- // PoC/disconfirmation script paths — show basenames only (the dedicated
2163
+ // PoC/disconfirmation/control script paths — show basenames only (the dedicated
1525
2164
  // log sections below already render them as basenames).
1526
- if ((k === "pocVerified" || k === "disconfirmationVerified") && v && typeof v === "object") {
2165
+ if (
2166
+ (k === "pocVerified" || k === "disconfirmationVerified" || k === "controlVerified") &&
2167
+ v &&
2168
+ typeof v === "object"
2169
+ ) {
1527
2170
  const redacted = {
1528
2171
  ...(v as Record<string, unknown>),
1529
2172
  path: basename((v as { path?: string }).path ?? ""),
@@ -1586,7 +2229,7 @@ function buildScratchpadSection(caseId: string): string {
1586
2229
  if (!allIds.includes(caseId) && !namedInArtifact) continue;
1587
2230
 
1588
2231
  sections.push(`### Run: ${entry.name} (project root: ${resume.checkpoint.project_root})`);
1589
- for (const phase of CONTEXT_PHASES) {
2232
+ for (const phase of PHASE_ORDER) {
1590
2233
  const names = resume.artifacts[phase];
1591
2234
  if (!names?.length) continue;
1592
2235
  sections.push(`#### ${phase}/`);
@@ -1617,6 +2260,20 @@ export function writeCaseContext(id: string): {
1617
2260
  throw new Error("Case context requires a confirmed or reported case");
1618
2261
  }
1619
2262
 
2263
+ // Report-time evidence-chain closure: a report bundle for a confirmed case
2264
+ // must carry the full observation → reproduction chain. A confirmed case
2265
+ // without it (e.g. promoted before the gate existed) is not reportable.
2266
+ if (
2267
+ current.status === "confirmed" &&
2268
+ (!current.evidenceItems?.some((e) => e.role === "observation") ||
2269
+ !current.evidenceItems?.some((e) => e.role === "reproduction"))
2270
+ ) {
2271
+ throw new Error(
2272
+ `Case ${id} is confirmed but lacks the evidence chain (observation + reproduction items). ` +
2273
+ "Add the missing EvidenceAdd items before generating the report context.",
2274
+ );
2275
+ }
2276
+
1620
2277
  const db = getDb();
1621
2278
  const dbPath = getCasefilePath();
1622
2279
 
@@ -1669,6 +2326,12 @@ export function writeCaseContext(id: string): {
1669
2326
  `### 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\`\`\``,
1670
2327
  )
1671
2328
  : undefined,
2329
+ current.controlVerified
2330
+ ? mdSection(
2331
+ "Control-Target Check (anti-cheat)",
2332
+ `### Control Run Verification\n- **Timestamp:** ${current.controlVerified.ranAt}\n- **Script:** \`${basename(current.controlVerified.path)}\`\n- **Sandbox:** ${current.controlVerified.sandbox ? "yes" : "no"}\n- **Exit Code:** ${current.controlVerified.exitCode}\n- **Marker on control:** absent (required) — same PoC against a target lacking the vuln did NOT print the verification marker.\n\n#### Output\n\`\`\`\n${current.controlVerified.output ?? ""}\n\`\`\``,
2333
+ )
2334
+ : undefined,
1672
2335
  mdSection("Disconfirmation Attempt", current.disconfirmation),
1673
2336
  current.disconfirmationVerified
1674
2337
  ? mdSection(
@@ -1677,6 +2340,17 @@ export function writeCaseContext(id: string): {
1677
2340
  )
1678
2341
  : undefined,
1679
2342
  mdSection("Impact", current.impact),
2343
+ mdSection(
2344
+ "Evidence Items (role-typed, hashed)",
2345
+ current.evidenceItems.length
2346
+ ? current.evidenceItems
2347
+ .map(
2348
+ (e) =>
2349
+ `- [${e.role}] ${e.summary}${e.artifactPath ? ` — artifact \`${e.artifactPath}\` sha256 \`${e.sha256 ?? "?"}\`` : ""} (${e.createdAt})`,
2350
+ )
2351
+ .join("\n")
2352
+ : "None recorded.",
2353
+ ),
1680
2354
  mdSection("Remediation", current.remediation),
1681
2355
  mdSection("Assumptions and Uncertainty", assumptions),
1682
2356
  mdSection("References", references),