@xaccefy/pi-casefile 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,18 +6,19 @@
6
6
  * one readable module. ledger.ts re-exports every public symbol here, so
7
7
  * callers and tests are unchanged.
8
8
  *
9
- * The gate's contract (docs/confirmation-design.md):
9
+ * The gate's contract:
10
10
  * - Zero exit + complete output capture = run integrity, never proof.
11
11
  * - Evidence must be nonce-bound, schema-valid, carry a discriminating
12
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.
13
+ * - Target-dependence requires a machine differential: the attack request
14
+ * must satisfy the predicate while a legitimate same-host baseline request
15
+ * (declared in the evidence) must not.
16
+ * - Only the main agent commits the phase-2 verdict; only the ledger can
17
+ * transition a case to confirmed.
17
18
  */
18
19
 
19
20
  import { createHash } from "node:crypto";
20
- import { readFileSync } from "node:fs";
21
+ import { lstatSync, readFileSync, statSync } from "node:fs";
21
22
 
22
23
  import { basename } from "node:path";
23
24
 
@@ -26,6 +27,7 @@ import {
26
27
  type MainAgentVerdict,
27
28
  normalizeEvidence,
28
29
  parsePoCEvidence,
30
+ scanArtifactForSecrets,
29
31
  validateMainAgentVerdict,
30
32
  } from "./evidence.ts";
31
33
  import { type HarnessVerifyResult, sameRequest, verifyUrlBindingError } from "./harness-verify.ts";
@@ -34,16 +36,17 @@ import type {
34
36
  CaseUpdateResult,
35
37
  EvidenceItem,
36
38
  MainAgentVerdictRecord,
37
- MainAgentVerification,
38
39
  NormalizedCaseInput,
39
40
  PendingConfirmation,
40
41
  PocEvidenceRun,
41
42
  } from "./ledger.ts";
42
43
  import { getCaseById, readWorkspaceArtifact } from "./ledger.ts";
