@xaccefy/pi-casefile 0.9.0 → 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,15 +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";
16
14
  import {
17
- type ConfirmerVerdict,
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 {
18
27
  evidenceNonceMatches,
28
+ type MainAgentVerdict,
19
29
  normalizeEvidence,
20
30
  type PoCEvidence,
21
- validateConfirmerVerdict,
31
+ parsePoCEvidence,
32
+ validateMainAgentVerdict,
22
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";
23
41
  import {
24
42
  findWorkspaceRoot,
25
43
  getScratchpadRoot,
@@ -53,6 +71,51 @@ export type CasePriority = (typeof PRIORITY_VALUES)[number];
53
71
 
54
72
  /** Cap on hashed evidence artifacts (10 MiB) — keeps readFileSync bounded. */
55
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
+ }
56
119
 
57
120
  /** Role-typed evidence roles (Black-cat style). cleanup = engagement cleanup item. */
58
121
  export const EVIDENCE_ROLE_VALUES = [
@@ -198,9 +261,9 @@ export type CaseRecord = {
198
261
  disconfirmationVerified?: PocVerificationRecord;
199
262
  /** Verification of a control-target run (set only by the confirmation gate). */
200
263
  controlVerified?: PocVerificationRecord;
201
- /** Phase-1 evidence bundle awaiting a confirmer verdict (ConfirmFinding). */
264
+ /** Phase-1 evidence bundle awaiting main-agent review (ConfirmFinding). */
202
265
  pendingConfirmation?: PendingConfirmation;
203
- /** Last confirmer verdict (CONFIRMED commits the promotion; NOT_CONFIRMED keeps investigating). */
266
+ /** Last phase-2 verdict (legacy field name retained for database compatibility). */
204
267
  confirmerVerdict?: ConfirmerVerdictRecord;
205
268
  /** ISO timestamp when CaseContext first wrote the context bundle. */
206
269
  reportedAt?: string;
@@ -239,7 +302,7 @@ export type PocEvidenceRun = {
239
302
  sandbox: boolean;
240
303
  completed: boolean;
241
304
  outputComplete: boolean;
242
- /** Display-sliced output (diagnostic; exit codes are not gates). */
305
+ /** Display-sliced output (diagnostic; zero exit is necessary, not proof). */
243
306
  output: string;
244
307
  evidence: PoCEvidence;
245
308
  evidenceSha256: string;
@@ -249,10 +312,16 @@ export type PocEvidenceRun = {
249
312
  evidencePath?: string;
250
313
  };
251
314
 
252
- /**
253
- * Phase-1 bundle PromoteFinding records; ConfirmFinding commits on a verdict.
254
- * Contains everything the confirmer reviews and the ledger re-checks.
255
- */
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
+
256
325
  export type PendingConfirmation = {
257
326
  caseId: string;
258
327
  ranAt: string;
@@ -263,10 +332,30 @@ export type PendingConfirmation = {
263
332
  controlTarget: string;
264
333
  targetRuns: [PocEvidenceRun, PocEvidenceRun];
265
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";
266
355
  };
267
356
 
268
- /** Persisted confirmer verdict with the commit timestamp. */
269
- export type ConfirmerVerdictRecord = ConfirmerVerdict & { at: string };
357
+ /** @deprecated Compatibility alias for the legacy database/API field name. */
358
+ export type ConfirmerVerdictRecord = MainAgentVerdictRecord;
270
359
 
271
360
  /** Pending confirmation expires after 1h — re-run PromoteFinding for a fresh bundle. */
272
361
  export const PENDING_CONFIRM_TTL_MS = 60 * 60 * 1000;
@@ -382,6 +471,101 @@ function detectWorkspaceRoot(): string {
382
471
  );
383
472
  }
384
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
+
385
569
  export function getCasefilePath(): string {
386
570
  if (ledgerPathOverride) return ledgerPathOverride;
387
571
  // Trim BEFORE the truthiness check: a whitespace-only value must not
@@ -410,11 +594,18 @@ function getDb(): DatabaseSync {
410
594
 
411
595
  const dbPath = getCasefilePath();
412
596
  const dbDir = dirname(dbPath);
413
- 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)) {
414
602
  try {
415
603
  mkdirSync(dbDir, { recursive: true });
416
604
  } catch {}
417
605
  }
606
+ for (const candidate of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
607
+ assertSafeRegularFile(candidate, "Casefile database state");
608
+ }
418
609
 
419
610
  const db = new DatabaseSync(dbPath);
420
611
  // Give parallel agents a short write wait instead of immediate SQLITE_BUSY.
@@ -554,6 +745,9 @@ function getDb(): DatabaseSync {
554
745
  db.exec(`CREATE INDEX IF NOT EXISTS idx_cases_priority ON cases(priority)`);
555
746
 
556
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);
557
751
  return db;
558
752
  }
559
753
 
@@ -786,9 +980,6 @@ function validateCase(record: CaseRecord): void {
786
980
  "Killed cases require evidence, next step, blockers, or assumptions explaining why",
787
981
  );
788
982
  }
789
- // A case becomes REPORTED only after the report FILE exists on disk (the
790
- // report writer writes it at the path CaseContext recorded). Require both
791
- // here so validation stays consistent with the confirmed→reported gate.
792
983
  // A case becomes REPORTED only after a report FILE that passes the content
793
984
  // gate exists on disk (the report writer writes it at the path CaseContext
794
985
  // recorded). Existence is not enough: any non-empty file — or a directory —
@@ -862,15 +1053,18 @@ const REPORT_REQUIRED_SECTIONS = ["summary", "impact", "remediation"];
862
1053
  * text (workflow.ts imports this) must not drift apart.
863
1054
  */
864
1055
  export const KILL_REASON_VALUES = [
1056
+ "unreachable",
865
1057
  "intended_behavior",
866
1058
  "duplicate",
867
1059
  "framework_protection",
1060
+ "input_validation_blocks",
1061
+ "requires_privilege_attacker_lacks",
868
1062
  "exploit_unreliable",
869
1063
  "insufficient_impact",
870
1064
  "environmental_issue",
871
1065
  "not_applicable",
872
1066
  "out_of_scope",
873
- "skeptic-disproven",
1067
+ "test_artifact",
874
1068
  "no_attack_path",
875
1069
  "refuted",
876
1070
  ] as const;
@@ -937,7 +1131,7 @@ function validateTransition(
937
1131
  : "Cannot kill without justification: add refutation evidence (EvidenceAdd role=refutation, " +
938
1132
  "artifact_path recommended) or state a kill reason in assumptions/nextStep/blockers " +
939
1133
  "(intended_behavior, duplicate, framework_protection, out_of_scope, " +
940
- "skeptic-disproven, no_attack_path, ...)",
1134
+ "insufficient_impact, no_attack_path, ...)",
941
1135
  );
942
1136
  }
943
1137
  }
@@ -965,7 +1159,7 @@ function validateTransition(
965
1159
  },
966
1160
  investigating: {
967
1161
  confirmed: () =>
968
- "investigating → confirmed requires a verified PoC run; use the promote_finding tool",
1162
+ "investigating → confirmed requires a verified PoC run; use the PromoteFinding tool",
969
1163
  hypothesis: () => null,
970
1164
  },
971
1165
  confirmed: {
@@ -1495,22 +1689,29 @@ export function addEvidenceItemResult(
1495
1689
  if (!summary) throw new Error("Evidence summary must not be empty");
1496
1690
 
1497
1691
  let artifactPath: string | undefined;
1692
+
1498
1693
  let sha256: string | undefined;
1499
1694
  if (input.artifactPath) {
1500
- if (!existsSync(input.artifactPath)) {
1501
- throw new Error(`Evidence artifact not found on disk: ${input.artifactPath}`);
1502
- }
1503
- const stat = statSync(input.artifactPath);
1504
- if (!stat.isFile()) {
1505
- throw new Error(`Evidence artifact is not a regular file: ${input.artifactPath}`);
1506
- }
1507
- if (stat.size > EVIDENCE_ARTIFACT_MAX_BYTES) {
1508
- throw new Error(
1509
- `Evidence artifact too large (${stat.size} bytes; max ${EVIDENCE_ARTIFACT_MAX_BYTES}): ${input.artifactPath}`,
1510
- );
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
+ }
1511
1714
  }
1512
- artifactPath = basename(input.artifactPath);
1513
- sha256 = createHash("sha256").update(readFileSync(input.artifactPath)).digest("hex");
1514
1715
  }
1515
1716
 
1516
1717
  const item: EvidenceItem = {
@@ -1814,6 +2015,11 @@ function validateRunEvidence(run: PocEvidenceRun, label: string): void {
1814
2015
  if (!run.outputComplete) {
1815
2016
  throw new Error(`${label} output capture was incomplete; evidence checks are unsafe`);
1816
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
+ }
1817
2023
  if (!run.evidence || !run.evidenceSha256) {
1818
2024
  throw new Error(
1819
2025
  `${label} has no evidence.json — the PoC must write evidence to $PI_POC_EVIDENCE_DIR`,
@@ -1822,6 +2028,39 @@ function validateRunEvidence(run: PocEvidenceRun, label: string): void {
1822
2028
  if (!evidenceNonceMatches(run.evidence, run.nonce)) {
1823
2029
  throw new Error(`${label} evidence nonce mismatch — evidence not bound to this run`);
1824
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
+ }
1825
2064
  }
1826
2065
 
1827
2066
  /** Determinism + differential on normalized evidence (nonce/observations stripped). */
@@ -1839,10 +2078,116 @@ function assertEvidenceDifferential(bundle: PendingConfirmation): void {
1839
2078
  }
1840
2079
  }
1841
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;
2101
+ }
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) {
2148
+ throw new Error(
2149
+ "MAIN-AGENT REPLAY REQUIRED: ConfirmFinding must produce a fresh harness-owned target/control transcript",
2150
+ );
2151
+ }
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
+ ) {
2162
+ throw new Error(
2163
+ "MAIN-AGENT REPLAY FAILED: transcript timestamp must be valid, newer than phase 1, and no more than 5 minutes old",
2164
+ );
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
+ }
2185
+ }
2186
+
1842
2187
  /**
1843
2188
  * Gate for phase 1 of promotion: case must exist, be investigating, and have
1844
2189
  * poc/evidence/impact/severity/target. The disconfirmation is provided by the
1845
- * confirmer at confirm time, so it is NOT a precondition here. Returns the
2190
+ * main agent at confirm time, so it is NOT a precondition here. Returns the
1846
2191
  * record when promotable, throws otherwise. Exported so PromoteFinding can
1847
2192
  * validate BEFORE paying for (potentially slow) sandboxed PoC runs.
1848
2193
  */
@@ -1890,7 +2235,7 @@ export function assertPromotable(id: string): CaseRecord {
1890
2235
  * contract is validated here — same-file control, nonce binding, run
1891
2236
  * completion, determinism across the two target runs, and the target/control
1892
2237
  * differential — so a bundle that cannot promote is rejected before the
1893
- * confirmer is ever dispatched.
2238
+ * main agent performs phase-2 review.
1894
2239
  */
1895
2240
  export function storePendingConfirmation(id: string, bundle: PendingConfirmation): CaseRecord {
1896
2241
  const db = getDb();
@@ -1961,6 +2306,23 @@ export function storePendingConfirmation(id: string, bundle: PendingConfirmation
1961
2306
  validateRunEvidence(run, `${run.mode} run`);
1962
2307
  }
1963
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,
2317
+ );
2318
+ if (controlBindingError) {
2319
+ throw new Error(`CONTROL BINDING FAILED: ${controlBindingError}`);
2320
+ }
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);
1964
2326
 
1965
2327
  const next = buildRecord({ pendingConfirmation: bundle }, current);
1966
2328
  validateCase(next);
@@ -1970,19 +2332,28 @@ export function storePendingConfirmation(id: string, bundle: PendingConfirmation
1970
2332
  }
1971
2333
 
1972
2334
  /**
1973
- * Phase 2: commit (or refuse) the promotion on a confirmer verdict.
2335
+ * Phase 2: commit (or refuse) the promotion on the main agent's verdict.
1974
2336
  *
1975
2337
  * CONFIRMED requires the full bundle to still hold (completion, nonce,
1976
2338
  * determinism, differential), the PoC script to be unchanged since the runs
1977
- * (pocSha256 — otherwise the confirmer reviewed different bytes), and a
1978
- * verdict that re-executed the verify request with a target-only differential
1979
- * and a disconfirmation attempt. NOT_CONFIRMED records the verdict and keeps
1980
- * the case investigating — no tie-breaker.
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.
1981
2343
  */
1982
2344
  export function applyConfirmationResult(
1983
2345
  id: string,
1984
- verdictInput: ConfirmerVerdict,
2346
+ verdictInput: MainAgentVerdict,
2347
+ phase2Verification?: MainAgentVerification,
2348
+ authority: { startedAsSubagent: boolean } = {
2349
+ startedAsSubagent: PROCESS_STARTED_AS_SUBAGENT || process.env.PI_SUBAGENT_CHILD === "1",
2350
+ },
1985
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
+ }
1986
2357
  const db = getDb();
1987
2358
  return withImmediateTransaction(db, () => {
1988
2359
  const current = getCaseById(id);
@@ -2003,17 +2374,48 @@ export function applyConfirmationResult(
2003
2374
  "Pending confirmation expired or has an invalid timestamp (1h TTL) — re-run PromoteFinding for a fresh bundle",
2004
2375
  );
2005
2376
  }
2006
- const parsed = validateConfirmerVerdict(verdictInput);
2007
- if (!parsed.ok) throw new Error(`Invalid confirmer verdict: ${parsed.error}`);
2377
+ const parsed = validateMainAgentVerdict(verdictInput);
2378
+ if (!parsed.ok) throw new Error(`Invalid main-agent confirmation verdict: ${parsed.error}`);
2008
2379
  const verdict = parsed.verdict;
2009
- const recorded: ConfirmerVerdictRecord = { ...verdict, at: new Date().toISOString() };
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
+ };
2010
2405
 
2011
2406
  if (verdict.verdict === "NOT_CONFIRMED") {
2012
- const note = `confirmer NOT_CONFIRMED${verdict.model ? ` (${verdict.model})` : ""}: ${verdict.reasoning}`;
2407
+ const note = `main agent NOT_CONFIRMED${verdict.model ? ` (${verdict.model})` : ""}: ${verdict.reasoning}`;
2013
2408
  const next = buildRecord(
2014
- { confirmerVerdict: recorded, assumptions: [...(current.assumptions ?? []), note] },
2409
+ {
2410
+ confirmerVerdict: recorded,
2411
+ pendingConfirmation: undefined,
2412
+ assumptions: [...(current.assumptions ?? []), note],
2413
+ },
2015
2414
  current,
2016
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;
2017
2419
  validateCase(next);
2018
2420
  upsertCase(db, next);
2019
2421
  return { record: next, changed: true };
@@ -2025,6 +2427,8 @@ export function applyConfirmationResult(
2025
2427
  validateRunEvidence(run, `${run.mode} run`);
2026
2428
  }
2027
2429
  assertEvidenceDifferential(bundle);
2430
+ assertMachineConfirmation(bundle);
2431
+ assertHarnessCanary(bundle.harnessVerified, canaryRequested, "PHASE-1 CANARY FAILED");
2028
2432
  let pocHash: string | undefined;
2029
2433
  try {
2030
2434
  pocHash = createHash("sha256").update(readFileSync(bundle.pocPath)).digest("hex");
@@ -2033,7 +2437,7 @@ export function applyConfirmationResult(
2033
2437
  }
2034
2438
  if (!pocHash || pocHash !== bundle.pocSha256) {
2035
2439
  throw new Error(
2036
- "PoC script changed since the runs — re-run PromoteFinding (the confirmer must review the exact bytes that ran)",
2440
+ "PoC script changed since the runs — re-run PromoteFinding (the main agent must review the exact bytes that ran)",
2037
2441
  );
2038
2442
  }
2039
2443
  // The case target must still be the host the PoC ran against, and still
@@ -2062,6 +2466,11 @@ export function applyConfirmationResult(
2062
2466
  );
2063
2467
  }
2064
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
+
2065
2474
  const reproductionItem: EvidenceItem = {
2066
2475
  id: `ev_${stableShortId(`${id}\nreproduction\n${targetRun.ranAt}`)}`,
2067
2476
  caseId: id,
@@ -2071,7 +2480,7 @@ export function applyConfirmationResult(
2071
2480
  // exists, so the item stays artifact-backed and re-verifiable.
2072
2481
  artifactPath: targetRun.evidencePath ? basename(targetRun.evidencePath) : "evidence.json",
2073
2482
  sha256: targetRun.evidenceSha256,
2074
- summary: `PoC evidence verified (2 target runs + control) — confirmer CONFIRMED${verdict.model ? ` (${verdict.model})` : ""}`,
2483
+ summary: `PoC evidence accepted (2 target runs + control; ${recorded.proofStrength}) — main agent semantic confirmation${verdict.model ? ` (${verdict.model})` : ""}`,
2075
2484
  createdAt: targetRun.ranAt,
2076
2485
  };
2077
2486
 
@@ -2080,7 +2489,8 @@ export function applyConfirmationResult(
2080
2489
  `### PoC Execution Capture (${targetRun.ranAt})\n` +
2081
2490
  `- **Evidence sha256:** ${targetRun.evidenceSha256}\n` +
2082
2491
  `- **Target:** ${targetRun.target}\n` +
2083
- `- **Confirmer:** ${verdict.model ?? "unknown model"} CONFIRMED\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` +
2084
2494
  `#### Target Run Output\n\`\`\`\n${targetRun.output ?? ""}\n\`\`\``;
2085
2495
 
2086
2496
  const update: NormalizedCaseInput = {
@@ -2194,8 +2604,21 @@ function eTLDPlus1(host: string): string {
2194
2604
  return parts.slice(-2).join(".");
2195
2605
  }
2196
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
+
2197
2616
  function chainText(c: CaseRecord): string {
2198
- 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(" ");
2199
2622
  }
2200
2623
 
2201
2624
  function hasChainClass(c: CaseRecord, re: RegExp): boolean {
@@ -2225,23 +2648,29 @@ function sameAssetOrRelated(a: CaseRecord, b: CaseRecord): boolean {
2225
2648
  return eTLDPlus1(ta) === eTLDPlus1(tb);
2226
2649
  }
2227
2650
 
2228
- /**
2229
- * Scan non-terminal cases for exploitable chains (CyberStrike-style detection
2230
- * over XPI's case records). Emits ranked suggestions; the agent decides
2231
- * whether to CaseLink or open an escalation case.
2232
- */
2233
2651
  export function suggestChains(caseId?: string): ChainSuggestion[] {
2234
2652
  // Pair over ALL non-terminal cases; the caseId filter narrows the RESULTS
2235
2653
  // to suggestions involving that case (filtering the inputs first would drop
2236
2654
  // unlinked partner cases and kill cross-case pairing).
2237
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("+"));
2238
2664
  const suggestions: ChainSuggestion[] = [];
2239
2665
  const seen = new Set<string>();
2240
2666
  const confirmed = (c: CaseRecord) => c.status === "confirmed";
2241
2667
  const confidenceFor = (a: CaseRecord, b?: CaseRecord) => {
2242
2668
  const both = confirmed(a) && (!b || confirmed(b));
2243
2669
  const one = confirmed(a) || (b ? confirmed(b) : false);
2244
- 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;
2245
2674
  };
2246
2675
  const add = (
2247
2676
  pattern: ChainPattern,
@@ -2250,6 +2679,7 @@ export function suggestChains(caseId?: string): ChainSuggestion[] {
2250
2679
  rationale: string,
2251
2680
  kind?: CaseLinkKind,
2252
2681
  ) => {
2682
+ if (b && linkedPairs.has([a.id, b.id].sort().join("+"))) return; // already known
2253
2683
  const key = b ? `${pattern}:${[a.id, b.id].sort().join("+")}` : `${pattern}:${a.id}`;
2254
2684
  if (seen.has(key)) return;
2255
2685
  seen.add(key);
@@ -2815,11 +3245,13 @@ function buildScratchpadSection(caseId: string): string {
2815
3245
  : "No scratchpad run found containing this case id (manual/CTF run without pipeline artifacts).";
2816
3246
  }
2817
3247
 
2818
- export function writeCaseContext(id: string): {
3248
+ export type CaseContextResult = {
2819
3249
  path: string;
2820
3250
  contextPath: string;
2821
3251
  record: CaseRecord;
2822
- } {
3252
+ };
3253
+
3254
+ export function writeCaseContext(id: string): CaseContextResult {
2823
3255
  const current = getCaseById(id);
2824
3256
  if (!current) throw new Error(`Case not found: ${id}`);
2825
3257
  if (current.status !== "confirmed" && current.status !== "reported") {
@@ -2896,7 +3328,7 @@ export function writeCaseContext(id: string): {
2896
3328
  current.controlVerified
2897
3329
  ? mdSection(
2898
3330
  "Control-Target Check (anti-cheat)",
2899
- `### Control Run Verification\n- **Timestamp:** ${current.controlVerified.ranAt}\n- **Script:** \`${basename(current.controlVerified.path)}\`\n- **Sandbox:** ${current.controlVerified.sandbox ? "yes" : "no"}\n- **Exit Code:** ${current.controlVerified.exitCode}\n- **Control target:** ${current.controlVerified.target ?? "not recorded"}\n- **Differential (machine-checked):** control evidence differs from the target runs' evidence — the claimed impact is target-dependent (assertEvidenceDifferential, re-checked at confirm).\n- **Note:** exit codes and output markers are diagnostics, not gates; the machine floor is the evidence differential + the confirmer's re-execution.\n\n#### Output\n\`\`\`\n${current.controlVerified.output ?? ""}\n\`\`\``,
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\`\`\``,
2900
3332
  )
2901
3333
  : undefined,
2902
3334
  mdSection("Disconfirmation Attempt", current.disconfirmation),