@xaccefy/pi-casefile 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Casefile — offensive security case tracker for Pi.
3
3
  *
4
- * Tools: CaseAdd, CaseUpdate, PromoteFinding, EvidenceAdd, CoverageAdd, CoverageReport, ChainSuggest, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseContext, PipelineSubmit, ScratchpadInit, ScratchpadResume, ScratchpadCheckpoint, ScratchpadWrite, ScratchpadRead, ScratchpadPhaseDone, ScratchpadClear
4
+ * Tools: CaseAdd, CaseUpdate, PromoteFinding, ConfirmFinding, EvidenceAdd, CoverageAdd, CoverageReport, ChainSuggest, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseContext, PipelineSubmit, ScratchpadInit, ScratchpadResume, ScratchpadCheckpoint, ScratchpadWrite, ScratchpadRead, ScratchpadPhaseDone, ScratchpadClear
5
5
  * Command: /casefile — interactive dashboard
6
6
  * Event: before_agent_start — injects cyber workflow once per session, refreshes the active case list per prompt
7
7
  */
@@ -9,15 +9,17 @@
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
+ CANARY_ASSESSMENT_VALUES,
16
17
  CONFIRM_DIFFERENTIAL_VALUES,
17
18
  CONFIRM_VERDICT_VALUES,
18
- type ConfirmerVerdict,
19
19
  SEVERITY_MATCH_VALUES,
20
+ validateMainAgentVerdict,
20
21
  } from "./evidence.ts";
