@xaccefy/pi-casefile 0.9.3 → 0.10.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
@@ -23,15 +23,24 @@ import {
23
23
  writeFileSync,
24
24
  } from "node:fs";
25
25
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
26
+ import type { MainAgentVerdict, PoCEvidence } from "./evidence.ts";
27
+ import type { HarnessVerifyResult } from "./harness-verify.ts";
26
28
  import {
27
- evidenceNonceMatches,
28
- type MainAgentVerdict,
29
- normalizeEvidence,
30
- type PoCEvidence,
31
- parsePoCEvidence,
32
- validateMainAgentVerdict,
33
- } from "./evidence.ts";
34
- import { type HarnessVerifyResult, sameRequest, verifyUrlBindingError } from "./harness-verify.ts";
29
+ buildRecord,
30
+ closeDb as closeSharedDb,
31
+ getDb as getSharedDb,
32
+ hasDbInstance as hasSharedDb,
33
+ insertEvidenceItem,
34
+ normalizeMatchText,
35
+ normalizeText,
36
+ setDbInstance,
37
+ setDbOpener,
38
+ setValidateReportFile,
39
+ stableShortId,
40
+ upsertCase,
41
+ validateCase,
42
+ withImmediateTransaction,
43
+ } from "./ledger-internal.ts";
35
44
  import {
36
45
  assertSafeRegularFile,
37
46
  ensureSafeStateDirectory,
@@ -46,8 +55,22 @@ import {
46
55
  scratchpad_resume,
47
56
  scratchpad_runs,
48
57
  } from "./scratchpad.ts";
58
+
59
+ // Two-phase PoC confirmation gate — extracted module, re-exported so callers
60
+ // (extension index, tests) keep importing from ledger.
61
+ export {
62
+ applyConfirmationResult,
63
+ assertPromotable,
64
+ PENDING_CONFIRM_TTL_MS,
65
+ storePendingConfirmation,
66
+ } from "./confirmation.ts";
67
+
49
68
  import { DatabaseSync } from "./sqlite-compat/index.ts";
50
69
 
70
+ // Register the shared opener so sibling modules (chains/objectives/confirmation)
71
+ // can lazy-open the ledger through ledger-internal without importing ledger.
72
+ setDbOpener(() => openAndRegisterDb());
73
+
51
74
  // ── Types ────────────────────────────────────────────────────────────
52
75
 
53
76
  export const STATUS_VALUES = [
@@ -71,19 +94,15 @@ export type CasePriority = (typeof PRIORITY_VALUES)[number];
71
94
 
72
95
  /** Cap on hashed evidence artifacts (10 MiB) — keeps readFileSync bounded. */
73
96
  const EVIDENCE_ARTIFACT_MAX_BYTES = 10 * 1024 * 1024;
74
- /** PoC evidence has a tighter runner-side cap and must remain equally bounded on re-read. */
75
- const POC_EVIDENCE_MAX_BYTES = 256 * 1024;
76
97
  /** Avoid racing an active or just-finished PoC whose bundle is not committed yet. */
77
98
  export const POC_EVIDENCE_GC_GRACE_MS = 24 * 60 * 60 * 1000;
78
- /** Immutable module-start role; child shells cannot upgrade this process by unsetting an env var. */
79
- const PROCESS_STARTED_AS_SUBAGENT = process.env.PI_SUBAGENT_CHILD === "1";
80
99
 
81
100
  function pathIsWithin(root: string, candidate: string): boolean {
82
101
  const rel = relative(root, candidate);
83
102
  return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
84
103
  }
85
104
 
86
- function readWorkspaceArtifact(inputPath: string): { path: string; bytes: Buffer } {
105
+ export function readWorkspaceArtifact(inputPath: string): { path: string; bytes: Buffer } {
87
106
  const workspace = realpathSync(detectWorkspaceRoot());
88
107
  const requested = resolve(workspace, inputPath);
89
108
  if (!existsSync(requested)) {
@@ -255,6 +274,8 @@ export type CaseRecord = {
255
274
  disproveIf?: string[];
256
275
  /** Agent's documented attempt to disprove the finding (required before CONFIRMED). */
257
276
  disconfirmation?: string;
277
+ /** Security invariant this finding violates (the rule broken, e.g. "a user cannot read another user's orders"). Confirmation checks the invariant is actually violated, not just that a request succeeded. */
278
+ invariant?: string;
258
279
  /** Verification of an on-disk PoC run (set only by promoteFindingResult). */
259
280
  pocVerified?: PocVerificationRecord;
260
281
  /** Verification of a disconfirmation run (set only by promoteFindingResult). */
@@ -345,11 +366,28 @@ export type PendingConfirmation = {
345
366
  controlRun?: PocEvidenceRun;
346
367
  /** Harness's own replay of evidence.verify (public targets). Absent = legacy bundle. */
347
368
  harnessVerified?: HarnessVerifyResult;
369
+ /** OOB-only bundles: per-run oracle tokens so phase-2 can re-poll freshly.
370
+ * Stored raw deliberately: the oracle is operator-owned and bearer-gated,
371
+ * so a ledger reader without oracle write access cannot fabricate hits. */
372
+ oobTokens?: { targetToken: string; controlToken: string };
348
373
  /** Harness-owned OOB listener log for the run (opt-in blind classes). */
349
374
  callbackVerified?: OobVerification;
350
375
  };
351
376
 
352
- /** Fresh machine transcript produced inside the main agent's ConfirmFinding call. */
377
+ /**
378
+ * Fresh machine transcript produced inside the main agent's ConfirmFinding call.
379
+ *
380
+ * BOUNDARY NOTE: the ledger enforces the STRUCTURAL floor on this object —
381
+ * valid timestamp newer than phase 1 and ≤5 minutes old, target/control
382
+ * binding, conclusive `target_only` differential, canary transcript when
383
+ * requested (see assertMainAgentVerification). What it cannot enforce at this
384
+ * API boundary is WHO executed the replay: in production the only caller is
385
+ * the PromoteFinding/ConfirmFinding tool layer in index.ts, which runs the
386
+ * replay itself before calling applyConfirmationResult. A second integration
387
+ * calling applyConfirmationResult directly owns the provenance of the
388
+ * transcript it passes. Cross-process identity limits are documented in
389
+ * docs/confirmation-design.md §7 (honest limits).
390
+ */
353
391
  export type MainAgentVerification = {
354
392
  at: string;
355
393
  result: HarnessVerifyResult;
@@ -368,9 +406,6 @@ export type MainAgentVerdictRecord = MainAgentVerdict & {
368
406
  /** @deprecated Compatibility alias for the legacy database/API field name. */
369
407
  export type ConfirmerVerdictRecord = MainAgentVerdictRecord;
370
408
 
371
- /** Pending confirmation expires after 1h — re-run PromoteFinding for a fresh bundle. */
372
- export const PENDING_CONFIRM_TTL_MS = 60 * 60 * 1000;
373
-
374
409
  export type CaseInput = {
375
410
  title: string;
376
411
  status?: CaseStatus;
@@ -394,9 +429,11 @@ export type CaseInput = {
394
429
  disproveIf?: string[];
395
430
  /** Agent's documented attempt to disprove the finding (required before CONFIRMED). */
396
431
  disconfirmation?: string;
432
+ /** Security invariant this finding violates. */
433
+ invariant?: string;
397
434
  };
398
435
 
399
- type NormalizedCaseInput = Partial<CaseInput> & {
436
+ export type NormalizedCaseInput = Partial<CaseInput> & {
400
437
  pocVerified?: CaseRecord["pocVerified"];
401
438
  disconfirmationVerified?: CaseRecord["disconfirmationVerified"];
402
439
  controlVerified?: CaseRecord["controlVerified"];
@@ -452,24 +489,7 @@ export type CaseSearchOptions = {
452
489
  // ── Globals & Environment ─────────────────────────────────────────────
453
490
 
454
491
  let ledgerPathOverride: string | undefined;
455
- let dbInstance: DatabaseSync | undefined;
456
492
 
457
- function normalizeList(values: string[] | undefined): string[] {
458
- return Array.from(new Set((values ?? []).map((v) => v.trim()).filter(Boolean)));
459
- }
460
-
461
- function normalizeText(value: string | undefined): string | undefined {
462
- const trimmed = value?.trim();
463
- return trimmed || undefined;
464
- }
465
-
466
- function normalizeMatchText(value: string | undefined): string {
467
- return normalizeText(value)?.toLowerCase().replace(/\s+/g, " ") ?? "";
468
- }
469
-
470
- function stableShortId(input: string): string {
471
- return createHash("sha1").update(input).digest("hex").slice(0, 10);
472
- }
473
493
 
474
494
  function detectWorkspaceRoot(): string {
475
495
  // PWD is deliberately excluded: it is shell-set, can be stale or forged in
@@ -572,11 +592,6 @@ function gcOrphanedPocEvidenceForDb(db: DatabaseSync, nowMs = Date.now()): PocEv
572
592
  }
573
593
  }
574
594
 
575
- /** Run the same conservative orphan sweep used when the ledger opens. */
576
- export function gcOrphanedPocEvidence(nowMs = Date.now()): PocEvidenceGcResult {
577
- return gcOrphanedPocEvidenceForDb(getDb(), nowMs);
578
- }
579
-
580
595
  export function getCasefilePath(): string {
581
596
  if (ledgerPathOverride) return ledgerPathOverride;
582
597
  // Trim BEFORE the truthiness check: a whitespace-only value must not
@@ -587,22 +602,22 @@ export function getCasefilePath(): string {
587
602
  }
588
603
 
589
604
  export function setCasefilePath(path: string | undefined): void {
590
- if (dbInstance) {
591
- try {
592
- dbInstance.close();
593
- } catch {
594
- // Best-effort close.
595
- }
596
- }
605
+ closeSharedDb(); // closes the shared handle; next getDb() reopens at the new path
597
606
  ledgerPathOverride = path;
598
- dbInstance = undefined; // Force reconnection on next getDb
599
607
  }
600
608
 
601
609
  // ── SQLite Schema Init ────────────────────────────────────────────────
602
610
 
603
611
  function getDb(): DatabaseSync {
604
- if (dbInstance) return dbInstance;
605
-
612
+ // The opener registration below makes this the single lazy-open path for
613
+ // every casefile module (chains/objectives/confirmation resolve through
614
+ // ledger-internal's getDb, which calls back into openAndRegisterDb).
615
+ if (!hasSharedDb()) {
616
+ openAndRegisterDb();
617
+ }
618
+ return getSharedDb();
619
+ }
620
+ function openAndRegisterDb(): DatabaseSync {
606
621
  const dbPath = getCasefilePath();
607
622
  const dbDir = dirname(dbPath);
608
623
  const workspace = detectWorkspaceRoot();
@@ -649,6 +664,7 @@ function getDb(): DatabaseSync {
649
664
  nextStep TEXT,
650
665
  poc TEXT,
651
666
  remediation TEXT,
667
+ invariant TEXT,
652
668
  references_json TEXT, -- JSON string array
653
669
  blockers_json TEXT, -- JSON string array
654
670
  tags_json TEXT, -- JSON string array
@@ -687,6 +703,9 @@ function getDb(): DatabaseSync {
687
703
  if (!caseCols.some((c) => c.name === "disconfirmation")) {
688
704
  db.exec("ALTER TABLE cases ADD COLUMN disconfirmation TEXT");
689
705
  }
706
+ if (!caseCols.some((c) => c.name === "invariant")) {
707
+ db.exec("ALTER TABLE cases ADD COLUMN invariant TEXT");
708
+ }
690
709
  if (!caseCols.some((c) => c.name === "disconfirmation_verified_json")) {
691
710
  db.exec("ALTER TABLE cases ADD COLUMN disconfirmation_verified_json TEXT");
692
711
  }
@@ -755,7 +774,7 @@ function getDb(): DatabaseSync {
755
774
  db.exec(`CREATE INDEX IF NOT EXISTS idx_cases_severity ON cases(severity)`);
756
775
  db.exec(`CREATE INDEX IF NOT EXISTS idx_cases_priority ON cases(priority)`);
757
776
 
758
- dbInstance = db;
777
+ setDbInstance(db);
759
778
  // Best-effort housekeeping: failures and ambiguous state fail closed and do
760
779
  // not prevent the ledger from opening.
761
780
  gcOrphanedPocEvidenceForDb(db);
@@ -812,6 +831,7 @@ function mapRow(
812
831
  assumptions: safeParseArray(row.assumptions_json),
813
832
  disproveIf: safeParseArray(row.disprove_if_json),
814
833
  disconfirmation: row.disconfirmation || undefined,
834
+ invariant: row.invariant || undefined,
815
835
  pocVerified: safeParseObject(row.poc_verified_json),
816
836
  disconfirmationVerified: safeParseObject(row.disconfirmation_verified_json),
817
837
  controlVerified: safeParseObject(row.control_verified_json),
@@ -950,58 +970,6 @@ export function getCaseById(id: string): CaseRecord | undefined {
950
970
 
951
971
  // ── Validation ────────────────────────────────────────────────────────
952
972
 
953
- function validateCase(record: CaseRecord): void {
954
- if (!record.title.trim()) throw new Error("Case title cannot be empty");
955
- // Falsification conditions are load-bearing: they are required at creation
956
- // and must not be erasable later (CaseUpdate({ disproveIf: [] }) would wipe
957
- // the hypothesis's falsifiability). Re-check on every write.
958
- if (record.status !== "reported" && !(record.disproveIf ?? []).some((d) => d.trim())) {
959
- throw new Error(
960
- "Cases require disproveIf — falsification conditions (what would disprove this hypothesis). " +
961
- "They cannot be cleared once set.",
962
- );
963
- }
964
- // Keep this gate in lockstep with promoteFindingResult: a case may only be
965
- // CONFIRMED when it has evidence, a PoC, demonstrated impact, a severity,
966
- // and a named target (what host/repo/scope this affects).
967
- if (
968
- record.status === "confirmed" &&
969
- (!record.evidence ||
970
- !record.poc ||
971
- !record.impact ||
972
- !record.severity ||
973
- !record.target ||
974
- !record.disconfirmation)
975
- ) {
976
- throw new Error(
977
- "Confirmed cases require evidence, poc, impact, severity, target, and disconfirmation",
978
- );
979
- }
980
- if (record.status === "blocked" && (record.blockers ?? []).length === 0) {
981
- throw new Error("Blocked cases require at least one blocker");
982
- }
983
- if (
984
- record.status === "killed" &&
985
- !record.evidence &&
986
- !record.nextStep &&
987
- (record.blockers ?? []).length === 0 &&
988
- (record.assumptions ?? []).length === 0
989
- ) {
990
- throw new Error(
991
- "Killed cases require evidence, next step, blockers, or assumptions explaining why",
992
- );
993
- }
994
- // A case becomes REPORTED only after a report FILE that passes the content
995
- // gate exists on disk (the main agent writes it at the path CaseContext
996
- // recorded). Existence is not enough: any non-empty file — or a directory —
997
- // would otherwise flip the case to a permanent, immutable state.
998
- if (record.status === "reported") {
999
- const reportError = validateReportFile(record.reportPath, record);
1000
- if (reportError) {
1001
- throw new Error(`Reported cases require a valid report file: ${reportError}`);
1002
- }
1003
- }
1004
- }
1005
973
 
1006
974
  /**
1007
975
  * Machine content gate for the final deliverable. The report is the only
@@ -1058,6 +1026,10 @@ export function validateReportFile(
1058
1026
  /** Section headings the final report must contain. */
1059
1027
  const REPORT_REQUIRED_SECTIONS = ["summary", "impact", "remediation"];
1060
1028
 
1029
+ // Inject the report content gate into the shared validateCase (ledger-internal)
1030
+ // so the reported-state check works across the module split.
1031
+ setValidateReportFile(validateReportFile);
1032
+
1061
1033
  /**
1062
1034
  * Kill-reason vocabulary — a kill must name one of these (or carry refutation
1063
1035
  * evidence). Single source of truth: the ledger gate AND the injected workflow
@@ -1079,7 +1051,6 @@ export const KILL_REASON_VALUES = [
1079
1051
  "no_attack_path",
1080
1052
  "refuted",
1081
1053
  ] as const;
1082
- export type KillReason = (typeof KILL_REASON_VALUES)[number];
1083
1054
 
1084
1055
  /**
1085
1056
  * Matches a kill reason whether the agent wrote the canonical token
@@ -1222,57 +1193,6 @@ function validateNewCaseInput(input: CaseInput): void {
1222
1193
  }
1223
1194
  }
1224
1195
 
1225
- function buildRecord(input: NormalizedCaseInput, existing?: CaseRecord): CaseRecord {
1226
- const timestamp = new Date().toISOString();
1227
- const title = ("title" in input ? input.title : existing?.title)?.trim() ?? "";
1228
- const id = existing?.id ?? `case_${stableShortId(`${title}\n${timestamp}\n${randomUUID()}`)}`;
1229
-
1230
- return {
1231
- id,
1232
- title,
1233
- status: input.status ?? existing?.status ?? "hypothesis",
1234
- // Once a case has been investigating/confirmed it never forgets — the kill
1235
- // gate must not be defeatable by demoting first.
1236
- everAdvanced:
1237
- existing?.everAdvanced === true ||
1238
- input.status === "investigating" ||
1239
- input.status === "confirmed",
1240
- confidence: input.confidence ?? existing?.confidence ?? "low",
1241
- severity: input.severity ?? existing?.severity,
1242
- priority: input.priority ?? existing?.priority,
1243
- target: input.target !== undefined ? normalizeText(input.target) : existing?.target,
1244
- endpoint: input.endpoint !== undefined ? normalizeText(input.endpoint) : existing?.endpoint,
1245
- bugClass: input.bugClass !== undefined ? normalizeText(input.bugClass) : existing?.bugClass,
1246
- summary: input.summary !== undefined ? normalizeText(input.summary) : existing?.summary,
1247
- evidence: input.evidence !== undefined ? normalizeText(input.evidence) : existing?.evidence,
1248
- impact: input.impact !== undefined ? normalizeText(input.impact) : existing?.impact,
1249
- nextStep: input.nextStep !== undefined ? normalizeText(input.nextStep) : existing?.nextStep,
1250
- poc: input.poc !== undefined ? normalizeText(input.poc) : existing?.poc,
1251
- remediation:
1252
- input.remediation !== undefined ? normalizeText(input.remediation) : existing?.remediation,
1253
- references: normalizeList(input.references ?? existing?.references),
1254
- blockers: normalizeList(input.blockers ?? existing?.blockers),
1255
- tags: normalizeList(input.tags ?? existing?.tags),
1256
- assumptions: normalizeList(input.assumptions ?? existing?.assumptions),
1257
- disproveIf: normalizeList(input.disproveIf ?? existing?.disproveIf),
1258
- pocVerified: input.pocVerified ?? existing?.pocVerified,
1259
- disconfirmation:
1260
- input.disconfirmation !== undefined
1261
- ? normalizeText(input.disconfirmation)
1262
- : existing?.disconfirmation,
1263
- disconfirmationVerified: input.disconfirmationVerified ?? existing?.disconfirmationVerified,
1264
- controlVerified: input.controlVerified ?? existing?.controlVerified,
1265
- pendingConfirmation: input.pendingConfirmation ?? existing?.pendingConfirmation,
1266
- confirmerVerdict: input.confirmerVerdict ?? existing?.confirmerVerdict,
1267
- reportedAt: input.reportedAt ?? existing?.reportedAt,
1268
- reportPath: input.reportPath ?? existing?.reportPath,
1269
- evidenceItems: existing?.evidenceItems ?? [],
1270
- coverageItems: existing?.coverageItems ?? [],
1271
- linkedCases: existing?.linkedCases ?? [],
1272
- createdAt: existing?.createdAt ?? timestamp,
1273
- updatedAt: timestamp,
1274
- };
1275
- }
1276
1196
 
1277
1197
  function findDuplicateCaseInDb(
1278
1198
  db: DatabaseSync,
@@ -1555,127 +1475,8 @@ function rowToRecord(db: DatabaseSync, row: any): CaseRecord {
1555
1475
  );
1556
1476
  }
1557
1477
 
1558
- // ── SQLite Mutation Actions ───────────────────────────────────────────
1559
-
1560
- function withImmediateTransaction<T>(db: DatabaseSync, fn: () => T): T {
1561
- db.exec("BEGIN IMMEDIATE");
1562
- try {
1563
- const value = fn();
1564
- db.exec("COMMIT");
1565
- return value;
1566
- } catch (err) {
1567
- try {
1568
- db.exec("ROLLBACK");
1569
- } catch {
1570
- // ignore rollback errors
1571
- }
1572
- throw err;
1573
- }
1574
- }
1575
-
1576
- function upsertCase(db: DatabaseSync, record: CaseRecord) {
1577
- // Use ON CONFLICT DO UPDATE (not INSERT OR REPLACE) so FK CASCADE does not
1578
- // wipe case_links when updating an existing primary key.
1579
- const stmt = db.prepare(`
1580
- INSERT INTO cases (
1581
- id, title, status, ever_advanced, confidence, severity, priority, target, endpoint, bugClass,
1582
- summary, evidence, impact, nextStep, poc, remediation,
1583
- references_json, blockers_json, tags_json, assumptions_json, poc_verified_json,
1584
- disconfirmation, disconfirmation_verified_json, disprove_if_json, control_verified_json,
1585
- pending_confirmation_json, confirmer_verdict_json,
1586
- reported_at, report_path, created_at, updated_at
1587
- ) VALUES (
1588
- ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
1589
- ?, ?, ?, ?, ?, ?,
1590
- ?, ?, ?, ?, ?,
1591
- ?, ?, ?, ?,
1592
- ?, ?,
1593
- ?, ?, ?, ?
1594
- )
1595
- ON CONFLICT(id) DO UPDATE SET
1596
- title = excluded.title,
1597
- status = excluded.status,
1598
- ever_advanced = excluded.ever_advanced,
1599
- confidence = excluded.confidence,
1600
- severity = excluded.severity,
1601
- priority = excluded.priority,
1602
- target = excluded.target,
1603
- endpoint = excluded.endpoint,
1604
- bugClass = excluded.bugClass,
1605
- summary = excluded.summary,
1606
- evidence = excluded.evidence,
1607
- impact = excluded.impact,
1608
- nextStep = excluded.nextStep,
1609
- poc = excluded.poc,
1610
- remediation = excluded.remediation,
1611
- references_json = excluded.references_json,
1612
- blockers_json = excluded.blockers_json,
1613
- tags_json = excluded.tags_json,
1614
- assumptions_json = excluded.assumptions_json,
1615
- poc_verified_json = excluded.poc_verified_json,
1616
- disconfirmation = excluded.disconfirmation,
1617
- disconfirmation_verified_json = excluded.disconfirmation_verified_json,
1618
- disprove_if_json = excluded.disprove_if_json,
1619
- control_verified_json = excluded.control_verified_json,
1620
- pending_confirmation_json = excluded.pending_confirmation_json,
1621
- confirmer_verdict_json = excluded.confirmer_verdict_json,
1622
- reported_at = excluded.reported_at,
1623
- report_path = excluded.report_path,
1624
- created_at = excluded.created_at,
1625
- updated_at = excluded.updated_at
1626
- `);
1627
-
1628
- stmt.run(
1629
- record.id,
1630
- record.title,
1631
- record.status,
1632
- record.everAdvanced ? 1 : 0,
1633
- record.confidence,
1634
- record.severity || null,
1635
- record.priority || null,
1636
- record.target || null,
1637
- record.endpoint || null,
1638
- record.bugClass || null,
1639
- record.summary || null,
1640
- record.evidence || null,
1641
- record.impact || null,
1642
- record.nextStep || null,
1643
- record.poc || null,
1644
- record.remediation || null,
1645
- JSON.stringify(record.references),
1646
- JSON.stringify(record.blockers),
1647
- JSON.stringify(record.tags),
1648
- JSON.stringify(record.assumptions),
1649
- record.pocVerified ? JSON.stringify(record.pocVerified) : null,
1650
- record.disconfirmation || null,
1651
- record.disconfirmationVerified ? JSON.stringify(record.disconfirmationVerified) : null,
1652
- JSON.stringify(record.disproveIf),
1653
- record.controlVerified ? JSON.stringify(record.controlVerified) : null,
1654
- record.pendingConfirmation ? JSON.stringify(record.pendingConfirmation) : null,
1655
- record.confirmerVerdict ? JSON.stringify(record.confirmerVerdict) : null,
1656
- record.reportedAt || null,
1657
- record.reportPath || null,
1658
- record.createdAt,
1659
- record.updatedAt,
1660
- );
1661
- }
1662
-
1663
1478
  // ── Evidence items ──────────────────────────────────────────────────
1664
1479
 
1665
- function insertEvidenceItem(db: DatabaseSync, item: EvidenceItem): void {
1666
- db.prepare(
1667
- `INSERT INTO evidence_items (id, case_id, role, artifact_path, sha256, summary, created_at)
1668
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
1669
- ).run(
1670
- item.id,
1671
- item.caseId,
1672
- item.role,
1673
- item.artifactPath ?? null,
1674
- item.sha256 ?? null,
1675
- item.summary,
1676
- item.createdAt,
1677
- );
1678
- }
1679
1480
 
1680
1481
  /**
1681
1482
  * Add a role-typed evidence item. Artifact path is hashed (SHA-256) and only
@@ -2019,874 +1820,6 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
2019
1820
  });
2020
1821
  }
2021
1822
 
2022
- function validateRunEvidence(run: PocEvidenceRun, label: string): void {
2023
- if (!run.completed) {
2024
- throw new Error(`${label} did not complete; a crash is not evidence`);
2025
- }
2026
- if (!run.outputComplete) {
2027
- throw new Error(`${label} output capture was incomplete; evidence checks are unsafe`);
2028
- }
2029
- if (run.exitCode !== 0) {
2030
- throw new Error(
2031
- `${label} exited with ${run.exitCode}; exit 0 is required for a complete run but is never sufficient proof`,
2032
- );
2033
- }
2034
- if (!run.evidence || !run.evidenceSha256) {
2035
- throw new Error(
2036
- `${label} has no evidence.json — the PoC must write evidence to $PI_POC_EVIDENCE_DIR`,
2037
- );
2038
- }
2039
- if (!evidenceNonceMatches(run.evidence, run.nonce)) {
2040
- throw new Error(`${label} evidence nonce mismatch — evidence not bound to this run`);
2041
- }
2042
- const parsed = parsePoCEvidence(run.evidence);
2043
- if (!parsed.ok) {
2044
- throw new Error(`${label} evidence contract invalid: ${parsed.error}`);
2045
- }
2046
- if (!run.evidencePath) {
2047
- throw new Error(`${label} has no durable evidencePath; ephemeral evidence cannot confirm`);
2048
- }
2049
- const artifact = readWorkspaceArtifact(run.evidencePath);
2050
- if (artifact.bytes.byteLength > POC_EVIDENCE_MAX_BYTES) {
2051
- throw new Error(
2052
- `${label} durable evidence exceeds ${POC_EVIDENCE_MAX_BYTES} bytes; evidence cannot be revalidated safely`,
2053
- );
2054
- }
2055
- const durableHash = createHash("sha256").update(artifact.bytes).digest("hex");
2056
- if (durableHash !== run.evidenceSha256) {
2057
- throw new Error(`${label} durable evidence hash does not match evidenceSha256`);
2058
- }
2059
- let durableRaw: unknown;
2060
- try {
2061
- durableRaw = JSON.parse(artifact.bytes.toString("utf8"));
2062
- } catch (error) {
2063
- throw new Error(`${label} durable evidence is not valid JSON: ${(error as Error).message}`);
2064
- }
2065
- const durable = parsePoCEvidence(durableRaw);
2066
- if (!durable.ok) {
2067
- throw new Error(`${label} durable evidence contract invalid: ${durable.error}`);
2068
- }
2069
- if (
2070
- normalizeEvidence(durable.evidence) !== normalizeEvidence(run.evidence) ||
2071
- JSON.stringify(durable.evidence.observations) !== JSON.stringify(run.evidence.observations)
2072
- ) {
2073
- throw new Error(`${label} durable evidence bytes do not match the stored evidence object`);
2074
- }
2075
- }
2076
-
2077
- /** Determinism + differential on normalized evidence (nonce/observations stripped). */
2078
- function assertEvidenceDifferential(bundle: PendingConfirmation, isIntra = false): void {
2079
- const [r1, r2] = bundle.targetRuns;
2080
- if (normalizeEvidence(r1.evidence) !== normalizeEvidence(r2.evidence)) {
2081
- throw new Error(
2082
- "Target runs produced inconsistent evidence — the exploit did not reproduce deterministically",
2083
- );
2084
- }
2085
- // Intra-target target-dependence is proven by the harness attack-vs-baseline
2086
- // replay (same host), not by comparing a target run to a separate control run.
2087
- if (isIntra) return;
2088
- if (!bundle.controlRun) {
2089
- throw new Error("inter-host confirmation requires a control run");
2090
- }
2091
- if (normalizeEvidence(r1.evidence) === normalizeEvidence(bundle.controlRun.evidence)) {
2092
- throw new Error(
2093
- "Control run produced identical evidence to the target — the claimed impact is not target-dependent",
2094
- );
2095
- }
2096
- }
2097
-
2098
- function assertMachineConfirmation(bundle: PendingConfirmation): void {
2099
- const oob = bundle.callbackVerified;
2100
- if (oob?.attempted) {
2101
- if (oob.targetHits === 0) {
2102
- throw new Error(
2103
- `OOB VERIFY FAILED: no interaction with the target-run callback token. ${oob.note}`,
2104
- );
2105
- }
2106
- if (oob.controlHits > 0) {
2107
- throw new Error(
2108
- `OOB VERIFY FAILED: the control-run callback token received ${oob.controlHits} interaction(s) — the callback is not target-dependent. ${oob.note}`,
2109
- );
2110
- }
2111
- if (oob.sourceSeparated !== true) {
2112
- throw new Error(
2113
- "OOB VERIFY FAILED: callback source separation was not established. " +
2114
- "A loopback listener reachable by the PoC is diagnostic telemetry, not proof that the target caused the interaction.",
2115
- );
2116
- }
2117
- return;
2118
- }
2119
-
2120
- assertHarnessTargetOnly(
2121
- bundle.harnessVerified,
2122
- "HARNESS DIFFERENTIAL FAILED",
2123
- "no machine-owned target/control replay was recorded",
2124
- );
2125
- }
2126
-
2127
- function assertHarnessTargetOnly(
2128
- harness: HarnessVerifyResult | undefined,
2129
- label: string,
2130
- missingNote: string,
2131
- ): asserts harness is HarnessVerifyResult {
2132
- if (
2133
- !harness?.attempted ||
2134
- harness.pass !== true ||
2135
- harness.differential !== "target_only" ||
2136
- harness.target?.matched !== true ||
2137
- harness.control?.matched !== false
2138
- ) {
2139
- throw new Error(`${label}: ${harness?.note ?? missingNote}`);
2140
- }
2141
- }
2142
-
2143
- function assertHarnessCanary(
2144
- harness: HarnessVerifyResult | undefined,
2145
- required: boolean,
2146
- label: string,
2147
- ): void {
2148
- if (!required) return;
2149
- if (
2150
- harness?.canary?.attempted !== true ||
2151
- harness.canary.pass !== true ||
2152
- harness.canary.targetObserved !== true ||
2153
- harness.canary.controlObserved !== false ||
2154
- harness.proofStrength !== "canary_differential"
2155
- ) {
2156
- throw new Error(`${label}: ${harness?.canary?.note ?? "required canary transcript missing"}`);
2157
- }
2158
- }
2159
-
2160
- function assertMainAgentVerification(
2161
- bundle: PendingConfirmation,
2162
- verification: MainAgentVerification | undefined,
2163
- isIntra = false,
2164
- ): asserts verification is MainAgentVerification {
2165
- if (!verification) {
2166
- throw new Error(
2167
- "MAIN-AGENT REPLAY REQUIRED: ConfirmFinding must produce a fresh harness-owned target/control transcript",
2168
- );
2169
- }
2170
- const at = Date.parse(verification.at);
2171
- const bundleAt = Date.parse(bundle.ranAt);
2172
- const now = Date.now();
2173
- if (
2174
- !Number.isFinite(at) ||
2175
- !Number.isFinite(bundleAt) ||
2176
- at < bundleAt ||
2177
- at > now + 30_000 ||
2178
- now - at > 5 * 60 * 1000
2179
- ) {
2180
- throw new Error(
2181
- "MAIN-AGENT REPLAY FAILED: transcript timestamp must be valid, newer than phase 1, and no more than 5 minutes old",
2182
- );
2183
- }
2184
- assertHarnessTargetOnly(
2185
- verification.result,
2186
- "MAIN-AGENT REPLAY FAILED",
2187
- "no fresh phase-2 target/control replay was recorded",
2188
- );
2189
- assertHarnessCanary(
2190
- verification.result,
2191
- bundle.targetRuns[0].evidence.verify.canary !== undefined,
2192
- "MAIN-AGENT CANARY FAILED",
2193
- );
2194
- const targetUrl = verification.result.target?.url;
2195
- const controlUrl = verification.result.control?.url;
2196
- const targetIdentity = bundle.targetRuns[0].target;
2197
- if (!targetUrl || verifyUrlBindingError(targetUrl, targetIdentity)) {
2198
- throw new Error("MAIN-AGENT REPLAY FAILED: target transcript is not bound to the case target");
2199
- }
2200
- // Intra-target: the "control" transcript is the legitimate baseline request,
2201
- // which is bound to the SAME case target. Inter-host: it is bound to the
2202
- // distinct control target.
2203
- const controlBindTarget = isIntra ? targetIdentity : bundle.controlTarget;
2204
- if (!controlUrl || !controlBindTarget || verifyUrlBindingError(controlUrl, controlBindTarget)) {
2205
- throw new Error(
2206
- isIntra
2207
- ? "MAIN-AGENT REPLAY FAILED: baseline transcript is not bound to the case target"
2208
- : "MAIN-AGENT REPLAY FAILED: control transcript is not bound to control_target",
2209
- );
2210
- }
2211
- }
2212
-
2213
- /**
2214
- * Gate for phase 1 of promotion: case must exist, be investigating, and have
2215
- * poc/evidence/impact/severity/target. The disconfirmation is provided by the
2216
- * main agent at confirm time, so it is NOT a precondition here. Returns the
2217
- * record when promotable, throws otherwise. Exported so PromoteFinding can
2218
- * validate BEFORE paying for (potentially slow) sandboxed PoC runs.
2219
- */
2220
- export function assertPromotable(id: string): CaseRecord {
2221
- const current = getCaseById(id);
2222
- if (!current) {
2223
- throw new Error(`Case not found: ${id}`);
2224
- }
2225
- if (current.status !== "investigating") {
2226
- throw new Error(`PromoteFinding requires an investigating case (current: ${current.status})`);
2227
- }
2228
- if (!current.poc) {
2229
- throw new Error("CONFIRMED requires poc; set poc on the case first");
2230
- }
2231
- if (!current.evidence) {
2232
- throw new Error("CONFIRMED requires evidence; set evidence on the case first");
2233
- }
2234
- if (!current.impact) {
2235
- throw new Error("CONFIRMED requires impact; set impact on the case first");
2236
- }
2237
- if (!current.severity) {
2238
- throw new Error("CONFIRMED requires severity; set severity on the case first");
2239
- }
2240
- if (!current.target) {
2241
- throw new Error(
2242
- "CONFIRMED requires target (what host/repo/scope this affects); set target on the case first",
2243
- );
2244
- }
2245
- // Evidence-chain closure: the observation item must be ARTIFACT-BACKED. A
2246
- // summary-only observation is agent prose about itself — promotion requires
2247
- // a real file with its SHA-256 as the initial signal. (The reproduction item
2248
- // is always artifact-backed: the gate writes it from the evidence hash.)
2249
- if (!current.evidenceItems.some((e) => e.role === "observation" && e.sha256)) {
2250
- throw new Error(
2251
- "Evidence chain incomplete: CONFIRMED requires an artifact-backed observation evidence item " +
2252
- "(EvidenceAdd role=observation with artifact_path — the initial signal, stored as basename + SHA-256) " +
2253
- "in addition to the auto-recorded reproduction item. Add the artifact-backed observation item and retry promotion.",
2254
- );
2255
- }
2256
- return current;
2257
- }
2258
-
2259
- /**
2260
- * Phase 1 (intra-target): validate a same-host attack-vs-baseline bundle. The
2261
- * differential is proven by the harness replay (attack matched, baseline did
2262
- * not, both against the case target), not by a separate control run — the
2263
- * discriminating variable is the request's identity or a parameter, not the host.
2264
- */
2265
- function validateIntraTargetBundle(
2266
- current: CaseRecord,
2267
- id: string,
2268
- bundle: PendingConfirmation,
2269
- ): CaseRecord {
2270
- if (bundle.targetRuns.length !== 2) {
2271
- throw new Error("Intra-target confirmation requires two target runs");
2272
- }
2273
- if (bundle.controlRun || bundle.controlTarget) {
2274
- throw new Error(
2275
- "Intra-target confirmation must not carry a control run or control target — the baseline is a same-host request inside the evidence",
2276
- );
2277
- }
2278
- const targetRunTarget = bundle.targetRuns[0]?.target;
2279
- if (!targetRunTarget || bundle.targetRuns.some((r) => r.target !== targetRunTarget)) {
2280
- throw new Error("Intra-target confirmation requires both runs against the same case target");
2281
- }
2282
- let pocHash: string | undefined;
2283
- try {
2284
- pocHash = createHash("sha256").update(readFileSync(bundle.pocPath)).digest("hex");
2285
- } catch {
2286
- pocHash = undefined;
2287
- }
2288
- if (!pocHash || (bundle.pocSha256 && bundle.pocSha256 !== pocHash)) {
2289
- throw new Error("pocSha256 does not match the PoC file on disk");
2290
- }
2291
- for (const run of bundle.targetRuns) {
2292
- validateRunEvidence(run, `${run.mode} run`);
2293
- const ev = run.evidence;
2294
- if (ev.verify.mode !== "intra_target") {
2295
- throw new Error(
2296
- "INTRA-TARGET FAILED: each run's evidence.verify.mode must be 'intra_target'",
2297
- );
2298
- }
2299
- if (!ev.baseline) {
2300
- throw new Error(
2301
- "INTRA-TARGET FAILED: evidence.baseline (a legitimate same-host request) is required",
2302
- );
2303
- }
2304
- const attackBinding = verifyUrlBindingError(ev.verify.url, targetRunTarget);
2305
- if (attackBinding) throw new Error(`ATTACK BINDING FAILED: ${attackBinding}`);
2306
- const baselineBinding = verifyUrlBindingError(ev.baseline.url, targetRunTarget);
2307
- if (baselineBinding) throw new Error(`BASELINE BINDING FAILED: ${baselineBinding}`);
2308
- if (ev.baseline && sameRequest(ev.verify, ev.baseline)) {
2309
- throw new Error(
2310
- "INTRA-TARGET FAILED: attack and baseline requests are identical — vary identity or a parameter",
2311
- );
2312
- }
2313
- }
2314
- if (bundle.caseId !== id) throw new Error("Pending confirmation caseId mismatch");
2315
- assertEvidenceDifferential(bundle, true);
2316
- // Machine floor: attack matched, baseline did not, both against the case target.
2317
- assertMachineConfirmation(bundle);
2318
- assertHarnessCanary(
2319
- bundle.harnessVerified,
2320
- bundle.targetRuns[0].evidence.verify.canary !== undefined,
2321
- "PHASE-1 CANARY FAILED",
2322
- );
2323
- const next = buildRecord({ pendingConfirmation: bundle }, current);
2324
- validateCase(next);
2325
- return next;
2326
- }
2327
-
2328
- /**
2329
- * Phase 1: record the harness-observed evidence bundle on the case. The whole
2330
- * contract is validated here — same-file control, nonce binding, run
2331
- * completion, determinism across the two target runs, and the target/control
2332
- * differential — so a bundle that cannot promote is rejected before the
2333
- * main agent performs phase-2 review.
2334
- */
2335
- export function storePendingConfirmation(id: string, bundle: PendingConfirmation): CaseRecord {
2336
- const db = getDb();
2337
- return withImmediateTransaction(db, () => {
2338
- const current = getCaseById(id);
2339
- if (!current) throw new Error(`Case not found: ${id}`);
2340
- if (current.status !== "investigating") {
2341
- throw new Error(
2342
- `Pending confirmation requires an investigating case (current: ${current.status})`,
2343
- );
2344
- }
2345
- if (bundle.caseId !== id) throw new Error("Pending confirmation caseId mismatch");
2346
- if (bundle.mode === "intra_target") {
2347
- const next = validateIntraTargetBundle(current, id, bundle);
2348
- upsertCase(db, next);
2349
- return next;
2350
- }
2351
- if (bundle.targetRuns.length !== 2 || !bundle.controlRun) {
2352
- throw new Error("Pending confirmation requires two target runs and one control run");
2353
- }
2354
- if (!bundle.pocPath || !bundle.controlPath || !bundle.controlTarget) {
2355
- throw new Error("Pending confirmation requires pocPath, controlPath, and controlTarget");
2356
- }
2357
- // Control-target binding (machine-verified here, not just in the tool
2358
- // layer): the control run must actually have targeted the declared
2359
- // control_target, that target must differ from the target runs' target,
2360
- // and the control target must differ from the case's target — otherwise
2361
- // "the control demonstrated nothing on the vulnerable target" passes.
2362
- const targetRunTarget = bundle.targetRuns[0]?.target;
2363
- if (!targetRunTarget || bundle.targetRuns.some((r) => r.target !== targetRunTarget)) {
2364
- throw new Error(
2365
- "Pending confirmation requires both target runs against the same case target",
2366
- );
2367
- }
2368
- if (!bundle.controlRun.target || bundle.controlRun.target !== bundle.controlTarget) {
2369
- throw new Error(
2370
- "CONTROL BINDING FAILED: controlRun.target must equal control_target — a control run " +
2371
- "against a different host than the one declared proves nothing.",
2372
- );
2373
- }
2374
- if (bundle.controlRun.target === targetRunTarget) {
2375
- throw new Error(
2376
- "CONTROL BINDING FAILED: the control run targeted the same host as the target runs — " +
2377
- "the claimed impact is not target-dependent.",
2378
- );
2379
- }
2380
- if (bundle.controlTarget === current.target) {
2381
- throw new Error(
2382
- "CONTROL BINDING FAILED: control_target must differ from the case target; a control run " +
2383
- "against the vulnerable target proves nothing.",
2384
- );
2385
- }
2386
- // Same-file contract re-checked at store time (the tool already checked).
2387
- let pocHash: string | undefined;
2388
- let controlHash: string | undefined;
2389
- try {
2390
- pocHash = createHash("sha256").update(readFileSync(bundle.pocPath)).digest("hex");
2391
- controlHash = createHash("sha256").update(readFileSync(bundle.controlPath)).digest("hex");
2392
- } catch {
2393
- pocHash = undefined;
2394
- controlHash = undefined;
2395
- }
2396
- if (!pocHash || !controlHash || pocHash !== controlHash) {
2397
- throw new Error(
2398
- "CONTROL CHECK FAILED: control_path must be the SAME script as poc_path " +
2399
- "(sha256 mismatch). A separately written control file proves nothing.",
2400
- );
2401
- }
2402
- if (bundle.pocSha256 && bundle.pocSha256 !== pocHash) {
2403
- throw new Error("pocSha256 does not match the PoC file on disk");
2404
- }
2405
- for (const run of [...bundle.targetRuns, bundle.controlRun]) {
2406
- validateRunEvidence(run, `${run.mode} run`);
2407
- }
2408
- assertEvidenceDifferential(bundle);
2409
- if (!bundle.callbackVerified?.attempted) {
2410
- for (const run of bundle.targetRuns) {
2411
- const bindingError = verifyUrlBindingError(run.evidence.verify.url, targetRunTarget);
2412
- if (bindingError) throw new Error(`TARGET BINDING FAILED: ${bindingError}`);
2413
- }
2414
- const controlBindingError = verifyUrlBindingError(
2415
- bundle.controlRun.evidence.verify.url,
2416
- bundle.controlTarget,
2417
- );
2418
- if (controlBindingError) {
2419
- throw new Error(`CONTROL BINDING FAILED: ${controlBindingError}`);
2420
- }
2421
- }
2422
- // A clean exit and model-authored evidence are necessary inputs, never the
2423
- // proof. Promotion requires a harness-observed target/control differential
2424
- // or a harness-owned OOB interaction differential.
2425
- assertMachineConfirmation(bundle);
2426
-
2427
- const next = buildRecord({ pendingConfirmation: bundle }, current);
2428
- validateCase(next);
2429
- upsertCase(db, next);
2430
- return next;
2431
- });
2432
- }
2433
-
2434
- /**
2435
- * Phase 2: commit (or refuse) the promotion on the main agent's verdict.
2436
- *
2437
- * CONFIRMED requires the full bundle to still hold (completion, nonce,
2438
- * determinism, differential), the PoC script to be unchanged since the runs
2439
- * (pocSha256 — otherwise the main agent reviewed different bytes), and a
2440
- * verdict accompanied by a fresh harness-owned target-only replay, a concrete
2441
- * review note, and a disconfirmation attempt. NOT_CONFIRMED records the
2442
- * verdict and keeps the case investigating — no tie-breaker.
2443
- */
2444
- export function applyConfirmationResult(
2445
- id: string,
2446
- verdictInput: MainAgentVerdict,
2447
- phase2Verification?: MainAgentVerification,
2448
- authority: { startedAsSubagent: boolean } = {
2449
- startedAsSubagent: PROCESS_STARTED_AS_SUBAGENT || process.env.PI_SUBAGENT_CHILD === "1",
2450
- },
2451
- ): CaseUpdateResult {
2452
- if (authority.startedAsSubagent) {
2453
- throw new Error(
2454
- "ConfirmFinding is reserved for the main/coordinator agent; worker processes cannot commit confirmation",
2455
- );
2456
- }
2457
- const db = getDb();
2458
- return withImmediateTransaction(db, () => {
2459
- const current = getCaseById(id);
2460
- if (!current) throw new Error(`Case not found: ${id}`);
2461
- if (current.status !== "investigating") {
2462
- throw new Error(`ConfirmFinding requires an investigating case (current: ${current.status})`);
2463
- }
2464
- const bundle = current.pendingConfirmation;
2465
- if (!bundle) {
2466
- throw new Error("No pending confirmation on this case — run PromoteFinding first");
2467
- }
2468
- // Fail closed on an unparseable ranAt: Date.parse(garbage) is NaN, and
2469
- // NaN > TTL is false — a malformed timestamp must NOT make the bundle
2470
- // immortal. Treat it as expired (re-run PromoteFinding for a fresh one).
2471
- const ranAtMs = Date.parse(bundle.ranAt);
2472
- if (!Number.isFinite(ranAtMs) || Date.now() - ranAtMs > PENDING_CONFIRM_TTL_MS) {
2473
- throw new Error(
2474
- "Pending confirmation expired or has an invalid timestamp (1h TTL) — re-run PromoteFinding for a fresh bundle",
2475
- );
2476
- }
2477
- const parsed = validateMainAgentVerdict(verdictInput);
2478
- if (!parsed.ok) throw new Error(`Invalid main-agent confirmation verdict: ${parsed.error}`);
2479
- const verdict = parsed.verdict;
2480
- const canaryRequested = bundle.targetRuns[0].evidence.verify.canary !== undefined;
2481
- if (verdict.verdict === "CONFIRMED") {
2482
- if (canaryRequested && verdict.canary_assessment !== "verified") {
2483
- throw new Error(
2484
- "CONFIRMED canary mismatch: evidence requested a harness canary, so canary_assessment must be verified",
2485
- );
2486
- }
2487
- if (!canaryRequested && verdict.canary_assessment !== "not_applicable") {
2488
- throw new Error(
2489
- "CONFIRMED canary mismatch: this evidence has no canary template; record canary_assessment=not_applicable and explain why",
2490
- );
2491
- }
2492
- }
2493
- const recorded: MainAgentVerdictRecord = {
2494
- ...verdict,
2495
- at: new Date().toISOString(),
2496
- reviewer: "main_agent",
2497
- phase2Verification: verdict.verdict === "CONFIRMED" ? phase2Verification : undefined,
2498
- proofStrength:
2499
- verdict.verdict === "CONFIRMED"
2500
- ? canaryRequested
2501
- ? "canary_differential"
2502
- : "predicate_differential"
2503
- : undefined,
2504
- };
2505
-
2506
- if (verdict.verdict === "NOT_CONFIRMED") {
2507
- const note = `main agent NOT_CONFIRMED${verdict.model ? ` (${verdict.model})` : ""}: ${verdict.reasoning}`;
2508
- const next = buildRecord(
2509
- {
2510
- confirmerVerdict: recorded,
2511
- pendingConfirmation: undefined,
2512
- assumptions: [...(current.assumptions ?? []), note],
2513
- },
2514
- current,
2515
- );
2516
- // buildRecord's nullish fallback preserves the old value; consume the
2517
- // rejected attempt explicitly so a retry must produce fresh evidence.
2518
- next.pendingConfirmation = undefined;
2519
- validateCase(next);
2520
- upsertCase(db, next);
2521
- return { record: next, changed: true };
2522
- }
2523
-
2524
- // CONFIRMED — re-validate the whole bundle (defense in depth; the case may
2525
- // have been touched between phase 1 and the verdict).
2526
- const isIntra = bundle.mode === "intra_target";
2527
- const allRuns = isIntra
2528
- ? [...bundle.targetRuns]
2529
- : [...bundle.targetRuns, ...(bundle.controlRun ? [bundle.controlRun] : [])];
2530
- for (const run of allRuns) {
2531
- validateRunEvidence(run, `${run.mode} run`);
2532
- }
2533
- assertEvidenceDifferential(bundle, isIntra);
2534
- assertMachineConfirmation(bundle);
2535
- assertHarnessCanary(bundle.harnessVerified, canaryRequested, "PHASE-1 CANARY FAILED");
2536
- let pocHash: string | undefined;
2537
- try {
2538
- pocHash = createHash("sha256").update(readFileSync(bundle.pocPath)).digest("hex");
2539
- } catch {
2540
- pocHash = undefined;
2541
- }
2542
- if (!pocHash || pocHash !== bundle.pocSha256) {
2543
- throw new Error(
2544
- "PoC script changed since the runs — re-run PromoteFinding (the main agent must review the exact bytes that ran)",
2545
- );
2546
- }
2547
- // The case target must still be the host the PoC ran against, and still
2548
- // differ from the control target. The evidence proves nothing about a
2549
- // target the case adopted after the runs.
2550
- const targetRun = bundle.targetRuns[0];
2551
- if (!current.target || current.target !== targetRun.target) {
2552
- throw new Error(
2553
- "Case target changed since the PoC runs — re-run PromoteFinding against the current target " +
2554
- `(bundle target: ${targetRun.target}, case target: ${current.target ?? "(none)"}).`,
2555
- );
2556
- }
2557
- if (!isIntra && current.target === bundle.controlTarget) {
2558
- throw new Error(
2559
- "Case target now equals the control target — the claimed impact is not target-dependent; " +
2560
- "re-run PromoteFinding with a distinct control_target.",
2561
- );
2562
- }
2563
-
2564
- // The observation must predate the repro (provenance guard).
2565
- const observation = current.evidenceItems.find((e) => e.role === "observation" && e.sha256);
2566
- if (observation && observation.createdAt > bundle.targetRuns[0].ranAt) {
2567
- throw new Error(
2568
- "Evidence chain invalid: the observation item was recorded after the PoC ran " +
2569
- `(${observation.createdAt} > ${bundle.targetRuns[0].ranAt}). The observation must predate the repro.`,
2570
- );
2571
- }
2572
-
2573
- // Phase 1 proves the evidence floor. Phase 2 must freshly replay that same
2574
- // request inside the main agent's ConfirmFinding call; a caller-provided
2575
- // boolean is not accepted as proof of re-execution.
2576
- assertMainAgentVerification(bundle, phase2Verification, isIntra);
2577
-
2578
- const reproductionItem: EvidenceItem = {
2579
- id: `ev_${stableShortId(`${id}\nreproduction\n${targetRun.ranAt}`)}`,
2580
- caseId: id,
2581
- role: "reproduction",
2582
- // The runner preserves each run's evidence.json in a durable dir
2583
- // (.pi/poc-evidence/) — the artifact the hash was computed over still
2584
- // exists, so the item stays artifact-backed and re-verifiable.
2585
- artifactPath: targetRun.evidencePath ? basename(targetRun.evidencePath) : "evidence.json",
2586
- sha256: targetRun.evidenceSha256,
2587
- summary: `PoC evidence accepted (2 target runs + ${isIntra ? "same-host baseline" : "control"}; ${recorded.proofStrength}) — main agent semantic confirmation${verdict.model ? ` (${verdict.model})` : ""}`,
2588
- createdAt: targetRun.ranAt,
2589
- };
2590
-
2591
- const newEvidence =
2592
- (current.evidence ? `${current.evidence}\n\n` : "") +
2593
- `### PoC Execution Capture (${targetRun.ranAt})\n` +
2594
- `- **Evidence sha256:** ${targetRun.evidenceSha256}\n` +
2595
- `- **Target:** ${targetRun.target}\n` +
2596
- `- **Machine evidence:** ${recorded.proofStrength} (a differential is not by itself proof of exploitation)\n` +
2597
- `- **Main-agent reviewer:** ${verdict.model ?? "unknown model"} — semantic confirmation\n` +
2598
- `#### Target Run Output\n\`\`\`\n${targetRun.output ?? ""}\n\`\`\``;
2599
-
2600
- const update: NormalizedCaseInput = {
2601
- status: "confirmed",
2602
- pocVerified: {
2603
- path: bundle.pocPath,
2604
- exitCode: targetRun.exitCode,
2605
- ranAt: targetRun.ranAt,
2606
- output: targetRun.output,
2607
- sandbox: targetRun.sandbox,
2608
- completed: true,
2609
- outputComplete: true,
2610
- mode: "poc",
2611
- target: targetRun.target,
2612
- },
2613
- controlVerified:
2614
- isIntra || !bundle.controlRun
2615
- ? {
2616
- path: bundle.pocPath,
2617
- exitCode: targetRun.exitCode,
2618
- ranAt: targetRun.ranAt,
2619
- output: `intra-target baseline (same host): ${bundle.harnessVerified?.control?.note ?? "baseline did not satisfy the attack predicate"}`,
2620
- sandbox: targetRun.sandbox,
2621
- completed: true,
2622
- outputComplete: true,
2623
- mode: "baseline",
2624
- target: targetRun.target,
2625
- }
2626
- : {
2627
- path: bundle.controlPath ?? bundle.pocPath,
2628
- exitCode: bundle.controlRun.exitCode,
2629
- ranAt: bundle.controlRun.ranAt,
2630
- output: bundle.controlRun.output,
2631
- sandbox: bundle.controlRun.sandbox,
2632
- completed: true,
2633
- outputComplete: true,
2634
- mode: "control",
2635
- target: bundle.controlRun.target,
2636
- },
2637
- disconfirmation: verdict.disconfirmation_attempt,
2638
- confirmerVerdict: recorded,
2639
- pendingConfirmation: undefined,
2640
- evidence: newEvidence,
2641
- };
2642
-
2643
- const next = buildRecord(update, current);
2644
- next.pendingConfirmation = undefined; // buildRecord's ?? existing keeps it; clear explicitly
2645
- validateCase(next);
2646
- insertEvidenceItem(db, reproductionItem);
2647
- upsertCase(db, next);
2648
- next.evidenceItems = [...(next.evidenceItems ?? []), reproductionItem];
2649
- return { record: next, changed: true };
2650
- });
2651
- }
2652
-
2653
- // ── Chain suggestions ───────────────────────────────────────────────
2654
-
2655
- /** Automated exploit-chain patterns (ported shape from CyberStrike chain.ts). */
2656
- const CHAIN_PATTERN_VALUES = [
2657
- "credential_endpoint",
2658
- "info_disclosure_ssrf",
2659
- "redirect_oauth",
2660
- "idor_data_leak",
2661
- "xss_csrf",
2662
- "ssti_rce",
2663
- "race_condition_business",
2664
- ] as const;
2665
- export type ChainPattern = (typeof CHAIN_PATTERN_VALUES)[number];
2666
-
2667
- export type ChainSuggestion = {
2668
- pattern: ChainPattern;
2669
- sourceId: string;
2670
- targetId?: string;
2671
- sourceTitle: string;
2672
- targetTitle?: string;
2673
- rationale: string;
2674
- confidence: number;
2675
- /** Suggested CaseLink kind when the agent links the pair. */
2676
- suggestedKind?: CaseLinkKind;
2677
- };
2678
-
2679
- // Word-boundary anchored so "admin" does not match "administration" and
2680
- // "update" does not match "updated" — substring matching over-mines pairs.
2681
- const CHAIN_CLASS_RE = {
2682
- credential: /\b(credential|password|api[ -]?key|token|secret|leak|dump|exposure)\b/i,
2683
- authEndpoint: /\b(auth|login|sso|signup|account|admin|endpoint|api)\b/i,
2684
- redirect: /\b(open redirect|redirect)\b/i,
2685
- oauth: /\b(oauth|callback|redirect_uri|sso|saml|openid|authorize)\b/i,
2686
- xss: /\b(xss|cross-?site.?script)\b/i,
2687
- stateChange:
2688
- /\b(POST|PUT|DELETE|PATCH|create|update|delete|transfer|payment|invite|admin|state.?chang)\b/i,
2689
- idor: /\b(idor|bola|object reference|broken access)\b/i,
2690
- userData:
2691
- /\b(user|users|profile|account|accounts|email|phone|address|personal|private|settings|data)\b/i,
2692
- ssti: /\b(ssti|template injection|template render)\b/i,
2693
- race: /\b(race|toctou|concurrent)\b/i,
2694
- payment: /\b(payment|transfer|order|checkout|cart|purchase|balance|credit|withdraw|deposit)\b/i,
2695
- infoDisclosure: /\b(info disclosure|information disclosure|leak|exposure|debug)\b/i,
2696
- ssrf: /\b(ssrf|server-?side request)\b/i,
2697
- } satisfies Record<string, RegExp>;
2698
-
2699
- /** Multi-label second-level suffixes — *.co.uk must not false-pair via last-2 labels. */
2700
- const SECOND_LEVEL_SUFFIXES = new Set([
2701
- "co",
2702
- "com",
2703
- "org",
2704
- "net",
2705
- "gov",
2706
- "ac",
2707
- "edu",
2708
- "mil",
2709
- "ltd",
2710
- "me",
2711
- "tv",
2712
- "info",
2713
- "biz",
2714
- ]);
2715
-
2716
- function eTLDPlus1(host: string): string {
2717
- const parts = host.split(".");
2718
- if (parts.length >= 3 && SECOND_LEVEL_SUFFIXES.has(parts[parts.length - 2] ?? "")) {
2719
- return parts.slice(-3).join(".");
2720
- }
2721
- return parts.slice(-2).join(".");
2722
- }
2723
-
2724
- /**
2725
- * Ruled-out phrasings that must not contribute to chain matching. Sentence
2726
- * granularity keeps the positive signals intact: "no CSRF token on /transfer"
2727
- * (a reason XSS→state-change chains) is NOT dropped — only explicit
2728
- * "this class is not a finding" sentences are.
2729
- */
2730
- const CHAIN_NEGATION_RE =
2731
- /\b(not vulnerable|not susceptible|not exploitable|not present|not found|not affected|ruled out|no vulnerability|no vuln|no evidence of|absence of|false positive|not a finding|no issue found|dismissed|non-?vulnerable|not reachable)\b/i;
2732
-
2733
- function chainText(c: CaseRecord): string {
2734
- const raw = [c.title, c.bugClass ?? "", c.evidence ?? ""].join(" ");
2735
- return raw
2736
- .split(/[.;\n]+/)
2737
- .filter((s) => !CHAIN_NEGATION_RE.test(s))
2738
- .join(" ");
2739
- }
2740
-
2741
- function hasChainClass(c: CaseRecord, re: RegExp): boolean {
2742
- return re.test(chainText(c));
2743
- }
2744
-
2745
- /** Reduce a target string to a bare hostname (strip scheme, port, path). */
2746
- function normalizeTargetHost(target: string): string {
2747
- let h = target
2748
- .toLowerCase()
2749
- .trim()
2750
- .replace(/^[a-z][a-z0-9+.-]*:\/\//, "");
2751
- h = h.split("?")[0].split("/")[0].split(":")[0];
2752
- return h.trim();
2753
- }
2754
-
2755
- /** Same asset or related (same eTLD+1) — chains only pair cases on one target. */
2756
- function sameAssetOrRelated(a: CaseRecord, b: CaseRecord): boolean {
2757
- const ta = normalizeTargetHost(a.target ?? "");
2758
- const tb = normalizeTargetHost(b.target ?? "");
2759
- if (!ta || !tb) return false;
2760
- if (ta === tb) return true;
2761
- // Subdomain relation requires a label boundary: "api.example.com" vs
2762
- // "example.com" pair, but "myshop.io" vs "shop.io" do NOT — a bare
2763
- // substring check pairs unrelated targets whose names merely overlap.
2764
- if (ta.endsWith(`.${tb}`) || tb.endsWith(`.${ta}`)) return true;
2765
- return eTLDPlus1(ta) === eTLDPlus1(tb);
2766
- }
2767
-
2768
- export function suggestChains(caseId?: string): ChainSuggestion[] {
2769
- // Pair over ALL non-terminal cases; the caseId filter narrows the RESULTS
2770
- // to suggestions involving that case (filtering the inputs first would drop
2771
- // unlinked partner cases and kill cross-case pairing).
2772
- const cases = readCasefile().filter((c) => c.status !== "killed" && c.status !== "reported");
2773
- // Already-linked pairs are existing knowledge, not a missed combination —
2774
- // suggesting them again is noise. One query for every link row.
2775
- const linkedPairs = new Set<string>();
2776
- const linkRows = getDb().prepare("SELECT source_id, target_id FROM case_links").all() as {
2777
- source_id: string;
2778
- target_id: string;
2779
- }[];
2780
- for (const row of linkRows) linkedPairs.add([row.source_id, row.target_id].sort().join("+"));
2781
- const suggestions: ChainSuggestion[] = [];
2782
- const seen = new Set<string>();
2783
- const confirmed = (c: CaseRecord) => c.status === "confirmed";
2784
- const confidenceFor = (a: CaseRecord, b?: CaseRecord) => {
2785
- const both = confirmed(a) && (!b || confirmed(b));
2786
- const one = confirmed(a) || (b ? confirmed(b) : false);
2787
- const anyHypothesis = a.status === "hypothesis" || (b ? b.status === "hypothesis" : false);
2788
- if (both) return 90;
2789
- if (anyHypothesis) return 40; // unproven primitives chain weakly
2790
- return one ? 75 : 60;
2791
- };
2792
- const add = (
2793
- pattern: ChainPattern,
2794
- a: CaseRecord,
2795
- b: CaseRecord | undefined,
2796
- rationale: string,
2797
- kind?: CaseLinkKind,
2798
- ) => {
2799
- if (b && linkedPairs.has([a.id, b.id].sort().join("+"))) return; // already known
2800
- const key = b ? `${pattern}:${[a.id, b.id].sort().join("+")}` : `${pattern}:${a.id}`;
2801
- if (seen.has(key)) return;
2802
- seen.add(key);
2803
- suggestions.push({
2804
- pattern,
2805
- sourceId: a.id,
2806
- targetId: b?.id,
2807
- sourceTitle: a.title,
2808
- targetTitle: b?.title,
2809
- rationale,
2810
- confidence: confidenceFor(a, b),
2811
- suggestedKind: kind,
2812
- });
2813
- };
2814
-
2815
- // Pair rules as data: (classifier A, classifier B, rationale, link kind).
2816
- // One loop replaces seven copy-pasted pair loops.
2817
- const PAIR_RULES: Array<{
2818
- pattern: Exclude<ChainPattern, "ssti_rce">;
2819
- a: RegExp;
2820
- b: RegExp;
2821
- rationale: (a: CaseRecord, b: CaseRecord) => string;
2822
- kind?: CaseLinkKind;
2823
- }> = [
2824
- {
2825
- pattern: "credential_endpoint",
2826
- a: CHAIN_CLASS_RE.credential,
2827
- b: CHAIN_CLASS_RE.authEndpoint,
2828
- kind: "depends-on",
2829
- rationale: (a, b) =>
2830
- `Use leaked credential "${a.title}" to authenticate against "${b.title}" → account takeover`,
2831
- },
2832
- {
2833
- pattern: "redirect_oauth",
2834
- a: CHAIN_CLASS_RE.redirect,
2835
- b: CHAIN_CLASS_RE.oauth,
2836
- rationale: (a, b) =>
2837
- `Chain open redirect "${a.title}" into OAuth flow "${b.title}" to steal access tokens`,
2838
- },
2839
- {
2840
- pattern: "xss_csrf",
2841
- a: CHAIN_CLASS_RE.xss,
2842
- b: CHAIN_CLASS_RE.stateChange,
2843
- rationale: (a, b) =>
2844
- `Use XSS "${a.title}" to drive state-changing "${b.title}" (CSRF bypass / victim-action)`,
2845
- },
2846
- {
2847
- pattern: "idor_data_leak",
2848
- a: CHAIN_CLASS_RE.idor,
2849
- b: CHAIN_CLASS_RE.userData,
2850
- rationale: (a, b) => `Use IDOR "${a.title}" to enumerate user data via "${b.title}"`,
2851
- },
2852
- {
2853
- pattern: "race_condition_business",
2854
- a: CHAIN_CLASS_RE.race,
2855
- b: CHAIN_CLASS_RE.payment,
2856
- rationale: (a, b) =>
2857
- `Use race condition "${a.title}" on financial endpoint "${b.title}" (double-spend / bypass)`,
2858
- },
2859
- {
2860
- pattern: "info_disclosure_ssrf",
2861
- a: CHAIN_CLASS_RE.infoDisclosure,
2862
- b: CHAIN_CLASS_RE.ssrf,
2863
- rationale: (a, b) =>
2864
- `Use internal URL/config from "${a.title}" as SSRF target via "${b.title}"`,
2865
- },
2866
- ];
2867
-
2868
- for (const rule of PAIR_RULES) {
2869
- const aCases = cases.filter((c) => rule.a.test(chainText(c)));
2870
- const bCases = cases.filter((c) => rule.b.test(chainText(c)));
2871
- for (const a of aCases) {
2872
- for (const b of bCases) {
2873
- if (a.id === b.id || !sameAssetOrRelated(a, b)) continue;
2874
- add(rule.pattern, a, b, rule.rationale(a, b), rule.kind);
2875
- }
2876
- }
2877
- }
2878
-
2879
- // SSTI → RCE (single-case escalation)
2880
- for (const s of cases.filter((c) => hasChainClass(c, CHAIN_CLASS_RE.ssti))) {
2881
- add("ssti_rce", s, undefined, `Escalate SSTI "${s.title}" to RCE via template-engine gadgets`);
2882
- }
2883
-
2884
- const scoped = caseId
2885
- ? suggestions.filter((s) => s.sourceId === caseId || s.targetId === caseId)
2886
- : suggestions;
2887
- return scoped.sort((a, b) => b.confidence - a.confidence);
2888
- }
2889
-
2890
1823
  // ── Link operations ──────────────────────────────────────────────────
2891
1824
 
2892
1825
  /** Both cases must exist and be mutable (not killed/reported). */