@xaccefy/pi-casefile 0.7.6 → 0.8.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/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,69 @@ 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
+ /** Map raw snake_case DB rows to their camelCase item types. */
534
+ function mapEvidenceRow(row: any): EvidenceItem {
535
+ return {
536
+ id: row.id,
537
+ caseId: row.case_id,
538
+ role: row.role,
539
+ artifactPath: row.artifact_path ?? undefined,
540
+ sha256: row.sha256 ?? undefined,
541
+ summary: row.summary,
542
+ createdAt: row.created_at,
543
+ };
544
+ }
545
+
546
+ function mapCoverageRow(row: any): CoverageItem {
547
+ return {
548
+ id: row.id,
549
+ caseId: row.case_id,
550
+ asset: row.asset,
551
+ class: row.class,
552
+ scope: row.scope,
553
+ note: row.note,
554
+ testedBy: row.tested_by ?? undefined,
555
+ createdAt: row.created_at,
556
+ };
557
+ }
558
+
559
+ /** Batch-fetch per-case item tables (evidence / coverage) for a set of ids. */
560
+ function fetchItemMap<T extends { caseId: string }>(
561
+ db: DatabaseSync,
562
+ table: "evidence_items" | "coverage_items",
563
+ ids: string[],
564
+ ): Map<string, T[]> {
565
+ if (ids.length === 0) return new Map();
566
+ const placeholders = ids.map(() => "?").join(",");
567
+ const rows = db
568
+ .prepare(`SELECT * FROM ${table} WHERE case_id IN (${placeholders}) ORDER BY created_at`)
569
+ .all(...ids) as any[];
570
+ const mapRow = table === "evidence_items" ? mapEvidenceRow : mapCoverageRow;
571
+ const map = new Map<string, T[]>();
572
+ for (const row of rows) {
573
+ const item = mapRow(row) as unknown as T;
574
+ const bucket = map.get(item.caseId);
575
+ if (bucket) bucket.push(item);
576
+ else map.set(item.caseId, [item]);
577
+ }
578
+ return map;
579
+ }
580
+
415
581
  // ── Read operations ──────────────────────────────────────────────────
416
582
 
417
583
  export function readCasefile(): CaseRecord[] {
@@ -431,7 +597,17 @@ export function readCasefile(): CaseRecord[] {
431
597
  linkMap.get(link.source_id)?.push({ id: link.target_id, kind: link.kind });
432
598
  }
433
599
 
434
- return rows.map((row: any) => mapRow(row, linkMap.get(row.id) ?? []));
600
+ const ids = rows.map((r: any) => r.id);
601
+ const evidenceMap = fetchItemMap<EvidenceItem>(db, "evidence_items", ids);
602
+ const coverageMap = fetchItemMap<CoverageItem>(db, "coverage_items", ids);
603
+ return rows.map((row: any) =>
604
+ mapRow(
605
+ row,
606
+ linkMap.get(row.id) ?? [],
607
+ evidenceMap.get(row.id) ?? [],
608
+ coverageMap.get(row.id) ?? [],
609
+ ),
610
+ );
435
611
  }
436
612
 
437
613
  /**
@@ -455,10 +631,22 @@ export function getCaseById(id: string): CaseRecord | undefined {
455
631
 
456
632
  const linkStmt = db.prepare("SELECT target_id, kind FROM case_links WHERE source_id = ?");
457
633
  const links = linkStmt.all(id) as { target_id: string; kind: string }[];
634
+ const evidence = (
635
+ db
636
+ .prepare("SELECT * FROM evidence_items WHERE case_id = ? ORDER BY created_at")
637
+ .all(id) as any[]
638
+ ).map(mapEvidenceRow);
639
+ const coverage = (
640
+ db
641
+ .prepare("SELECT * FROM coverage_items WHERE case_id = ? ORDER BY created_at")
642
+ .all(id) as any[]
643
+ ).map(mapCoverageRow);
458
644
 
459
645
  return mapRow(
460
646
  row,
461
647
  links.map((l) => ({ id: l.target_id, kind: l.kind })),
648
+ evidence,
649
+ coverage,
462
650
  );
463
651
  }
464
652
 
@@ -466,6 +654,15 @@ export function getCaseById(id: string): CaseRecord | undefined {
466
654
 
467
655
  function validateCase(record: CaseRecord): void {
468
656
  if (!record.title.trim()) throw new Error("Case title cannot be empty");
657
+ // Falsification conditions are load-bearing: they are required at creation
658
+ // and must not be erasable later (CaseUpdate({ disproveIf: [] }) would wipe
659
+ // the hypothesis's falsifiability). Re-check on every write.
660
+ if (record.status !== "reported" && !(record.disproveIf ?? []).some((d) => d.trim())) {
661
+ throw new Error(
662
+ "Cases require disproveIf — falsification conditions (what would disprove this hypothesis). " +
663
+ "They cannot be cleared once set.",
664
+ );
665
+ }
469
666
  // Keep this gate in lockstep with promoteFindingResult: a case may only be
470
667
  // CONFIRMED when it has evidence, a PoC, demonstrated impact, and a severity.
471
668
  if (
@@ -502,6 +699,28 @@ function validateCase(record: CaseRecord): void {
502
699
  }
503
700
  }
504
701
 
702
+ /**
703
+ * Kill-reason vocabulary — a kill must name one of these (or carry refutation
704
+ * evidence). Single source of truth: the ledger gate AND the injected workflow
705
+ * text (workflow.ts imports this) must not drift apart.
706
+ */
707
+ export const KILL_REASON_VALUES = [
708
+ "intended_behavior",
709
+ "duplicate",
710
+ "framework_protection",
711
+ "exploit_unreliable",
712
+ "insufficient_impact",
713
+ "environmental_issue",
714
+ "not_applicable",
715
+ "out_of_scope",
716
+ "skeptic-disproven",
717
+ "no_attack_path",
718
+ "refuted",
719
+ ] as const;
720
+ export type KillReason = (typeof KILL_REASON_VALUES)[number];
721
+
722
+ const KILL_REASON_PATTERN = new RegExp(`\\b(${KILL_REASON_VALUES.join("|")})\\b`, "i");
723
+
505
724
  function validateTransition(
506
725
  from: CaseStatus,
507
726
  to: CaseStatus,
@@ -521,9 +740,28 @@ function validateTransition(
521
740
  );
522
741
  }
523
742
 
524
- if (to === "killed") return;
525
743
  if (to === "blocked") return;
526
744
 
