@xaccefy/pi-casefile 0.8.3 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Casefile — offensive security case tracker for Pi.
3
3
  *
4
- * Tools: CaseAdd, CaseUpdate, PromoteFinding, EvidenceAdd, CoverageAdd, CoverageReport, ChainSuggest, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseContext, PipelineSubmit, ScratchpadInit, ScratchpadResume, ScratchpadCheckpoint, ScratchpadWrite, ScratchpadRead, ScratchpadPhaseDone, ScratchpadClear
4
+ * Tools: CaseAdd, CaseUpdate, PromoteFinding, ConfirmFinding, EvidenceAdd, CoverageAdd, CoverageReport, ChainSuggest, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseContext, PipelineSubmit, ScratchpadInit, ScratchpadResume, ScratchpadCheckpoint, ScratchpadWrite, ScratchpadRead, ScratchpadPhaseDone, ScratchpadClear
5
5
  * Command: /casefile — interactive dashboard
6
6
  * Event: before_agent_start — injects cyber workflow once per session, refreshes the active case list per prompt
7
7
  */
@@ -12,10 +12,22 @@ import { dirname, join } from "node:path";
12
12
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
13
  import { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
14
14
  import { Type } from "typebox";
15
-
15
+ import {
16
+ CANARY_ASSESSMENT_VALUES,
17
+ CONFIRM_DIFFERENTIAL_VALUES,
18
+ CONFIRM_VERDICT_VALUES,
19
+ SEVERITY_MATCH_VALUES,
20
+ validateMainAgentVerdict,
21
+ } from "./evidence.ts";
22
+ import {
23
+ controlTargetAuthorizationError,
24
+ type HarnessVerifyResult,
25
+ replayDifferential,
26
+ } from "./harness-verify.ts";
16
27
  import {
17
28
  addCaseResult,
18
29
  addEvidenceItemResult,
30
+ applyConfirmationResult,
19
31
  assertPromotable,
20
32
  type CaseConfidence,
21
33
  type CaseInput,
@@ -41,8 +53,10 @@ import {
41
53
  getCasefilePath,
42
54
  LINK_KIND_VALUES,
43
55
  linkCasesResult,
56
+ type MainAgentVerification,
57
+ type PendingConfirmation,
58
+ type PocEvidenceRun,
44
59
  PRIORITY_VALUES,
45
- promoteFindingResult,
46
60
  readActiveCases,
47
61
  readCasefile,
48
62
  recordCoverageResult,
@@ -50,14 +64,15 @@ import {
50
64
  SEVERITY_VALUES,
51
65
  STATUS_VALUES,
52
66
  searchCases,
53
- suggestChains,
67
+ storePendingConfirmation,
54
68
  unlinkCasesResult,
55
69
  updateCaseResult,
56
- writeCaseContext,
57
70
  } from "./ledger.ts";
71
+ import { suggestChainsAsync, writeCaseContextAsync } from "./ledger-worker.ts";
58
72
  import { pipeline_submit, SUBMIT_STAGES, type SubmitStage } from "./pipeline-submit.ts";
59
73
  import { type PocRun, type PocRunOptions, runPoc } from "./poc-runner.ts";
60
74
  import {
75
+ detectWorkspaceRoot,
61
76
  PHASE_ORDER,
62
77
  type ScratchpadPhase,
63
78
  type ScratchpadResume,
@@ -68,8 +83,13 @@ import {
68
83
  scratchpad_read,
69
84
  scratchpad_resume,
70
85
  scratchpad_write,
86
+ setScratchpadRoot,
71
87
  } from "./scratchpad.ts";
72
- import { STATIC_CYBER_WORKFLOW, STATIC_CYBER_WORKFLOW_LITE } from "./workflow.ts";
88
+ import {
89
+ STATIC_CYBER_WORKFLOW,
90
+ STATIC_CYBER_WORKFLOW_LITE,
91
+ STATIC_CYBER_WORKFLOW_OMP,
92
+ } from "./workflow.ts";
73
93
 
74
94
  // ── Schemas ───────────────────────────────────────────────────────────
75
95
 
@@ -153,15 +173,21 @@ const EvidenceAddSchema = Type.Object(
153
173
  artifact_path: Type.Optional(
154
174
  Type.String({
155
175
  description:
156
- "Path to the artifact file on disk. Stored as basename + SHA-256 hash " +
157
- "(full path is never persisted).",
176
+ "Path to a regular, non-symlink artifact inside the workspace. The bytes are copied durably and stored as basename + SHA-256 (full source path is never persisted).",
158
177
  }),
159
178
  ),
160
179
  },
161
180
  { additionalProperties: false },
162
181
  );
163
182
 
164
- // ── Tool: PromoteFinding ─────────────────────────────────────────────
183
+ // ── Tool: PromoteFinding (phase 1) / ConfirmFinding (phase 2) ──────────
184
+ //
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.
165
191
 
166
192
  const PromoteSchema = Type.Object(
167
193
  {
@@ -169,39 +195,88 @@ const PromoteSchema = Type.Object(
169
195
  poc_path: Type.String({
170
196
  description: "Absolute path to the PoC script on disk",
171
197
  }),
172
- verification_marker: Type.String({
173
- minLength: 1,
174
- description:
175
- "Unique string the PoC must print AFTER verifying the exploit worked (data extracted, callback received, payload reflected). The gate checks output contains this marker — exit code 0 alone is NOT sufficient; the marker prevents fluke exit 0 and mocked PoCs. Example: 'VULN_CONFIRMED_<case-id>'. Never print it unconditionally or before the exploit check.",
176
- }),
177
- disconfirmation_path: Type.String({
178
- description:
179
- "REQUIRED for EVERY promotion: absolute path to a disconfirmation script that tries to disprove the finding; it must complete and exit non-zero (finding survived disproof).",
180
- }),
181
198
  control_path: Type.String({
182
199
  description:
183
- "REQUIRED for EVERY promotion: absolute path to the SAME script as poc_path (sha256-enforced). The harness runs it in control mode against control_target and blocks if verification_marker appears.",
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.",
184
201
  }),
185
202
  control_target: Type.String({
186
203
  minLength: 1,
187
204
  description:
188
- "REQUIRED for EVERY promotion: target passed to the same PoC in control mode. Must be distinct from the case target and lack the vulnerability (patched replica, second account, baseline endpoint).",
189
- }),
190
- control_liveness_marker: Type.String({
191
- minLength: 1,
192
- description:
193
- "REQUIRED for EVERY promotion: a unique string the control script must print AFTER successfully reaching/exercising the control target (e.g. 'CONTROL_REACHED_<case-id>'). The harness blocks promotion if the control output lacks it — a control that never reached its target (unreachable host, wrong port, early exit) is not a clean verdict. Must differ from verification_marker.",
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).",
194
206
  }),
195
207
  local: Type.Optional(
196
208
  Type.Boolean({
197
209
  description:
198
- "Run with network access (Docker sandbox with --network host — still read-only FS, dropped capabilities, unprivileged user) instead of the isolated --network none sandbox. True host execution is NOT agent-selectable: it requires the operator to set PI_POC_ALLOW_LOCAL=1, and is used only as a fallback when Docker is unavailable.",
210
+ "Run with network access instead of --network none. Requires operator authorization via PI_POC_ALLOW_NETWORK=1. True host fallback additionally requires PI_POC_ALLOW_LOCAL=1.",
211
+ }),
212
+ ),
213
+ oob: Type.Optional(
214
+ Type.Boolean({
215
+ description:
216
+ "Reserved for source-separated out-of-band verification. Currently fails closed because a loopback listener reachable by the PoC cannot prove target causation.",
199
217
  }),
200
218
  ),
201
219
  },
202
220
  { additionalProperties: false },
203
221
  );
204
222
 
223
+ const ConfirmSchema = Type.Object(
224
+ {
225
+ id: Type.String({ description: "Case ID with a pending confirmation" }),
226
+ verdict: Type.Object(
227
+ {
228
+ verdict: Type.String({ enum: [...CONFIRM_VERDICT_VALUES] }),
229
+ reasoning: Type.String({
230
+ description: "Why the evidence does or does not demonstrate the claim",
231
+ }),
232
+ evidence_reviewed: Type.Array(Type.String(), {
233
+ description: "Files/evidence the main agent actually reviewed",
234
+ }),
235
+ re_execution_note: Type.Optional(
236
+ Type.String({
237
+ description:
238
+ "What the main agent observed during review and the fresh harness-owned target/control replay. Mandatory for CONFIRMED.",
239
+ }),
240
+ ),
241
+ differential: Type.String({
242
+ enum: [...CONFIRM_DIFFERENTIAL_VALUES],
243
+ description: "Target vs control evidence comparison. CONFIRMED requires target_only.",
244
+ }),
245
+ severity_match: Type.Optional(
246
+ Type.String({
247
+ enum: [...SEVERITY_MATCH_VALUES],
248
+ description: "Claimed severity vs what the evidence shows",
249
+ }),
250
+ ),
251
+ disconfirmation_attempt: Type.Optional(
252
+ Type.String({
253
+ description:
254
+ "The main agent's own failed attempt to disprove — becomes the case's disconfirmation",
255
+ }),
256
+ ),
257
+ canary_assessment: Type.Optional(
258
+ Type.String({
259
+ enum: [...CANARY_ASSESSMENT_VALUES],
260
+ description:
261
+ "verified when the replay carried a harness-generated reflection canary; otherwise not_applicable with a concrete reason",
262
+ }),
263
+ ),
264
+ canary_reason: Type.Optional(
265
+ Type.String({
266
+ description:
267
+ "Why a causal reflection canary is not meaningful for this exploit class. Required when canary_assessment=not_applicable.",
268
+ }),
269
+ ),
270
+ model: Type.Optional(
271
+ Type.String({ description: "Which model judged (recorded for the accuracy ledger)" }),
272
+ ),
273
+ },
274
+ { additionalProperties: false },
275
+ ),
276
+ },
277
+ { additionalProperties: false },
278
+ );
279
+
205
280
  // ── Tool: CaseGet ─────────────────────────────────────────────────────
206
281
 
207
282
  /** id-only schema, shared by CaseGet / CaseContext. */
@@ -604,6 +679,19 @@ function buildCaseListContext(records: CaseRecord[]): string {
604
679
  return lines.join("\n");
605
680
  }
606
681
 
682
+ /**
683
+ * Detect the extension host. OMP is a fork of Pi: both load the same
684
+ * `pi`-manifest extensions, but subagent dispatch differs (pi-subagents'
685
+ * `subagent({workflowScript})` vs OMP's native `task`). The entry script path
686
+ * carries the host package: `@oh-my-pi/pi-coding-agent/dist/cli.js` under OMP,
687
+ * `@earendil-works/pi-coding-agent` under Pi.
688
+ */
689
+ export function detectHost(): "omp" | "pi" {
690
+ const argv = process.argv.join(" ");
691
+ if (argv.includes("@oh-my-pi")) return "omp";
692
+ return "pi";
693
+ }
694
+
607
695
  /**
608
696
  * Builds the per-prompt injection. The cyber workflow is session-scope data —
609
697
  * it never changes — so the caller passes includeWorkflow=true exactly once
@@ -611,7 +699,8 @@ function buildCaseListContext(records: CaseRecord[]): string {
611
699
  * case list DOES change as cases are added, so it is refreshed every prompt.
612
700
  *
613
701
  * mode selects the workflow text: "lite" injects the single-agent workflow
614
- * (no subagent dispatch), anything else gets the full subagent pipeline.
702
+ * (no subagent dispatch), anything else gets the full subagent pipeline,
703
+ * rendered for the host's dispatch convention (pi-subagents vs OMP task).
615
704
  */
616
705
  function buildAgentInjection(
617
706
  active: CaseRecord[],
@@ -620,7 +709,12 @@ function buildAgentInjection(
620
709
  ): string {
621
710
  const caseList = buildCaseListContext(active);
622
711
  if (!includeWorkflow) return caseList;
623
- const workflow = mode === "lite" ? STATIC_CYBER_WORKFLOW_LITE : STATIC_CYBER_WORKFLOW;
712
+ const workflow =
713
+ mode === "lite"
714
+ ? STATIC_CYBER_WORKFLOW_LITE
715
+ : detectHost() === "omp"
716
+ ? STATIC_CYBER_WORKFLOW_OMP
717
+ : STATIC_CYBER_WORKFLOW;
624
718
  // Workflow FIRST for prominence, then case list as reference data.
625
719
  return caseList ? `${workflow}\n\n${caseList}` : workflow;
626
720
  }
@@ -681,6 +775,20 @@ export function parseXpModeArg(args: string, current: XpMode): XpMode {
681
775
  // ── Main extension ────────────────────────────────────────────────────
682
776
 
683
777
  export default function casefileExtension(pi: ExtensionAPI) {
778
+ // Process role is immutable for this extension instance. A worker may spawn
779
+ // shells, but unsetting PI_SUBAGENT_CHILD in a child shell cannot upgrade the
780
+ // already-loaded extension or reveal a tool that was omitted at startup.
781
+ const startedAsSubagent = process.env.PI_SUBAGENT_CHILD === "1";
782
+ const isSubagentProcess = () => startedAsSubagent || process.env.PI_SUBAGENT_CHILD === "1";
783
+ // Pin the workspace root ONCE at extension load. Every scratchpad / pipeline
784
+ // / PoC-path lookup otherwise re-walks the ambient cwd on each call — a
785
+ // mid-session `cd` would split state across two .scratchpad roots and
786
+ // misroot the hunt file-existence filter. The PoC runner reads PI_POC_ROOT
787
+ // (set only when the operator hasn't pinned it explicitly).
788
+ const workspaceRoot = detectWorkspaceRoot();
789
+ setScratchpadRoot(workspaceRoot);
790
+ process.env.PI_POC_ROOT ??= workspaceRoot;
791
+
684
792
  // ── Diagnostic Error Handler Middleware ──
685
793
  const originalRegisterTool = pi.registerTool.bind(pi);
686
794
  pi.registerTool = (spec: any) => {
@@ -821,7 +929,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
821
929
  name: "EvidenceAdd",
822
930
  label: "Add Evidence Item",
823
931
  description:
824
- "Record a role-typed, artifact-backed evidence item on a case (observation, reproduction, impact, refutation, cleanup). Artifact files are stored as basename + SHA-256 — the full path is never persisted. refutation items justify a kill; cleanup items track engagement cleanup before REPORT.",
932
+ "Record a role-typed, artifact-backed evidence item on a case. Artifact reads are restricted to regular, non-symlink files inside the workspace; bytes are copied durably and stored as basename + SHA-256. refutation items justify a kill; cleanup items track engagement cleanup before REPORT.",
825
933
  promptSnippet: "Record a role-typed evidence item",
826
934
  promptGuidelines: [
827
935
  "Use EvidenceAdd for artifact-backed evidence: raw responses, logs, screenshots, disproof attempts — anything a claim should trace back to.",
@@ -887,13 +995,15 @@ export default function casefileExtension(pi: ExtensionAPI) {
887
995
  description:
888
996
  "The attack class tested (e.g. sql-injection, xss, idor, ssti, ssrf, auth-bypass, ...).",
889
997
  }),
890
- scope: Type.Union(
891
- COVERAGE_SCOPE_VALUES.map((s) => Type.Literal(s)),
892
- {
893
- description:
894
- "'wide' if the verdict applies to the whole deployment/account/host (recorded ONCE, applies to every asset of the deployment — do NOT re-test it per asset); 'local' if specific to this one asset.",
895
- },
896
- ),
998
+ // Provider-safe string enum (per the header rule): Type.Union(Type.Literal)
999
+ // serializes to anyOf/const, which some providers drop — scope would
1000
+ // arrive undefined and every explicit 'wide' verdict would silently
1001
+ // persist as 'local', under-reporting tested classes.
1002
+ scope: Type.String({
1003
+ enum: [...COVERAGE_SCOPE_VALUES],
1004
+ description:
1005
+ "'wide' if the verdict applies to the whole deployment/account/host (recorded ONCE, applies to every asset of the deployment — do NOT re-test it per asset); 'local' if specific to this one asset.",
1006
+ }),
897
1007
  note: Type.String({
898
1008
  description:
899
1009
  "Short note of the tests ACTUALLY RUN and the verdict: techniques tried · result · key gap. A verdict guessed without testing can hide a real issue.",
@@ -1021,71 +1131,49 @@ export default function casefileExtension(pi: ExtensionAPI) {
1021
1131
  },
1022
1132
  });
1023
1133
 
1024
- // ── Tool: PromoteFinding ──
1134
+ // ── Tool: PromoteFinding (phase 1) ──
1025
1135
 
1026
1136
  pi.registerTool({
1027
1137
  name: "PromoteFinding",
1028
- label: "Promote Finding",
1138
+ label: "Run PoC Evidence",
1029
1139
  description:
1030
- "Run an on-disk PoC script (Docker sandbox or host-network sandbox) and, on exit 0 + verification marker present in output, promote an investigating case to confirmed. The verification_marker proves the exploit worked exit code 0 alone is NOT sufficient. EVERY promotion REQUIRES disconfirmation_path, same-script control_path, distinct control_target, and control_liveness_marker. The harness blocks promotion if output capture is incomplete, if the control prints the vuln marker, if liveness is absent, or if control/disconfirmation crash. Host execution is never agent-selectable local:true uses a host-network Docker sandbox; true host runs need PI_POC_ALLOW_LOCAL=1.",
1031
- promptSnippet: "Run a PoC and promote an investigating case to confirmed",
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",
1032
1142
  promptGuidelines: [
1033
- "Use PromoteFinding when an investigating case has a concrete PoC script on disk and you are ready to prove it.",
1034
- "Prerequisites: status='investigating' and non-empty poc, evidence, impact, severity, target, disconfirmation, plus an artifact-backed EvidenceAdd 'observation' item on the case (the initial signal, with artifact_path) the PoC gate auto-records the reproduction item.",
1035
- "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).",
1036
- "Gate: exit 0 AND verification_marker in the PoC output. The marker (e.g. 'VULN_CONFIRMED_<case-id>') must be printed only AFTER the exploit is verified (data extracted, callback received, payload reflected) never unconditionally or before the exploit check. The marker check prevents fluke exit 0 (script crashed early) and mocked PoCs (target faked) from passing.",
1037
- "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 must not print the verification marker and must print the liveness marker.",
1038
- "control_target + control_liveness_marker (REQUIRED): control_target is the distinct baseline target. The liveness marker is printed only AFTER reaching/exercising it; absent liveness blocks promotion.",
1039
- "disconfirmation_path: a script that tries to disprove the finding; if it exits 0, promotion is blocked. REQUIRED for EVERY promotion — the prose disconfirmation field is not enough at any severity (a case filed low/medium must not skip the run and be re-raised afterwards).",
1040
- "Never CaseUpdate status='confirmed' directly — it is rejected. Always use PromoteFinding.",
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.",
1041
1151
  ],
1042
1152
  parameters: PromoteSchema,
1043
1153
 
1044
1154
  async execute(_id, params, _signal, _onUpdate, _ctx) {
1045
- // Validate promotability BEFORE running the PoC — a sandboxed run can take
1046
- // 30s (plus first-time image pull), so fail cheap when the case can't
1047
- // advance anyway (missing, wrong status, missing required fields, missing
1048
- // artifact-backed observation evidence).
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).
1049
1159
  const caseId = params.id as string;
1050
1160
  const current = assertPromotable(caseId);
1051
1161
 
1052
- // Shared blocked-promotion shape: the case stays investigating and the
1053
- // caller gets the record back for context.
1054
1162
  const fail = (text: string, _extra?: Record<string, unknown>): never => {
1055
1163
  throw new Error(text);
1056
1164
  };
1057
1165
 
1058
- // Reject empty/whitespace markers BEFORE any PoC run it's a param
1059
- // error, so fail cheap instead of burning a (up to 30s) sandboxed run.
1060
- const marker = (params.verification_marker as string | undefined)?.trim();
1061
- if (!marker) {
1062
- return fail(
1063
- "verification_marker is empty or whitespace. " +
1064
- "A non-empty marker printed only AFTER the PoC confirms exploitation is required — " +
1065
- "exit code 0 alone is not sufficient. Case remains investigating.",
1066
- );
1067
- }
1068
-
1069
- // Control-target anti-cheat is mandatory for EVERY promotion — sandboxed
1070
- // and live alike. It is the only deterministic check that the marker is
1071
- // target-dependent; skipping it for the default sandboxed mode would let
1072
- // an unconditional-marker PoC pass untouched. Check BEFORE paying for the
1073
- // PoC run.
1074
- const controlPath = (params.control_path as string | undefined)?.trim();
1166
+ const controlPath = (params.control_path as string | undefined)?.trim() ?? "";
1167
+ const controlTarget = (params.control_target as string | undefined)?.trim() ?? "";
1075
1168
  if (!controlPath) {
1076
1169
  return fail(
1077
- "control_path is REQUIRED for every promotion (sandboxed and live alike): a script that runs " +
1078
- "the SAME PoC against a control lacking the vuln (patched replica, second account, baseline endpoint). " +
1079
- "The harness verifies the verification_marker is absent from the control run's output — that is what " +
1080
- "proves the marker is target-dependent. Write the control script and retry.",
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.",
1081
1171
  { missingControl: true },
1082
1172
  );
1083
1173
  }
1084
-
1085
- const controlTarget = (params.control_target as string | undefined)?.trim();
1086
1174
  if (!controlTarget) {
1087
1175
  return fail(
1088
- "control_target is REQUIRED for every promotion: a distinct baseline target that lacks the vulnerability.",
1176
+ "control_target is REQUIRED: a distinct baseline target that lacks the vulnerability.",
1089
1177
  { missingControlTarget: true },
1090
1178
  );
1091
1179
  }
@@ -1095,49 +1183,23 @@ export default function casefileExtension(pi: ExtensionAPI) {
1095
1183
  { controlTargetEqualsCaseTarget: true },
1096
1184
  );
1097
1185
  }
1098
-
1099
- // The control must ALSO prove it reached its target: without a liveness
1100
- // marker, a control pointed at an unreachable host / wrong port / early
1101
- // exit would show "marker absent" for reasons unrelated to the vuln.
1102
- const livenessMarker = (params.control_liveness_marker as string | undefined)?.trim();
1103
- if (!livenessMarker) {
1104
- return fail(
1105
- "control_liveness_marker is REQUIRED for every promotion: a unique string the control script " +
1106
- "prints ONLY AFTER reaching/exercising the control target (e.g. 'CONTROL_REACHED_<case-id>'). " +
1107
- "The harness blocks promotion if the control output lacks it — a control that never reached its " +
1108
- "target proves nothing. Must differ from verification_marker.",
1109
- { missingLivenessMarker: true },
1110
- );
1111
- }
1112
- if (livenessMarker === marker) {
1186
+ if (params.local === true && process.env.PI_POC_ALLOW_NETWORK !== "1") {
1113
1187
  return fail(
1114
- "control_liveness_marker must differ from verification_marker the liveness marker proves the " +
1115
- "control reached its target, the verification marker proves the vuln fired. Use distinct strings.",
1116
- { livenessEqualsMarker: true },
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 },
1117
1190
  );
1118
1191
  }
1119
-
1120
- // EXECUTED disconfirmation is required for EVERY promotion — the prose
1121
- // `disconfirmation` field cannot carry the disprove-attempt. Making it
1122
- // unconditional (not severity-keyed) also kills the ordering attack:
1123
- // a case filed as low/medium could previously skip the run, promote,
1124
- // then be re-raised to high/critical with no executed disproof.
1125
- const disconfirmationPath = (params.disconfirmation_path as string | undefined)?.trim();
1126
- if (!disconfirmationPath) {
1192
+ const controlAuthorization = controlTargetAuthorizationError(controlTarget);
1193
+ if (controlAuthorization) {
1127
1194
  return fail(
1128
- "disconfirmation_path is REQUIRED for every promotion: a script that tries to " +
1129
- "disprove the finding and must exit non-zero (the finding survived the attempt). " +
1130
- "The prose disconfirmation field is not enough. Write the disconfirmation script and retry.",
1131
- { missingDisconfirmation: true },
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 },
1132
1198
  );
1133
1199
  }
1134
1200
 
1135
- // Same-file contract (anti-cheat): the control must be the SAME script as
1136
- // the PoC, differing only via the harness-set PI_POC_MODE env var. Two
1137
- // agent-written files (poc prints the marker unconditionally, control
1138
- // prints the liveness marker) pass every string check — the "same script"
1139
- // recommendation in the docs is now enforced: sha256(control) must equal
1140
- // sha256(poc). Hash BEFORE any run so a mismatch fails cheap.
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.
1141
1203
  const pocPath = (params.poc_path as string | undefined)?.trim() ?? "";
1142
1204
  let pocHash: string | undefined;
1143
1205
  let controlHash: string | undefined;
@@ -1152,134 +1214,137 @@ export default function casefileExtension(pi: ExtensionAPI) {
1152
1214
  }
1153
1215
  if (pocHash !== controlHash) {
1154
1216
  return fail(
1155
- "CONTROL CHECK FAILED: control_path must be the SAME script as poc_path " +
1156
- "(sha256 mismatch). The control run is only meaningful as the same PoC " +
1157
- "pointed at a distinct control_target via PI_POC_MODE=control and PI_POC_TARGET. " +
1158
- "A separately written control file proves nothing. Case remains investigating.",
1159
- { run: undefined, controlHashMismatch: true },
1217
+ "CONTROL CHECK FAILED: control_path must be the SAME script as poc_path (sha256 mismatch). Case remains investigating.",
1218
+ { controlHashMismatch: true },
1160
1219
  );
1161
1220
  }
1162
1221
 
1163
- // local:true now means "network access": with the operator's
1164
- // PI_POC_ALLOW_LOCAL=1 opt-in the run executes on the host; WITHOUT the
1165
- // opt-in it uses a Docker sandbox with --network host (same FS/cap/user
1166
- // isolation) and fails closed when Docker is unavailable too. Host
1167
- // execution is never agent-selectable on its own.
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
+ }
1168
1230
  const runOptions = (pocMode: string, target: string): PocRunOptions => ({
1169
1231
  network: params.local === true ? "host" : "none",
1170
1232
  local: params.local === true,
1171
- env: { PI_POC_MODE: pocMode, PI_POC_TARGET: target },
1233
+ env: {
1234
+ PI_POC_MODE: pocMode,
1235
+ PI_POC_TARGET: target,
1236
+ },
1172
1237
  });
1173
1238
 
1174
1239
  const caseTarget = current.target ?? "";
1175
- const run = runPoc(pocPath, runOptions("poc", caseTarget));
1176
-
1177
- // Fail closed without throwing: non-zero PoC must leave the case investigating.
1178
- if (run.exitCode !== 0) {
1179
- return fail(
1180
- `PoC failed (exit ${run.exitCode}). Case remains investigating.\nOutput:\n${run.output}`,
1181
- { run },
1182
- );
1183
- }
1184
-
1185
- // Defense-in-depth: exit 0 implies the run completed (sandbox wrapper /
1186
- // local spawn semantics), but never trust a run the runner says crashed.
1187
- if (!run.completed || !run.outputComplete) {
1188
- return fail(
1189
- `PoC did NOT complete or output capture was incomplete. Case remains investigating.\nOutput:\n${run.output}`,
1190
- { run, pocCrashed: true },
1191
- );
1192
- }
1193
-
1194
- // Verification marker check: exit code 0 alone is NOT sufficient.
1195
- // The PoC must print the verification_marker, proving the exploit
1196
- // actually worked — not just that the script ran. The check runs on
1197
- // rawOutput (untruncated) so a script printing its marker past the
1198
- // 4000-char display window cannot hide it.
1199
- const pocOut = run.rawOutput ?? run.output;
1200
- if (!pocOut.includes(marker)) {
1201
- return fail(
1202
- `PoC exited 0 but the verification marker "${marker}" was NOT found in the output.\n` +
1203
- `This means the script ran but did not prove exploitation. The marker must be printed only AFTER the PoC verifies the exploit worked (data extracted, callback received, payload reflected, etc.).\n` +
1204
- `Do not print the marker unconditionally — print it only when the exploit is confirmed.\n\nOutput:\n${run.output}`,
1205
- { run, markerMissing: true },
1206
- );
1207
- }
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));
1208
1249
 
1209
- // Run disconfirmation script must exit NON-0 (finding survived the attempt to disprove).
1210
- let disconfirmationRun: PocRun | undefined;
1211
- if (disconfirmationPath) {
1212
- disconfirmationRun = runPoc(disconfirmationPath, runOptions("disconfirmation", caseTarget));
1213
- if (!disconfirmationRun.completed || !disconfirmationRun.outputComplete) {
1250
+ const evidenceRun = (r: PocRun, mode: "poc" | "control", target: string): PocEvidenceRun => {
1251
+ if (!r.completed || !r.outputComplete) {
1214
1252
  return fail(
1215
- `Disconfirmation script did NOT complete (spawn error, killed, or timeout no completion marker). ` +
1216
- `A crash is not a survived disproof: fix the disconfirmation script and retry.\n` +
1217
- `Output:\n${disconfirmationRun.output}`,
1218
- { run, disconfirmationRun, disconfirmationCrashed: true },
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 },
1219
1257
  );
1220
1258
  }
1221
- if (disconfirmationRun.exitCode === 0) {
1259
+ if (r.evidenceError) {
1222
1260
  return fail(
1223
- `Disconfirmation script exited 0 (finding was disproven). ` +
1224
- `Case remains investigating.\nOutput:\n${disconfirmationRun.output}`,
1225
- { run, disconfirmationRun },
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 },
1226
1265
  );
1227
1266
  }
1228
- }
1267
+ if (!r.evidence || !r.evidenceSha256 || !r.nonce) {
1268
+ return fail(`${mode} run produced no evidence. Case remains investigating.`, {
1269
+ run: r,
1270
+ });
1271
+ }
1272
+ return {
1273
+ 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,
1285
+ };
1286
+ };
1229
1287
 
1230
- // Control-target anti-cheat check: the same PoC pointed at a control that
1231
- // lacks the vuln must NOT print the marker. The control script is
1232
- // agent-written, but the marker-absence check is harness-side and
1233
- // deterministic the model cannot pass it by asserting success. The
1234
- // liveness-marker check closes the "control pointed at an unreachable
1235
- // host / exited early" hole: the control must prove it reached its target.
1236
- const controlRun = runPoc(controlPath, runOptions("control", controlTarget));
1237
- if (!controlRun.completed || !controlRun.outputComplete) {
1238
- return fail(
1239
- `CONTROL CHECK FAILED: the control-target script did NOT complete (spawn error, killed, or timeout). ` +
1240
- `A control run that never executed proves nothing about the marker — fix the control script and retry.\n` +
1241
- `Control output:\n${controlRun.output}`,
1242
- { run, controlRun, controlCrashed: true },
1243
- );
1244
- }
1245
- // Marker-absence + liveness checks run on the UNTRUNCATED control output.
1246
- const controlOut = controlRun.rawOutput ?? controlRun.output;
1247
- if (controlOut.includes(marker)) {
1248
- return fail(
1249
- `CONTROL CHECK FAILED: the verification marker "${marker}" appeared in the control-target run. ` +
1250
- `The PoC prints the marker without the vulnerable condition — a cheating PoC (unconditional marker) ` +
1251
- `or a broken check. Case remains investigating.\nControl output:\n${controlRun.output}`,
1252
- { run, controlRun, controlCheated: true },
1253
- );
1254
- }
1255
- if (!controlOut.includes(livenessMarker)) {
1256
- return fail(
1257
- `CONTROL CHECK FAILED: the control-target run completed but the control_liveness_marker "${livenessMarker}" ` +
1258
- `was NOT found in its output. The control must print the liveness marker only AFTER reaching/exercising ` +
1259
- `the control target — an unreachable host, wrong port, or early exit is not a valid control verdict. ` +
1260
- `Case remains investigating.\nControl output:\n${controlRun.output}`,
1261
- { run, controlRun, controlLivenessMissing: true },
1262
- );
1263
- }
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
+ );
1264
1300
 
1265
- // PocRun is structurally a PocVerification — pass the runs straight through.
1266
- const result = promoteFindingResult(
1301
+ const bundle: PendingConfirmation = {
1267
1302
  caseId,
1268
- run,
1269
- disconfirmationRun,
1270
- controlRun,
1271
- marker,
1272
- livenessMarker,
1273
- );
1274
- const record = result.record;
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
+ }
1321
+
1275
1322
  return {
1276
1323
  content: [
1277
1324
  {
1278
1325
  type: "text",
1279
- text: `PoC verified (exit ${run.exitCode}). Case promoted to confirmed:\n${formatCaseDetail(record)}`,
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.`,
1280
1333
  },
1281
1334
  ],
1282
- details: { record, run },
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,
1346
+ },
1347
+ },
1283
1348
  };
1284
1349
  },
1285
1350
 
@@ -1287,13 +1352,113 @@ export default function casefileExtension(pi: ExtensionAPI) {
1287
1352
  return callLine(theme, "PromoteFinding", (args.id as string) ?? "");
1288
1353
  },
1289
1354
 
1290
- renderResult(result, _options, theme) {
1291
- const details = result.details as { run?: { exitCode: number } } | undefined;
1292
- const success = details?.run?.exitCode === 0;
1293
- return new Text(renderCaseResult(result, theme, success ? "" : "✗ ", "✗ "), 0, 0);
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
+ );
1294
1367
  },
1295
1368
  });
1296
1369
 
1370
+ // ── Tool: ConfirmFinding (phase 2) ──
1371
+
1372
+ // Do not expose the commit capability in a worker process at all. The
1373
+ // execute-time check remains as defense in depth if process state changes
1374
+ // after registration or another integration forwards a stale tool handle.
1375
+ if (!startedAsSubagent)
1376
+ pi.registerTool({
1377
+ name: "ConfirmFinding",
1378
+ label: "Main-Agent Confirmation",
1379
+ description:
1380
+ "Phase 2 of confirmation, reserved for the main/coordinator agent: commit or refuse promotion after personally reviewing the machine bundle. On CONFIRMED, this tool performs a fresh harness-owned target/control replay; the verdict requires a target-only differential, a concrete re_execution_note and disconfirmation_attempt, a canary assessment, and the still-valid PromoteFinding bundle. The machine transcript is evidence, not the semantic vulnerability verdict. Worker/subagent processes are rejected. NOT_CONFIRMED records the review and keeps the case investigating.",
1381
+ promptSnippet: "Main agent: independently review and commit or refuse PoC confirmation",
1382
+ promptGuidelines: [
1383
+ "Run only in the main/coordinator agent after PromoteFinding returns. Do not dispatch a worker to decide or author this verdict.",
1384
+ "Personally inspect the PoC and preserved evidence and try a concrete disconfirmation before deciding. ConfirmFinding itself re-sends the immutable verify request against target and operator-approved control so phase 2 has a harness-owned transcript.",
1385
+ "CONFIRMED requires differential: 'target_only', re_execution_note, and disconfirmation_attempt (the main agent's failed disproof). A verdict missing any of these is rejected.",
1386
+ "Set canary_assessment='verified' when the immutable request declared a canary; otherwise set not_applicable and explain why a reflection canary is not meaningful for this exploit class.",
1387
+ "NOT_CONFIRMED is final for that attempt — the case stays investigating with the main agent's reasoning recorded. A fresh PromoteFinding run is required for another attempt.",
1388
+ "Never CaseUpdate status='confirmed' directly — it is rejected. Always use PromoteFinding + ConfirmFinding.",
1389
+ ],
1390
+ parameters: ConfirmSchema,
1391
+
1392
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
1393
+ if (isSubagentProcess()) {
1394
+ throw new Error(
1395
+ "ConfirmFinding is reserved for the main/coordinator agent. A worker or subagent may produce evidence but cannot confirm a PoC.",
1396
+ );
1397
+ }
1398
+ const caseId = params.id as string;
1399
+ const parsedVerdict = validateMainAgentVerdict(params.verdict);
1400
+ if (!parsedVerdict.ok) {
1401
+ throw new Error(`Invalid main-agent confirmation verdict: ${parsedVerdict.error}`);
1402
+ }
1403
+ let phase2Verification: MainAgentVerification | undefined;
1404
+ if (parsedVerdict.verdict.verdict === "CONFIRMED") {
1405
+ const current = getCaseById(caseId);
1406
+ if (!current) throw new Error(`Case not found: ${caseId}`);
1407
+ const bundle = current.pendingConfirmation;
1408
+ if (!bundle) {
1409
+ throw new Error("No pending confirmation on this case — run PromoteFinding first");
1410
+ }
1411
+ const controlAuthorizationError = controlTargetAuthorizationError(bundle.controlTarget);
1412
+ if (controlAuthorizationError) {
1413
+ throw new Error(`CONTROL AUTHORIZATION FAILED: ${controlAuthorizationError}`);
1414
+ }
1415
+ const replay = await replayDifferential(
1416
+ bundle.targetRuns[0].evidence,
1417
+ current.target ?? bundle.targetRuns[0].target,
1418
+ bundle.controlTarget,
1419
+ { allowPrivate: process.env.PI_POC_ALLOW_PRIVATE_REPLAY === "1" },
1420
+ );
1421
+ phase2Verification = {
1422
+ at: new Date().toISOString(),
1423
+ result: replay,
1424
+ };
1425
+ }
1426
+ const result = applyConfirmationResult(caseId, parsedVerdict.verdict, phase2Verification, {
1427
+ startedAsSubagent: isSubagentProcess(),
1428
+ });
1429
+ const record = result.record;
1430
+ const promoted = record.status === "confirmed";
1431
+ return {
1432
+ content: [
1433
+ {
1434
+ type: "text",
1435
+ text: promoted
1436
+ ? `Main agent CONFIRMED. Case promoted:
1437
+ ${formatCaseDetail(record)}`
1438
+ : `Main agent NOT_CONFIRMED — case stays investigating (attempt recorded):
1439
+ ${formatCaseDetail(record)}`,
1440
+ },
1441
+ ],
1442
+ details: { record, promoted, changed: result.changed },
1443
+ };
1444
+ },
1445
+
1446
+ renderCall(args, theme) {
1447
+ return callLine(theme, "ConfirmFinding", (args.id as string) ?? "");
1448
+ },
1449
+
1450
+ renderResult(result, _opts, theme) {
1451
+ const details = result.details as { promoted?: boolean } | undefined;
1452
+ return new Text(
1453
+ details?.promoted
1454
+ ? theme.fg("success", "✓ Promoted")
1455
+ : theme.fg("warning", "↷ Not confirmed"),
1456
+ 0,
1457
+ 0,
1458
+ );
1459
+ },
1460
+ });
1461
+
1297
1462
  // ── Tool: CaseGet ──
1298
1463
 
1299
1464
  pi.registerTool({
@@ -1536,7 +1701,9 @@ export default function casefileExtension(pi: ExtensionAPI) {
1536
1701
  parameters: ChainSuggestSchema,
1537
1702
 
1538
1703
  async execute(_id, params, _signal, _onUpdate, _ctx) {
1539
- const suggestions = suggestChains((params.case_id as string | undefined) ?? undefined);
1704
+ const suggestions = await suggestChainsAsync(
1705
+ (params.case_id as string | undefined) ?? undefined,
1706
+ );
1540
1707
  const lines: string[] = [
1541
1708
  suggestions.length
1542
1709
  ? `${suggestions.length} chain candidate(s):`
@@ -1582,7 +1749,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
1582
1749
  parameters: IdSchema,
1583
1750
 
1584
1751
  async execute(_id, params, _signal, _onUpdate, _ctx) {
1585
- const { path, contextPath, record } = writeCaseContext(params.id as string);
1752
+ const { path, contextPath, record } = await writeCaseContextAsync(params.id as string);
1586
1753
  return {
1587
1754
  content: [
1588
1755
  {
@@ -2076,7 +2243,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
2076
2243
  // the workflow + entire active-case ledger into every child dispatch is a
2077
2244
  // token multiplier (N subagents × workflow + growing case list per turn) —
2078
2245
  // workers get what they need via their task and tool guidelines.
2079
- if (process.env.PI_SUBAGENT_CHILD === "1") return;
2246
+ if (isSubagentProcess()) return;
2080
2247
 
2081
2248
  const includeWorkflow = !workflowInjected;
2082
2249