43
44
  import {
45
+ appendCaseEvent,
44
46
  buildRecord,
45
47
  getDb,
46
48
  insertEvidenceItem,
49
+ stableShortId,
47
50
  upsertCase,
48
51
  validateCase,
49
52
  withImmediateTransaction,
@@ -58,10 +61,159 @@ const PROCESS_STARTED_AS_SUBAGENT = process.env.PI_SUBAGENT_CHILD === "1";
58
61
  /** Pending confirmation expires after 1h — re-run PromoteFinding for a fresh bundle. */
59
62
  export const PENDING_CONFIRM_TTL_MS = 60 * 60 * 1000;
60
63
 
61
- // Re-declared here as narrow internal helpers; ledger.ts keeps the shared copies.
62
- function stableShortId(input: string): string {
63
- return createHash("sha1").update(input).digest("hex").slice(0, 10);
64
+ // ── Report contract gate (confirmed reported) ──────────────────────
65
+
66
+ /** Typed, fail-closed error for an invalid report contract. */
67
+ export class ReportContractError extends Error {
68
+ readonly code = "REPORT_CONTRACT_INVALID";
69
+ readonly violations: string[];
70
+
71
+ constructor(violations: string[]) {
72
+ super(
73
+ `report contract invalid (${violations.length} violation(s)):\n- ${violations.join("\n- ")}`,
74
+ );
75
+ this.name = "ReportContractError";
76
+ this.violations = violations;
77
+ }
78
+ }
79
+
80
+ /** The closed-schema contract companion path for a report markdown path. */
81
+ export function reportContractPathFor(reportPath: string): string {
82
+ return reportPath.replace(/\.md$/i, ".contract.json");
64
83
  }
84
+
85
+ /** Hard cap on the contract document — it is metadata, not a report carrier. */
86
+ const REPORT_CONTRACT_MAX_BYTES = 64 * 1024;
87
+
88
+ /** Keys the closed schema accepts; anything else is a violation. */
89
+ const REPORT_CONTRACT_KEYS = new Set([
90
+ "case_id",
91
+ "title",
92
+ "severity",
93
+ "summary",
94
+ "impact",
95
+ "remediation",
96
+ "steps",
97
+ "evidence_ids",
98
+ "coverage_refs",
99
+ ]);
100
+
101
+ /**
102
+ * Validate the closed-schema report contract for a confirmed case:
103
+ * - a regular, non-symlink JSON file of bounded size exists at contractPath;
104
+ * - only schema keys are present, and the required text fields are non-empty;
105
+ * - evidence_ids reference ONLY evidence items that exist on this case, and
106
+ * include at least one observation and one reproduction item;
107
+ * - coverage_refs reference ONLY (asset, class) cells recorded on this case.
108
+ *
109
+ * Throws ReportContractError (fail closed) on any violation.
110
+ */
111
+ export function validateReportContract(record: CaseRecord, contractPath: string): void {
112
+ const violations: string[] = [];
113
+ let stat: ReturnType<typeof statSync>;
114
+ try {
115
+ stat = statSync(contractPath);
116
+ } catch {
117
+ throw new ReportContractError([
118
+ `report contract not found: ${basename(contractPath)} (write the closed-schema JSON contract next to the report, then retry status='reported')`,
119
+ ]);
120
+ }
121
+ if (!stat.isFile()) violations.push("report contract path is not a regular file");
122
+ if (lstatSync(contractPath).isSymbolicLink()) {
123
+ violations.push("report contract must not be a symbolic link");
124
+ }
125
+ if (stat.size > REPORT_CONTRACT_MAX_BYTES) {
126
+ violations.push(
127
+ `report contract too large (${stat.size} bytes; max ${REPORT_CONTRACT_MAX_BYTES})`,
128
+ );
129
+ }
130
+ if (violations.length > 0) throw new ReportContractError(violations);
131
+
132
+ let doc: unknown;
133
+ try {
134
+ doc = JSON.parse(readFileSync(contractPath, "utf8"));
135
+ } catch (e) {
136
+ throw new ReportContractError([`report contract is not valid JSON: ${(e as Error).message}`]);
137
+ }
138
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
139
+ throw new ReportContractError(["report contract must be a JSON object"]);
140
+ }
141
+ const contract = doc as Record<string, unknown>;
142
+ for (const key of Object.keys(contract)) {
143
+ if (!REPORT_CONTRACT_KEYS.has(key)) {
144
+ violations.push(`unknown key "${key}" — the report contract schema is closed`);
145
+ }
146
+ }
147
+ for (const required of ["case_id", "title", "severity", "summary", "impact", "remediation"]) {
148
+ const v = contract[required];
149
+ if (typeof v !== "string" || v.trim().length === 0) {
150
+ violations.push(`"${required}" must be a non-empty string`);
151
+ }
152
+ }
153
+ if (contract.case_id !== record.id) {
154
+ violations.push(`"case_id" must be ${record.id} (got ${String(contract.case_id)})`);
155
+ }
156
+ const SEVERITIES = ["info", "low", "medium", "high", "critical"];
157
+ if (
158
+ typeof contract.severity === "string" &&
159
+ !(record.severity
160
+ ? contract.severity === record.severity
161
+ : SEVERITIES.includes(contract.severity))
162
+ ) {
163
+ violations.push(
164
+ `"severity" must match the case severity (${record.severity ?? "unset"}) or be a valid severity`,
165
+ );
166
+ }
167
+ if (!Array.isArray(contract.steps) || contract.steps.length === 0) {
168
+ violations.push('"steps" must be a non-empty array of reproduction steps');
169
+ } else if (!contract.steps.every((s: unknown) => typeof s === "string" && s.trim().length > 0)) {
170
+ violations.push('"steps" entries must be non-empty strings');
171
+ }
172
+
173
+ const items = record.evidenceItems ?? [];
174
+ const knownIds = new Set(items.map((i) => i.id));
175
+ const evidenceIds = contract.evidence_ids;
176
+ if (!Array.isArray(evidenceIds) || evidenceIds.length === 0) {
177
+ violations.push('"evidence_ids" must be a non-empty array of evidence item ids');
178
+ } else {
179
+ if (!evidenceIds.every((id: unknown) => typeof id === "string" && knownIds.has(id))) {
180
+ violations.push('"evidence_ids" references evidence items that do not exist on this case');
181
+ }
182
+ const referenced = items.filter((i) => evidenceIds.includes(i.id));
183
+ if (!referenced.some((i) => i.role === "observation")) {
184
+ violations.push('"evidence_ids" must include at least one observation item');
185
+ }
186
+ if (!referenced.some((i) => i.role === "reproduction")) {
187
+ violations.push('"evidence_ids" must include at least one reproduction item');
188
+ }
189
+ }
190
+
191
+ const coverageRefs = contract.coverage_refs;
192
+ if (coverageRefs !== undefined && !Array.isArray(coverageRefs)) {
193
+ violations.push('"coverage_refs" must be an array of { asset, class } objects');
194
+ } else if (Array.isArray(coverageRefs)) {
195
+ const cells = new Set((record.coverageItems ?? []).map((c) => `${c.asset}\n${c.class}`));
196
+ for (const [index, ref] of coverageRefs.entries()) {
197
+ if (
198
+ typeof ref !== "object" ||
199
+ ref === null ||
200
+ typeof (ref as Record<string, unknown>).asset !== "string" ||
201
+ typeof (ref as Record<string, unknown>).class !== "string"
202
+ ) {
203
+ violations.push(`"coverage_refs[${index}]" must be an { asset, class } object`);
204
+ } else if (
205
+ !cells.has(`${(ref as { asset: string }).asset}\n${(ref as { class: string }).class}`)
206
+ ) {
207
+ violations.push(
208
+ `"coverage_refs[${index}]" references a coverage cell not recorded on this case`,
209
+ );
210
+ }
211
+ }
212
+ }
213
+
214
+ if (violations.length > 0) throw new ReportContractError(violations);
215
+ }
216
+
65
217
  function validateRunEvidence(run: PocEvidenceRun, label: string): void {
66
218
  if (!run.completed) {
67
219
  throw new Error(`${label} did not complete; a crash is not evidence`);
@@ -117,56 +269,21 @@ function validateRunEvidence(run: PocEvidenceRun, label: string): void {
117
269
  }
118
270
  }
119
271
 
120
- /** Determinism + differential on normalized evidence (nonce/observations stripped). */
121
- function assertEvidenceDifferential(bundle: PendingConfirmation, isIntra = false): void {
272
+ /** Determinism on normalized evidence (nonce/observations stripped). */
273
+ function assertEvidenceDifferential(bundle: PendingConfirmation): void {
122
274
  const [r1, r2] = bundle.targetRuns;
123
275
  if (normalizeEvidence(r1.evidence) !== normalizeEvidence(r2.evidence)) {
124
276
  throw new Error(
125
277
  "Target runs produced inconsistent evidence — the exploit did not reproduce deterministically",
126
278
  );
127
279
  }
128
- // Intra-target target-dependence is proven by the harness attack-vs-baseline
129
- // replay (same host), not by comparing a target run to a separate control run.
130
- if (isIntra) return;
131
- // OOB-only bundles prove target-dependence via the oracle token differential
132
- // (assertMachineConfirmation judges callbackVerified); no control run exists.
133
- if (!bundle.controlRun && bundle.callbackVerified?.attempted) return;
134
- if (!bundle.controlRun) {
135
- throw new Error("inter-host confirmation requires a control run");
136
- }
137
- if (normalizeEvidence(r1.evidence) === normalizeEvidence(bundle.controlRun.evidence)) {
138
- throw new Error(
139
- "Control run produced identical evidence to the target — the claimed impact is not target-dependent",
140
- );
141
- }
142
280
  }
143
281
 
144
282
  function assertMachineConfirmation(bundle: PendingConfirmation): void {
145
- const oob = bundle.callbackVerified;
146
- if (oob?.attempted) {
147
- if (oob.targetHits === 0) {
148
- throw new Error(
149
- `OOB VERIFY FAILED: no interaction with the target-run callback token. ${oob.note}`,
150
- );
151
- }
152
- if (oob.controlHits > 0) {
153
- throw new Error(
154
- `OOB VERIFY FAILED: the control-run callback token received ${oob.controlHits} interaction(s) — the callback is not target-dependent. ${oob.note}`,
155
- );
156
- }
157
- if (oob.sourceSeparated !== true) {
158
- throw new Error(
159
- "OOB VERIFY FAILED: callback source separation was not established. " +
160
- "A loopback listener reachable by the PoC is diagnostic telemetry, not proof that the target caused the interaction.",
161
- );
162
- }
163
- return;
164
- }
165
-
166
283
  assertHarnessTargetOnly(
167
284
  bundle.harnessVerified,
168
285
  "HARNESS DIFFERENTIAL FAILED",
169
- "no machine-owned target/control replay was recorded",
286
+ "no machine-owned attack/baseline replay was recorded",
170
287
  );
171
288
  }
172
289
 
@@ -186,79 +303,6 @@ function assertHarnessTargetOnly(
186
303
  }
187
304
  }
188
305
 
189
- function assertHarnessCanary(
190
- harness: HarnessVerifyResult | undefined,
191
- required: boolean,
192
- label: string,
193
- ): void {
194
- if (!required) return;
195
- if (
196
- harness?.canary?.attempted !== true ||
197
- harness.canary.pass !== true ||
198
- harness.canary.targetObserved !== true ||
199
- harness.canary.controlObserved !== false ||
200
- harness.proofStrength !== "canary_differential"
201
- ) {
202
- throw new Error(`${label}: ${harness?.canary?.note ?? "required canary transcript missing"}`);
203
- }
204
- }
205
-
206
- function assertMainAgentVerification(
207
- bundle: PendingConfirmation,
208
- verification: MainAgentVerification | undefined,
209
- isIntra = false,
210
- ): asserts verification is MainAgentVerification {
211
- if (!verification) {
212
- throw new Error(
213
- "MAIN-AGENT REPLAY REQUIRED: ConfirmFinding must produce a fresh harness-owned target/control transcript",
214
- );
215
- }
216
- const at = Date.parse(verification.at);
217
- const bundleAt = Date.parse(bundle.ranAt);
218
- const now = Date.now();
219
- if (
220
- !Number.isFinite(at) ||
221
- !Number.isFinite(bundleAt) ||
222
- at < bundleAt ||
223
- at > now + 30_000 ||
224
- now - at > 5 * 60 * 1000
225
- ) {
226
- throw new Error(
227
- "MAIN-AGENT REPLAY FAILED: transcript timestamp must be valid, newer than phase 1, and no more than 5 minutes old",
228
- );
229
- }
230
- assertHarnessTargetOnly(
231
- verification.result,
232
- "MAIN-AGENT REPLAY FAILED",
233
- "no fresh phase-2 target/control replay was recorded",
234
- );
235
- assertHarnessCanary(
236
- verification.result,
237
- bundle.targetRuns[0].evidence.verify.canary !== undefined,
238
- "MAIN-AGENT CANARY FAILED",
239
- );
240
- // OOB-only bundles bind by TOKEN identity (enforced at store time on
241
- // evidence.verify.url); there is no control host to bind a transcript to.
242
- if (bundle.callbackVerified?.attempted && bundle.oobTokens) return;
243
- const targetUrl = verification.result.target?.url;
244
- const controlUrl = verification.result.control?.url;
245
- const targetIdentity = bundle.targetRuns[0].target;
246
- if (!targetUrl || verifyUrlBindingError(targetUrl, targetIdentity)) {
247
- throw new Error("MAIN-AGENT REPLAY FAILED: target transcript is not bound to the case target");
248
- }
249
- // Intra-target: the "control" transcript is the legitimate baseline request,
250
- // which is bound to the SAME case target. Inter-host: it is bound to the
251
- // distinct control target.
252
- const controlBindTarget = isIntra ? targetIdentity : bundle.controlTarget;
253
- if (!controlUrl || !controlBindTarget || verifyUrlBindingError(controlUrl, controlBindTarget)) {
254
- throw new Error(
255
- isIntra
256
- ? "MAIN-AGENT REPLAY FAILED: baseline transcript is not bound to the case target"
257
- : "MAIN-AGENT REPLAY FAILED: control transcript is not bound to control_target",
258
- );
259
- }
260
- }
261
-
262
306
  /**
263
307
  * Gate for phase 1 of promotion: case must exist, be investigating, and have
264
308
  * poc/evidence/impact/severity/target. The disconfirmation is provided by the
@@ -306,27 +350,16 @@ export function assertPromotable(id: string): CaseRecord {
306
350
  }
307
351
 
308
352
  /**
309
- * Phase 1 (intra-target): validate a same-host attack-vs-baseline bundle. The
310
- * differential is proven by the harness replay (attack matched, baseline did
311
- * not, both against the case target), not by a separate control run the
312
- * discriminating variable is the request's identity or a parameter, not the host.
353
+ * Phase 1: validate a same-host attack-vs-baseline bundle. The differential is
354
+ * proven by the harness replay (attack matched, baseline did not, both against
355
+ * the case target) the discriminating variable is the request's identity or
356
+ * a parameter, not the host.
313
357
  */
314
- function validateIntraTargetBundle(
315
- current: CaseRecord,
316
- id: string,
317
- bundle: PendingConfirmation,
318
- ): CaseRecord {
319
- if (bundle.targetRuns.length !== 2) {
320
- throw new Error("Intra-target confirmation requires two target runs");
321
- }
322
- if (bundle.controlRun || bundle.controlTarget) {
323
- throw new Error(
324
- "Intra-target confirmation must not carry a control run or control target — the baseline is a same-host request inside the evidence",
325
- );
326
- }
358
+ function validateBundle(current: CaseRecord, id: string, bundle: PendingConfirmation): CaseRecord {
359
+ if (bundle.targetRuns.length !== 2) throw new Error("Confirmation requires two target runs");
327
360
  const targetRunTarget = bundle.targetRuns[0]?.target;
328
361
  if (!targetRunTarget || bundle.targetRuns.some((r) => r.target !== targetRunTarget)) {
329
- throw new Error("Intra-target confirmation requires both runs against the same case target");
362
+ throw new Error("Confirmation requires both runs against the same case target");
330
363
  }
331
364
  let pocHash: string | undefined;
332
365
  try {
@@ -340,35 +373,20 @@ function validateIntraTargetBundle(
340
373
  for (const run of bundle.targetRuns) {
341
374
  validateRunEvidence(run, `${run.mode} run`);
342
375
  const ev = run.evidence;
343
- if (ev.verify.mode !== "intra_target") {
344
- throw new Error(
345
- "INTRA-TARGET FAILED: each run's evidence.verify.mode must be 'intra_target'",
346
- );
347
- }
348
- if (!ev.baseline) {
349
- throw new Error(
350
- "INTRA-TARGET FAILED: evidence.baseline (a legitimate same-host request) is required",
351
- );
352
- }
353
376
  const attackBinding = verifyUrlBindingError(ev.verify.url, targetRunTarget);
354
377
  if (attackBinding) throw new Error(`ATTACK BINDING FAILED: ${attackBinding}`);
355
378
  const baselineBinding = verifyUrlBindingError(ev.baseline.url, targetRunTarget);
356
379
  if (baselineBinding) throw new Error(`BASELINE BINDING FAILED: ${baselineBinding}`);
357
380
  if (ev.baseline && sameRequest(ev.verify, ev.baseline)) {
358
381
  throw new Error(
359
- "INTRA-TARGET FAILED: attack and baseline requests are identical — vary identity or a parameter",
382
+ "BASELINE CHECK FAILED: attack and baseline requests are identical — vary identity or a parameter",
360
383
  );
361
384
  }
362
385
  }
363
386
  if (bundle.caseId !== id) throw new Error("Pending confirmation caseId mismatch");
364
- assertEvidenceDifferential(bundle, true);
387
+ assertEvidenceDifferential(bundle);
365
388
  // Machine floor: attack matched, baseline did not, both against the case target.
366
389
  assertMachineConfirmation(bundle);
367
- assertHarnessCanary(
368
- bundle.harnessVerified,
369
- bundle.targetRuns[0].evidence.verify.canary !== undefined,
370
- "PHASE-1 CANARY FAILED",
371
- );
372
390
  const next = buildRecord({ pendingConfirmation: bundle }, current);
373
391
  validateCase(next);
374
392
  return next;
@@ -376,9 +394,9 @@ function validateIntraTargetBundle(
376
394
 
377
395
  /**
378
396
  * Phase 1: record the harness-observed evidence bundle on the case. The whole
379
- * contract is validated here — same-file control, nonce binding, run
380
- * completion, determinism across the two target runs, and the target/control
381
- * differential — so a bundle that cannot promote is rejected before the
397
+ * contract is validated here — nonce binding, run completion, determinism
398
+ * across the two target runs, and the attack/baseline differential — so a
399
+ * bundle that cannot promote is rejected before the
382
400
  * main agent performs phase-2 review.
383
401
  */
384
402
  export function storePendingConfirmation(id: string, bundle: PendingConfirmation): CaseRecord {
@@ -392,107 +410,23 @@ export function storePendingConfirmation(id: string, bundle: PendingConfirmation
392
410
  );
393
411
  }
394
412
  if (bundle.caseId !== id) throw new Error("Pending confirmation caseId mismatch");
395
- if (bundle.mode === "intra_target") {
396
- const next = validateIntraTargetBundle(current, id, bundle);
397
- upsertCase(db, next);
398
- return next;
399
- }
400
- // Control-run requirements key off controlRun PRESENCE, not the OOB flag:
401
- // an OOB-only bundle has no control run (token differential instead), but
402
- // an OOB+control bundle still carries one and gets the full checks.
403
- if (!bundle.controlRun && !bundle.callbackVerified) {
404
- throw new Error("Pending confirmation requires two target runs and one control run");
405
- }
406
- if (bundle.controlRun && (!bundle.pocPath || !bundle.controlPath || !bundle.controlTarget)) {
407
- throw new Error("Pending confirmation requires pocPath, controlPath, and controlTarget");
408
- }
409
- // Control-target binding (machine-verified here, not just in the tool
410
- // layer): the control run must actually have targeted the declared
411
- // control_target, that target must differ from the target runs' target,
412
- // and the control target must differ from the case's target — otherwise
413
- // "the control demonstrated nothing on the vulnerable target" passes.
414
- const targetRunTarget = bundle.targetRuns[0]?.target;
415
- if (!targetRunTarget || bundle.targetRuns.some((r) => r.target !== targetRunTarget)) {
416
- throw new Error(
417
- "Pending confirmation requires both target runs against the same case target",
418
- );
419
- }
420
- if (bundle.controlRun) {
421
- if (!bundle.controlRun.target || bundle.controlRun.target !== bundle.controlTarget) {
422
- throw new Error(
423
- "CONTROL BINDING FAILED: controlRun.target must equal control_target — a control run " +
424
- "against a different host than the one declared proves nothing.",
425
- );
426
- }
427
- if (bundle.controlRun.target === targetRunTarget) {
428
- throw new Error(
429
- "CONTROL BINDING FAILED: the control run targeted the same host as the target runs — " +
430
- "the claimed impact is not target-dependent.",
431
- );
432
- }
433
- }
434
- if (bundle.controlTarget && bundle.controlTarget === current.target) {
435
- throw new Error(
436
- "CONTROL BINDING FAILED: control_target must differ from the case target; a control run " +
437
- "against the vulnerable target proves nothing.",
438
- );
439
- }
440
- // Same-file contract re-checked at store time (the tool already checked).
441
- // OOB-only bundles carry no separate control script — the PoC hash alone
442
- // is re-verified against the file on disk.
443
413
  let pocHash: string | undefined;
444
- let controlHash: string | undefined;
445
414
  try {
446
415
  pocHash = createHash("sha256").update(readFileSync(bundle.pocPath)).digest("hex");
447
- controlHash = bundle.controlPath
448
- ? createHash("sha256").update(readFileSync(bundle.controlPath)).digest("hex")
449
- : pocHash;
450
416
  } catch {
451
417
  pocHash = undefined;
452
- controlHash = undefined;
453
418
  }
454
- if (!pocHash || !controlHash || pocHash !== controlHash) {
455
- throw new Error(
456
- "CONTROL CHECK FAILED: control_path must be the SAME script as poc_path " +
457
- "(sha256 mismatch). A separately written control file proves nothing.",
458
- );
459
- }
460
- if (bundle.pocSha256 && bundle.pocSha256 !== pocHash) {
419
+ if (!pocHash || (bundle.pocSha256 && bundle.pocSha256 !== pocHash)) {
461
420
  throw new Error("pocSha256 does not match the PoC file on disk");
462
421
  }
463
- // Validate every run that exists — OOB-only bundles have no control run;
464
- // OOB+control bundles validate all three.
465
- const runsToValidate = bundle.controlRun
466
- ? [...bundle.targetRuns, bundle.controlRun]
467
- : [...bundle.targetRuns];
468
- for (const run of runsToValidate) {
469
- validateRunEvidence(run, `${run.mode} run`);
470
- }
471
- assertEvidenceDifferential(bundle);
472
- // Target binding applies to EVERY mode — an OOB bundle's verify.url must
473
- // still belong to the case target, or the PoC could anchor its evidence on
474
- // an unrelated host while the callback alone carries the proof.
475
- for (const run of bundle.targetRuns) {
476
- const bindingError = verifyUrlBindingError(run.evidence.verify.url, targetRunTarget);
477
- if (bindingError) throw new Error(`TARGET BINDING FAILED: ${bindingError}`);
478
- }
479
- if (bundle.controlRun) {
480
- const controlBindingError = verifyUrlBindingError(
481
- bundle.controlRun.evidence.verify.url,
482
- bundle.controlTarget!,
483
- );
484
- if (controlBindingError) {
485
- throw new Error(`CONTROL BINDING FAILED: ${controlBindingError}`);
486
- }
487
- }
488
- // A clean exit and model-authored evidence are necessary inputs, never the
489
- // proof. Promotion requires a harness-observed target/control differential
490
- // or a harness-owned OOB interaction differential.
491
- assertMachineConfirmation(bundle);
492
-
493
- const next = buildRecord({ pendingConfirmation: bundle }, current);
494
- validateCase(next);
422
+ const next = validateBundle(current, id, bundle);
495
423
  upsertCase(db, next);
424
+ appendCaseEvent(db, {
425
+ actor: "harness",
426
+ caseId: id,
427
+ eventType: "promotion_pending",
428
+ payload: { mode: "intra_target", evidence_sha256: bundle.targetRuns[0].evidenceSha256 },
429
+ });
496
430
  return next;
497
431
  });
498
432
  }
@@ -503,16 +437,14 @@ export function storePendingConfirmation(id: string, bundle: PendingConfirmation
503
437
  * CONFIRMED requires the full bundle to still hold (completion, nonce,
504
438
  * determinism, differential), the PoC script to be unchanged since the runs
505
439
  * (pocSha256 — otherwise the main agent reviewed different bytes), and a
506
- * verdict accompanied by a fresh harness-owned target-only replay, a concrete
507
- * review note, and a disconfirmation attempt. NOT_CONFIRMED (positively
508
- * disproved) and INCONCLUSIVE (neither reproduced nor disproved) both record
509
- * the verdict and keep the case investigating INCONCLUSIVE preserves it for
510
- * manual review rather than dropping it.
440
+ * verdict accompanied by a concrete review note and a disconfirmation attempt.
441
+ * NOT_CONFIRMED (positively disproved) and INCONCLUSIVE (neither reproduced
442
+ * nor disproved) both record the verdict and keep the case investigating —
443
+ * INCONCLUSIVE preserves it for manual review rather than dropping it.
511
444
  */
512
445
  export function applyConfirmationResult(
513
446
  id: string,
514
447
  verdictInput: MainAgentVerdict,
515
- phase2Verification?: MainAgentVerification,
516
448
  authority: { startedAsSubagent: boolean } = {
517
449
  startedAsSubagent: PROCESS_STARTED_AS_SUBAGENT || process.env.PI_SUBAGENT_CHILD === "1",
518
450
  },
@@ -545,30 +477,11 @@ export function applyConfirmationResult(
545
477
  const parsed = validateMainAgentVerdict(verdictInput);
546
478
  if (!parsed.ok) throw new Error(`Invalid main-agent confirmation verdict: ${parsed.error}`);
547
479
  const verdict = parsed.verdict;
548
- const canaryRequested = bundle.targetRuns[0].evidence.verify.canary !== undefined;
549
- if (verdict.verdict === "CONFIRMED") {
550
- if (canaryRequested && verdict.canary_assessment !== "verified") {
551
- throw new Error(
552
- "CONFIRMED canary mismatch: evidence requested a harness canary, so canary_assessment must be verified",
553
- );
554
- }
555
- if (!canaryRequested && verdict.canary_assessment !== "not_applicable") {
556
- throw new Error(
557
- "CONFIRMED canary mismatch: this evidence has no canary template; record canary_assessment=not_applicable and explain why",
558
- );
559
- }
560
- }
561
480
  const recorded: MainAgentVerdictRecord = {
562
481
  ...verdict,
563
482
  at: new Date().toISOString(),
564
483
  reviewer: "main_agent",
565
- phase2Verification: verdict.verdict === "CONFIRMED" ? phase2Verification : undefined,
566
- proofStrength:
567
- verdict.verdict === "CONFIRMED"
568
- ? canaryRequested
569
- ? "canary_differential"
570
- : "predicate_differential"
571
- : undefined,
484
+ proofStrength: verdict.verdict === "CONFIRMED" ? "predicate_differential" : undefined,
572
485
  };
573
486
 
574
487
  if (verdict.verdict !== "CONFIRMED") {
@@ -594,21 +507,22 @@ export function applyConfirmationResult(
594
507
  next.pendingConfirmation = undefined;
595
508
  validateCase(next);
596
509
  upsertCase(db, next);
510
+ appendCaseEvent(db, {
511
+ caseId: id,
512
+ actor: "main_agent",
513
+ eventType: "confirmation_verdict",
514
+ payload: { verdict: verdict.verdict, model: verdict.model ?? null },
515
+ });
597
516
  return { record: next, changed: true };
598
517
  }
599
518
 
600
519
  // CONFIRMED — re-validate the whole bundle (defense in depth; the case may
601
520
  // have been touched between phase 1 and the verdict).
602
- const isIntra = bundle.mode === "intra_target";
603
- const allRuns = isIntra
604
- ? [...bundle.targetRuns]
605
- : [...bundle.targetRuns, ...(bundle.controlRun ? [bundle.controlRun] : [])];
606
- for (const run of allRuns) {
521
+ for (const run of bundle.targetRuns) {
607
522
  validateRunEvidence(run, `${run.mode} run`);
608
523
  }
609
- assertEvidenceDifferential(bundle, isIntra);
524
+ assertEvidenceDifferential(bundle);
610
525
  assertMachineConfirmation(bundle);
611
- assertHarnessCanary(bundle.harnessVerified, canaryRequested, "PHASE-1 CANARY FAILED");
612
526
  let pocHash: string | undefined;
613
527
  try {
614
528
  pocHash = createHash("sha256").update(readFileSync(bundle.pocPath)).digest("hex");
@@ -620,9 +534,8 @@ export function applyConfirmationResult(
620
534
  "PoC script changed since the runs — re-run PromoteFinding (the main agent must review the exact bytes that ran)",
621
535
  );
622
536
  }
623
- // The case target must still be the host the PoC ran against, and still
624
- // differ from the control target. The evidence proves nothing about a
625
- // target the case adopted after the runs.
537
+ // The case target must still be the host the PoC ran against. The
538
+ // evidence proves nothing about a target the case adopted after the runs.
626
539
  const targetRun = bundle.targetRuns[0];
627
540
  if (!current.target || current.target !== targetRun.target) {
628
541
  throw new Error(
@@ -630,12 +543,6 @@ export function applyConfirmationResult(
630
543
  `(bundle target: ${targetRun.target}, case target: ${current.target ?? "(none)"}).`,
631
544
  );
632
545
  }
633
- if (!isIntra && current.target === bundle.controlTarget) {
634
- throw new Error(
635
- "Case target now equals the control target — the claimed impact is not target-dependent; " +
636
- "re-run PromoteFinding with a distinct control_target.",
637
- );
638
- }
639
546
 
640
547
  // The observation must predate the repro (provenance guard).
641
548
  const observation = current.evidenceItems?.find(
@@ -648,11 +555,6 @@ export function applyConfirmationResult(
648
555
  );
649
556
  }
650
557
 
651
- // Phase 1 proves the evidence floor. Phase 2 must freshly replay that same
652
- // request inside the main agent's ConfirmFinding call; a caller-provided
653
- // boolean is not accepted as proof of re-execution.
654
- assertMainAgentVerification(bundle, phase2Verification, isIntra);
655
-
656
558
  const reproductionItem: EvidenceItem = {
657
559
  id: `ev_${stableShortId(`${id}\nreproduction\n${targetRun.ranAt}`)}`,
658
560
  caseId: id,
@@ -662,9 +564,25 @@ export function applyConfirmationResult(
662
564
  // exists, so the item stays artifact-backed and re-verifiable.
663
565
  artifactPath: targetRun.evidencePath ? basename(targetRun.evidencePath) : "evidence.json",
664
566
  sha256: targetRun.evidenceSha256,
665
- summary: `PoC evidence accepted (2 target runs + ${isIntra ? "same-host baseline" : "control"}; ${recorded.proofStrength}) — main agent semantic confirmation${verdict.model ? ` (${verdict.model})` : ""}`,
567
+ summary: `PoC evidence accepted (2 target runs + same-host baseline; ${recorded.proofStrength}) — main agent semantic confirmation${verdict.model ? ` (${verdict.model})` : ""}`,
666
568
  createdAt: targetRun.ranAt,
667
569
  };
570
+ // Defense in depth: the run's evidence.json may embed secrets in
571
+ // observations/claim text — flag it like any other artifact.
572
+ if (targetRun.evidencePath) {
573
+ try {
574
+ const secretFindings = scanArtifactForSecrets(
575
+ readWorkspaceArtifact(targetRun.evidencePath).bytes,
576
+ );
577
+ if (secretFindings.length > 0) {
578
+ reproductionItem.containsSecret = true;
579
+ reproductionItem.secretFindings = secretFindings;
580
+ }
581
+ } catch {
582
+ // validateRunEvidence already proved the artifact readable; a scan
583
+ // failure never blocks the confirmation itself.
584
+ }
585
+ }
668
586
 
669
587
  const newEvidence =
670
588
  (current.evidence ? `${current.evidence}\n\n` : "") +
@@ -688,30 +606,17 @@ export function applyConfirmationResult(
688
606
  mode: "poc",
689
607
  target: targetRun.target,
690
608
  },
691
- controlVerified:
692
- isIntra || !bundle.controlRun
693
- ? {
694
- path: bundle.pocPath,
695
- exitCode: targetRun.exitCode,
696
- ranAt: targetRun.ranAt,
697
- output: `intra-target baseline (same host): ${bundle.harnessVerified?.control?.note ?? "baseline did not satisfy the attack predicate"}`,
698
- sandbox: targetRun.sandbox,
699
- completed: true,
700
- outputComplete: true,
701
- mode: "baseline",
702
- target: targetRun.target,
703
- }
704
- : {
705
- path: bundle.controlPath ?? bundle.pocPath,
706
- exitCode: bundle.controlRun.exitCode,
707
- ranAt: bundle.controlRun.ranAt,
708
- output: bundle.controlRun.output,
709
- sandbox: bundle.controlRun.sandbox,
710
- completed: true,
711
- outputComplete: true,
712
- mode: "control",
713
- target: bundle.controlRun.target,
714
- },
609
+ controlVerified: {
610
+ path: bundle.pocPath,
611
+ exitCode: targetRun.exitCode,
612
+ ranAt: targetRun.ranAt,
613
+ output: `same-host baseline: ${bundle.harnessVerified?.control?.note ?? "baseline did not satisfy the attack predicate"}`,
614
+ sandbox: targetRun.sandbox,
615
+ completed: true,
616
+ outputComplete: true,
617
+ mode: "baseline",
618
+ target: targetRun.target,
619
+ },
715
620
  disconfirmation: verdict.disconfirmation_attempt,
716
621
  confirmerVerdict: recorded,
717
622
  pendingConfirmation: undefined,
@@ -723,6 +628,17 @@ export function applyConfirmationResult(
723
628
  validateCase(next);
724
629
  insertEvidenceItem(db, reproductionItem);
725
630
  upsertCase(db, next);
631
+ appendCaseEvent(db, {
632
+ caseId: id,
633
+ actor: "main_agent",
634
+ eventType: "case_confirmed",
635
+ payload: {
636
+ verdict: "CONFIRMED",
637
+ proof_strength: recorded.proofStrength ?? null,
638
+ model: verdict.model ?? null,
639
+ reproduction_evidence_id: reproductionItem.id,
640
+ },
641
+ });
726
642
  next.evidenceItems = [...(next.evidenceItems ?? []), reproductionItem];
727
643
  return { record: next, changed: true };
728
644
  });