745
+ if (to === "killed") {
746
+ // Black-cat rule: a kill must be justified. Valid iff (a) a refutation
747
+ // evidence item exists for this case, or (b) the update states a kill
748
+ // reason from the KILLED catalog vocabulary (matches workflow.ts).
749
+ const items = current ? listEvidenceItems(current.id) : [];
750
+ if (!items.some((e) => e.role === "refutation")) {
751
+ const text = [update.nextStep, (update.assumptions ?? []).join(" "), update.evidence]
752
+ .filter(Boolean)
753
+ .join(" ");
754
+ if (!KILL_REASON_PATTERN.test(text)) {
755
+ throw new Error(
756
+ "Cannot kill without justification: add refutation evidence (EvidenceAdd role=refutation) " +
757
+ "or state a kill reason in assumptions/nextStep (intended_behavior, duplicate, " +
758
+ "framework_protection, out_of_scope, skeptic-disproven, no_attack_path, ...)",
759
+ );
760
+ }
761
+ }
762
+ return;
763
+ }
764
+
527
765
  type Rule = (u: CaseUpdate, current?: CaseRecord) => string | null;
528
766
 
529
767
  // Transition rules must consult both the update payload AND the current record.
@@ -577,6 +815,14 @@ function validateNewCaseInput(input: CaseInput): void {
577
815
  "New cases must start as hypothesis or investigating; promote with CaseUpdate after validation",
578
816
  );
579
817
  }
818
+ // Black-cat style falsification: a hypothesis that cannot name what would
819
+ // disprove it is not a hypothesis yet. Required at creation (update later).
820
+ if (!input.disproveIf?.length || !input.disproveIf.some((d) => d.trim())) {
821
+ throw new Error(
822
+ "New cases require disproveIf — falsification conditions (what would disprove this hypothesis). " +
823
+ "Update them later via CaseUpdate if the picture changes.",
824
+ );
825
+ }
580
826
  if (input.status === "investigating") {
581
827
  if (!input.evidence) {
582
828
  throw new Error("New investigating cases require evidence (source→sink trace)");
@@ -613,16 +859,19 @@ function buildRecord(input: NormalizedCaseInput, existing?: CaseRecord): CaseRec
613
859
  blockers: normalizeList(input.blockers ?? existing?.blockers),
614
860
  tags: normalizeList(input.tags ?? existing?.tags),
615
861
  assumptions: normalizeList(input.assumptions ?? existing?.assumptions),
862
+ disproveIf: normalizeList(input.disproveIf ?? existing?.disproveIf),
616
863
  pocVerified: input.pocVerified ?? existing?.pocVerified,
617
864
  disconfirmation:
618
865
  input.disconfirmation !== undefined
619
866
  ? normalizeText(input.disconfirmation)
620
867
  : existing?.disconfirmation,
621
868
  disconfirmationVerified: input.disconfirmationVerified ?? existing?.disconfirmationVerified,
869
+ controlVerified: input.controlVerified ?? existing?.controlVerified,
622
870
  reportedAt: input.reportedAt ?? existing?.reportedAt,
623
871
  reportPath: input.reportPath ?? existing?.reportPath,
872
+ evidenceItems: existing?.evidenceItems ?? [],
873
+ coverageItems: existing?.coverageItems ?? [],
624
874
  linkedCases: existing?.linkedCases ?? [],
625
- linkedCaseIds: existing?.linkedCaseIds ?? [],
626
875
  createdAt: existing?.createdAt ?? timestamp,
627
876
  updatedAt: timestamp,
628
877
  };
@@ -877,13 +1126,13 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
877
1126
  id, title, status, confidence, severity, priority, target, endpoint, bugClass,
878
1127
  summary, evidence, impact, nextStep, poc, remediation,
879
1128
  references_json, blockers_json, tags_json, assumptions_json, poc_verified_json,
880
- disconfirmation, disconfirmation_verified_json,
1129
+ disconfirmation, disconfirmation_verified_json, disprove_if_json, control_verified_json,
881
1130
  reported_at, report_path, created_at, updated_at
882
1131
  ) VALUES (
883
1132
  ?, ?, ?, ?, ?, ?, ?, ?, ?,
884
1133
  ?, ?, ?, ?, ?, ?,
885
1134
  ?, ?, ?, ?, ?,
886
- ?, ?,
1135
+ ?, ?, ?, ?,
887
1136
  ?, ?, ?, ?
888
1137
  )
889
1138
  ON CONFLICT(id) DO UPDATE SET
@@ -908,6 +1157,8 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
908
1157
  poc_verified_json = excluded.poc_verified_json,
909
1158
  disconfirmation = excluded.disconfirmation,
910
1159
  disconfirmation_verified_json = excluded.disconfirmation_verified_json,
1160
+ disprove_if_json = excluded.disprove_if_json,
1161
+ control_verified_json = excluded.control_verified_json,
911
1162
  reported_at = excluded.reported_at,
912
1163
  report_path = excluded.report_path,
913
1164
  created_at = excluded.created_at,
@@ -937,6 +1188,8 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
937
1188
  record.pocVerified ? JSON.stringify(record.pocVerified) : null,
938
1189
  record.disconfirmation || null,
939
1190
  record.disconfirmationVerified ? JSON.stringify(record.disconfirmationVerified) : null,
1191
+ JSON.stringify(record.disproveIf),
1192
+ record.controlVerified ? JSON.stringify(record.controlVerified) : null,
940
1193
  record.reportedAt || null,
941
1194
  record.reportPath || null,
942
1195
  record.createdAt,
@@ -944,6 +1197,210 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
944
1197
  );
945
1198
  }
946
1199
 
