@xaccefy/pi-casefile 0.9.1 → 0.9.3

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
@@ -9,9 +9,9 @@
9
9
  import { createHash } from "node:crypto";
10
10
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
11
11
  import { dirname, join } from "node:path";
12
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
13
13
  import { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
14
- import { Type } from "typebox";
14
+ import { type TSchema, Type } from "typebox";
15
15
  import {
16
16
  CANARY_ASSESSMENT_VALUES,
17
17
  CONFIRM_DIFFERENTIAL_VALUES,
@@ -23,6 +23,7 @@ import {
23
23
  controlTargetAuthorizationError,
24
24
  type HarnessVerifyResult,
25
25
  replayDifferential,
26
+ replayIntraTarget,
26
27
  } from "./harness-verify.ts";
27
28
  import {
28
29
  addCaseResult,
@@ -73,7 +74,7 @@ import { pipeline_submit, SUBMIT_STAGES, type SubmitStage } from "./pipeline-sub
73
74
  import { type PocRun, type PocRunOptions, runPoc } from "./poc-runner.ts";
74
75
  import {
75
76
  detectWorkspaceRoot,
76
- PHASE_ORDER,
77
+ SCRATCHPAD_PHASES,
77
78
  type ScratchpadPhase,
78
79
  type ScratchpadResume,
79
80
  scratchpad_checkpoint,
@@ -182,12 +183,13 @@ const EvidenceAddSchema = Type.Object(
182
183
 
183
184
  // ── Tool: PromoteFinding (phase 1) / ConfirmFinding (phase 2) ──────────
184
185
  //
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
+ // Confirmation is TWO-PHASE and main-agent-owned: PromoteFinding runs the PoC
187
+ // 2x + control, validates nonce-bound evidence.json, and records the pending
188
+ // bundle; ConfirmFinding then performs the main coordinator's review/replay and
189
+ // commits or refuses the verdict. Subagents may gather or challenge evidence,
190
+ // but they cannot run validation or confirmation gates. Zero exit is necessary
191
+ // run integrity and markers are diagnostic only; the machine records
192
+ // predicate/canary differentials and the main agent owns the semantic judgment.
191
193
 
192
194
  const PromoteSchema = Type.Object(
193
195
  {
@@ -195,15 +197,26 @@ const PromoteSchema = Type.Object(
195
197
  poc_path: Type.String({
196
198
  description: "Absolute path to the PoC script on disk",
197
199
  }),
198
- control_path: Type.String({
199
- description:
200
- "REQUIRED: absolute path to 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.",
201
- }),
202
- control_target: Type.String({
203
- minLength: 1,
204
- description:
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).",
206
- }),
200
+ control_path: Type.Optional(
201
+ Type.String({
202
+ description:
203
+ "Optional absolute path to the SAME script as poc_path (sha256-equality is ENFORCED). Defaults to poc_path. The harness runs it with PI_POC_MODE=control and PI_POC_TARGET=control_target.",
204
+ }),
205
+ ),
206
+ mode: Type.Optional(
207
+ Type.String({
208
+ enum: ["inter_host", "intra_target"],
209
+ description:
210
+ "Differential shape. 'inter_host' (default) proves target-dependence with a distinct patched control host — for body-carried proof (file read, injection exfil, info leak, reflection). 'intra_target' proves it with a legitimate same-host baseline request declared in the evidence — for access-control / business-logic classes (IDOR, auth bypass, privilege escalation, logic flaws) where the discriminating variable is identity or a parameter, not the host. In intra_target the evidence must set verify.mode='intra_target' and include a baseline; control_target/control_path are not used.",
211
+ }),
212
+ ),
213
+ control_target: Type.Optional(
214
+ Type.String({
215
+ minLength: 1,
216
+ description:
217
+ "REQUIRED for mode='inter_host': a distinct baseline target that lacks the vulnerability and is operator-approved through PI_POC_CONTROL_TARGETS (patched replica, second account, baseline service). Not used for mode='intra_target'.",
218
+ }),
219
+ ),
207
220
  local: Type.Optional(
208
221
  Type.Boolean({
209
222
  description:
@@ -358,9 +371,9 @@ const UnlinkSchema = Type.Object(
358
371
  // artifacts; it does not re-run completed phases (idempotent).
359
372
 
360
373
  const ScratchpadPhaseSchema = Type.String({
361
- enum: [...PHASE_ORDER],
374
+ enum: [...SCRATCHPAD_PHASES],
362
375
  description:
363
- "Pipeline phase: recon | hunt | gapfil | trace | skeptic | validate | chain | patch | report",
376
+ "Pipeline phase: recon | hunt | trace | skeptic | validate | chain | patch | report (legacy gapfil is accepted for older runs)",
364
377
  });
365
378
 
366
379
  /** run_id-only schema, shared by Scratchpad Init / Resume / Clear. */
@@ -699,13 +712,13 @@ export function detectHost(): "omp" | "pi" {
699
712
  * case list DOES change as cases are added, so it is refreshed every prompt.
700
713
  *
701
714
  * mode selects the workflow text: "lite" injects the single-agent workflow
702
- * (no subagent dispatch), anything else gets the full subagent pipeline,
715
+ * (no subagent dispatch), "swarm" gets the full subagent pipeline,
703
716
  * rendered for the host's dispatch convention (pi-subagents vs OMP task).
704
717
  */
705
718
  function buildAgentInjection(
706
719
  active: CaseRecord[],
707
720
  includeWorkflow: boolean,
708
- mode: XpMode = "on",
721
+ mode: XpMode = "swarm",
709
722
  ): string {
710
723
  const caseList = buildCaseListContext(active);
711
724
  if (!includeWorkflow) return caseList;
@@ -722,13 +735,15 @@ function buildAgentInjection(
722
735
  // ── XP (offensive / exploit) mode toggle ─────────────────────────────
723
736
  // Casefile historically injected the cyber workflow into every prompt.
724
737
  // For normal dev work that is just noise, so XP mode defaults OFF. Enable
725
- // it for offensive/audit sessions to get the full attacker discipline back,
726
- // or lite for the single-agent variant (no subagent dispatch). Toggle with
727
- // /xp (or /xp on|off|lite); override per-session with PI_XP_MODE.
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.
728
743
  // Pure helpers exported for unit tests.
729
744
 
730
745
  export const XP_MODE_ENV = "PI_XP_MODE";
731
- export type XpMode = "on" | "off" | "lite";
746
+ export type XpMode = "swarm" | "off" | "lite";
732
747
 
733
748
  export function getXpModeStatePath(): string {
734
749
  return join(dirname(getCasefilePath()), "xp-mode");
@@ -739,13 +754,14 @@ export function readXpMode(
739
754
  statePath: string = getXpModeStatePath(),
740
755
  ): XpMode {
741
756
  const env = (envValue ?? "").trim().toLowerCase();
742
- if (env === "on" || env === "1" || env === "true") return "on";
757
+ if (env === "swarm") return "swarm";
758
+ if (env === "on" || env === "1" || env === "true") return "swarm";
743
759
  if (env === "lite") return "lite";
744
760
  if (env === "off" || env === "0" || env === "false") return "off";
745
761
  try {
746
762
  if (existsSync(statePath)) {
747
763
  const v = readFileSync(statePath, "utf8").trim().toLowerCase();
748
- if (v === "on") return "on";
764
+ if (v === "swarm" || v === "on") return "swarm";
749
765
  if (v === "lite") return "lite";
750
766
  if (v === "off") return "off";
751
767
  }
@@ -765,11 +781,12 @@ export function writeXpMode(state: XpMode, statePath: string = getXpModeStatePat
765
781
 
766
782
  export function parseXpModeArg(args: string, current: XpMode): XpMode {
767
783
  const arg = (args ?? "").trim().toLowerCase();
768
- if (arg === "on") return "on";
784
+ if (arg === "swarm") return "swarm";
785
+ if (arg === "on") return "swarm";
769
786
  if (arg === "off") return "off";
770
787
  if (arg === "lite") return "lite";
771
- // Bare /xp toggles between on and off (lite is only set explicitly).
772
- return current === "on" ? "off" : "on";
788
+ // Bare /xp is the low-ceremony path: toggle the default XP workflow on/off.
789
+ return current === "off" ? "swarm" : "off";
773
790
  }
774
791
 
775
792
  // ── Main extension ────────────────────────────────────────────────────
@@ -789,34 +806,37 @@ export default function casefileExtension(pi: ExtensionAPI) {
789
806
  setScratchpadRoot(workspaceRoot);
790
807
  process.env.PI_POC_ROOT ??= workspaceRoot;
791
808
 
792
- // ── Diagnostic Error Handler Middleware ──
793
- const originalRegisterTool = pi.registerTool.bind(pi);
794
- pi.registerTool = (spec: any) => {
809
+ // ── Diagnostic Error Handler ──
810
+ const registerCaseTool = <TParams extends TSchema, TDetails = unknown, TState = unknown>(
811
+ spec: ToolDefinition<TParams, TDetails, TState>,
812
+ ) => {
795
813
  const origExecute = spec.execute;
796
- spec.execute = async (...args: any[]) => {
797
- try {
798
- return await origExecute(...args);
799
- } catch (err) {
800
- const message = err instanceof Error ? err.message : String(err);
801
- let hint = "";
802
- if (
803
- message.includes("SQLITE") ||
804
- message.includes("database") ||
805
- message.includes("permission") ||
806
- message.includes("readonly") ||
807
- message.includes("lock")
808
- ) {
809
- hint = `\n\nHint: A database access error occurred on the casefile SQLite ledger.\nTo troubleshoot:\n 1. Check filesystem read/write permissions for the database path: ${getCasefilePath()}.\n 2. If using a locked folder, you can override the ledger location by setting:\n export PI_CASEFILE_PATH=/your/writable/directory/casefile.db`;
814
+ pi.registerTool({
815
+ ...spec,
816
+ execute: async (...args: Parameters<typeof origExecute>) => {
817
+ try {
818
+ return await origExecute(...args);
819
+ } catch (err) {
820
+ const message = err instanceof Error ? err.message : String(err);
821
+ let hint = "";
822
+ if (
823
+ message.includes("SQLITE") ||
824
+ message.includes("database") ||
825
+ message.includes("permission") ||
826
+ message.includes("readonly") ||
827
+ message.includes("lock")
828
+ ) {
829
+ hint = `\n\nHint: A database access error occurred on the casefile SQLite ledger.\nTo troubleshoot:\n 1. Check filesystem read/write permissions for the database path: ${getCasefilePath()}.\n 2. If using a locked folder, you can override the ledger location by setting:\n export PI_CASEFILE_PATH=/your/writable/directory/casefile.db`;
830
+ }
831
+ throw new Error(`${spec.name} failed: ${message}${hint}`, { cause: err });
810
832
  }
811
- throw new Error(`${spec.name} failed: ${message}${hint}`, { cause: err });
812
- }
813
- };
814
- originalRegisterTool(spec);
833
+ },
834
+ });
815
835
  };
816
836
 
817
837
  // ── Tool: CaseAdd ──
818
838
 
819
- pi.registerTool({
839
+ registerCaseTool({
820
840
  name: "CaseAdd",
821
841
  label: "Add Case",
822
842
  description:
@@ -870,7 +890,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
870
890
 
871
891
  // ── Tool: CaseUpdate ──
872
892
 
873
- pi.registerTool({
893
+ registerCaseTool({
874
894
  name: "CaseUpdate",
875
895
  label: "Update Case",
876
896
  description:
@@ -925,7 +945,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
925
945
 
926
946
  // ── Tool: EvidenceAdd ──
927
947
 
928
- pi.registerTool({
948
+ registerCaseTool({
929
949
  name: "EvidenceAdd",
930
950
  label: "Add Evidence Item",
931
951
  description:
@@ -945,7 +965,8 @@ export default function casefileExtension(pi: ExtensionAPI) {
945
965
  summary: params.summary as string,
946
966
  artifactPath: params.artifact_path as string | undefined,
947
967
  });
948
- const record = getCaseById(params.case_id as string)!;
968
+ const record = getCaseById(params.case_id as string);
969
+ if (!record) throw new Error(`Case not found after evidence insert: ${params.case_id}`);
949
970
  return {
950
971
  content: [
951
972
  {
@@ -1018,7 +1039,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
1018
1039
  { additionalProperties: false },
1019
1040
  );
1020
1041
 
1021
- pi.registerTool({
1042
+ registerCaseTool({
1022
1043
  name: "CoverageAdd",
1023
1044
  label: "Record Coverage",
1024
1045
  description:
@@ -1040,7 +1061,8 @@ export default function casefileExtension(pi: ExtensionAPI) {
1040
1061
  note: params.note as string,
1041
1062
  evidenceItemId: params.evidence_item_id as string | undefined,
1042
1063
  });
1043
- const record = getCaseById(params.case_id as string)!;
1064
+ const record = getCaseById(params.case_id as string);
1065
+ if (!record) throw new Error(`Case not found after coverage insert: ${params.case_id}`);
1044
1066
  return {
1045
1067
  content: [
1046
1068
  {
@@ -1084,11 +1106,11 @@ export default function casefileExtension(pi: ExtensionAPI) {
1084
1106
  { additionalProperties: false },
1085
1107
  );
1086
1108
 
1087
- pi.registerTool({
1109
+ registerCaseTool({
1088
1110
  name: "CoverageReport",
1089
1111
  label: "Coverage Matrix",
1090
1112
  description:
1091
- "Render the machine-checkable coverage matrix for a case: which (asset × attack-class) cells are tested, with wide-verdict propagation. Run before deciding the hunt/gapfill is done — the plateau stop (zero new classes testable) must be visible in the matrix, not asserted in prose.",
1113
+ "Render the machine-checkable coverage matrix for a case: which (asset × attack-class) cells are tested, with wide-verdict propagation. Run before deciding HUNT coverage is done — the plateau stop (zero new classes testable) must be visible in the matrix, not asserted in prose.",
1092
1114
  promptSnippet: "Show which attack classes were tested where",
1093
1115
  promptGuidelines: [
1094
1116
  "Run CoverageReport before claiming 'every class is COVERED/SKIPPED/NOT_FOUND' — the claim must match the matrix.",
@@ -1133,239 +1155,294 @@ export default function casefileExtension(pi: ExtensionAPI) {
1133
1155
 
1134
1156
  // ── Tool: PromoteFinding (phase 1) ──
1135
1157
 
1136
- pi.registerTool({
1137
- name: "PromoteFinding",
1138
- label: "Run PoC Evidence",
1139
- description:
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.",
1141
- promptSnippet: "Phase 1: run PoC evidence (target x2 + control) and record the pending bundle",
1142
- promptGuidelines: [
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.",
1151
- ],
1152
- parameters: PromoteSchema,
1153
-
1154
- async execute(_id, params, _signal, _onUpdate, _ctx) {
1155
- // Validate promotability BEFORE running the PoC — each sandboxed run can
1156
- // take 30s (plus first-time image pull), so fail cheap when the case
1157
- // can't advance anyway (missing, wrong status, missing required fields,
1158
- // missing artifact-backed observation evidence).
1159
- const caseId = params.id as string;
1160
- const current = assertPromotable(caseId);
1161
-
1162
- const fail = (text: string, _extra?: Record<string, unknown>): never => {
1163
- throw new Error(text);
1164
- };
1165
-
1166
- const controlPath = (params.control_path as string | undefined)?.trim() ?? "";
1167
- const controlTarget = (params.control_target as string | undefined)?.trim() ?? "";
1168
- if (!controlPath) {
1169
- return fail(
1170
- "control_path is REQUIRED: the SAME script as poc_path (sha256-equality is ENFORCED), run by the harness with PI_POC_MODE=control and PI_POC_TARGET=control_target.",
1171
- { missingControl: true },
1172
- );
1173
- }
1174
- if (!controlTarget) {
1175
- return fail(
1176
- "control_target is REQUIRED: a distinct baseline target that lacks the vulnerability.",
1177
- { missingControlTarget: true },
1178
- );
1179
- }
1180
- if (controlTarget === current.target) {
1181
- return fail(
1182
- "control_target must differ from the case target; a control run against the vulnerable target proves nothing.",
1183
- { controlTargetEqualsCaseTarget: true },
1184
- );
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
- }
1158
+ if (!startedAsSubagent)
1159
+ registerCaseTool({
1160
+ name: "PromoteFinding",
1161
+ label: "Run PoC Evidence",
1162
+ description:
1163
+ "Main-agent 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. control_path defaults to poc_path; if supplied, sha256 equality is enforced. 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. Worker/subagent processes are rejected.",
1164
+ promptSnippet:
1165
+ "Phase 1: run PoC evidence (target x2 + control) and record the pending bundle",
1166
+ promptGuidelines: [
1167
+ "Use PromoteFinding only from the main/coordinator agent when an investigating case has a concrete PoC script on disk and you are ready to subject its claim to the machine gate.",
1168
+ "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.",
1169
+ "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
+ "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
+ "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.",
1172
+ "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.",
1174
+ "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
+ ],
1176
+ parameters: PromoteSchema,
1200
1177
 
1201
- // Same-file contract (anti-cheat): control must be the SAME bytes as the
1202
- // PoC, differing only via the harness-set env. Check BEFORE any run.
1203
- const pocPath = (params.poc_path as string | undefined)?.trim() ?? "";
1204
- let pocHash: string | undefined;
1205
- let controlHash: string | undefined;
1206
- try {
1207
- pocHash = createHash("sha256").update(readFileSync(pocPath)).digest("hex");
1208
- controlHash = createHash("sha256").update(readFileSync(controlPath)).digest("hex");
1209
- } catch (e) {
1210
- return fail(
1211
- `Cannot read PoC/control scripts for the same-file check: ${(e as Error).message}`,
1212
- { sameFileCheckFailed: true },
1213
- );
1214
- }
1215
- if (pocHash !== controlHash) {
1216
- return fail(
1217
- "CONTROL CHECK FAILED: control_path must be the SAME script as poc_path (sha256 mismatch). Case remains investigating.",
1218
- { controlHashMismatch: true },
1219
- );
1220
- }
1178
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
1179
+ if (isSubagentProcess()) {
1180
+ throw new Error(
1181
+ "PromoteFinding is reserved for the main/coordinator agent. A worker or subagent may gather evidence but cannot run validation or create a promotion bundle.",
1182
+ );
1183
+ }
1184
+ // Validate promotability BEFORE running the PoC — each sandboxed run can
1185
+ // take 30s (plus first-time image pull), so fail cheap when the case
1186
+ // can't advance anyway (missing, wrong status, missing required fields,
1187
+ // missing artifact-backed observation evidence).
1188
+ const caseId = params.id as string;
1189
+ const current = assertPromotable(caseId);
1221
1190
 
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
- }
1230
- const runOptions = (pocMode: string, target: string): PocRunOptions => ({
1231
- network: params.local === true ? "host" : "none",
1232
- local: params.local === true,
1233
- env: {
1234
- PI_POC_MODE: pocMode,
1235
- PI_POC_TARGET: target,
1236
- },
1237
- });
1191
+ const fail = (text: string, _extra?: Record<string, unknown>): never => {
1192
+ throw new Error(text);
1193
+ };
1238
1194
 
1239
- const caseTarget = current.target ?? "";
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.
1246
- const run1 = runPoc(pocPath, runOptions("poc", caseTarget));
1247
- const run2 = runPoc(pocPath, runOptions("poc", caseTarget));
1248
- const controlRun = runPoc(controlPath, runOptions("control", controlTarget));
1249
-
1250
- const evidenceRun = (r: PocRun, mode: "poc" | "control", target: string): PocEvidenceRun => {
1251
- if (!r.completed || !r.outputComplete) {
1195
+ const pocPath = (params.poc_path as string | undefined)?.trim() ?? "";
1196
+ const controlPath = (params.control_path as string | undefined)?.trim() || pocPath;
1197
+ const controlTarget = (params.control_target as string | undefined)?.trim() ?? "";
1198
+ const mode: "inter_host" | "intra_target" =
1199
+ (params.mode as string | undefined) === "intra_target" ? "intra_target" : "inter_host";
1200
+ const isIntra = mode === "intra_target";
1201
+ if (!pocPath) {
1202
+ return fail("poc_path is REQUIRED: absolute path to the PoC script run by the harness.", {
1203
+ missingPocPath: true,
1204
+ });
1205
+ }
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) {
1210
+ if (!controlTarget) {
1211
+ 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.",
1213
+ { missingControlTarget: true },
1214
+ );
1215
+ }
1216
+ if (controlTarget === current.target) {
1217
+ return fail(
1218
+ "control_target must differ from the case target; a control run against the vulnerable target proves nothing.",
1219
+ { controlTargetEqualsCaseTarget: true },
1220
+ );
1221
+ }
1222
+ }
1223
+ if (params.local === true && process.env.PI_POC_ALLOW_NETWORK !== "1") {
1252
1224
  return fail(
1253
- `${mode} run did not complete or output capture was incomplete` +
1254
- (r.infraError ? ` (infra: ${r.output.trim()})` : "") +
1255
- ". A crash is not evidence. Case remains investigating.",
1256
- { run: r, pocCrashed: true },
1225
+ "Networked PoC execution is operator-gated. Set PI_POC_ALLOW_NETWORK=1 to authorize the host-network sandbox for this session.",
1226
+ { networkNotAuthorized: true },
1257
1227
  );
1258
1228
  }
1259
- if (r.evidenceError) {
1229
+ if (!isIntra) {
1230
+ const controlAuthorization = controlTargetAuthorizationError(controlTarget);
1231
+ if (controlAuthorization) {
1232
+ return fail(
1233
+ `CONTROL AUTHORIZATION FAILED: ${controlAuthorization}. ` +
1234
+ "The operator must set PI_POC_CONTROL_TARGETS to the exact approved control host/origin before this control can anchor confirmation.",
1235
+ { controlNotAuthorized: true },
1236
+ );
1237
+ }
1238
+ }
1239
+
1240
+ // Anti-cheat: hash the PoC (always) and, for inter-host, require the
1241
+ // control script to be the SAME bytes (differing only via harness env).
1242
+ let pocHash: string | undefined;
1243
+ try {
1244
+ pocHash = createHash("sha256").update(readFileSync(pocPath)).digest("hex");
1245
+ } catch (e) {
1246
+ return fail(`Cannot read PoC script: ${(e as Error).message}`, {
1247
+ sameFileCheckFailed: true,
1248
+ });
1249
+ }
1250
+ if (!isIntra) {
1251
+ let controlHash: string | undefined;
1252
+ try {
1253
+ controlHash = createHash("sha256").update(readFileSync(controlPath)).digest("hex");
1254
+ } catch (e) {
1255
+ return fail(
1256
+ `Cannot read control script for the same-file check: ${(e as Error).message}`,
1257
+ { sameFileCheckFailed: true },
1258
+ );
1259
+ }
1260
+ if (pocHash !== controlHash) {
1261
+ return fail(
1262
+ "CONTROL CHECK FAILED: control_path must be the SAME script as poc_path (sha256 mismatch). Case remains investigating.",
1263
+ { controlHashMismatch: true },
1264
+ );
1265
+ }
1266
+ }
1267
+
1268
+ // ── OOB callback (Tier 1, opt-in for blind classes) ──
1269
+ const oobRequested = params.oob === true;
1270
+ if (oobRequested) {
1260
1271
  return fail(
1261
- `EVIDENCE CONTRACT FAILED (${mode} run): ${r.evidenceError}. ` +
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 — " +
1263
- "the file is bound to this run and validated by the harness. Case remains investigating.",
1264
- { run: r, evidenceError: r.evidenceError },
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 },
1265
1274
  );
1266
1275
  }
1267
- if (!r.evidence || !r.evidenceSha256 || !r.nonce) {
1268
- return fail(`${mode} run produced no evidence. Case remains investigating.`, {
1269
- run: r,
1276
+ const runOptions = (pocMode: string, target: string): PocRunOptions => ({
1277
+ network: params.local === true ? "host" : "none",
1278
+ local: params.local === true,
1279
+ env: {
1280
+ PI_POC_MODE: pocMode,
1281
+ PI_POC_TARGET: target,
1282
+ },
1283
+ });
1284
+
1285
+ const caseTarget = current.target ?? "";
1286
+ // Determinism: TWO target runs. Exit 0 is run integrity only; nonce-bound
1287
+ // body evidence plus the harness-owned differential replay (inter-host
1288
+ // control, or intra-target same-host baseline) form the machine gate.
1289
+ const run1 = runPoc(pocPath, runOptions("poc", caseTarget));
1290
+ const run2 = runPoc(pocPath, runOptions("poc", caseTarget));
1291
+
1292
+ const evidenceRun = (
1293
+ r: PocRun,
1294
+ mode: "poc" | "control",
1295
+ target: string,
1296
+ ): PocEvidenceRun => {
1297
+ if (!r.completed || !r.outputComplete) {
1298
+ return fail(
1299
+ `${mode} run did not complete or output capture was incomplete` +
1300
+ (r.infraError ? ` (infra: ${r.output.trim()})` : "") +
1301
+ ". A crash is not evidence. Case remains investigating.",
1302
+ { run: r, pocCrashed: true },
1303
+ );
1304
+ }
1305
+ if (r.evidenceError) {
1306
+ return fail(
1307
+ `EVIDENCE CONTRACT FAILED (${mode} run): ${r.evidenceError}. ` +
1308
+ "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 — " +
1309
+ "the file is bound to this run and validated by the harness. Case remains investigating.",
1310
+ { run: r, evidenceError: r.evidenceError },
1311
+ );
1312
+ }
1313
+ if (!r.evidence || !r.evidenceSha256 || !r.nonce) {
1314
+ return fail(`${mode} run produced no evidence. Case remains investigating.`, {
1315
+ run: r,
1316
+ });
1317
+ }
1318
+ return {
1319
+ mode,
1320
+ target,
1321
+ nonce: r.nonce,
1322
+ ranAt: r.ranAt,
1323
+ exitCode: r.exitCode,
1324
+ sandbox: r.sandbox,
1325
+ completed: r.completed,
1326
+ outputComplete: r.outputComplete,
1327
+ output: r.output ?? "",
1328
+ evidence: r.evidence,
1329
+ evidenceSha256: r.evidenceSha256,
1330
+ evidencePath: r.evidencePath,
1331
+ };
1332
+ };
1333
+
1334
+ const targetRuns: [PocEvidenceRun, PocEvidenceRun] = [
1335
+ evidenceRun(run1, "poc", caseTarget),
1336
+ evidenceRun(run2, "poc", caseTarget),
1337
+ ];
1338
+
1339
+ const allowPrivateReplay = process.env.PI_POC_ALLOW_PRIVATE_REPLAY === "1";
1340
+ let harnessVerified: HarnessVerifyResult;
1341
+ let controlRun: PocEvidenceRun | undefined;
1342
+ if (isIntra) {
1343
+ // Intra-target: prove target-dependence with the evidence's same-host
1344
+ // baseline request — no separate control run. The harness sends attack +
1345
+ // baseline to the case target and requires the proof on attack only.
1346
+ const ev0 = targetRuns[0].evidence;
1347
+ if (ev0.verify.mode !== "intra_target") {
1348
+ return fail(
1349
+ "INTRA-TARGET FAILED: the PoC's evidence.json must set verify.mode='intra_target' when promoting in intra-target mode.",
1350
+ { intraModeMismatch: true },
1351
+ );
1352
+ }
1353
+ if (!ev0.baseline) {
1354
+ return fail(
1355
+ "INTRA-TARGET FAILED: evidence.json must include a baseline — a legitimate same-host request whose response must NOT satisfy the attack predicate.",
1356
+ { intraBaselineMissing: true },
1357
+ );
1358
+ }
1359
+ harnessVerified = await replayIntraTarget(ev0, caseTarget, {
1360
+ allowPrivate: allowPrivateReplay,
1270
1361
  });
1362
+ } else {
1363
+ // Inter-host (Tier 2): the harness executes the SAME request template
1364
+ // against target and operator-approved control, applying the target's
1365
+ // predicates to both. DNS is pinned at connect time.
1366
+ controlRun = evidenceRun(
1367
+ runPoc(controlPath, runOptions("control", controlTarget)),
1368
+ "control",
1369
+ controlTarget,
1370
+ );
1371
+ harnessVerified = await replayDifferential(
1372
+ targetRuns[0].evidence,
1373
+ caseTarget,
1374
+ controlTarget,
1375
+ { allowPrivate: allowPrivateReplay },
1376
+ );
1271
1377
  }
1272
- return {
1378
+ const bundle: PendingConfirmation = {
1379
+ caseId,
1380
+ ranAt: new Date().toISOString(),
1381
+ pocPath,
1382
+ pocSha256: pocHash,
1273
1383
  mode,
1274
- target,
1275
- nonce: r.nonce,
1276
- ranAt: r.ranAt,
1277
- exitCode: r.exitCode,
1278
- sandbox: r.sandbox,
1279
- completed: r.completed,
1280
- outputComplete: r.outputComplete,
1281
- output: r.output ?? "",
1282
- evidence: r.evidence,
1283
- evidenceSha256: r.evidenceSha256,
1284
- evidencePath: r.evidencePath,
1384
+ targetRuns,
1385
+ harnessVerified,
1386
+ ...(isIntra ? {} : { controlPath, controlTarget, controlRun }),
1285
1387
  };
1286
- };
1287
1388
 
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
- );
1300
-
1301
- const bundle: PendingConfirmation = {
1302
- caseId,
1303
- ranAt: new Date().toISOString(),
1304
- pocPath,
1305
- pocSha256: pocHash,
1306
- controlPath,
1307
- controlTarget,
1308
- targetRuns,
1309
- controlRun: control,
1310
- harnessVerified,
1311
- };
1312
-
1313
- let record: CaseRecord;
1314
- try {
1315
- record = storePendingConfirmation(caseId, bundle);
1316
- } catch (e) {
1317
- return fail(`Pending confirmation rejected: ${(e as Error).message}`, {
1318
- storeRejected: true,
1319
- });
1320
- }
1389
+ let record: CaseRecord;
1390
+ try {
1391
+ record = storePendingConfirmation(caseId, bundle);
1392
+ } catch (e) {
1393
+ return fail(`Pending confirmation rejected: ${(e as Error).message}`, {
1394
+ storeRejected: true,
1395
+ });
1396
+ }
1321
1397
 
1322
- return {
1323
- content: [
1324
- {
1325
- type: "text",
1326
- text:
1327
- `Phase 1 complete — evidence bundle recorded on ${caseId} (expires in 1h).\n` +
1328
- `Target runs: 2, Control run: 1 — all with validated nonce-bound evidence.json.\n` +
1329
- `Evidence sha256: ${targetRuns[0].evidenceSha256}\n` +
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.`,
1333
- },
1334
- ],
1335
- details: {
1336
- record,
1337
- bundle: {
1338
- caseId,
1339
- ranAt: bundle.ranAt,
1340
- pocPath,
1341
- controlPath,
1342
- controlTarget,
1343
- pocSha256: pocHash,
1344
- evidenceSha256: targetRuns[0].evidenceSha256,
1345
- harnessVerified,
1398
+ return {
1399
+ content: [
1400
+ {
1401
+ type: "text",
1402
+ text:
1403
+ `Phase 1 complete — evidence bundle recorded on ${caseId} (expires in 1h).\n` +
1404
+ `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
+ `Evidence sha256: ${targetRuns[0].evidenceSha256}\n` +
1406
+ `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` +
1408
+ `\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
+ },
1410
+ ],
1411
+ details: {
1412
+ record,
1413
+ bundle: {
1414
+ caseId,
1415
+ ranAt: bundle.ranAt,
1416
+ mode,
1417
+ pocPath,
1418
+ controlPath: isIntra ? undefined : controlPath,
1419
+ controlTarget: isIntra ? undefined : controlTarget,
1420
+ pocSha256: pocHash,
1421
+ evidenceSha256: targetRuns[0].evidenceSha256,
1422
+ harnessVerified,
1423
+ },
1346
1424
  },
1347
- },
1348
- };
1349
- },
1425
+ };
1426
+ },
1350
1427
 
1351
- renderCall(args, theme) {
1352
- return callLine(theme, "PromoteFinding", (args.id as string) ?? "");
1353
- },
1428
+ renderCall(args, theme) {
1429
+ return callLine(theme, "PromoteFinding", (args.id as string) ?? "");
1430
+ },
1354
1431
 
1355
- renderResult(result, _opts, theme) {
1356
- const details = result.details as { bundle?: { evidenceSha256?: string } } | undefined;
1357
- if (!details?.bundle) {
1358
- return new Text(theme.fg("error", "✗ PromoteFinding failed"), 0, 0);
1359
- }
1360
- return new Text(
1361
- theme.fg("success", "✓ ") +
1362
- theme.fg("dim", "evidence bundle ") +
1363
- theme.fg("muted", details.bundle.evidenceSha256?.slice(0, 12) ?? ""),
1364
- 0,
1365
- 0,
1366
- );
1367
- },
1368
- });
1432
+ renderResult(result, _opts, theme) {
1433
+ const details = result.details as { bundle?: { evidenceSha256?: string } } | undefined;
1434
+ if (!details?.bundle) {
1435
+ return new Text(theme.fg("error", "✗ PromoteFinding failed"), 0, 0);
1436
+ }
1437
+ return new Text(
1438
+ theme.fg("success", "✓ ") +
1439
+ theme.fg("dim", "evidence bundle ") +
1440
+ theme.fg("muted", details.bundle.evidenceSha256?.slice(0, 12) ?? ""),
1441
+ 0,
1442
+ 0,
1443
+ );
1444
+ },
1445
+ });
1369
1446
 
1370
1447
  // ── Tool: ConfirmFinding (phase 2) ──
1371
1448
 
@@ -1373,7 +1450,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
1373
1450
  // execute-time check remains as defense in depth if process state changes
1374
1451
  // after registration or another integration forwards a stale tool handle.
1375
1452
  if (!startedAsSubagent)
1376
- pi.registerTool({
1453
+ registerCaseTool({
1377
1454
  name: "ConfirmFinding",
1378
1455
  label: "Main-Agent Confirmation",
1379
1456
  description:
@@ -1392,7 +1469,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
1392
1469
  async execute(_id, params, _signal, _onUpdate, _ctx) {
1393
1470
  if (isSubagentProcess()) {
1394
1471
  throw new Error(
1395
- "ConfirmFinding is reserved for the main/coordinator agent. A worker or subagent may produce evidence but cannot confirm a PoC.",
1472
+ "ConfirmFinding is reserved for the main/coordinator agent. A worker or subagent may gather or challenge evidence but cannot run validation or confirm a PoC.",
1396
1473
  );
1397
1474
  }
1398
1475
  const caseId = params.id as string;
@@ -1408,16 +1485,29 @@ export default function casefileExtension(pi: ExtensionAPI) {
1408
1485
  if (!bundle) {
1409
1486
  throw new Error("No pending confirmation on this case — run PromoteFinding first");
1410
1487
  }
1411
- const controlAuthorizationError = controlTargetAuthorizationError(bundle.controlTarget);
1412
- if (controlAuthorizationError) {
1413
- throw new Error(`CONTROL AUTHORIZATION FAILED: ${controlAuthorizationError}`);
1488
+ const allowPrivate = process.env.PI_POC_ALLOW_PRIVATE_REPLAY === "1";
1489
+ const caseTargetForReplay = current.target ?? bundle.targetRuns[0].target;
1490
+ let replay: HarnessVerifyResult;
1491
+ if (bundle.mode === "intra_target") {
1492
+ // Same-host attack-vs-baseline replay; no control target to authorize.
1493
+ replay = await replayIntraTarget(bundle.targetRuns[0].evidence, caseTargetForReplay, {
1494
+ allowPrivate,
1495
+ });
1496
+ } else {
1497
+ if (!bundle.controlTarget) {
1498
+ throw new Error("inter-host confirmation requires a control target");
1499
+ }
1500
+ const controlAuthorizationError = controlTargetAuthorizationError(bundle.controlTarget);
1501
+ if (controlAuthorizationError) {
1502
+ throw new Error(`CONTROL AUTHORIZATION FAILED: ${controlAuthorizationError}`);
1503
+ }
1504
+ replay = await replayDifferential(
1505
+ bundle.targetRuns[0].evidence,
1506
+ caseTargetForReplay,
1507
+ bundle.controlTarget,
1508
+ { allowPrivate },
1509
+ );
1414
1510
  }
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
1511
  phase2Verification = {
1422
1512
  at: new Date().toISOString(),
1423
1513
  result: replay,
@@ -1461,7 +1551,7 @@ ${formatCaseDetail(record)}`,
1461
1551
 
1462
1552
  // ── Tool: CaseGet ──
1463
1553
 
1464
- pi.registerTool({
1554
+ registerCaseTool({
1465
1555
  name: "CaseGet",
1466
1556
  label: "Get Case",
1467
1557
  description: "Get full details of a single case by ID.",
@@ -1490,7 +1580,7 @@ ${formatCaseDetail(record)}`,
1490
1580
 
1491
1581
  // ── Tool: CaseList ──
1492
1582
 
1493
- pi.registerTool({
1583
+ registerCaseTool({
1494
1584
  name: "CaseList",
1495
1585
  label: "List Cases",
1496
1586
  description:
@@ -1520,7 +1610,7 @@ ${formatCaseDetail(record)}`,
1520
1610
 
1521
1611
  // ── Tool: CaseSearch ──
1522
1612
 
1523
- pi.registerTool({
1613
+ registerCaseTool({
1524
1614
  name: "CaseSearch",
1525
1615
  label: "Search Cases",
1526
1616
  description:
@@ -1548,7 +1638,7 @@ ${formatCaseDetail(record)}`,
1548
1638
 
1549
1639
  // ── Tool: CaseLink ──
1550
1640
 
1551
- pi.registerTool({
1641
+ registerCaseTool({
1552
1642
  name: "CaseLink",
1553
1643
  label: "Link Cases",
1554
1644
  description:
@@ -1620,7 +1710,7 @@ ${formatCaseDetail(record)}`,
1620
1710
 
1621
1711
  // ── Tool: CaseUnlink ──
1622
1712
 
1623
- pi.registerTool({
1713
+ registerCaseTool({
1624
1714
  name: "CaseUnlink",
1625
1715
  label: "Unlink Cases",
1626
1716
  description: "Remove a bidirectional link between two cases.",
@@ -1687,7 +1777,7 @@ ${formatCaseDetail(record)}`,
1687
1777
  { additionalProperties: false },
1688
1778
  );
1689
1779
 
1690
- pi.registerTool({
1780
+ registerCaseTool({
1691
1781
  name: "ChainSuggest",
1692
1782
  label: "Suggest Exploit Chains",
1693
1783
  description:
@@ -1736,15 +1826,15 @@ ${formatCaseDetail(record)}`,
1736
1826
 
1737
1827
  // ── Tool: CaseContext ──
1738
1828
 
1739
- pi.registerTool({
1829
+ registerCaseTool({
1740
1830
  name: "CaseContext",
1741
1831
  label: "Generate Case Context",
1742
1832
  description:
1743
- "Generate the case context bundle for a confirmed or reported case under the casefile report directory (next to the casefile DB): full evidence, PoC verification log, disconfirmation attempt, links, and timeline, plus the target report path. The report writer (reporter subagent) turns this context into the final polished H1-style report. Hypothesis/investigating/blocked/killed cases are rejected — promote to confirmed first.",
1744
- promptSnippet: "Generate case context for the report writer",
1833
+ "Generate the case context bundle for a confirmed or reported case under the casefile report directory (next to the casefile DB): full evidence, PoC verification log, disconfirmation attempt, links, and timeline, plus the target report path. The main agent turns this context into the final polished H1-style report. Hypothesis/investigating/blocked/killed cases are rejected — promote to confirmed first.",
1834
+ promptSnippet: "Generate case context for the final report",
1745
1835
  promptGuidelines: [
1746
1836
  "Use CaseContext only for confirmed or already reported cases. Keep hypotheses and investigating cases in the ledger until proof is captured.",
1747
- "After CaseContext, dispatch the reporter subagent (agents/reporter) to write the final report to the returned report path, then CaseUpdate(status: 'reported').",
1837
+ "After CaseContext, write the final report to the returned report path yourself, then CaseUpdate(status: 'reported').",
1748
1838
  ],
1749
1839
  parameters: IdSchema,
1750
1840
 
@@ -1754,7 +1844,7 @@ ${formatCaseDetail(record)}`,
1754
1844
  content: [
1755
1845
  {
1756
1846
  type: "text",
1757
- text: `Case context written: ${contextPath}\nReport path (for the reporter agent): ${path}\n${formatCase(record)}`,
1847
+ text: `Case context written: ${contextPath}\nReport path: ${path}\n${formatCase(record)}`,
1758
1848
  },
1759
1849
  ],
1760
1850
  details: { path, contextPath, record },
@@ -1779,7 +1869,7 @@ ${formatCaseDetail(record)}`,
1779
1869
 
1780
1870
  pi.registerCommand("xp", {
1781
1871
  description:
1782
- "Toggle casefile XP (offensive) mode. ON injects the full cyber workflow (subagent pipeline); LITE injects the single-agent workflow (no subagent dispatch); OFF (default) keeps context quiet for normal dev work. Usage: /xp [on|off|lite]",
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]",
1783
1873
  handler: async (args, ctx) => {
1784
1874
  const next = parseXpModeArg(args ?? "", readXpMode());
1785
1875
  writeXpMode(next);
@@ -1790,23 +1880,25 @@ ${formatCaseDetail(record)}`,
1790
1880
  if (next !== "off") workflowInjected = false;
1791
1881
  ctx.ui.notify(
1792
1882
  `Casefile XP mode: ${next.toUpperCase()} (takes effect on the next prompt)`,
1793
- next === "on" ? "info" : "warning",
1883
+ next === "off" ? "warning" : "info",
1794
1884
  );
1795
1885
  },
1796
1886
  });
1797
1887
 
1798
1888
  // ── Tool: PipelineSubmit ──
1799
1889
 
1800
- pi.registerTool({
1890
+ registerCaseTool({
1801
1891
  name: "PipelineSubmit",
1802
1892
  label: "Submit Stage Output",
1803
1893
  description:
1804
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.",
1805
1895
  promptSnippet: "Validate and submit a pipeline stage's output",
1806
1896
  promptGuidelines: [
1807
- "Every stage output a subagent returns must go through PipelineSubmit before the next stage is dispatched — do not eyeball schemas.",
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.",
1808
1898
  "verdict repair → fix the listed fields and re-submit the same output; budget is 2 attempts per finding, then rejected.",
1809
- "Skeptic: unparseable/schema-invalid = UNDETERMINED (never DISPROVEN). Tracer error = UNREACHABLE. Both return repair.",
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.',
1810
1902
  "Test-path findings and hallucinated files are rejected by the pre-filter, not repairable — the finding itself is noise.",
1811
1903
  ],
1812
1904
  parameters: Type.Object(
@@ -1866,7 +1958,7 @@ ${formatCaseDetail(record)}`,
1866
1958
 
1867
1959
  // ── Tool: ScratchpadInit ──
1868
1960
 
1869
- pi.registerTool({
1961
+ registerCaseTool({
1870
1962
  name: "ScratchpadInit",
1871
1963
  label: "Init Scratchpad",
1872
1964
  description:
@@ -1904,7 +1996,7 @@ ${formatCaseDetail(record)}`,
1904
1996
 
1905
1997
  // ── Tool: ScratchpadResume ──
1906
1998
 
1907
- pi.registerTool({
1999
+ registerCaseTool({
1908
2000
  name: "ScratchpadResume",
1909
2001
  label: "Resume Scratchpad",
1910
2002
  description:
@@ -1963,7 +2055,7 @@ ${formatCaseDetail(record)}`,
1963
2055
 
1964
2056
  // ── Tool: ScratchpadCheckpoint ──
1965
2057
 
1966
- pi.registerTool({
2058
+ registerCaseTool({
1967
2059
  name: "ScratchpadCheckpoint",
1968
2060
  label: "Checkpoint Phase",
1969
2061
  description:
@@ -2013,7 +2105,7 @@ ${formatCaseDetail(record)}`,
2013
2105
 
2014
2106
  // ── Tool: ScratchpadWrite ──
2015
2107
 
2016
- pi.registerTool({
2108
+ registerCaseTool({
2017
2109
  name: "ScratchpadWrite",
2018
2110
  label: "Write Artifact",
2019
2111
  description:
@@ -2059,7 +2151,7 @@ ${formatCaseDetail(record)}`,
2059
2151
 
2060
2152
  // ── Tool: ScratchpadRead ──
2061
2153
 
2062
- pi.registerTool({
2154
+ registerCaseTool({
2063
2155
  name: "ScratchpadRead",
2064
2156
  label: "Read Artifact",
2065
2157
  description:
@@ -2116,7 +2208,7 @@ ${formatCaseDetail(record)}`,
2116
2208
 
2117
2209
  // ── Tool: ScratchpadPhaseDone ──
2118
2210
 
2119
- pi.registerTool({
2211
+ registerCaseTool({
2120
2212
  name: "ScratchpadPhaseDone",
2121
2213
  label: "Phase Done?",
2122
2214
  description:
@@ -2159,7 +2251,7 @@ ${formatCaseDetail(record)}`,
2159
2251
 
2160
2252
  // ── Tool: ScratchpadClear ──
2161
2253
 
2162
- pi.registerTool({
2254
+ registerCaseTool({
2163
2255
  name: "ScratchpadClear",
2164
2256
  label: "Clear Run",
2165
2257
  description: