@xaccefy/pi-casefile 0.9.3 → 0.10.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.
package/src/index.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * Casefile — offensive security case tracker for Pi.
3
3
  *
4
- * Tools: CaseAdd, CaseUpdate, PromoteFinding, ConfirmFinding, 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, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseContext, ScratchpadInit, ScratchpadResume, ScratchpadCheckpoint, ScratchpadWrite, ScratchpadRead, ScratchpadPhaseDone, ScratchpadClear
5
5
  * Command: /casefile — interactive dashboard
6
- * Event: before_agent_start — injects cyber workflow once per session, refreshes the active case list per prompt
6
+ * Event: before_agent_start — injects the recon workflow once per session, refreshes the active case list per prompt
7
7
  */
8
8
 
9
9
  import { createHash } from "node:crypto";
10
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
11
- import { dirname, join } from "node:path";
10
+ import { readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
12
  import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
13
13
  import { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
14
14
  import { type TSchema, Type } from "typebox";
@@ -55,6 +55,7 @@ import {
55
55
  LINK_KIND_VALUES,
56
56
  linkCasesResult,
57
57
  type MainAgentVerification,
58
+ type OobVerification,
58
59
  type PendingConfirmation,
59
60
  type PocEvidenceRun,
60
61
  PRIORITY_VALUES,
@@ -69,8 +70,14 @@ import {
69
70
  unlinkCasesResult,
70
71
  updateCaseResult,
71
72
  } from "./ledger.ts";
72
- import { suggestChainsAsync, writeCaseContextAsync } from "./ledger-worker.ts";
73
- import { pipeline_submit, SUBMIT_STAGES, type SubmitStage } from "./pipeline-submit.ts";
73
+ import { writeCaseContext } from "./ledger.ts";
74
+ import {
75
+ type OobOracleConfig,
76
+ type ProvisionedCallback,
77
+ provisionCallback,
78
+ readOobOracleConfig,
79
+ verifyOobDifferential,
80
+ } from "./oob-oracle.ts";
74
81
  import { type PocRun, type PocRunOptions, runPoc } from "./poc-runner.ts";
75
82
  import {
76
83
  detectWorkspaceRoot,
@@ -86,11 +93,7 @@ import {
86
93
  scratchpad_write,
87
94
  setScratchpadRoot,
88
95
  } from "./scratchpad.ts";
89
- import {
90
- STATIC_CYBER_WORKFLOW,
91
- STATIC_CYBER_WORKFLOW_LITE,
92
- STATIC_CYBER_WORKFLOW_OMP,
93
- } from "./workflow.ts";
96
+ import { STATIC_RECON_WORKFLOW, STATIC_RECON_WORKFLOW_OMP } from "./workflow.ts";
94
97
 
95
98
  // ── Schemas ───────────────────────────────────────────────────────────
96
99
 
@@ -135,6 +138,12 @@ const CommonFields = {
135
138
  description: "Documented attempt to disprove the finding before confirmation",
136
139
  }),
137
140
  ),
141
+ invariant: Type.Optional(
142
+ Type.String({
143
+ description:
144
+ "The security invariant this finding violates — the rule broken (e.g. 'a user cannot read another user's orders'). Confirmation checks the invariant is actually violated, not just that a request returned 200.",
145
+ }),
146
+ ),
138
147
  };
139
148
 
140
149
  // ── Tool: CaseAdd ─────────────────────────────────────────────────────
@@ -226,7 +235,7 @@ const PromoteSchema = Type.Object(
226
235
  oob: Type.Optional(
227
236
  Type.Boolean({
228
237
  description:
229
- "Reserved for source-separated out-of-band verification. Currently fails closed because a loopback listener reachable by the PoC cannot prove target causation.",
238
+ "Blind/OOB confirmation via the operator-run oracle (PI_OOB_ORACLE_URL). The harness provisions per-run callback tokens, injects PI_POC_CALLBACK_DOMAIN into the runs, and polls the oracle itself: promotion requires target-token interactions, ZERO control-token interactions, attested source separation (PI_OOB_SOURCE_SEPARATED=1), and self-source/missing-src_ip interactions are rejected. Without an oracle this fails closed.",
230
239
  }),
231
240
  ),
232
241
  },
@@ -365,7 +374,7 @@ const UnlinkSchema = Type.Object(
365
374
 
366
375
  // ── Tool: Scratchpad ─────────────────────────────────────────────────
367
376
  //
368
- // The scratchpad is the pipeline's crash-recoverable artifact store.
377
+ // The scratchpad is a crash-recoverable working-notes store for a run.
369
378
  // The casefile owns state transitions; the scratchpad owns artifacts
370
379
  // (recon maps, trace outputs, verification logs). Resume re-reads
371
380
  // artifacts; it does not re-run completed phases (idempotent).
@@ -605,10 +614,8 @@ class CasefileDashboard {
605
614
  }
606
615
 
607
616
  // ── Context injection ─────────────────────────────────────────────────
608
- // Injected once per user prompt via before_agent_start (not every tool turn).
609
- // Skills are opt-in; this keeps bounty discipline always present even with an empty ledger.
610
-
611
- // workflow.ts contains the full text
617
+ // The active case list is injected via before_agent_start (once per user
618
+ // prompt, not every tool turn) so open cases stay visible in context.
612
619
 
613
620
  function sanitizeContextText(v?: string, max = 160): string | undefined {
614
621
  // biome-ignore lint/suspicious/noControlCharactersInRegex: strip C0 controls from untrusted case text
@@ -694,101 +701,30 @@ function buildCaseListContext(records: CaseRecord[]): string {
694
701
 
695
702
  /**
696
703
  * Detect the extension host. OMP is a fork of Pi: both load the same
697
- * `pi`-manifest extensions, but subagent dispatch differs (pi-subagents'
704
+ * `pi`-manifest extensions, but recon subagent dispatch differs (pi-subagents'
698
705
  * `subagent({workflowScript})` vs OMP's native `task`). The entry script path
699
- * carries the host package: `@oh-my-pi/pi-coding-agent/dist/cli.js` under OMP,
700
- * `@earendil-works/pi-coding-agent` under Pi.
706
+ * carries the host package.
701
707
  */
702
- export function detectHost(): "omp" | "pi" {
708
+ function detectHost(): "omp" | "pi" {
703
709
  const argv = process.argv.join(" ");
704
710
  if (argv.includes("@oh-my-pi")) return "omp";
705
711
  return "pi";
706
712
  }
707
713
 
708
714
  /**
709
- * Builds the per-prompt injection. The cyber workflow is session-scope data
710
- * it never changes — so the caller passes includeWorkflow=true exactly once
711
- * per session; re-injecting it on every prompt is pure token cost. The active
712
- * case list DOES change as cases are added, so it is refreshed every prompt.
713
- *
714
- * mode selects the workflow text: "lite" injects the single-agent workflow
715
- * (no subagent dispatch), "swarm" gets the full subagent pipeline,
716
- * rendered for the host's dispatch convention (pi-subagents vs OMP task).
715
+ * Per-prompt injection. The recon workflow is session-scope guidance it never
716
+ * changes — so it is injected once (first prompt, includeWorkflow=true). The
717
+ * active case list DOES change as cases are added, so it refreshes every prompt.
718
+ * The workflow text is rendered for the host's dispatch convention.
717
719
  */
718
- function buildAgentInjection(
719
- active: CaseRecord[],
720
- includeWorkflow: boolean,
721
- mode: XpMode = "swarm",
722
- ): string {
720
+ function buildAgentInjection(active: CaseRecord[], includeWorkflow: boolean): string {
723
721
  const caseList = buildCaseListContext(active);
724
722
  if (!includeWorkflow) return caseList;
725
- const workflow =
726
- mode === "lite"
727
- ? STATIC_CYBER_WORKFLOW_LITE
728
- : detectHost() === "omp"
729
- ? STATIC_CYBER_WORKFLOW_OMP
730
- : STATIC_CYBER_WORKFLOW;
723
+ const workflow = detectHost() === "omp" ? STATIC_RECON_WORKFLOW_OMP : STATIC_RECON_WORKFLOW;
731
724
  // Workflow FIRST for prominence, then case list as reference data.
732
725
  return caseList ? `${workflow}\n\n${caseList}` : workflow;
733
726
  }
734
727
 
735
- // ── XP (offensive / exploit) mode toggle ─────────────────────────────
736
- // Casefile historically injected the cyber workflow into every prompt.
737
- // For normal dev work that is just noise, so XP mode defaults OFF. Enable
738
- // swarm for the bounded multi-agent variant, or lite for the single-agent
739
- // attacker discipline. Toggle with /xp (off <-> swarm), or set explicitly with
740
- // /xp on|lite|swarm|off. "on" means the default enabled SWARM mode; use "lite"
741
- // for no subagent dispatch.
742
- // Override per-session with PI_XP_MODE.
743
- // Pure helpers exported for unit tests.
744
-
745
- export const XP_MODE_ENV = "PI_XP_MODE";
746
- export type XpMode = "swarm" | "off" | "lite";
747
-
748
- export function getXpModeStatePath(): string {
749
- return join(dirname(getCasefilePath()), "xp-mode");
750
- }
751
-
752
- export function readXpMode(
753
- envValue: string | undefined = process.env[XP_MODE_ENV],
754
- statePath: string = getXpModeStatePath(),
755
- ): XpMode {
756
- const env = (envValue ?? "").trim().toLowerCase();
757
- if (env === "swarm") return "swarm";
758
- if (env === "on" || env === "1" || env === "true") return "swarm";
759
- if (env === "lite") return "lite";
760
- if (env === "off" || env === "0" || env === "false") return "off";
761
- try {
762
- if (existsSync(statePath)) {
763
- const v = readFileSync(statePath, "utf8").trim().toLowerCase();
764
- if (v === "swarm" || v === "on") return "swarm";
765
- if (v === "lite") return "lite";
766
- if (v === "off") return "off";
767
- }
768
- } catch {
769
- // ignore and fall through to default
770
- }
771
- return "off";
772
- }
773
-
774
- export function writeXpMode(state: XpMode, statePath: string = getXpModeStatePath()): void {
775
- try {
776
- writeFileSync(statePath, state, "utf8");
777
- } catch {
778
- // best-effort; env var can still override at runtime
779
- }
780
- }
781
-
782
- export function parseXpModeArg(args: string, current: XpMode): XpMode {
783
- const arg = (args ?? "").trim().toLowerCase();
784
- if (arg === "swarm") return "swarm";
785
- if (arg === "on") return "swarm";
786
- if (arg === "off") return "off";
787
- if (arg === "lite") return "lite";
788
- // Bare /xp is the low-ceremony path: toggle the default XP workflow on/off.
789
- return current === "off" ? "swarm" : "off";
790
- }
791
-
792
728
  // ── Main extension ────────────────────────────────────────────────────
793
729
 
794
730
  export default function casefileExtension(pi: ExtensionAPI) {
@@ -845,6 +781,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
845
781
  promptGuidelines: [
846
782
  "Use CaseAdd for a new security lead. New cases start as status='hypothesis' or 'investigating' — promote later with CaseUpdate.",
847
783
  "disproveIf is REQUIRED on CaseAdd: name the falsification conditions (what would disprove this hypothesis). A hypothesis that can't say what kills it isn't a hypothesis yet.",
784
+ "Declare the invariant: the security rule the finding would violate (e.g. 'a user cannot read another user's orders'). Confirmation checks the invariant is actually broken, not just that a request succeeded — a reproduction without a violated invariant is a mechanism, not a vulnerability.",
848
785
  "Check the injected case list or CaseList/CaseSearch first. Do not add a duplicate for the same title/scope.",
849
786
  "CaseAdd rejects exact and NEAR-duplicates (same target + overlapping title, e.g. parallel-subagent re-phrasings). A near-duplicate result → continue the existing case ID via CaseUpdate, don't create a new one.",
850
787
  "confirmed/reported only via their gates: proof in poc + PromoteFinding for confirmed; CaseContext + report for reported.",
@@ -1169,8 +1106,9 @@ export default function casefileExtension(pi: ExtensionAPI) {
1169
1106
  "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.",
1170
1107
  "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.",
1171
1108
  "control_path is optional and defaults to poc_path; if supplied, it must be 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.",
1109
+ "oob=true unlocks blind/OOB classes (SSRF, blind XSS, XXE): the harness provisions per-run callback tokens via the operator's oracle (PI_OOB_ORACLE_URL), injects PI_POC_CALLBACK_DOMAIN into the runs, and polls the oracle itself — target-token interactions with ZERO control-token interactions are required. Promotion additionally requires attested source separation (PI_OOB_SOURCE_SEPARATED=1); without it the verification stays diagnostic.",
1172
1110
  "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.",
1173
- "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.",
1111
+ "Blind/OOB classes fail closed unless the operator configured an OOB oracle; self-interactions (PI_OOB_SELF_IPS) are rejected and never counted as target hits.",
1174
1112
  "After the bundle is recorded, stay in the main agent: inspect the script/evidence, attempt disconfirmation, and call ConfirmFinding itself; that call performs a fresh harness-owned target/control replay. Never delegate validation/confirmation and never CaseUpdate status='confirmed' directly.",
1175
1113
  ],
1176
1114
  parameters: PromoteSchema,
@@ -1203,13 +1141,47 @@ export default function casefileExtension(pi: ExtensionAPI) {
1203
1141
  missingPocPath: true,
1204
1142
  });
1205
1143
  }
1206
- // Control-target preconditions apply only to the inter-host differential.
1207
- // Intra-target proves target-dependence with a same-host baseline request
1208
- // carried in the evidence, so it needs no control target or control script.
1209
- if (!isIntra) {
1144
+ const caseTarget = current.target ?? "";
1145
+ // ── OOB callback (Tier 1, opt-in for blind classes) ──
1146
+ // The operator-run oracle owns the evidence channel; the harness owns
1147
+ // the secret (per-run token, provisioned before the runs and injected
1148
+ // as env — the value does not exist when the script was written).
1149
+ // Without an oracle this stays fail-closed.
1150
+ const oobRequested = params.oob === true;
1151
+ // Intra-target + OOB is rejected up front: intra_target's
1152
+ // discriminating variable is identity/parameter on the SAME host;
1153
+ // mixing it with a callback differential would make precedence
1154
+ // ambiguous. OOB is for inter-host/blind classes.
1155
+ if (isIntra && oobRequested) {
1156
+ return fail(
1157
+ "mode:'intra_target' cannot be combined with oob:true — intra-target proof uses a same-host baseline request, not a callback channel. Use one or the other.",
1158
+ { intraOobConflict: true },
1159
+ );
1160
+ }
1161
+ let oobConfig: OobOracleConfig | undefined;
1162
+ let targetCallback: ProvisionedCallback | undefined;
1163
+ let controlCallback: ProvisionedCallback | undefined;
1164
+ if (oobRequested) {
1165
+ const oracle = readOobOracleConfig();
1166
+ if (!oracle.config) {
1167
+ return fail(`OOB CONFIRMATION UNAVAILABLE: ${oracle.error}`, {
1168
+ oobOracleNotConfigured: true,
1169
+ });
1170
+ }
1171
+ oobConfig = oracle.config;
1172
+ // Provision both identities concurrently — each is an oracle round trip.
1173
+ [targetCallback, controlCallback] = await Promise.all([
1174
+ provisionCallback(oobConfig),
1175
+ provisionCallback(oobConfig),
1176
+ ]);
1177
+ }
1178
+ // OOB-only bundles (blind classes, no operator-approved control host)
1179
+ // prove target-dependence via the token differential instead.
1180
+ const oobOnly = oobRequested && !controlTarget;
1181
+ if (!isIntra && !oobOnly) {
1210
1182
  if (!controlTarget) {
1211
1183
  return fail(
1212
- "control_target is REQUIRED for inter-host mode: a distinct baseline target that lacks the vulnerability. For access-control/logic bugs use mode='intra_target' with an evidence baseline instead.",
1184
+ "control_target is REQUIRED for inter-host mode: a distinct baseline target that lacks the vulnerability. For access-control/logic bugs use mode='intra_target' with an evidence baseline instead; for blind/OOB classes pass oob=true (with or without a control target).",
1213
1185
  { missingControlTarget: true },
1214
1186
  );
1215
1187
  }
@@ -1226,7 +1198,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
1226
1198
  { networkNotAuthorized: true },
1227
1199
  );
1228
1200
  }
1229
- if (!isIntra) {
1201
+ if (!isIntra && !oobOnly) {
1230
1202
  const controlAuthorization = controlTargetAuthorizationError(controlTarget);
1231
1203
  if (controlAuthorization) {
1232
1204
  return fail(
@@ -1247,7 +1219,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
1247
1219
  sameFileCheckFailed: true,
1248
1220
  });
1249
1221
  }
1250
- if (!isIntra) {
1222
+ if (!isIntra && !oobOnly) {
1251
1223
  let controlHash: string | undefined;
1252
1224
  try {
1253
1225
  controlHash = createHash("sha256").update(readFileSync(controlPath)).digest("hex");
@@ -1265,24 +1237,22 @@ export default function casefileExtension(pi: ExtensionAPI) {
1265
1237
  }
1266
1238
  }
1267
1239
 
1268
- // ── OOB callback (Tier 1, opt-in for blind classes) ──
1269
- const oobRequested = params.oob === true;
1270
- if (oobRequested) {
1271
- return fail(
1272
- "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.",
1273
- { oobSourceSeparationRequired: true },
1274
- );
1275
- }
1240
+ // ── OOB callback tokens were provisioned above, before the runs ──
1276
1241
  const runOptions = (pocMode: string, target: string): PocRunOptions => ({
1277
1242
  network: params.local === true ? "host" : "none",
1278
1243
  local: params.local === true,
1279
1244
  env: {
1280
1245
  PI_POC_MODE: pocMode,
1281
1246
  PI_POC_TARGET: target,
1247
+ ...(oobRequested && targetCallback && controlCallback
1248
+ ? {
1249
+ PI_POC_CALLBACK_DOMAIN:
1250
+ pocMode === "control" ? controlCallback.domain : targetCallback.domain,
1251
+ }
1252
+ : {}),
1282
1253
  },
1283
1254
  });
1284
1255
 
1285
- const caseTarget = current.target ?? "";
1286
1256
  // Determinism: TWO target runs. Exit 0 is run integrity only; nonce-bound
1287
1257
  // body evidence plus the harness-owned differential replay (inter-host
1288
1258
  // control, or intra-target same-host baseline) form the machine gate.
@@ -1336,8 +1306,18 @@ export default function casefileExtension(pi: ExtensionAPI) {
1336
1306
  evidenceRun(run2, "poc", caseTarget),
1337
1307
  ];
1338
1308
 
1309
+ // Reflection canary + OOB is rejected after run 1 (the canary is
1310
+ // declared inside evidence.json): the canary path requires a harness
1311
+ // response transcript, which OOB-only bundles never produce — the
1312
+ // per-run callback token IS the causality signal there.
1313
+ if (oobRequested && targetRuns.some((r) => r.evidence.verify.canary !== undefined)) {
1314
+ return fail(
1315
+ "verify.canary cannot be combined with oob:true — the per-run callback token already provides a harness-owned causality signal. Remove the {{PI_POC_CANARY}} placeholder and verify.canary from evidence.json, then re-promote.",
1316
+ { canaryOobConflict: true },
1317
+ );
1318
+ }
1339
1319
  const allowPrivateReplay = process.env.PI_POC_ALLOW_PRIVATE_REPLAY === "1";
1340
- let harnessVerified: HarnessVerifyResult;
1320
+ let harnessVerified: HarnessVerifyResult | undefined;
1341
1321
  let controlRun: PocEvidenceRun | undefined;
1342
1322
  if (isIntra) {
1343
1323
  // Intra-target: prove target-dependence with the evidence's same-host
@@ -1359,7 +1339,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
1359
1339
  harnessVerified = await replayIntraTarget(ev0, caseTarget, {
1360
1340
  allowPrivate: allowPrivateReplay,
1361
1341
  });
1362
- } else {
1342
+ } else if (!oobOnly) {
1363
1343
  // Inter-host (Tier 2): the harness executes the SAME request template
1364
1344
  // against target and operator-approved control, applying the target's
1365
1345
  // predicates to both. DNS is pinned at connect time.
@@ -1375,6 +1355,20 @@ export default function casefileExtension(pi: ExtensionAPI) {
1375
1355
  { allowPrivate: allowPrivateReplay },
1376
1356
  );
1377
1357
  }
1358
+ // OOB differential: poll the oracle for both run tokens. The ledger's
1359
+ // assertMachineConfirmation consumes this BEFORE the response-diff
1360
+ // requirement — blind classes pass via this path when the oracle saw
1361
+ // the target token and NOT the control token under attested source
1362
+ // separation.
1363
+ let callbackVerified: OobVerification | undefined;
1364
+ if (oobConfig && targetCallback && controlCallback) {
1365
+ callbackVerified = (
1366
+ await verifyOobDifferential({
1367
+ targetToken: targetCallback.token,
1368
+ controlToken: controlCallback.token,
1369
+ })
1370
+ ).verification;
1371
+ }
1378
1372
  const bundle: PendingConfirmation = {
1379
1373
  caseId,
1380
1374
  ranAt: new Date().toISOString(),
@@ -1383,7 +1377,16 @@ export default function casefileExtension(pi: ExtensionAPI) {
1383
1377
  mode,
1384
1378
  targetRuns,
1385
1379
  harnessVerified,
1386
- ...(isIntra ? {} : { controlPath, controlTarget, controlRun }),
1380
+ ...(callbackVerified && targetCallback && controlCallback
1381
+ ? {
1382
+ callbackVerified,
1383
+ oobTokens: {
1384
+ targetToken: targetCallback.token,
1385
+ controlToken: controlCallback.token,
1386
+ },
1387
+ }
1388
+ : {}),
1389
+ ...(!(isIntra || oobOnly) ? { controlPath, controlTarget, controlRun } : {}),
1387
1390
  };
1388
1391
 
1389
1392
  let record: CaseRecord;
@@ -1404,7 +1407,11 @@ export default function casefileExtension(pi: ExtensionAPI) {
1404
1407
  `Mode: ${mode}. ${isIntra ? "Target runs: 2, same-host baseline differential" : "Target runs: 2, Control run: 1"} — all with validated nonce-bound evidence.json.\n` +
1405
1408
  `Evidence sha256: ${targetRuns[0].evidenceSha256}\n` +
1406
1409
  `PoC script sha256 (at run time): ${pocHash}\n` +
1407
- `Harness verify replay: ${harnessVerified.attempted ? (harnessVerified.pass ? `PASS (status ${harnessVerified.status})` : `FAILED — ${harnessVerified.note}`) : harnessVerified.note}\n` +
1410
+ `Harness verify replay: ${harnessVerified?.attempted ? (harnessVerified.pass ? `PASS (status ${harnessVerified.status})` : `FAILED — ${harnessVerified.note}`) : (harnessVerified?.note ?? "not run")}
1411
+ ` +
1412
+ (callbackVerified
1413
+ ? `OOB oracle: target-token hits ${callbackVerified.targetHits}, control-token hits ${callbackVerified.controlHits}, source-separated: ${String(callbackVerified.sourceSeparated)} — ${callbackVerified.note}\n`
1414
+ : "") +
1408
1415
  `\nMAIN-AGENT REVIEW REQUIRED (do not delegate): inspect case ${caseId}, PoC ${pocPath}, ${isIntra ? "same-host baseline" : `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 ${isIntra ? "attack/baseline" : "target/control"} replay; NOT_CONFIRMED keeps the case investigating.`,
1409
1416
  },
1410
1417
  ],
@@ -1454,15 +1461,18 @@ export default function casefileExtension(pi: ExtensionAPI) {
1454
1461
  name: "ConfirmFinding",
1455
1462
  label: "Main-Agent Confirmation",
1456
1463
  description:
1457
- "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.",
1458
- promptSnippet: "Main agent: independently review and commit or refuse PoC confirmation",
1464
+ "Phase 2 of confirmation, reserved for the main/coordinator agent: commit or refuse promotion after independently re-testing the finding. 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. Three verdicts: CONFIRMED (you reproduced real impact), NOT_CONFIRMED (you POSITIVELY disproved it), INCONCLUSIVE (you could neither reproduce nor disprove — the case is preserved for manual review, never dropped).",
1465
+ promptSnippet: "Main agent: independently re-test, then commit or refuse PoC confirmation",
1459
1466
  promptGuidelines: [
1460
1467
  "Run only in the main/coordinator agent after PromoteFinding returns. Do not dispatch a worker to decide or author this verdict.",
1461
- "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.",
1462
- "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.",
1463
- "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.",
1464
- "NOT_CONFIRMED is final for that attemptthe case stays investigating with the main agent's reasoning recorded. A fresh PromoteFinding run is required for another attempt.",
1465
- "Never CaseUpdate status='confirmed' directly it is rejected. Always use PromoteFinding + ConfirmFinding.",
1468
+ "Verify with DISBELIEF: assume the finding is a false positive until your OWN re-test proves otherwise. Reproduce the exact observable yourself from the primary evidence (not the hunter's narrative), with a negative/baseline control a difference you cannot tie to the control is not proof. ConfirmFinding itself re-sends the immutable verify request against target and operator-approved control so phase 2 has a harness-owned transcript.",
1469
+ "Provenance: the proof must exercise THIS finding's own mechanism. Evidence obtained through a DIFFERENT bug (e.g. 'SQLi' proven by dumping the DB via an RCE) does not confirm it that is INCONCLUSIVE at best.",
1470
+ "Kill the cheapest benign explanation: is this the technology's intended behavior? Did the attacker supply the 'secret' themselves (circular)? Is the claimed C/I/A impact actually demonstrated?",
1471
+ "Want a second pair of eyes? Dispatch a read-only skeptic subagent to re-test it CANNOT confirm (only the main agent commits). You review its verdict and commit it here.",
1472
+ "CONFIRMED requires differential: 'target_only', re_execution_note, and disconfirmation_attempt (your failed disproof). A verdict missing any of these is rejected. Set canary_assessment='verified' when the immutable request declared a canary; otherwise not_applicable with a reason.",
1473
+ "NOT_CONFIRMED means you POSITIVELY disproved it (by-design, circular, mislabeled, no impact). Never mark NOT_CONFIRMED merely because you could not reproduce it.",
1474
+ "INCONCLUSIVE when you could neither reproduce nor disprove (needs auth, a second account, specific state, timing, or a blind/stored trigger you cannot observe). The case stays investigating and is preserved for manual review — dropping a real finding is worse than keeping an unproven one.",
1475
+ "Every verdict consumes the attempt: a fresh PromoteFinding run is required to try again. Never CaseUpdate status='confirmed' directly — always PromoteFinding + ConfirmFinding.",
1466
1476
  ],
1467
1477
  parameters: ConfirmSchema,
1468
1478
 
@@ -1493,6 +1503,46 @@ export default function casefileExtension(pi: ExtensionAPI) {
1493
1503
  replay = await replayIntraTarget(bundle.targetRuns[0].evidence, caseTargetForReplay, {
1494
1504
  allowPrivate,
1495
1505
  });
1506
+ } else if (bundle.callbackVerified?.attempted && bundle.oobTokens) {
1507
+ // OOB differential: fresh harness-owned re-poll of BOTH run tokens.
1508
+ // Re-polling at confirm time catches interactions that landed after
1509
+ // phase 1 (e.g. a delayed control-token hit) — the verdict is bound
1510
+ // to this fresh observation, not the stored one.
1511
+ const { verification } = await verifyOobDifferential({
1512
+ targetToken: bundle.oobTokens.targetToken,
1513
+ controlToken: bundle.oobTokens.controlToken,
1514
+ });
1515
+ const oobPass =
1516
+ verification.targetHits > 0 &&
1517
+ verification.controlHits === 0 &&
1518
+ verification.sourceSeparated === true;
1519
+ replay = {
1520
+ attempted: true,
1521
+ pass: oobPass,
1522
+ target: {
1523
+ attempted: true,
1524
+ matched: verification.targetHits > 0,
1525
+ url: bundle.targetRuns[0].evidence.verify.url,
1526
+ note: verification.note,
1527
+ },
1528
+ control: {
1529
+ attempted: true,
1530
+ matched: verification.controlHits > 0,
1531
+ url: bundle.targetRuns[0].evidence.verify.url,
1532
+ note: `${verification.controlHits} control-token interaction(s)`,
1533
+ },
1534
+ differential:
1535
+ verification.targetHits > 0
1536
+ ? verification.controlHits === 0
1537
+ ? "target_only"
1538
+ : "both"
1539
+ : "neither",
1540
+ note: `harness OOB re-poll: ${verification.note}`,
1541
+ };
1542
+ } else if (bundle.callbackVerified?.attempted) {
1543
+ throw new Error(
1544
+ "OOB bundle lacks its provisioned tokens (pre-token-storage ledger) — re-run PromoteFinding for a fresh bundle",
1545
+ );
1496
1546
  } else {
1497
1547
  if (!bundle.controlTarget) {
1498
1548
  throw new Error("inter-host confirmation requires a control target");
@@ -1525,7 +1575,10 @@ export default function casefileExtension(pi: ExtensionAPI) {
1525
1575
  text: promoted
1526
1576
  ? `Main agent CONFIRMED. Case promoted:
1527
1577
  ${formatCaseDetail(record)}`
1528
- : `Main agent NOT_CONFIRMED — case stays investigating (attempt recorded):
1578
+ : parsedVerdict.verdict.verdict === "INCONCLUSIVE"
1579
+ ? `Main agent INCONCLUSIVE — case stays investigating, preserved for manual review (not disproved):
1580
+ ${formatCaseDetail(record)}`
1581
+ : `Main agent NOT_CONFIRMED — case stays investigating (attempt recorded):
1529
1582
  ${formatCaseDetail(record)}`,
1530
1583
  },
1531
1584
  ],
@@ -1763,67 +1816,6 @@ ${formatCaseDetail(record)}`,
1763
1816
  },
1764
1817
  });
1765
1818
 
1766
- // ── Tool: ChainSuggest ──
1767
-
1768
- const ChainSuggestSchema = Type.Object(
1769
- {
1770
- case_id: Type.Optional(
1771
- Type.String({
1772
- description:
1773
- "Optional: scope suggestions to this case and its linked cases. Omit to scan all non-terminal cases.",
1774
- }),
1775
- ),
1776
- },
1777
- { additionalProperties: false },
1778
- );
1779
-
1780
- registerCaseTool({
1781
- name: "ChainSuggest",
1782
- label: "Suggest Exploit Chains",
1783
- description:
1784
- "Scan non-terminal cases for exploitable chains (credential+endpoint→ATO, open-redirect+OAuth→token theft, XSS+state-changing→CSRF bypass, IDOR+user-data→mass leak, SSTI→RCE, race+payment→financial, info-disclosure+SSRF). Returns ranked candidates with confidence and a suggested link kind — the agent decides whether to CaseLink them or open an escalation case. Catches chain combinations the model may have missed.",
1785
- promptSnippet: "Find missed exploit-chain combinations",
1786
- promptGuidelines: [
1787
- "Run ChainSuggest before concluding an engagement — low-severity findings that chain into high-impact (ATO, token theft, mass leak) are the ones triage cares about.",
1788
- "A suggestion is a HYPOTHESIS to verify, not a finding: test the chained behavior on the live target before linking or promoting anything.",
1789
- "Chain a suggested pair with CaseLink (suggested kind) or open a new escalation case with status=hypothesis.",
1790
- ],
1791
- parameters: ChainSuggestSchema,
1792
-
1793
- async execute(_id, params, _signal, _onUpdate, _ctx) {
1794
- const suggestions = await suggestChainsAsync(
1795
- (params.case_id as string | undefined) ?? undefined,
1796
- );
1797
- const lines: string[] = [
1798
- suggestions.length
1799
- ? `${suggestions.length} chain candidate(s):`
1800
- : "No chain candidates found across non-terminal cases.",
1801
- ];
1802
- for (const s of suggestions) {
1803
- const pair = s.targetId
1804
- ? `${s.sourceId} (${s.sourceTitle}) + ${s.targetId} (${s.targetTitle ?? ""})`
1805
- : `${s.sourceId} (${s.sourceTitle})`;
1806
- lines.push(
1807
- `\n[${s.pattern}] conf ${s.confidence}% — ${pair}\n ${s.rationale}${s.suggestedKind ? `\n suggested link kind: ${s.suggestedKind}` : ""}`,
1808
- );
1809
- }
1810
- return {
1811
- content: [{ type: "text", text: lines.join("\n") }],
1812
- details: { suggestions },
1813
- };
1814
- },
1815
-
1816
- renderCall(args, theme) {
1817
- return callLine(theme, "ChainSuggest", (args.case_id as string) ?? "all");
1818
- },
1819
-
1820
- renderResult(result, _opts, theme) {
1821
- const details = result.details as { suggestions?: { length: number } } | undefined;
1822
- const n = details?.suggestions?.length ?? 0;
1823
- return new Text(theme.fg(n ? "accent" : "dim", `${n} chain candidate(s)`), 0, 0);
1824
- },
1825
- });
1826
-
1827
1819
  // ── Tool: CaseContext ──
1828
1820
 
1829
1821
  registerCaseTool({
@@ -1839,7 +1831,7 @@ ${formatCaseDetail(record)}`,
1839
1831
  parameters: IdSchema,
1840
1832
 
1841
1833
  async execute(_id, params, _signal, _onUpdate, _ctx) {
1842
- const { path, contextPath, record } = await writeCaseContextAsync(params.id as string);
1834
+ const { path, contextPath, record } = writeCaseContext(params.id as string);
1843
1835
  return {
1844
1836
  content: [
1845
1837
  {
@@ -1865,97 +1857,6 @@ ${formatCaseDetail(record)}`,
1865
1857
  },
1866
1858
  });
1867
1859
 
1868
- // ── Command: /xp (toggle offensive XP mode) ──
1869
-
1870
- pi.registerCommand("xp", {
1871
- description:
1872
- "Toggle casefile XP (offensive) mode. Bare /xp and /xp on select SWARM, the bounded multi-agent workflow. LITE keeps XP single-agent. OFF (default) keeps context quiet for normal dev work. Usage: /xp [on|lite|swarm|off]",
1873
- handler: async (args, ctx) => {
1874
- const next = parseXpModeArg(args ?? "", readXpMode());
1875
- writeXpMode(next);
1876
- // Re-enabling after a mid-session /xp off must re-inject the workflow
1877
- // on the next prompt — otherwise workflowInjected (module-level, set on
1878
- // first enable) stays true and the workflow never comes back until the
1879
- // process restarts.
1880
- if (next !== "off") workflowInjected = false;
1881
- ctx.ui.notify(
1882
- `Casefile XP mode: ${next.toUpperCase()} (takes effect on the next prompt)`,
1883
- next === "off" ? "warning" : "info",
1884
- );
1885
- },
1886
- });
1887
-
1888
- // ── Tool: PipelineSubmit ──
1889
-
1890
- registerCaseTool({
1891
- name: "PipelineSubmit",
1892
- label: "Submit Stage Output",
1893
- description:
1894
- "Submit a pipeline stage's output (hunt, trace, skeptic, validate, chain, report) through the validation gate. Validates required fields against the stage spec (mirrors schemas/*.json), applies the deterministic pre-filter (test-path and file-existence filters on hunt findings, trivial dedup by file+class+line), and counts repair attempts (max 2, then rejected). A stage cannot advance on an invalid output — submit fixed output until accepted.",
1895
- promptSnippet: "Validate and submit a pipeline stage's output",
1896
- promptGuidelines: [
1897
- "Every delegated stage output and every main-agent VALIDATE/REPORT output must go through PipelineSubmit before the next stage starts — do not eyeball schemas.",
1898
- "verdict repair → fix the listed fields and re-submit the same output; budget is 2 attempts per finding, then rejected.",
1899
- "Skeptic: unparseable/schema-invalid = no verdict (repair/re-dispatch); only schema-valid DISPROVEN kills, and schema-valid UNDETERMINED blocks validation.",
1900
- "Tracer crash/invalid output = no trace verdict; repair or re-dispatch.",
1901
- 'Only schema-valid trace_result: "UNREACHABLE" blocks advancement as a proven unreachable path; schema-valid UNDETERMINED blocks validation until resolved.',
1902
- "Test-path findings and hallucinated files are rejected by the pre-filter, not repairable — the finding itself is noise.",
1903
- ],
1904
- parameters: Type.Object(
1905
- {
1906
- run_id: Type.String({
1907
- description: "Pipeline run identifier (same as the scratchpad run_id)",
1908
- }),
1909
- stage: Type.String({
1910
- enum: [...SUBMIT_STAGES],
1911
- description: "Pipeline stage: hunt | trace | skeptic | validate | chain | report",
1912
- }),
1913
- output: Type.Union([Type.String(), Type.Object({}, { additionalProperties: true })], {
1914
- description: "The stage output as a JSON object or JSON string (code fences tolerated)",
1915
- }),
1916
- },
1917
- { additionalProperties: false },
1918
- ),
1919
-
1920
- async execute(_id, params, _signal, _onUpdate, _ctx) {
1921
- const result = pipeline_submit(
1922
- params.run_id as string,
1923
- params.stage as SubmitStage,
1924
- params.output,
1925
- );
1926
- const statusLine =
1927
- result.verdict === "accepted"
1928
- ? `ACCEPTED (${params.stage}) — artifact: ${result.artifact}`
1929
- : result.verdict === "repair"
1930
- ? `REPAIR (attempt ${result.repair_attempt}/2) — fix these and re-submit:\n - ${result.errors.join("\n - ")}`
1931
- : `REJECTED — ${result.errors.join("\n")}`;
1932
- if (result.verdict !== "accepted") throw new Error(statusLine);
1933
- return {
1934
- content: [{ type: "text", text: statusLine }],
1935
- details: result as unknown as Record<string, unknown>,
1936
- };
1937
- },
1938
-
1939
- renderCall(args, theme) {
1940
- return callLine(theme, "PipelineSubmit", `${args.stage ?? ""}`);
1941
- },
1942
-
1943
- renderResult(result, _opts, theme) {
1944
- const details = result.details as { verdict?: string; repair_attempt?: number } | undefined;
1945
- if (details?.verdict === "accepted") {
1946
- return new Text(theme.fg("success", "✓ PipelineSubmit accepted"), 0, 0);
1947
- }
1948
- if (details?.verdict === "repair") {
1949
- return new Text(
1950
- theme.fg("warning", `↷ PipelineSubmit repair ${details.repair_attempt}/2`),
1951
- 0,
1952
- 0,
1953
- );
1954
- }
1955
- return new Text(theme.fg("error", "✗ PipelineSubmit rejected"), 0, 0);
1956
- },
1957
- });
1958
-
1959
1860
  // ── Tool: ScratchpadInit ──
1960
1861
 
1961
1862
  registerCaseTool({
@@ -2319,22 +2220,19 @@ ${formatCaseDetail(record)}`,
2319
2220
  }
2320
2221
  });
2321
2222
 
2322
- // ── Event: Inject cyber workflow into system prompt ──
2323
- // XP (offensive) mode is OFF by default so normal dev work stays quiet.
2324
- // When enabled, the cyber workflow is injected ONCE per session (first
2325
- // prompt); the active case list refreshes every prompt because it changes
2326
- // as cases are added. Injecting into event.systemPrompt (not as a
2327
- // conversation message) avoids session bloat from repeated message entries.
2223
+ // ── Event: Inject the recon workflow + active-case list into the prompt ──
2224
+ // The recon workflow is injected ONCE per session (first prompt); the active
2225
+ // case list refreshes every prompt because it changes as cases are added.
2226
+ // Injecting into event.systemPrompt (not as a conversation message) avoids
2227
+ // session bloat from repeated message entries.
2328
2228
  let workflowInjected = false;
2329
2229
 
2330
2230
  pi.on("before_agent_start", async (event) => {
2331
- const mode = readXpMode();
2332
- if (mode === "off") return;
2333
2231
  // Skip subagent child processes: pi-subagents runs each child in its own
2334
2232
  // pi process (PI_SUBAGENT_CHILD=1) with this extension loaded. Injecting
2335
2233
  // the workflow + entire active-case ledger into every child dispatch is a
2336
- // token multiplier (N subagents × workflow + growing case list per turn) —
2337
- // workers get what they need via their task and tool guidelines.
2234
+ // token multiplier (N children × workflow + growing case list per turn) —
2235
+ // recon workers get what they need via their task, not the coordinator's.
2338
2236
  if (isSubagentProcess()) return;
2339
2237
 
2340
2238
  const includeWorkflow = !workflowInjected;
@@ -2343,15 +2241,13 @@ ${formatCaseDetail(record)}`,
2343
2241
  try {
2344
2242
  active = readActiveCases();
2345
2243
  } catch {
2346
- // No database yet — still inject workflow.
2244
+ // No database yet — still inject the workflow.
2347
2245
  }
2348
2246
 
2349
- const injection = buildAgentInjection(active, includeWorkflow, mode);
2247
+ const injection = buildAgentInjection(active, includeWorkflow);
2350
2248
  if (!injection) return; // workflow already injected, no active cases
2351
2249
  workflowInjected = true;
2352
2250
 
2353
- // Inject workflow FIRST (before skills) so the attacker mindset is
2354
- // prominent, not buried at the end of a long system prompt.
2355
2251
  return {
2356
2252
  systemPrompt: `${injection}\n\n${event.systemPrompt ?? ""}`,
2357
2253
  };