1200
+ // ── Evidence items ──────────────────────────────────────────────────
1201
+
1202
+ function insertEvidenceItem(db: DatabaseSync, item: EvidenceItem): void {
1203
+ db.prepare(
1204
+ `INSERT INTO evidence_items (id, case_id, role, artifact_path, sha256, summary, created_at)
1205
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
1206
+ ).run(
1207
+ item.id,
1208
+ item.caseId,
1209
+ item.role,
1210
+ item.artifactPath ?? null,
1211
+ item.sha256 ?? null,
1212
+ item.summary,
1213
+ item.createdAt,
1214
+ );
1215
+ }
1216
+
1217
+ /**
1218
+ * Add a role-typed evidence item. Artifact path is hashed (SHA-256) and only
1219
+ * its basename is stored — the full path is never persisted (path-leak guard).
1220
+ */
1221
+ export function addEvidenceItemResult(
1222
+ caseId: string,
1223
+ input: { role: EvidenceRole; summary: string; artifactPath?: string },
1224
+ ): EvidenceItem {
1225
+ const db = getDb();
1226
+ const current = getCaseById(caseId);
1227
+ if (!current) throw new Error(`Case not found: ${caseId}`);
1228
+ if (current.status === "killed" || current.status === "reported") {
1229
+ throw new Error(`Cannot add evidence to terminal case ${caseId} (${current.status})`);
1230
+ }
1231
+ if (!(EVIDENCE_ROLE_VALUES as readonly string[]).includes(input.role)) {
1232
+ throw new Error(
1233
+ `Invalid evidence role: ${input.role}. Roles: ${EVIDENCE_ROLE_VALUES.join(", ")}`,
1234
+ );
1235
+ }
1236
+ const summary = normalizeText(input.summary);
1237
+ if (!summary) throw new Error("Evidence summary must not be empty");
1238
+
1239
+ let artifactPath: string | undefined;
1240
+ let sha256: string | undefined;
1241
+ if (input.artifactPath) {
1242
+ if (!existsSync(input.artifactPath)) {
1243
+ throw new Error(`Evidence artifact not found on disk: ${input.artifactPath}`);
1244
+ }
1245
+ const stat = statSync(input.artifactPath);
1246
+ if (!stat.isFile()) {
1247
+ throw new Error(`Evidence artifact is not a regular file: ${input.artifactPath}`);
1248
+ }
1249
+ if (stat.size > EVIDENCE_ARTIFACT_MAX_BYTES) {
1250
+ throw new Error(
1251
+ `Evidence artifact too large (${stat.size} bytes; max ${EVIDENCE_ARTIFACT_MAX_BYTES}): ${input.artifactPath}`,
1252
+ );
1253
+ }
1254
+ artifactPath = basename(input.artifactPath);
1255
+ sha256 = createHash("sha256").update(readFileSync(input.artifactPath)).digest("hex");
1256
+ }
1257
+
1258
+ const item: EvidenceItem = {
1259
+ id: `ev_${stableShortId(`${caseId}\n${summary}\n${randomUUID()}`)}`,
1260
+ caseId,
1261
+ role: input.role,
1262
+ artifactPath,
1263
+ sha256,
1264
+ summary,
1265
+ createdAt: new Date().toISOString(),
1266
+ };
1267
+ insertEvidenceItem(db, item);
1268
+ return item;
1269
+ }
1270
+
1271
+ export function listEvidenceItems(caseId: string): EvidenceItem[] {
1272
+ const db = getDb();
1273
+ return (
1274
+ db
1275
+ .prepare("SELECT * FROM evidence_items WHERE case_id = ? ORDER BY created_at")
1276
+ .all(caseId) as any[]
1277
+ ).map(mapEvidenceRow);
1278
+ }
1279
+
1280
+ // ── Coverage items ──────────────────────────────────────────────────
1281
+
1282
+ function insertCoverageItem(db: DatabaseSync, item: CoverageItem): void {
1283
+ db.prepare(
1284
+ `INSERT INTO coverage_items (id, case_id, asset, class, scope, note, tested_by, created_at)
1285
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
1286
+ ).run(
1287
+ item.id,
1288
+ item.caseId,
1289
+ item.asset,
1290
+ item.class,
1291
+ item.scope,
1292
+ item.note,
1293
+ item.testedBy ?? null,
1294
+ item.createdAt,
1295
+ );
1296
+ }
1297
+
1298
+ /**
1299
+ * Record a tested (asset × attack-class) cell. The cell's existence marks the
1300
+ * class tested for that asset — found OR clean. `scope: wide` means the verdict
1301
+ * is a property of the whole deployment: recorded once, applies to every asset
1302
+ * of the deployment (do NOT re-test per asset).
1303
+ */
1304
+ export function recordCoverageResult(
1305
+ caseId: string,
1306
+ input: {
1307
+ asset: string;
1308
+ class: string;
1309
+ scope: CoverageScope;
1310
+ note: string;
1311
+ testedBy?: string;
1312
+ },
1313
+ ): CoverageItem {
1314
+ const db = getDb();
1315
+ const current = getCaseById(caseId);
1316
+ if (!current) throw new Error(`Case not found: ${caseId}`);
1317
+ if (current.status === "killed" || current.status === "reported") {
1318
+ throw new Error(`Cannot record coverage on terminal case ${caseId} (${current.status})`);
1319
+ }
1320
+ if (!(COVERAGE_SCOPE_VALUES as readonly string[]).includes(input.scope)) {
1321
+ throw new Error(
1322
+ `Invalid coverage scope: ${input.scope}. Scope must be one of: ${COVERAGE_SCOPE_VALUES.join(", ")}`,
1323
+ );
1324
+ }
1325
+ const asset = normalizeText(input.asset);
1326
+ const attackClass = normalizeText(input.class);
1327
+ const note = normalizeText(input.note);
1328
+ if (!asset) throw new Error("Coverage asset must not be empty");
1329
+ if (!attackClass) throw new Error("Coverage class must not be empty");
1330
+ if (!note) throw new Error("Coverage note must not be empty");
1331
+
1332
+ const item: CoverageItem = {
1333
+ id: `cov_${stableShortId(`${caseId}\n${asset}\n${attackClass}\n${input.scope}\n${randomUUID()}`)}`,
1334
+ caseId,
1335
+ asset,
1336
+ class: attackClass,
1337
+ scope: input.scope,
1338
+ note,
1339
+ testedBy: input.testedBy ? normalizeText(input.testedBy) : undefined,
1340
+ createdAt: new Date().toISOString(),
1341
+ };
1342
+ insertCoverageItem(db, item);
1343
+ return item;
1344
+ }
1345
+
1346
+ export function listCoverage(caseId: string): CoverageItem[] {
1347
+ const db = getDb();
1348
+ return (
1349
+ db
1350
+ .prepare("SELECT * FROM coverage_items WHERE case_id = ? ORDER BY created_at")
1351
+ .all(caseId) as any[]
1352
+ ).map(mapCoverageRow);
1353
+ }
1354
+
1355
+ export type CoverageSummary = {
1356
+ items: CoverageItem[];
1357
+ /** Cells grouped per asset (wide cells repeated under every later asset they cover). */
1358
+ byAsset: Record<string, CoverageItem[]>;
1359
+ assets: string[];
1360
+ classes: string[];
1361
+ };
1362
+
1363
+ /**
1364
+ * Machine-checkable coverage view: which (asset × class) cells are tested.
1365
+ * A `wide` cell covers every asset recorded after it — a class with a wide
1366
+ * clean verdict must NOT be re-tested per asset (that is the wide semantics).
1367
+ */
1368
+ export function coverageSummary(caseId: string): CoverageSummary {
1369
+ const items = listCoverage(caseId);
1370
+ const byAsset: Record<string, CoverageItem[]> = {};
1371
+ const assets: string[] = [];
1372
+ const classes: string[] = [];
1373
+
1374
+ for (const item of items) {
1375
+ if (!assets.includes(item.asset)) assets.push(item.asset);
1376
+ if (!classes.includes(item.class)) classes.push(item.class);
1377
+ if (!byAsset[item.asset]) byAsset[item.asset] = [];
1378
+ byAsset[item.asset].push(item);
1379
+ }
1380
+ // Wide cells: a deployment-wide verdict covers every asset in the case. A
1381
+ // local cell for the same class on the same asset is more specific and wins
1382
+ // (the agent re-tested after the wide verdict — record shows both).
1383
+ const wideByClass = new Map<string, CoverageItem>();
1384
+ for (const item of items) {
1385
+ if (item.scope === "wide") {
1386
+ const prev = wideByClass.get(item.class);
1387
+ if (!prev || item.createdAt >= prev.createdAt) wideByClass.set(item.class, item);
1388
+ }
1389
+ }
1390
+ for (const [cls, wide] of wideByClass) {
1391
+ for (const asset of assets) {
1392
+ if (!byAsset[asset]) byAsset[asset] = [];
1393
+ const cells = byAsset[asset];
1394
+ const hasLocal = cells.some((c) => c.class === cls && c.scope === "local");
1395
+ const hasWide = cells.some((c) => c.class === cls && c.scope === "wide");
1396
+ if (!hasLocal && !hasWide) {
1397
+ cells.push({ ...wide, asset, note: `${wide.note} (wide verdict covers this asset)` });
1398
+ }
1399
+ }
1400
+ }
1401
+ return { items, byAsset, assets, classes };
1402
+ }
1403
+
947
1404
  export function addCaseResult(input: CaseInput): CaseAddResult {
948
1405
  const db = getDb();
949
1406
  validateNewCaseInput(input);
@@ -983,49 +1440,24 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
983
1440
  throw new Error("Cannot mutate a reported case; file a follow-up case instead");
984
1441
  }
