@xaccefy/pi-casefile 0.8.3 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ledger.ts CHANGED
@@ -11,8 +11,33 @@
11
11
  */
12
12
 
13
13
  import { createHash, randomUUID } from "node:crypto";
14
- import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
15
- import { basename, dirname, join, resolve } from "node:path";
14
+ import {
15
+ existsSync,
16
+ lstatSync,
17
+ mkdirSync,
18
+ readdirSync,
19
+ readFileSync,
20
+ realpathSync,
21
+ statSync,
22
+ unlinkSync,
23
+ writeFileSync,
24
+ } from "node:fs";
25
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
26
+ import {
27
+ evidenceNonceMatches,
28
+ type MainAgentVerdict,
29
+ normalizeEvidence,
30
+ type PoCEvidence,
31
+ parsePoCEvidence,
32
+ validateMainAgentVerdict,
33
+ } from "./evidence.ts";
34
+ import { type HarnessVerifyResult, verifyUrlBindingError } from "./harness-verify.ts";
35
+ import {
36
+ assertSafeRegularFile,
37
+ ensureSafeStateDirectory,
38
+ readSafeFile,
39
+ writeSafeFileExclusive,
40
+ } from "./safe-state.ts";
16
41
  import {
17
42
  findWorkspaceRoot,
18
43
  getScratchpadRoot,
@@ -46,6 +71,51 @@ export type CasePriority = (typeof PRIORITY_VALUES)[number];
46
71
 
47
72
  /** Cap on hashed evidence artifacts (10 MiB) — keeps readFileSync bounded. */
48
73
  const EVIDENCE_ARTIFACT_MAX_BYTES = 10 * 1024 * 1024;
74
+ /** PoC evidence has a tighter runner-side cap and must remain equally bounded on re-read. */
75
+ const POC_EVIDENCE_MAX_BYTES = 256 * 1024;
76
+ /** Avoid racing an active or just-finished PoC whose bundle is not committed yet. */
77
+ export const POC_EVIDENCE_GC_GRACE_MS = 24 * 60 * 60 * 1000;
78
+ /** Immutable module-start role; child shells cannot upgrade this process by unsetting an env var. */
79
+ const PROCESS_STARTED_AS_SUBAGENT = process.env.PI_SUBAGENT_CHILD === "1";
80
+
81
+ function pathIsWithin(root: string, candidate: string): boolean {
82
+ const rel = relative(root, candidate);
83
+ return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
84
+ }
85
+
86
+ function readWorkspaceArtifact(inputPath: string): { path: string; bytes: Buffer } {
87
+ const workspace = realpathSync(detectWorkspaceRoot());
88
+ const requested = resolve(workspace, inputPath);
89
+ if (!existsSync(requested)) {
90
+ throw new Error(`Evidence artifact not found on disk: ${inputPath}`);
91
+ }
92
+ const direct = lstatSync(requested);
93
+ if (direct.isSymbolicLink()) {
94
+ throw new Error(`Evidence artifact must not be a symbolic link: ${inputPath}`);
95
+ }
96
+ const canonical = realpathSync(requested);
97
+ if (!pathIsWithin(workspace, canonical)) {
98
+ throw new Error(
99
+ `Evidence artifact must stay inside the workspace (${workspace}): ${inputPath}`,
100
+ );
101
+ }
102
+ const stat = statSync(canonical);
103
+ if (!stat.isFile()) {
104
+ throw new Error(`Evidence artifact is not a regular file: ${inputPath}`);
105
+ }
106
+ if (stat.size > EVIDENCE_ARTIFACT_MAX_BYTES) {
107
+ throw new Error(
108
+ `Evidence artifact too large (${stat.size} bytes; max ${EVIDENCE_ARTIFACT_MAX_BYTES}): ${inputPath}`,
109
+ );
110
+ }
111
+ const bytes = readFileSync(canonical);
112
+ if (bytes.byteLength > EVIDENCE_ARTIFACT_MAX_BYTES) {
113
+ throw new Error(
114
+ `Evidence artifact too large (${bytes.byteLength} bytes; max ${EVIDENCE_ARTIFACT_MAX_BYTES}): ${inputPath}`,
115
+ );
116
+ }
117
+ return { path: canonical, bytes };
118
+ }
49
119
 
50
120
  /** Role-typed evidence roles (Black-cat style). cleanup = engagement cleanup item. */
51
121
  export const EVIDENCE_ROLE_VALUES = [
@@ -157,6 +227,13 @@ export type CaseRecord = {
157
227
  id: string;
158
228
  title: string;
159
229
  status: CaseStatus;
230
+ /**
231
+ * True once the case has EVER reached investigating or confirmed. The kill
232
+ * gate keys off this, not the current status: a demotion
233
+ * (investigating/confirmed -> hypothesis) must not let an advanced case die
234
+ * with a keyword in free text instead of artifact-backed refutation evidence.
235
+ */
236
+ everAdvanced: boolean;
160
237
  confidence: CaseConfidence;
161
238
  severity?: CaseSeverity;
162
239
  priority?: CasePriority;
@@ -182,8 +259,12 @@ export type CaseRecord = {
182
259
  pocVerified?: PocVerificationRecord;
183
260
  /** Verification of a disconfirmation run (set only by promoteFindingResult). */
184
261
  disconfirmationVerified?: PocVerificationRecord;
185
- /** Verification of a control-target run (set only by promoteFindingResult; anti-cheat gate). */
262
+ /** Verification of a control-target run (set only by the confirmation gate). */
186
263
  controlVerified?: PocVerificationRecord;
264
+ /** Phase-1 evidence bundle awaiting main-agent review (ConfirmFinding). */
265
+ pendingConfirmation?: PendingConfirmation;
266
+ /** Last phase-2 verdict (legacy field name retained for database compatibility). */
267
+ confirmerVerdict?: ConfirmerVerdictRecord;
187
268
  /** ISO timestamp when CaseContext first wrote the context bundle. */
188
269
  reportedAt?: string;
189
270
  /** Path to the final report file (set by writeCaseContext; the reporter agent writes the file). */
@@ -210,6 +291,75 @@ export type PocVerificationRecord = {
210
291
  target?: string;
211
292
  };
212
293
 
294
+ /** One harness-observed PoC run with its validated, nonce-bound evidence. */
295
+ export type PocEvidenceRun = {
296
+ mode: "poc" | "control";
297
+ target: string;
298
+ /** The run's PI_POC_NONCE — evidence.nonce must equal it (binds evidence to the run). */
299
+ nonce: string;
300
+ ranAt: string;
301
+ exitCode: number;
302
+ sandbox: boolean;
303
+ completed: boolean;
304
+ outputComplete: boolean;
305
+ /** Display-sliced output (diagnostic; zero exit is necessary, not proof). */
306
+ output: string;
307
+ evidence: PoCEvidence;
308
+ evidenceSha256: string;
309
+ /** Absolute path to the PRESERVED copy of this run's evidence.json (the
310
+ * runner copies the temp file into a durable .pi/poc-evidence/ dir; the
311
+ * reproduction item references it so the stored hash stays verifiable). */
312
+ evidencePath?: string;
313
+ };
314
+
315
+ /** Harness-observed out-of-band interactions (Tier 1, docs/poc-trust-model.md). */
316
+ export type OobVerification = {
317
+ attempted: boolean;
318
+ targetHits: number;
319
+ controlHits: number;
320
+ /** True only when the PoC runner cannot directly reach the listener. */
321
+ sourceSeparated?: boolean;
322
+ note: string;
323
+ };
324
+
325
+ export type PendingConfirmation = {
326
+ caseId: string;
327
+ ranAt: string;
328
+ pocPath: string;
329
+ /** SHA-256 of the PoC script AT RUN TIME — re-hashed at confirm to catch edits. */
330
+ pocSha256: string;
331
+ controlPath: string;
332
+ controlTarget: string;
333
+ targetRuns: [PocEvidenceRun, PocEvidenceRun];
334
+ controlRun: PocEvidenceRun;
335
+ /** Harness's own replay of evidence.verify (public targets). Absent = legacy bundle. */
336
+ harnessVerified?: HarnessVerifyResult;
337
+ /** Harness-owned OOB listener log for the run (opt-in blind classes). */
338
+ callbackVerified?: OobVerification;
339
+ };
340
+
341
+ /** Fresh machine transcript produced inside the main agent's ConfirmFinding call. */
342
+ export type MainAgentVerification = {
343
+ at: string;
344
+ result: HarnessVerifyResult;
345
+ };
346
+
347
+ /** Persisted main-agent verdict; `confirmer` naming is retained for DB compatibility. */
348
+ export type MainAgentVerdictRecord = MainAgentVerdict & {
349
+ at: string;
350
+ reviewer: "main_agent";
351
+ /** Harness-owned phase-2 replay bound to this verdict. */
352
+ phase2Verification?: MainAgentVerification;
353
+ /** What the machine actually established; semantic vulnerability judgment remains main-agent-owned. */
354
+ proofStrength?: "predicate_differential" | "canary_differential";
355
+ };
356
+
357
+ /** @deprecated Compatibility alias for the legacy database/API field name. */
358
+ export type ConfirmerVerdictRecord = MainAgentVerdictRecord;
359
+
360
+ /** Pending confirmation expires after 1h — re-run PromoteFinding for a fresh bundle. */
361
+ export const PENDING_CONFIRM_TTL_MS = 60 * 60 * 1000;
362
+
213
363
  export type CaseInput = {
214
364
  title: string;
215
365
  status?: CaseStatus;
@@ -239,6 +389,8 @@ type NormalizedCaseInput = Partial<CaseInput> & {
239
389
  pocVerified?: CaseRecord["pocVerified"];
240
390
  disconfirmationVerified?: CaseRecord["disconfirmationVerified"];
241
391
  controlVerified?: CaseRecord["controlVerified"];
392
+ pendingConfirmation?: CaseRecord["pendingConfirmation"];
393
+ confirmerVerdict?: CaseRecord["confirmerVerdict"];
242
394
  reportedAt?: string;
243
395
  reportPath?: string;
244
396
  };
@@ -319,6 +471,101 @@ function detectWorkspaceRoot(): string {
319
471
  );
320
472
  }
321
473
 
474
+ export type PocEvidenceGcResult = {
475
+ scanned: number;
476
+ removed: number;
477
+ skipped: boolean;
478
+ reason?: string;
479
+ };
480
+
481
+ /**
482
+ * Delete only old harness evidence copies that no ledger row or pending
483
+ * confirmation bundle references. Malformed pending JSON fails closed because
484
+ * it may contain paths we cannot safely identify.
485
+ */
486
+ function gcOrphanedPocEvidenceForDb(db: DatabaseSync, nowMs = Date.now()): PocEvidenceGcResult {
487
+ const evidenceDir = join(detectWorkspaceRoot(), ".pi", "poc-evidence");
488
+ if (!existsSync(evidenceDir)) return { scanned: 0, removed: 0, skipped: false };
489
+ try {
490
+ const dirStat = lstatSync(evidenceDir);
491
+ if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) {
492
+ return {
493
+ scanned: 0,
494
+ removed: 0,
495
+ skipped: true,
496
+ reason: "poc-evidence is not a regular directory",
497
+ };
498
+ }
499
+
500
+ const protectedNames = new Set<string>();
501
+ const items = db
502
+ .prepare("SELECT artifact_path FROM evidence_items WHERE artifact_path IS NOT NULL")
503
+ .all() as { artifact_path: string }[];
504
+ for (const item of items) protectedNames.add(basename(item.artifact_path));
505
+
506
+ const collectPendingPaths = (value: unknown): void => {
507
+ if (Array.isArray(value)) {
508
+ for (const item of value) collectPendingPaths(item);
509
+ return;
510
+ }
511
+ if (typeof value !== "object" || value === null) return;
512
+ for (const [key, nested] of Object.entries(value)) {
513
+ if (key === "evidencePath" && typeof nested === "string") {
514
+ protectedNames.add(basename(nested));
515
+ } else {
516
+ collectPendingPaths(nested);
517
+ }
518
+ }
519
+ };
520
+
521
+ const pendingRows = db
522
+ .prepare(
523
+ "SELECT pending_confirmation_json FROM cases WHERE pending_confirmation_json IS NOT NULL",
524
+ )
525
+ .all() as { pending_confirmation_json: string }[];
526
+ for (const row of pendingRows) {
527
+ let pending: unknown;
528
+ try {
529
+ pending = JSON.parse(row.pending_confirmation_json);
530
+ } catch {
531
+ return {
532
+ scanned: 0,
533
+ removed: 0,
534
+ skipped: true,
535
+ reason: "malformed pending confirmation JSON",
536
+ };
537
+ }
538
+ collectPendingPaths(pending);
539
+ }
540
+
541
+ let scanned = 0;
542
+ let removed = 0;
543
+ for (const entry of readdirSync(evidenceDir, { withFileTypes: true })) {
544
+ if (!entry.name.endsWith(".evidence.json") || !entry.isFile()) continue;
545
+ scanned++;
546
+ if (protectedNames.has(entry.name)) continue;
547
+ const candidate = join(evidenceDir, entry.name);
548
+ const file = lstatSync(candidate);
549
+ if (!file.isFile() || nowMs - file.mtimeMs < POC_EVIDENCE_GC_GRACE_MS) continue;
550
+ unlinkSync(candidate);
551
+ removed++;
552
+ }
553
+ return { scanned, removed, skipped: false };
554
+ } catch (error) {
555
+ return {
556
+ scanned: 0,
557
+ removed: 0,
558
+ skipped: true,
559
+ reason: error instanceof Error ? error.message : String(error),
560
+ };
561
+ }
562
+ }
563
+
564
+ /** Run the same conservative orphan sweep used when the ledger opens. */
565
+ export function gcOrphanedPocEvidence(nowMs = Date.now()): PocEvidenceGcResult {
566
+ return gcOrphanedPocEvidenceForDb(getDb(), nowMs);
567
+ }
568
+
322
569
  export function getCasefilePath(): string {
323
570
  if (ledgerPathOverride) return ledgerPathOverride;
324
571
  // Trim BEFORE the truthiness check: a whitespace-only value must not
@@ -347,11 +594,18 @@ function getDb(): DatabaseSync {
347
594
 
348
595
  const dbPath = getCasefilePath();
349
596
  const dbDir = dirname(dbPath);
350
- if (!existsSync(dbDir)) {
597
+ const workspace = detectWorkspaceRoot();
598
+ const defaultStateDir = join(workspace, ".pi");
599
+ if (resolve(dbDir) === resolve(defaultStateDir)) {
600
+ ensureSafeStateDirectory(workspace, [".pi"]);
601
+ } else if (!existsSync(dbDir)) {
351
602
  try {
352
603
  mkdirSync(dbDir, { recursive: true });
353
604
  } catch {}
354
605
  }
606
+ for (const candidate of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
607
+ assertSafeRegularFile(candidate, "Casefile database state");
608
+ }
355
609
 
356
610
  const db = new DatabaseSync(dbPath);
357
611
  // Give parallel agents a short write wait instead of immediate SQLITE_BUSY.
@@ -371,6 +625,7 @@ function getDb(): DatabaseSync {
371
625
  id TEXT PRIMARY KEY,
372
626
  title TEXT NOT NULL,
373
627
  status TEXT NOT NULL,
628
+ ever_advanced INTEGER NOT NULL DEFAULT 0,
374
629
  confidence TEXT NOT NULL,
375
630
  severity TEXT,
376
631
  priority TEXT,
@@ -390,6 +645,8 @@ function getDb(): DatabaseSync {
390
645
  poc_verified_json TEXT, -- JSON object
391
646
  disconfirmation TEXT,
392
647
  disconfirmation_verified_json TEXT, -- JSON object
648
+ pending_confirmation_json TEXT, -- JSON object
649
+ confirmer_verdict_json TEXT, -- JSON object
393
650
  reported_at TEXT,
394
651
  report_path TEXT,
395
652
  created_at TEXT NOT NULL,
@@ -428,6 +685,21 @@ function getDb(): DatabaseSync {
428
685
  if (!caseCols.some((c) => c.name === "control_verified_json")) {
429
686
  db.exec("ALTER TABLE cases ADD COLUMN control_verified_json TEXT");
430
687
  }
688
+ if (!caseCols.some((c) => c.name === "pending_confirmation_json")) {
689
+ db.exec("ALTER TABLE cases ADD COLUMN pending_confirmation_json TEXT");
690
+ }
691
+ if (!caseCols.some((c) => c.name === "confirmer_verdict_json")) {
692
+ db.exec("ALTER TABLE cases ADD COLUMN confirmer_verdict_json TEXT");
693
+ }
694
+ if (!caseCols.some((c) => c.name === "ever_advanced")) {
695
+ db.exec("ALTER TABLE cases ADD COLUMN ever_advanced INTEGER NOT NULL DEFAULT 0");
696
+ // Backfill: a case that is (or was) past hypothesis has reached an
697
+ // advanced state. Terminal rows can no longer be mutated, but marking them
698
+ // keeps the flag consistent for history/context reads.
699
+ db.exec(
700
+ "UPDATE cases SET ever_advanced = 1 WHERE status IN ('investigating','confirmed','blocked','killed','reported')",
701
+ );
702
+ }
431
703
 
432
704
  // Role-typed, artifact-backed evidence items (Black-cat style evidence chain).
433
705
  db.exec(`
@@ -473,6 +745,9 @@ function getDb(): DatabaseSync {
473
745
  db.exec(`CREATE INDEX IF NOT EXISTS idx_cases_priority ON cases(priority)`);
474
746
 
475
747
  dbInstance = db;
748
+ // Best-effort housekeeping: failures and ambiguous state fail closed and do
749
+ // not prevent the ledger from opening.
750
+ gcOrphanedPocEvidenceForDb(db);
476
751
  return db;
477
752
  }
478
753
 
@@ -507,6 +782,7 @@ function mapRow(
507
782
  id: row.id,
508
783
  title: row.title,
509
784
  status: row.status as CaseStatus,
785
+ everAdvanced: row.ever_advanced === 1,
510
786
  confidence: row.confidence as CaseConfidence,
511
787
  severity: row.severity as CaseSeverity | undefined,
512
788
  priority: row.priority as CasePriority | undefined,
@@ -528,6 +804,8 @@ function mapRow(
528
804
  pocVerified: safeParseObject(row.poc_verified_json),
529
805
  disconfirmationVerified: safeParseObject(row.disconfirmation_verified_json),
530
806
  controlVerified: safeParseObject(row.control_verified_json),
807
+ pendingConfirmation: safeParseObject(row.pending_confirmation_json),
808
+ confirmerVerdict: safeParseObject(row.confirmer_verdict_json),
531
809
  reportedAt: row.reported_at || undefined,
532
810
  reportPath: row.report_path || undefined,
533
811
  evidenceItems,
@@ -673,16 +951,20 @@ function validateCase(record: CaseRecord): void {
673
951
  );
674
952
  }
675
953
  // 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.
954
+ // CONFIRMED when it has evidence, a PoC, demonstrated impact, a severity,
955
+ // and a named target (what host/repo/scope this affects).
677
956
  if (
678
957
  record.status === "confirmed" &&
679
958
  (!record.evidence ||
680
959
  !record.poc ||
681
960
  !record.impact ||
682
961
  !record.severity ||
962
+ !record.target ||
683
963
  !record.disconfirmation)
684
964
  ) {
685
- throw new Error("Confirmed cases require evidence, poc, impact, severity, and disconfirmation");
965
+ throw new Error(
966
+ "Confirmed cases require evidence, poc, impact, severity, target, and disconfirmation",
967
+ );
686
968
  }
687
969
  if (record.status === "blocked" && (record.blockers ?? []).length === 0) {
688
970
  throw new Error("Blocked cases require at least one blocker");
@@ -698,9 +980,6 @@ function validateCase(record: CaseRecord): void {
698
980
  "Killed cases require evidence, next step, blockers, or assumptions explaining why",
699
981
  );
700
982
  }
701
- // A case becomes REPORTED only after the report FILE exists on disk (the
702
- // report writer writes it at the path CaseContext recorded). Require both
703
- // here so validation stays consistent with the confirmed→reported gate.
704
983
  // A case becomes REPORTED only after a report FILE that passes the content
705
984
  // gate exists on disk (the report writer writes it at the path CaseContext
706
985
  // recorded). Existence is not enough: any non-empty file — or a directory —
@@ -774,21 +1053,33 @@ const REPORT_REQUIRED_SECTIONS = ["summary", "impact", "remediation"];
774
1053
  * text (workflow.ts imports this) must not drift apart.
775
1054
  */
776
1055
  export const KILL_REASON_VALUES = [
1056
+ "unreachable",
777
1057
  "intended_behavior",
778
1058
  "duplicate",
779
1059
  "framework_protection",
1060
+ "input_validation_blocks",
1061
+ "requires_privilege_attacker_lacks",
780
1062
  "exploit_unreliable",
781
1063
  "insufficient_impact",
782
1064
  "environmental_issue",
783
1065
  "not_applicable",
784
1066
  "out_of_scope",
785
- "skeptic-disproven",
1067
+ "test_artifact",
786
1068
  "no_attack_path",
787
1069
  "refuted",
788
1070
  ] as const;
789
1071
  export type KillReason = (typeof KILL_REASON_VALUES)[number];
790
1072
 
791
- const KILL_REASON_PATTERN = new RegExp(`\\b(${KILL_REASON_VALUES.join("|")})\\b`, "i");
1073
+ /**
1074
+ * Matches a kill reason whether the agent wrote the canonical token
1075
+ * ("out_of_scope"), a spaced form ("out of scope"), or hyphenated
1076
+ * ("out-of-scope") — the underscore spelling is machine vocabulary; free text
1077
+ * must not be rejected just because it reads naturally.
1078
+ */
1079
+ const KILL_REASON_PATTERN = new RegExp(
1080
+ `\\b(${KILL_REASON_VALUES.map((v) => v.replace(/_/g, "[ _-]+")).join("|")})\\b`,
1081
+ "i",
1082
+ );
792
1083
 
793
1084
  function validateTransition(
794
1085
  from: CaseStatus,
@@ -813,28 +1104,34 @@ function validateTransition(
813
1104
 
814
1105
  if (to === "killed") {
815
1106
  // 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).
1107
+ // evidence item exists for this case, or (b) — only for cases that have
1108
+ // NEVER reached investigating/confirmed — the update states a kill reason
1109
+ // from the KILLED catalog vocabulary (matches workflow.ts). Once a case
1110
+ // reached investigating or confirmed (everAdvanced immune to demotion
1111
+ // round-trips), a keyword in free text is NOT enough: the kill must be
1112
+ // backed by a real refutation evidence item (EvidenceAdd role=refutation —
1113
+ // the disprove attempt that ended the lead).
822
1114
  const items = current ? listEvidenceItems(current.id) : [];
823
1115
  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]
1116
+ const advanced = current?.everAdvanced === true;
1117
+ const text = [
1118
+ update.nextStep,
1119
+ (update.assumptions ?? []).join(" "),
1120
+ (update.blockers ?? []).join(" "),
1121
+ update.evidence,
1122
+ ]
826
1123
  .filter(Boolean)
827
1124
  .join(" ");
828
1125
  if (advanced || !KILL_REASON_PATTERN.test(text)) {
829
1126
  throw new Error(
830
1127
  advanced
831
- ? "Cannot kill an investigating/confirmed case without ARTIFACT-BACKED refutation evidence: add " +
1128
+ ? "Cannot kill an advanced case (ever reached investigating/confirmed) without ARTIFACT-BACKED refutation evidence: add " +
832
1129
  "EvidenceAdd role=refutation with artifact_path (sha256 required — the disprove attempt " +
833
1130
  "that ended this lead) before killing."
834
1131
  : "Cannot kill without justification: add refutation evidence (EvidenceAdd role=refutation, " +
835
- "artifact_path recommended) or state a kill reason in assumptions/nextStep " +
1132
+ "artifact_path recommended) or state a kill reason in assumptions/nextStep/blockers " +
836
1133
  "(intended_behavior, duplicate, framework_protection, out_of_scope, " +
837
- "skeptic-disproven, no_attack_path, ...)",
1134
+ "insufficient_impact, no_attack_path, ...)",
838
1135
  );
839
1136
  }
840
1137
  }
@@ -862,7 +1159,7 @@ function validateTransition(
862
1159
  },
863
1160
  investigating: {
864
1161
  confirmed: () =>
865
- "investigating → confirmed requires a verified PoC run; use the promote_finding tool",
1162
+ "investigating → confirmed requires a verified PoC run; use the PromoteFinding tool",
866
1163
  hypothesis: () => null,
867
1164
  },
868
1165
  confirmed: {
@@ -923,6 +1220,12 @@ function buildRecord(input: NormalizedCaseInput, existing?: CaseRecord): CaseRec
923
1220
  id,
924
1221
  title,
925
1222
  status: input.status ?? existing?.status ?? "hypothesis",
1223
+ // Once a case has been investigating/confirmed it never forgets — the kill
1224
+ // gate must not be defeatable by demoting first.
1225
+ everAdvanced:
1226
+ existing?.everAdvanced === true ||
1227
+ input.status === "investigating" ||
1228
+ input.status === "confirmed",
926
1229
  confidence: input.confidence ?? existing?.confidence ?? "low",
927
1230
  severity: input.severity ?? existing?.severity,
928
1231
  priority: input.priority ?? existing?.priority,
@@ -948,6 +1251,8 @@ function buildRecord(input: NormalizedCaseInput, existing?: CaseRecord): CaseRec
948
1251
  : existing?.disconfirmation,
949
1252
  disconfirmationVerified: input.disconfirmationVerified ?? existing?.disconfirmationVerified,
950
1253
  controlVerified: input.controlVerified ?? existing?.controlVerified,
1254
+ pendingConfirmation: input.pendingConfirmation ?? existing?.pendingConfirmation,
1255
+ confirmerVerdict: input.confirmerVerdict ?? existing?.confirmerVerdict,
951
1256
  reportedAt: input.reportedAt ?? existing?.reportedAt,
952
1257
  reportPath: input.reportPath ?? existing?.reportPath,
953
1258
  evidenceItems: existing?.evidenceItems ?? [],
@@ -1207,8 +1512,12 @@ function titleTokenRarityWeights(titles: string[]): Map<string, number> {
1207
1512
  const n = titles.length;
1208
1513
  const weights = new Map<string, number>();
1209
1514
  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)));
1515
+ // ln((n+1)/(docs+1)) NO +1 baseline. A token in every title scores ~0
1516
+ // (ln 1), a token in one title scores ln((n+1)/2) > 1 for n >= 3. The old
1517
+ // `1 + ln(...)` made every weight >= 1, so the weighted half of the hybrid
1518
+ // gate was vacuous (weightedSum >= sharedCount always) and generic
1519
+ // vocabulary could never be down-weighted.
1520
+ weights.set(token, Math.log((n + 1) / (docs + 1)));
1212
1521
  }
1213
1522
  return weights;
1214
1523
  }
@@ -1258,21 +1567,24 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
1258
1567
  // wipe case_links when updating an existing primary key.
1259
1568
  const stmt = db.prepare(`
1260
1569
  INSERT INTO cases (
1261
- id, title, status, confidence, severity, priority, target, endpoint, bugClass,
1570
+ id, title, status, ever_advanced, confidence, severity, priority, target, endpoint, bugClass,
1262
1571
  summary, evidence, impact, nextStep, poc, remediation,
1263
1572
  references_json, blockers_json, tags_json, assumptions_json, poc_verified_json,
1264
1573
  disconfirmation, disconfirmation_verified_json, disprove_if_json, control_verified_json,
1574
+ pending_confirmation_json, confirmer_verdict_json,
1265
1575
  reported_at, report_path, created_at, updated_at
1266
1576
  ) VALUES (
1267
- ?, ?, ?, ?, ?, ?, ?, ?, ?,
1577
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
1268
1578
  ?, ?, ?, ?, ?, ?,
1269
1579
  ?, ?, ?, ?, ?,
1270
1580
  ?, ?, ?, ?,
1581
+ ?, ?,
1271
1582
  ?, ?, ?, ?
1272
1583
  )
1273
1584
  ON CONFLICT(id) DO UPDATE SET
1274
1585
  title = excluded.title,
1275
1586
  status = excluded.status,
1587
+ ever_advanced = excluded.ever_advanced,
1276
1588
  confidence = excluded.confidence,
1277
1589
  severity = excluded.severity,
1278
1590
  priority = excluded.priority,
@@ -1294,6 +1606,8 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
1294
1606
  disconfirmation_verified_json = excluded.disconfirmation_verified_json,
1295
1607
  disprove_if_json = excluded.disprove_if_json,
1296
1608
  control_verified_json = excluded.control_verified_json,
1609
+ pending_confirmation_json = excluded.pending_confirmation_json,
1610
+ confirmer_verdict_json = excluded.confirmer_verdict_json,
1297
1611
  reported_at = excluded.reported_at,
1298
1612
  report_path = excluded.report_path,
1299
1613
  created_at = excluded.created_at,
@@ -1304,6 +1618,7 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
1304
1618
  record.id,
1305
1619
  record.title,
1306
1620
  record.status,
1621
+ record.everAdvanced ? 1 : 0,
1307
1622
  record.confidence,
1308
1623
  record.severity || null,
1309
1624
  record.priority || null,
@@ -1325,6 +1640,8 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
1325
1640
  record.disconfirmationVerified ? JSON.stringify(record.disconfirmationVerified) : null,
1326
1641
  JSON.stringify(record.disproveIf),
1327
1642
  record.controlVerified ? JSON.stringify(record.controlVerified) : null,
1643
+ record.pendingConfirmation ? JSON.stringify(record.pendingConfirmation) : null,
1644
+ record.confirmerVerdict ? JSON.stringify(record.confirmerVerdict) : null,
1328
1645
  record.reportedAt || null,
1329
1646
  record.reportPath || null,
1330
1647
  record.createdAt,
@@ -1372,22 +1689,29 @@ export function addEvidenceItemResult(
1372
1689
  if (!summary) throw new Error("Evidence summary must not be empty");
1373
1690
 
1374
1691
  let artifactPath: string | undefined;
1692
+
1375
1693
  let sha256: string | undefined;
1376
1694
  if (input.artifactPath) {
1377
- if (!existsSync(input.artifactPath)) {
1378
- throw new Error(`Evidence artifact not found on disk: ${input.artifactPath}`);
1379
- }
1380
- const stat = statSync(input.artifactPath);
1381
- if (!stat.isFile()) {
1382
- throw new Error(`Evidence artifact is not a regular file: ${input.artifactPath}`);
1383
- }
1384
- if (stat.size > EVIDENCE_ARTIFACT_MAX_BYTES) {
1385
- throw new Error(
1386
- `Evidence artifact too large (${stat.size} bytes; max ${EVIDENCE_ARTIFACT_MAX_BYTES}): ${input.artifactPath}`,
1387
- );
1695
+ const artifact = readWorkspaceArtifact(input.artifactPath);
1696
+ artifactPath = basename(artifact.path);
1697
+ sha256 = createHash("sha256").update(artifact.bytes).digest("hex");
1698
+ // Durable copy: artifact_path stores the basename only (path-leak guard),
1699
+ // so the bytes must survive somewhere re-verifiable by the sha256. Copy
1700
+ // into <ledger-dir>/evidence-items/<sha256>.bin the location is
1701
+ // derivable from the hash column, so no new column or path persistence.
1702
+ const ledgerDir = dirname(getCasefilePath());
1703
+ const evidenceDir = ensureSafeStateDirectory(ledgerDir, ["evidence-items"]);
1704
+ const durable = join(evidenceDir, `${sha256}.bin`);
1705
+ if (!assertSafeRegularFile(durable, "Durable evidence artifact")) {
1706
+ writeSafeFileExclusive(durable, artifact.bytes);
1707
+ } else {
1708
+ const durableHash = createHash("sha256")
1709
+ .update(readSafeFile(durable, "Durable evidence artifact"))
1710
+ .digest("hex");
1711
+ if (durableHash !== sha256) {
1712
+ throw new Error(`Durable evidence artifact hash mismatch: ${durable}`);
1713
+ }
1388
1714
  }
1389
- artifactPath = basename(input.artifactPath);
1390
- sha256 = createHash("sha256").update(readFileSync(input.artifactPath)).digest("hex");
1391
1715
  }
1392
1716
 
1393
1717
  const item: EvidenceItem = {
@@ -1621,6 +1945,8 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
1621
1945
  pocVerified: undefined,
1622
1946
  disconfirmationVerified: undefined,
1623
1947
  controlVerified: undefined,
1948
+ confirmerVerdict: undefined,
1949
+ pendingConfirmation: undefined,
1624
1950
  };
1625
1951
  }
1626
1952
 
@@ -1682,67 +2008,188 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
1682
2008
  });
1683
2009
  }
1684
2010
 
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;
2011
+ function validateRunEvidence(run: PocEvidenceRun, label: string): void {
2012
+ if (!run.completed) {
2013
+ throw new Error(`${label} did not complete; a crash is not evidence`);
2014
+ }
2015
+ if (!run.outputComplete) {
2016
+ throw new Error(`${label} output capture was incomplete; evidence checks are unsafe`);
2017
+ }
2018
+ if (run.exitCode !== 0) {
2019
+ throw new Error(
2020
+ `${label} exited with ${run.exitCode}; exit 0 is required for a complete run but is never sufficient proof`,
2021
+ );
2022
+ }
2023
+ if (!run.evidence || !run.evidenceSha256) {
2024
+ throw new Error(
2025
+ `${label} has no evidence.json — the PoC must write evidence to $PI_POC_EVIDENCE_DIR`,
2026
+ );
2027
+ }
2028
+ if (!evidenceNonceMatches(run.evidence, run.nonce)) {
2029
+ throw new Error(`${label} evidence nonce mismatch evidence not bound to this run`);
2030
+ }
2031
+ const parsed = parsePoCEvidence(run.evidence);
2032
+ if (!parsed.ok) {
2033
+ throw new Error(`${label} evidence contract invalid: ${parsed.error}`);
2034
+ }
2035
+ if (!run.evidencePath) {
2036
+ throw new Error(`${label} has no durable evidencePath; ephemeral evidence cannot confirm`);
2037
+ }
2038
+ const artifact = readWorkspaceArtifact(run.evidencePath);
2039
+ if (artifact.bytes.byteLength > POC_EVIDENCE_MAX_BYTES) {
2040
+ throw new Error(
2041
+ `${label} durable evidence exceeds ${POC_EVIDENCE_MAX_BYTES} bytes; evidence cannot be revalidated safely`,
2042
+ );
2043
+ }
2044
+ const durableHash = createHash("sha256").update(artifact.bytes).digest("hex");
2045
+ if (durableHash !== run.evidenceSha256) {
2046
+ throw new Error(`${label} durable evidence hash does not match evidenceSha256`);
2047
+ }
2048
+ let durableRaw: unknown;
2049
+ try {
2050
+ durableRaw = JSON.parse(artifact.bytes.toString("utf8"));
2051
+ } catch (error) {
2052
+ throw new Error(`${label} durable evidence is not valid JSON: ${(error as Error).message}`);
2053
+ }
2054
+ const durable = parsePoCEvidence(durableRaw);
2055
+ if (!durable.ok) {
2056
+ throw new Error(`${label} durable evidence contract invalid: ${durable.error}`);
2057
+ }
2058
+ if (
2059
+ normalizeEvidence(durable.evidence) !== normalizeEvidence(run.evidence) ||
2060
+ JSON.stringify(durable.evidence.observations) !== JSON.stringify(run.evidence.observations)
2061
+ ) {
2062
+ throw new Error(`${label} durable evidence bytes do not match the stored evidence object`);
2063
+ }
1705
2064
  }
1706
2065
 
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) {
2066
+ /** Determinism + differential on normalized evidence (nonce/observations stripped). */
2067
+ function assertEvidenceDifferential(bundle: PendingConfirmation): void {
2068
+ const [r1, r2] = bundle.targetRuns;
2069
+ if (normalizeEvidence(r1.evidence) !== normalizeEvidence(r2.evidence)) {
2070
+ throw new Error(
2071
+ "Target runs produced inconsistent evidence — the exploit did not reproduce deterministically",
2072
+ );
2073
+ }
2074
+ if (normalizeEvidence(r1.evidence) === normalizeEvidence(bundle.controlRun.evidence)) {
1722
2075
  throw new Error(
1723
- `${label} verification output capture was incomplete; marker checks are unsafe`,
2076
+ "Control run produced identical evidence to the target the claimed impact is not target-dependent",
1724
2077
  );
1725
2078
  }
1726
- if (typeof v.rawOutput !== "string") {
1727
- throw new Error(`${label} verification rawOutput is required for full-output marker checks`);
2079
+ }
2080
+
2081
+ function assertMachineConfirmation(bundle: PendingConfirmation): void {
2082
+ const oob = bundle.callbackVerified;
2083
+ if (oob?.attempted) {
2084
+ if (oob.targetHits === 0) {
2085
+ throw new Error(
2086
+ `OOB VERIFY FAILED: no interaction with the target-run callback token. ${oob.note}`,
2087
+ );
2088
+ }
2089
+ if (oob.controlHits > 0) {
2090
+ throw new Error(
2091
+ `OOB VERIFY FAILED: the control-run callback token received ${oob.controlHits} interaction(s) — the callback is not target-dependent. ${oob.note}`,
2092
+ );
2093
+ }
2094
+ if (oob.sourceSeparated !== true) {
2095
+ throw new Error(
2096
+ "OOB VERIFY FAILED: callback source separation was not established. " +
2097
+ "A loopback listener reachable by the PoC is diagnostic telemetry, not proof that the target caused the interaction.",
2098
+ );
2099
+ }
2100
+ return;
1728
2101
  }
1729
- if (v.mode !== expectedMode) {
2102
+
2103
+ assertHarnessTargetOnly(
2104
+ bundle.harnessVerified,
2105
+ "HARNESS DIFFERENTIAL FAILED",
2106
+ "no machine-owned target/control replay was recorded",
2107
+ );
2108
+ }
2109
+
2110
+ function assertHarnessTargetOnly(
2111
+ harness: HarnessVerifyResult | undefined,
2112
+ label: string,
2113
+ missingNote: string,
2114
+ ): asserts harness is HarnessVerifyResult {
2115
+ if (
2116
+ !harness?.attempted ||
2117
+ harness.pass !== true ||
2118
+ harness.differential !== "target_only" ||
2119
+ harness.target?.matched !== true ||
2120
+ harness.control?.matched !== false
2121
+ ) {
2122
+ throw new Error(`${label}: ${harness?.note ?? missingNote}`);
2123
+ }
2124
+ }
2125
+
2126
+ function assertHarnessCanary(
2127
+ harness: HarnessVerifyResult | undefined,
2128
+ required: boolean,
2129
+ label: string,
2130
+ ): void {
2131
+ if (!required) return;
2132
+ if (
2133
+ harness?.canary?.attempted !== true ||
2134
+ harness.canary.pass !== true ||
2135
+ harness.canary.targetObserved !== true ||
2136
+ harness.canary.controlObserved !== false ||
2137
+ harness.proofStrength !== "canary_differential"
2138
+ ) {
2139
+ throw new Error(`${label}: ${harness?.canary?.note ?? "required canary transcript missing"}`);
2140
+ }
2141
+ }
2142
+
2143
+ function assertMainAgentVerification(
2144
+ bundle: PendingConfirmation,
2145
+ verification: MainAgentVerification | undefined,
2146
+ ): asserts verification is MainAgentVerification {
2147
+ if (!verification) {
1730
2148
  throw new Error(
1731
- `${label} verification mode mismatch: expected ${expectedMode}, got ${v.mode ?? "unset"}`,
2149
+ "MAIN-AGENT REPLAY REQUIRED: ConfirmFinding must produce a fresh harness-owned target/control transcript",
1732
2150
  );
1733
2151
  }
1734
- if (v.target !== expectedTarget) {
2152
+ const at = Date.parse(verification.at);
2153
+ const bundleAt = Date.parse(bundle.ranAt);
2154
+ const now = Date.now();
2155
+ if (
2156
+ !Number.isFinite(at) ||
2157
+ !Number.isFinite(bundleAt) ||
2158
+ at < bundleAt ||
2159
+ at > now + 30_000 ||
2160
+ now - at > 5 * 60 * 1000
2161
+ ) {
1735
2162
  throw new Error(
1736
- `${label} verification target mismatch: expected ${expectedTarget}, got ${v.target ?? "unset"}`,
2163
+ "MAIN-AGENT REPLAY FAILED: transcript timestamp must be valid, newer than phase 1, and no more than 5 minutes old",
1737
2164
  );
1738
2165
  }
2166
+ assertHarnessTargetOnly(
2167
+ verification.result,
2168
+ "MAIN-AGENT REPLAY FAILED",
2169
+ "no fresh phase-2 target/control replay was recorded",
2170
+ );
2171
+ assertHarnessCanary(
2172
+ verification.result,
2173
+ bundle.targetRuns[0].evidence.verify.canary !== undefined,
2174
+ "MAIN-AGENT CANARY FAILED",
2175
+ );
2176
+ const targetUrl = verification.result.target?.url;
2177
+ const controlUrl = verification.result.control?.url;
2178
+ const targetIdentity = bundle.targetRuns[0].target;
2179
+ if (!targetUrl || verifyUrlBindingError(targetUrl, targetIdentity)) {
2180
+ throw new Error("MAIN-AGENT REPLAY FAILED: target transcript is not bound to the case target");
2181
+ }
2182
+ if (!controlUrl || verifyUrlBindingError(controlUrl, bundle.controlTarget)) {
2183
+ throw new Error("MAIN-AGENT REPLAY FAILED: control transcript is not bound to control_target");
2184
+ }
1739
2185
  }
1740
2186
 
1741
2187
  /**
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.
2188
+ * Gate for phase 1 of promotion: case must exist, be investigating, and have
2189
+ * poc/evidence/impact/severity/target. The disconfirmation is provided by the
2190
+ * main agent at confirm time, so it is NOT a precondition here. Returns the
2191
+ * record when promotable, throws otherwise. Exported so PromoteFinding can
2192
+ * validate BEFORE paying for (potentially slow) sandboxed PoC runs.
1746
2193
  */
1747
2194
  export function assertPromotable(id: string): CaseRecord {
1748
2195
  const current = getCaseById(id);
@@ -1750,7 +2197,7 @@ export function assertPromotable(id: string): CaseRecord {
1750
2197
  throw new Error(`Case not found: ${id}`);
1751
2198
  }
1752
2199
  if (current.status !== "investigating") {
1753
- throw new Error(`promote_finding requires an investigating case (current: ${current.status})`);
2200
+ throw new Error(`PromoteFinding requires an investigating case (current: ${current.status})`);
1754
2201
  }
1755
2202
  if (!current.poc) {
1756
2203
  throw new Error("CONFIRMED requires poc; set poc on the case first");
@@ -1769,15 +2216,10 @@ export function assertPromotable(id: string): CaseRecord {
1769
2216
  "CONFIRMED requires target (what host/repo/scope this affects); set target on the case first",
1770
2217
  );
1771
2218
  }
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
2219
  // Evidence-chain closure: the observation item must be ARTIFACT-BACKED. A
1778
2220
  // summary-only observation is agent prose about itself — promotion requires
1779
2221
  // 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.)
2222
+ // is always artifact-backed: the gate writes it from the evidence hash.)
1781
2223
  if (!current.evidenceItems.some((e) => e.role === "observation" && e.sha256)) {
1782
2224
  throw new Error(
1783
2225
  "Evidence chain incomplete: CONFIRMED requires an artifact-backed observation evidence item " +
@@ -1788,171 +2230,302 @@ export function assertPromotable(id: string): CaseRecord {
1788
2230
  return current;
1789
2231
  }
1790
2232
 
1791
- export function promoteFindingResult(
1792
- id: string,
1793
- verification: PocVerification,
1794
- disconfirmationVerification?: PocVerification,
1795
- controlVerification?: PocVerification,
1796
- marker?: string,
1797
- controlLivenessMarker?: string,
1798
- ): CaseUpdateResult {
2233
+ /**
2234
+ * Phase 1: record the harness-observed evidence bundle on the case. The whole
2235
+ * contract is validated here — same-file control, nonce binding, run
2236
+ * completion, determinism across the two target runs, and the target/control
2237
+ * differential — so a bundle that cannot promote is rejected before the
2238
+ * main agent performs phase-2 review.
2239
+ */
2240
+ export function storePendingConfirmation(id: string, bundle: PendingConfirmation): CaseRecord {
1799
2241
  const db = getDb();
1800
2242
  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) {
2243
+ const current = getCaseById(id);
2244
+ if (!current) throw new Error(`Case not found: ${id}`);
2245
+ if (current.status !== "investigating") {
1809
2246
  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.",
2247
+ `Pending confirmation requires an investigating case (current: ${current.status})`,
1812
2248
  );
1813
2249
  }
1814
- const verificationMarker = marker?.trim();
1815
- if (!verificationMarker) {
2250
+ if (bundle.caseId !== id) throw new Error("Pending confirmation caseId mismatch");
2251
+ if (bundle.targetRuns.length !== 2 || !bundle.controlRun) {
2252
+ throw new Error("Pending confirmation requires two target runs and one control run");
2253
+ }
2254
+ if (!bundle.pocPath || !bundle.controlPath || !bundle.controlTarget) {
2255
+ throw new Error("Pending confirmation requires pocPath, controlPath, and controlTarget");
2256
+ }
2257
+ // Control-target binding (machine-verified here, not just in the tool
2258
+ // layer): the control run must actually have targeted the declared
2259
+ // control_target, that target must differ from the target runs' target,
2260
+ // and the control target must differ from the case's target — otherwise
2261
+ // "the control demonstrated nothing on the vulnerable target" passes.
2262
+ const targetRunTarget = bundle.targetRuns[0]?.target;
2263
+ if (!targetRunTarget || bundle.targetRuns.some((r) => r.target !== targetRunTarget)) {
1816
2264
  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.",
2265
+ "Pending confirmation requires both target runs against the same case target",
1819
2266
  );
1820
2267
  }
1821
-
1822
- assertVerificationRecord("PoC", verification, "poc", caseTarget);
1823
- if (!controlVerification?.target?.trim()) {
1824
- throw new Error("Every promotion requires a controlVerification target");
2268
+ if (!bundle.controlRun.target || bundle.controlRun.target !== bundle.controlTarget) {
2269
+ throw new Error(
2270
+ "CONTROL BINDING FAILED: controlRun.target must equal control_target — a control run " +
2271
+ "against a different host than the one declared proves nothing.",
2272
+ );
1825
2273
  }
1826
- if (controlVerification.target === caseTarget) {
2274
+ if (bundle.controlRun.target === targetRunTarget) {
1827
2275
  throw new Error(
1828
- "Every promotion requires a distinct control target; the control run cannot use the case target",
2276
+ "CONTROL BINDING FAILED: the control run targeted the same host as the target runs — " +
2277
+ "the claimed impact is not target-dependent.",
1829
2278
  );
1830
2279
  }
1831
- assertVerificationRecord("Control", controlVerification, "control", controlVerification.target);
1832
- assertVerificationRecord(
1833
- "Disconfirmation",
1834
- disconfirmationVerification,
1835
- "disconfirmation",
1836
- caseTarget,
1837
- );
1838
-
1839
- if (verification.exitCode !== 0) {
2280
+ if (bundle.controlTarget === current.target) {
1840
2281
  throw new Error(
1841
- `PoC verification failed (exit ${verification.exitCode}); cannot promote to confirmed`,
2282
+ "CONTROL BINDING FAILED: control_target must differ from the case target; a control run " +
2283
+ "against the vulnerable target proves nothing.",
1842
2284
  );
1843
2285
  }
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.
2286
+ // Same-file contract re-checked at store time (the tool already checked).
1848
2287
  let pocHash: string | undefined;
1849
2288
  let controlHash: string | undefined;
1850
2289
  try {
1851
- pocHash = createHash("sha256").update(readFileSync(verification.path)).digest("hex");
1852
- controlHash = createHash("sha256")
1853
- .update(readFileSync(controlVerification.path))
1854
- .digest("hex");
2290
+ pocHash = createHash("sha256").update(readFileSync(bundle.pocPath)).digest("hex");
2291
+ controlHash = createHash("sha256").update(readFileSync(bundle.controlPath)).digest("hex");
1855
2292
  } catch {
1856
2293
  pocHash = undefined;
1857
2294
  controlHash = undefined;
1858
2295
  }
1859
2296
  if (!pocHash || !controlHash || pocHash !== controlHash) {
1860
2297
  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.",
2298
+ "CONTROL CHECK FAILED: control_path must be the SAME script as poc_path " +
2299
+ "(sha256 mismatch). A separately written control file proves nothing.",
1864
2300
  );
1865
2301
  }
1866
-
1867
- const controlOutput = controlVerification.rawOutput ?? "";
1868
- if (controlOutput.includes(verificationMarker) || !controlOutput.includes(liveness)) {
1869
- 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.",
2302
+ if (bundle.pocSha256 && bundle.pocSha256 !== pocHash) {
2303
+ throw new Error("pocSha256 does not match the PoC file on disk");
2304
+ }
2305
+ for (const run of [...bundle.targetRuns, bundle.controlRun]) {
2306
+ validateRunEvidence(run, `${run.mode} run`);
2307
+ }
2308
+ assertEvidenceDifferential(bundle);
2309
+ if (!bundle.callbackVerified?.attempted) {
2310
+ for (const run of bundle.targetRuns) {
2311
+ const bindingError = verifyUrlBindingError(run.evidence.verify.url, targetRunTarget);
2312
+ if (bindingError) throw new Error(`TARGET BINDING FAILED: ${bindingError}`);
2313
+ }
2314
+ const controlBindingError = verifyUrlBindingError(
2315
+ bundle.controlRun.evidence.verify.url,
2316
+ bundle.controlTarget,
1875
2317
  );
2318
+ if (controlBindingError) {
2319
+ throw new Error(`CONTROL BINDING FAILED: ${controlBindingError}`);
2320
+ }
1876
2321
  }
2322
+ // A clean exit and model-authored evidence are necessary inputs, never the
2323
+ // proof. Promotion requires a harness-observed target/control differential
2324
+ // or a harness-owned OOB interaction differential.
2325
+ assertMachineConfirmation(bundle);
2326
+
2327
+ const next = buildRecord({ pendingConfirmation: bundle }, current);
2328
+ validateCase(next);
2329
+ upsertCase(db, next);
2330
+ return next;
2331
+ });
2332
+ }
1877
2333
 
1878
- const pocOutput = verification.rawOutput ?? "";
1879
- if (!pocOutput.includes(verificationMarker)) {
2334
+ /**
2335
+ * Phase 2: commit (or refuse) the promotion on the main agent's verdict.
2336
+ *
2337
+ * CONFIRMED requires the full bundle to still hold (completion, nonce,
2338
+ * determinism, differential), the PoC script to be unchanged since the runs
2339
+ * (pocSha256 — otherwise the main agent reviewed different bytes), and a
2340
+ * verdict accompanied by a fresh harness-owned target-only replay, a concrete
2341
+ * review note, and a disconfirmation attempt. NOT_CONFIRMED records the
2342
+ * verdict and keeps the case investigating — no tie-breaker.
2343
+ */
2344
+ export function applyConfirmationResult(
2345
+ id: string,
2346
+ verdictInput: MainAgentVerdict,
2347
+ phase2Verification?: MainAgentVerification,
2348
+ authority: { startedAsSubagent: boolean } = {
2349
+ startedAsSubagent: PROCESS_STARTED_AS_SUBAGENT || process.env.PI_SUBAGENT_CHILD === "1",
2350
+ },
2351
+ ): CaseUpdateResult {
2352
+ if (authority.startedAsSubagent) {
2353
+ throw new Error(
2354
+ "ConfirmFinding is reserved for the main/coordinator agent; worker processes cannot commit confirmation",
2355
+ );
2356
+ }
2357
+ const db = getDb();
2358
+ return withImmediateTransaction(db, () => {
2359
+ const current = getCaseById(id);
2360
+ if (!current) throw new Error(`Case not found: ${id}`);
2361
+ if (current.status !== "investigating") {
2362
+ throw new Error(`ConfirmFinding requires an investigating case (current: ${current.status})`);
2363
+ }
2364
+ const bundle = current.pendingConfirmation;
2365
+ if (!bundle) {
2366
+ throw new Error("No pending confirmation on this case — run PromoteFinding first");
2367
+ }
2368
+ // Fail closed on an unparseable ranAt: Date.parse(garbage) is NaN, and
2369
+ // NaN > TTL is false — a malformed timestamp must NOT make the bundle
2370
+ // immortal. Treat it as expired (re-run PromoteFinding for a fresh one).
2371
+ const ranAtMs = Date.parse(bundle.ranAt);
2372
+ if (!Number.isFinite(ranAtMs) || Date.now() - ranAtMs > PENDING_CONFIRM_TTL_MS) {
1880
2373
  throw new Error(
1881
- `PoC verification output does not contain the verification marker "${verificationMarker}"; ` +
1882
- "exit 0 alone cannot promote to confirmed",
2374
+ "Pending confirmation expired or has an invalid timestamp (1h TTL) re-run PromoteFinding for a fresh bundle",
1883
2375
  );
1884
2376
  }
2377
+ const parsed = validateMainAgentVerdict(verdictInput);
2378
+ if (!parsed.ok) throw new Error(`Invalid main-agent confirmation verdict: ${parsed.error}`);
2379
+ const verdict = parsed.verdict;
2380
+ const canaryRequested = bundle.targetRuns[0].evidence.verify.canary !== undefined;
2381
+ if (verdict.verdict === "CONFIRMED") {
2382
+ if (canaryRequested && verdict.canary_assessment !== "verified") {
2383
+ throw new Error(
2384
+ "CONFIRMED canary mismatch: evidence requested a harness canary, so canary_assessment must be verified",
2385
+ );
2386
+ }
2387
+ if (!canaryRequested && verdict.canary_assessment !== "not_applicable") {
2388
+ throw new Error(
2389
+ "CONFIRMED canary mismatch: this evidence has no canary template; record canary_assessment=not_applicable and explain why",
2390
+ );
2391
+ }
2392
+ }
2393
+ const recorded: MainAgentVerdictRecord = {
2394
+ ...verdict,
2395
+ at: new Date().toISOString(),
2396
+ reviewer: "main_agent",
2397
+ phase2Verification: verdict.verdict === "CONFIRMED" ? phase2Verification : undefined,
2398
+ proofStrength:
2399
+ verdict.verdict === "CONFIRMED"
2400
+ ? canaryRequested
2401
+ ? "canary_differential"
2402
+ : "predicate_differential"
2403
+ : undefined,
2404
+ };
1885
2405
 
1886
- if (disconfirmationVerification.exitCode === 0) {
1887
- 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.",
2406
+ if (verdict.verdict === "NOT_CONFIRMED") {
2407
+ const note = `main agent NOT_CONFIRMED${verdict.model ? ` (${verdict.model})` : ""}: ${verdict.reasoning}`;
2408
+ const next = buildRecord(
2409
+ {
2410
+ confirmerVerdict: recorded,
2411
+ pendingConfirmation: undefined,
2412
+ assumptions: [...(current.assumptions ?? []), note],
2413
+ },
2414
+ current,
1890
2415
  );
2416
+ // buildRecord's nullish fallback preserves the old value; consume the
2417
+ // rejected attempt explicitly so a retry must produce fresh evidence.
2418
+ next.pendingConfirmation = undefined;
2419
+ validateCase(next);
2420
+ upsertCase(db, next);
2421
+ return { record: next, changed: true };
1891
2422
  }
1892
2423
 
1893
- // Machine-recorded reproduction evidence: the PoC gate itself writes the
1894
- // artifact-backed evidence item confirmation is anchored to a real file
1895
- // with its SHA-256, not to agent prose in the evidence field.
1896
- let pocSha256: string | undefined;
2424
+ // CONFIRMED re-validate the whole bundle (defense in depth; the case may
2425
+ // have been touched between phase 1 and the verdict).
2426
+ for (const run of [...bundle.targetRuns, bundle.controlRun]) {
2427
+ validateRunEvidence(run, `${run.mode} run`);
2428
+ }
2429
+ assertEvidenceDifferential(bundle);
2430
+ assertMachineConfirmation(bundle);
2431
+ assertHarnessCanary(bundle.harnessVerified, canaryRequested, "PHASE-1 CANARY FAILED");
2432
+ let pocHash: string | undefined;
1897
2433
  try {
1898
- pocSha256 = createHash("sha256").update(readFileSync(verification.path)).digest("hex");
2434
+ pocHash = createHash("sha256").update(readFileSync(bundle.pocPath)).digest("hex");
1899
2435
  } catch {
1900
- pocSha256 = undefined;
2436
+ pocHash = undefined;
2437
+ }
2438
+ if (!pocHash || pocHash !== bundle.pocSha256) {
2439
+ throw new Error(
2440
+ "PoC script changed since the runs — re-run PromoteFinding (the main agent must review the exact bytes that ran)",
2441
+ );
2442
+ }
2443
+ // The case target must still be the host the PoC ran against, and still
2444
+ // differ from the control target. The evidence proves nothing about a
2445
+ // target the case adopted after the runs.
2446
+ const targetRun = bundle.targetRuns[0];
2447
+ if (!current.target || current.target !== targetRun.target) {
2448
+ throw new Error(
2449
+ "Case target changed since the PoC runs — re-run PromoteFinding against the current target " +
2450
+ `(bundle target: ${targetRun.target}, case target: ${current.target ?? "(none)"}).`,
2451
+ );
2452
+ }
2453
+ if (current.target === bundle.controlTarget) {
2454
+ throw new Error(
2455
+ "Case target now equals the control target — the claimed impact is not target-dependent; " +
2456
+ "re-run PromoteFinding with a distinct control_target.",
2457
+ );
1901
2458
  }
1902
2459
 
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.
2460
+ // The observation must predate the repro (provenance guard).
1906
2461
  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
- }
2462
+ if (observation && observation.createdAt > bundle.targetRuns[0].ranAt) {
2463
+ throw new Error(
2464
+ "Evidence chain invalid: the observation item was recorded after the PoC ran " +
2465
+ `(${observation.createdAt} > ${bundle.targetRuns[0].ranAt}). The observation must predate the repro.`,
2466
+ );
1926
2467
  }
2468
+
2469
+ // Phase 1 proves the evidence floor. Phase 2 must freshly replay that same
2470
+ // request inside the main agent's ConfirmFinding call; a caller-provided
2471
+ // boolean is not accepted as proof of re-execution.
2472
+ assertMainAgentVerification(bundle, phase2Verification);
2473
+
1927
2474
  const reproductionItem: EvidenceItem = {
1928
- id: `ev_${stableShortId(`${id}\nreproduction\n${verification.ranAt}`)}`,
2475
+ id: `ev_${stableShortId(`${id}\nreproduction\n${targetRun.ranAt}`)}`,
1929
2476
  caseId: id,
1930
2477
  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,
2478
+ // The runner preserves each run's evidence.json in a durable dir
2479
+ // (.pi/poc-evidence/) — the artifact the hash was computed over still
2480
+ // exists, so the item stays artifact-backed and re-verifiable.
2481
+ artifactPath: targetRun.evidencePath ? basename(targetRun.evidencePath) : "evidence.json",
2482
+ sha256: targetRun.evidenceSha256,
2483
+ summary: `PoC evidence accepted (2 target runs + control; ${recorded.proofStrength}) — main agent semantic confirmation${verdict.model ? ` (${verdict.model})` : ""}`,
2484
+ createdAt: targetRun.ranAt,
1935
2485
  };
1936
2486
 
1937
2487
  const newEvidence =
1938
2488
  (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\`\`\``;
2489
+ `### PoC Execution Capture (${targetRun.ranAt})\n` +
2490
+ `- **Evidence sha256:** ${targetRun.evidenceSha256}\n` +
2491
+ `- **Target:** ${targetRun.target}\n` +
2492
+ `- **Machine evidence:** ${recorded.proofStrength} (a differential is not by itself proof of exploitation)\n` +
2493
+ `- **Main-agent reviewer:** ${verdict.model ?? "unknown model"} — semantic confirmation\n` +
2494
+ `#### Target Run Output\n\`\`\`\n${targetRun.output ?? ""}\n\`\`\``;
1944
2495
 
1945
2496
  const update: NormalizedCaseInput = {
1946
2497
  status: "confirmed",
1947
- pocVerified: stripRaw(verification),
1948
- disconfirmationVerified: stripRaw(disconfirmationVerification),
1949
- controlVerified: stripRaw(controlVerification),
2498
+ pocVerified: {
2499
+ path: bundle.pocPath,
2500
+ exitCode: targetRun.exitCode,
2501
+ ranAt: targetRun.ranAt,
2502
+ output: targetRun.output,
2503
+ sandbox: targetRun.sandbox,
2504
+ completed: true,
2505
+ outputComplete: true,
2506
+ mode: "poc",
2507
+ target: targetRun.target,
2508
+ },
2509
+ controlVerified: {
2510
+ path: bundle.controlPath,
2511
+ exitCode: bundle.controlRun.exitCode,
2512
+ ranAt: bundle.controlRun.ranAt,
2513
+ output: bundle.controlRun.output,
2514
+ sandbox: bundle.controlRun.sandbox,
2515
+ completed: true,
2516
+ outputComplete: true,
2517
+ mode: "control",
2518
+ target: bundle.controlRun.target,
2519
+ },
2520
+ disconfirmation: verdict.disconfirmation_attempt,
2521
+ confirmerVerdict: recorded,
2522
+ pendingConfirmation: undefined,
1950
2523
  evidence: newEvidence,
1951
2524
  };
1952
2525
 
1953
2526
  const next = buildRecord(update, current);
2527
+ next.pendingConfirmation = undefined; // buildRecord's ?? existing keeps it; clear explicitly
1954
2528
  validateCase(next);
1955
-
1956
2529
  insertEvidenceItem(db, reproductionItem);
1957
2530
  upsertCase(db, next);
1958
2531
  next.evidenceItems = [...(next.evidenceItems ?? []), reproductionItem];
@@ -2031,8 +2604,21 @@ function eTLDPlus1(host: string): string {
2031
2604
  return parts.slice(-2).join(".");
2032
2605
  }
2033
2606
 
2607
+ /**
2608
+ * Ruled-out phrasings that must not contribute to chain matching. Sentence
2609
+ * granularity keeps the positive signals intact: "no CSRF token on /transfer"
2610
+ * (a reason XSS→state-change chains) is NOT dropped — only explicit
2611
+ * "this class is not a finding" sentences are.
2612
+ */
2613
+ const CHAIN_NEGATION_RE =
2614
+ /\b(not vulnerable|not susceptible|not exploitable|not present|not found|not affected|ruled out|no vulnerability|no vuln|no evidence of|absence of|false positive|not a finding|no issue found|dismissed|non-?vulnerable|not reachable)\b/i;
2615
+
2034
2616
  function chainText(c: CaseRecord): string {
2035
- return [c.title, c.bugClass ?? "", c.evidence ?? ""].join(" ");
2617
+ const raw = [c.title, c.bugClass ?? "", c.evidence ?? ""].join(" ");
2618
+ return raw
2619
+ .split(/[.;\n]+/)
2620
+ .filter((s) => !CHAIN_NEGATION_RE.test(s))
2621
+ .join(" ");
2036
2622
  }
2037
2623
 
2038
2624
  function hasChainClass(c: CaseRecord, re: RegExp): boolean {
@@ -2062,23 +2648,29 @@ function sameAssetOrRelated(a: CaseRecord, b: CaseRecord): boolean {
2062
2648
  return eTLDPlus1(ta) === eTLDPlus1(tb);
2063
2649
  }
2064
2650
 
2065
- /**
2066
- * Scan non-terminal cases for exploitable chains (CyberStrike-style detection
2067
- * over XPI's case records). Emits ranked suggestions; the agent decides
2068
- * whether to CaseLink or open an escalation case.
2069
- */
2070
2651
  export function suggestChains(caseId?: string): ChainSuggestion[] {
2071
2652
  // Pair over ALL non-terminal cases; the caseId filter narrows the RESULTS
2072
2653
  // to suggestions involving that case (filtering the inputs first would drop
2073
2654
  // unlinked partner cases and kill cross-case pairing).
2074
2655
  const cases = readCasefile().filter((c) => c.status !== "killed" && c.status !== "reported");
2656
+ // Already-linked pairs are existing knowledge, not a missed combination —
2657
+ // suggesting them again is noise. One query for every link row.
2658
+ const linkedPairs = new Set<string>();
2659
+ const linkRows = getDb().prepare("SELECT source_id, target_id FROM case_links").all() as {
2660
+ source_id: string;
2661
+ target_id: string;
2662
+ }[];
2663
+ for (const row of linkRows) linkedPairs.add([row.source_id, row.target_id].sort().join("+"));
2075
2664
  const suggestions: ChainSuggestion[] = [];
2076
2665
  const seen = new Set<string>();
2077
2666
  const confirmed = (c: CaseRecord) => c.status === "confirmed";
2078
2667
  const confidenceFor = (a: CaseRecord, b?: CaseRecord) => {
2079
2668
  const both = confirmed(a) && (!b || confirmed(b));
2080
2669
  const one = confirmed(a) || (b ? confirmed(b) : false);
2081
- return both ? 90 : one ? 75 : 55;
2670
+ const anyHypothesis = a.status === "hypothesis" || (b ? b.status === "hypothesis" : false);
2671
+ if (both) return 90;
2672
+ if (anyHypothesis) return 40; // unproven primitives chain weakly
2673
+ return one ? 75 : 60;
2082
2674
  };
2083
2675
  const add = (
2084
2676
  pattern: ChainPattern,
@@ -2087,6 +2679,7 @@ export function suggestChains(caseId?: string): ChainSuggestion[] {
2087
2679
  rationale: string,
2088
2680
  kind?: CaseLinkKind,
2089
2681
  ) => {
2682
+ if (b && linkedPairs.has([a.id, b.id].sort().join("+"))) return; // already known
2090
2683
  const key = b ? `${pattern}:${[a.id, b.id].sort().join("+")}` : `${pattern}:${a.id}`;
2091
2684
  if (seen.has(key)) return;
2092
2685
  seen.add(key);
@@ -2360,12 +2953,16 @@ function buildCaseWhere(options: CaseSearchOptions): {
2360
2953
 
2361
2954
  const query = options.query?.trim().toLowerCase();
2362
2955
  if (query) {
2363
- const likeParam = `%${query}%`;
2956
+ // Escape LIKE wildcards so a query containing % or _ matches literally
2957
+ // instead of acting as a pattern ("100%" must not match "1000"). The
2958
+ // backslash is the escape char, so it is escaped first.
2959
+ const escaped = query.replace(/[\\%_]/g, (m) => `\\${m}`);
2960
+ const likeParam = `%${escaped}%`;
2364
2961
  if (options.field) {
2365
- where.push(`lower(${options.field}) LIKE ?`);
2962
+ where.push(`lower(${options.field}) LIKE ? ESCAPE '\\'`);
2366
2963
  params.push(likeParam);
2367
2964
  } else {
2368
- const ors = SEARCH_FIELD_VALUES.map((c) => `lower(${c}) LIKE ?`).join(" OR ");
2965
+ const ors = SEARCH_FIELD_VALUES.map((c) => `lower(${c}) LIKE ? ESCAPE '\\'`).join(" OR ");
2369
2966
  where.push(`(${ors})`);
2370
2967
  for (let i = 0; i < SEARCH_FIELD_VALUES.length; i++) params.push(likeParam);
2371
2968
  }
@@ -2405,8 +3002,12 @@ export function searchCases(options: CaseSearchOptions = {}): {
2405
3002
  total: number;
2406
3003
  } {
2407
3004
  const db = getDb();
2408
- const limit = Math.max(1, Math.min(options.limit ?? 50, 200));
2409
- const offset = Math.max(0, options.offset ?? 0);
3005
+ // NaN is not clamped by Math.min/max (it passes through) and SQLite binds it
3006
+ // as NULL, which disables LIMIT — fall back to the default instead.
3007
+ const rawLimit = Number.isFinite(options.limit) ? options.limit : undefined;
3008
+ const rawOffset = Number.isFinite(options.offset) ? options.offset : undefined;
3009
+ const limit = Math.max(1, Math.min(rawLimit ?? 50, 200));
3010
+ const offset = Math.max(0, rawOffset ?? 0);
2410
3011
 
2411
3012
  const { whereSql, orderSql, params } = buildCaseWhere(options);
2412
3013
 
@@ -2496,7 +3097,10 @@ export function formatCaseDetail(record: CaseRecord): string {
2496
3097
  } else if (Array.isArray(val)) {
2497
3098
  display = val.join(", ");
2498
3099
  } else if (typeof val === "object") {
2499
- display = JSON.stringify(val);
3100
+ // Path-leak guard (consistent with buildCompleteRecord): verification
3101
+ // objects and the pending bundle carry local script/evidence paths —
3102
+ // show basenames only.
3103
+ display = JSON.stringify(redactPaths(val));
2500
3104
  } else {
2501
3105
  display = String(val);
2502
3106
  }
@@ -2528,25 +3132,39 @@ const MAX_ARTIFACT_CHARS = 100_000;
2528
3132
  * must not balloon the report context into megabytes. */
2529
3133
  const MAX_TOTAL_ARTIFACT_CHARS = 400_000;
2530
3134
 
3135
+ /**
3136
+ * Recursively redact local filesystem paths to basenames in a serialized
3137
+ * object. Covers the verification records (path), the pending confirmation
3138
+ * bundle (pocPath/controlPath) and preserved evidence copies (evidencePath) —
3139
+ * the context bundle must never leak the researcher's local paths.
3140
+ */
3141
+ function redactPaths(value: unknown, seen = new Set<object>()): unknown {
3142
+ if (Array.isArray(value)) return value.map((v) => redactPaths(v, seen));
3143
+ if (typeof value !== "object" || value === null) return value;
3144
+ if (seen.has(value)) return value;
3145
+ seen.add(value);
3146
+ const out: Record<string, unknown> = {};
3147
+ for (const [k, v] of Object.entries(value)) {
3148
+ if (
3149
+ typeof v === "string" &&
3150
+ (k === "path" || k === "pocPath" || k === "controlPath" || k === "evidencePath")
3151
+ ) {
3152
+ out[k] = basename(v) || v;
3153
+ } else {
3154
+ out[k] = redactPaths(v, seen);
3155
+ }
3156
+ }
3157
+ return out;
3158
+ }
3159
+
2531
3160
  function buildCompleteRecord(current: CaseRecord): string {
2532
3161
  const rows: string[] = [];
2533
3162
  for (const [k, v] of Object.entries(current)) {
2534
3163
  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
- }
3164
+ // Path-leak guard: verification objects + the pending bundle carry the
3165
+ // researcher's local PoC/disconfirmation/control/evidence paths show
3166
+ // basenames only (the dedicated log sections do the same).
3167
+ const display = typeof v === "object" ? JSON.stringify(redactPaths(v), null, 2) : String(v);
2550
3168
  rows.push(`- **${k}:** ${display.replace(/\n/g, "\n ")}`);
2551
3169
  }
2552
3170
  return rows.join("\n");
@@ -2627,11 +3245,13 @@ function buildScratchpadSection(caseId: string): string {
2627
3245
  : "No scratchpad run found containing this case id (manual/CTF run without pipeline artifacts).";
2628
3246
  }
2629
3247
 
2630
- export function writeCaseContext(id: string): {
3248
+ export type CaseContextResult = {
2631
3249
  path: string;
2632
3250
  contextPath: string;
2633
3251
  record: CaseRecord;
2634
- } {
3252
+ };
3253
+
3254
+ export function writeCaseContext(id: string): CaseContextResult {
2635
3255
  const current = getCaseById(id);
2636
3256
  if (!current) throw new Error(`Case not found: ${id}`);
2637
3257
  if (current.status !== "confirmed" && current.status !== "reported") {
@@ -2702,13 +3322,13 @@ export function writeCaseContext(id: string): {
2702
3322
  current.pocVerified
2703
3323
  ? mdSection(
2704
3324
  "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\`\`\``,
3325
+ `### 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
3326
  )
2707
3327
  : undefined,
2708
3328
  current.controlVerified
2709
3329
  ? mdSection(
2710
3330
  "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\`\`\``,
3331
+ `### 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:** zero exit is necessary run integrity, never vulnerability proof; output markers are diagnostic only. The machine floor is the harness differential plus main-agent review.\n\n#### Output\n\`\`\`\n${current.controlVerified.output ?? ""}\n\`\`\``,
2712
3332
  )
2713
3333
  : undefined,
2714
3334
  mdSection("Disconfirmation Attempt", current.disconfirmation),