@xaccefy/pi-casefile 0.8.3 → 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
@@ -13,6 +13,13 @@
13
13
  import { createHash, randomUUID } from "node:crypto";
14
14
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
15
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";
16
23
  import {
17
24
  findWorkspaceRoot,
18
25
  getScratchpadRoot,
@@ -157,6 +164,13 @@ export type CaseRecord = {
157
164
  id: string;
158
165
  title: string;
159
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;
160
174
  confidence: CaseConfidence;
161
175
  severity?: CaseSeverity;
162
176
  priority?: CasePriority;
@@ -182,8 +196,12 @@ export type CaseRecord = {
182
196
  pocVerified?: PocVerificationRecord;
183
197
  /** Verification of a disconfirmation run (set only by promoteFindingResult). */
184
198
  disconfirmationVerified?: PocVerificationRecord;
185
- /** Verification of a control-target run (set only by promoteFindingResult; anti-cheat gate). */
199
+ /** Verification of a control-target run (set only by the confirmation gate). */
186
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;
187
205
  /** ISO timestamp when CaseContext first wrote the context bundle. */
188
206
  reportedAt?: string;
189
207
  /** Path to the final report file (set by writeCaseContext; the reporter agent writes the file). */
@@ -210,6 +228,49 @@ export type PocVerificationRecord = {
210
228
  target?: string;
211
229
  };
212
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
+
213
274
  export type CaseInput = {
214
275
  title: string;
215
276
  status?: CaseStatus;
@@ -239,6 +300,8 @@ type NormalizedCaseInput = Partial<CaseInput> & {
239
300
  pocVerified?: CaseRecord["pocVerified"];
240
301
  disconfirmationVerified?: CaseRecord["disconfirmationVerified"];
241
302
  controlVerified?: CaseRecord["controlVerified"];
303
+ pendingConfirmation?: CaseRecord["pendingConfirmation"];
304
+ confirmerVerdict?: CaseRecord["confirmerVerdict"];
242
305
  reportedAt?: string;
243
306
  reportPath?: string;
244
307
  };
@@ -371,6 +434,7 @@ function getDb(): DatabaseSync {
371
434
  id TEXT PRIMARY KEY,
372
435
  title TEXT NOT NULL,
373
436
  status TEXT NOT NULL,
437
+ ever_advanced INTEGER NOT NULL DEFAULT 0,
374
438
  confidence TEXT NOT NULL,
375
439
  severity TEXT,
376
440
  priority TEXT,
@@ -390,6 +454,8 @@ function getDb(): DatabaseSync {
390
454
  poc_verified_json TEXT, -- JSON object
391
455
  disconfirmation TEXT,
392
456
  disconfirmation_verified_json TEXT, -- JSON object
457
+ pending_confirmation_json TEXT, -- JSON object
458
+ confirmer_verdict_json TEXT, -- JSON object
393
459
  reported_at TEXT,
394
460
  report_path TEXT,
395
461
  created_at TEXT NOT NULL,
@@ -428,6 +494,21 @@ function getDb(): DatabaseSync {
428
494
  if (!caseCols.some((c) => c.name === "control_verified_json")) {
429
495
  db.exec("ALTER TABLE cases ADD COLUMN control_verified_json TEXT");
430
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
+ }
431
512
 
432
513
  // Role-typed, artifact-backed evidence items (Black-cat style evidence chain).
433
514
  db.exec(`
@@ -507,6 +588,7 @@ function mapRow(
507
588
  id: row.id,
508
589
  title: row.title,
509
590
  status: row.status as CaseStatus,
591
+ everAdvanced: row.ever_advanced === 1,
510
592
  confidence: row.confidence as CaseConfidence,
511
593
  severity: row.severity as CaseSeverity | undefined,
512
594
  priority: row.priority as CasePriority | undefined,
@@ -528,6 +610,8 @@ function mapRow(
528
610
  pocVerified: safeParseObject(row.poc_verified_json),
529
611
  disconfirmationVerified: safeParseObject(row.disconfirmation_verified_json),
530
612
  controlVerified: safeParseObject(row.control_verified_json),
613
+ pendingConfirmation: safeParseObject(row.pending_confirmation_json),
614
+ confirmerVerdict: safeParseObject(row.confirmer_verdict_json),
531
615
  reportedAt: row.reported_at || undefined,
532
616
  reportPath: row.report_path || undefined,
533
617
  evidenceItems,
@@ -673,16 +757,20 @@ function validateCase(record: CaseRecord): void {
673
757
  );
674
758
  }
675
759
  // Keep this gate in lockstep with promoteFindingResult: a case may only be
676
- // 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).
677
762
  if (
678
763
  record.status === "confirmed" &&
679
764
  (!record.evidence ||
680
765
  !record.poc ||
681
766
  !record.impact ||
682
767
  !record.severity ||
768
+ !record.target ||
683
769
  !record.disconfirmation)
684
770
  ) {
685
- 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
+ );
686
774
  }
687
775
  if (record.status === "blocked" && (record.blockers ?? []).length === 0) {
688
776
  throw new Error("Blocked cases require at least one blocker");
@@ -788,7 +876,16 @@ export const KILL_REASON_VALUES = [
788
876
  ] as const;
789
877
  export type KillReason = (typeof KILL_REASON_VALUES)[number];
790
878
 
791
- 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
+ );
792
889
 
793
890
  function validateTransition(
794
891
  from: CaseStatus,
@@ -813,26 +910,32 @@ function validateTransition(
813
910
 
814
911
  if (to === "killed") {
815
912
  // Black-cat rule: a kill must be justified. Valid iff (a) a refutation
816
- // evidence item exists for this case, or (b) — only for hypothesis-stage
817
- // cases — the update states a kill reason from the KILLED catalog
818
- // vocabulary (matches workflow.ts). Once a case reached investigating or
819
- // confirmed, a keyword in free text is NOT enough: the kill must be backed
820
- // by a real refutation evidence item (EvidenceAdd role=refutation the
821
- // 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).
822
920
  const items = current ? listEvidenceItems(current.id) : [];
823
921
  if (!items.some((e) => e.role === "refutation" && e.sha256)) {
824
- const advanced = current?.status === "investigating" || current?.status === "confirmed";
825
- 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
+ ]
826
929
  .filter(Boolean)
827
930
  .join(" ");
828
931
  if (advanced || !KILL_REASON_PATTERN.test(text)) {
829
932
  throw new Error(
830
933
  advanced
831
- ? "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 " +
832
935
  "EvidenceAdd role=refutation with artifact_path (sha256 required — the disprove attempt " +
833
936
  "that ended this lead) before killing."
834
937
  : "Cannot kill without justification: add refutation evidence (EvidenceAdd role=refutation, " +
835
- "artifact_path recommended) or state a kill reason in assumptions/nextStep " +
938
+ "artifact_path recommended) or state a kill reason in assumptions/nextStep/blockers " +
836
939
  "(intended_behavior, duplicate, framework_protection, out_of_scope, " +
837
940
  "skeptic-disproven, no_attack_path, ...)",
838
941
  );
@@ -923,6 +1026,12 @@ function buildRecord(input: NormalizedCaseInput, existing?: CaseRecord): CaseRec
923
1026
  id,
924
1027
  title,
925
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",
926
1035
  confidence: input.confidence ?? existing?.confidence ?? "low",
927
1036
  severity: input.severity ?? existing?.severity,
928
1037
  priority: input.priority ?? existing?.priority,
@@ -948,6 +1057,8 @@ function buildRecord(input: NormalizedCaseInput, existing?: CaseRecord): CaseRec
948
1057
  : existing?.disconfirmation,
949
1058
  disconfirmationVerified: input.disconfirmationVerified ?? existing?.disconfirmationVerified,
950
1059
  controlVerified: input.controlVerified ?? existing?.controlVerified,
1060
+ pendingConfirmation: input.pendingConfirmation ?? existing?.pendingConfirmation,
1061
+ confirmerVerdict: input.confirmerVerdict ?? existing?.confirmerVerdict,
951
1062
  reportedAt: input.reportedAt ?? existing?.reportedAt,
952
1063
  reportPath: input.reportPath ?? existing?.reportPath,
953
1064
  evidenceItems: existing?.evidenceItems ?? [],
@@ -1207,8 +1318,12 @@ function titleTokenRarityWeights(titles: string[]): Map<string, number> {
1207
1318
  const n = titles.length;
1208
1319
  const weights = new Map<string, number>();
1209
1320
  for (const [token, docs] of df) {
1210
- // +1 smoothing: tokens unique to one doc stay above the baseline.
1211
- 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)));
1212
1327
  }
1213
1328
  return weights;
1214
1329
  }
@@ -1258,21 +1373,24 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
1258
1373
  // wipe case_links when updating an existing primary key.
1259
1374
  const stmt = db.prepare(`
1260
1375
  INSERT INTO cases (
1261
- id, title, status, confidence, severity, priority, target, endpoint, bugClass,
1376
+ id, title, status, ever_advanced, confidence, severity, priority, target, endpoint, bugClass,
1262
1377
  summary, evidence, impact, nextStep, poc, remediation,
1263
1378
  references_json, blockers_json, tags_json, assumptions_json, poc_verified_json,
1264
1379
  disconfirmation, disconfirmation_verified_json, disprove_if_json, control_verified_json,
1380
+ pending_confirmation_json, confirmer_verdict_json,
1265
1381
  reported_at, report_path, created_at, updated_at
1266
1382
  ) VALUES (
1267
- ?, ?, ?, ?, ?, ?, ?, ?, ?,
1383
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
1268
1384
  ?, ?, ?, ?, ?, ?,
1269
1385
  ?, ?, ?, ?, ?,
1270
1386
  ?, ?, ?, ?,
1387
+ ?, ?,
1271
1388
  ?, ?, ?, ?
1272
1389
  )
1273
1390
  ON CONFLICT(id) DO UPDATE SET
1274
1391
  title = excluded.title,
1275
1392
  status = excluded.status,
1393
+ ever_advanced = excluded.ever_advanced,
1276
1394
  confidence = excluded.confidence,
1277
1395
  severity = excluded.severity,
1278
1396
  priority = excluded.priority,
@@ -1294,6 +1412,8 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
1294
1412
  disconfirmation_verified_json = excluded.disconfirmation_verified_json,
1295
1413
  disprove_if_json = excluded.disprove_if_json,
1296
1414
  control_verified_json = excluded.control_verified_json,
1415
+ pending_confirmation_json = excluded.pending_confirmation_json,
1416
+ confirmer_verdict_json = excluded.confirmer_verdict_json,
1297
1417
  reported_at = excluded.reported_at,
1298
1418
  report_path = excluded.report_path,
1299
1419
  created_at = excluded.created_at,
@@ -1304,6 +1424,7 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
1304
1424
  record.id,
1305
1425
  record.title,
1306
1426
  record.status,
1427
+ record.everAdvanced ? 1 : 0,
1307
1428
  record.confidence,
1308
1429
  record.severity || null,
1309
1430
  record.priority || null,
@@ -1325,6 +1446,8 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
1325
1446
  record.disconfirmationVerified ? JSON.stringify(record.disconfirmationVerified) : null,
1326
1447
  JSON.stringify(record.disproveIf),
1327
1448
  record.controlVerified ? JSON.stringify(record.controlVerified) : null,
1449
+ record.pendingConfirmation ? JSON.stringify(record.pendingConfirmation) : null,
1450
+ record.confirmerVerdict ? JSON.stringify(record.confirmerVerdict) : null,
1328
1451
  record.reportedAt || null,
1329
1452
  record.reportPath || null,
1330
1453
  record.createdAt,
@@ -1621,6 +1744,8 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
1621
1744
  pocVerified: undefined,
1622
1745
  disconfirmationVerified: undefined,
1623
1746
  controlVerified: undefined,
1747
+ confirmerVerdict: undefined,
1748
+ pendingConfirmation: undefined,
1624
1749
  };
1625
1750
  }
1626
1751
 
@@ -1682,67 +1807,44 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
1682
1807
  });
1683
1808
  }
1684
1809
 
1685
- export type PocVerification = PocVerificationRecord & {
1686
- /** True iff child output capture was complete. False on maxBuffer/timeouts/spawn failures. */
1687
- outputComplete?: boolean;
1688
- /** Harness mode used for the run: poc, control, or disconfirmation. */
1689
- mode?: string;
1690
- /** Target passed to the PoC through PI_POC_TARGET. */
1691
- target?: string;
1692
- /**
1693
- * Sanitized but UNTRUNCATED output, used for marker presence/absence
1694
- * checks. Never persisted to the ledger (stripRaw drops it) — a cheating
1695
- * script must not hide its marker in the slice, and the DB must not grow
1696
- * with megabytes of run output.
1697
- */
1698
- rawOutput?: string;
1699
- };
1700
-
1701
- /** Drop the transient rawOutput before persisting a verification record. */
1702
- function stripRaw(v: PocVerification): PocVerificationRecord {
1703
- const { rawOutput: _raw, ...rest } = v;
1704
- return rest;
1705
- }
1706
-
1707
- function assertVerificationRecord(
1708
- label: string,
1709
- v: PocVerification | undefined,
1710
- expectedMode: string,
1711
- expectedTarget: string,
1712
- ): asserts v is PocVerification {
1713
- if (!v) throw new Error(`${label} verification is required`);
1714
- if (!v.path || typeof v.path !== "string") throw new Error(`${label} verification path missing`);
1715
- if (!Number.isInteger(v.exitCode)) throw new Error(`${label} verification exitCode invalid`);
1716
- if (!v.ranAt || Number.isNaN(Date.parse(v.ranAt))) {
1717
- throw new Error(`${label} verification ranAt must be an ISO timestamp`);
1718
- }
1719
- if (typeof v.sandbox !== "boolean") throw new Error(`${label} verification sandbox flag missing`);
1720
- if (v.completed !== true) throw new Error(`${label} verification did not complete`);
1721
- if (v.outputComplete !== true) {
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) {
1722
1818
  throw new Error(
1723
- `${label} verification output capture was incomplete; marker checks are unsafe`,
1819
+ `${label} has no evidence.json the PoC must write evidence to $PI_POC_EVIDENCE_DIR`,
1724
1820
  );
1725
1821
  }
1726
- if (typeof v.rawOutput !== "string") {
1727
- throw new Error(`${label} verification rawOutput is required for full-output marker checks`);
1822
+ if (!evidenceNonceMatches(run.evidence, run.nonce)) {
1823
+ throw new Error(`${label} evidence nonce mismatch evidence not bound to this run`);
1728
1824
  }
1729
- if (v.mode !== expectedMode) {
1825
+ }
1826
+
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)) {
1730
1831
  throw new Error(
1731
- `${label} verification mode mismatch: expected ${expectedMode}, got ${v.mode ?? "unset"}`,
1832
+ "Target runs produced inconsistent evidence the exploit did not reproduce deterministically",
1732
1833
  );
1733
1834
  }
1734
- if (v.target !== expectedTarget) {
1835
+ if (normalizeEvidence(r1.evidence) === normalizeEvidence(bundle.controlRun.evidence)) {
1735
1836
  throw new Error(
1736
- `${label} verification target mismatch: expected ${expectedTarget}, got ${v.target ?? "unset"}`,
1837
+ "Control run produced identical evidence to the target the claimed impact is not target-dependent",
1737
1838
  );
1738
1839
  }
1739
1840
  }
1740
1841
 
1741
1842
  /**
1742
- * Gate for promotion to confirmed: case must exist, be investigating, and have
1743
- * poc/evidence/impact/severity. Returns the record when promotable, throws
1744
- * otherwise. Exported so PromoteFinding can validate BEFORE paying for a
1745
- * (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.
1746
1848
  */
1747
1849
  export function assertPromotable(id: string): CaseRecord {
1748
1850
  const current = getCaseById(id);
@@ -1750,7 +1852,7 @@ export function assertPromotable(id: string): CaseRecord {
1750
1852
  throw new Error(`Case not found: ${id}`);
1751
1853
  }
1752
1854
  if (current.status !== "investigating") {
1753
- throw new Error(`promote_finding requires an investigating case (current: ${current.status})`);
1855
+ throw new Error(`PromoteFinding requires an investigating case (current: ${current.status})`);
1754
1856
  }
1755
1857
  if (!current.poc) {
1756
1858
  throw new Error("CONFIRMED requires poc; set poc on the case first");
@@ -1769,15 +1871,10 @@ export function assertPromotable(id: string): CaseRecord {
1769
1871
  "CONFIRMED requires target (what host/repo/scope this affects); set target on the case first",
1770
1872
  );
1771
1873
  }
1772
- if (!current.disconfirmation) {
1773
- throw new Error(
1774
- "CONFIRMED requires disconfirmation (your attempt to disprove the finding); set disconfirmation on the case first",
1775
- );
1776
- }
1777
1874
  // Evidence-chain closure: the observation item must be ARTIFACT-BACKED. A
1778
1875
  // summary-only observation is agent prose about itself — promotion requires
1779
1876
  // a real file with its SHA-256 as the initial signal. (The reproduction item
1780
- // 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.)
1781
1878
  if (!current.evidenceItems.some((e) => e.role === "observation" && e.sha256)) {
1782
1879
  throw new Error(
1783
1880
  "Evidence chain incomplete: CONFIRMED requires an artifact-backed observation evidence item " +
@@ -1788,171 +1885,237 @@ export function assertPromotable(id: string): CaseRecord {
1788
1885
  return current;
1789
1886
  }
1790
1887
 
1791
- export function promoteFindingResult(
1792
- id: string,
1793
- verification: PocVerification,
1794
- disconfirmationVerification?: PocVerification,
1795
- controlVerification?: PocVerification,
1796
- marker?: string,
1797
- controlLivenessMarker?: string,
1798
- ): 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 {
1799
1896
  const db = getDb();
1800
1897
  return withImmediateTransaction(db, () => {
1801
- const current = assertPromotable(id);
1802
- const caseTarget = current.target ?? "";
1803
-
1804
- // Anti-cheat, enforced at the ledger level (not just the tool) for EVERY
1805
- // promotion — sandboxed and live alike. The control run must be the same
1806
- // script against a DISTINCT baseline target, with complete captured output.
1807
- const liveness = controlLivenessMarker?.trim();
1808
- if (!liveness) {
1898
+ const current = getCaseById(id);
1899
+ if (!current) throw new Error(`Case not found: ${id}`);
1900
+ if (current.status !== "investigating") {
1809
1901
  throw new Error(
1810
- "Every promotion requires controlLivenessMarker: a non-empty string the control run must print " +
1811
- "after reaching its target. PromoteFinding requires control_path + control_liveness_marker.",
1902
+ `Pending confirmation requires an investigating case (current: ${current.status})`,
1812
1903
  );
1813
1904
  }
1814
- const verificationMarker = marker?.trim();
1815
- if (!verificationMarker) {
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)) {
1816
1919
  throw new Error(
1817
- "Every promotion requires verificationMarker: the marker the PoC must print after exploitation. " +
1818
- "promoteFindingResult refuses to promote on exit 0 alone.",
1920
+ "Pending confirmation requires both target runs against the same case target",
1819
1921
  );
1820
1922
  }
1821
-
1822
- assertVerificationRecord("PoC", verification, "poc", caseTarget);
1823
- if (!controlVerification?.target?.trim()) {
1824
- throw new Error("Every promotion requires a controlVerification target");
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
+ );
1825
1928
  }
1826
- if (controlVerification.target === caseTarget) {
1929
+ if (bundle.controlRun.target === targetRunTarget) {
1827
1930
  throw new Error(
1828
- "Every promotion requires a distinct control target; the control run cannot use the case target",
1931
+ "CONTROL BINDING FAILED: the control run targeted the same host as the target runs — " +
1932
+ "the claimed impact is not target-dependent.",
1829
1933
  );
1830
1934
  }
1831
- assertVerificationRecord("Control", controlVerification, "control", controlVerification.target);
1832
- assertVerificationRecord(
1833
- "Disconfirmation",
1834
- disconfirmationVerification,
1835
- "disconfirmation",
1836
- caseTarget,
1837
- );
1838
-
1839
- if (verification.exitCode !== 0) {
1935
+ if (bundle.controlTarget === current.target) {
1840
1936
  throw new Error(
1841
- `PoC verification failed (exit ${verification.exitCode}); cannot promote to confirmed`,
1937
+ "CONTROL BINDING FAILED: control_target must differ from the case target; a control run " +
1938
+ "against the vulnerable target proves nothing.",
1842
1939
  );
1843
1940
  }
1844
-
1845
- // Same-file contract: the control must be the SAME script as the PoC
1846
- // (differing only via PI_POC_MODE / PI_POC_TARGET). The tool enforces this
1847
- // before running; the ledger re-checks so a direct caller cannot bypass it.
1941
+ // Same-file contract re-checked at store time (the tool already checked).
1848
1942
  let pocHash: string | undefined;
1849
1943
  let controlHash: string | undefined;
1850
1944
  try {
1851
- pocHash = createHash("sha256").update(readFileSync(verification.path)).digest("hex");
1852
- controlHash = createHash("sha256")
1853
- .update(readFileSync(controlVerification.path))
1854
- .digest("hex");
1945
+ pocHash = createHash("sha256").update(readFileSync(bundle.pocPath)).digest("hex");
1946
+ controlHash = createHash("sha256").update(readFileSync(bundle.controlPath)).digest("hex");
1855
1947
  } catch {
1856
1948
  pocHash = undefined;
1857
1949
  controlHash = undefined;
1858
1950
  }
1859
1951
  if (!pocHash || !controlHash || pocHash !== controlHash) {
1860
1952
  throw new Error(
1861
- "Every promotion requires controlVerification from the SAME script as the PoC " +
1862
- "(sha256 of controlVerification.path must equal sha256 of verification.path). " +
1863
- "A separately written control file proves nothing.",
1953
+ "CONTROL CHECK FAILED: control_path must be the SAME script as poc_path " +
1954
+ "(sha256 mismatch). A separately written control file proves nothing.",
1864
1955
  );
1865
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);
1866
1964
 
1867
- const controlOutput = controlVerification.rawOutput ?? "";
1868
- if (controlOutput.includes(verificationMarker) || !controlOutput.includes(liveness)) {
1965
+ const next = buildRecord({ pendingConfirmation: bundle }, current);
1966
+ validateCase(next);
1967
+ upsertCase(db, next);
1968
+ return next;
1969
+ });
1970
+ }
1971
+
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) {
1869
2002
  throw new Error(
1870
- "Every promotion requires a valid controlVerification: a control-target run of the same " +
1871
- `PoC whose output does not contain the marker "${verificationMarker}"` +
1872
- ` and whose output DOES contain the control liveness marker "${liveness}"` +
1873
- " (the control must actually reach its target — a failed/early control is not a clean verdict)" +
1874
- ". PromoteFinding requires control_path + control_liveness_marker.",
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,
1875
2016
  );
2017
+ validateCase(next);
2018
+ upsertCase(db, next);
2019
+ return { record: next, changed: true };
1876
2020
  }
1877
2021
 
1878
- const pocOutput = verification.rawOutput ?? "";
1879
- if (!pocOutput.includes(verificationMarker)) {
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) {
1880
2035
  throw new Error(
1881
- `PoC verification output does not contain the verification marker "${verificationMarker}"; ` +
1882
- "exit 0 alone cannot promote to confirmed",
2036
+ "PoC script changed since the runs — re-run PromoteFinding (the confirmer must review the exact bytes that ran)",
1883
2037
  );
1884
2038
  }
1885
-
1886
- if (disconfirmationVerification.exitCode === 0) {
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) {
1887
2044
  throw new Error(
1888
- "Every promotion requires an executed disconfirmation run that completed and exited non-zero " +
1889
- "(the finding survived the attempt to disprove it). PromoteFinding requires disconfirmation_path for every promotion.",
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)"}).`,
1890
2047
  );
1891
2048
  }
1892
-
1893
- // Machine-recorded reproduction evidence: the PoC gate itself writes the
1894
- // artifact-backed evidence itemconfirmation is anchored to a real file
1895
- // with its SHA-256, not to agent prose in the evidence field.
1896
- let pocSha256: string | undefined;
1897
- try {
1898
- pocSha256 = createHash("sha256").update(readFileSync(verification.path)).digest("hex");
1899
- } catch {
1900
- pocSha256 = undefined;
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
+ );
1901
2054
  }
1902
2055
 
1903
- // Cheap provenance guards on the observation item: it must be a DIFFERENT
1904
- // file than the PoC (same hash = the model re-used its PoC as "the initial
1905
- // signal"), a different basename, and it must predate the PoC run.
2056
+ // The observation must predate the repro (provenance guard).
1906
2057
  const observation = current.evidenceItems.find((e) => e.role === "observation" && e.sha256);
1907
- if (observation) {
1908
- if (pocSha256 && observation.sha256 === pocSha256) {
1909
- throw new Error(
1910
- "Evidence chain invalid: the observation artifact is the same file as the PoC " +
1911
- "(identical sha256). The initial signal must be a separate captured artifact.",
1912
- );
1913
- }
1914
- if (observation.artifactPath && observation.artifactPath === basename(verification.path)) {
1915
- throw new Error(
1916
- "Evidence chain invalid: the observation artifact has the same basename as the PoC file. " +
1917
- "The initial signal must be a separate captured artifact.",
1918
- );
1919
- }
1920
- if (observation.createdAt > verification.ranAt) {
1921
- throw new Error(
1922
- "Evidence chain invalid: the observation item was recorded after the PoC ran " +
1923
- `(${observation.createdAt} > ${verification.ranAt}). The observation must predate the repro.`,
1924
- );
1925
- }
2058
+ if (observation && observation.createdAt > bundle.targetRuns[0].ranAt) {
2059
+ throw new Error(
2060
+ "Evidence chain invalid: the observation item was recorded after the PoC ran " +
2061
+ `(${observation.createdAt} > ${bundle.targetRuns[0].ranAt}). The observation must predate the repro.`,
2062
+ );
1926
2063
  }
2064
+
1927
2065
  const reproductionItem: EvidenceItem = {
1928
- id: `ev_${stableShortId(`${id}\nreproduction\n${verification.ranAt}`)}`,
2066
+ id: `ev_${stableShortId(`${id}\nreproduction\n${targetRun.ranAt}`)}`,
1929
2067
  caseId: id,
1930
2068
  role: "reproduction",
1931
- artifactPath: basename(verification.path),
1932
- sha256: pocSha256,
1933
- summary: `PoC run exit ${verification.exitCode} (sandbox: ${verification.sandbox}) verification marker present in output`,
1934
- createdAt: verification.ranAt,
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,
1935
2076
  };
1936
2077
 
1937
2078
  const newEvidence =
1938
2079
  (current.evidence ? `${current.evidence}\n\n` : "") +
1939
- `### PoC Execution Capture (${verification.ranAt})\n` +
1940
- `- **Exit Code:** ${verification.exitCode}\n` +
1941
- `- **Sandbox:** ${verification.sandbox ? "yes" : "no"}\n` +
1942
- `- **Target:** ${verification.target}\n` +
1943
- `#### Execution Output\n\`\`\`\n${verification.output ?? ""}\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\`\`\``;
1944
2085
 
1945
2086
  const update: NormalizedCaseInput = {
1946
2087
  status: "confirmed",
1947
- pocVerified: stripRaw(verification),
1948
- disconfirmationVerified: stripRaw(disconfirmationVerification),
1949
- controlVerified: stripRaw(controlVerification),
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,
1950
2113
  evidence: newEvidence,
1951
2114
  };
1952
2115
 
1953
2116
  const next = buildRecord(update, current);
2117
+ next.pendingConfirmation = undefined; // buildRecord's ?? existing keeps it; clear explicitly
1954
2118
  validateCase(next);
1955
-
1956
2119
  insertEvidenceItem(db, reproductionItem);
1957
2120
  upsertCase(db, next);
1958
2121
  next.evidenceItems = [...(next.evidenceItems ?? []), reproductionItem];
@@ -2360,12 +2523,16 @@ function buildCaseWhere(options: CaseSearchOptions): {
2360
2523
 
2361
2524
  const query = options.query?.trim().toLowerCase();
2362
2525
  if (query) {
2363
- 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}%`;
2364
2531
  if (options.field) {
2365
- where.push(`lower(${options.field}) LIKE ?`);
2532
+ where.push(`lower(${options.field}) LIKE ? ESCAPE '\\'`);
2366
2533
  params.push(likeParam);
2367
2534
  } else {
2368
- 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 ");
2369
2536
  where.push(`(${ors})`);
2370
2537
  for (let i = 0; i < SEARCH_FIELD_VALUES.length; i++) params.push(likeParam);
2371
2538
  }
@@ -2405,8 +2572,12 @@ export function searchCases(options: CaseSearchOptions = {}): {
2405
2572
  total: number;
2406
2573
  } {
2407
2574
  const db = getDb();
2408
- const limit = Math.max(1, Math.min(options.limit ?? 50, 200));
2409
- 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);
2410
2581
 
2411
2582
  const { whereSql, orderSql, params } = buildCaseWhere(options);
2412
2583
 
@@ -2496,7 +2667,10 @@ export function formatCaseDetail(record: CaseRecord): string {
2496
2667
  } else if (Array.isArray(val)) {
2497
2668
  display = val.join(", ");
2498
2669
  } else if (typeof val === "object") {
2499
- 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));
2500
2674
  } else {
2501
2675
  display = String(val);
2502
2676
  }
@@ -2528,25 +2702,39 @@ const MAX_ARTIFACT_CHARS = 100_000;
2528
2702
  * must not balloon the report context into megabytes. */
2529
2703
  const MAX_TOTAL_ARTIFACT_CHARS = 400_000;
2530
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
+
2531
2730
  function buildCompleteRecord(current: CaseRecord): string {
2532
2731
  const rows: string[] = [];
2533
2732
  for (const [k, v] of Object.entries(current)) {
2534
2733
  if (v === undefined || v === null || v === "") continue;
2535
- let display = typeof v === "object" ? JSON.stringify(v, null, 2) : String(v);
2536
- // Path-leak guard: the verification objects carry the researcher's local
2537
- // PoC/disconfirmation/control script paths show basenames only (the dedicated
2538
- // log sections below already render them as basenames).
2539
- if (
2540
- (k === "pocVerified" || k === "disconfirmationVerified" || k === "controlVerified") &&
2541
- v &&
2542
- typeof v === "object"
2543
- ) {
2544
- const redacted = {
2545
- ...(v as Record<string, unknown>),
2546
- path: basename((v as { path?: string }).path ?? ""),
2547
- };
2548
- display = JSON.stringify(redacted, null, 2);
2549
- }
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);
2550
2738
  rows.push(`- **${k}:** ${display.replace(/\n/g, "\n ")}`);
2551
2739
  }
2552
2740
  return rows.join("\n");
@@ -2702,13 +2890,13 @@ export function writeCaseContext(id: string): {
2702
2890
  current.pocVerified
2703
2891
  ? mdSection(
2704
2892
  "PoC Verification Log",
2705
- `### 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\`\`\``,
2706
2894
  )
2707
2895
  : undefined,
2708
2896
  current.controlVerified
2709
2897
  ? mdSection(
2710
2898
  "Control-Target Check (anti-cheat)",
2711
- `### 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\`\`\``,
2712
2900
  )
2713
2901
  : undefined,
2714
2902
  mdSection("Disconfirmation Attempt", current.disconfirmation),