985
1442
 
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
- );
1443
+ // buildRecord resolves every field as `update.x ?? current.x`; the patch
1444
+ // construction is the update itself.
1445
+ let next = buildRecord(update, current);
1020
1446
 
1021
1447
  if (update.status && update.status !== current.status) {
1022
1448
  validateTransition(current.status, next.status, update, current);
1023
1449
  }
1024
1450
 
1025
- // Demoting off confirmed invalidates prior PoC + disconfirmation verification
1026
- // re-promote required. Both verification artifacts must be re-earned together.
1451
+ // Demoting off confirmed invalidates prior PoC + disconfirmation + control
1452
+ // verification — re-promote required. All three artifacts must be re-earned
1453
+ // together (a stale control run must not survive a demote/re-promote cycle).
1027
1454
  if (current.status === "confirmed" && next.status === "investigating") {
1028
- next = { ...next, pocVerified: undefined, disconfirmationVerified: undefined };
1455
+ next = {
1456
+ ...next,
1457
+ pocVerified: undefined,
1458
+ disconfirmationVerified: undefined,
1459
+ controlVerified: undefined,
1460
+ };
1029
1461
  }
1030
1462
 
1031
1463
  validateCase(next);
@@ -1044,8 +1476,9 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
1044
1476
  if (
1045
1477
  k === "updatedAt" ||
1046
1478
  k === "createdAt" ||
1047
- k === "linkedCaseIds" ||
1048
- k === "linkedCases"
1479
+ k === "linkedCases" ||
1480
+ k === "evidenceItems" ||
1481
+ k === "coverageItems"
1049
1482
  ) {
1050
1483
  acc[k] = "";
1051
1484
  } else {
@@ -1083,6 +1516,8 @@ export type PocVerification = {
1083
1516
  ranAt: string;
1084
1517
  output?: string;
1085
1518
  sandbox: boolean;
1519
+ /** True iff the script ran to completion (not a spawn error / signal kill / timeout). */
1520
+ completed?: boolean;
1086
1521
  };
1087
1522
 
1088
1523
  /**
@@ -1128,6 +1563,8 @@ export function promoteFindingResult(
1128
1563
  id: string,
1129
1564
  verification: PocVerification,
1130
1565
  disconfirmationVerification?: PocVerification,
1566
+ controlVerification?: PocVerification,
1567
+ marker?: string,
1131
1568
  ): CaseUpdateResult {
1132
1569
  const db = getDb();
1133
1570
  const current = assertPromotable(id);
@@ -1137,6 +1574,62 @@ export function promoteFindingResult(
1137
1574
  );
1138
1575
  }
1139
1576
 
1577
+ // Anti-cheat, enforced at the ledger level (not just the tool): a live
1578
+ // finding (any non-sandboxed run — `sandbox` must be explicitly true to
1579
+ // skip the control; undefined/false from JS callers fails closed) must carry
1580
+ // a control-target verification that COMPLETED and, when the marker is known
1581
+ // to the caller, did NOT print the marker in its output. Checking presence
1582
+ // alone is not enough: a control run that crashed (completed: false) or that
1583
+ // printed the marker proves nothing about target-dependence.
1584
+ const isLive = verification.sandbox !== true;
1585
+ if (isLive) {
1586
+ const controlOk =
1587
+ controlVerification?.completed === true &&
1588
+ (!marker || !(controlVerification.output ?? "").includes(marker));
1589
+ if (!controlOk) {
1590
+ throw new Error(
1591
+ "Live findings (non-sandboxed PoC run) require a valid controlVerification: a control-target " +
1592
+ "run of the same PoC that COMPLETED (completed: true)" +
1593
+ (marker ? ` and whose output does not contain the marker "${marker}"` : "") +
1594
+ ". PromoteFinding requires control_path for local:true findings.",
1595
+ );
1596
+ }
1597
+ }
1598
+
1599
+ // Evidence-chain closure pre-check BEFORE any DB write: the observation item
1600
+ // must already exist. Checking first means a failed promote (missing
1601
+ // observation) writes nothing — no phantom reproduction item is left on an
1602
+ // investigating case, and a retry sees the real error, not a PK conflict.
1603
+ if (!current.evidenceItems.some((e) => e.role === "observation")) {
1604
+ throw new Error(
1605
+ "Evidence chain incomplete: CONFIRMED requires an observation evidence item " +
1606
+ "(EvidenceAdd role=observation — the initial signal, artifact-backed) in addition to " +
1607
+ "the auto-recorded reproduction item. Add the observation item and retry promotion.",
1608
+ );
1609
+ }
1610
+
1611
+ // Machine-recorded reproduction evidence: the PoC gate itself writes the
1612
+ // artifact-backed evidence item — confirmation is anchored to a real file
1613
+ // with its SHA-256, not to agent prose in the evidence field.
1614
+ let pocSha256: string | undefined;
1615
+ try {
1616
+ pocSha256 = createHash("sha256").update(readFileSync(verification.path)).digest("hex");
1617
+ } catch {
1618
+ pocSha256 = undefined;
1619
+ }
1620
+ const reproductionItem: EvidenceItem = {
1621
+ id: `ev_${stableShortId(`${id}\nreproduction\n${verification.ranAt}`)}`,
1622
+ caseId: id,
1623
+ role: "reproduction",
1624
+ artifactPath: basename(verification.path),
1625
+ sha256: pocSha256,
1626
+ summary: `PoC run exit ${verification.exitCode} (sandbox: ${verification.sandbox}) — verification marker present in output`,
1627
+ createdAt: verification.ranAt,
1628
+ };
1629
+ insertEvidenceItem(db, reproductionItem);
1630
+ // Attach to the record being validated (current was fetched pre-insert).
1631
+ current.evidenceItems = [...(current.evidenceItems ?? []), reproductionItem];
1632
+
1140
1633
  const newEvidence =
1141
1634
  (current.evidence ? `${current.evidence}\n\n` : "") +
1142
1635
  `### PoC Execution Capture (${verification.ranAt})\n` +
@@ -1152,6 +1645,9 @@ export function promoteFindingResult(
1152
1645
  if (disconfirmationVerification) {
1153
1646
  update.disconfirmationVerified = disconfirmationVerification;
1154
1647
  }
1648
+ if (controlVerification) {
1649
+ update.controlVerified = controlVerification;
1650
+ }
1155
1651
 
1156
1652
  const next = buildRecord(update, current);
1157
1653
  validateCase(next);
@@ -1160,53 +1656,254 @@ export function promoteFindingResult(
1160
1656
  return { record: next, changed: true };
1161
1657
  }
1162
1658
 
1163
- // ── Link operations ──────────────────────────────────────────────────
1659
+ // ── Chain suggestions ───────────────────────────────────────────────
1660
+
1661
+ /** Automated exploit-chain patterns (ported shape from CyberStrike chain.ts). */
1662
+ const CHAIN_PATTERN_VALUES = [
1663
+ "credential_endpoint",
1664
+ "info_disclosure_ssrf",
1665
+ "redirect_oauth",
1666
+ "idor_data_leak",
1667
+ "xss_csrf",
1668
+ "ssti_rce",
1669
+ "race_condition_business",
1670
+ ] as const;
1671
+ export type ChainPattern = (typeof CHAIN_PATTERN_VALUES)[number];
1672
+
1673
+ export type ChainSuggestion = {
1674
+ pattern: ChainPattern;
1675
+ sourceId: string;
1676
+ targetId?: string;
1677
+ sourceTitle: string;
1678
+ targetTitle?: string;
1679
+ rationale: string;
1680
+ confidence: number;
1681
+ /** Suggested CaseLink kind when the agent links the pair. */
1682
+ suggestedKind?: CaseLinkKind;
1683
+ };
1164
1684
 
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");
1685
+ // Word-boundary anchored so "admin" does not match "administration" and
1686
+ // "update" does not match "updated" — substring matching over-mines pairs.
1687
+ const CHAIN_CLASS_RE = {
1688
+ credential: /\b(credential|password|api[ -]?key|token|secret|leak|dump|exposure)\b/i,
1689
+ authEndpoint: /\b(auth|login|sso|signup|account|admin|endpoint|api)\b/i,
1690
+ redirect: /\b(open redirect|redirect)\b/i,
1691
+ oauth: /\b(oauth|callback|redirect_uri|sso|saml|openid|authorize)\b/i,
1692
+ xss: /\b(xss|cross-?site.?script)\b/i,
1693
+ stateChange:
1694
+ /\b(POST|PUT|DELETE|PATCH|create|update|delete|transfer|payment|invite|admin|state.?chang)\b/i,
1695
+ idor: /\b(idor|bola|object reference|broken access)\b/i,
1696
+ userData:
1697
+ /\b(user|users|profile|account|accounts|email|phone|address|personal|private|settings|data)\b/i,
1698
+ ssti: /\b(ssti|template injection|template render)\b/i,
1699
+ race: /\b(race|toctou|concurrent)\b/i,
1700
+ payment: /\b(payment|transfer|order|checkout|cart|purchase|balance|credit|withdraw|deposit)\b/i,
1701
+ infoDisclosure: /\b(info disclosure|information disclosure|leak|exposure|debug)\b/i,
1702
+ ssrf: /\b(ssrf|server-?side request)\b/i,
1703
+ } satisfies Record<string, RegExp>;
1704
+
1705
+ /** Multi-label second-level suffixes — *.co.uk must not false-pair via last-2 labels. */
1706
+ const SECOND_LEVEL_SUFFIXES = new Set([
1707
+ "co",
1708
+ "com",
1709
+ "org",
1710
+ "net",
1711
+ "gov",
1712
+ "ac",
1713
+ "edu",
1714
+ "mil",
1715
+ "ltd",
1716
+ "me",
1717
+ "tv",
1718
+ "info",
1719
+ "biz",
1720
+ ]);
1721
+
1722
+ function eTLDPlus1(host: string): string {
1723
+ const parts = host.split(".");
1724
+ if (parts.length >= 3 && SECOND_LEVEL_SUFFIXES.has(parts[parts.length - 2] ?? "")) {
1725
+ return parts.slice(-3).join(".");
1169
1726
  }
1170
- const resolvedKind: CaseLinkKind =
1171
- kind && (LINK_KIND_VALUES as readonly string[]).includes(kind)
1172
- ? (kind as CaseLinkKind)
1173
- : DEFAULT_LINK_KIND;
1727
+ return parts.slice(-2).join(".");
1728
+ }
1729
+
1730
+ function chainText(c: CaseRecord): string {
1731
+ return [c.title, c.bugClass ?? "", c.evidence ?? ""].join(" ");
1732
+ }
1733
+
1734
+ function hasChainClass(c: CaseRecord, re: RegExp): boolean {
1735
+ return re.test(chainText(c));
1736
+ }
1737
+
1738
+ /** Reduce a target string to a bare hostname (strip scheme, port, path). */
1739
+ function normalizeTargetHost(target: string): string {
1740
+ let h = target
1741
+ .toLowerCase()
1742
+ .trim()
1743
+ .replace(/^[a-z][a-z0-9+.-]*:\/\//, "");
1744
+ h = h.split("?")[0].split("/")[0].split(":")[0];
1745
+ return h.trim();
1746
+ }
1747
+
1748
+ /** Same asset or related (same eTLD+1) — chains only pair cases on one target. */
1749
+ function sameAssetOrRelated(a: CaseRecord, b: CaseRecord): boolean {
1750
+ const ta = normalizeTargetHost(a.target ?? "");
1751
+ const tb = normalizeTargetHost(b.target ?? "");
1752
+ if (!ta || !tb) return false;
1753
+ if (ta === tb) return true;
1754
+ // Subdomain relation requires a label boundary: "api.example.com" vs
1755
+ // "example.com" pair, but "myshop.io" vs "shop.io" do NOT — a bare
1756
+ // substring check pairs unrelated targets whose names merely overlap.
1757
+ if (ta.endsWith(`.${tb}`) || tb.endsWith(`.${ta}`)) return true;
1758
+ return eTLDPlus1(ta) === eTLDPlus1(tb);
1759
+ }
1760
+
1761
+ /**
1762
+ * Scan non-terminal cases for exploitable chains (CyberStrike-style detection
1763
+ * over XPI's case records). Emits ranked suggestions; the agent decides
1764
+ * whether to CaseLink or open an escalation case.
1765
+ */
1766
+ export function suggestChains(caseId?: string): ChainSuggestion[] {
1767
+ // Pair over ALL non-terminal cases; the caseId filter narrows the RESULTS
1768
+ // to suggestions involving that case (filtering the inputs first would drop
1769
+ // unlinked partner cases and kill cross-case pairing).
1770
+ const cases = readCasefile().filter((c) => c.status !== "killed" && c.status !== "reported");
1771
+ const suggestions: ChainSuggestion[] = [];
1772
+ const seen = new Set<string>();
1773
+ const confirmed = (c: CaseRecord) => c.status === "confirmed";
1774
+ const confidenceFor = (a: CaseRecord, b?: CaseRecord) => {
1775
+ const both = confirmed(a) && (!b || confirmed(b));
1776
+ const one = confirmed(a) || (b ? confirmed(b) : false);
1777
+ return both ? 90 : one ? 75 : 55;
1778
+ };
1779
+ const add = (
1780
+ pattern: ChainPattern,
1781
+ a: CaseRecord,
1782
+ b: CaseRecord | undefined,
1783
+ rationale: string,
1784
+ kind?: CaseLinkKind,
1785
+ ) => {
1786
+ const key = b ? `${pattern}:${[a.id, b.id].sort().join("+")}` : `${pattern}:${a.id}`;
1787
+ if (seen.has(key)) return;
1788
+ seen.add(key);
1789
+ suggestions.push({
1790
+ pattern,
1791
+ sourceId: a.id,
1792
+ targetId: b?.id,
1793
+ sourceTitle: a.title,
1794
+ targetTitle: b?.title,
1795
+ rationale,
1796
+ confidence: confidenceFor(a, b),
1797
+ suggestedKind: kind,
1798
+ });
1799
+ };
1800
+
1801
+ // Pair rules as data: (classifier A, classifier B, rationale, link kind).
1802
+ // One loop replaces seven copy-pasted pair loops.
1803
+ const PAIR_RULES: Array<{
1804
+ pattern: Exclude<ChainPattern, "ssti_rce">;
1805
+ a: RegExp;
1806
+ b: RegExp;
1807
+ rationale: (a: CaseRecord, b: CaseRecord) => string;
1808
+ kind?: CaseLinkKind;
1809
+ }> = [
1810
+ {
1811
+ pattern: "credential_endpoint",
1812
+ a: CHAIN_CLASS_RE.credential,
1813
+ b: CHAIN_CLASS_RE.authEndpoint,
1814
+ kind: "depends-on",
1815
+ rationale: (a, b) =>
1816
+ `Use leaked credential "${a.title}" to authenticate against "${b.title}" → account takeover`,
1817
+ },
1818
+ {
1819
+ pattern: "redirect_oauth",
1820
+ a: CHAIN_CLASS_RE.redirect,
1821
+ b: CHAIN_CLASS_RE.oauth,
1822
+ rationale: (a, b) =>
1823
+ `Chain open redirect "${a.title}" into OAuth flow "${b.title}" to steal access tokens`,
1824
+ },
1825
+ {
1826
+ pattern: "xss_csrf",
1827
+ a: CHAIN_CLASS_RE.xss,
1828
+ b: CHAIN_CLASS_RE.stateChange,
1829
+ rationale: (a, b) =>
1830
+ `Use XSS "${a.title}" to drive state-changing "${b.title}" (CSRF bypass / victim-action)`,
1831
+ },
1832
+ {
1833
+ pattern: "idor_data_leak",
1834
+ a: CHAIN_CLASS_RE.idor,
1835
+ b: CHAIN_CLASS_RE.userData,
1836
+ rationale: (a, b) => `Use IDOR "${a.title}" to enumerate user data via "${b.title}"`,
1837
+ },
1838
+ {
1839
+ pattern: "race_condition_business",
1840
+ a: CHAIN_CLASS_RE.race,
1841
+ b: CHAIN_CLASS_RE.payment,
1842
+ rationale: (a, b) =>
1843
+ `Use race condition "${a.title}" on financial endpoint "${b.title}" (double-spend / bypass)`,
1844
+ },
1845
+ {
1846
+ pattern: "info_disclosure_ssrf",
1847
+ a: CHAIN_CLASS_RE.infoDisclosure,
1848
+ b: CHAIN_CLASS_RE.ssrf,
1849
+ rationale: (a, b) =>
1850
+ `Use internal URL/config from "${a.title}" as SSRF target via "${b.title}"`,
1851
+ },
1852
+ ];
1853
+
1854
+ for (const rule of PAIR_RULES) {
1855
+ const aCases = cases.filter((c) => rule.a.test(chainText(c)));
1856
+ const bCases = cases.filter((c) => rule.b.test(chainText(c)));
1857
+ for (const a of aCases) {
1858
+ for (const b of bCases) {
1859
+ if (a.id === b.id || !sameAssetOrRelated(a, b)) continue;
1860
+ add(rule.pattern, a, b, rule.rationale(a, b), rule.kind);
1861
+ }
1862
+ }
1863
+ }
1864
+
1865
+ // SSTI → RCE (single-case escalation)
1866
+ for (const s of cases.filter((c) => hasChainClass(c, CHAIN_CLASS_RE.ssti))) {
1867
+ add("ssti_rce", s, undefined, `Escalate SSTI "${s.title}" to RCE via template-engine gadgets`);
1868
+ }
1869
+
1870
+ const scoped = caseId
1871
+ ? suggestions.filter((s) => s.sourceId === caseId || s.targetId === caseId)
1872
+ : suggestions;
1873
+ return scoped.sort((a, b) => b.confidence - a.confidence);
1874
+ }
1875
+
1876
+ // ── Link operations ──────────────────────────────────────────────────
1877
+
1878
+ /** Both cases must exist and be mutable (not killed/reported). */
1879
+ function assertMutablePair(
1880
+ sourceId: string,
1881
+ targetId: string,
1882
+ verb: "link" | "unlink",
1883
+ ): { source: CaseRecord; target: CaseRecord } {
1174
1884
  const source = getCaseById(sourceId);
1175
1885
  const target = getCaseById(targetId);
1176
1886
  if (!source) throw new Error(`Case not found: ${sourceId}`);
1177
1887
  if (!target) throw new Error(`Case not found: ${targetId}`);
1178
1888
  if (source.status === "killed" || source.status === "reported") {
1179
- throw new Error(`Cannot link terminal case ${sourceId} (${source.status})`);
1889
+ throw new Error(`Cannot ${verb} terminal case ${sourceId} (${source.status})`);
1180
1890
  }
1181
1891
  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
- };
1892
+ throw new Error(`Cannot ${verb} terminal case ${targetId} (${target.status})`);
1196
1893
  }
1894
+ return { source, target };
1895
+ }
1197
1896
 
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];
1897
+ /** Run a case_links mutation + updated_at touch inside one transaction. */
1898
+ function withLinkTx(
1899
+ db: DatabaseSync,
1900
+ sourceId: string,
1901
+ targetId: string,
1902
+ mutate: (db: DatabaseSync) => void,
1903
+ ): void {
1202
1904
  db.exec("BEGIN");
1203
1905
  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
-
1906
+ mutate(db);
1210
1907
  const now = new Date().toISOString();
1211
1908
  const updateTimeStmt = db.prepare("UPDATE cases SET updated_at = ? WHERE id = ?");
1212
1909
  updateTimeStmt.run(now, sourceId);
@@ -1220,82 +1917,82 @@ export function linkCasesResult(sourceId: string, targetId: string, kind?: strin
1220
1917
  }
1221
1918
  throw err;
1222
1919
  }