22
+ import { controlTargetAuthorizationError, replayDifferential } from "./harness-verify.ts";
21
23
  import {
22
24
  addCaseResult,
23
25
  addEvidenceItemResult,
@@ -47,6 +49,7 @@ import {
47
49
  getCasefilePath,
48
50
  LINK_KIND_VALUES,
49
51
  linkCasesResult,
52
+ type MainAgentVerification,
50
53
  type PendingConfirmation,
51
54
  type PocEvidenceRun,
52
55
  PRIORITY_VALUES,
@@ -58,16 +61,15 @@ import {
58
61
  STATUS_VALUES,
59
62
  searchCases,
60
63
  storePendingConfirmation,
61
- suggestChains,
62
64
  unlinkCasesResult,
63
65
  updateCaseResult,
64
- writeCaseContext,
65
66
  } from "./ledger.ts";
67
+ import { suggestChainsAsync, writeCaseContextAsync } from "./ledger-worker.ts";
66
68
  import { pipeline_submit, SUBMIT_STAGES, type SubmitStage } from "./pipeline-submit.ts";
67
69
  import { type PocRun, type PocRunOptions, runPoc } from "./poc-runner.ts";
68
70
  import {
69
71
  detectWorkspaceRoot,
70
- PHASE_ORDER,
72
+ SCRATCHPAD_PHASES,
71
73
  type ScratchpadPhase,
72
74
  type ScratchpadResume,
73
75
  scratchpad_checkpoint,
@@ -167,8 +169,7 @@ const EvidenceAddSchema = Type.Object(
167
169
  artifact_path: Type.Optional(
168
170
  Type.String({
169
171
  description:
170
- "Path to the artifact file on disk. Stored as basename + SHA-256 hash " +
171
- "(full path is never persisted).",
172
+ "Path to a regular, non-symlink artifact inside the workspace. The bytes are copied durably and stored as basename + SHA-256 (full source path is never persisted).",
172
173
  }),
173
174
  ),
174
175
  },
@@ -177,12 +178,13 @@ const EvidenceAddSchema = Type.Object(
177
178
 
178
179
  // ── Tool: PromoteFinding (phase 1) / ConfirmFinding (phase 2) ──────────
179
180
  //
180
- // Confirmation is TWO-PHASE because tools cannot dispatch subagents: the
181
- // coordinator runs PromoteFinding (harness runs the PoC 2x + control,
182
- // validates nonce-bound evidence.json, records the bundle), dispatches the
183
- // confirmer subagent (fresh context, re-executes the verify request), then
184
- // commits the verdict via ConfirmFinding. Exit codes and markers are
185
- // diagnostics the gate is evidence + verdict.
181
+ // Confirmation is TWO-PHASE and main-agent-owned: PromoteFinding runs the PoC
182
+ // 2x + control, validates nonce-bound evidence.json, and records the pending
183
+ // bundle; ConfirmFinding then performs the main coordinator's review/replay and
184
+ // commits or refuses the verdict. Subagents may gather or challenge evidence,
185
+ // but they cannot run validation or confirmation gates. Zero exit is necessary
186
+ // run integrity and markers are diagnostic only; the machine records
187
+ // predicate/canary differentials and the main agent owns the semantic judgment.
186
188
 
187
189
  const PromoteSchema = Type.Object(
188
190
  {
@@ -190,19 +192,27 @@ const PromoteSchema = Type.Object(
190
192
  poc_path: Type.String({
191
193
  description: "Absolute path to the PoC script on disk",
192
194
  }),
193
- control_path: Type.String({
194
- description:
195
- "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.",
196
- }),
195
+ control_path: Type.Optional(
196
+ Type.String({
197
+ description:
198
+ "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.",
199
+ }),
200
+ ),
197
201
  control_target: Type.String({
198
202
  minLength: 1,
199
203
  description:
200
- "REQUIRED: a distinct baseline target that lacks the vulnerability (patched replica, second account, baseline endpoint).",
204
+ "REQUIRED: a distinct baseline target that lacks the vulnerability and is operator-approved through PI_POC_CONTROL_TARGETS (patched replica, second account, baseline service).",
201
205
  }),
202
206
  local: Type.Optional(
203
207
  Type.Boolean({
204
208
  description:
205
- "Run with network access (Docker sandbox with --network host — still read-only FS, dropped capabilities, unprivileged user) instead of the isolated --network none sandbox. True host execution is NOT agent-selectable: it requires the operator to set PI_POC_ALLOW_LOCAL=1, and is used only as a fallback when Docker is unavailable.",
209
+ "Run with network access instead of --network none. Requires operator authorization via PI_POC_ALLOW_NETWORK=1. True host fallback additionally requires PI_POC_ALLOW_LOCAL=1.",
210
+ }),
211
+ ),
212
+ oob: Type.Optional(
213
+ Type.Boolean({
214
+ description:
215
+ "Reserved for source-separated out-of-band verification. Currently fails closed because a loopback listener reachable by the PoC cannot prove target causation.",
206
216
  }),
207
217
  ),
208
218
  },
@@ -219,14 +229,13 @@ const ConfirmSchema = Type.Object(
219
229
  description: "Why the evidence does or does not demonstrate the claim",
220
230
  }),
221
231
  evidence_reviewed: Type.Array(Type.String(), {
222
- description: "Files/evidence the confirmer actually reviewed",
223
- }),
224
- re_executed: Type.Boolean({
225
- description:
226
- "True iff the confirmer re-sent the verify request itself. Mandatory for CONFIRMED.",
232
+ description: "Files/evidence the main agent actually reviewed",
227
233
  }),
228
234
  re_execution_note: Type.Optional(
229
- Type.String({ description: "What the confirmer observed when re-executing" }),
235
+ Type.String({
236
+ description:
237
+ "What the main agent observed during review and the fresh harness-owned target/control replay. Mandatory for CONFIRMED.",
238
+ }),
230
239
  ),
231
240
  differential: Type.String({
232
241
  enum: [...CONFIRM_DIFFERENTIAL_VALUES],
@@ -241,7 +250,20 @@ const ConfirmSchema = Type.Object(
241
250
  disconfirmation_attempt: Type.Optional(
242
251
  Type.String({
243
252
  description:
244
- "The confirmer's own failed attempt to disprove — becomes the case's disconfirmation",
253
+ "The main agent's own failed attempt to disprove — becomes the case's disconfirmation",
254
+ }),
255
+ ),
256
+ canary_assessment: Type.Optional(
257
+ Type.String({
258
+ enum: [...CANARY_ASSESSMENT_VALUES],
259
+ description:
260
+ "verified when the replay carried a harness-generated reflection canary; otherwise not_applicable with a concrete reason",
261
+ }),
262
+ ),
263
+ canary_reason: Type.Optional(
264
+ Type.String({
265
+ description:
266
+ "Why a causal reflection canary is not meaningful for this exploit class. Required when canary_assessment=not_applicable.",
245
267
  }),
246
268
  ),
247
269
  model: Type.Optional(
@@ -335,9 +357,9 @@ const UnlinkSchema = Type.Object(
335
357
  // artifacts; it does not re-run completed phases (idempotent).
336
358
 
337
359
  const ScratchpadPhaseSchema = Type.String({
338
- enum: [...PHASE_ORDER],
360
+ enum: [...SCRATCHPAD_PHASES],
339
361
  description:
340
- "Pipeline phase: recon | hunt | gapfil | trace | skeptic | validate | chain | patch | report",
362
+ "Pipeline phase: recon | hunt | trace | skeptic | validate | chain | patch | report (legacy gapfil is accepted for older runs)",
341
363
  });
342
364
 
343
365
  /** run_id-only schema, shared by Scratchpad Init / Resume / Clear. */
@@ -676,13 +698,13 @@ export function detectHost(): "omp" | "pi" {
676
698
  * case list DOES change as cases are added, so it is refreshed every prompt.
677
699
  *
678
700
  * mode selects the workflow text: "lite" injects the single-agent workflow
679
- * (no subagent dispatch), anything else gets the full subagent pipeline,
701
+ * (no subagent dispatch), "swarm" gets the full subagent pipeline,
680
702
  * rendered for the host's dispatch convention (pi-subagents vs OMP task).
681
703
  */
682
704
  function buildAgentInjection(
683
705
  active: CaseRecord[],
684
706
  includeWorkflow: boolean,
685
- mode: XpMode = "on",
707
+ mode: XpMode = "swarm",
686
708
  ): string {
687
709
  const caseList = buildCaseListContext(active);
688
710
  if (!includeWorkflow) return caseList;
@@ -699,13 +721,15 @@ function buildAgentInjection(
699
721
  // ── XP (offensive / exploit) mode toggle ─────────────────────────────
700
722
  // Casefile historically injected the cyber workflow into every prompt.
701
723
  // For normal dev work that is just noise, so XP mode defaults OFF. Enable
702
- // it for offensive/audit sessions to get the full attacker discipline back,
703
- // or lite for the single-agent variant (no subagent dispatch). Toggle with
704
- // /xp (or /xp on|off|lite); override per-session with PI_XP_MODE.
724
+ // swarm for the bounded multi-agent variant, or lite for the single-agent
725
+ // attacker discipline. Toggle with /xp (off <-> swarm), or set explicitly with
726
+ // /xp on|lite|swarm|off. "on" means the default enabled SWARM mode; use "lite"
727
+ // for no subagent dispatch.
728
+ // Override per-session with PI_XP_MODE.
705
729
  // Pure helpers exported for unit tests.
706
730
 
707
731
  export const XP_MODE_ENV = "PI_XP_MODE";
708
- export type XpMode = "on" | "off" | "lite";
732
+ export type XpMode = "swarm" | "off" | "lite";
709
733
 
710
734
  export function getXpModeStatePath(): string {
711
735
  return join(dirname(getCasefilePath()), "xp-mode");
@@ -716,13 +740,14 @@ export function readXpMode(
716
740
  statePath: string = getXpModeStatePath(),
717
741
  ): XpMode {
718
742
  const env = (envValue ?? "").trim().toLowerCase();
719
- if (env === "on" || env === "1" || env === "true") return "on";
743
+ if (env === "swarm") return "swarm";
744
+ if (env === "on" || env === "1" || env === "true") return "swarm";
720
745
  if (env === "lite") return "lite";
721
746
  if (env === "off" || env === "0" || env === "false") return "off";
722
747
  try {
723
748
  if (existsSync(statePath)) {
724
749
  const v = readFileSync(statePath, "utf8").trim().toLowerCase();
725
- if (v === "on") return "on";
750
+ if (v === "swarm" || v === "on") return "swarm";
726
751
  if (v === "lite") return "lite";
727
752
  if (v === "off") return "off";
728
753
  }
@@ -742,16 +767,22 @@ export function writeXpMode(state: XpMode, statePath: string = getXpModeStatePat
742
767
 
743
768
  export function parseXpModeArg(args: string, current: XpMode): XpMode {
744
769
  const arg = (args ?? "").trim().toLowerCase();
745
- if (arg === "on") return "on";
770
+ if (arg === "swarm") return "swarm";
771
+ if (arg === "on") return "swarm";
746
772
  if (arg === "off") return "off";
747
773
  if (arg === "lite") return "lite";
748
- // Bare /xp toggles between on and off (lite is only set explicitly).
749
- return current === "on" ? "off" : "on";
774
+ // Bare /xp is the low-ceremony path: toggle the default XP workflow on/off.
775
+ return current === "off" ? "swarm" : "off";
750
776
  }
751
777
 
752
778
  // ── Main extension ────────────────────────────────────────────────────
753
779
 
754
780
  export default function casefileExtension(pi: ExtensionAPI) {
781
+ // Process role is immutable for this extension instance. A worker may spawn
782
+ // shells, but unsetting PI_SUBAGENT_CHILD in a child shell cannot upgrade the
783
+ // already-loaded extension or reveal a tool that was omitted at startup.
784
+ const startedAsSubagent = process.env.PI_SUBAGENT_CHILD === "1";
785
+ const isSubagentProcess = () => startedAsSubagent || process.env.PI_SUBAGENT_CHILD === "1";
755
786
  // Pin the workspace root ONCE at extension load. Every scratchpad / pipeline
756
787
  // / PoC-path lookup otherwise re-walks the ambient cwd on each call — a
757
788
  // mid-session `cd` would split state across two .scratchpad roots and
@@ -761,34 +792,37 @@ export default function casefileExtension(pi: ExtensionAPI) {
761
792
  setScratchpadRoot(workspaceRoot);
762
793
  process.env.PI_POC_ROOT ??= workspaceRoot;
763
794
 
764
- // ── Diagnostic Error Handler Middleware ──
765
- const originalRegisterTool = pi.registerTool.bind(pi);
766
- pi.registerTool = (spec: any) => {
795
+ // ── Diagnostic Error Handler ──
796
+ const registerCaseTool = <TParams extends TSchema, TDetails = unknown, TState = unknown>(
797
+ spec: ToolDefinition<TParams, TDetails, TState>,
798
+ ) => {
767
799
  const origExecute = spec.execute;
768
- spec.execute = async (...args: any[]) => {
769
- try {
770
- return await origExecute(...args);
771
- } catch (err) {
772
- const message = err instanceof Error ? err.message : String(err);
773
- let hint = "";
774
- if (
775
- message.includes("SQLITE") ||
776
- message.includes("database") ||
777
- message.includes("permission") ||
778
- message.includes("readonly") ||
779
- message.includes("lock")
780
- ) {
781
- 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`;
800
+ pi.registerTool({
801
+ ...spec,
802
+ execute: async (...args: Parameters<typeof origExecute>) => {
803
+ try {
804
+ return await origExecute(...args);
805
+ } catch (err) {
806
+ const message = err instanceof Error ? err.message : String(err);
807
+ let hint = "";
808
+ if (
809
+ message.includes("SQLITE") ||
810
+ message.includes("database") ||
811
+ message.includes("permission") ||
812
+ message.includes("readonly") ||
813
+ message.includes("lock")
814
+ ) {
815
+ 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`;
816
+ }
817
+ throw new Error(`${spec.name} failed: ${message}${hint}`, { cause: err });
782
818
  }
783
- throw new Error(`${spec.name} failed: ${message}${hint}`, { cause: err });
784
- }
785
- };
786
- originalRegisterTool(spec);
819
+ },
820
+ });
787
821
  };
788
822
 
789
823
  // ── Tool: CaseAdd ──
790
824
 
791
- pi.registerTool({
825
+ registerCaseTool({
792
826
  name: "CaseAdd",
793
827
  label: "Add Case",
794
828
  description:
@@ -842,7 +876,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
842
876
 
843
877
  // ── Tool: CaseUpdate ──
844
878
 
845
- pi.registerTool({
879
+ registerCaseTool({
846
880
  name: "CaseUpdate",
847
881
  label: "Update Case",
848
882
  description:
@@ -897,11 +931,11 @@ export default function casefileExtension(pi: ExtensionAPI) {
897
931
 
898
932
  // ── Tool: EvidenceAdd ──
899
933
 
900
- pi.registerTool({
934
+ registerCaseTool({
901
935
  name: "EvidenceAdd",
902
936
  label: "Add Evidence Item",
903
937
  description:
904
- "Record a role-typed, artifact-backed evidence item on a case (observation, reproduction, impact, refutation, cleanup). Artifact files are stored as basename + SHA-256 — the full path is never persisted. refutation items justify a kill; cleanup items track engagement cleanup before REPORT.",
938
+ "Record a role-typed, artifact-backed evidence item on a case. Artifact reads are restricted to regular, non-symlink files inside the workspace; bytes are copied durably and stored as basename + SHA-256. refutation items justify a kill; cleanup items track engagement cleanup before REPORT.",
905
939
  promptSnippet: "Record a role-typed evidence item",
906
940
  promptGuidelines: [
907
941
  "Use EvidenceAdd for artifact-backed evidence: raw responses, logs, screenshots, disproof attempts — anything a claim should trace back to.",
@@ -917,7 +951,8 @@ export default function casefileExtension(pi: ExtensionAPI) {
917
951
  summary: params.summary as string,
918
952
  artifactPath: params.artifact_path as string | undefined,
919
953
  });
920
- const record = getCaseById(params.case_id as string)!;
954
+ const record = getCaseById(params.case_id as string);
955
+ if (!record) throw new Error(`Case not found after evidence insert: ${params.case_id}`);
921
956
  return {
922
957
  content: [
923
958
  {
@@ -990,7 +1025,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
990
1025
  { additionalProperties: false },
991
1026
  );
992
1027
 
993
- pi.registerTool({
1028
+ registerCaseTool({
994
1029
  name: "CoverageAdd",
995
1030
  label: "Record Coverage",
996
1031
  description:
@@ -1012,7 +1047,8 @@ export default function casefileExtension(pi: ExtensionAPI) {
1012
1047
  note: params.note as string,
1013
1048
  evidenceItemId: params.evidence_item_id as string | undefined,
1014
1049
  });
1015
- const record = getCaseById(params.case_id as string)!;
1050
+ const record = getCaseById(params.case_id as string);
1051
+ if (!record) throw new Error(`Case not found after coverage insert: ${params.case_id}`);
1016
1052
  return {
1017
1053
  content: [
1018
1054
  {
@@ -1056,11 +1092,11 @@ export default function casefileExtension(pi: ExtensionAPI) {
1056
1092
  { additionalProperties: false },
1057
1093
  );
1058
1094
 
1059
- pi.registerTool({
1095
+ registerCaseTool({
1060
1096
  name: "CoverageReport",
1061
1097
  label: "Coverage Matrix",
1062
1098
  description:
1063
- "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.",
1099
+ "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.",
1064
1100
  promptSnippet: "Show which attack classes were tested where",
1065
1101
  promptGuidelines: [
1066
1102
  "Run CoverageReport before claiming 'every class is COVERED/SKIPPED/NOT_FOUND' — the claim must match the matrix.",
@@ -1103,259 +1139,347 @@ export default function casefileExtension(pi: ExtensionAPI) {
1103
1139
  },
1104
1140
  });
1105
1141
 
1106
- // ── Tool: PromoteFinding ──
1107
1142
  // ── Tool: PromoteFinding (phase 1) ──
1108
1143
 
1109
- pi.registerTool({
1110
- name: "PromoteFinding",
1111
- label: "Run PoC Evidence",
1112
- description:
1113
- "Phase 1 of confirmation: run the PoC twice against the case target plus once against control_target (same script, sha256-enforced), then validate the nonce-bound evidence.json each run writes to $PI_POC_EVIDENCE_DIR. Records a pending confirmation bundle (expires in 1h) and returns the confirmer dispatch instruction. Exit codes and markers are DIAGNOSTICS — the gate is evidence + confirmer verdict. After PromoteFinding: dispatch the confirmer subagent, then commit its verdict with ConfirmFinding. Host execution is never agent-selectable — local:true uses a host-network Docker sandbox; true host runs need PI_POC_ALLOW_LOCAL=1.",
1114
- promptSnippet: "Phase 1: run PoC evidence (target x2 + control) and record the pending bundle",
1115
- promptGuidelines: [
1116
- "Use PromoteFinding when an investigating case has a concrete PoC script on disk and you are ready to prove it.",
1117
- "Prerequisites: status='investigating' and non-empty poc, evidence, impact, severity, target, plus an artifact-backed EvidenceAdd 'observation' item on the case (the initial signal, with artifact_path). The disconfirmation comes from the confirmer at confirm time.",
1118
- "The PoC MUST write evidence.json to $PI_POC_EVIDENCE_DIR: { nonce (echo $PI_POC_NONCE), claim, verify: { method, url, expect: { status/body_contains/body_regex } }, observations }. The harness validates it and binds it to the run — missing/invalid/misnonced evidence blocks promotion.",
1119
- "control_path (REQUIRED): the SAME script as poc_path (sha256-equality is ENFORCED). The harness runs it with PI_POC_MODE=control and PI_POC_TARGET=control_target. The control's evidence must DIFFER from the target's (not target-dependent blocked).",
1120
- "Default sandbox: docker run --rm --network none. Use local:true for network-dependent bugs (host-network sandbox; host execution needs operator PI_POC_ALLOW_LOCAL=1).",
1121
- "After the bundle is recorded, dispatch the confirmer subagent (agents/confirmer.md, fresh context, different model) and commit its verdict with ConfirmFinding. Never CaseUpdate status='confirmed' directly.",
1122
- ],
1123
- parameters: PromoteSchema,
1124
-
1125
- async execute(_id, params, _signal, _onUpdate, _ctx) {
1126
- // Validate promotability BEFORE running the PoC — each sandboxed run can
1127
- // take 30s (plus first-time image pull), so fail cheap when the case
1128
- // can't advance anyway (missing, wrong status, missing required fields,
1129
- // missing artifact-backed observation evidence).
1130
- const caseId = params.id as string;
1131
- const current = assertPromotable(caseId);
1132
-
1133
- const fail = (text: string, _extra?: Record<string, unknown>): never => {
1134
- throw new Error(text);
1135
- };
1136
-
1137
- const controlPath = (params.control_path as string | undefined)?.trim() ?? "";
1138
- const controlTarget = (params.control_target as string | undefined)?.trim() ?? "";
1139
- if (!controlPath) {
1140
- return fail(
1141
- "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.",
1142
- { missingControl: true },
1143
- );
1144
- }
1145
- if (!controlTarget) {
1146
- return fail(
1147
- "control_target is REQUIRED: a distinct baseline target that lacks the vulnerability.",
1148
- { missingControlTarget: true },
1149
- );
1150
- }
1151
- if (controlTarget === current.target) {
1152
- return fail(
1153
- "control_target must differ from the case target; a control run against the vulnerable target proves nothing.",
1154
- { controlTargetEqualsCaseTarget: true },
1155
- );
1156
- }
1157
-
1158
- // Same-file contract (anti-cheat): control must be the SAME bytes as the
1159
- // PoC, differing only via the harness-set env. Check BEFORE any run.
1160
- const pocPath = (params.poc_path as string | undefined)?.trim() ?? "";
1161
- let pocHash: string | undefined;
1162
- let controlHash: string | undefined;
1163
- try {
1164
- pocHash = createHash("sha256").update(readFileSync(pocPath)).digest("hex");
1165
- controlHash = createHash("sha256").update(readFileSync(controlPath)).digest("hex");
1166
- } catch (e) {
1167
- return fail(
1168
- `Cannot read PoC/control scripts for the same-file check: ${(e as Error).message}`,
1169
- { sameFileCheckFailed: true },
1170
- );
1171
- }
1172
- if (pocHash !== controlHash) {
1173
- return fail(
1174
- "CONTROL CHECK FAILED: control_path must be the SAME script as poc_path (sha256 mismatch). Case remains investigating.",
1175
- { controlHashMismatch: true },
1176
- );
1177
- }
1178
-
1179
- const runOptions = (pocMode: string, target: string): PocRunOptions => ({
1180
- network: params.local === true ? "host" : "none",
1181
- local: params.local === true,
1182
- env: { PI_POC_MODE: pocMode, PI_POC_TARGET: target },
1183
- });
1144
+ if (!startedAsSubagent)
1145
+ registerCaseTool({
1146
+ name: "PromoteFinding",
1147
+ label: "Run PoC Evidence",
1148
+ description:
1149
+ "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.",
1150
+ promptSnippet:
1151
+ "Phase 1: run PoC evidence (target x2 + control) and record the pending bundle",
1152
+ promptGuidelines: [
1153
+ "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.",
1154
+ "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.",
1155
+ "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.",
1156
+ "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.",
1157
+ "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.",
1158
+ "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.",
1159
+ "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.",
1160
+ "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.",
1161
+ ],
1162
+ parameters: PromoteSchema,
1163
+
1164
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
1165
+ if (isSubagentProcess()) {
1166
+ throw new Error(
1167
+ "PromoteFinding is reserved for the main/coordinator agent. A worker or subagent may gather evidence but cannot run validation or create a promotion bundle.",
1168
+ );
1169
+ }
1170
+ // Validate promotability BEFORE running the PoC — each sandboxed run can
1171
+ // take 30s (plus first-time image pull), so fail cheap when the case
1172
+ // can't advance anyway (missing, wrong status, missing required fields,
1173
+ // missing artifact-backed observation evidence).
1174
+ const caseId = params.id as string;
1175
+ const current = assertPromotable(caseId);
1176
+
1177
+ const fail = (text: string, _extra?: Record<string, unknown>): never => {
1178
+ throw new Error(text);
1179
+ };
1184
1180
 
1185
- const caseTarget = current.target ?? "";
1186
- // Determinism: TWO target runs + one control run. Exit codes are
1187
- // diagnostics; completion, evidence validity, determinism, and the
1188
- // differential are the gate.
1189
- const run1 = runPoc(pocPath, runOptions("poc", caseTarget));
1190
- const run2 = runPoc(pocPath, runOptions("poc", caseTarget));
1191
- const controlRun = runPoc(controlPath, runOptions("control", controlTarget));
1181
+ const pocPath = (params.poc_path as string | undefined)?.trim() ?? "";
1182
+ const controlPath = (params.control_path as string | undefined)?.trim() || pocPath;
1183
+ const controlTarget = (params.control_target as string | undefined)?.trim() ?? "";
1184
+ if (!pocPath) {
1185
+ return fail("poc_path is REQUIRED: absolute path to the PoC script run by the harness.", {
1186
+ missingPocPath: true,
1187
+ });
1188
+ }
1189
+ if (!controlTarget) {
1190
+ return fail(
1191
+ "control_target is REQUIRED: a distinct baseline target that lacks the vulnerability.",
1192
+ { missingControlTarget: true },
1193
+ );
1194
+ }
1195
+ if (controlTarget === current.target) {
1196
+ return fail(
1197
+ "control_target must differ from the case target; a control run against the vulnerable target proves nothing.",
1198
+ { controlTargetEqualsCaseTarget: true },
1199
+ );
1200
+ }
1201
+ if (params.local === true && process.env.PI_POC_ALLOW_NETWORK !== "1") {
1202
+ return fail(
1203
+ "Networked PoC execution is operator-gated. Set PI_POC_ALLOW_NETWORK=1 to authorize the host-network sandbox for this session.",
1204
+ { networkNotAuthorized: true },
1205
+ );
1206
+ }
1207
+ const controlAuthorization = controlTargetAuthorizationError(controlTarget);
1208
+ if (controlAuthorization) {
1209
+ return fail(
1210
+ `CONTROL AUTHORIZATION FAILED: ${controlAuthorization}. ` +
1211
+ "The operator must set PI_POC_CONTROL_TARGETS to the exact approved control host/origin before this control can anchor confirmation.",
1212
+ { controlNotAuthorized: true },
1213
+ );
1214
+ }
1192
1215
 
1193
- const evidenceRun = (r: PocRun, mode: "poc" | "control", target: string): PocEvidenceRun => {
1194
- if (!r.completed || !r.outputComplete) {
1216
+ // Same-file contract (anti-cheat): control must be the SAME bytes as the
1217
+ // PoC, differing only via the harness-set env. Check BEFORE any run.
1218
+ let pocHash: string | undefined;
1219
+ let controlHash: string | undefined;
1220
+ try {
1221
+ pocHash = createHash("sha256").update(readFileSync(pocPath)).digest("hex");
1222
+ controlHash = createHash("sha256").update(readFileSync(controlPath)).digest("hex");
1223
+ } catch (e) {
1195
1224
  return fail(
1196
- `${mode} run did not complete or output capture was incomplete` +
1197
- (r.infraError ? ` (infra: ${r.output.trim()})` : "") +
1198
- ". A crash is not evidence. Case remains investigating.",
1199
- { run: r, pocCrashed: true },
1225
+ `Cannot read PoC/control scripts for the same-file check: ${(e as Error).message}`,
1226
+ { sameFileCheckFailed: true },
1200
1227
  );
1201
1228
  }
1202
- if (r.evidenceError) {
1229
+ if (pocHash !== controlHash) {
1203
1230
  return fail(
1204
- `EVIDENCE CONTRACT FAILED (${mode} run): ${r.evidenceError}. ` +
1205
- "The PoC must write evidence.json to $PI_POC_EVIDENCE_DIR — { nonce (echo $PI_POC_NONCE), claim, verify: { method, url, expect: { status / body_contains / body_regex } }, observations } — " +
1206
- "the file is bound to this run and validated by the harness. Case remains investigating.",
1207
- { run: r, evidenceError: r.evidenceError },
1231
+ "CONTROL CHECK FAILED: control_path must be the SAME script as poc_path (sha256 mismatch). Case remains investigating.",
1232
+ { controlHashMismatch: true },
1208
1233
  );
1209
1234
  }
1210
- if (!r.evidence || !r.evidenceSha256 || !r.nonce) {
1211
- return fail(`${mode} run produced no evidence. Case remains investigating.`, { run: r });
1235
+
1236
+ // ── OOB callback (Tier 1, opt-in for blind classes) ──
1237
+ const oobRequested = params.oob === true;
1238
+ if (oobRequested) {
1239
+ return fail(
1240
+ "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.",
1241
+ { oobSourceSeparationRequired: true },
1242
+ );
1212
1243
  }
1213
- return {
1214
- mode,
1215
- target,
1216
- nonce: r.nonce,
1217
- ranAt: r.ranAt,
1218
- exitCode: r.exitCode,
1219
- sandbox: r.sandbox,
1220
- completed: r.completed,
1221
- outputComplete: r.outputComplete,
1222
- output: r.output ?? "",
1223
- evidence: r.evidence,
1224
- evidenceSha256: r.evidenceSha256,
1225
- evidencePath: r.evidencePath,
1244
+ const runOptions = (pocMode: string, target: string): PocRunOptions => ({
1245
+ network: params.local === true ? "host" : "none",
1246
+ local: params.local === true,
1247
+ env: {
1248
+ PI_POC_MODE: pocMode,
1249
+ PI_POC_TARGET: target,
1250
+ },
1251
+ });
1252
+
1253
+ const caseTarget = current.target ?? "";
1254
+ // Determinism: TWO target runs + one control run. Exit 0 is run
1255
+ // integrity only; nonce-bound body evidence and the harness-owned
1256
+ // target/control replay form the machine gate.
1257
+ const run1 = runPoc(pocPath, runOptions("poc", caseTarget));
1258
+ const run2 = runPoc(pocPath, runOptions("poc", caseTarget));
1259
+ const controlRun = runPoc(controlPath, runOptions("control", controlTarget));
1260
+
1261
+ const evidenceRun = (
1262
+ r: PocRun,
1263
+ mode: "poc" | "control",
1264
+ target: string,
1265
+ ): PocEvidenceRun => {
1266
+ if (!r.completed || !r.outputComplete) {
1267
+ return fail(
1268
+ `${mode} run did not complete or output capture was incomplete` +
1269
+ (r.infraError ? ` (infra: ${r.output.trim()})` : "") +
1270
+ ". A crash is not evidence. Case remains investigating.",
1271
+ { run: r, pocCrashed: true },
1272
+ );
1273
+ }
1274
+ if (r.evidenceError) {
1275
+ return fail(
1276
+ `EVIDENCE CONTRACT FAILED (${mode} run): ${r.evidenceError}. ` +
1277
+ "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 — " +
1278
+ "the file is bound to this run and validated by the harness. Case remains investigating.",
1279
+ { run: r, evidenceError: r.evidenceError },
1280
+ );
1281
+ }
1282
+ if (!r.evidence || !r.evidenceSha256 || !r.nonce) {
1283
+ return fail(`${mode} run produced no evidence. Case remains investigating.`, {
1284
+ run: r,
1285
+ });
1286
+ }
1287
+ return {
1288
+ mode,
1289
+ target,
1290
+ nonce: r.nonce,
1291
+ ranAt: r.ranAt,
1292
+ exitCode: r.exitCode,
1293
+ sandbox: r.sandbox,
1294
+ completed: r.completed,
1295
+ outputComplete: r.outputComplete,
1296
+ output: r.output ?? "",
1297
+ evidence: r.evidence,
1298
+ evidenceSha256: r.evidenceSha256,
1299
+ evidencePath: r.evidencePath,
1300
+ };
1226
1301
  };
1227
- };
1228
1302
 
1229
- const targetRuns: [PocEvidenceRun, PocEvidenceRun] = [
1230
- evidenceRun(run1, "poc", caseTarget),
1231
- evidenceRun(run2, "poc", caseTarget),
1232
- ];
1233
- const control = evidenceRun(controlRun, "control", controlTarget);
1234
-
1235
- const bundle: PendingConfirmation = {
1236
- caseId,
1237
- ranAt: new Date().toISOString(),
1238
- pocPath,
1239
- pocSha256: pocHash,
1240
- controlPath,
1241
- controlTarget,
1242
- targetRuns,
1243
- controlRun: control,
1244
- };
1303
+ const targetRuns: [PocEvidenceRun, PocEvidenceRun] = [
1304
+ evidenceRun(run1, "poc", caseTarget),
1305
+ evidenceRun(run2, "poc", caseTarget),
1306
+ ];
1307
+ const control = evidenceRun(controlRun, "control", controlTarget);
1308
+
1309
+ // Tier 2 (docs/poc-trust-model.md): the harness executes the SAME
1310
+ // request template against target and operator-approved control, applying
1311
+ // the target's predicates to both. DNS is pinned at connect time.
1312
+ const harnessVerified = await replayDifferential(
1313
+ targetRuns[0].evidence,
1314
+ caseTarget,
1315
+ controlTarget,
1316
+ { allowPrivate: process.env.PI_POC_ALLOW_PRIVATE_REPLAY === "1" },
1317
+ );
1245
1318
 
1246
- let record: CaseRecord;
1247
- try {
1248
- record = storePendingConfirmation(caseId, bundle);
1249
- } catch (e) {
1250
- return fail(`Pending confirmation rejected: ${(e as Error).message}`, {
1251
- storeRejected: true,
1252
- });
1253
- }
1319
+ const bundle: PendingConfirmation = {
1320
+ caseId,
1321
+ ranAt: new Date().toISOString(),
1322
+ pocPath,
1323
+ pocSha256: pocHash,
1324
+ controlPath,
1325
+ controlTarget,
1326
+ targetRuns,
1327
+ controlRun: control,
1328
+ harnessVerified,
1329
+ };
1254
1330
 
1255
- return {
1256
- content: [
1257
- {
1258
- type: "text",
1259
- text:
1260
- `Phase 1 complete — evidence bundle recorded on ${caseId} (expires in 1h).\n` +
1261
- `Target runs: 2, Control run: 1 — all with validated nonce-bound evidence.json.\n` +
1262
- `Evidence sha256: ${targetRuns[0].evidenceSha256}\n` +
1263
- `PoC script sha256 (at run time): ${pocHash}\n\n` +
1264
- (detectHost() === "omp"
1265
- ? `DISPATCH THE CONFIRMER now: task({ context: 'fresh', tasks: [{ name: 'confirm-${caseId}-1', agent: 'confirmer', task: 'Verify the PoC evidence for case ${caseId} (poc_path=${pocPath}, control_target=${controlTarget}, evidence_sha256=${targetRuns[0].evidenceSha256}, poc_sha256=${pocHash}). Assume fabricated, prove real. Re-send the verify request yourself. Return the verdict.' }] })\n`
1266
- : `DISPATCH THE CONFIRMER now: subagent({ workflowScript: "return runs.run('confirm-${caseId}-1', { agent: 'confirmer', task: 'Verify the PoC evidence for case ${caseId} (poc_path=${pocPath}, control_target=${controlTarget}, evidence_sha256=${targetRuns[0].evidenceSha256}, poc_sha256=${pocHash}). Assume fabricated, prove real. Re-send the verify request yourself. Return the verdict.' })", context: 'fresh', async: true })\n`) +
1267
- "Then commit the verdict with ConfirmFinding(case_id, verdict) — CONFIRMED promotes, NOT_CONFIRMED keeps investigating.",
1268
- },
1269
- ],
1270
- details: {
1271
- record,
1272
- bundle: {
1273
- caseId,
1274
- ranAt: bundle.ranAt,
1275
- pocPath,
1276
- controlPath,
1277
- controlTarget,
1278
- pocSha256: pocHash,
1279
- evidenceSha256: targetRuns[0].evidenceSha256,
1331
+ let record: CaseRecord;
1332
+ try {
1333
+ record = storePendingConfirmation(caseId, bundle);
1334
+ } catch (e) {
1335
+ return fail(`Pending confirmation rejected: ${(e as Error).message}`, {
1336
+ storeRejected: true,
1337
+ });
1338
+ }
1339
+
1340
+ return {
1341
+ content: [
1342
+ {
1343
+ type: "text",
1344
+ text:
1345
+ `Phase 1 complete — evidence bundle recorded on ${caseId} (expires in 1h).\n` +
1346
+ `Target runs: 2, Control run: 1 — all with validated nonce-bound evidence.json.\n` +
1347
+ `Evidence sha256: ${targetRuns[0].evidenceSha256}\n` +
1348
+ `PoC script sha256 (at run time): ${pocHash}\n` +
1349
+ `Harness verify replay: ${harnessVerified.attempted ? (harnessVerified.pass ? `PASS (status ${harnessVerified.status})` : `FAILED — ${harnessVerified.note}`) : harnessVerified.note}\n` +
1350
+ `\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.`,
1351
+ },
1352
+ ],
1353
+ details: {
1354
+ record,
1355
+ bundle: {
1356
+ caseId,
1357
+ ranAt: bundle.ranAt,
1358
+ pocPath,
1359
+ controlPath,
1360
+ controlTarget,
1361
+ pocSha256: pocHash,
1362
+ evidenceSha256: targetRuns[0].evidenceSha256,
1363
+ harnessVerified,
1364
+ },
1280
1365
  },
1281
- },
1282
- };
1283
- },
1366
+ };
1367
+ },
1284
1368
 
1285
- renderCall(args, theme) {
1286
- return callLine(theme, "PromoteFinding", (args.id as string) ?? "");
1287
- },
1369
+ renderCall(args, theme) {
1370
+ return callLine(theme, "PromoteFinding", (args.id as string) ?? "");
1371
+ },
1288
1372
 
1289
- renderResult(result, _opts, theme) {
1290
- const details = result.details as { bundle?: { evidenceSha256?: string } } | undefined;
1291
- if (!details?.bundle) {
1292
- return new Text(theme.fg("error", "✗ PromoteFinding failed"), 0, 0);
1293
- }
1294
- return new Text(
1295
- theme.fg("success", "✓ ") +
1296
- theme.fg("dim", "evidence bundle ") +
1297
- theme.fg("muted", details.bundle.evidenceSha256?.slice(0, 12) ?? ""),
1298
- 0,
1299
- 0,
1300
- );
1301
- },
1302
- });
1373
+ renderResult(result, _opts, theme) {
1374
+ const details = result.details as { bundle?: { evidenceSha256?: string } } | undefined;
1375
+ if (!details?.bundle) {
1376
+ return new Text(theme.fg("error", "✗ PromoteFinding failed"), 0, 0);
1377
+ }
1378
+ return new Text(
1379
+ theme.fg("success", "✓ ") +
1380
+ theme.fg("dim", "evidence bundle ") +
1381
+ theme.fg("muted", details.bundle.evidenceSha256?.slice(0, 12) ?? ""),
1382
+ 0,
1383
+ 0,
1384
+ );
1385
+ },
1386
+ });
1303
1387
 
1304
1388
  // ── Tool: ConfirmFinding (phase 2) ──
1305
1389
 
1306
- pi.registerTool({
1307
- name: "ConfirmFinding",
1308
- label: "Commit Confirmer Verdict",
1309
- description:
1310
- "Phase 2 of confirmation: commit (or refuse) a promotion on the confirmer subagent's verdict. CONFIRMED requires a target-only differential, re_executed: true (the confirmer re-sent the verify request itself), a disconfirmation_attempt (becomes the case's disconfirmation), and the pending bundle from PromoteFinding still valid (nonce-bound evidence, determinism, control differential, PoC script unchanged — checked again at the ledger). NOT_CONFIRMED records the verdict and keeps the case investigating — no tie-breaker.",
1311
- promptSnippet: "Commit the confirmer verdict — promote or keep investigating",
1312
- promptGuidelines: [
1313
- "Run after PromoteFinding + the confirmer dispatch. The verdict comes from the confirmer subagent output, not from the writer.",
1314
- "CONFIRMED requires differential: 'target_only', re_executed: true, and disconfirmation_attempt (the confirmer's own failed disproof). A verdict missing any of these is rejected.",
1315
- "NOT_CONFIRMED is final for that attempt the case stays investigating with the reasoning recorded in assumptions. Re-dispatch a new confirmer if you want a second opinion; every attempt is recorded.",
1316
- "Never CaseUpdate status='confirmed' directly — it is rejected. Always use PromoteFinding + ConfirmFinding.",
1317
- ],
1318
- parameters: ConfirmSchema,
1319
-
1320
- async execute(_id, params, _signal, _onUpdate, _ctx) {
1321
- const caseId = params.id as string;
1322
- const result = applyConfirmationResult(caseId, params.verdict as ConfirmerVerdict);
1323
- const record = result.record;
1324
- const promoted = record.status === "confirmed";
1325
- return {
1326
- content: [
1327
- {
1328
- type: "text",
1329
- text: promoted
1330
- ? `Confirmer CONFIRMED. Case promoted:
1390
+ // Do not expose the commit capability in a worker process at all. The
1391
+ // execute-time check remains as defense in depth if process state changes
1392
+ // after registration or another integration forwards a stale tool handle.
1393
+ if (!startedAsSubagent)
1394
+ registerCaseTool({
1395
+ name: "ConfirmFinding",
1396
+ label: "Main-Agent Confirmation",
1397
+ description:
1398
+ "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.",
1399
+ promptSnippet: "Main agent: independently review and commit or refuse PoC confirmation",
1400
+ promptGuidelines: [
1401
+ "Run only in the main/coordinator agent after PromoteFinding returns. Do not dispatch a worker to decide or author this verdict.",
1402
+ "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.",
1403
+ "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.",
1404
+ "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.",
1405
+ "NOT_CONFIRMED is final for that attempt — the case stays investigating with the main agent's reasoning recorded. A fresh PromoteFinding run is required for another attempt.",
1406
+ "Never CaseUpdate status='confirmed' directly — it is rejected. Always use PromoteFinding + ConfirmFinding.",
1407
+ ],
1408
+ parameters: ConfirmSchema,
1409
+
1410
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
1411
+ if (isSubagentProcess()) {
1412
+ throw new Error(
1413
+ "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.",
1414
+ );
1415
+ }
1416
+ const caseId = params.id as string;
1417
+ const parsedVerdict = validateMainAgentVerdict(params.verdict);
1418
+ if (!parsedVerdict.ok) {
1419
+ throw new Error(`Invalid main-agent confirmation verdict: ${parsedVerdict.error}`);
1420
+ }
1421
+ let phase2Verification: MainAgentVerification | undefined;
1422
+ if (parsedVerdict.verdict.verdict === "CONFIRMED") {
1423
+ const current = getCaseById(caseId);
1424
+ if (!current) throw new Error(`Case not found: ${caseId}`);
1425
+ const bundle = current.pendingConfirmation;
1426
+ if (!bundle) {
1427
+ throw new Error("No pending confirmation on this case — run PromoteFinding first");
1428
+ }
1429
+ const controlAuthorizationError = controlTargetAuthorizationError(bundle.controlTarget);
1430
+ if (controlAuthorizationError) {
1431
+ throw new Error(`CONTROL AUTHORIZATION FAILED: ${controlAuthorizationError}`);
1432
+ }
1433
+ const replay = await replayDifferential(
1434
+ bundle.targetRuns[0].evidence,
1435
+ current.target ?? bundle.targetRuns[0].target,
1436
+ bundle.controlTarget,
1437
+ { allowPrivate: process.env.PI_POC_ALLOW_PRIVATE_REPLAY === "1" },
1438
+ );
1439
+ phase2Verification = {
1440
+ at: new Date().toISOString(),
1441
+ result: replay,
1442
+ };
1443
+ }
1444
+ const result = applyConfirmationResult(caseId, parsedVerdict.verdict, phase2Verification, {
1445
+ startedAsSubagent: isSubagentProcess(),
1446
+ });
1447
+ const record = result.record;
1448
+ const promoted = record.status === "confirmed";
1449
+ return {
1450
+ content: [
1451
+ {
1452
+ type: "text",
1453
+ text: promoted
1454
+ ? `Main agent CONFIRMED. Case promoted:
1331
1455
  ${formatCaseDetail(record)}`
1332
- : `Confirmer NOT_CONFIRMED — case stays investigating (attempt recorded):
1456
+ : `Main agent NOT_CONFIRMED — case stays investigating (attempt recorded):
1333
1457
  ${formatCaseDetail(record)}`,
1334
- },
1335
- ],
1336
- details: { record, promoted, changed: result.changed },
1337
- };
1338
- },
1458
+ },
1459
+ ],
1460
+ details: { record, promoted, changed: result.changed },
1461
+ };
1462
+ },
1339
1463
 
1340
- renderCall(args, theme) {
1341
- return callLine(theme, "ConfirmFinding", (args.id as string) ?? "");
1342
- },
1464
+ renderCall(args, theme) {
1465
+ return callLine(theme, "ConfirmFinding", (args.id as string) ?? "");
1466
+ },
1343
1467
 
1344
- renderResult(result, _opts, theme) {
1345
- const details = result.details as { promoted?: boolean } | undefined;
1346
- return new Text(
1347
- details?.promoted
1348
- ? theme.fg("success", "✓ Promoted")
1349
- : theme.fg("warning", "↷ Not confirmed"),
1350
- 0,
1351
- 0,
1352
- );
1353
- },
1354
- });
1468
+ renderResult(result, _opts, theme) {
1469
+ const details = result.details as { promoted?: boolean } | undefined;
1470
+ return new Text(
1471
+ details?.promoted
1472
+ ? theme.fg("success", "✓ Promoted")
1473
+ : theme.fg("warning", "↷ Not confirmed"),
1474
+ 0,
1475
+ 0,
1476
+ );
1477
+ },
1478
+ });
1355
1479
 
1356
1480
  // ── Tool: CaseGet ──
1357
1481
 
1358
- pi.registerTool({
1482
+ registerCaseTool({
1359
1483
  name: "CaseGet",
1360
1484
  label: "Get Case",
1361
1485
  description: "Get full details of a single case by ID.",
@@ -1384,7 +1508,7 @@ ${formatCaseDetail(record)}`,
1384
1508
 
1385
1509
  // ── Tool: CaseList ──
1386
1510
 
1387
- pi.registerTool({
1511
+ registerCaseTool({
1388
1512
  name: "CaseList",
1389
1513
  label: "List Cases",
1390
1514
  description:
@@ -1414,7 +1538,7 @@ ${formatCaseDetail(record)}`,
1414
1538
 
1415
1539
  // ── Tool: CaseSearch ──
1416
1540
 
1417
- pi.registerTool({
1541
+ registerCaseTool({
1418
1542
  name: "CaseSearch",
1419
1543
  label: "Search Cases",
1420
1544
  description:
@@ -1442,7 +1566,7 @@ ${formatCaseDetail(record)}`,
1442
1566
 
1443
1567
  // ── Tool: CaseLink ──
1444
1568
 
1445
- pi.registerTool({
1569
+ registerCaseTool({
1446
1570
  name: "CaseLink",
1447
1571
  label: "Link Cases",
1448
1572
  description:
@@ -1514,7 +1638,7 @@ ${formatCaseDetail(record)}`,
1514
1638
 
1515
1639
  // ── Tool: CaseUnlink ──
1516
1640
 
1517
- pi.registerTool({
1641
+ registerCaseTool({
1518
1642
  name: "CaseUnlink",
1519
1643
  label: "Unlink Cases",
1520
1644
  description: "Remove a bidirectional link between two cases.",
@@ -1581,7 +1705,7 @@ ${formatCaseDetail(record)}`,
1581
1705
  { additionalProperties: false },
1582
1706
  );
1583
1707
 
1584
- pi.registerTool({
1708
+ registerCaseTool({
1585
1709
  name: "ChainSuggest",
1586
1710
  label: "Suggest Exploit Chains",
1587
1711
  description:
@@ -1595,7 +1719,9 @@ ${formatCaseDetail(record)}`,
1595
1719
  parameters: ChainSuggestSchema,
1596
1720
 
1597
1721
  async execute(_id, params, _signal, _onUpdate, _ctx) {
1598
- const suggestions = suggestChains((params.case_id as string | undefined) ?? undefined);
1722
+ const suggestions = await suggestChainsAsync(
1723
+ (params.case_id as string | undefined) ?? undefined,
1724
+ );
1599
1725
  const lines: string[] = [
1600
1726
  suggestions.length
1601
1727
  ? `${suggestions.length} chain candidate(s):`
@@ -1628,25 +1754,25 @@ ${formatCaseDetail(record)}`,
1628
1754
 
1629
1755
  // ── Tool: CaseContext ──
1630
1756
 
1631
- pi.registerTool({
1757
+ registerCaseTool({
1632
1758
  name: "CaseContext",
1633
1759
  label: "Generate Case Context",
1634
1760
  description:
1635
- "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.",
1636
- promptSnippet: "Generate case context for the report writer",
1761
+ "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.",
1762
+ promptSnippet: "Generate case context for the final report",
1637
1763
  promptGuidelines: [
1638
1764
  "Use CaseContext only for confirmed or already reported cases. Keep hypotheses and investigating cases in the ledger until proof is captured.",
1639
- "After CaseContext, dispatch the reporter subagent (agents/reporter) to write the final report to the returned report path, then CaseUpdate(status: 'reported').",
1765
+ "After CaseContext, write the final report to the returned report path yourself, then CaseUpdate(status: 'reported').",
1640
1766
  ],
1641
1767
  parameters: IdSchema,
1642
1768
 
1643
1769
  async execute(_id, params, _signal, _onUpdate, _ctx) {
1644
- const { path, contextPath, record } = writeCaseContext(params.id as string);
1770
+ const { path, contextPath, record } = await writeCaseContextAsync(params.id as string);
1645
1771
  return {
1646
1772
  content: [
1647
1773
  {
1648
1774
  type: "text",
1649
- text: `Case context written: ${contextPath}\nReport path (for the reporter agent): ${path}\n${formatCase(record)}`,
1775
+ text: `Case context written: ${contextPath}\nReport path: ${path}\n${formatCase(record)}`,
1650
1776
  },
1651
1777
  ],
1652
1778
  details: { path, contextPath, record },
@@ -1671,7 +1797,7 @@ ${formatCaseDetail(record)}`,
1671
1797
 
1672
1798
  pi.registerCommand("xp", {
1673
1799
  description:
1674
- "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]",
1800
+ "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]",
1675
1801
  handler: async (args, ctx) => {
1676
1802
  const next = parseXpModeArg(args ?? "", readXpMode());
1677
1803
  writeXpMode(next);
@@ -1682,23 +1808,25 @@ ${formatCaseDetail(record)}`,
1682
1808
  if (next !== "off") workflowInjected = false;
1683
1809
  ctx.ui.notify(
1684
1810
  `Casefile XP mode: ${next.toUpperCase()} (takes effect on the next prompt)`,
1685
- next === "on" ? "info" : "warning",
1811
+ next === "off" ? "warning" : "info",
1686
1812
  );
1687
1813
  },
1688
1814
  });
1689
1815
 
1690
1816
  // ── Tool: PipelineSubmit ──
1691
1817
 
1692
- pi.registerTool({
1818
+ registerCaseTool({
1693
1819
  name: "PipelineSubmit",
1694
1820
  label: "Submit Stage Output",
1695
1821
  description:
1696
1822
  "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.",
1697
1823
  promptSnippet: "Validate and submit a pipeline stage's output",
1698
1824
  promptGuidelines: [
1699
- "Every stage output a subagent returns must go through PipelineSubmit before the next stage is dispatched — do not eyeball schemas.",
1825
+ "Every delegated stage output and every main-agent VALIDATE/REPORT output must go through PipelineSubmit before the next stage starts — do not eyeball schemas.",
1700
1826
  "verdict repair → fix the listed fields and re-submit the same output; budget is 2 attempts per finding, then rejected.",
1701
- "Skeptic: unparseable/schema-invalid = UNDETERMINED (never DISPROVEN). Tracer error = UNREACHABLE. Both return repair.",
1827
+ "Skeptic: unparseable/schema-invalid = no verdict (repair/re-dispatch); only schema-valid DISPROVEN kills, and schema-valid UNDETERMINED blocks validation.",
1828
+ "Tracer crash/invalid output = no trace verdict; repair or re-dispatch.",
1829
+ 'Only schema-valid trace_result: "UNREACHABLE" blocks advancement as a proven unreachable path; schema-valid UNDETERMINED blocks validation until resolved.',
1702
1830
  "Test-path findings and hallucinated files are rejected by the pre-filter, not repairable — the finding itself is noise.",
1703
1831
  ],
1704
1832
  parameters: Type.Object(
@@ -1758,7 +1886,7 @@ ${formatCaseDetail(record)}`,
1758
1886
 
1759
1887
  // ── Tool: ScratchpadInit ──
1760
1888
 
1761
- pi.registerTool({
1889
+ registerCaseTool({
1762
1890
  name: "ScratchpadInit",
1763
1891
  label: "Init Scratchpad",
1764
1892
  description:
@@ -1796,7 +1924,7 @@ ${formatCaseDetail(record)}`,
1796
1924
 
1797
1925
  // ── Tool: ScratchpadResume ──
1798
1926
 
1799
- pi.registerTool({
1927
+ registerCaseTool({
1800
1928
  name: "ScratchpadResume",
1801
1929
  label: "Resume Scratchpad",
1802
1930
  description:
@@ -1855,7 +1983,7 @@ ${formatCaseDetail(record)}`,
1855
1983
 
1856
1984
  // ── Tool: ScratchpadCheckpoint ──
1857
1985
 
1858
- pi.registerTool({
1986
+ registerCaseTool({
1859
1987
  name: "ScratchpadCheckpoint",
1860
1988
  label: "Checkpoint Phase",
1861
1989
  description:
@@ -1905,7 +2033,7 @@ ${formatCaseDetail(record)}`,
1905
2033
 
1906
2034
  // ── Tool: ScratchpadWrite ──
1907
2035
 
1908
- pi.registerTool({
2036
+ registerCaseTool({
1909
2037
  name: "ScratchpadWrite",
1910
2038
  label: "Write Artifact",
1911
2039
  description:
@@ -1951,7 +2079,7 @@ ${formatCaseDetail(record)}`,
1951
2079
 
1952
2080
  // ── Tool: ScratchpadRead ──
1953
2081
 
1954
- pi.registerTool({
2082
+ registerCaseTool({
1955
2083
  name: "ScratchpadRead",
1956
2084
  label: "Read Artifact",
1957
2085
  description:
@@ -2008,7 +2136,7 @@ ${formatCaseDetail(record)}`,
2008
2136
 
2009
2137
  // ── Tool: ScratchpadPhaseDone ──
2010
2138
 
2011
- pi.registerTool({
2139
+ registerCaseTool({
2012
2140
  name: "ScratchpadPhaseDone",
2013
2141
  label: "Phase Done?",
2014
2142
  description:
@@ -2051,7 +2179,7 @@ ${formatCaseDetail(record)}`,
2051
2179
 
2052
2180
  // ── Tool: ScratchpadClear ──
2053
2181
 
2054
- pi.registerTool({
2182
+ registerCaseTool({
2055
2183
  name: "ScratchpadClear",
2056
2184
  label: "Clear Run",
2057
2185
  description:
@@ -2135,7 +2263,7 @@ ${formatCaseDetail(record)}`,
2135
2263
  // the workflow + entire active-case ledger into every child dispatch is a
2136
2264
  // token multiplier (N subagents × workflow + growing case list per turn) —
2137
2265
  // workers get what they need via their task and tool guidelines.
2138
- if (process.env.PI_SUBAGENT_CHILD === "1") return;
2266
+ if (isSubagentProcess()) return;
2139
2267
 
2140
2268
  const includeWorkflow = !workflowInjected;
2141
2269