@xaccefy/pi-casefile 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ledger.ts CHANGED
@@ -11,22 +11,22 @@
11
11
  */
12
12
 
13
13
  import { createHash, randomUUID } from "node:crypto";
14
- import {
15
- type Dirent,
16
- existsSync,
17
- mkdirSync,
18
- readdirSync,
19
- readFileSync,
20
- statSync,
21
- writeFileSync,
22
- } from "node:fs";
14
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
23
15
  import { basename, dirname, join, resolve } from "node:path";
16
+ import {
17
+ type ConfirmerVerdict,
18
+ evidenceNonceMatches,
19
+ normalizeEvidence,
20
+ type PoCEvidence,
21
+ validateConfirmerVerdict,
22
+ } from "./evidence.ts";
24
23
  import {
25
24
  findWorkspaceRoot,
26
25
  getScratchpadRoot,
27
26
  PHASE_ORDER,
28
27
  scratchpad_read,
29
28
  scratchpad_resume,
29
+ scratchpad_runs,
30
30
  } from "./scratchpad.ts";
31
31
  import { DatabaseSync } from "./sqlite-compat/index.ts";
32
32
 
@@ -164,6 +164,13 @@ export type CaseRecord = {
164
164
  id: string;
165
165
  title: string;
166
166
  status: CaseStatus;
167
+ /**
168
+ * True once the case has EVER reached investigating or confirmed. The kill
169
+ * gate keys off this, not the current status: a demotion
170
+ * (investigating/confirmed -> hypothesis) must not let an advanced case die
171
+ * with a keyword in free text instead of artifact-backed refutation evidence.
172
+ */
173
+ everAdvanced: boolean;
167
174
  confidence: CaseConfidence;
168
175
  severity?: CaseSeverity;
169
176
  priority?: CasePriority;
@@ -186,29 +193,15 @@ export type CaseRecord = {
186
193
  /** Agent's documented attempt to disprove the finding (required before CONFIRMED). */
187
194
  disconfirmation?: string;
188
195
  /** Verification of an on-disk PoC run (set only by promoteFindingResult). */
189
- pocVerified?: {
190
- path: string;
191
- exitCode: number;
192
- ranAt: string;
193
- output?: string;
194
- sandbox: boolean;
195
- };
196
+ pocVerified?: PocVerificationRecord;
196
197
  /** Verification of a disconfirmation run (set only by promoteFindingResult). */
197
- disconfirmationVerified?: {
198
- path: string;
199
- exitCode: number;
200
- ranAt: string;
201
- output?: string;
202
- sandbox: boolean;
203
- };
204
- /** Verification of a control-target run (set only by promoteFindingResult; anti-cheat gate). */
205
- controlVerified?: {
206
- path: string;
207
- exitCode: number;
208
- ranAt: string;
209
- output?: string;
210
- sandbox: boolean;
211
- };
198
+ disconfirmationVerified?: PocVerificationRecord;
199
+ /** Verification of a control-target run (set only by the confirmation gate). */
200
+ controlVerified?: PocVerificationRecord;
201
+ /** Phase-1 evidence bundle awaiting a confirmer verdict (ConfirmFinding). */
202
+ pendingConfirmation?: PendingConfirmation;
203
+ /** Last confirmer verdict (CONFIRMED commits the promotion; NOT_CONFIRMED keeps investigating). */
204
+ confirmerVerdict?: ConfirmerVerdictRecord;
212
205
  /** ISO timestamp when CaseContext first wrote the context bundle. */
213
206
  reportedAt?: string;
214
207
  /** Path to the final report file (set by writeCaseContext; the reporter agent writes the file). */
@@ -223,6 +216,61 @@ export type CaseRecord = {
223
216
  updatedAt: string;
224
217
  };
225
218
 
219
+ export type PocVerificationRecord = {
220
+ path: string;
221
+ exitCode: number;
222
+ ranAt: string;
223
+ output?: string;
224
+ sandbox: boolean;
225
+ completed?: boolean;
226
+ outputComplete?: boolean;
227
+ mode?: string;
228
+ target?: string;
229
+ };
230
+
231
+ /** One harness-observed PoC run with its validated, nonce-bound evidence. */
232
+ export type PocEvidenceRun = {
233
+ mode: "poc" | "control";
234
+ target: string;
235
+ /** The run's PI_POC_NONCE — evidence.nonce must equal it (binds evidence to the run). */
236
+ nonce: string;
237
+ ranAt: string;
238
+ exitCode: number;
239
+ sandbox: boolean;
240
+ completed: boolean;
241
+ outputComplete: boolean;
242
+ /** Display-sliced output (diagnostic; exit codes are not gates). */
243
+ output: string;
244
+ evidence: PoCEvidence;
245
+ evidenceSha256: string;
246
+ /** Absolute path to the PRESERVED copy of this run's evidence.json (the
247
+ * runner copies the temp file into a durable .pi/poc-evidence/ dir; the
248
+ * reproduction item references it so the stored hash stays verifiable). */
249
+ evidencePath?: string;
250
+ };
251
+
252
+ /**
253
+ * Phase-1 bundle PromoteFinding records; ConfirmFinding commits on a verdict.
254
+ * Contains everything the confirmer reviews and the ledger re-checks.
255
+ */
256
+ export type PendingConfirmation = {
257
+ caseId: string;
258
+ ranAt: string;
259
+ pocPath: string;
260
+ /** SHA-256 of the PoC script AT RUN TIME — re-hashed at confirm to catch edits. */
261
+ pocSha256: string;
262
+ controlPath: string;
263
+ controlTarget: string;
264
+ targetRuns: [PocEvidenceRun, PocEvidenceRun];
265
+ controlRun: PocEvidenceRun;
266
+ };
267
+
268
+ /** Persisted confirmer verdict with the commit timestamp. */
269
+ export type ConfirmerVerdictRecord = ConfirmerVerdict & { at: string };
270
+
271
+ /** Pending confirmation expires after 1h — re-run PromoteFinding for a fresh bundle. */
272
+ export const PENDING_CONFIRM_TTL_MS = 60 * 60 * 1000;
273
+
226
274
  export type CaseInput = {
227
275
  title: string;
228
276
  status?: CaseStatus;
@@ -252,6 +300,8 @@ type NormalizedCaseInput = Partial<CaseInput> & {
252
300
  pocVerified?: CaseRecord["pocVerified"];
253
301
  disconfirmationVerified?: CaseRecord["disconfirmationVerified"];
254
302
  controlVerified?: CaseRecord["controlVerified"];
303
+ pendingConfirmation?: CaseRecord["pendingConfirmation"];
304
+ confirmerVerdict?: CaseRecord["confirmerVerdict"];
255
305
  reportedAt?: string;
256
306
  reportPath?: string;
257
307
  };
@@ -367,6 +417,13 @@ function getDb(): DatabaseSync {
367
417
  }
368
418
 
369
419
  const db = new DatabaseSync(dbPath);
420
+ // Give parallel agents a short write wait instead of immediate SQLITE_BUSY.
421
+ db.exec("PRAGMA busy_timeout = 5000");
422
+ try {
423
+ db.exec("PRAGMA journal_mode = WAL");
424
+ } catch {
425
+ // Some filesystems/backends reject WAL; rollback journal still works.
426
+ }
370
427
  // Enable foreign-key enforcement so ON DELETE CASCADE actually fires
371
428
  // (SQLite keeps FK off by default; bun:sqlite in particular defaults it off).
372
429
  db.exec("PRAGMA foreign_keys = ON");
@@ -377,6 +434,7 @@ function getDb(): DatabaseSync {
377
434
  id TEXT PRIMARY KEY,
378
435
  title TEXT NOT NULL,
379
436
  status TEXT NOT NULL,
437
+ ever_advanced INTEGER NOT NULL DEFAULT 0,
380
438
  confidence TEXT NOT NULL,
381
439
  severity TEXT,
382
440
  priority TEXT,
@@ -396,6 +454,8 @@ function getDb(): DatabaseSync {
396
454
  poc_verified_json TEXT, -- JSON object
397
455
  disconfirmation TEXT,
398
456
  disconfirmation_verified_json TEXT, -- JSON object
457
+ pending_confirmation_json TEXT, -- JSON object
458
+ confirmer_verdict_json TEXT, -- JSON object
399
459
  reported_at TEXT,
400
460
  report_path TEXT,
401
461
  created_at TEXT NOT NULL,
@@ -434,6 +494,21 @@ function getDb(): DatabaseSync {
434
494
  if (!caseCols.some((c) => c.name === "control_verified_json")) {
435
495
  db.exec("ALTER TABLE cases ADD COLUMN control_verified_json TEXT");
436
496
  }
497
+ if (!caseCols.some((c) => c.name === "pending_confirmation_json")) {
498
+ db.exec("ALTER TABLE cases ADD COLUMN pending_confirmation_json TEXT");
499
+ }
500
+ if (!caseCols.some((c) => c.name === "confirmer_verdict_json")) {
501
+ db.exec("ALTER TABLE cases ADD COLUMN confirmer_verdict_json TEXT");
502
+ }
503
+ if (!caseCols.some((c) => c.name === "ever_advanced")) {
504
+ db.exec("ALTER TABLE cases ADD COLUMN ever_advanced INTEGER NOT NULL DEFAULT 0");
505
+ // Backfill: a case that is (or was) past hypothesis has reached an
506
+ // advanced state. Terminal rows can no longer be mutated, but marking them
507
+ // keeps the flag consistent for history/context reads.
508
+ db.exec(
509
+ "UPDATE cases SET ever_advanced = 1 WHERE status IN ('investigating','confirmed','blocked','killed','reported')",
510
+ );
511
+ }
437
512
 
438
513
  // Role-typed, artifact-backed evidence items (Black-cat style evidence chain).
439
514
  db.exec(`
@@ -513,6 +588,7 @@ function mapRow(
513
588
  id: row.id,
514
589
  title: row.title,
515
590
  status: row.status as CaseStatus,
591
+ everAdvanced: row.ever_advanced === 1,
516
592
  confidence: row.confidence as CaseConfidence,
517
593
  severity: row.severity as CaseSeverity | undefined,
518
594
  priority: row.priority as CasePriority | undefined,
@@ -534,6 +610,8 @@ function mapRow(
534
610
  pocVerified: safeParseObject(row.poc_verified_json),
535
611
  disconfirmationVerified: safeParseObject(row.disconfirmation_verified_json),
536
612
  controlVerified: safeParseObject(row.control_verified_json),
613
+ pendingConfirmation: safeParseObject(row.pending_confirmation_json),
614
+ confirmerVerdict: safeParseObject(row.confirmer_verdict_json),
537
615
  reportedAt: row.reported_at || undefined,
538
616
  reportPath: row.report_path || undefined,
539
617
  evidenceItems,
@@ -679,16 +757,20 @@ function validateCase(record: CaseRecord): void {
679
757
  );
680
758
  }
681
759
  // Keep this gate in lockstep with promoteFindingResult: a case may only be
682
- // CONFIRMED when it has evidence, a PoC, demonstrated impact, and a severity.
760
+ // CONFIRMED when it has evidence, a PoC, demonstrated impact, a severity,
761
+ // and a named target (what host/repo/scope this affects).
683
762
  if (
684
763
  record.status === "confirmed" &&
685
764
  (!record.evidence ||
686
765
  !record.poc ||
687
766
  !record.impact ||
688
767
  !record.severity ||
768
+ !record.target ||
689
769
  !record.disconfirmation)
690
770
  ) {
691
- throw new Error("Confirmed cases require evidence, poc, impact, severity, and disconfirmation");
771
+ throw new Error(
772
+ "Confirmed cases require evidence, poc, impact, severity, target, and disconfirmation",
773
+ );
692
774
  }
693
775
  if (record.status === "blocked" && (record.blockers ?? []).length === 0) {
694
776
  throw new Error("Blocked cases require at least one blocker");
@@ -794,7 +876,16 @@ export const KILL_REASON_VALUES = [
794
876
  ] as const;
795
877
  export type KillReason = (typeof KILL_REASON_VALUES)[number];
796
878
 
797
- const KILL_REASON_PATTERN = new RegExp(`\\b(${KILL_REASON_VALUES.join("|")})\\b`, "i");
879
+ /**
880
+ * Matches a kill reason whether the agent wrote the canonical token
881
+ * ("out_of_scope"), a spaced form ("out of scope"), or hyphenated
882
+ * ("out-of-scope") — the underscore spelling is machine vocabulary; free text
883
+ * must not be rejected just because it reads naturally.
884
+ */
885
+ const KILL_REASON_PATTERN = new RegExp(
886
+ `\\b(${KILL_REASON_VALUES.map((v) => v.replace(/_/g, "[ _-]+")).join("|")})\\b`,
887
+ "i",
888
+ );
798
889
 
799
890
  function validateTransition(
800
891
  from: CaseStatus,
@@ -819,26 +910,32 @@ function validateTransition(
819
910
 
820
911
  if (to === "killed") {
821
912
  // Black-cat rule: a kill must be justified. Valid iff (a) a refutation
822
- // evidence item exists for this case, or (b) — only for hypothesis-stage
823
- // cases — the update states a kill reason from the KILLED catalog
824
- // vocabulary (matches workflow.ts). Once a case reached investigating or
825
- // confirmed, a keyword in free text is NOT enough: the kill must be backed
826
- // by a real refutation evidence item (EvidenceAdd role=refutation the
827
- // disprove attempt that ended the lead).
913
+ // evidence item exists for this case, or (b) — only for cases that have
914
+ // NEVER reached investigating/confirmed — the update states a kill reason
915
+ // from the KILLED catalog vocabulary (matches workflow.ts). Once a case
916
+ // reached investigating or confirmed (everAdvanced immune to demotion
917
+ // round-trips), a keyword in free text is NOT enough: the kill must be
918
+ // backed by a real refutation evidence item (EvidenceAdd role=refutation —
919
+ // the disprove attempt that ended the lead).
828
920
  const items = current ? listEvidenceItems(current.id) : [];
829
921
  if (!items.some((e) => e.role === "refutation" && e.sha256)) {
830
- const advanced = current?.status === "investigating" || current?.status === "confirmed";
831
- const text = [update.nextStep, (update.assumptions ?? []).join(" "), update.evidence]
922
+ const advanced = current?.everAdvanced === true;
923
+ const text = [
924
+ update.nextStep,
925
+ (update.assumptions ?? []).join(" "),
926
+ (update.blockers ?? []).join(" "),
927
+ update.evidence,
928
+ ]
832
929
  .filter(Boolean)
833
930
  .join(" ");
834
931
  if (advanced || !KILL_REASON_PATTERN.test(text)) {
835
932
  throw new Error(
836
933
  advanced
837
- ? "Cannot kill an investigating/confirmed case without ARTIFACT-BACKED refutation evidence: add " +
934
+ ? "Cannot kill an advanced case (ever reached investigating/confirmed) without ARTIFACT-BACKED refutation evidence: add " +
838
935
  "EvidenceAdd role=refutation with artifact_path (sha256 required — the disprove attempt " +
839
936
  "that ended this lead) before killing."
840
937
  : "Cannot kill without justification: add refutation evidence (EvidenceAdd role=refutation, " +
841
- "artifact_path recommended) or state a kill reason in assumptions/nextStep " +
938
+ "artifact_path recommended) or state a kill reason in assumptions/nextStep/blockers " +
842
939
  "(intended_behavior, duplicate, framework_protection, out_of_scope, " +
843
940
  "skeptic-disproven, no_attack_path, ...)",
844
941
  );
@@ -929,6 +1026,12 @@ function buildRecord(input: NormalizedCaseInput, existing?: CaseRecord): CaseRec
929
1026
  id,
930
1027
  title,
931
1028
  status: input.status ?? existing?.status ?? "hypothesis",
1029
+ // Once a case has been investigating/confirmed it never forgets — the kill
1030
+ // gate must not be defeatable by demoting first.
1031
+ everAdvanced:
1032
+ existing?.everAdvanced === true ||
1033
+ input.status === "investigating" ||
1034
+ input.status === "confirmed",
932
1035
  confidence: input.confidence ?? existing?.confidence ?? "low",
933
1036
  severity: input.severity ?? existing?.severity,
934
1037
  priority: input.priority ?? existing?.priority,
@@ -954,6 +1057,8 @@ function buildRecord(input: NormalizedCaseInput, existing?: CaseRecord): CaseRec
954
1057
  : existing?.disconfirmation,
955
1058
  disconfirmationVerified: input.disconfirmationVerified ?? existing?.disconfirmationVerified,
956
1059
  controlVerified: input.controlVerified ?? existing?.controlVerified,
1060
+ pendingConfirmation: input.pendingConfirmation ?? existing?.pendingConfirmation,
1061
+ confirmerVerdict: input.confirmerVerdict ?? existing?.confirmerVerdict,
957
1062
  reportedAt: input.reportedAt ?? existing?.reportedAt,
958
1063
  reportPath: input.reportPath ?? existing?.reportPath,
959
1064
  evidenceItems: existing?.evidenceItems ?? [],
@@ -1213,8 +1318,12 @@ function titleTokenRarityWeights(titles: string[]): Map<string, number> {
1213
1318
  const n = titles.length;
1214
1319
  const weights = new Map<string, number>();
1215
1320
  for (const [token, docs] of df) {
1216
- // +1 smoothing: tokens unique to one doc stay above the baseline.
1217
- weights.set(token, 1 + Math.log((n + 1) / (docs + 1)));
1321
+ // ln((n+1)/(docs+1)) NO +1 baseline. A token in every title scores ~0
1322
+ // (ln 1), a token in one title scores ln((n+1)/2) > 1 for n >= 3. The old
1323
+ // `1 + ln(...)` made every weight >= 1, so the weighted half of the hybrid
1324
+ // gate was vacuous (weightedSum >= sharedCount always) and generic
1325
+ // vocabulary could never be down-weighted.
1326
+ weights.set(token, Math.log((n + 1) / (docs + 1)));
1218
1327
  }
1219
1328
  return weights;
1220
1329
  }
@@ -1243,26 +1352,45 @@ function rowToRecord(db: DatabaseSync, row: any): CaseRecord {
1243
1352
 
1244
1353
  // ── SQLite Mutation Actions ───────────────────────────────────────────
1245
1354
 
1355
+ function withImmediateTransaction<T>(db: DatabaseSync, fn: () => T): T {
1356
+ db.exec("BEGIN IMMEDIATE");
1357
+ try {
1358
+ const value = fn();
1359
+ db.exec("COMMIT");
1360
+ return value;
1361
+ } catch (err) {
1362
+ try {
1363
+ db.exec("ROLLBACK");
1364
+ } catch {
1365
+ // ignore rollback errors
1366
+ }
1367
+ throw err;
1368
+ }
1369
+ }
1370
+
1246
1371
  function upsertCase(db: DatabaseSync, record: CaseRecord) {
1247
1372
  // Use ON CONFLICT DO UPDATE (not INSERT OR REPLACE) so FK CASCADE does not
1248
1373
  // wipe case_links when updating an existing primary key.
1249
1374
  const stmt = db.prepare(`
1250
1375
  INSERT INTO cases (
1251
- id, title, status, confidence, severity, priority, target, endpoint, bugClass,
1376
+ id, title, status, ever_advanced, confidence, severity, priority, target, endpoint, bugClass,
1252
1377
  summary, evidence, impact, nextStep, poc, remediation,
1253
1378
  references_json, blockers_json, tags_json, assumptions_json, poc_verified_json,
1254
1379
  disconfirmation, disconfirmation_verified_json, disprove_if_json, control_verified_json,
1380
+ pending_confirmation_json, confirmer_verdict_json,
1255
1381
  reported_at, report_path, created_at, updated_at
1256
1382
  ) VALUES (
1257
- ?, ?, ?, ?, ?, ?, ?, ?, ?,
1383
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
1258
1384
  ?, ?, ?, ?, ?, ?,
1259
1385
  ?, ?, ?, ?, ?,
1260
1386
  ?, ?, ?, ?,
1387
+ ?, ?,
1261
1388
  ?, ?, ?, ?
1262
1389
  )
1263
1390
  ON CONFLICT(id) DO UPDATE SET
1264
1391
  title = excluded.title,
1265
1392
  status = excluded.status,
1393
+ ever_advanced = excluded.ever_advanced,
1266
1394
  confidence = excluded.confidence,
1267
1395
  severity = excluded.severity,
1268
1396
  priority = excluded.priority,
@@ -1284,6 +1412,8 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
1284
1412
  disconfirmation_verified_json = excluded.disconfirmation_verified_json,
1285
1413
  disprove_if_json = excluded.disprove_if_json,
1286
1414
  control_verified_json = excluded.control_verified_json,
1415
+ pending_confirmation_json = excluded.pending_confirmation_json,
1416
+ confirmer_verdict_json = excluded.confirmer_verdict_json,
1287
1417
  reported_at = excluded.reported_at,
1288
1418
  report_path = excluded.report_path,
1289
1419
  created_at = excluded.created_at,
@@ -1294,6 +1424,7 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
1294
1424
  record.id,
1295
1425
  record.title,
1296
1426
  record.status,
1427
+ record.everAdvanced ? 1 : 0,
1297
1428
  record.confidence,
1298
1429
  record.severity || null,
1299
1430
  record.priority || null,
@@ -1315,6 +1446,8 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
1315
1446
  record.disconfirmationVerified ? JSON.stringify(record.disconfirmationVerified) : null,
1316
1447
  JSON.stringify(record.disproveIf),
1317
1448
  record.controlVerified ? JSON.stringify(record.controlVerified) : null,
1449
+ record.pendingConfirmation ? JSON.stringify(record.pendingConfirmation) : null,
1450
+ record.confirmerVerdict ? JSON.stringify(record.confirmerVerdict) : null,
1318
1451
  record.reportedAt || null,
1319
1452
  record.reportPath || null,
1320
1453
  record.createdAt,
@@ -1554,153 +1687,164 @@ export function coverageSummary(caseId: string): CoverageSummary {
1554
1687
  export function addCaseResult(input: CaseInput): CaseAddResult {
1555
1688
  const db = getDb();
1556
1689
  validateNewCaseInput(input);
1557
- const record = buildRecord(input, undefined);
1558
- validateCase(record);
1559
-
1560
- // Check duplicates
1561
- const duplicate = findDuplicateCaseInDb(db, record);
1562
- if (duplicate) {
1563
- return {
1564
- record: duplicate.record,
1565
- created: false,
1566
- nearDuplicate: duplicate.near,
1567
- reason: duplicate.near
1568
- ? `Near-duplicate of existing case ${duplicate.record.id} — "${duplicate.record.title}". ` +
1569
- `Same target, overlapping title. Your candidate was NOT created — the existing case is returned. ` +
1570
- `Continue with it via CaseUpdate, or re-file with a clearly distinct title if these are genuinely separate findings.`
1571
- : `Duplicate case exists: ${duplicate.record.id}`,
1572
- };
1573
- }
1690
+ return withImmediateTransaction(db, () => {
1691
+ const record = buildRecord(input, undefined);
1692
+ validateCase(record);
1693
+
1694
+ const duplicate = findDuplicateCaseInDb(db, record);
1695
+ if (duplicate) {
1696
+ return {
1697
+ record: duplicate.record,
1698
+ created: false,
1699
+ nearDuplicate: duplicate.near,
1700
+ reason: duplicate.near
1701
+ ? `Near-duplicate of existing case ${duplicate.record.id} — "${duplicate.record.title}". ` +
1702
+ `Same target, overlapping title. Your candidate was NOT created — the existing case is returned. ` +
1703
+ `Continue with it via CaseUpdate, or re-file with a clearly distinct title if these are genuinely separate findings.`
1704
+ : `Duplicate case exists: ${duplicate.record.id}`,
1705
+ };
1706
+ }
1574
1707
 
1575
- upsertCase(db, record);
1576
- return { record, created: true };
1708
+ upsertCase(db, record);
1709
+ return { record, created: true };
1710
+ });
1577
1711
  }
1578
1712
 
1579
1713
  export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResult {
1580
1714
  const db = getDb();
1581
- const current = getCaseById(id);
1582
- if (!current) {
1583
- throw new Error(`Case not found: ${id}`);
1584
- }
1715
+ return withImmediateTransaction(db, () => {
1716
+ const current = getCaseById(id);
1717
+ if (!current) {
1718
+ throw new Error(`Case not found: ${id}`);
1719
+ }
1585
1720
 
1586
- // Terminal states: block all mutations (status and field edits). The transition
1587
- // gate only runs on status changes, so without this reported/killed cases could
1588
- // still be rewritten via field-only updates.
1589
- if (current.status === "killed") {
1590
- throw new Error("Cannot mutate a killed case; open a new case if the lead is revived");
1591
- }
1592
- if (current.status === "reported") {
1593
- throw new Error("Cannot mutate a reported case; file a follow-up case instead");
1594
- }
1721
+ // Terminal states: block all mutations (status and field edits). The transition
1722
+ // gate only runs on status changes, so without this reported/killed cases could
1723
+ // still be rewritten via field-only updates.
1724
+ if (current.status === "killed") {
1725
+ throw new Error("Cannot mutate a killed case; open a new case if the lead is revived");
1726
+ }
1727
+ if (current.status === "reported") {
1728
+ throw new Error("Cannot mutate a reported case; file a follow-up case instead");
1729
+ }
1595
1730
 
1596
- // buildRecord resolves every field as `update.x ?? current.x`; the patch
1597
- // construction is the update itself.
1598
- let next = buildRecord(update, current);
1731
+ let next = buildRecord(update, current);
1599
1732
 
1600
- if (update.status && update.status !== current.status) {
1601
- validateTransition(current.status, next.status, update, current);
1602
- }
1733
+ if (update.status && update.status !== current.status) {
1734
+ validateTransition(current.status, next.status, update, current);
1735
+ }
1603
1736
 
1604
- // reportedAt is stamped when the confirmed → reported transition COMMITS
1605
- // (not at CaseContext time the bundle may be generated days before the
1606
- // report file is written, and a disclosure timeline must not lie).
1607
- // Reported cases are immutable, so current.status cannot be reported here.
1608
- if (next.status === "reported") {
1609
- next = { ...next, reportedAt: new Date().toISOString() };
1610
- }
1737
+ if (next.status === "reported") {
1738
+ next = { ...next, reportedAt: new Date().toISOString() };
1739
+ }
1611
1740
 
1612
- // Demoting off confirmed invalidates prior PoC + disconfirmation + control
1613
- // verification — re-promote required. All three artifacts must be re-earned
1614
- // together (a stale control run must not survive a demote/re-promote cycle).
1615
- if (current.status === "confirmed" && next.status === "investigating") {
1616
- next = {
1617
- ...next,
1618
- pocVerified: undefined,
1619
- disconfirmationVerified: undefined,
1620
- controlVerified: undefined,
1621
- };
1622
- }
1741
+ if (current.status === "confirmed" && next.status === "investigating") {
1742
+ next = {
1743
+ ...next,
1744
+ pocVerified: undefined,
1745
+ disconfirmationVerified: undefined,
1746
+ controlVerified: undefined,
1747
+ confirmerVerdict: undefined,
1748
+ pendingConfirmation: undefined,
1749
+ };
1750
+ }
1623
1751
 
1624
- validateCase(next);
1752
+ if (current.status === "confirmed" && next.status === "confirmed") {
1753
+ const proofFields = ["target", "poc", "impact", "severity"] as const;
1754
+ const changed = proofFields.filter((field) => current[field] !== next[field]);
1755
+ if (changed.length > 0) {
1756
+ throw new Error(
1757
+ `Confirmed proof-bound field(s) changed: ${changed.join(", ")}. ` +
1758
+ `Demote the case with status: "investigating" in the same update; that clears stale verification records and requires re-promotion.`,
1759
+ );
1760
+ }
1761
+ }
1625
1762
 
1626
- // Check material equality (we ignore links since links are mutated via CaseLink).
1627
- // Keys are sorted before stringify because mapRow (DB read) and buildRecord
1628
- // (write) emit CaseRecord keys in different orders — a plain JSON.stringify({...r})
1629
- // would report false "changed" on no-op updates whenever a field is undefined on
1630
- // one side and absent on the other. Sorting makes the comparison order-independent.
1631
- // Do NOT simplify back to JSON.stringify({...r}) — it reintroduces the bug.
1632
- const norm = (r: CaseRecord) =>
1633
- JSON.stringify(
1634
- Object.keys(r)
1635
- .sort()
1636
- .reduce<Record<string, unknown>>((acc, k) => {
1637
- if (
1638
- k === "updatedAt" ||
1639
- k === "createdAt" ||
1640
- k === "linkedCases" ||
1641
- k === "evidenceItems" ||
1642
- k === "coverageItems"
1643
- ) {
1644
- acc[k] = "";
1645
- } else {
1646
- acc[k] = (r as Record<string, unknown>)[k];
1647
- }
1648
- return acc;
1649
- }, {}),
1650
- );
1651
- if (norm(current) === norm(next)) {
1652
- const reason =
1653
- update.status && update.status === current.status
1654
- ? `Case is already ${current.status}; no material fields changed.`
1655
- : "No material fields changed.";
1656
- return { record: current, changed: false, reason };
1657
- }
1658
-
1659
- const duplicate = findDuplicateCaseInDb(db, next, id);
1660
- if (duplicate) {
1661
- return {
1662
- record: current,
1663
- changed: false,
1664
- reason: duplicate.near
1665
- ? `Update would near-duplicate case ${duplicate.record.id} — "${duplicate.record.title}" ` +
1666
- `(same target, overlapping title). Not applied — continue with the existing case, or pick a ` +
1667
- `clearly distinct title if these are genuinely separate findings.`
1668
- : `Update would create a duplicate of case ${duplicate.record.id}`,
1669
- };
1670
- }
1763
+ validateCase(next);
1764
+
1765
+ const norm = (r: CaseRecord) =>
1766
+ JSON.stringify(
1767
+ Object.keys(r)
1768
+ .sort()
1769
+ .reduce<Record<string, unknown>>((acc, k) => {
1770
+ if (
1771
+ k === "updatedAt" ||
1772
+ k === "createdAt" ||
1773
+ k === "linkedCases" ||
1774
+ k === "evidenceItems" ||
1775
+ k === "coverageItems"
1776
+ ) {
1777
+ acc[k] = "";
1778
+ } else {
1779
+ acc[k] = (r as Record<string, unknown>)[k];
1780
+ }
1781
+ return acc;
1782
+ }, {}),
1783
+ );
1784
+ if (norm(current) === norm(next)) {
1785
+ const reason =
1786
+ update.status && update.status === current.status
1787
+ ? `Case is already ${current.status}; no material fields changed.`
1788
+ : "No material fields changed.";
1789
+ return { record: current, changed: false, reason };
1790
+ }
1671
1791
 
1672
- upsertCase(db, next);
1673
- return { record: next, changed: true };
1792
+ const duplicate = findDuplicateCaseInDb(db, next, id);
1793
+ if (duplicate) {
1794
+ return {
1795
+ record: current,
1796
+ changed: false,
1797
+ reason: duplicate.near
1798
+ ? `Update would near-duplicate case ${duplicate.record.id} — "${duplicate.record.title}" ` +
1799
+ `(same target, overlapping title). Not applied — continue with the existing case, or pick a ` +
1800
+ `clearly distinct title if these are genuinely separate findings.`
1801
+ : `Update would create a duplicate of case ${duplicate.record.id}`,
1802
+ };
1803
+ }
1804
+
1805
+ upsertCase(db, next);
1806
+ return { record: next, changed: true };
1807
+ });
1674
1808
  }
1675
1809
 
1676
- export type PocVerification = {
1677
- path: string;
1678
- exitCode: number;
1679
- ranAt: string;
1680
- output?: string;
1681
- sandbox: boolean;
1682
- /** True iff the script ran to completion (not a spawn error / signal kill / timeout). */
1683
- completed?: boolean;
1684
- /**
1685
- * Sanitized but UNTRUNCATED output, used for marker presence/absence
1686
- * checks. Never persisted to the ledger (stripRaw drops it) — a cheating
1687
- * script must not hide its marker in the slice, and the DB must not grow
1688
- * with megabytes of run output.
1689
- */
1690
- rawOutput?: string;
1691
- };
1810
+ function validateRunEvidence(run: PocEvidenceRun, label: string): void {
1811
+ if (!run.completed) {
1812
+ throw new Error(`${label} did not complete; a crash is not evidence`);
1813
+ }
1814
+ if (!run.outputComplete) {
1815
+ throw new Error(`${label} output capture was incomplete; evidence checks are unsafe`);
1816
+ }
1817
+ if (!run.evidence || !run.evidenceSha256) {
1818
+ throw new Error(
1819
+ `${label} has no evidence.json the PoC must write evidence to $PI_POC_EVIDENCE_DIR`,
1820
+ );
1821
+ }
1822
+ if (!evidenceNonceMatches(run.evidence, run.nonce)) {
1823
+ throw new Error(`${label} evidence nonce mismatch — evidence not bound to this run`);
1824
+ }
1825
+ }
1692
1826
 
1693
- /** Drop the transient rawOutput before persisting a verification record. */
1694
- function stripRaw(v: PocVerification): PocVerification {
1695
- const { rawOutput: _raw, ...rest } = v;
1696
- return rest;
1827
+ /** Determinism + differential on normalized evidence (nonce/observations stripped). */
1828
+ function assertEvidenceDifferential(bundle: PendingConfirmation): void {
1829
+ const [r1, r2] = bundle.targetRuns;
1830
+ if (normalizeEvidence(r1.evidence) !== normalizeEvidence(r2.evidence)) {
1831
+ throw new Error(
1832
+ "Target runs produced inconsistent evidence — the exploit did not reproduce deterministically",
1833
+ );
1834
+ }
1835
+ if (normalizeEvidence(r1.evidence) === normalizeEvidence(bundle.controlRun.evidence)) {
1836
+ throw new Error(
1837
+ "Control run produced identical evidence to the target — the claimed impact is not target-dependent",
1838
+ );
1839
+ }
1697
1840
  }
1698
1841
 
1699
1842
  /**
1700
- * Gate for promotion to confirmed: case must exist, be investigating, and have
1701
- * poc/evidence/impact/severity. Returns the record when promotable, throws
1702
- * otherwise. Exported so PromoteFinding can validate BEFORE paying for a
1703
- * (potentially slow) sandboxed PoC run.
1843
+ * Gate for phase 1 of promotion: case must exist, be investigating, and have
1844
+ * poc/evidence/impact/severity/target. The disconfirmation is provided by the
1845
+ * confirmer at confirm time, so it is NOT a precondition here. Returns the
1846
+ * record when promotable, throws otherwise. Exported so PromoteFinding can
1847
+ * validate BEFORE paying for (potentially slow) sandboxed PoC runs.
1704
1848
  */
1705
1849
  export function assertPromotable(id: string): CaseRecord {
1706
1850
  const current = getCaseById(id);
@@ -1708,7 +1852,7 @@ export function assertPromotable(id: string): CaseRecord {
1708
1852
  throw new Error(`Case not found: ${id}`);
1709
1853
  }
1710
1854
  if (current.status !== "investigating") {
1711
- throw new Error(`promote_finding requires an investigating case (current: ${current.status})`);
1855
+ throw new Error(`PromoteFinding requires an investigating case (current: ${current.status})`);
1712
1856
  }
1713
1857
  if (!current.poc) {
1714
1858
  throw new Error("CONFIRMED requires poc; set poc on the case first");
@@ -1727,15 +1871,10 @@ export function assertPromotable(id: string): CaseRecord {
1727
1871
  "CONFIRMED requires target (what host/repo/scope this affects); set target on the case first",
1728
1872
  );
1729
1873
  }
1730
- if (!current.disconfirmation) {
1731
- throw new Error(
1732
- "CONFIRMED requires disconfirmation (your attempt to disprove the finding); set disconfirmation on the case first",
1733
- );
1734
- }
1735
1874
  // Evidence-chain closure: the observation item must be ARTIFACT-BACKED. A
1736
1875
  // summary-only observation is agent prose about itself — promotion requires
1737
1876
  // a real file with its SHA-256 as the initial signal. (The reproduction item
1738
- // is always artifact-backed: the PoC gate writes it from verification.path.)
1877
+ // is always artifact-backed: the gate writes it from the evidence hash.)
1739
1878
  if (!current.evidenceItems.some((e) => e.role === "observation" && e.sha256)) {
1740
1879
  throw new Error(
1741
1880
  "Evidence chain incomplete: CONFIRMED requires an artifact-backed observation evidence item " +
@@ -1746,203 +1885,242 @@ export function assertPromotable(id: string): CaseRecord {
1746
1885
  return current;
1747
1886
  }
1748
1887
 
1749
- export function promoteFindingResult(
1750
- id: string,
1751
- verification: PocVerification,
1752
- disconfirmationVerification?: PocVerification,
1753
- controlVerification?: PocVerification,
1754
- marker?: string,
1755
- controlLivenessMarker?: string,
1756
- ): CaseUpdateResult {
1888
+ /**
1889
+ * Phase 1: record the harness-observed evidence bundle on the case. The whole
1890
+ * contract is validated here — same-file control, nonce binding, run
1891
+ * completion, determinism across the two target runs, and the target/control
1892
+ * differential — so a bundle that cannot promote is rejected before the
1893
+ * confirmer is ever dispatched.
1894
+ */
1895
+ export function storePendingConfirmation(id: string, bundle: PendingConfirmation): CaseRecord {
1757
1896
  const db = getDb();
1758
- const current = assertPromotable(id);
1759
- if (verification.exitCode !== 0) {
1760
- throw new Error(
1761
- `PoC verification failed (exit ${verification.exitCode}); cannot promote to confirmed`,
1762
- );
1763
- }
1764
-
1765
- // Anti-cheat, enforced at the ledger level (not just the tool) for EVERY
1766
- // promotion sandboxed and live alike. The control-target run is the only
1767
- // deterministic proof that the verification marker is target-dependent: an
1768
- // unconditional-marker PoC prints it in the control too. The control must
1769
- // have COMPLETED (a crash proves nothing), its output must NOT contain the
1770
- // verification marker (when known), AND must contain the control liveness
1771
- // Liveness is mandatory at the ledger too: omitting it is not
1772
- // a bypass for direct promoteFindingResult callers.
1773
- const liveness = controlLivenessMarker?.trim();
1774
- if (!liveness) {
1775
- throw new Error(
1776
- "Every promotion requires controlLivenessMarker: a non-empty string the control run must print " +
1777
- "after reaching its target. PromoteFinding requires control_path + control_liveness_marker.",
1778
- );
1779
- }
1780
- // Verification marker is MANDATORY at the ledger too — no `!marker` branch.
1781
- // The tool always passes it; a future direct caller that omits it must fail
1782
- // closed, not get a weakened control gate.
1783
- const verificationMarker = marker?.trim();
1784
- if (!verificationMarker) {
1785
- throw new Error(
1786
- "Every promotion requires verificationMarker: the marker the PoC must print after exploitation. " +
1787
- "promoteFindingResult refuses to promote on exit 0 alone.",
1788
- );
1789
- }
1790
-
1791
- // Same-file contract: the control must be the SAME script as the PoC
1792
- // (differing only via PI_POC_MODE). The tool enforces this before running;
1793
- // the ledger re-checks so a direct caller cannot bypass it. A separately
1794
- // written control file is meaningless — the same actor writes both.
1795
- let pocHash: string | undefined;
1796
- let controlHash: string | undefined;
1797
- try {
1798
- pocHash = createHash("sha256").update(readFileSync(verification.path)).digest("hex");
1799
- controlHash = createHash("sha256")
1800
- .update(readFileSync(controlVerification?.path ?? ""))
1801
- .digest("hex");
1802
- } catch {
1803
- pocHash = undefined;
1804
- controlHash = undefined;
1805
- }
1806
- if (!pocHash || !controlHash || pocHash !== controlHash) {
1807
- throw new Error(
1808
- "Every promotion requires controlVerification from the SAME script as the PoC " +
1809
- "(sha256 of controlVerification.path must equal sha256 of verification.path). " +
1810
- "A separately written control file proves nothing.",
1811
- );
1812
- }
1813
-
1814
- // Marker-absence + liveness checks run on the UNTRUNCATED output
1815
- // (rawOutput) a script printing its marker past the 4000-char display
1816
- // window must not hide it from the control check.
1817
- const controlOutput = controlVerification?.rawOutput ?? controlVerification?.output ?? "";
1818
- const controlOk =
1819
- controlVerification?.completed === true &&
1820
- !controlOutput.includes(verificationMarker) &&
1821
- controlOutput.includes(liveness);
1822
- if (!controlOk) {
1823
- throw new Error(
1824
- "Every promotion requires a valid controlVerification: a control-target run of the same " +
1825
- `PoC that COMPLETED (completed: true) and whose output does not contain the marker "${verificationMarker}"` +
1826
- ` and whose output DOES contain the control liveness marker "${liveness}"` +
1827
- " (the control must actually reach its target — a failed/early control is not a clean verdict)" +
1828
- ". PromoteFinding requires control_path + control_liveness_marker.",
1829
- );
1830
- }
1897
+ return withImmediateTransaction(db, () => {
1898
+ const current = getCaseById(id);
1899
+ if (!current) throw new Error(`Case not found: ${id}`);
1900
+ if (current.status !== "investigating") {
1901
+ throw new Error(
1902
+ `Pending confirmation requires an investigating case (current: ${current.status})`,
1903
+ );
1904
+ }
1905
+ if (bundle.caseId !== id) throw new Error("Pending confirmation caseId mismatch");
1906
+ if (bundle.targetRuns.length !== 2 || !bundle.controlRun) {
1907
+ throw new Error("Pending confirmation requires two target runs and one control run");
1908
+ }
1909
+ if (!bundle.pocPath || !bundle.controlPath || !bundle.controlTarget) {
1910
+ throw new Error("Pending confirmation requires pocPath, controlPath, and controlTarget");
1911
+ }
1912
+ // Control-target binding (machine-verified here, not just in the tool
1913
+ // layer): the control run must actually have targeted the declared
1914
+ // control_target, that target must differ from the target runs' target,
1915
+ // and the control target must differ from the case's target otherwise
1916
+ // "the control demonstrated nothing on the vulnerable target" passes.
1917
+ const targetRunTarget = bundle.targetRuns[0]?.target;
1918
+ if (!targetRunTarget || bundle.targetRuns.some((r) => r.target !== targetRunTarget)) {
1919
+ throw new Error(
1920
+ "Pending confirmation requires both target runs against the same case target",
1921
+ );
1922
+ }
1923
+ if (!bundle.controlRun.target || bundle.controlRun.target !== bundle.controlTarget) {
1924
+ throw new Error(
1925
+ "CONTROL BINDING FAILED: controlRun.target must equal control_target a control run " +
1926
+ "against a different host than the one declared proves nothing.",
1927
+ );
1928
+ }
1929
+ if (bundle.controlRun.target === targetRunTarget) {
1930
+ throw new Error(
1931
+ "CONTROL BINDING FAILED: the control run targeted the same host as the target runs — " +
1932
+ "the claimed impact is not target-dependent.",
1933
+ );
1934
+ }
1935
+ if (bundle.controlTarget === current.target) {
1936
+ throw new Error(
1937
+ "CONTROL BINDING FAILED: control_target must differ from the case target; a control run " +
1938
+ "against the vulnerable target proves nothing.",
1939
+ );
1940
+ }
1941
+ // Same-file contract re-checked at store time (the tool already checked).
1942
+ let pocHash: string | undefined;
1943
+ let controlHash: string | undefined;
1944
+ try {
1945
+ pocHash = createHash("sha256").update(readFileSync(bundle.pocPath)).digest("hex");
1946
+ controlHash = createHash("sha256").update(readFileSync(bundle.controlPath)).digest("hex");
1947
+ } catch {
1948
+ pocHash = undefined;
1949
+ controlHash = undefined;
1950
+ }
1951
+ if (!pocHash || !controlHash || pocHash !== controlHash) {
1952
+ throw new Error(
1953
+ "CONTROL CHECK FAILED: control_path must be the SAME script as poc_path " +
1954
+ "(sha256 mismatch). A separately written control file proves nothing.",
1955
+ );
1956
+ }
1957
+ if (bundle.pocSha256 && bundle.pocSha256 !== pocHash) {
1958
+ throw new Error("pocSha256 does not match the PoC file on disk");
1959
+ }
1960
+ for (const run of [...bundle.targetRuns, bundle.controlRun]) {
1961
+ validateRunEvidence(run, `${run.mode} run`);
1962
+ }
1963
+ assertEvidenceDifferential(bundle);
1831
1964
 
1832
- // The verification marker must ALSO be present in the (untruncated) PoC
1833
- // output at the ledger level — defense in depth against direct callers
1834
- // skipping the tool's check (exit 0 alone is not a verdict).
1835
- const pocOutput = verification.rawOutput ?? verification.output ?? "";
1836
- if (!pocOutput.includes(verificationMarker)) {
1837
- throw new Error(
1838
- `PoC verification output does not contain the verification marker "${verificationMarker}"; ` +
1839
- "exit 0 alone cannot promote to confirmed",
1840
- );
1841
- }
1965
+ const next = buildRecord({ pendingConfirmation: bundle }, current);
1966
+ validateCase(next);
1967
+ upsertCase(db, next);
1968
+ return next;
1969
+ });
1970
+ }
1842
1971
 
1843
- // EVERY promotion must survive an EXECUTED disconfirmation run: completed
1844
- // (no crash a crash is not a survived disproof) and non-zero exit (the
1845
- // finding was NOT disproven). Unconditional (not severity-keyed): a case
1846
- // filed at low/medium must not skip the run and be re-raised afterwards,
1847
- // and the prose `disconfirmation` field cannot carry the burden alone.
1848
- const disconfirmationOk =
1849
- disconfirmationVerification?.completed === true && disconfirmationVerification.exitCode !== 0;
1850
- if (!disconfirmationOk) {
1851
- throw new Error(
1852
- "Every promotion requires an executed disconfirmation run that COMPLETED " +
1853
- "and exited non-zero (the finding survived the attempt to disprove it). PromoteFinding " +
1854
- "requires disconfirmation_path for every promotion.",
1855
- );
1856
- }
1972
+ /**
1973
+ * Phase 2: commit (or refuse) the promotion on a confirmer verdict.
1974
+ *
1975
+ * CONFIRMED requires the full bundle to still hold (completion, nonce,
1976
+ * determinism, differential), the PoC script to be unchanged since the runs
1977
+ * (pocSha256 — otherwise the confirmer reviewed different bytes), and a
1978
+ * verdict that re-executed the verify request with a target-only differential
1979
+ * and a disconfirmation attempt. NOT_CONFIRMED records the verdict and keeps
1980
+ * the case investigating — no tie-breaker.
1981
+ */
1982
+ export function applyConfirmationResult(
1983
+ id: string,
1984
+ verdictInput: ConfirmerVerdict,
1985
+ ): CaseUpdateResult {
1986
+ const db = getDb();
1987
+ return withImmediateTransaction(db, () => {
1988
+ const current = getCaseById(id);
1989
+ if (!current) throw new Error(`Case not found: ${id}`);
1990
+ if (current.status !== "investigating") {
1991
+ throw new Error(`ConfirmFinding requires an investigating case (current: ${current.status})`);
1992
+ }
1993
+ const bundle = current.pendingConfirmation;
1994
+ if (!bundle) {
1995
+ throw new Error("No pending confirmation on this case — run PromoteFinding first");
1996
+ }
1997
+ // Fail closed on an unparseable ranAt: Date.parse(garbage) is NaN, and
1998
+ // NaN > TTL is false — a malformed timestamp must NOT make the bundle
1999
+ // immortal. Treat it as expired (re-run PromoteFinding for a fresh one).
2000
+ const ranAtMs = Date.parse(bundle.ranAt);
2001
+ if (!Number.isFinite(ranAtMs) || Date.now() - ranAtMs > PENDING_CONFIRM_TTL_MS) {
2002
+ throw new Error(
2003
+ "Pending confirmation expired or has an invalid timestamp (1h TTL) — re-run PromoteFinding for a fresh bundle",
2004
+ );
2005
+ }
2006
+ const parsed = validateConfirmerVerdict(verdictInput);
2007
+ if (!parsed.ok) throw new Error(`Invalid confirmer verdict: ${parsed.error}`);
2008
+ const verdict = parsed.verdict;
2009
+ const recorded: ConfirmerVerdictRecord = { ...verdict, at: new Date().toISOString() };
2010
+
2011
+ if (verdict.verdict === "NOT_CONFIRMED") {
2012
+ const note = `confirmer NOT_CONFIRMED${verdict.model ? ` (${verdict.model})` : ""}: ${verdict.reasoning}`;
2013
+ const next = buildRecord(
2014
+ { confirmerVerdict: recorded, assumptions: [...(current.assumptions ?? []), note] },
2015
+ current,
2016
+ );
2017
+ validateCase(next);
2018
+ upsertCase(db, next);
2019
+ return { record: next, changed: true };
2020
+ }
1857
2021
 
1858
- // Machine-recorded reproduction evidence: the PoC gate itself writes the
1859
- // artifact-backed evidence item confirmation is anchored to a real file
1860
- // with its SHA-256, not to agent prose in the evidence field.
1861
- let pocSha256: string | undefined;
1862
- try {
1863
- pocSha256 = createHash("sha256").update(readFileSync(verification.path)).digest("hex");
1864
- } catch {
1865
- pocSha256 = undefined;
1866
- }
1867
-
1868
- // Cheap provenance guards on the observation item: it must be a DIFFERENT
1869
- // file than the PoC (same hash = the model re-used its PoC as "the initial
1870
- // signal"), a different basename, and it must predate the PoC run (an
1871
- // observation recorded after the fact is not the initial signal). The
1872
- // irreducible residual — a model writing a fake capture file seconds before
1873
- // EvidenceAdd — is documented, not machine-checkable.
1874
- const observation = current.evidenceItems.find((e) => e.role === "observation" && e.sha256);
1875
- if (observation) {
1876
- if (pocSha256 && observation.sha256 === pocSha256) {
2022
+ // CONFIRMED re-validate the whole bundle (defense in depth; the case may
2023
+ // have been touched between phase 1 and the verdict).
2024
+ for (const run of [...bundle.targetRuns, bundle.controlRun]) {
2025
+ validateRunEvidence(run, `${run.mode} run`);
2026
+ }
2027
+ assertEvidenceDifferential(bundle);
2028
+ let pocHash: string | undefined;
2029
+ try {
2030
+ pocHash = createHash("sha256").update(readFileSync(bundle.pocPath)).digest("hex");
2031
+ } catch {
2032
+ pocHash = undefined;
2033
+ }
2034
+ if (!pocHash || pocHash !== bundle.pocSha256) {
1877
2035
  throw new Error(
1878
- "Evidence chain invalid: the observation artifact is the same file as the PoC " +
1879
- "(identical sha256). The initial signal must be a separate captured artifact.",
2036
+ "PoC script changed since the runs re-run PromoteFinding (the confirmer must review the exact bytes that ran)",
1880
2037
  );
1881
2038
  }
1882
- if (observation.artifactPath && observation.artifactPath === basename(verification.path)) {
2039
+ // The case target must still be the host the PoC ran against, and still
2040
+ // differ from the control target. The evidence proves nothing about a
2041
+ // target the case adopted after the runs.
2042
+ const targetRun = bundle.targetRuns[0];
2043
+ if (!current.target || current.target !== targetRun.target) {
1883
2044
  throw new Error(
1884
- "Evidence chain invalid: the observation artifact has the same basename as the PoC file. " +
1885
- "The initial signal must be a separate captured artifact.",
2045
+ "Case target changed since the PoC runs re-run PromoteFinding against the current target " +
2046
+ `(bundle target: ${targetRun.target}, case target: ${current.target ?? "(none)"}).`,
1886
2047
  );
1887
2048
  }
1888
- if (observation.createdAt > verification.ranAt) {
2049
+ if (current.target === bundle.controlTarget) {
2050
+ throw new Error(
2051
+ "Case target now equals the control target — the claimed impact is not target-dependent; " +
2052
+ "re-run PromoteFinding with a distinct control_target.",
2053
+ );
2054
+ }
2055
+
2056
+ // The observation must predate the repro (provenance guard).
2057
+ const observation = current.evidenceItems.find((e) => e.role === "observation" && e.sha256);
2058
+ if (observation && observation.createdAt > bundle.targetRuns[0].ranAt) {
1889
2059
  throw new Error(
1890
2060
  "Evidence chain invalid: the observation item was recorded after the PoC ran " +
1891
- `(${observation.createdAt} > ${verification.ranAt}). The observation must predate the repro.`,
2061
+ `(${observation.createdAt} > ${bundle.targetRuns[0].ranAt}). The observation must predate the repro.`,
1892
2062
  );
1893
2063
  }
1894
- }
1895
- const reproductionItem: EvidenceItem = {
1896
- id: `ev_${stableShortId(`${id}\nreproduction\n${verification.ranAt}`)}`,
1897
- caseId: id,
1898
- role: "reproduction",
1899
- artifactPath: basename(verification.path),
1900
- sha256: pocSha256,
1901
- summary: `PoC run exit ${verification.exitCode} (sandbox: ${verification.sandbox}) — verification marker present in output`,
1902
- createdAt: verification.ranAt,
1903
- };
1904
2064
 
1905
- const newEvidence =
1906
- (current.evidence ? `${current.evidence}\n\n` : "") +
1907
- `### PoC Execution Capture (${verification.ranAt})\n` +
1908
- `- **Exit Code:** ${verification.exitCode}\n` +
1909
- `- **Sandbox:** ${verification.sandbox ? "yes" : "no"}\n` +
1910
- `#### Execution Output\n\`\`\`\n${verification.output ?? ""}\n\`\`\``;
1911
-
1912
- const update: NormalizedCaseInput = {
1913
- status: "confirmed",
1914
- pocVerified: stripRaw(verification),
1915
- evidence: newEvidence,
1916
- };
1917
- if (disconfirmationVerification) {
1918
- update.disconfirmationVerified = stripRaw(disconfirmationVerification);
1919
- }
1920
- if (controlVerification) {
1921
- update.controlVerified = stripRaw(controlVerification);
1922
- }
2065
+ const reproductionItem: EvidenceItem = {
2066
+ id: `ev_${stableShortId(`${id}\nreproduction\n${targetRun.ranAt}`)}`,
2067
+ caseId: id,
2068
+ role: "reproduction",
2069
+ // The runner preserves each run's evidence.json in a durable dir
2070
+ // (.pi/poc-evidence/) the artifact the hash was computed over still
2071
+ // exists, so the item stays artifact-backed and re-verifiable.
2072
+ artifactPath: targetRun.evidencePath ? basename(targetRun.evidencePath) : "evidence.json",
2073
+ sha256: targetRun.evidenceSha256,
2074
+ summary: `PoC evidence verified (2 target runs + control) — confirmer CONFIRMED${verdict.model ? ` (${verdict.model})` : ""}`,
2075
+ createdAt: targetRun.ranAt,
2076
+ };
1923
2077
 
1924
- const next = buildRecord(update, current);
1925
- validateCase(next);
2078
+ const newEvidence =
2079
+ (current.evidence ? `${current.evidence}\n\n` : "") +
2080
+ `### PoC Execution Capture (${targetRun.ranAt})\n` +
2081
+ `- **Evidence sha256:** ${targetRun.evidenceSha256}\n` +
2082
+ `- **Target:** ${targetRun.target}\n` +
2083
+ `- **Confirmer:** ${verdict.model ?? "unknown model"} — CONFIRMED\n` +
2084
+ `#### Target Run Output\n\`\`\`\n${targetRun.output ?? ""}\n\`\`\``;
2085
+
2086
+ const update: NormalizedCaseInput = {
2087
+ status: "confirmed",
2088
+ pocVerified: {
2089
+ path: bundle.pocPath,
2090
+ exitCode: targetRun.exitCode,
2091
+ ranAt: targetRun.ranAt,
2092
+ output: targetRun.output,
2093
+ sandbox: targetRun.sandbox,
2094
+ completed: true,
2095
+ outputComplete: true,
2096
+ mode: "poc",
2097
+ target: targetRun.target,
2098
+ },
2099
+ controlVerified: {
2100
+ path: bundle.controlPath,
2101
+ exitCode: bundle.controlRun.exitCode,
2102
+ ranAt: bundle.controlRun.ranAt,
2103
+ output: bundle.controlRun.output,
2104
+ sandbox: bundle.controlRun.sandbox,
2105
+ completed: true,
2106
+ outputComplete: true,
2107
+ mode: "control",
2108
+ target: bundle.controlRun.target,
2109
+ },
2110
+ disconfirmation: verdict.disconfirmation_attempt,
2111
+ confirmerVerdict: recorded,
2112
+ pendingConfirmation: undefined,
2113
+ evidence: newEvidence,
2114
+ };
1926
2115
 
1927
- // Evidence insert + case upsert are one atomic step: a failure between them
1928
- // would orphan a reproduction item on an investigating case (a promotion
1929
- // that never happened must leave no trace).
1930
- db.exec("BEGIN");
1931
- try {
2116
+ const next = buildRecord(update, current);
2117
+ next.pendingConfirmation = undefined; // buildRecord's ?? existing keeps it; clear explicitly
2118
+ validateCase(next);
1932
2119
  insertEvidenceItem(db, reproductionItem);
1933
2120
  upsertCase(db, next);
1934
- db.exec("COMMIT");
1935
- } catch (err) {
1936
- try {
1937
- db.exec("ROLLBACK");
1938
- } catch {
1939
- // ignore
1940
- }
1941
- throw err;
1942
- }
1943
- // Attach to the record being returned (current was fetched pre-insert).
1944
- next.evidenceItems = [...(next.evidenceItems ?? []), reproductionItem];
1945
- return { record: next, changed: true };
2121
+ next.evidenceItems = [...(next.evidenceItems ?? []), reproductionItem];
2122
+ return { record: next, changed: true };
2123
+ });
1946
2124
  }
1947
2125
 
1948
2126
  // ── Chain suggestions ───────────────────────────────────────────────
@@ -2345,12 +2523,16 @@ function buildCaseWhere(options: CaseSearchOptions): {
2345
2523
 
2346
2524
  const query = options.query?.trim().toLowerCase();
2347
2525
  if (query) {
2348
- const likeParam = `%${query}%`;
2526
+ // Escape LIKE wildcards so a query containing % or _ matches literally
2527
+ // instead of acting as a pattern ("100%" must not match "1000"). The
2528
+ // backslash is the escape char, so it is escaped first.
2529
+ const escaped = query.replace(/[\\%_]/g, (m) => `\\${m}`);
2530
+ const likeParam = `%${escaped}%`;
2349
2531
  if (options.field) {
2350
- where.push(`lower(${options.field}) LIKE ?`);
2532
+ where.push(`lower(${options.field}) LIKE ? ESCAPE '\\'`);
2351
2533
  params.push(likeParam);
2352
2534
  } else {
2353
- const ors = SEARCH_FIELD_VALUES.map((c) => `lower(${c}) LIKE ?`).join(" OR ");
2535
+ const ors = SEARCH_FIELD_VALUES.map((c) => `lower(${c}) LIKE ? ESCAPE '\\'`).join(" OR ");
2354
2536
  where.push(`(${ors})`);
2355
2537
  for (let i = 0; i < SEARCH_FIELD_VALUES.length; i++) params.push(likeParam);
2356
2538
  }
@@ -2390,8 +2572,12 @@ export function searchCases(options: CaseSearchOptions = {}): {
2390
2572
  total: number;
2391
2573
  } {
2392
2574
  const db = getDb();
2393
- const limit = Math.max(1, Math.min(options.limit ?? 50, 200));
2394
- const offset = Math.max(0, options.offset ?? 0);
2575
+ // NaN is not clamped by Math.min/max (it passes through) and SQLite binds it
2576
+ // as NULL, which disables LIMIT — fall back to the default instead.
2577
+ const rawLimit = Number.isFinite(options.limit) ? options.limit : undefined;
2578
+ const rawOffset = Number.isFinite(options.offset) ? options.offset : undefined;
2579
+ const limit = Math.max(1, Math.min(rawLimit ?? 50, 200));
2580
+ const offset = Math.max(0, rawOffset ?? 0);
2395
2581
 
2396
2582
  const { whereSql, orderSql, params } = buildCaseWhere(options);
2397
2583
 
@@ -2481,7 +2667,10 @@ export function formatCaseDetail(record: CaseRecord): string {
2481
2667
  } else if (Array.isArray(val)) {
2482
2668
  display = val.join(", ");
2483
2669
  } else if (typeof val === "object") {
2484
- display = JSON.stringify(val);
2670
+ // Path-leak guard (consistent with buildCompleteRecord): verification
2671
+ // objects and the pending bundle carry local script/evidence paths —
2672
+ // show basenames only.
2673
+ display = JSON.stringify(redactPaths(val));
2485
2674
  } else {
2486
2675
  display = String(val);
2487
2676
  }
@@ -2513,25 +2702,39 @@ const MAX_ARTIFACT_CHARS = 100_000;
2513
2702
  * must not balloon the report context into megabytes. */
2514
2703
  const MAX_TOTAL_ARTIFACT_CHARS = 400_000;
2515
2704
 
2705
+ /**
2706
+ * Recursively redact local filesystem paths to basenames in a serialized
2707
+ * object. Covers the verification records (path), the pending confirmation
2708
+ * bundle (pocPath/controlPath) and preserved evidence copies (evidencePath) —
2709
+ * the context bundle must never leak the researcher's local paths.
2710
+ */
2711
+ function redactPaths(value: unknown, seen = new Set<object>()): unknown {
2712
+ if (Array.isArray(value)) return value.map((v) => redactPaths(v, seen));
2713
+ if (typeof value !== "object" || value === null) return value;
2714
+ if (seen.has(value)) return value;
2715
+ seen.add(value);
2716
+ const out: Record<string, unknown> = {};
2717
+ for (const [k, v] of Object.entries(value)) {
2718
+ if (
2719
+ typeof v === "string" &&
2720
+ (k === "path" || k === "pocPath" || k === "controlPath" || k === "evidencePath")
2721
+ ) {
2722
+ out[k] = basename(v) || v;
2723
+ } else {
2724
+ out[k] = redactPaths(v, seen);
2725
+ }
2726
+ }
2727
+ return out;
2728
+ }
2729
+
2516
2730
  function buildCompleteRecord(current: CaseRecord): string {
2517
2731
  const rows: string[] = [];
2518
2732
  for (const [k, v] of Object.entries(current)) {
2519
2733
  if (v === undefined || v === null || v === "") continue;
2520
- let display = typeof v === "object" ? JSON.stringify(v, null, 2) : String(v);
2521
- // Path-leak guard: the verification objects carry the researcher's local
2522
- // PoC/disconfirmation/control script paths show basenames only (the dedicated
2523
- // log sections below already render them as basenames).
2524
- if (
2525
- (k === "pocVerified" || k === "disconfirmationVerified" || k === "controlVerified") &&
2526
- v &&
2527
- typeof v === "object"
2528
- ) {
2529
- const redacted = {
2530
- ...(v as Record<string, unknown>),
2531
- path: basename((v as { path?: string }).path ?? ""),
2532
- };
2533
- display = JSON.stringify(redacted, null, 2);
2534
- }
2734
+ // Path-leak guard: verification objects + the pending bundle carry the
2735
+ // researcher's local PoC/disconfirmation/control/evidence paths show
2736
+ // basenames only (the dedicated log sections do the same).
2737
+ const display = typeof v === "object" ? JSON.stringify(redactPaths(v), null, 2) : String(v);
2535
2738
  rows.push(`- **${k}:** ${display.replace(/\n/g, "\n ")}`);
2536
2739
  }
2537
2740
  return rows.join("\n");
@@ -2566,19 +2769,11 @@ function buildCaseLinks(db: DatabaseSync, id: string): string {
2566
2769
  function buildScratchpadSection(caseId: string): string {
2567
2770
  const root = getScratchpadRoot();
2568
2771
  if (!existsSync(root)) return "No scratchpad found (no pipeline run artifacts recorded).";
2569
- let entries: Dirent[] = [];
2570
- try {
2571
- entries = readdirSync(root, { withFileTypes: true });
2572
- } catch {
2573
- return "Scratchpad root unreadable.";
2574
- }
2575
-
2576
2772
  const sections: string[] = [];
2577
2773
  let totalChars = 0;
2578
2774
  let totalCapped = false;
2579
- outer: for (const entry of entries) {
2580
- if (!entry.isDirectory()) continue;
2581
- const resume = scratchpad_resume(entry.name);
2775
+ outer: for (const runId of scratchpad_runs()) {
2776
+ const resume = scratchpad_resume(runId);
2582
2777
  if (!resume) continue;
2583
2778
  const allIds = Object.values(resume.checkpoint.phase_ids ?? {}).flat() as string[];
2584
2779
  // Gate on the case id appearing in phase_ids OR in any artifact filename —
@@ -2589,7 +2784,7 @@ function buildScratchpadSection(caseId: string): string {
2589
2784
  .some((n) => n.includes(caseId));
2590
2785
  if (!allIds.includes(caseId) && !namedInArtifact) continue;
2591
2786
 
2592
- sections.push(`### Run: ${entry.name} (project root: ${resume.checkpoint.project_root})`);
2787
+ sections.push(`### Run: ${runId} (project root: ${resume.checkpoint.project_root})`);
2593
2788
  for (const phase of PHASE_ORDER) {
2594
2789
  const names = resume.artifacts[phase];
2595
2790
  if (!names?.length) continue;
@@ -2599,7 +2794,7 @@ function buildScratchpadSection(caseId: string): string {
2599
2794
  totalCapped = true;
2600
2795
  break outer;
2601
2796
  }
2602
- const content = scratchpad_read(entry.name, phase, name) ?? "(unreadable)";
2797
+ const content = scratchpad_read(runId, phase, name) ?? "(unreadable)";
2603
2798
  const clipped =
2604
2799
  content.length > MAX_ARTIFACT_CHARS
2605
2800
  ? `${content.slice(0, MAX_ARTIFACT_CHARS)}\n… [truncated ${content.length - MAX_ARTIFACT_CHARS} chars]`
@@ -2695,13 +2890,13 @@ export function writeCaseContext(id: string): {
2695
2890
  current.pocVerified
2696
2891
  ? mdSection(
2697
2892
  "PoC Verification Log",
2698
- `### 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\`\`\``,
2893
+ `### 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- **Target:** ${current.pocVerified.target ?? "not recorded"}\n\n#### Output\n\`\`\`\n${current.pocVerified.output ?? ""}\n\`\`\``,
2699
2894
  )
2700
2895
  : undefined,
2701
2896
  current.controlVerified
2702
2897
  ? mdSection(
2703
2898
  "Control-Target Check (anti-cheat)",
2704
- `### 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\`\`\``,
2899
+ `### 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- **Control target:** ${current.controlVerified.target ?? "not recorded"}\n- **Differential (machine-checked):** control evidence differs from the target runs' evidence the claimed impact is target-dependent (assertEvidenceDifferential, re-checked at confirm).\n- **Note:** exit codes and output markers are diagnostics, not gates; the machine floor is the evidence differential + the confirmer's re-execution.\n\n#### Output\n\`\`\`\n${current.controlVerified.output ?? ""}\n\`\`\``,
2705
2900
  )
2706
2901
  : undefined,
2707
2902
  mdSection("Disconfirmation Attempt", current.disconfirmation),