1920
+ }
1223
1921
 
1224
- const finalSource = getCaseById(sourceId)!;
1225
- const finalTarget = getCaseById(targetId)!;
1226
- return { source: finalSource, target: finalTarget, changed: true, kind: resolvedKind };
1922
+ function existingLinkKind(
1923
+ db: DatabaseSync,
1924
+ sourceId: string,
1925
+ targetId: string,
1926
+ ): string | undefined {
1927
+ return (
1928
+ db
1929
+ .prepare("SELECT kind FROM case_links WHERE source_id = ? AND target_id = ?")
1930
+ .get(sourceId, targetId) as { kind: string } | undefined
1931
+ )?.kind;
1227
1932
  }
1228
1933
 
1229
- export function unlinkCasesResult(sourceId: string, targetId: string): CaseLinkResult {
1934
+ export function linkCasesResult(sourceId: string, targetId: string, kind?: string): CaseLinkResult {
1230
1935
  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})`);
1936
+ if (sourceId === targetId) {
1937
+ throw new Error("Cannot link a case to itself");
1237
1938
  }
1238
- if (target.status === "killed" || target.status === "reported") {
1239
- throw new Error(`Cannot unlink terminal case ${targetId} (${target.status})`);
1939
+ const resolvedKind: CaseLinkKind =
1940
+ kind && (LINK_KIND_VALUES as readonly string[]).includes(kind)
1941
+ ? (kind as CaseLinkKind)
1942
+ : DEFAULT_LINK_KIND;
1943
+ const { source, target } = assertMutablePair(sourceId, targetId, "link");
1944
+
1945
+ const existing = existingLinkKind(db, sourceId, targetId);
1946
+ if (existing) {
1947
+ return { source, target, changed: false, reason: "Cases are already linked", kind: existing };
1240
1948
  }
1241
1949
 
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;
1950
+ // Atomic insert both directions: source→target keeps the stated kind, the
1951
+ // reverse row stores the inverse so each case lists the edge from its own
1952
+ // perspective.
1953
+ const inverseKind = LINK_KIND_INVERSE[resolvedKind];
1954
+ withLinkTx(db, sourceId, targetId, (tx) => {
1955
+ const linkStmt = tx.prepare(
1956
+ "INSERT INTO case_links (source_id, target_id, kind) VALUES (?, ?, ?)",
1957
+ );
1958
+ linkStmt.run(sourceId, targetId, resolvedKind);
1959
+ linkStmt.run(targetId, sourceId, inverseKind);
1960
+ });
1961
+
1962
+ return {
1963
+ source: getCaseById(sourceId)!,
1964
+ target: getCaseById(targetId)!,
1965
+ changed: true,
1966
+ kind: resolvedKind,
1967
+ };
1968
+ }
1969
+
1970
+ export function unlinkCasesResult(sourceId: string, targetId: string): CaseLinkResult {
1971
+ const db = getDb();
1972
+ const { source, target } = assertMutablePair(sourceId, targetId, "unlink");
1244
1973
 
1974
+ const existing = existingLinkKind(db, sourceId, targetId);
1245
1975
  if (!existing) {
1246
1976
  return { source, target, changed: false, reason: "Cases are not linked", kind: "related" };
1247
1977
  }
1248
1978
 
1249
- db.exec("BEGIN");
1250
- try {
1251
- const unlinkStmt = db.prepare(
1979
+ withLinkTx(db, sourceId, targetId, (tx) => {
1980
+ tx.prepare(
1252
1981
  "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
- }
1982
+ ).run(sourceId, targetId, targetId, sourceId);
1983
+ });
1269
1984
 
1270
- const finalSource = getCaseById(sourceId)!;
1271
- const finalTarget = getCaseById(targetId)!;
1272
- return { source: finalSource, target: finalTarget, changed: true, kind: existing.kind };
1985
+ return {
1986
+ source: getCaseById(sourceId)!,
1987
+ target: getCaseById(targetId)!,
1988
+ changed: true,
1989
+ kind: existing,
1990
+ };
1273
1991
  }
1274
1992
 
1275
1993
  // ── Search & Queries ─────────────────────────────────────────────────
1276
1994
 
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
- };
1995
+ // Search field names double as their column names (SEARCH_FIELD_VALUES above).
1299
1996
 
1300
1997
  function severityRank(s: CaseSeverity): number {
1301
1998
  return SEVERITY_VALUES.indexOf(s);
@@ -1354,12 +2051,12 @@ function buildCaseWhere(options: CaseSearchOptions): {
1354
2051
  if (query) {
1355
2052
  const likeParam = `%${query}%`;
1356
2053
  if (options.field) {
1357
- where.push(`lower(${FIELD_COLUMN[options.field]}) LIKE ?`);
2054
+ where.push(`lower(${options.field}) LIKE ?`);
1358
2055
  params.push(likeParam);
1359
2056
  } else {
1360
- const ors = SEARCH_COLUMNS.map((c) => `lower(${c}) LIKE ?`).join(" OR ");
2057
+ const ors = SEARCH_FIELD_VALUES.map((c) => `lower(${c}) LIKE ?`).join(" OR ");
1361
2058
  where.push(`(${ors})`);
1362
- for (let i = 0; i < SEARCH_COLUMNS.length; i++) params.push(likeParam);
2059
+ for (let i = 0; i < SEARCH_FIELD_VALUES.length; i++) params.push(likeParam);
1363
2060
  }
1364
2061
  }
1365
2062
 
@@ -1465,7 +2162,7 @@ export function formatCaseDetail(record: CaseRecord): string {
1465
2162
  if (
1466
2163
  !val ||
1467
2164
  (Array.isArray(val) && !val.length) ||
1468
- ["id", "createdAt", "updatedAt", "linkedCaseIds"].includes(key)
2165
+ ["id", "createdAt", "updatedAt"].includes(key)
1469
2166
  )
1470
2167
  continue;
1471
2168
  const label = key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, " $1");
@@ -1474,6 +2171,17 @@ export function formatCaseDetail(record: CaseRecord): string {
1474
2171
  display = (val as { id: string; kind: string }[])
1475
2172
  .map((l) => `${l.id} (${l.kind})`)
1476
2173
  .join(", ");
2174
+ } else if (key === "evidenceItems") {
2175
+ display = (val as EvidenceItem[])
2176
+ .map(
2177
+ (e) =>
2178
+ `[${e.role}] ${e.summary}${e.artifactPath ? ` — \`${e.artifactPath}\` sha256:\`${e.sha256?.slice(0, 12) ?? "?"}\`` : ""} (${e.createdAt})`,
2179
+ )
2180
+ .join("\n");
2181
+ } else if (key === "coverageItems") {
2182
+ display = (val as CoverageItem[])
2183
+ .map((c) => `[${c.scope}] ${c.asset} × ${c.class} — ${c.note}`)
2184
+ .join("\n");
1477
2185
  } else if (Array.isArray(val)) {
1478
2186
  display = val.join(", ");
1479
2187
  } else if (typeof val === "object") {
@@ -1500,17 +2208,8 @@ function mdSection(title: string, body?: string): string {
1500
2208
  // pipeline artifacts (recon entry points, traces, skeptic verdicts, PoC logs)
1501
2209
  // from any scratchpad run that produced this case.
1502
2210
 
1503
- const CONTEXT_PHASES: ScratchpadPhase[] = [
1504
- "recon",
1505
- "hunt",
1506
- "gapfil",
1507
- "trace",
1508
- "skeptic",
1509
- "validate",
1510
- "chain",
1511
- "patch",
1512
- "report",
1513
- ];
2211
+ // Context bundles cover every pipeline phase (imported from the scratchpad
2212
+ // where the canonical order lives).
1514
2213
 
1515
2214
  /** Per-artifact content cap for the context bundle (generous; artifacts are small). */
1516
2215
  const MAX_ARTIFACT_CHARS = 100_000;
@@ -1521,9 +2220,13 @@ function buildCompleteRecord(current: CaseRecord): string {
1521
2220
  if (v === undefined || v === null || v === "") continue;
1522
2221
  let display = typeof v === "object" ? JSON.stringify(v, null, 2) : String(v);
1523
2222
  // Path-leak guard: the verification objects carry the researcher's local
1524
- // PoC/disconfirmation script paths — show basenames only (the dedicated
2223
+ // PoC/disconfirmation/control script paths — show basenames only (the dedicated
1525
2224
  // log sections below already render them as basenames).
1526
- if ((k === "pocVerified" || k === "disconfirmationVerified") && v && typeof v === "object") {
2225
+ if (
2226
+ (k === "pocVerified" || k === "disconfirmationVerified" || k === "controlVerified") &&
2227
+ v &&
2228
+ typeof v === "object"
2229
+ ) {
1527
2230
  const redacted = {
1528
2231
  ...(v as Record<string, unknown>),
1529
2232
  path: basename((v as { path?: string }).path ?? ""),
@@ -1586,7 +2289,7 @@ function buildScratchpadSection(caseId: string): string {
1586
2289
  if (!allIds.includes(caseId) && !namedInArtifact) continue;
1587
2290
 
1588
2291
  sections.push(`### Run: ${entry.name} (project root: ${resume.checkpoint.project_root})`);
