@xaccefy/pi-casefile 0.9.4 → 0.10.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.
@@ -0,0 +1,951 @@
1
+ /**
2
+ * Two-phase PoC confirmation gate (phase 1: PromoteFinding evidence bundle;
3
+ * phase 2: main-agent ConfirmFinding commit).
4
+ *
5
+ * Extracted verbatim from ledger.ts so the trust-critical machinery lives in
6
+ * one readable module. ledger.ts re-exports every public symbol here, so
7
+ * callers and tests are unchanged.
8
+ *
9
+ * The gate's contract (docs/confirmation-design.md):
10
+ * - Zero exit + complete output capture = run integrity, never proof.
11
+ * - Evidence must be nonce-bound, schema-valid, carry a discriminating
12
+ * response-body predicate, and survive durable-hash re-verification.
13
+ * - Target-dependence requires a machine differential: inter-host control run,
14
+ * intra-target same-host baseline, or a source-separated OOB token delta.
15
+ * - Phase 2 requires a fresh harness-owned replay bound to the verdict; only
16
+ * the ledger can transition a case to confirmed.
17
+ */
18
+
19
+ import { createHash } from "node:crypto";
20
+ import { lstatSync, readFileSync, statSync } from "node:fs";
21
+
22
+ import { basename } from "node:path";
23
+
24
+ import {
25
+ evidenceNonceMatches,
26
+ type MainAgentVerdict,
27
+ normalizeEvidence,
28
+ panelQuorumReached,
29
+ parsePoCEvidence,
30
+ scanArtifactForSecrets,
31
+ validateMainAgentVerdict,
32
+ validatePanelVotes,
33
+ } from "./evidence.ts";
34
+ import { type HarnessVerifyResult, sameRequest, verifyUrlBindingError } from "./harness-verify.ts";
35
+ import type {
36
+ CaseRecord,
37
+ CaseUpdateResult,
38
+ EvidenceItem,
39
+ MainAgentVerdictRecord,
40
+ MainAgentVerification,
41
+ NormalizedCaseInput,
42
+ PendingConfirmation,
43
+ PocEvidenceRun,
44
+ } from "./ledger.ts";
45
+ import { getCaseById, readWorkspaceArtifact } from "./ledger.ts";
46
+ import {
47
+ appendCaseEvent,
48
+ buildRecord,
49
+ getDb,
50
+ insertEvidenceItem,
51
+ stableShortId,
52
+ upsertCase,
53
+ validateCase,
54
+ withImmediateTransaction,
55
+ } from "./ledger-internal.ts";
56
+
57
+ /** PoC evidence has a tighter runner-side cap and must remain equally bounded on re-read. */
58
+ const POC_EVIDENCE_MAX_BYTES = 256 * 1024;
59
+
60
+ /** Immutable module-start role; child shells cannot upgrade this process by unsetting an env var. */
61
+ const PROCESS_STARTED_AS_SUBAGENT = process.env.PI_SUBAGENT_CHILD === "1";
62
+
63
+ /** Pending confirmation expires after 1h — re-run PromoteFinding for a fresh bundle. */
64
+ export const PENDING_CONFIRM_TTL_MS = 60 * 60 * 1000;
65
+
66
+ // ── Report contract gate (confirmed → reported) ──────────────────────
67
+
68
+ /** Typed, fail-closed error for an invalid report contract. */
69
+ export class ReportContractError extends Error {
70
+ readonly code = "REPORT_CONTRACT_INVALID";
71
+ readonly violations: string[];
72
+
73
+ constructor(violations: string[]) {
74
+ super(
75
+ `report contract invalid (${violations.length} violation(s)):\n- ${violations.join("\n- ")}`,
76
+ );
77
+ this.name = "ReportContractError";
78
+ this.violations = violations;
79
+ }
80
+ }
81
+
82
+ /** The closed-schema contract companion path for a report markdown path. */
83
+ export function reportContractPathFor(reportPath: string): string {
84
+ return reportPath.replace(/\.md$/i, ".contract.json");
85
+ }
86
+
87
+ /** Hard cap on the contract document — it is metadata, not a report carrier. */
88
+ const REPORT_CONTRACT_MAX_BYTES = 64 * 1024;
89
+
90
+ /** Keys the closed schema accepts; anything else is a violation. */
91
+ const REPORT_CONTRACT_KEYS = new Set([
92
+ "case_id",
93
+ "title",
94
+ "severity",
95
+ "summary",
96
+ "impact",
97
+ "remediation",
98
+ "steps",
99
+ "evidence_ids",
100
+ "coverage_refs",
101
+ ]);
102
+
103
+ /**
104
+ * Validate the closed-schema report contract for a confirmed case:
105
+ * - a regular, non-symlink JSON file of bounded size exists at contractPath;
106
+ * - only schema keys are present, and the required text fields are non-empty;
107
+ * - evidence_ids reference ONLY evidence items that exist on this case, and
108
+ * include at least one observation and one reproduction item;
109
+ * - coverage_refs reference ONLY (asset, class) cells recorded on this case.
110
+ *
111
+ * Throws ReportContractError (fail closed) on any violation.
112
+ */
113
+ export function validateReportContract(record: CaseRecord, contractPath: string): void {
114
+ const violations: string[] = [];
115
+ let stat: ReturnType<typeof statSync>;
116
+ try {
117
+ stat = statSync(contractPath);
118
+ } catch {
119
+ throw new ReportContractError([
120
+ `report contract not found: ${basename(contractPath)} (write the closed-schema JSON contract next to the report, then retry status='reported')`,
121
+ ]);
122
+ }
123
+ if (!stat.isFile()) violations.push("report contract path is not a regular file");
124
+ if (lstatSync(contractPath).isSymbolicLink()) {
125
+ violations.push("report contract must not be a symbolic link");
126
+ }
127
+ if (stat.size > REPORT_CONTRACT_MAX_BYTES) {
128
+ violations.push(
129
+ `report contract too large (${stat.size} bytes; max ${REPORT_CONTRACT_MAX_BYTES})`,
130
+ );
131
+ }
132
+ if (violations.length > 0) throw new ReportContractError(violations);
133
+
134
+ let doc: unknown;
135
+ try {
136
+ doc = JSON.parse(readFileSync(contractPath, "utf8"));
137
+ } catch (e) {
138
+ throw new ReportContractError([`report contract is not valid JSON: ${(e as Error).message}`]);
139
+ }
140
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
141
+ throw new ReportContractError(["report contract must be a JSON object"]);
142
+ }
143
+ const contract = doc as Record<string, unknown>;
144
+ for (const key of Object.keys(contract)) {
145
+ if (!REPORT_CONTRACT_KEYS.has(key)) {
146
+ violations.push(`unknown key "${key}" — the report contract schema is closed`);
147
+ }
148
+ }
149
+ for (const required of ["case_id", "title", "severity", "summary", "impact", "remediation"]) {
150
+ const v = contract[required];
151
+ if (typeof v !== "string" || v.trim().length === 0) {
152
+ violations.push(`"${required}" must be a non-empty string`);
153
+ }
154
+ }
155
+ if (contract.case_id !== record.id) {
156
+ violations.push(`"case_id" must be ${record.id} (got ${String(contract.case_id)})`);
157
+ }
158
+ const SEVERITIES = ["info", "low", "medium", "high", "critical"];
159
+ if (
160
+ typeof contract.severity === "string" &&
161
+ !(record.severity
162
+ ? contract.severity === record.severity
163
+ : SEVERITIES.includes(contract.severity))
164
+ ) {
165
+ violations.push(
166
+ `"severity" must match the case severity (${record.severity ?? "unset"}) or be a valid severity`,
167
+ );
168
+ }
169
+ if (!Array.isArray(contract.steps) || contract.steps.length === 0) {
170
+ violations.push('"steps" must be a non-empty array of reproduction steps');
171
+ } else if (!contract.steps.every((s: unknown) => typeof s === "string" && s.trim().length > 0)) {
172
+ violations.push('"steps" entries must be non-empty strings');
173
+ }
174
+
175
+ const items = record.evidenceItems ?? [];
176
+ const knownIds = new Set(items.map((i) => i.id));
177
+ const evidenceIds = contract.evidence_ids;
178
+ if (!Array.isArray(evidenceIds) || evidenceIds.length === 0) {
179
+ violations.push('"evidence_ids" must be a non-empty array of evidence item ids');
180
+ } else {
181
+ if (!evidenceIds.every((id: unknown) => typeof id === "string" && knownIds.has(id))) {
182
+ violations.push('"evidence_ids" references evidence items that do not exist on this case');
183
+ }
184
+ const referenced = items.filter((i) => evidenceIds.includes(i.id));
185
+ if (!referenced.some((i) => i.role === "observation")) {
186
+ violations.push('"evidence_ids" must include at least one observation item');
187
+ }
188
+ if (!referenced.some((i) => i.role === "reproduction")) {
189
+ violations.push('"evidence_ids" must include at least one reproduction item');
190
+ }
191
+ }
192
+
193
+ const coverageRefs = contract.coverage_refs;
194
+ if (coverageRefs !== undefined && !Array.isArray(coverageRefs)) {
195
+ violations.push('"coverage_refs" must be an array of { asset, class } objects');
196
+ } else if (Array.isArray(coverageRefs)) {
197
+ const cells = new Set((record.coverageItems ?? []).map((c) => `${c.asset}\n${c.class}`));
198
+ for (const [index, ref] of coverageRefs.entries()) {
199
+ if (
200
+ typeof ref !== "object" ||
201
+ ref === null ||
202
+ typeof (ref as Record<string, unknown>).asset !== "string" ||
203
+ typeof (ref as Record<string, unknown>).class !== "string"
204
+ ) {
205
+ violations.push(`"coverage_refs[${index}]" must be an { asset, class } object`);
206
+ } else if (
207
+ !cells.has(`${(ref as { asset: string }).asset}\n${(ref as { class: string }).class}`)
208
+ ) {
209
+ violations.push(
210
+ `"coverage_refs[${index}]" references a coverage cell not recorded on this case`,
211
+ );
212
+ }
213
+ }
214
+ }
215
+
216
+ if (violations.length > 0) throw new ReportContractError(violations);
217
+ }
218
+
219
+ function validateRunEvidence(run: PocEvidenceRun, label: string): void {
220
+ if (!run.completed) {
221
+ throw new Error(`${label} did not complete; a crash is not evidence`);
222
+ }
223
+ if (!run.outputComplete) {
224
+ throw new Error(`${label} output capture was incomplete; evidence checks are unsafe`);
225
+ }
226
+ if (run.exitCode !== 0) {
227
+ throw new Error(
228
+ `${label} exited with ${run.exitCode}; exit 0 is required for a complete run but is never sufficient proof`,
229
+ );
230
+ }
231
+ if (!run.evidence || !run.evidenceSha256) {
232
+ throw new Error(
233
+ `${label} has no evidence.json — the PoC must write evidence to $PI_POC_EVIDENCE_DIR`,
234
+ );
235
+ }
236
+ if (!evidenceNonceMatches(run.evidence, run.nonce)) {
237
+ throw new Error(`${label} evidence nonce mismatch — evidence not bound to this run`);
238
+ }
239
+ const parsed = parsePoCEvidence(run.evidence);
240
+ if (!parsed.ok) {
241
+ throw new Error(`${label} evidence contract invalid: ${parsed.error}`);
242
+ }
243
+ if (!run.evidencePath) {
244
+ throw new Error(`${label} has no durable evidencePath; ephemeral evidence cannot confirm`);
245
+ }
246
+ const artifact = readWorkspaceArtifact(run.evidencePath);
247
+ if (artifact.bytes.byteLength > POC_EVIDENCE_MAX_BYTES) {
248
+ throw new Error(
249
+ `${label} durable evidence exceeds ${POC_EVIDENCE_MAX_BYTES} bytes; evidence cannot be revalidated safely`,
250
+ );
251
+ }
252
+ const durableHash = createHash("sha256").update(artifact.bytes).digest("hex");
253
+ if (durableHash !== run.evidenceSha256) {
254
+ throw new Error(`${label} durable evidence hash does not match evidenceSha256`);
255
+ }
256
+ let durableRaw: unknown;
257
+ try {
258
+ durableRaw = JSON.parse(artifact.bytes.toString("utf8"));
259
+ } catch (error) {
260
+ throw new Error(`${label} durable evidence is not valid JSON: ${(error as Error).message}`);
261
+ }
262
+ const durable = parsePoCEvidence(durableRaw);
263
+ if (!durable.ok) {
264
+ throw new Error(`${label} durable evidence contract invalid: ${durable.error}`);
265
+ }
266
+ if (
267
+ normalizeEvidence(durable.evidence) !== normalizeEvidence(run.evidence) ||
268
+ JSON.stringify(durable.evidence.observations) !== JSON.stringify(run.evidence.observations)
269
+ ) {
270
+ throw new Error(`${label} durable evidence bytes do not match the stored evidence object`);
271
+ }
272
+ }
273
+
274
+ /** Determinism + differential on normalized evidence (nonce/observations stripped). */
275
+ function assertEvidenceDifferential(bundle: PendingConfirmation, isIntra = false): void {
276
+ const [r1, r2] = bundle.targetRuns;
277
+ if (normalizeEvidence(r1.evidence) !== normalizeEvidence(r2.evidence)) {
278
+ throw new Error(
279
+ "Target runs produced inconsistent evidence — the exploit did not reproduce deterministically",
280
+ );
281
+ }
282
+ // Intra-target target-dependence is proven by the harness attack-vs-baseline
283
+ // replay (same host), not by comparing a target run to a separate control run.
284
+ if (isIntra) return;
285
+ // OOB-only bundles prove target-dependence via the oracle token differential
286
+ // (assertMachineConfirmation judges callbackVerified); no control run exists.
287
+ if (!bundle.controlRun && bundle.callbackVerified?.attempted) return;
288
+ if (!bundle.controlRun) {
289
+ throw new Error("inter-host confirmation requires a control run");
290
+ }
291
+ if (normalizeEvidence(r1.evidence) === normalizeEvidence(bundle.controlRun.evidence)) {
292
+ throw new Error(
293
+ "Control run produced identical evidence to the target — the claimed impact is not target-dependent",
294
+ );
295
+ }
296
+ }
297
+
298
+ function assertMachineConfirmation(bundle: PendingConfirmation): void {
299
+ const oob = bundle.callbackVerified;
300
+ if (oob?.attempted) {
301
+ if (oob.targetHits === 0) {
302
+ throw new Error(
303
+ `OOB VERIFY FAILED: no interaction with the target-run callback token. ${oob.note}`,
304
+ );
305
+ }
306
+ if (oob.controlHits > 0) {
307
+ throw new Error(
308
+ `OOB VERIFY FAILED: the control-run callback token received ${oob.controlHits} interaction(s) — the callback is not target-dependent. ${oob.note}`,
309
+ );
310
+ }
311
+ if (oob.sourceSeparated !== true) {
312
+ throw new Error(
313
+ "OOB VERIFY FAILED: callback source separation was not established. " +
314
+ "A loopback listener reachable by the PoC is diagnostic telemetry, not proof that the target caused the interaction.",
315
+ );
316
+ }
317
+ return;
318
+ }
319
+
320
+ assertHarnessTargetOnly(
321
+ bundle.harnessVerified,
322
+ "HARNESS DIFFERENTIAL FAILED",
323
+ "no machine-owned target/control replay was recorded",
324
+ );
325
+ }
326
+
327
+ function assertHarnessTargetOnly(
328
+ harness: HarnessVerifyResult | undefined,
329
+ label: string,
330
+ missingNote: string,
331
+ ): asserts harness is HarnessVerifyResult {
332
+ if (
333
+ !harness?.attempted ||
334
+ harness.pass !== true ||
335
+ harness.differential !== "target_only" ||
336
+ harness.target?.matched !== true ||
337
+ harness.control?.matched !== false
338
+ ) {
339
+ throw new Error(`${label}: ${harness?.note ?? missingNote}`);
340
+ }
341
+ }
342
+
343
+ function assertHarnessCanary(
344
+ harness: HarnessVerifyResult | undefined,
345
+ required: boolean,
346
+ label: string,
347
+ ): void {
348
+ if (!required) return;
349
+ if (
350
+ harness?.canary?.attempted !== true ||
351
+ harness.canary.pass !== true ||
352
+ harness.canary.targetObserved !== true ||
353
+ harness.canary.controlObserved !== false ||
354
+ harness.proofStrength !== "canary_differential"
355
+ ) {
356
+ throw new Error(`${label}: ${harness?.canary?.note ?? "required canary transcript missing"}`);
357
+ }
358
+ }
359
+
360
+ function assertMainAgentVerification(
361
+ bundle: PendingConfirmation,
362
+ verification: MainAgentVerification | undefined,
363
+ isIntra = false,
364
+ ): asserts verification is MainAgentVerification {
365
+ if (!verification) {
366
+ throw new Error(
367
+ "MAIN-AGENT REPLAY REQUIRED: ConfirmFinding must produce a fresh harness-owned target/control transcript",
368
+ );
369
+ }
370
+ const at = Date.parse(verification.at);
371
+ const bundleAt = Date.parse(bundle.ranAt);
372
+ const now = Date.now();
373
+ if (
374
+ !Number.isFinite(at) ||
375
+ !Number.isFinite(bundleAt) ||
376
+ at < bundleAt ||
377
+ at > now + 30_000 ||
378
+ now - at > 5 * 60 * 1000
379
+ ) {
380
+ throw new Error(
381
+ "MAIN-AGENT REPLAY FAILED: transcript timestamp must be valid, newer than phase 1, and no more than 5 minutes old",
382
+ );
383
+ }
384
+ assertHarnessTargetOnly(
385
+ verification.result,
386
+ "MAIN-AGENT REPLAY FAILED",
387
+ "no fresh phase-2 target/control replay was recorded",
388
+ );
389
+ assertHarnessCanary(
390
+ verification.result,
391
+ bundle.targetRuns[0].evidence.verify.canary !== undefined,
392
+ "MAIN-AGENT CANARY FAILED",
393
+ );
394
+ // OOB-only bundles bind by TOKEN identity (enforced at store time on
395
+ // evidence.verify.url); there is no control host to bind a transcript to.
396
+ if (bundle.callbackVerified?.attempted && bundle.oobTokens) return;
397
+ const targetUrl = verification.result.target?.url;
398
+ const controlUrl = verification.result.control?.url;
399
+ const targetIdentity = bundle.targetRuns[0].target;
400
+ if (!targetUrl || verifyUrlBindingError(targetUrl, targetIdentity)) {
401
+ throw new Error("MAIN-AGENT REPLAY FAILED: target transcript is not bound to the case target");
402
+ }
403
+ // Intra-target: the "control" transcript is the legitimate baseline request,
404
+ // which is bound to the SAME case target. Inter-host: it is bound to the
405
+ // distinct control target.
406
+ const controlBindTarget = isIntra ? targetIdentity : bundle.controlTarget;
407
+ if (!controlUrl || !controlBindTarget || verifyUrlBindingError(controlUrl, controlBindTarget)) {
408
+ throw new Error(
409
+ isIntra
410
+ ? "MAIN-AGENT REPLAY FAILED: baseline transcript is not bound to the case target"
411
+ : "MAIN-AGENT REPLAY FAILED: control transcript is not bound to control_target",
412
+ );
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Gate for phase 1 of promotion: case must exist, be investigating, and have
418
+ * poc/evidence/impact/severity/target. The disconfirmation is provided by the
419
+ * main agent at confirm time, so it is NOT a precondition here. Returns the
420
+ * record when promotable, throws otherwise. Exported so PromoteFinding can
421
+ * validate BEFORE paying for (potentially slow) sandboxed PoC runs.
422
+ */
423
+ export function assertPromotable(id: string): CaseRecord {
424
+ const current = getCaseById(id);
425
+ if (!current) {
426
+ throw new Error(`Case not found: ${id}`);
427
+ }
428
+ if (current.status !== "investigating") {
429
+ throw new Error(`PromoteFinding requires an investigating case (current: ${current.status})`);
430
+ }
431
+ if (!current.poc) {
432
+ throw new Error("CONFIRMED requires poc; set poc on the case first");
433
+ }
434
+ if (!current.evidence) {
435
+ throw new Error("CONFIRMED requires evidence; set evidence on the case first");
436
+ }
437
+ if (!current.impact) {
438
+ throw new Error("CONFIRMED requires impact; set impact on the case first");
439
+ }
440
+ if (!current.severity) {
441
+ throw new Error("CONFIRMED requires severity; set severity on the case first");
442
+ }
443
+ if (!current.target) {
444
+ throw new Error(
445
+ "CONFIRMED requires target (what host/repo/scope this affects); set target on the case first",
446
+ );
447
+ }
448
+ // Evidence-chain closure: the observation item must be ARTIFACT-BACKED. A
449
+ // summary-only observation is agent prose about itself — promotion requires
450
+ // a real file with its SHA-256 as the initial signal. (The reproduction item
451
+ // is always artifact-backed: the gate writes it from the evidence hash.)
452
+ if (!current.evidenceItems?.some((e: EvidenceItem) => e.role === "observation" && e.sha256)) {
453
+ throw new Error(
454
+ "Evidence chain incomplete: CONFIRMED requires an artifact-backed observation evidence item " +
455
+ "(EvidenceAdd role=observation with artifact_path — the initial signal, stored as basename + SHA-256) " +
456
+ "in addition to the auto-recorded reproduction item. Add the artifact-backed observation item and retry promotion.",
457
+ );
458
+ }
459
+ return current;
460
+ }
461
+
462
+ /**
463
+ * Phase 1 (intra-target): validate a same-host attack-vs-baseline bundle. The
464
+ * differential is proven by the harness replay (attack matched, baseline did
465
+ * not, both against the case target), not by a separate control run — the
466
+ * discriminating variable is the request's identity or a parameter, not the host.
467
+ */
468
+ function validateIntraTargetBundle(
469
+ current: CaseRecord,
470
+ id: string,
471
+ bundle: PendingConfirmation,
472
+ ): CaseRecord {
473
+ if (bundle.targetRuns.length !== 2) {
474
+ throw new Error("Intra-target confirmation requires two target runs");
475
+ }
476
+ if (bundle.controlRun || bundle.controlTarget) {
477
+ throw new Error(
478
+ "Intra-target confirmation must not carry a control run or control target — the baseline is a same-host request inside the evidence",
479
+ );
480
+ }
481
+ const targetRunTarget = bundle.targetRuns[0]?.target;
482
+ if (!targetRunTarget || bundle.targetRuns.some((r) => r.target !== targetRunTarget)) {
483
+ throw new Error("Intra-target confirmation requires both runs against the same case target");
484
+ }
485
+ let pocHash: string | undefined;
486
+ try {
487
+ pocHash = createHash("sha256").update(readFileSync(bundle.pocPath)).digest("hex");
488
+ } catch {
489
+ pocHash = undefined;
490
+ }
491
+ if (!pocHash || (bundle.pocSha256 && bundle.pocSha256 !== pocHash)) {
492
+ throw new Error("pocSha256 does not match the PoC file on disk");
493
+ }
494
+ for (const run of bundle.targetRuns) {
495
+ validateRunEvidence(run, `${run.mode} run`);
496
+ const ev = run.evidence;
497
+ if (ev.verify.mode !== "intra_target") {
498
+ throw new Error(
499
+ "INTRA-TARGET FAILED: each run's evidence.verify.mode must be 'intra_target'",
500
+ );
501
+ }
502
+ if (!ev.baseline) {
503
+ throw new Error(
504
+ "INTRA-TARGET FAILED: evidence.baseline (a legitimate same-host request) is required",
505
+ );
506
+ }
507
+ const attackBinding = verifyUrlBindingError(ev.verify.url, targetRunTarget);
508
+ if (attackBinding) throw new Error(`ATTACK BINDING FAILED: ${attackBinding}`);
509
+ const baselineBinding = verifyUrlBindingError(ev.baseline.url, targetRunTarget);
510
+ if (baselineBinding) throw new Error(`BASELINE BINDING FAILED: ${baselineBinding}`);
511
+ if (ev.baseline && sameRequest(ev.verify, ev.baseline)) {
512
+ throw new Error(
513
+ "INTRA-TARGET FAILED: attack and baseline requests are identical — vary identity or a parameter",
514
+ );
515
+ }
516
+ }
517
+ if (bundle.caseId !== id) throw new Error("Pending confirmation caseId mismatch");
518
+ assertEvidenceDifferential(bundle, true);
519
+ // Machine floor: attack matched, baseline did not, both against the case target.
520
+ assertMachineConfirmation(bundle);
521
+ assertHarnessCanary(
522
+ bundle.harnessVerified,
523
+ bundle.targetRuns[0].evidence.verify.canary !== undefined,
524
+ "PHASE-1 CANARY FAILED",
525
+ );
526
+ const next = buildRecord({ pendingConfirmation: bundle }, current);
527
+ validateCase(next);
528
+ return next;
529
+ }
530
+
531
+ /**
532
+ * Phase 1: record the harness-observed evidence bundle on the case. The whole
533
+ * contract is validated here — same-file control, nonce binding, run
534
+ * completion, determinism across the two target runs, and the target/control
535
+ * differential — so a bundle that cannot promote is rejected before the
536
+ * main agent performs phase-2 review.
537
+ */
538
+ export function storePendingConfirmation(id: string, bundle: PendingConfirmation): CaseRecord {
539
+ const db = getDb();
540
+ return withImmediateTransaction(db, () => {
541
+ const current = getCaseById(id);
542
+ if (!current) throw new Error(`Case not found: ${id}`);
543
+ if (current.status !== "investigating") {
544
+ throw new Error(
545
+ `Pending confirmation requires an investigating case (current: ${current.status})`,
546
+ );
547
+ }
548
+ if (bundle.caseId !== id) throw new Error("Pending confirmation caseId mismatch");
549
+ // Panel vote shape is machine-checked at store time — a malformed panel
550
+ // must never silently count toward a quorum later.
551
+ if (bundle.panelVotes !== undefined) {
552
+ const votes = validatePanelVotes(bundle.panelVotes);
553
+ if (!votes.ok) throw new Error(`Pending confirmation panel invalid: ${votes.error}`);
554
+ }
555
+ if (bundle.mode === "intra_target") {
556
+ const next = validateIntraTargetBundle(current, id, bundle);
557
+ upsertCase(db, next);
558
+ appendCaseEvent(db, {
559
+ actor: "harness",
560
+ caseId: id,
561
+ eventType: "promotion_pending",
562
+ payload: { mode: "intra_target", evidence_sha256: bundle.targetRuns[0].evidenceSha256 },
563
+ });
564
+ return next;
565
+ }
566
+ // Control-run requirements key off controlRun PRESENCE, not the OOB flag:
567
+ // an OOB-only bundle has no control run (token differential instead), but
568
+ // an OOB+control bundle still carries one and gets the full checks.
569
+ if (!bundle.controlRun && !bundle.callbackVerified) {
570
+ throw new Error("Pending confirmation requires two target runs and one control run");
571
+ }
572
+ if (bundle.controlRun && (!bundle.pocPath || !bundle.controlPath || !bundle.controlTarget)) {
573
+ throw new Error("Pending confirmation requires pocPath, controlPath, and controlTarget");
574
+ }
575
+ // Control-target binding (machine-verified here, not just in the tool
576
+ // layer): the control run must actually have targeted the declared
577
+ // control_target, that target must differ from the target runs' target,
578
+ // and the control target must differ from the case's target — otherwise
579
+ // "the control demonstrated nothing on the vulnerable target" passes.
580
+ const targetRunTarget = bundle.targetRuns[0]?.target;
581
+ if (!targetRunTarget || bundle.targetRuns.some((r) => r.target !== targetRunTarget)) {
582
+ throw new Error(
583
+ "Pending confirmation requires both target runs against the same case target",
584
+ );
585
+ }
586
+ if (bundle.controlRun) {
587
+ if (!bundle.controlRun.target || bundle.controlRun.target !== bundle.controlTarget) {
588
+ throw new Error(
589
+ "CONTROL BINDING FAILED: controlRun.target must equal control_target — a control run " +
590
+ "against a different host than the one declared proves nothing.",
591
+ );
592
+ }
593
+ if (bundle.controlRun.target === targetRunTarget) {
594
+ throw new Error(
595
+ "CONTROL BINDING FAILED: the control run targeted the same host as the target runs — " +
596
+ "the claimed impact is not target-dependent.",
597
+ );
598
+ }
599
+ }
600
+ if (bundle.controlTarget && bundle.controlTarget === current.target) {
601
+ throw new Error(
602
+ "CONTROL BINDING FAILED: control_target must differ from the case target; a control run " +
603
+ "against the vulnerable target proves nothing.",
604
+ );
605
+ }
606
+ // Same-file contract re-checked at store time (the tool already checked).
607
+ // OOB-only bundles carry no separate control script — the PoC hash alone
608
+ // is re-verified against the file on disk.
609
+ let pocHash: string | undefined;
610
+ let controlHash: string | undefined;
611
+ try {
612
+ pocHash = createHash("sha256").update(readFileSync(bundle.pocPath)).digest("hex");
613
+ controlHash = bundle.controlPath
614
+ ? createHash("sha256").update(readFileSync(bundle.controlPath)).digest("hex")
615
+ : pocHash;
616
+ } catch {
617
+ pocHash = undefined;
618
+ controlHash = undefined;
619
+ }
620
+ if (!pocHash || !controlHash || pocHash !== controlHash) {
621
+ throw new Error(
622
+ "CONTROL CHECK FAILED: control_path must be the SAME script as poc_path " +
623
+ "(sha256 mismatch). A separately written control file proves nothing.",
624
+ );
625
+ }
626
+ if (bundle.pocSha256 && bundle.pocSha256 !== pocHash) {
627
+ throw new Error("pocSha256 does not match the PoC file on disk");
628
+ }
629
+ // Validate every run that exists — OOB-only bundles have no control run;
630
+ // OOB+control bundles validate all three.
631
+ const runsToValidate = bundle.controlRun
632
+ ? [...bundle.targetRuns, bundle.controlRun]
633
+ : [...bundle.targetRuns];
634
+ for (const run of runsToValidate) {
635
+ validateRunEvidence(run, `${run.mode} run`);
636
+ }
637
+ assertEvidenceDifferential(bundle);
638
+ // Target binding applies to EVERY mode — an OOB bundle's verify.url must
639
+ // still belong to the case target, or the PoC could anchor its evidence on
640
+ // an unrelated host while the callback alone carries the proof.
641
+ for (const run of bundle.targetRuns) {
642
+ const bindingError = verifyUrlBindingError(run.evidence.verify.url, targetRunTarget);
643
+ if (bindingError) throw new Error(`TARGET BINDING FAILED: ${bindingError}`);
644
+ }
645
+ if (bundle.controlRun) {
646
+ const controlBindingError = verifyUrlBindingError(
647
+ bundle.controlRun.evidence.verify.url,
648
+ bundle.controlTarget!,
649
+ );
650
+ if (controlBindingError) {
651
+ throw new Error(`CONTROL BINDING FAILED: ${controlBindingError}`);
652
+ }
653
+ }
654
+ // A clean exit and model-authored evidence are necessary inputs, never the
655
+ // proof. Promotion requires a harness-observed target/control differential
656
+ // or a harness-owned OOB interaction differential.
657
+ assertMachineConfirmation(bundle);
658
+
659
+ const next = buildRecord({ pendingConfirmation: bundle }, current);
660
+ validateCase(next);
661
+ upsertCase(db, next);
662
+ appendCaseEvent(db, {
663
+ actor: "harness",
664
+ caseId: id,
665
+ eventType: "promotion_pending",
666
+ payload: {
667
+ mode: bundle.callbackVerified?.attempted ? "oob" : "inter_host",
668
+ evidence_sha256: bundle.targetRuns[0].evidenceSha256,
669
+ },
670
+ });
671
+ return next;
672
+ });
673
+ }
674
+
675
+ /**
676
+ * Phase 2: commit (or refuse) the promotion on the main agent's verdict.
677
+ *
678
+ * CONFIRMED requires the full bundle to still hold (completion, nonce,
679
+ * determinism, differential), the PoC script to be unchanged since the runs
680
+ * (pocSha256 — otherwise the main agent reviewed different bytes), and a
681
+ * verdict accompanied by a fresh harness-owned target-only replay, a concrete
682
+ * review note, and a disconfirmation attempt. NOT_CONFIRMED (positively
683
+ * disproved) and INCONCLUSIVE (neither reproduced nor disproved) both record
684
+ * the verdict and keep the case investigating — INCONCLUSIVE preserves it for
685
+ * manual review rather than dropping it.
686
+ */
687
+ export function applyConfirmationResult(
688
+ id: string,
689
+ verdictInput: MainAgentVerdict,
690
+ phase2Verification?: MainAgentVerification,
691
+ authority: { startedAsSubagent: boolean } = {
692
+ startedAsSubagent: PROCESS_STARTED_AS_SUBAGENT || process.env.PI_SUBAGENT_CHILD === "1",
693
+ },
694
+ ): CaseUpdateResult {
695
+ if (authority.startedAsSubagent) {
696
+ throw new Error(
697
+ "ConfirmFinding is reserved for the main/coordinator agent; worker processes cannot commit confirmation",
698
+ );
699
+ }
700
+ const db = getDb();
701
+ return withImmediateTransaction(db, () => {
702
+ const current = getCaseById(id);
703
+ if (!current) throw new Error(`Case not found: ${id}`);
704
+ if (current.status !== "investigating") {
705
+ throw new Error(`ConfirmFinding requires an investigating case (current: ${current.status})`);
706
+ }
707
+ const bundle = current.pendingConfirmation;
708
+ if (!bundle) {
709
+ throw new Error("No pending confirmation on this case — run PromoteFinding first");
710
+ }
711
+ // Fail closed on an unparseable ranAt: Date.parse(garbage) is NaN, and
712
+ // NaN > TTL is false — a malformed timestamp must NOT make the bundle
713
+ // immortal. Treat it as expired (re-run PromoteFinding for a fresh one).
714
+ const ranAtMs = Date.parse(bundle.ranAt);
715
+ if (!Number.isFinite(ranAtMs) || Date.now() - ranAtMs > PENDING_CONFIRM_TTL_MS) {
716
+ throw new Error(
717
+ "Pending confirmation expired or has an invalid timestamp (1h TTL) — re-run PromoteFinding for a fresh bundle",
718
+ );
719
+ }
720
+ const parsed = validateMainAgentVerdict(verdictInput);
721
+ if (!parsed.ok) throw new Error(`Invalid main-agent confirmation verdict: ${parsed.error}`);
722
+ const verdict = parsed.verdict;
723
+ const canaryRequested = bundle.targetRuns[0].evidence.verify.canary !== undefined;
724
+ if (verdict.verdict === "CONFIRMED") {
725
+ if (canaryRequested && verdict.canary_assessment !== "verified") {
726
+ throw new Error(
727
+ "CONFIRMED canary mismatch: evidence requested a harness canary, so canary_assessment must be verified",
728
+ );
729
+ }
730
+ if (!canaryRequested && verdict.canary_assessment !== "not_applicable") {
731
+ throw new Error(
732
+ "CONFIRMED canary mismatch: this evidence has no canary template; record canary_assessment=not_applicable and explain why",
733
+ );
734
+ }
735
+ // Quorum panel pre-gate: CONFIRMED needs a 2/3 exploit panel or an
736
+ // explicit override note recording why the panel was skipped (or
737
+ // overruled). Votes are advisory — the main agent still commits — but a
738
+ // non-quorum CONFIRMED without a note is refused.
739
+ const quorum = panelQuorumReached(bundle.panelVotes);
740
+ if (!quorum.quorum && !verdict.panel_override_note?.trim()) {
741
+ throw new Error(
742
+ `PANEL QUORUM REQUIRED: CONFIRMED needs either a 2/3 exploit panel (got ${quorum.exploit} exploit / ${quorum.total} vote(s)) ` +
743
+ "or an explicit panel_override_note recording why the panel was skipped or overruled. " +
744
+ "Re-run PromoteFinding with panel_votes, or justify the solo confirmation in panel_override_note.",
745
+ );
746
+ }
747
+ }
748
+ const recorded: MainAgentVerdictRecord = {
749
+ ...verdict,
750
+ at: new Date().toISOString(),
751
+ reviewer: "main_agent",
752
+ phase2Verification: verdict.verdict === "CONFIRMED" ? phase2Verification : undefined,
753
+ proofStrength:
754
+ verdict.verdict === "CONFIRMED"
755
+ ? canaryRequested
756
+ ? "canary_differential"
757
+ : "predicate_differential"
758
+ : undefined,
759
+ };
760
+
761
+ if (verdict.verdict !== "CONFIRMED") {
762
+ // NOT_CONFIRMED (positively disproved) and INCONCLUSIVE (neither reproduced
763
+ // nor disproved) both record the verdict, consume the attempt, and keep the
764
+ // case investigating — neither auto-kills. INCONCLUSIVE is the fail-safe:
765
+ // the finding is preserved for manual review, not dropped.
766
+ const model = verdict.model ? ` (${verdict.model})` : "";
767
+ const note =
768
+ verdict.verdict === "INCONCLUSIVE"
769
+ ? `main agent INCONCLUSIVE${model}: ${verdict.reasoning} — preserved for manual review, not disproved`
770
+ : `main agent NOT_CONFIRMED${model}: ${verdict.reasoning}`;
771
+ const next = buildRecord(
772
+ {
773
+ confirmerVerdict: recorded,
774
+ pendingConfirmation: undefined,
775
+ assumptions: [...(current.assumptions ?? []), note],
776
+ },
777
+ current,
778
+ );
779
+ // buildRecord's nullish fallback preserves the old value; consume the
780
+ // rejected attempt explicitly so a retry must produce fresh evidence.
781
+ next.pendingConfirmation = undefined;
782
+ validateCase(next);
783
+ upsertCase(db, next);
784
+ appendCaseEvent(db, {
785
+ caseId: id,
786
+ actor: "main_agent",
787
+ eventType: "confirmation_verdict",
788
+ payload: { verdict: verdict.verdict, model: verdict.model ?? null },
789
+ });
790
+ return { record: next, changed: true };
791
+ }
792
+
793
+ // CONFIRMED — re-validate the whole bundle (defense in depth; the case may
794
+ // have been touched between phase 1 and the verdict).
795
+ const isIntra = bundle.mode === "intra_target";
796
+ const allRuns = isIntra
797
+ ? [...bundle.targetRuns]
798
+ : [...bundle.targetRuns, ...(bundle.controlRun ? [bundle.controlRun] : [])];
799
+ for (const run of allRuns) {
800
+ validateRunEvidence(run, `${run.mode} run`);
801
+ }
802
+ assertEvidenceDifferential(bundle, isIntra);
803
+ assertMachineConfirmation(bundle);
804
+ assertHarnessCanary(bundle.harnessVerified, canaryRequested, "PHASE-1 CANARY FAILED");
805
+ let pocHash: string | undefined;
806
+ try {
807
+ pocHash = createHash("sha256").update(readFileSync(bundle.pocPath)).digest("hex");
808
+ } catch {
809
+ pocHash = undefined;
810
+ }
811
+ if (!pocHash || pocHash !== bundle.pocSha256) {
812
+ throw new Error(
813
+ "PoC script changed since the runs — re-run PromoteFinding (the main agent must review the exact bytes that ran)",
814
+ );
815
+ }
816
+ // The case target must still be the host the PoC ran against, and still
817
+ // differ from the control target. The evidence proves nothing about a
818
+ // target the case adopted after the runs.
819
+ const targetRun = bundle.targetRuns[0];
820
+ if (!current.target || current.target !== targetRun.target) {
821
+ throw new Error(
822
+ "Case target changed since the PoC runs — re-run PromoteFinding against the current target " +
823
+ `(bundle target: ${targetRun.target}, case target: ${current.target ?? "(none)"}).`,
824
+ );
825
+ }
826
+ if (!isIntra && current.target === bundle.controlTarget) {
827
+ throw new Error(
828
+ "Case target now equals the control target — the claimed impact is not target-dependent; " +
829
+ "re-run PromoteFinding with a distinct control_target.",
830
+ );
831
+ }
832
+
833
+ // The observation must predate the repro (provenance guard).
834
+ const observation = current.evidenceItems?.find(
835
+ (e: EvidenceItem) => e.role === "observation" && e.sha256,
836
+ );
837
+ if (observation && observation.createdAt > bundle.targetRuns[0].ranAt) {
838
+ throw new Error(
839
+ "Evidence chain invalid: the observation item was recorded after the PoC ran " +
840
+ `(${observation.createdAt} > ${bundle.targetRuns[0].ranAt}). The observation must predate the repro.`,
841
+ );
842
+ }
843
+
844
+ // Phase 1 proves the evidence floor. Phase 2 must freshly replay that same
845
+ // request inside the main agent's ConfirmFinding call; a caller-provided
846
+ // boolean is not accepted as proof of re-execution.
847
+ assertMainAgentVerification(bundle, phase2Verification, isIntra);
848
+
849
+ const reproductionItem: EvidenceItem = {
850
+ id: `ev_${stableShortId(`${id}\nreproduction\n${targetRun.ranAt}`)}`,
851
+ caseId: id,
852
+ role: "reproduction",
853
+ // The runner preserves each run's evidence.json in a durable dir
854
+ // (.pi/poc-evidence/) — the artifact the hash was computed over still
855
+ // exists, so the item stays artifact-backed and re-verifiable.
856
+ artifactPath: targetRun.evidencePath ? basename(targetRun.evidencePath) : "evidence.json",
857
+ sha256: targetRun.evidenceSha256,
858
+ summary: `PoC evidence accepted (2 target runs + ${isIntra ? "same-host baseline" : "control"}; ${recorded.proofStrength}) — main agent semantic confirmation${verdict.model ? ` (${verdict.model})` : ""}`,
859
+ createdAt: targetRun.ranAt,
860
+ };
861
+ // Defense in depth: the run's evidence.json may embed secrets in
862
+ // observations/claim text — flag it like any other artifact.
863
+ if (targetRun.evidencePath) {
864
+ try {
865
+ const secretFindings = scanArtifactForSecrets(
866
+ readWorkspaceArtifact(targetRun.evidencePath).bytes,
867
+ );
868
+ if (secretFindings.length > 0) {
869
+ reproductionItem.containsSecret = true;
870
+ reproductionItem.secretFindings = secretFindings;
871
+ }
872
+ } catch {
873
+ // validateRunEvidence already proved the artifact readable; a scan
874
+ // failure never blocks the confirmation itself.
875
+ }
876
+ }
877
+
878
+ const newEvidence =
879
+ (current.evidence ? `${current.evidence}\n\n` : "") +
880
+ `### PoC Execution Capture (${targetRun.ranAt})\n` +
881
+ `- **Evidence sha256:** ${targetRun.evidenceSha256}\n` +
882
+ `- **Target:** ${targetRun.target}\n` +
883
+ `- **Machine evidence:** ${recorded.proofStrength} (a differential is not by itself proof of exploitation)\n` +
884
+ `- **Main-agent reviewer:** ${verdict.model ?? "unknown model"} — semantic confirmation\n` +
885
+ `#### Target Run Output\n\`\`\`\n${targetRun.output ?? ""}\n\`\`\``;
886
+
887
+ const update: NormalizedCaseInput = {
888
+ status: "confirmed",
889
+ pocVerified: {
890
+ path: bundle.pocPath,
891
+ exitCode: targetRun.exitCode,
892
+ ranAt: targetRun.ranAt,
893
+ output: targetRun.output,
894
+ sandbox: targetRun.sandbox,
895
+ completed: true,
896
+ outputComplete: true,
897
+ mode: "poc",
898
+ target: targetRun.target,
899
+ },
900
+ controlVerified:
901
+ isIntra || !bundle.controlRun
902
+ ? {
903
+ path: bundle.pocPath,
904
+ exitCode: targetRun.exitCode,
905
+ ranAt: targetRun.ranAt,
906
+ output: `intra-target baseline (same host): ${bundle.harnessVerified?.control?.note ?? "baseline did not satisfy the attack predicate"}`,
907
+ sandbox: targetRun.sandbox,
908
+ completed: true,
909
+ outputComplete: true,
910
+ mode: "baseline",
911
+ target: targetRun.target,
912
+ }
913
+ : {
914
+ path: bundle.controlPath ?? bundle.pocPath,
915
+ exitCode: bundle.controlRun.exitCode,
916
+ ranAt: bundle.controlRun.ranAt,
917
+ output: bundle.controlRun.output,
918
+ sandbox: bundle.controlRun.sandbox,
919
+ completed: true,
920
+ outputComplete: true,
921
+ mode: "control",
922
+ target: bundle.controlRun.target,
923
+ },
924
+ disconfirmation: verdict.disconfirmation_attempt,
925
+ confirmerVerdict: recorded,
926
+ pendingConfirmation: undefined,
927
+ evidence: newEvidence,
928
+ };
929
+
930
+ const next = buildRecord(update, current);
931
+ next.pendingConfirmation = undefined; // buildRecord's ?? existing keeps it; clear explicitly
932
+ validateCase(next);
933
+ insertEvidenceItem(db, reproductionItem);
934
+ upsertCase(db, next);
935
+ appendCaseEvent(db, {
936
+ caseId: id,
937
+ actor: "main_agent",
938
+ eventType: "case_confirmed",
939
+ payload: {
940
+ verdict: "CONFIRMED",
941
+ proof_strength: recorded.proofStrength ?? null,
942
+ model: verdict.model ?? null,
943
+ panel: panelQuorumReached(bundle.panelVotes),
944
+ override: verdict.panel_override_note ? true : false,
945
+ reproduction_evidence_id: reproductionItem.id,
946
+ },
947
+ });
948
+ next.evidenceItems = [...(next.evidenceItems ?? []), reproductionItem];
949
+ return { record: next, changed: true };
950
+ });
951
+ }