@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/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Casefile — offensive security case tracker for Pi.
3
3
  *
4
- * Tools: CaseAdd, CaseUpdate, PromoteFinding, EvidenceAdd, CoverageAdd, CoverageReport, ChainSuggest, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseContext, PipelineSubmit, ScratchpadInit, ScratchpadResume, ScratchpadCheckpoint, ScratchpadWrite, ScratchpadRead, ScratchpadPhaseDone, ScratchpadClear
4
+ * Tools: CaseAdd, CaseUpdate, PromoteFinding, ConfirmFinding, EvidenceAdd, CoverageAdd, CoverageReport, ChainSuggest, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseContext, PipelineSubmit, ScratchpadInit, ScratchpadResume, ScratchpadCheckpoint, ScratchpadWrite, ScratchpadRead, ScratchpadPhaseDone, ScratchpadClear
5
5
  * Command: /casefile — interactive dashboard
6
6
  * Event: before_agent_start — injects cyber workflow once per session, refreshes the active case list per prompt
7
7
  */
@@ -13,11 +13,17 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
13
  import { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
14
14
  import { Type } from "typebox";
15
15
  import {
16
+ CANARY_ASSESSMENT_VALUES,
16
17
  CONFIRM_DIFFERENTIAL_VALUES,
17
18
  CONFIRM_VERDICT_VALUES,
18
- type ConfirmerVerdict,
19
19
  SEVERITY_MATCH_VALUES,
20
+ validateMainAgentVerdict,
20
21
  } from "./evidence.ts";
22
+ import {
23
+ controlTargetAuthorizationError,
24
+ type HarnessVerifyResult,
25
+ replayDifferential,
26
+ } from "./harness-verify.ts";
21
27
  import {
22
28
  addCaseResult,
23
29
  addEvidenceItemResult,
@@ -47,6 +53,7 @@ import {
47
53
  getCasefilePath,
48
54
  LINK_KIND_VALUES,
49
55
  linkCasesResult,
56
+ type MainAgentVerification,
50
57
  type PendingConfirmation,
51
58
  type PocEvidenceRun,
52
59
  PRIORITY_VALUES,
@@ -58,11 +65,10 @@ import {
58
65
  STATUS_VALUES,
59
66
  searchCases,
60
67
  storePendingConfirmation,
61
- suggestChains,
62
68
  unlinkCasesResult,
63
69
  updateCaseResult,
64
- writeCaseContext,
65
70
  } from "./ledger.ts";
71
+ import { suggestChainsAsync, writeCaseContextAsync } from "./ledger-worker.ts";
66
72
  import { pipeline_submit, SUBMIT_STAGES, type SubmitStage } from "./pipeline-submit.ts";
67
73
  import { type PocRun, type PocRunOptions, runPoc } from "./poc-runner.ts";
68
74
  import {
@@ -167,8 +173,7 @@ const EvidenceAddSchema = Type.Object(
167
173
  artifact_path: Type.Optional(
168
174
  Type.String({
169
175
  description:
170
- "Path to the artifact file on disk. Stored as basename + SHA-256 hash " +
171
- "(full path is never persisted).",
176
+ "Path to a regular, non-symlink artifact inside the workspace. The bytes are copied durably and stored as basename + SHA-256 (full source path is never persisted).",
172
177
  }),
173
178
  ),
174
179
  },
@@ -177,12 +182,12 @@ const EvidenceAddSchema = Type.Object(
177
182
 
178
183
  // ── Tool: PromoteFinding (phase 1) / ConfirmFinding (phase 2) ──────────
179
184
  //
180
- // Confirmation is TWO-PHASE because tools cannot dispatch subagents: the
181
- // coordinator runs PromoteFinding (harness runs the PoC 2x + control,
182
- // validates nonce-bound evidence.json, records the bundle), dispatches the
183
- // confirmer subagent (fresh context, re-executes the verify request), then
184
- // commits the verdict via ConfirmFinding. Exit codes and markers are
185
- // diagnostics the gate is evidence + verdict.
185
+ // Confirmation is TWO-PHASE: a worker may run PromoteFinding (the harness runs
186
+ // the PoC 2x + control, validates nonce-bound evidence.json, and records the
187
+ // bundle), but only the MAIN coordinator agent may review/re-execute and commit
188
+ // a verdict via ConfirmFinding. Zero exit is necessary run integrity and
189
+ // markers are diagnostic only; the machine records predicate/canary
190
+ // differentials and the main agent owns the semantic vulnerability judgment.
186
191
 
187
192
  const PromoteSchema = Type.Object(
188
193
  {
@@ -197,12 +202,18 @@ const PromoteSchema = Type.Object(
197
202
  control_target: Type.String({
198
203
  minLength: 1,
199
204
  description:
200
- "REQUIRED: a distinct baseline target that lacks the vulnerability (patched replica, second account, baseline endpoint).",
205
+ "REQUIRED: a distinct baseline target that lacks the vulnerability and is operator-approved through PI_POC_CONTROL_TARGETS (patched replica, second account, baseline service).",
201
206
  }),
202
207
  local: Type.Optional(
203
208
  Type.Boolean({
204
209
  description:
205
- "Run with network access (Docker sandbox with --network host — still read-only FS, dropped capabilities, unprivileged user) instead of the isolated --network none sandbox. True host execution is NOT agent-selectable: it requires the operator to set PI_POC_ALLOW_LOCAL=1, and is used only as a fallback when Docker is unavailable.",
210
+ "Run with network access instead of --network none. Requires operator authorization via PI_POC_ALLOW_NETWORK=1. True host fallback additionally requires PI_POC_ALLOW_LOCAL=1.",
211
+ }),
212
+ ),
213
+ oob: Type.Optional(
214
+ Type.Boolean({
215
+ description:
216
+ "Reserved for source-separated out-of-band verification. Currently fails closed because a loopback listener reachable by the PoC cannot prove target causation.",
206
217
  }),
207
218
  ),
208
219
  },
@@ -219,14 +230,13 @@ const ConfirmSchema = Type.Object(
219
230
  description: "Why the evidence does or does not demonstrate the claim",
220
231
  }),
221
232
  evidence_reviewed: Type.Array(Type.String(), {
222
- description: "Files/evidence the confirmer actually reviewed",
223
- }),
224
- re_executed: Type.Boolean({
225
- description:
226
- "True iff the confirmer re-sent the verify request itself. Mandatory for CONFIRMED.",
233
+ description: "Files/evidence the main agent actually reviewed",
227
234
  }),
228
235
  re_execution_note: Type.Optional(
229
- Type.String({ description: "What the confirmer observed when re-executing" }),
236
+ Type.String({
237
+ description:
238
+ "What the main agent observed during review and the fresh harness-owned target/control replay. Mandatory for CONFIRMED.",
239
+ }),
230
240
  ),
231
241
  differential: Type.String({
232
242
  enum: [...CONFIRM_DIFFERENTIAL_VALUES],
@@ -241,7 +251,20 @@ const ConfirmSchema = Type.Object(
241
251
  disconfirmation_attempt: Type.Optional(
242
252
  Type.String({
243
253
  description:
244
- "The confirmer's own failed attempt to disprove — becomes the case's disconfirmation",
254
+ "The main agent's own failed attempt to disprove — becomes the case's disconfirmation",
255
+ }),
256
+ ),
257
+ canary_assessment: Type.Optional(
258
+ Type.String({
259
+ enum: [...CANARY_ASSESSMENT_VALUES],
260
+ description:
261
+ "verified when the replay carried a harness-generated reflection canary; otherwise not_applicable with a concrete reason",
262
+ }),
263
+ ),
264
+ canary_reason: Type.Optional(
265
+ Type.String({
266
+ description:
267
+ "Why a causal reflection canary is not meaningful for this exploit class. Required when canary_assessment=not_applicable.",
245
268
  }),
246
269
  ),
247
270
  model: Type.Optional(
@@ -752,6 +775,11 @@ export function parseXpModeArg(args: string, current: XpMode): XpMode {
752
775
  // ── Main extension ────────────────────────────────────────────────────
753
776
 
754
777
  export default function casefileExtension(pi: ExtensionAPI) {
778
+ // Process role is immutable for this extension instance. A worker may spawn
779
+ // shells, but unsetting PI_SUBAGENT_CHILD in a child shell cannot upgrade the
780
+ // already-loaded extension or reveal a tool that was omitted at startup.
781
+ const startedAsSubagent = process.env.PI_SUBAGENT_CHILD === "1";
782
+ const isSubagentProcess = () => startedAsSubagent || process.env.PI_SUBAGENT_CHILD === "1";
755
783
  // Pin the workspace root ONCE at extension load. Every scratchpad / pipeline
756
784
  // / PoC-path lookup otherwise re-walks the ambient cwd on each call — a
757
785
  // mid-session `cd` would split state across two .scratchpad roots and
@@ -901,7 +929,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
901
929
  name: "EvidenceAdd",
902
930
  label: "Add Evidence Item",
903
931
  description:
904
- "Record a role-typed, artifact-backed evidence item on a case (observation, reproduction, impact, refutation, cleanup). Artifact files are stored as basename + SHA-256 — the full path is never persisted. refutation items justify a kill; cleanup items track engagement cleanup before REPORT.",
932
+ "Record a role-typed, artifact-backed evidence item on a case. Artifact reads are restricted to regular, non-symlink files inside the workspace; bytes are copied durably and stored as basename + SHA-256. refutation items justify a kill; cleanup items track engagement cleanup before REPORT.",
905
933
  promptSnippet: "Record a role-typed evidence item",
906
934
  promptGuidelines: [
907
935
  "Use EvidenceAdd for artifact-backed evidence: raw responses, logs, screenshots, disproof attempts — anything a claim should trace back to.",
@@ -1103,22 +1131,23 @@ export default function casefileExtension(pi: ExtensionAPI) {
1103
1131
  },
1104
1132
  });
1105
1133
 
1106
- // ── Tool: PromoteFinding ──
1107
1134
  // ── Tool: PromoteFinding (phase 1) ──
1108
1135
 
1109
1136
  pi.registerTool({
1110
1137
  name: "PromoteFinding",
1111
1138
  label: "Run PoC Evidence",
1112
1139
  description:
1113
- "Phase 1 of confirmation: run the PoC twice against the case target plus once against control_target (same script, sha256-enforced), then validate the nonce-bound evidence.json each run writes to $PI_POC_EVIDENCE_DIR. Records a pending confirmation bundle (expires in 1h) and returns the confirmer dispatch instruction. Exit codes and markers are DIAGNOSTICS the gate is evidence + confirmer verdict. After PromoteFinding: dispatch the confirmer subagent, then commit its verdict with ConfirmFinding. Host execution is never agent-selectable local:true uses a host-network Docker sandbox; true host runs need PI_POC_ALLOW_LOCAL=1.",
1140
+ "Phase 1 of confirmation: run the same PoC twice against the case target and once against an operator-approved control_target, validate nonce-bound evidence.json with a response-body assertion, then have the harness execute one immutable HTTP request template against both target and control. The machine records a predicate differential, or a stronger canary differential when a reflection placeholder is requested and observed only on target; neither is automatically a vulnerability verdict. Exit 0 is necessary run integrity, never proof. Networked execution, controls, and private replay are operator-gated. Blind/OOB confirmation fails closed until source separation exists. Records a pending bundle for main-agent semantic review via ConfirmFinding.",
1114
1141
  promptSnippet: "Phase 1: run PoC evidence (target x2 + control) and record the pending bundle",
1115
1142
  promptGuidelines: [
1116
- "Use PromoteFinding when an investigating case has a concrete PoC script on disk and you are ready to prove it.",
1117
- "Prerequisites: status='investigating' and non-empty poc, evidence, impact, severity, target, plus an artifact-backed EvidenceAdd 'observation' item on the case (the initial signal, with artifact_path). The disconfirmation comes from the confirmer at confirm time.",
1118
- "The PoC MUST write evidence.json to $PI_POC_EVIDENCE_DIR: { nonce (echo $PI_POC_NONCE), claim, verify: { method, url, expect: { status/body_contains/body_regex } }, observations }. The harness validates it and binds it to the run missing/invalid/misnonced evidence blocks promotion.",
1119
- "control_path (REQUIRED): the SAME script as poc_path (sha256-equality is ENFORCED). The harness runs it with PI_POC_MODE=control and PI_POC_TARGET=control_target. The control's evidence must DIFFER from the target's (not target-dependent blocked).",
1120
- "Default sandbox: docker run --rm --network none. Use local:true for network-dependent bugs (host-network sandbox; host execution needs operator PI_POC_ALLOW_LOCAL=1).",
1121
- "After the bundle is recorded, dispatch the confirmer subagent (agents/confirmer.md, fresh context, different model) and commit its verdict with ConfirmFinding. Never CaseUpdate status='confirmed' directly.",
1143
+ "Use PromoteFinding when an investigating case has a concrete PoC script on disk and you are ready to subject its claim to the machine gate.",
1144
+ "Prerequisites: status='investigating' and non-empty poc, evidence, impact, severity, target, plus an artifact-backed EvidenceAdd 'observation' item on the case (the initial signal, with artifact_path). The final disconfirmation comes from the main agent at confirm time.",
1145
+ "The PoC MUST write evidence.json to $PI_POC_EVIDENCE_DIR: { nonce (echo $PI_POC_NONCE), claim, verify: { method, url, expect: { status?, body_contains/body_regex } }, observations }. A non-empty body predicate is mandatory; status-only evidence is rejected. verify.url must belong to the case target.",
1146
+ "For reflection-capable requests, place {{PI_POC_CANARY}} exactly once in verify.url/body/header values and declare verify.canary={mode:'reflection',placeholder:'{{PI_POC_CANARY}}'}. The harness substitutes an unpredictable value only after the PoC exits and requires target-only reflection; the raw token is not persisted.",
1147
+ "control_path (REQUIRED): the SAME script as poc_path. control_target must be pre-approved by the operator in PI_POC_CONTROL_TARGETS. The harness derives the control request from the target request, changes only its origin, and applies the same predicates to two conclusive responses.",
1148
+ "local:true requires PI_POC_ALLOW_NETWORK=1. Private/internal harness replay additionally requires PI_POC_ALLOW_PRIVATE_REPLAY=1. Neither silently falls back to a model verdict.",
1149
+ "Blind/OOB classes are not promotable through the built-in loopback listener because the PoC can self-call it; obtain a direct-response or state oracle, otherwise keep the case investigating.",
1150
+ "After the bundle is recorded, return control to the main agent. The main agent must inspect the script/evidence, attempt disconfirmation, and call ConfirmFinding itself; that call performs a fresh harness-owned target/control replay. Never delegate phase 2 and never CaseUpdate status='confirmed' directly.",
1122
1151
  ],
1123
1152
  parameters: PromoteSchema,
1124
1153
 
@@ -1154,6 +1183,20 @@ export default function casefileExtension(pi: ExtensionAPI) {
1154
1183
  { controlTargetEqualsCaseTarget: true },
1155
1184
  );
1156
1185
  }
1186
+ if (params.local === true && process.env.PI_POC_ALLOW_NETWORK !== "1") {
1187
+ return fail(
1188
+ "Networked PoC execution is operator-gated. Set PI_POC_ALLOW_NETWORK=1 to authorize the host-network sandbox for this session.",
1189
+ { networkNotAuthorized: true },
1190
+ );
1191
+ }
1192
+ const controlAuthorization = controlTargetAuthorizationError(controlTarget);
1193
+ if (controlAuthorization) {
1194
+ return fail(
1195
+ `CONTROL AUTHORIZATION FAILED: ${controlAuthorization}. ` +
1196
+ "The operator must set PI_POC_CONTROL_TARGETS to the exact approved control host/origin before this control can anchor confirmation.",
1197
+ { controlNotAuthorized: true },
1198
+ );
1199
+ }
1157
1200
 
1158
1201
  // Same-file contract (anti-cheat): control must be the SAME bytes as the
1159
1202
  // PoC, differing only via the harness-set env. Check BEFORE any run.
@@ -1176,16 +1219,30 @@ export default function casefileExtension(pi: ExtensionAPI) {
1176
1219
  );
1177
1220
  }
1178
1221
 
1222
+ // ── OOB callback (Tier 1, opt-in for blind classes) ──
1223
+ const oobRequested = params.oob === true;
1224
+ if (oobRequested) {
1225
+ return fail(
1226
+ "OOB confirmation is fail-closed: the built-in loopback listener is reachable by the PoC and cannot prove the target caused a callback. A source-separated, operator-owned callback service is required before blind findings can be promoted.",
1227
+ { oobSourceSeparationRequired: true },
1228
+ );
1229
+ }
1179
1230
  const runOptions = (pocMode: string, target: string): PocRunOptions => ({
1180
1231
  network: params.local === true ? "host" : "none",
1181
1232
  local: params.local === true,
1182
- env: { PI_POC_MODE: pocMode, PI_POC_TARGET: target },
1233
+ env: {
1234
+ PI_POC_MODE: pocMode,
1235
+ PI_POC_TARGET: target,
1236
+ },
1183
1237
  });
1184
1238
 
1185
1239
  const caseTarget = current.target ?? "";
1186
- // Determinism: TWO target runs + one control run. Exit codes are
1187
- // diagnostics; completion, evidence validity, determinism, and the
1188
- // differential are the gate.
1240
+ let targetRuns!: [PocEvidenceRun, PocEvidenceRun];
1241
+ let control!: PocEvidenceRun;
1242
+ let harnessVerified!: HarnessVerifyResult;
1243
+ // Determinism: TWO target runs + one control run. Exit 0 is run
1244
+ // integrity only; nonce-bound body evidence and the harness-owned
1245
+ // target/control replay form the machine gate.
1189
1246
  const run1 = runPoc(pocPath, runOptions("poc", caseTarget));
1190
1247
  const run2 = runPoc(pocPath, runOptions("poc", caseTarget));
1191
1248
  const controlRun = runPoc(controlPath, runOptions("control", controlTarget));
@@ -1202,13 +1259,15 @@ export default function casefileExtension(pi: ExtensionAPI) {
1202
1259
  if (r.evidenceError) {
1203
1260
  return fail(
1204
1261
  `EVIDENCE CONTRACT FAILED (${mode} run): ${r.evidenceError}. ` +
1205
- "The PoC must write evidence.json to $PI_POC_EVIDENCE_DIR — { nonce (echo $PI_POC_NONCE), claim, verify: { method, url, expect: { status / body_contains / body_regex } }, observations } — " +
1262
+ "The PoC must write evidence.json to $PI_POC_EVIDENCE_DIR — { nonce (echo $PI_POC_NONCE), claim, verify: { method, url, expect: { status?, body_contains / body_regex } }, observations }; a response-body assertion is mandatory — " +
1206
1263
  "the file is bound to this run and validated by the harness. Case remains investigating.",
1207
1264
  { run: r, evidenceError: r.evidenceError },
1208
1265
  );
1209
1266
  }
1210
1267
  if (!r.evidence || !r.evidenceSha256 || !r.nonce) {
1211
- return fail(`${mode} run produced no evidence. Case remains investigating.`, { run: r });
1268
+ return fail(`${mode} run produced no evidence. Case remains investigating.`, {
1269
+ run: r,
1270
+ });
1212
1271
  }
1213
1272
  return {
1214
1273
  mode,
@@ -1226,11 +1285,18 @@ export default function casefileExtension(pi: ExtensionAPI) {
1226
1285
  };
1227
1286
  };
1228
1287
 
1229
- const targetRuns: [PocEvidenceRun, PocEvidenceRun] = [
1230
- evidenceRun(run1, "poc", caseTarget),
1231
- evidenceRun(run2, "poc", caseTarget),
1232
- ];
1233
- const control = evidenceRun(controlRun, "control", controlTarget);
1288
+ targetRuns = [evidenceRun(run1, "poc", caseTarget), evidenceRun(run2, "poc", caseTarget)];
1289
+ control = evidenceRun(controlRun, "control", controlTarget);
1290
+
1291
+ // Tier 2 (docs/poc-trust-model.md): the harness executes the SAME
1292
+ // request template against target and operator-approved control, applying
1293
+ // the target's predicates to both. DNS is pinned at connect time.
1294
+ harnessVerified = await replayDifferential(
1295
+ targetRuns[0].evidence,
1296
+ caseTarget,
1297
+ controlTarget,
1298
+ { allowPrivate: process.env.PI_POC_ALLOW_PRIVATE_REPLAY === "1" },
1299
+ );
1234
1300
 
1235
1301
  const bundle: PendingConfirmation = {
1236
1302
  caseId,
@@ -1241,6 +1307,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
1241
1307
  controlTarget,
1242
1308
  targetRuns,
1243
1309
  controlRun: control,
1310
+ harnessVerified,
1244
1311
  };
1245
1312
 
1246
1313
  let record: CaseRecord;
@@ -1260,11 +1327,9 @@ export default function casefileExtension(pi: ExtensionAPI) {
1260
1327
  `Phase 1 complete — evidence bundle recorded on ${caseId} (expires in 1h).\n` +
1261
1328
  `Target runs: 2, Control run: 1 — all with validated nonce-bound evidence.json.\n` +
1262
1329
  `Evidence sha256: ${targetRuns[0].evidenceSha256}\n` +
1263
- `PoC script sha256 (at run time): ${pocHash}\n\n` +
1264
- (detectHost() === "omp"
1265
- ? `DISPATCH THE CONFIRMER now: task({ context: 'fresh', tasks: [{ name: 'confirm-${caseId}-1', agent: 'confirmer', task: 'Verify the PoC evidence for case ${caseId} (poc_path=${pocPath}, control_target=${controlTarget}, evidence_sha256=${targetRuns[0].evidenceSha256}, poc_sha256=${pocHash}). Assume fabricated, prove real. Re-send the verify request yourself. Return the verdict.' }] })\n`
1266
- : `DISPATCH THE CONFIRMER now: subagent({ workflowScript: "return runs.run('confirm-${caseId}-1', { agent: 'confirmer', task: 'Verify the PoC evidence for case ${caseId} (poc_path=${pocPath}, control_target=${controlTarget}, evidence_sha256=${targetRuns[0].evidenceSha256}, poc_sha256=${pocHash}). Assume fabricated, prove real. Re-send the verify request yourself. Return the verdict.' })", context: 'fresh', async: true })\n`) +
1267
- "Then commit the verdict with ConfirmFinding(case_id, verdict) — CONFIRMED promotes, NOT_CONFIRMED keeps investigating.",
1330
+ `PoC script sha256 (at run time): ${pocHash}\n` +
1331
+ `Harness verify replay: ${harnessVerified.attempted ? (harnessVerified.pass ? `PASS (status ${harnessVerified.status})` : `FAILED — ${harnessVerified.note}`) : harnessVerified.note}\n` +
1332
+ `\nMAIN-AGENT REVIEW REQUIRED (do not delegate): inspect case ${caseId}, PoC ${pocPath}, control ${controlTarget}, evidence ${targetRuns[0].evidenceSha256}, and PoC hash ${pocHash}. Hunt for a trivial predicate or fabricated differential and perform a concrete disconfirmation attempt, then call ConfirmFinding yourself. A CONFIRMED call performs and stores a fresh harness-owned target/control replay; NOT_CONFIRMED keeps the case investigating.`,
1268
1333
  },
1269
1334
  ],
1270
1335
  details: {
@@ -1277,6 +1342,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
1277
1342
  controlTarget,
1278
1343
  pocSha256: pocHash,
1279
1344
  evidenceSha256: targetRuns[0].evidenceSha256,
1345
+ harnessVerified,
1280
1346
  },
1281
1347
  },
1282
1348
  };
@@ -1303,55 +1369,95 @@ export default function casefileExtension(pi: ExtensionAPI) {
1303
1369
 
1304
1370
  // ── Tool: ConfirmFinding (phase 2) ──
1305
1371
 
1306
- pi.registerTool({
1307
- name: "ConfirmFinding",
1308
- label: "Commit Confirmer Verdict",
1309
- description:
1310
- "Phase 2 of confirmation: commit (or refuse) a promotion on the confirmer subagent's verdict. CONFIRMED requires a target-only differential, re_executed: true (the confirmer re-sent the verify request itself), a disconfirmation_attempt (becomes the case's disconfirmation), and the pending bundle from PromoteFinding still valid (nonce-bound evidence, determinism, control differential, PoC script unchanged — checked again at the ledger). NOT_CONFIRMED records the verdict and keeps the case investigating — no tie-breaker.",
1311
- promptSnippet: "Commit the confirmer verdict — promote or keep investigating",
1312
- promptGuidelines: [
1313
- "Run after PromoteFinding + the confirmer dispatch. The verdict comes from the confirmer subagent output, not from the writer.",
1314
- "CONFIRMED requires differential: 'target_only', re_executed: true, and disconfirmation_attempt (the confirmer's own failed disproof). A verdict missing any of these is rejected.",
1315
- "NOT_CONFIRMED is final for that attempt the case stays investigating with the reasoning recorded in assumptions. Re-dispatch a new confirmer if you want a second opinion; every attempt is recorded.",
1316
- "Never CaseUpdate status='confirmed' directly — it is rejected. Always use PromoteFinding + ConfirmFinding.",
1317
- ],
1318
- parameters: ConfirmSchema,
1319
-
1320
- async execute(_id, params, _signal, _onUpdate, _ctx) {
1321
- const caseId = params.id as string;
1322
- const result = applyConfirmationResult(caseId, params.verdict as ConfirmerVerdict);
1323
- const record = result.record;
1324
- const promoted = record.status === "confirmed";
1325
- return {
1326
- content: [
1327
- {
1328
- type: "text",
1329
- text: promoted
1330
- ? `Confirmer CONFIRMED. Case promoted:
1372
+ // Do not expose the commit capability in a worker process at all. The
1373
+ // execute-time check remains as defense in depth if process state changes
1374
+ // after registration or another integration forwards a stale tool handle.
1375
+ if (!startedAsSubagent)
1376
+ pi.registerTool({
1377
+ name: "ConfirmFinding",
1378
+ label: "Main-Agent Confirmation",
1379
+ description:
1380
+ "Phase 2 of confirmation, reserved for the main/coordinator agent: commit or refuse promotion after personally reviewing the machine bundle. On CONFIRMED, this tool performs a fresh harness-owned target/control replay; the verdict requires a target-only differential, a concrete re_execution_note and disconfirmation_attempt, a canary assessment, and the still-valid PromoteFinding bundle. The machine transcript is evidence, not the semantic vulnerability verdict. Worker/subagent processes are rejected. NOT_CONFIRMED records the review and keeps the case investigating.",
1381
+ promptSnippet: "Main agent: independently review and commit or refuse PoC confirmation",
1382
+ promptGuidelines: [
1383
+ "Run only in the main/coordinator agent after PromoteFinding returns. Do not dispatch a worker to decide or author this verdict.",
1384
+ "Personally inspect the PoC and preserved evidence and try a concrete disconfirmation before deciding. ConfirmFinding itself re-sends the immutable verify request against target and operator-approved control so phase 2 has a harness-owned transcript.",
1385
+ "CONFIRMED requires differential: 'target_only', re_execution_note, and disconfirmation_attempt (the main agent's failed disproof). A verdict missing any of these is rejected.",
1386
+ "Set canary_assessment='verified' when the immutable request declared a canary; otherwise set not_applicable and explain why a reflection canary is not meaningful for this exploit class.",
1387
+ "NOT_CONFIRMED is final for that attempt — the case stays investigating with the main agent's reasoning recorded. A fresh PromoteFinding run is required for another attempt.",
1388
+ "Never CaseUpdate status='confirmed' directly — it is rejected. Always use PromoteFinding + ConfirmFinding.",
1389
+ ],
1390
+ parameters: ConfirmSchema,
1391
+
1392
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
1393
+ if (isSubagentProcess()) {
1394
+ throw new Error(
1395
+ "ConfirmFinding is reserved for the main/coordinator agent. A worker or subagent may produce evidence but cannot confirm a PoC.",
1396
+ );
1397
+ }
1398
+ const caseId = params.id as string;
1399
+ const parsedVerdict = validateMainAgentVerdict(params.verdict);
1400
+ if (!parsedVerdict.ok) {
1401
+ throw new Error(`Invalid main-agent confirmation verdict: ${parsedVerdict.error}`);
1402
+ }
1403
+ let phase2Verification: MainAgentVerification | undefined;
1404
+ if (parsedVerdict.verdict.verdict === "CONFIRMED") {
1405
+ const current = getCaseById(caseId);
1406
+ if (!current) throw new Error(`Case not found: ${caseId}`);
1407
+ const bundle = current.pendingConfirmation;
1408
+ if (!bundle) {
1409
+ throw new Error("No pending confirmation on this case — run PromoteFinding first");
1410
+ }
1411
+ const controlAuthorizationError = controlTargetAuthorizationError(bundle.controlTarget);
1412
+ if (controlAuthorizationError) {
1413
+ throw new Error(`CONTROL AUTHORIZATION FAILED: ${controlAuthorizationError}`);
1414
+ }
1415
+ const replay = await replayDifferential(
1416
+ bundle.targetRuns[0].evidence,
1417
+ current.target ?? bundle.targetRuns[0].target,
1418
+ bundle.controlTarget,
1419
+ { allowPrivate: process.env.PI_POC_ALLOW_PRIVATE_REPLAY === "1" },
1420
+ );
1421
+ phase2Verification = {
1422
+ at: new Date().toISOString(),
1423
+ result: replay,
1424
+ };
1425
+ }
1426
+ const result = applyConfirmationResult(caseId, parsedVerdict.verdict, phase2Verification, {
1427
+ startedAsSubagent: isSubagentProcess(),
1428
+ });
1429
+ const record = result.record;
1430
+ const promoted = record.status === "confirmed";
1431
+ return {
1432
+ content: [
1433
+ {
1434
+ type: "text",
1435
+ text: promoted
1436
+ ? `Main agent CONFIRMED. Case promoted:
1331
1437
  ${formatCaseDetail(record)}`
1332
- : `Confirmer NOT_CONFIRMED — case stays investigating (attempt recorded):
1438
+ : `Main agent NOT_CONFIRMED — case stays investigating (attempt recorded):
1333
1439
  ${formatCaseDetail(record)}`,
1334
- },
1335
- ],
1336
- details: { record, promoted, changed: result.changed },
1337
- };
1338
- },
1440
+ },
1441
+ ],
1442
+ details: { record, promoted, changed: result.changed },
1443
+ };
1444
+ },
1339
1445
 
1340
- renderCall(args, theme) {
1341
- return callLine(theme, "ConfirmFinding", (args.id as string) ?? "");
1342
- },
1446
+ renderCall(args, theme) {
1447
+ return callLine(theme, "ConfirmFinding", (args.id as string) ?? "");
1448
+ },
1343
1449
 
1344
- renderResult(result, _opts, theme) {
1345
- const details = result.details as { promoted?: boolean } | undefined;
1346
- return new Text(
1347
- details?.promoted
1348
- ? theme.fg("success", "✓ Promoted")
1349
- : theme.fg("warning", "↷ Not confirmed"),
1350
- 0,
1351
- 0,
1352
- );
1353
- },
1354
- });
1450
+ renderResult(result, _opts, theme) {
1451
+ const details = result.details as { promoted?: boolean } | undefined;
1452
+ return new Text(
1453
+ details?.promoted
1454
+ ? theme.fg("success", "✓ Promoted")
1455
+ : theme.fg("warning", "↷ Not confirmed"),
1456
+ 0,
1457
+ 0,
1458
+ );
1459
+ },
1460
+ });
1355
1461
 
1356
1462
  // ── Tool: CaseGet ──
1357
1463
 
@@ -1595,7 +1701,9 @@ ${formatCaseDetail(record)}`,
1595
1701
  parameters: ChainSuggestSchema,
1596
1702
 
1597
1703
  async execute(_id, params, _signal, _onUpdate, _ctx) {
1598
- const suggestions = suggestChains((params.case_id as string | undefined) ?? undefined);
1704
+ const suggestions = await suggestChainsAsync(
1705
+ (params.case_id as string | undefined) ?? undefined,
1706
+ );
1599
1707
  const lines: string[] = [
1600
1708
  suggestions.length
1601
1709
  ? `${suggestions.length} chain candidate(s):`
@@ -1641,7 +1749,7 @@ ${formatCaseDetail(record)}`,
1641
1749
  parameters: IdSchema,
1642
1750
 
1643
1751
  async execute(_id, params, _signal, _onUpdate, _ctx) {
1644
- const { path, contextPath, record } = writeCaseContext(params.id as string);
1752
+ const { path, contextPath, record } = await writeCaseContextAsync(params.id as string);
1645
1753
  return {
1646
1754
  content: [
1647
1755
  {
@@ -2135,7 +2243,7 @@ ${formatCaseDetail(record)}`,
2135
2243
  // the workflow + entire active-case ledger into every child dispatch is a
2136
2244
  // token multiplier (N subagents × workflow + growing case list per turn) —
2137
2245
  // workers get what they need via their task and tool guidelines.
2138
- if (process.env.PI_SUBAGENT_CHILD === "1") return;
2246
+ if (isSubagentProcess()) return;
2139
2247
 
2140
2248
  const includeWorkflow = !workflowInjected;
2141
2249
 
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Worker-thread entry for heavy ledger reads (suggestChains, writeCaseContext).
3
+ *
4
+ * Runs in a dedicated thread so an O(n²) chain scan or a multi-megabyte
5
+ * context-bundle build never blocks the agent's event loop. Each call spawns
6
+ * a fresh worker — rare operations, no lifecycle to manage, no stale state.
7
+ * The worker opens its OWN connection to the same WAL database (read-mostly
8
+ * work + one small reportPath write); WAL + busy_timeout make multi-connection
9
+ * access safe.
10
+ */
11
+
12
+ import { parentPort, workerData } from "node:worker_threads";
13
+ import { suggestChains, writeCaseContext } from "./ledger.ts";
14
+ import { setScratchpadRoot } from "./scratchpad.ts";
15
+
16
+ process.env.PI_CASEFILE_PATH = workerData.casefilePath as string;
17
+ setScratchpadRoot(workerData.workspaceRoot as string | undefined);
18
+
19
+ type WorkerRequest =
20
+ | { op: "suggestChains"; caseId?: string }
21
+ | { op: "writeCaseContext"; id: string };
22
+
23
+ parentPort?.on("message", (req: WorkerRequest) => {
24
+ try {
25
+ if (req.op === "suggestChains") {
26
+ parentPort?.postMessage({ ok: true, result: suggestChains(req.caseId) });
27
+ } else if (req.op === "writeCaseContext") {
28
+ parentPort?.postMessage({ ok: true, result: writeCaseContext(req.id) });
29
+ } else {
30
+ parentPort?.postMessage({ ok: false, error: `unknown ledger worker op` });
31
+ }
32
+ } catch (e) {
33
+ parentPort?.postMessage({ ok: false, error: (e as Error).message });
34
+ }
35
+ });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Async offload for the two heavy ledger reads — main-thread side.
3
+ *
4
+ * suggestChains is O(rules × cases²) over the whole ledger; writeCaseContext
5
+ * reads every artifact of every matching scratchpad run and builds a
6
+ * multi-hundred-KB bundle. Both ran synchronously inside async tool handlers,
7
+ * stalling the event loop. Each call here spawns a short-lived worker thread
8
+ * (ledger-worker-entry.ts) and falls back to the inline sync function when
9
+ * the worker cannot run (Node < 22.18 has no default type stripping for the
10
+ * TS entry) or times out. Writes stay sync on the main thread: they are
11
+ * single-row upserts, bounded by the 5s busy_timeout.
12
+ */
13
+
14
+ import { Worker } from "node:worker_threads";
15
+ import {
16
+ type CaseContextResult,
17
+ type ChainSuggestion,
18
+ getCasefilePath,
19
+ suggestChains,
20
+ writeCaseContext,
21
+ } from "./ledger.ts";
22
+ import { detectWorkspaceRoot } from "./scratchpad.ts";
23
+
24
+ type WorkerResponse = { ok: true; result: unknown } | { ok: false; error: string };
25
+
26
+ const WORKER_TIMEOUT_MS = 30_000;
27
+
28
+ function runInLedgerWorker(
29
+ request:
30
+ | {
31
+ op: "suggestChains";
32
+ caseId?: string;
33
+ }
34
+ | {
35
+ op: "writeCaseContext";
36
+ id: string;
37
+ },
38
+ ): Promise<unknown> {
39
+ const { promise, resolve, reject } = Promise.withResolvers<unknown>();
40
+ const worker = new Worker(new URL("./ledger-worker-entry.ts", import.meta.url), {
41
+ workerData: { casefilePath: getCasefilePath(), workspaceRoot: detectWorkspaceRoot() },
42
+ });
43
+ const timer = setTimeout(() => {
44
+ void worker.terminate();
45
+ reject(new Error("ledger worker timed out"));
46
+ }, WORKER_TIMEOUT_MS);
47
+ worker.once("message", (msg: WorkerResponse) => {
48
+ clearTimeout(timer);
49
+ void worker.terminate();
50
+ if (msg.ok) resolve(msg.result);
51
+ else reject(new Error(msg.error));
52
+ });
53
+ worker.once("error", (e) => {
54
+ clearTimeout(timer);
55
+ reject(e);
56
+ });
57
+ worker.postMessage(request);
58
+ return promise;
59
+ }
60
+
61
+ /** suggestChains on a worker thread; inline fallback keeps behavior identical. */
62
+ export async function suggestChainsAsync(caseId?: string): Promise<ChainSuggestion[]> {
63
+ try {
64
+ return (await runInLedgerWorker({ op: "suggestChains", caseId })) as ChainSuggestion[];
65
+ } catch {
66
+ return suggestChains(caseId);
67
+ }
68
+ }
69
+
70
+ /** writeCaseContext on a worker thread; inline fallback keeps behavior identical. */
71
+ export async function writeCaseContextAsync(id: string): Promise<CaseContextResult> {
72
+ try {
73
+ return (await runInLedgerWorker({ op: "writeCaseContext", id })) as CaseContextResult;
74
+ } catch {
75
+ return writeCaseContext(id);
76
+ }
77
+ }