1589
- for (const phase of CONTEXT_PHASES) {
2292
+ for (const phase of PHASE_ORDER) {
1590
2293
  const names = resume.artifacts[phase];
1591
2294
  if (!names?.length) continue;
1592
2295
  sections.push(`#### ${phase}/`);
@@ -1617,6 +2320,20 @@ export function writeCaseContext(id: string): {
1617
2320
  throw new Error("Case context requires a confirmed or reported case");
1618
2321
  }
1619
2322
 
2323
+ // Report-time evidence-chain closure: a report bundle for a confirmed case
2324
+ // must carry the full observation → reproduction chain. A confirmed case
2325
+ // without it (e.g. promoted before the gate existed) is not reportable.
2326
+ if (
2327
+ current.status === "confirmed" &&
2328
+ (!current.evidenceItems?.some((e) => e.role === "observation") ||
2329
+ !current.evidenceItems?.some((e) => e.role === "reproduction"))
2330
+ ) {
2331
+ throw new Error(
2332
+ `Case ${id} is confirmed but lacks the evidence chain (observation + reproduction items). ` +
2333
+ "Add the missing EvidenceAdd items before generating the report context.",
2334
+ );
2335
+ }
2336
+
1620
2337
  const db = getDb();
1621
2338
  const dbPath = getCasefilePath();
1622
2339
 
@@ -1669,6 +2386,12 @@ export function writeCaseContext(id: string): {
1669
2386
  `### 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
2387
  )
1671
2388
  : undefined,
2389
+ current.controlVerified
2390
+ ? mdSection(
2391
+ "Control-Target Check (anti-cheat)",
2392
+ `### 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\`\`\``,
2393
+ )
2394
+ : undefined,
1672
2395
  mdSection("Disconfirmation Attempt", current.disconfirmation),
1673
2396
  current.disconfirmationVerified
1674
2397
  ? mdSection(
@@ -1677,6 +2400,17 @@ export function writeCaseContext(id: string): {
1677
2400
  )
1678
2401
  : undefined,
1679
2402
  mdSection("Impact", current.impact),
2403
+ mdSection(
2404
+ "Evidence Items (role-typed, hashed)",
2405
+ current.evidenceItems.length
2406
+ ? current.evidenceItems
2407
+ .map(
2408
+ (e) =>
2409
+ `- [${e.role}] ${e.summary}${e.artifactPath ? ` — artifact \`${e.artifactPath}\` sha256 \`${e.sha256 ?? "?"}\`` : ""} (${e.createdAt})`,
2410
+ )
2411
+ .join("\n")
2412
+ : "None recorded.",
2413
+ ),
1680
2414
  mdSection("Remediation", current.remediation),
1681
2415
  mdSection("Assumptions and Uncertainty", assumptions),
1682
2416
  mdSection("References", references),