@xaccefy/pi-casefile 0.9.4 → 0.10.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,14 +1,13 @@
1
1
  /**
2
2
  * Casefile — offensive security case tracker for Pi.
3
3
  *
4
- * Tools: CaseAdd, CaseUpdate, PromoteFinding, ConfirmFinding, EvidenceAdd, CoverageAdd, CoverageReport, ChainSuggest, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseContext, PipelineSubmit, ScratchpadInit, ScratchpadResume, ScratchpadCheckpoint, ScratchpadWrite, ScratchpadRead, ScratchpadPhaseDone, ScratchpadClear
4
+ * Tools: CaseAdd, CaseUpdate, PromoteFinding, ConfirmFinding, EvidenceAdd, CoverageAdd, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseContext, ScratchpadWrite, ScratchpadRead, ScratchpadClear
5
5
  * Command: /casefile — interactive dashboard
6
- * Event: before_agent_start — injects cyber workflow once per session, refreshes the active case list per prompt
6
+ * Event: before_agent_start — injects the recon workflow once per session, refreshes the active case list per prompt
7
7
  */
8
8
 
9
9
  import { createHash } from "node:crypto";
10
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
11
- import { dirname, join } from "node:path";
10
+ import { readFileSync } from "node:fs";
12
11
  import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
13
12
  import { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
14
13
  import { type TSchema, Type } from "typebox";
@@ -16,8 +15,10 @@ import {
16
15
  CANARY_ASSESSMENT_VALUES,
17
16
  CONFIRM_DIFFERENTIAL_VALUES,
18
17
  CONFIRM_VERDICT_VALUES,
18
+ PANEL_VERDICT_VALUES,
19
19
  SEVERITY_MATCH_VALUES,
20
20
  validateMainAgentVerdict,
21
+ validatePanelVotes,
21
22
  } from "./evidence.ts";
22
23
  import {
23
24
  controlTargetAuthorizationError,
@@ -43,7 +44,6 @@ import {
43
44
  type CoverageItem,
44
45
  type CoverageScope,
45
46
  countCases,
46
- coverageSummary,
47
47
  EVIDENCE_ROLE_VALUES,
48
48
  type EvidenceItem,
49
49
  type EvidenceRole,
@@ -55,6 +55,7 @@ import {
55
55
  LINK_KIND_VALUES,
56
56
  linkCasesResult,
57
57
  type MainAgentVerification,
58
+ type OobVerification,
58
59
  type PendingConfirmation,
59
60
  type PocEvidenceRun,
60
61
  PRIORITY_VALUES,
@@ -68,29 +69,26 @@ import {
68
69
  storePendingConfirmation,
69
70
  unlinkCasesResult,
70
71
  updateCaseResult,
72
+ writeCaseContext,
71
73
  } from "./ledger.ts";
72
- import { suggestChainsAsync, writeCaseContextAsync } from "./ledger-worker.ts";
73
- import { pipeline_submit, SUBMIT_STAGES, type SubmitStage } from "./pipeline-submit.ts";
74
+ import {
75
+ type OobOracleConfig,
76
+ type ProvisionedCallback,
77
+ provisionCallback,
78
+ readOobOracleConfig,
79
+ verifyOobDifferential,
80
+ } from "./oob-oracle.ts";
74
81
  import { type PocRun, type PocRunOptions, runPoc } from "./poc-runner.ts";
75
82
  import {
76
83
  detectWorkspaceRoot,
77
84
  SCRATCHPAD_PHASES,
78
85
  type ScratchpadPhase,
79
- type ScratchpadResume,
80
- scratchpad_checkpoint,
81
86
  scratchpad_clear,
82
- scratchpad_init,
83
- scratchpad_phase_done,
84
87
  scratchpad_read,
85
- scratchpad_resume,
86
88
  scratchpad_write,
87
89
  setScratchpadRoot,
88
90
  } from "./scratchpad.ts";
89
- import {
90
- STATIC_CYBER_WORKFLOW,
91
- STATIC_CYBER_WORKFLOW_LITE,
92
- STATIC_CYBER_WORKFLOW_OMP,
93
- } from "./workflow.ts";
91
+ import { STATIC_RECON_WORKFLOW, STATIC_RECON_WORKFLOW_OMP } from "./workflow.ts";
94
92
 
95
93
  // ── Schemas ───────────────────────────────────────────────────────────
96
94
 
@@ -135,6 +133,27 @@ const CommonFields = {
135
133
  description: "Documented attempt to disprove the finding before confirmation",
136
134
  }),
137
135
  ),
136
+ invariant: Type.Optional(
137
+ Type.String({
138
+ description:
139
+ "The security invariant this finding violates — the rule broken (e.g. 'a user cannot read another user's orders'). Confirmation checks the invariant is actually violated, not just that a request returned 200.",
140
+ }),
141
+ ),
142
+ retry_policy: Type.Optional(
143
+ Type.Object(
144
+ {
145
+ max_attempts: Type.Number({
146
+ description: "Max attempts a phase may take for this case (integer 1–10)",
147
+ }),
148
+ fallback_models: Type.Optional(
149
+ Type.Array(Type.String(), {
150
+ description: "Fallback model identifiers to try when the primary model fails (≤8)",
151
+ }),
152
+ ),
153
+ },
154
+ { additionalProperties: false },
155
+ ),
156
+ ),
138
157
  };
139
158
 
140
159
  // ── Tool: CaseAdd ─────────────────────────────────────────────────────
@@ -181,6 +200,43 @@ const EvidenceAddSchema = Type.Object(
181
200
  { additionalProperties: false },
182
201
  );
183
202
 
203
+ // ── Tool: CoverageAdd ─────────────────────────────────────────────────
204
+
205
+ const CoverageAddSchema = Type.Object(
206
+ {
207
+ case_id: Type.String({
208
+ description: "Case ID (the finding case or target's main case) to record coverage under",
209
+ }),
210
+ asset: Type.String({
211
+ description:
212
+ "The asset tested — copy it verbatim from the case target where shown. For scope=wide use the deployment-wide identifier.",
213
+ }),
214
+ class: Type.String({
215
+ description:
216
+ "The attack class tested (e.g. sql-injection, xss, idor, ssti, ssrf, auth-bypass, ...).",
217
+ }),
218
+ // Provider-safe string enum (per the header rule): Type.Union(Type.Literal)
219
+ // serializes as anyOf/const, which some providers drop — scope would
220
+ // arrive undefined and every explicit 'wide' verdict would silently
221
+ // persist as 'local', under-reporting tested classes.
222
+ scope: Type.String({
223
+ enum: [...COVERAGE_SCOPE_VALUES],
224
+ description:
225
+ "'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.",
226
+ }),
227
+ note: Type.String({
228
+ description: "Short note: techniques tried · result · key gap.",
229
+ }),
230
+ evidence_item_id: Type.Optional(
231
+ Type.String({
232
+ description:
233
+ "Optional artifact-backed evidence item (EvidenceAdd, on this case) backing the tested verdict. Cells without one render as unbacked.",
234
+ }),
235
+ ),
236
+ },
237
+ { additionalProperties: false },
238
+ );
239
+
184
240
  // ── Tool: PromoteFinding (phase 1) / ConfirmFinding (phase 2) ──────────
185
241
  //
186
242
  // Confirmation is TWO-PHASE and main-agent-owned: PromoteFinding runs the PoC
@@ -226,9 +282,26 @@ const PromoteSchema = Type.Object(
226
282
  oob: Type.Optional(
227
283
  Type.Boolean({
228
284
  description:
229
- "Reserved for source-separated out-of-band verification. Currently fails closed because a loopback listener reachable by the PoC cannot prove target causation.",
285
+ "Blind/OOB confirmation via the operator-run oracle (PI_OOB_ORACLE_URL). The harness provisions per-run callback tokens, injects PI_POC_CALLBACK_DOMAIN into the runs, and polls the oracle itself: promotion requires target-token interactions, ZERO control-token interactions, attested source separation (PI_OOB_SOURCE_SEPARATED=1), and self-source/missing-src_ip interactions are rejected. Without an oracle this fails closed.",
230
286
  }),
231
287
  ),
288
+ panel_votes: Type.Optional(
289
+ Type.Array(
290
+ Type.Object(
291
+ {
292
+ verdict: Type.String({ enum: [...PANEL_VERDICT_VALUES] }),
293
+ rationale: Type.String({ description: "Why this voter reached its verdict" }),
294
+ model: Type.String({ description: "Which model voted" }),
295
+ at: Type.Optional(Type.String({ description: "Vote timestamp (ISO)" })),
296
+ },
297
+ { additionalProperties: false },
298
+ ),
299
+ {
300
+ description:
301
+ "Optional pre-gate panel votes (≤5). CONFIRMED later requires a 2/3 exploit quorum or an explicit override note on the verdict; votes never commit anything.",
302
+ },
303
+ ),
304
+ ),
232
305
  },
233
306
  { additionalProperties: false },
234
307
  );
@@ -280,6 +353,12 @@ const ConfirmSchema = Type.Object(
280
353
  "Why a causal reflection canary is not meaningful for this exploit class. Required when canary_assessment=not_applicable.",
281
354
  }),
282
355
  ),
356
+ panel_override_note: Type.Optional(
357
+ Type.String({
358
+ description:
359
+ "Why CONFIRMED proceeds without a 2/3 exploit panel quorum (panel skipped, unavailable, or documented disagreement). Required for CONFIRMED whenever quorum was not reached.",
360
+ }),
361
+ ),
283
362
  model: Type.Optional(
284
363
  Type.String({ description: "Which model judged (recorded for the accuracy ledger)" }),
285
364
  ),
@@ -365,7 +444,7 @@ const UnlinkSchema = Type.Object(
365
444
 
366
445
  // ── Tool: Scratchpad ─────────────────────────────────────────────────
367
446
  //
368
- // The scratchpad is the pipeline's crash-recoverable artifact store.
447
+ // The scratchpad is a crash-recoverable working-notes store for a run.
369
448
  // The casefile owns state transitions; the scratchpad owns artifacts
370
449
  // (recon maps, trace outputs, verification logs). Resume re-reads
371
450
  // artifacts; it does not re-run completed phases (idempotent).
@@ -376,7 +455,7 @@ const ScratchpadPhaseSchema = Type.String({
376
455
  "Pipeline phase: recon | hunt | trace | skeptic | validate | chain | patch | report (legacy gapfil is accepted for older runs)",
377
456
  });
378
457
 
379
- /** run_id-only schema, shared by Scratchpad Init / Resume / Clear. */
458
+ /** run_id-only schema, shared by Scratchpad tools. */
380
459
  const RunIdSchema = Type.Object(
381
460
  {
382
461
  run_id: Type.String({ description: "Pipeline run identifier" }),
@@ -384,20 +463,6 @@ const RunIdSchema = Type.Object(
384
463
  { additionalProperties: false },
385
464
  );
386
465
 
387
- const ScratchpadCheckpointSchema = Type.Object(
388
- {
389
- run_id: Type.String({ description: "Run identifier" }),
390
- phase: ScratchpadPhaseSchema,
391
- ids: Type.Optional(
392
- Type.Array(Type.String(), {
393
- description: "Key IDs produced by this phase (case IDs, finding IDs)",
394
- }),
395
- ),
396
- summary: Type.Optional(Type.String({ description: "One-line summary of phase completion" })),
397
- },
398
- { additionalProperties: false },
399
- );
400
-
401
466
  const ScratchpadWriteSchema = Type.Object(
402
467
  {
403
468
  run_id: Type.String({ description: "Run identifier" }),
@@ -419,14 +484,6 @@ const ScratchpadReadSchema = Type.Object(
419
484
  { additionalProperties: false },
420
485
  );
421
486
 
422
- const ScratchpadPhaseDoneSchema = Type.Object(
423
- {
424
- run_id: Type.String({ description: "Run identifier" }),
425
- phase: ScratchpadPhaseSchema,
426
- },
427
- { additionalProperties: false },
428
- );
429
-
430
487
  interface Theme {
431
488
  fg(color: string, text: string): string;
432
489
  bold(text: string): string;
@@ -605,10 +662,8 @@ class CasefileDashboard {
605
662
  }
606
663
 
607
664
  // ── Context injection ─────────────────────────────────────────────────
608
- // Injected once per user prompt via before_agent_start (not every tool turn).
609
- // Skills are opt-in; this keeps bounty discipline always present even with an empty ledger.
610
-
611
- // workflow.ts contains the full text
665
+ // The active case list is injected via before_agent_start (once per user
666
+ // prompt, not every tool turn) so open cases stay visible in context.
612
667
 
613
668
  function sanitizeContextText(v?: string, max = 160): string | undefined {
614
669
  // biome-ignore lint/suspicious/noControlCharactersInRegex: strip C0 controls from untrusted case text
@@ -694,101 +749,30 @@ function buildCaseListContext(records: CaseRecord[]): string {
694
749
 
695
750
  /**
696
751
  * Detect the extension host. OMP is a fork of Pi: both load the same
697
- * `pi`-manifest extensions, but subagent dispatch differs (pi-subagents'
752
+ * `pi`-manifest extensions, but recon subagent dispatch differs (pi-subagents'
698
753
  * `subagent({workflowScript})` vs OMP's native `task`). The entry script path
699
- * carries the host package: `@oh-my-pi/pi-coding-agent/dist/cli.js` under OMP,
700
- * `@earendil-works/pi-coding-agent` under Pi.
754
+ * carries the host package.
701
755
  */
702
- export function detectHost(): "omp" | "pi" {
756
+ function detectHost(): "omp" | "pi" {
703
757
  const argv = process.argv.join(" ");
704
758
  if (argv.includes("@oh-my-pi")) return "omp";
705
759
  return "pi";
706
760
  }
707
761
 
708
762
  /**
709
- * Builds the per-prompt injection. The cyber workflow is session-scope data
710
- * it never changes — so the caller passes includeWorkflow=true exactly once
711
- * per session; re-injecting it on every prompt is pure token cost. The active
712
- * case list DOES change as cases are added, so it is refreshed every prompt.
713
- *
714
- * mode selects the workflow text: "lite" injects the single-agent workflow
715
- * (no subagent dispatch), "swarm" gets the full subagent pipeline,
716
- * rendered for the host's dispatch convention (pi-subagents vs OMP task).
763
+ * Per-prompt injection. The recon workflow is session-scope guidance it never
764
+ * changes — so it is injected once (first prompt, includeWorkflow=true). The
765
+ * active case list DOES change as cases are added, so it refreshes every prompt.
766
+ * The workflow text is rendered for the host's dispatch convention.
717
767
  */
718
- function buildAgentInjection(
719
- active: CaseRecord[],
720
- includeWorkflow: boolean,
721
- mode: XpMode = "swarm",
722
- ): string {
768
+ function buildAgentInjection(active: CaseRecord[], includeWorkflow: boolean): string {
723
769
  const caseList = buildCaseListContext(active);
724
770
  if (!includeWorkflow) return caseList;
725
- const workflow =
726
- mode === "lite"
727
- ? STATIC_CYBER_WORKFLOW_LITE
728
- : detectHost() === "omp"
729
- ? STATIC_CYBER_WORKFLOW_OMP
730
- : STATIC_CYBER_WORKFLOW;
771
+ const workflow = detectHost() === "omp" ? STATIC_RECON_WORKFLOW_OMP : STATIC_RECON_WORKFLOW;
731
772
  // Workflow FIRST for prominence, then case list as reference data.
732
773
  return caseList ? `${workflow}\n\n${caseList}` : workflow;
733
774
  }
734
775
 
735
- // ── XP (offensive / exploit) mode toggle ─────────────────────────────
736
- // Casefile historically injected the cyber workflow into every prompt.
737
- // For normal dev work that is just noise, so XP mode defaults OFF. Enable
738
- // swarm for the bounded multi-agent variant, or lite for the single-agent
739
- // attacker discipline. Toggle with /xp (off <-> swarm), or set explicitly with
740
- // /xp on|lite|swarm|off. "on" means the default enabled SWARM mode; use "lite"
741
- // for no subagent dispatch.
742
- // Override per-session with PI_XP_MODE.
743
- // Pure helpers exported for unit tests.
744
-
745
- export const XP_MODE_ENV = "PI_XP_MODE";
746
- export type XpMode = "swarm" | "off" | "lite";
747
-
748
- export function getXpModeStatePath(): string {
749
- return join(dirname(getCasefilePath()), "xp-mode");
750
- }
751
-
752
- export function readXpMode(
753
- envValue: string | undefined = process.env[XP_MODE_ENV],
754
- statePath: string = getXpModeStatePath(),
755
- ): XpMode {
756
- const env = (envValue ?? "").trim().toLowerCase();
757
- if (env === "swarm") return "swarm";
758
- if (env === "on" || env === "1" || env === "true") return "swarm";
759
- if (env === "lite") return "lite";
760
- if (env === "off" || env === "0" || env === "false") return "off";
761
- try {
762
- if (existsSync(statePath)) {
763
- const v = readFileSync(statePath, "utf8").trim().toLowerCase();
764
- if (v === "swarm" || v === "on") return "swarm";
765
- if (v === "lite") return "lite";
766
- if (v === "off") return "off";
767
- }
768
- } catch {
769
- // ignore and fall through to default
770
- }
771
- return "off";
772
- }
773
-
774
- export function writeXpMode(state: XpMode, statePath: string = getXpModeStatePath()): void {
775
- try {
776
- writeFileSync(statePath, state, "utf8");
777
- } catch {
778
- // best-effort; env var can still override at runtime
779
- }
780
- }
781
-
782
- export function parseXpModeArg(args: string, current: XpMode): XpMode {
783
- const arg = (args ?? "").trim().toLowerCase();
784
- if (arg === "swarm") return "swarm";
785
- if (arg === "on") return "swarm";
786
- if (arg === "off") return "off";
787
- if (arg === "lite") return "lite";
788
- // Bare /xp is the low-ceremony path: toggle the default XP workflow on/off.
789
- return current === "off" ? "swarm" : "off";
790
- }
791
-
792
776
  // ── Main extension ────────────────────────────────────────────────────
793
777
 
794
778
  export default function casefileExtension(pi: ExtensionAPI) {
@@ -797,7 +781,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
797
781
  // already-loaded extension or reveal a tool that was omitted at startup.
798
782
  const startedAsSubagent = process.env.PI_SUBAGENT_CHILD === "1";
799
783
  const isSubagentProcess = () => startedAsSubagent || process.env.PI_SUBAGENT_CHILD === "1";
800
- // Pin the workspace root ONCE at extension load. Every scratchpad / pipeline
784
+ // Pin the workspace root ONCE at extension load. Every scratchpad
801
785
  // / PoC-path lookup otherwise re-walks the ambient cwd on each call — a
802
786
  // mid-session `cd` would split state across two .scratchpad roots and
803
787
  // misroot the hunt file-existence filter. The PoC runner reads PI_POC_ROOT
@@ -845,6 +829,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
845
829
  promptGuidelines: [
846
830
  "Use CaseAdd for a new security lead. New cases start as status='hypothesis' or 'investigating' — promote later with CaseUpdate.",
847
831
  "disproveIf is REQUIRED on CaseAdd: name the falsification conditions (what would disprove this hypothesis). A hypothesis that can't say what kills it isn't a hypothesis yet.",
832
+ "Declare the invariant: the security rule the finding would violate (e.g. 'a user cannot read another user's orders'). Confirmation checks the invariant is actually broken, not just that a request succeeded — a reproduction without a violated invariant is a mechanism, not a vulnerability.",
848
833
  "Check the injected case list or CaseList/CaseSearch first. Do not add a duplicate for the same title/scope.",
849
834
  "CaseAdd rejects exact and NEAR-duplicates (same target + overlapping title, e.g. parallel-subagent re-phrasings). A near-duplicate result → continue the existing case ID via CaseUpdate, don't create a new one.",
850
835
  "confirmed/reported only via their gates: proof in poc + PromoteFinding for confirmed; CaseContext + report for reported.",
@@ -853,7 +838,13 @@ export default function casefileExtension(pi: ExtensionAPI) {
853
838
  parameters: AddSchema,
854
839
 
855
840
  async execute(_id, params, _signal, _onUpdate, _ctx) {
856
- const result = addCaseResult(params as CaseInput);
841
+ const { retry_policy, ...rest } = params as Record<string, unknown>;
842
+ const result = addCaseResult({
843
+ ...(rest as CaseInput),
844
+ ...(retry_policy !== undefined
845
+ ? { retryPolicy: retry_policy as CaseInput["retryPolicy"] }
846
+ : {}),
847
+ });
857
848
  const record = result.record;
858
849
  return {
859
850
  content: [
@@ -905,8 +896,14 @@ export default function casefileExtension(pi: ExtensionAPI) {
905
896
  parameters: UpdateSchema,
906
897
 
907
898
  async execute(_id, params, _signal, _onUpdate, _ctx) {
908
- const { id, ...update } = params;
909
- const result = updateCaseResult(id as string, update as CaseUpdate);
899
+ const { id, retry_policy, ...rest } = params as Record<string, unknown>;
900
+ const update = {
901
+ ...(rest as CaseUpdate),
902
+ ...(retry_policy !== undefined
903
+ ? { retryPolicy: retry_policy as CaseUpdate["retryPolicy"] }
904
+ : {}),
905
+ };
906
+ const result = updateCaseResult(id as string, update);
910
907
  const record = result.record;
911
908
  return {
912
909
  content: [
@@ -971,7 +968,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
971
968
  content: [
972
969
  {
973
970
  type: "text",
974
- text: `Evidence item recorded:\n[${item.role}] ${item.summary}${item.artifactPath ? ` — ${item.artifactPath} sha256:${item.sha256?.slice(0, 12)}…` : ""}\n\n${formatCaseDetail(record)}`,
971
+ text: `Evidence item recorded:\n[${item.role}] ${item.summary}${item.artifactPath ? ` — ${item.artifactPath} sha256:${item.sha256?.slice(0, 12)}…` : ""}${item.containsSecret ? `\n⚠ Artifact contains suspected secrets (${item.secretFindings?.join(", ")}) — stored and hashed, but REDACT these values in any export or report.` : ""}\n\n${formatCaseDetail(record)}`,
975
972
  },
976
973
  ],
977
974
  details: { item, record },
@@ -1001,158 +998,6 @@ export default function casefileExtension(pi: ExtensionAPI) {
1001
998
  },
1002
999
  });
1003
1000
 
1004
- // ── Tool: CoverageAdd ──
1005
-
1006
- const CoverageAddSchema = Type.Object(
1007
- {
1008
- case_id: Type.String({
1009
- description: "Case ID (the pipeline-run or finding case) to record coverage under",
1010
- }),
1011
- asset: Type.String({
1012
- description:
1013
- "The asset tested — copy it verbatim from the case target where shown. For scope=wide use the deployment-wide identifier.",
1014
- }),
1015
- class: Type.String({
1016
- description:
1017
- "The attack class tested (e.g. sql-injection, xss, idor, ssti, ssrf, auth-bypass, ...).",
1018
- }),
1019
- // Provider-safe string enum (per the header rule): Type.Union(Type.Literal)
1020
- // serializes to anyOf/const, which some providers drop — scope would
1021
- // arrive undefined and every explicit 'wide' verdict would silently
1022
- // persist as 'local', under-reporting tested classes.
1023
- scope: Type.String({
1024
- enum: [...COVERAGE_SCOPE_VALUES],
1025
- description:
1026
- "'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.",
1027
- }),
1028
- note: Type.String({
1029
- description:
1030
- "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.",
1031
- }),
1032
- evidence_item_id: Type.Optional(
1033
- Type.String({
1034
- description:
1035
- "Evidence item id backing this tested verdict (must be an artifact-backed EvidenceAdd item on this case). Cells without a backing item render as 'unbacked' in CoverageReport — 'tested' claims must be machine-checkable, not prose-only.",
1036
- }),
1037
- ),
1038
- },
1039
- { additionalProperties: false },
1040
- );
1041
-
1042
- registerCaseTool({
1043
- name: "CoverageAdd",
1044
- label: "Record Coverage",
1045
- description:
1046
- "Record what you tested so it is not re-tested. Call AFTER finishing a CLASS of issue, for BOTH outcomes (found or clean — a clean result is just as important to record). scope=wide: the verdict is a property of the whole deployment, recorded once and applied to every later asset (do NOT re-test per asset); scope=local: specific to this one asset. The cell's existence marks that class tested for that asset.",
1047
- promptSnippet: "Record a tested attack class (coverage)",
1048
- promptGuidelines: [
1049
- "Record a coverage cell after you finish testing a class on an asset — found OR clean. Clean results are what make 'every class is COVERED' machine-checkable.",
1050
- "scope=wide for deployment-wide verdicts (record once, applies to every asset of the deployment — do NOT re-test it per asset). scope=local for single-asset verdicts.",
1051
- "The note must describe tests you ACTUALLY RAN, not assumptions. A verdict guessed without testing can hide a real issue.",
1052
- "Coverage cells live on the pipeline-run case (or the target's main case); CoverageReport shows the matrix.",
1053
- ],
1054
- parameters: CoverageAddSchema,
1055
-
1056
- async execute(_id, params, _signal, _onUpdate, _ctx) {
1057
- const item = recordCoverageResult(params.case_id as string, {
1058
- asset: params.asset as string,
1059
- class: params.class as string,
1060
- scope: (params.scope ?? "local") as CoverageScope,
1061
- note: params.note as string,
1062
- evidenceItemId: params.evidence_item_id as string | undefined,
1063
- });
1064
- const record = getCaseById(params.case_id as string);
1065
- if (!record) throw new Error(`Case not found after coverage insert: ${params.case_id}`);
1066
- return {
1067
- content: [
1068
- {
1069
- type: "text",
1070
- text: `Coverage recorded: [${item.scope}] ${item.asset} × ${item.class} — ${item.note}\n\n${formatCaseDetail(record)}`,
1071
- },
1072
- ],
1073
- details: { item, record },
1074
- };
1075
- },
1076
-
1077
- renderCall(args, theme) {
1078
- return callLine(
1079
- theme,
1080
- "CoverageAdd",
1081
- `${(args.asset as string) ?? ""} [${(args.class as string) ?? ""}]`,
1082
- );
1083
- },
1084
-
1085
- renderResult(result, _opts, theme) {
1086
- const details = result.details as { item?: CoverageItem } | undefined;
1087
- if (!details?.item) {
1088
- return new Text(theme.fg("error", "✗ CoverageAdd failed"), 0, 0);
1089
- }
1090
- return new Text(
1091
- theme.fg("success", "✓ ") +
1092
- theme.fg("dim", `[${details.item.scope}] `) +
1093
- truncateToWidth(`${details.item.asset} × ${details.item.class}`, 50),
1094
- 0,
1095
- 0,
1096
- );
1097
- },
1098
- });
1099
-
1100
- // ── Tool: CoverageReport ──
1101
-
1102
- const CoverageReportSchema = Type.Object(
1103
- {
1104
- case_id: Type.String({ description: "Case ID to render the coverage matrix for" }),
1105
- },
1106
- { additionalProperties: false },
1107
- );
1108
-
1109
- registerCaseTool({
1110
- name: "CoverageReport",
1111
- label: "Coverage Matrix",
1112
- description:
1113
- "Render the machine-checkable coverage matrix for a case: which (asset × attack-class) cells are tested, with wide-verdict propagation. Run before deciding HUNT coverage is done — the plateau stop (zero new classes testable) must be visible in the matrix, not asserted in prose.",
1114
- promptSnippet: "Show which attack classes were tested where",
1115
- promptGuidelines: [
1116
- "Run CoverageReport before claiming 'every class is COVERED/SKIPPED/NOT_FOUND' — the claim must match the matrix.",
1117
- "A class with a wide clean verdict covers every asset — do NOT re-test it per asset.",
1118
- "Classes tested with no cell recorded are invisible: record coverage as you finish each class (CoverageAdd).",
1119
- ],
1120
- parameters: CoverageReportSchema,
1121
-
1122
- async execute(_id, params, _signal, _onUpdate, _ctx) {
1123
- const summary = coverageSummary(params.case_id as string);
1124
- const lines: string[] = [`Coverage matrix for ${params.case_id}:`];
1125
- for (const asset of summary.assets) {
1126
- lines.push(`\n## ${asset}`);
1127
- for (const cell of summary.byAsset[asset] ?? []) {
1128
- lines.push(
1129
- `- [${cell.scope}] ${cell.class} — ${cell.note}${cell.testedBy ? ` (by ${cell.testedBy})` : ""}` +
1130
- (cell.evidenceItemId
1131
- ? ""
1132
- : " ⚠ unbacked (link an artifact-backed evidence item via CoverageAdd evidence_item_id)"),
1133
- );
1134
- }
1135
- }
1136
- if (summary.items.length === 0) {
1137
- lines.push("\n(no coverage recorded yet — run CoverageAdd as each class is tested)");
1138
- }
1139
- return {
1140
- content: [{ type: "text", text: lines.join("\n") }],
1141
- details: { summary },
1142
- };
1143
- },
1144
-
1145
- renderCall(args, theme) {
1146
- return callLine(theme, "CoverageReport", (args.case_id as string) ?? "");
1147
- },
1148
-
1149
- renderResult(result, _opts, theme) {
1150
- const details = result.details as { summary?: { items?: CoverageItem[] } } | undefined;
1151
- const n = details?.summary?.items?.length ?? 0;
1152
- return new Text(theme.fg("success", `✓ ${n} coverage cell(s)`), 0, 0);
1153
- },
1154
- });
1155
-
1156
1001
  // ── Tool: PromoteFinding (phase 1) ──
1157
1002
 
1158
1003
  if (!startedAsSubagent)
@@ -1169,8 +1014,9 @@ export default function casefileExtension(pi: ExtensionAPI) {
1169
1014
  "The PoC MUST write evidence.json to $PI_POC_EVIDENCE_DIR: { nonce (echo $PI_POC_NONCE), claim, verify: { method, url, expect: { status?, body_contains/body_regex } }, observations }. A non-empty body predicate is mandatory; status-only evidence is rejected. verify.url must belong to the case target.",
1170
1015
  "For reflection-capable requests, place {{PI_POC_CANARY}} exactly once in verify.url/body/header values and declare verify.canary={mode:'reflection',placeholder:'{{PI_POC_CANARY}}'}. The harness substitutes an unpredictable value only after the PoC exits and requires target-only reflection; the raw token is not persisted.",
1171
1016
  "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.",
1017
+ "oob=true unlocks blind/OOB classes (SSRF, blind XSS, XXE): the harness provisions per-run callback tokens via the operator's oracle (PI_OOB_ORACLE_URL), injects PI_POC_CALLBACK_DOMAIN into the runs, and polls the oracle itself — target-token interactions with ZERO control-token interactions are required. Promotion additionally requires attested source separation (PI_OOB_SOURCE_SEPARATED=1); without it the verification stays diagnostic.",
1172
1018
  "local:true requires PI_POC_ALLOW_NETWORK=1. Private/internal harness replay additionally requires PI_POC_ALLOW_PRIVATE_REPLAY=1. Neither silently falls back to a model verdict.",
1173
- "Blind/OOB classes are not promotable through the built-in loopback listener because the PoC can self-call it; obtain a direct-response or state oracle, otherwise keep the case investigating.",
1019
+ "Blind/OOB classes fail closed unless the operator configured an OOB oracle; self-interactions (PI_OOB_SELF_IPS) are rejected and never counted as target hits.",
1174
1020
  "After the bundle is recorded, stay in the main agent: inspect the script/evidence, attempt disconfirmation, and call ConfirmFinding itself; that call performs a fresh harness-owned target/control replay. Never delegate validation/confirmation and never CaseUpdate status='confirmed' directly.",
1175
1021
  ],
1176
1022
  parameters: PromoteSchema,
@@ -1188,7 +1034,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
1188
1034
  const caseId = params.id as string;
1189
1035
  const current = assertPromotable(caseId);
1190
1036
 
1191
- const fail = (text: string, _extra?: Record<string, unknown>): never => {
1037
+ const fail = (text: string): never => {
1192
1038
  throw new Error(text);
1193
1039
  };
1194
1040
 
@@ -1199,40 +1045,75 @@ export default function casefileExtension(pi: ExtensionAPI) {
1199
1045
  (params.mode as string | undefined) === "intra_target" ? "intra_target" : "inter_host";
1200
1046
  const isIntra = mode === "intra_target";
1201
1047
  if (!pocPath) {
1202
- return fail("poc_path is REQUIRED: absolute path to the PoC script run by the harness.", {
1203
- missingPocPath: true,
1204
- });
1048
+ return fail("poc_path is REQUIRED: absolute path to the PoC script run by the harness.");
1049
+ }
1050
+ const caseTarget = current.target ?? "";
1051
+ // Panel votes (advisory pre-gate): validated here so a malformed panel
1052
+ // is rejected before any sandboxed run is paid for.
1053
+ let panelVotes: PendingConfirmation["panelVotes"];
1054
+ if (params.panel_votes !== undefined) {
1055
+ const parsedVotes = validatePanelVotes(params.panel_votes);
1056
+ if (!parsedVotes.ok) {
1057
+ return fail(`Invalid panel_votes: ${parsedVotes.error}`);
1058
+ }
1059
+ panelVotes = parsedVotes.votes;
1205
1060
  }
1206
- // Control-target preconditions apply only to the inter-host differential.
1207
- // Intra-target proves target-dependence with a same-host baseline request
1208
- // carried in the evidence, so it needs no control target or control script.
1209
- if (!isIntra) {
1061
+ // ── OOB callback (Tier 1, opt-in for blind classes) ──
1062
+ // The operator-run oracle owns the evidence channel; the harness owns
1063
+ // the secret (per-run token, provisioned before the runs and injected
1064
+ // as env — the value does not exist when the script was written).
1065
+ // Without an oracle this stays fail-closed.
1066
+ const oobRequested = params.oob === true;
1067
+ // Intra-target + OOB is rejected up front: intra_target's
1068
+ // discriminating variable is identity/parameter on the SAME host;
1069
+ // mixing it with a callback differential would make precedence
1070
+ // ambiguous. OOB is for inter-host/blind classes.
1071
+ if (isIntra && oobRequested) {
1072
+ return fail(
1073
+ "mode:'intra_target' cannot be combined with oob:true — intra-target proof uses a same-host baseline request, not a callback channel. Use one or the other.",
1074
+ );
1075
+ }
1076
+ let oobConfig: OobOracleConfig | undefined;
1077
+ let targetCallback: ProvisionedCallback | undefined;
1078
+ let controlCallback: ProvisionedCallback | undefined;
1079
+ if (oobRequested) {
1080
+ const oracle = readOobOracleConfig();
1081
+ if (!oracle.config) {
1082
+ return fail(`OOB CONFIRMATION UNAVAILABLE: ${oracle.error}`);
1083
+ }
1084
+ oobConfig = oracle.config;
1085
+ // Provision both identities concurrently — each is an oracle round trip.
1086
+ [targetCallback, controlCallback] = await Promise.all([
1087
+ provisionCallback(oobConfig),
1088
+ provisionCallback(oobConfig),
1089
+ ]);
1090
+ }
1091
+ // OOB-only bundles (blind classes, no operator-approved control host)
1092
+ // prove target-dependence via the token differential instead.
1093
+ const oobOnly = oobRequested && !controlTarget;
1094
+ if (!isIntra && !oobOnly) {
1210
1095
  if (!controlTarget) {
1211
1096
  return fail(
1212
- "control_target is REQUIRED for inter-host mode: a distinct baseline target that lacks the vulnerability. For access-control/logic bugs use mode='intra_target' with an evidence baseline instead.",
1213
- { missingControlTarget: true },
1097
+ "control_target is REQUIRED for inter-host mode: a distinct baseline target that lacks the vulnerability. For access-control/logic bugs use mode='intra_target' with an evidence baseline instead; for blind/OOB classes pass oob=true (with or without a control target).",
1214
1098
  );
1215
1099
  }
1216
1100
  if (controlTarget === current.target) {
1217
1101
  return fail(
1218
1102
  "control_target must differ from the case target; a control run against the vulnerable target proves nothing.",
1219
- { controlTargetEqualsCaseTarget: true },
1220
1103
  );
1221
1104
  }
1222
1105
  }
1223
1106
  if (params.local === true && process.env.PI_POC_ALLOW_NETWORK !== "1") {
1224
1107
  return fail(
1225
1108
  "Networked PoC execution is operator-gated. Set PI_POC_ALLOW_NETWORK=1 to authorize the host-network sandbox for this session.",
1226
- { networkNotAuthorized: true },
1227
1109
  );
1228
1110
  }
1229
- if (!isIntra) {
1111
+ if (!isIntra && !oobOnly) {
1230
1112
  const controlAuthorization = controlTargetAuthorizationError(controlTarget);
1231
1113
  if (controlAuthorization) {
1232
1114
  return fail(
1233
1115
  `CONTROL AUTHORIZATION FAILED: ${controlAuthorization}. ` +
1234
1116
  "The operator must set PI_POC_CONTROL_TARGETS to the exact approved control host/origin before this control can anchor confirmation.",
1235
- { controlNotAuthorized: true },
1236
1117
  );
1237
1118
  }
1238
1119
  }
@@ -1243,46 +1124,40 @@ export default function casefileExtension(pi: ExtensionAPI) {
1243
1124
  try {
1244
1125
  pocHash = createHash("sha256").update(readFileSync(pocPath)).digest("hex");
1245
1126
  } catch (e) {
1246
- return fail(`Cannot read PoC script: ${(e as Error).message}`, {
1247
- sameFileCheckFailed: true,
1248
- });
1127
+ return fail(`Cannot read PoC script: ${(e as Error).message}`);
1249
1128
  }
1250
- if (!isIntra) {
1129
+ if (!isIntra && !oobOnly) {
1251
1130
  let controlHash: string | undefined;
1252
1131
  try {
1253
1132
  controlHash = createHash("sha256").update(readFileSync(controlPath)).digest("hex");
1254
1133
  } catch (e) {
1255
1134
  return fail(
1256
1135
  `Cannot read control script for the same-file check: ${(e as Error).message}`,
1257
- { sameFileCheckFailed: true },
1258
1136
  );
1259
1137
  }
1260
1138
  if (pocHash !== controlHash) {
1261
1139
  return fail(
1262
1140
  "CONTROL CHECK FAILED: control_path must be the SAME script as poc_path (sha256 mismatch). Case remains investigating.",
1263
- { controlHashMismatch: true },
1264
1141
  );
1265
1142
  }
1266
1143
  }
1267
1144
 
1268
- // ── OOB callback (Tier 1, opt-in for blind classes) ──
1269
- const oobRequested = params.oob === true;
1270
- if (oobRequested) {
1271
- return fail(
1272
- "OOB confirmation is fail-closed: the built-in loopback listener is reachable by the PoC and cannot prove the target caused a callback. A source-separated, operator-owned callback service is required before blind findings can be promoted.",
1273
- { oobSourceSeparationRequired: true },
1274
- );
1275
- }
1145
+ // ── OOB callback tokens were provisioned above, before the runs ──
1276
1146
  const runOptions = (pocMode: string, target: string): PocRunOptions => ({
1277
1147
  network: params.local === true ? "host" : "none",
1278
1148
  local: params.local === true,
1279
1149
  env: {
1280
1150
  PI_POC_MODE: pocMode,
1281
1151
  PI_POC_TARGET: target,
1152
+ ...(oobRequested && targetCallback && controlCallback
1153
+ ? {
1154
+ PI_POC_CALLBACK_DOMAIN:
1155
+ pocMode === "control" ? controlCallback.domain : targetCallback.domain,
1156
+ }
1157
+ : {}),
1282
1158
  },
1283
1159
  });
1284
1160
 
1285
- const caseTarget = current.target ?? "";
1286
1161
  // Determinism: TWO target runs. Exit 0 is run integrity only; nonce-bound
1287
1162
  // body evidence plus the harness-owned differential replay (inter-host
1288
1163
  // control, or intra-target same-host baseline) form the machine gate.
@@ -1299,7 +1174,6 @@ export default function casefileExtension(pi: ExtensionAPI) {
1299
1174
  `${mode} run did not complete or output capture was incomplete` +
1300
1175
  (r.infraError ? ` (infra: ${r.output.trim()})` : "") +
1301
1176
  ". A crash is not evidence. Case remains investigating.",
1302
- { run: r, pocCrashed: true },
1303
1177
  );
1304
1178
  }
1305
1179
  if (r.evidenceError) {
@@ -1307,13 +1181,10 @@ export default function casefileExtension(pi: ExtensionAPI) {
1307
1181
  `EVIDENCE CONTRACT FAILED (${mode} run): ${r.evidenceError}. ` +
1308
1182
  "The PoC must write evidence.json to $PI_POC_EVIDENCE_DIR — { nonce (echo $PI_POC_NONCE), claim, verify: { method, url, expect: { status?, body_contains / body_regex } }, observations }; a response-body assertion is mandatory — " +
1309
1183
  "the file is bound to this run and validated by the harness. Case remains investigating.",
1310
- { run: r, evidenceError: r.evidenceError },
1311
1184
  );
1312
1185
  }
1313
1186
  if (!r.evidence || !r.evidenceSha256 || !r.nonce) {
1314
- return fail(`${mode} run produced no evidence. Case remains investigating.`, {
1315
- run: r,
1316
- });
1187
+ return fail(`${mode} run produced no evidence. Case remains investigating.`);
1317
1188
  }
1318
1189
  return {
1319
1190
  mode,
@@ -1336,8 +1207,17 @@ export default function casefileExtension(pi: ExtensionAPI) {
1336
1207
  evidenceRun(run2, "poc", caseTarget),
1337
1208
  ];
1338
1209
 
1210
+ // Reflection canary + OOB is rejected after run 1 (the canary is
1211
+ // declared inside evidence.json): the canary path requires a harness
1212
+ // response transcript, which OOB-only bundles never produce — the
1213
+ // per-run callback token IS the causality signal there.
1214
+ if (oobRequested && targetRuns.some((r) => r.evidence.verify.canary !== undefined)) {
1215
+ return fail(
1216
+ "verify.canary cannot be combined with oob:true — the per-run callback token already provides a harness-owned causality signal. Remove the {{PI_POC_CANARY}} placeholder and verify.canary from evidence.json, then re-promote.",
1217
+ );
1218
+ }
1339
1219
  const allowPrivateReplay = process.env.PI_POC_ALLOW_PRIVATE_REPLAY === "1";
1340
- let harnessVerified: HarnessVerifyResult;
1220
+ let harnessVerified: HarnessVerifyResult | undefined;
1341
1221
  let controlRun: PocEvidenceRun | undefined;
1342
1222
  if (isIntra) {
1343
1223
  // Intra-target: prove target-dependence with the evidence's same-host
@@ -1347,19 +1227,17 @@ export default function casefileExtension(pi: ExtensionAPI) {
1347
1227
  if (ev0.verify.mode !== "intra_target") {
1348
1228
  return fail(
1349
1229
  "INTRA-TARGET FAILED: the PoC's evidence.json must set verify.mode='intra_target' when promoting in intra-target mode.",
1350
- { intraModeMismatch: true },
1351
1230
  );
1352
1231
  }
1353
1232
  if (!ev0.baseline) {
1354
1233
  return fail(
1355
1234
  "INTRA-TARGET FAILED: evidence.json must include a baseline — a legitimate same-host request whose response must NOT satisfy the attack predicate.",
1356
- { intraBaselineMissing: true },
1357
1235
  );
1358
1236
  }
1359
1237
  harnessVerified = await replayIntraTarget(ev0, caseTarget, {
1360
1238
  allowPrivate: allowPrivateReplay,
1361
1239
  });
1362
- } else {
1240
+ } else if (!oobOnly) {
1363
1241
  // Inter-host (Tier 2): the harness executes the SAME request template
1364
1242
  // against target and operator-approved control, applying the target's
1365
1243
  // predicates to both. DNS is pinned at connect time.
@@ -1375,6 +1253,20 @@ export default function casefileExtension(pi: ExtensionAPI) {
1375
1253
  { allowPrivate: allowPrivateReplay },
1376
1254
  );
1377
1255
  }
1256
+ // OOB differential: poll the oracle for both run tokens. The ledger's
1257
+ // assertMachineConfirmation consumes this BEFORE the response-diff
1258
+ // requirement — blind classes pass via this path when the oracle saw
1259
+ // the target token and NOT the control token under attested source
1260
+ // separation.
1261
+ let callbackVerified: OobVerification | undefined;
1262
+ if (oobConfig && targetCallback && controlCallback) {
1263
+ callbackVerified = (
1264
+ await verifyOobDifferential({
1265
+ targetToken: targetCallback.token,
1266
+ controlToken: controlCallback.token,
1267
+ })
1268
+ ).verification;
1269
+ }
1378
1270
  const bundle: PendingConfirmation = {
1379
1271
  caseId,
1380
1272
  ranAt: new Date().toISOString(),
@@ -1383,16 +1275,24 @@ export default function casefileExtension(pi: ExtensionAPI) {
1383
1275
  mode,
1384
1276
  targetRuns,
1385
1277
  harnessVerified,
1386
- ...(isIntra ? {} : { controlPath, controlTarget, controlRun }),
1278
+ ...(panelVotes ? { panelVotes } : {}),
1279
+ ...(callbackVerified && targetCallback && controlCallback
1280
+ ? {
1281
+ callbackVerified,
1282
+ oobTokens: {
1283
+ targetToken: targetCallback.token,
1284
+ controlToken: controlCallback.token,
1285
+ },
1286
+ }
1287
+ : {}),
1288
+ ...(!(isIntra || oobOnly) ? { controlPath, controlTarget, controlRun } : {}),
1387
1289
  };
1388
1290
 
1389
1291
  let record: CaseRecord;
1390
1292
  try {
1391
1293
  record = storePendingConfirmation(caseId, bundle);
1392
1294
  } catch (e) {
1393
- return fail(`Pending confirmation rejected: ${(e as Error).message}`, {
1394
- storeRejected: true,
1395
- });
1295
+ return fail(`Pending confirmation rejected: ${(e as Error).message}`);
1396
1296
  }
1397
1297
 
1398
1298
  return {
@@ -1404,7 +1304,11 @@ export default function casefileExtension(pi: ExtensionAPI) {
1404
1304
  `Mode: ${mode}. ${isIntra ? "Target runs: 2, same-host baseline differential" : "Target runs: 2, Control run: 1"} — all with validated nonce-bound evidence.json.\n` +
1405
1305
  `Evidence sha256: ${targetRuns[0].evidenceSha256}\n` +
1406
1306
  `PoC script sha256 (at run time): ${pocHash}\n` +
1407
- `Harness verify replay: ${harnessVerified.attempted ? (harnessVerified.pass ? `PASS (status ${harnessVerified.status})` : `FAILED — ${harnessVerified.note}`) : harnessVerified.note}\n` +
1307
+ `Harness verify replay: ${harnessVerified?.attempted ? (harnessVerified.pass ? `PASS (status ${harnessVerified.status})` : `FAILED — ${harnessVerified.note}`) : (harnessVerified?.note ?? "not run")}
1308
+ ` +
1309
+ (callbackVerified
1310
+ ? `OOB oracle: target-token hits ${callbackVerified.targetHits}, control-token hits ${callbackVerified.controlHits}, source-separated: ${String(callbackVerified.sourceSeparated)} — ${callbackVerified.note}\n`
1311
+ : "") +
1408
1312
  `\nMAIN-AGENT REVIEW REQUIRED (do not delegate): inspect case ${caseId}, PoC ${pocPath}, ${isIntra ? "same-host baseline" : `control ${controlTarget}`}, evidence ${targetRuns[0].evidenceSha256}, and PoC hash ${pocHash}. Hunt for a trivial predicate or fabricated differential and perform a concrete disconfirmation attempt, then call ConfirmFinding yourself. A CONFIRMED call performs and stores a fresh harness-owned ${isIntra ? "attack/baseline" : "target/control"} replay; NOT_CONFIRMED keeps the case investigating.`,
1409
1313
  },
1410
1314
  ],
@@ -1454,15 +1358,18 @@ export default function casefileExtension(pi: ExtensionAPI) {
1454
1358
  name: "ConfirmFinding",
1455
1359
  label: "Main-Agent Confirmation",
1456
1360
  description:
1457
- "Phase 2 of confirmation, reserved for the main/coordinator agent: commit or refuse promotion after personally reviewing the machine bundle. On CONFIRMED, this tool performs a fresh harness-owned target/control replay; the verdict requires a target-only differential, a concrete re_execution_note and disconfirmation_attempt, a canary assessment, and the still-valid PromoteFinding bundle. The machine transcript is evidence, not the semantic vulnerability verdict. Worker/subagent processes are rejected. NOT_CONFIRMED records the review and keeps the case investigating.",
1458
- promptSnippet: "Main agent: independently review and commit or refuse PoC confirmation",
1361
+ "Phase 2 of confirmation, reserved for the main/coordinator agent: commit or refuse promotion after independently re-testing the finding. On CONFIRMED, this tool performs a fresh harness-owned target/control replay; the verdict requires a target-only differential, a concrete re_execution_note and disconfirmation_attempt, a canary assessment, and the still-valid PromoteFinding bundle. The machine transcript is evidence, not the semantic vulnerability verdict. Worker/subagent processes are rejected. Three verdicts: CONFIRMED (you reproduced real impact), NOT_CONFIRMED (you POSITIVELY disproved it), INCONCLUSIVE (you could neither reproduce nor disprove — the case is preserved for manual review, never dropped).",
1362
+ promptSnippet: "Main agent: independently re-test, then commit or refuse PoC confirmation",
1459
1363
  promptGuidelines: [
1460
1364
  "Run only in the main/coordinator agent after PromoteFinding returns. Do not dispatch a worker to decide or author this verdict.",
1461
- "Personally inspect the PoC and preserved evidence and try a concrete disconfirmation before deciding. ConfirmFinding itself re-sends the immutable verify request against target and operator-approved control so phase 2 has a harness-owned transcript.",
1462
- "CONFIRMED requires differential: 'target_only', re_execution_note, and disconfirmation_attempt (the main agent's failed disproof). A verdict missing any of these is rejected.",
1463
- "Set canary_assessment='verified' when the immutable request declared a canary; otherwise set not_applicable and explain why a reflection canary is not meaningful for this exploit class.",
1464
- "NOT_CONFIRMED is final for that attemptthe case stays investigating with the main agent's reasoning recorded. A fresh PromoteFinding run is required for another attempt.",
1465
- "Never CaseUpdate status='confirmed' directly it is rejected. Always use PromoteFinding + ConfirmFinding.",
1365
+ "Verify with DISBELIEF: assume the finding is a false positive until your OWN re-test proves otherwise. Reproduce the exact observable yourself from the primary evidence (not the hunter's narrative), with a negative/baseline control a difference you cannot tie to the control is not proof. ConfirmFinding itself re-sends the immutable verify request against target and operator-approved control so phase 2 has a harness-owned transcript.",
1366
+ "Provenance: the proof must exercise THIS finding's own mechanism. Evidence obtained through a DIFFERENT bug (e.g. 'SQLi' proven by dumping the DB via an RCE) does not confirm it that is INCONCLUSIVE at best.",
1367
+ "Kill the cheapest benign explanation: is this the technology's intended behavior? Did the attacker supply the 'secret' themselves (circular)? Is the claimed C/I/A impact actually demonstrated?",
1368
+ "Want a second pair of eyes? Dispatch a read-only skeptic subagent to re-test it CANNOT confirm (only the main agent commits). You review its verdict and commit it here.",
1369
+ "CONFIRMED requires differential: 'target_only', re_execution_note, and disconfirmation_attempt (your failed disproof). A verdict missing any of these is rejected. Set canary_assessment='verified' when the immutable request declared a canary; otherwise not_applicable with a reason.",
1370
+ "NOT_CONFIRMED means you POSITIVELY disproved it (by-design, circular, mislabeled, no impact). Never mark NOT_CONFIRMED merely because you could not reproduce it.",
1371
+ "INCONCLUSIVE when you could neither reproduce nor disprove (needs auth, a second account, specific state, timing, or a blind/stored trigger you cannot observe). The case stays investigating and is preserved for manual review — dropping a real finding is worse than keeping an unproven one.",
1372
+ "Every verdict consumes the attempt: a fresh PromoteFinding run is required to try again. Never CaseUpdate status='confirmed' directly — always PromoteFinding + ConfirmFinding.",
1466
1373
  ],
1467
1374
  parameters: ConfirmSchema,
1468
1375
 
@@ -1493,6 +1400,46 @@ export default function casefileExtension(pi: ExtensionAPI) {
1493
1400
  replay = await replayIntraTarget(bundle.targetRuns[0].evidence, caseTargetForReplay, {
1494
1401
  allowPrivate,
1495
1402
  });
1403
+ } else if (bundle.callbackVerified?.attempted && bundle.oobTokens) {
1404
+ // OOB differential: fresh harness-owned re-poll of BOTH run tokens.
1405
+ // Re-polling at confirm time catches interactions that landed after
1406
+ // phase 1 (e.g. a delayed control-token hit) — the verdict is bound
1407
+ // to this fresh observation, not the stored one.
1408
+ const { verification } = await verifyOobDifferential({
1409
+ targetToken: bundle.oobTokens.targetToken,
1410
+ controlToken: bundle.oobTokens.controlToken,
1411
+ });
1412
+ const oobPass =
1413
+ verification.targetHits > 0 &&
1414
+ verification.controlHits === 0 &&
1415
+ verification.sourceSeparated === true;
1416
+ replay = {
1417
+ attempted: true,
1418
+ pass: oobPass,
1419
+ target: {
1420
+ attempted: true,
1421
+ matched: verification.targetHits > 0,
1422
+ url: bundle.targetRuns[0].evidence.verify.url,
1423
+ note: verification.note,
1424
+ },
1425
+ control: {
1426
+ attempted: true,
1427
+ matched: verification.controlHits > 0,
1428
+ url: bundle.targetRuns[0].evidence.verify.url,
1429
+ note: `${verification.controlHits} control-token interaction(s)`,
1430
+ },
1431
+ differential:
1432
+ verification.targetHits > 0
1433
+ ? verification.controlHits === 0
1434
+ ? "target_only"
1435
+ : "both"
1436
+ : "neither",
1437
+ note: `harness OOB re-poll: ${verification.note}`,
1438
+ };
1439
+ } else if (bundle.callbackVerified?.attempted) {
1440
+ throw new Error(
1441
+ "OOB bundle lacks its provisioned tokens (pre-token-storage ledger) — re-run PromoteFinding for a fresh bundle",
1442
+ );
1496
1443
  } else {
1497
1444
  if (!bundle.controlTarget) {
1498
1445
  throw new Error("inter-host confirmation requires a control target");
@@ -1525,7 +1472,10 @@ export default function casefileExtension(pi: ExtensionAPI) {
1525
1472
  text: promoted
1526
1473
  ? `Main agent CONFIRMED. Case promoted:
1527
1474
  ${formatCaseDetail(record)}`
1528
- : `Main agent NOT_CONFIRMED — case stays investigating (attempt recorded):
1475
+ : parsedVerdict.verdict.verdict === "INCONCLUSIVE"
1476
+ ? `Main agent INCONCLUSIVE — case stays investigating, preserved for manual review (not disproved):
1477
+ ${formatCaseDetail(record)}`
1478
+ : `Main agent NOT_CONFIRMED — case stays investigating (attempt recorded):
1529
1479
  ${formatCaseDetail(record)}`,
1530
1480
  },
1531
1481
  ],
@@ -1549,6 +1499,65 @@ ${formatCaseDetail(record)}`,
1549
1499
  },
1550
1500
  });
1551
1501
 
1502
+ // ── Tool: CoverageAdd ──
1503
+
1504
+ registerCaseTool({
1505
+ name: "CoverageAdd",
1506
+ label: "Record Coverage Cell",
1507
+ description:
1508
+ "Record a tested (asset × attack-class) coverage cell on a case — for BOTH outcomes (found or clean). Clean results make 'every class is covered' machine-checkable. scope='wide' records a deployment-wide verdict ONCE (do not re-test per asset); 'local' is asset-specific. Cells can carry an artifact-backed evidence item; unbacked cells render as such in the report contract gate.",
1509
+ promptSnippet: "Record a tested coverage cell (found or clean)",
1510
+ promptGuidelines: [
1511
+ "Use CoverageAdd whenever you finish testing a class on an asset — a clean 'no injection on /api/orders' verdict is just as load-bearing as a finding.",
1512
+ "scope='wide' when the verdict is a property of the whole deployment (record once — do NOT re-test per asset); scope='local' for one asset.",
1513
+ "Reference an artifact-backed EvidenceAdd item via evidence_item_id so the tested verdict is machine-checkable, not prose-only.",
1514
+ ],
1515
+ parameters: CoverageAddSchema,
1516
+
1517
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
1518
+ const item = recordCoverageResult(params.case_id as string, {
1519
+ asset: params.asset as string,
1520
+ class: params.class as string,
1521
+ scope: params.scope as CoverageScope,
1522
+ note: params.note as string,
1523
+ evidenceItemId: params.evidence_item_id as string | undefined,
1524
+ });
1525
+ const record = getCaseById(params.case_id as string);
1526
+ if (!record) throw new Error(`Case not found after coverage insert: ${params.case_id}`);
1527
+ return {
1528
+ content: [
1529
+ {
1530
+ type: "text",
1531
+ text: `Coverage cell recorded:\n[${item.scope}] ${item.asset} × ${item.class} — ${item.note}${item.evidenceItemId ? ` (backed by ${item.evidenceItemId})` : " (unbacked — attach an EvidenceAdd item to make it machine-checkable)"}\n\n${formatCaseDetail(record)}`,
1532
+ },
1533
+ ],
1534
+ details: { item, record },
1535
+ };
1536
+ },
1537
+
1538
+ renderCall(args, theme) {
1539
+ return callLine(
1540
+ theme,
1541
+ "CoverageAdd",
1542
+ `${(args.case_id as string) ?? ""} ${(args.asset as string) ?? ""}×${(args.class as string) ?? ""}`,
1543
+ );
1544
+ },
1545
+
1546
+ renderResult(result, _opts, theme) {
1547
+ const details = result.details as { item?: CoverageItem } | undefined;
1548
+ if (!details?.item) {
1549
+ return new Text(theme.fg("error", "✗ CoverageAdd failed"), 0, 0);
1550
+ }
1551
+ return new Text(
1552
+ theme.fg("success", "✓ ") +
1553
+ theme.fg("dim", `[${details.item.scope}] `) +
1554
+ truncateToWidth(`${details.item.asset} × ${details.item.class}`, 60),
1555
+ 0,
1556
+ 0,
1557
+ );
1558
+ },
1559
+ });
1560
+
1552
1561
  // ── Tool: CaseGet ──
1553
1562
 
1554
1563
  registerCaseTool({
@@ -1763,67 +1772,6 @@ ${formatCaseDetail(record)}`,
1763
1772
  },
1764
1773
  });
1765
1774
 
1766
- // ── Tool: ChainSuggest ──
1767
-
1768
- const ChainSuggestSchema = Type.Object(
1769
- {
1770
- case_id: Type.Optional(
1771
- Type.String({
1772
- description:
1773
- "Optional: scope suggestions to this case and its linked cases. Omit to scan all non-terminal cases.",
1774
- }),
1775
- ),
1776
- },
1777
- { additionalProperties: false },
1778
- );
1779
-
1780
- registerCaseTool({
1781
- name: "ChainSuggest",
1782
- label: "Suggest Exploit Chains",
1783
- description:
1784
- "Scan non-terminal cases for exploitable chains (credential+endpoint→ATO, open-redirect+OAuth→token theft, XSS+state-changing→CSRF bypass, IDOR+user-data→mass leak, SSTI→RCE, race+payment→financial, info-disclosure+SSRF). Returns ranked candidates with confidence and a suggested link kind — the agent decides whether to CaseLink them or open an escalation case. Catches chain combinations the model may have missed.",
1785
- promptSnippet: "Find missed exploit-chain combinations",
1786
- promptGuidelines: [
1787
- "Run ChainSuggest before concluding an engagement — low-severity findings that chain into high-impact (ATO, token theft, mass leak) are the ones triage cares about.",
1788
- "A suggestion is a HYPOTHESIS to verify, not a finding: test the chained behavior on the live target before linking or promoting anything.",
1789
- "Chain a suggested pair with CaseLink (suggested kind) or open a new escalation case with status=hypothesis.",
1790
- ],
1791
- parameters: ChainSuggestSchema,
1792
-
1793
- async execute(_id, params, _signal, _onUpdate, _ctx) {
1794
- const suggestions = await suggestChainsAsync(
1795
- (params.case_id as string | undefined) ?? undefined,
1796
- );
1797
- const lines: string[] = [
1798
- suggestions.length
1799
- ? `${suggestions.length} chain candidate(s):`
1800
- : "No chain candidates found across non-terminal cases.",
1801
- ];
1802
- for (const s of suggestions) {
1803
- const pair = s.targetId
1804
- ? `${s.sourceId} (${s.sourceTitle}) + ${s.targetId} (${s.targetTitle ?? ""})`
1805
- : `${s.sourceId} (${s.sourceTitle})`;
1806
- lines.push(
1807
- `\n[${s.pattern}] conf ${s.confidence}% — ${pair}\n ${s.rationale}${s.suggestedKind ? `\n suggested link kind: ${s.suggestedKind}` : ""}`,
1808
- );
1809
- }
1810
- return {
1811
- content: [{ type: "text", text: lines.join("\n") }],
1812
- details: { suggestions },
1813
- };
1814
- },
1815
-
1816
- renderCall(args, theme) {
1817
- return callLine(theme, "ChainSuggest", (args.case_id as string) ?? "all");
1818
- },
1819
-
1820
- renderResult(result, _opts, theme) {
1821
- const details = result.details as { suggestions?: { length: number } } | undefined;
1822
- const n = details?.suggestions?.length ?? 0;
1823
- return new Text(theme.fg(n ? "accent" : "dim", `${n} chain candidate(s)`), 0, 0);
1824
- },
1825
- });
1826
-
1827
1775
  // ── Tool: CaseContext ──
1828
1776
 
1829
1777
  registerCaseTool({
@@ -1839,15 +1787,15 @@ ${formatCaseDetail(record)}`,
1839
1787
  parameters: IdSchema,
1840
1788
 
1841
1789
  async execute(_id, params, _signal, _onUpdate, _ctx) {
1842
- const { path, contextPath, record } = await writeCaseContextAsync(params.id as string);
1790
+ const { path, contextPath, contractPath, record } = writeCaseContext(params.id as string);
1843
1791
  return {
1844
1792
  content: [
1845
1793
  {
1846
1794
  type: "text",
1847
- text: `Case context written: ${contextPath}\nReport path: ${path}\n${formatCase(record)}`,
1795
+ text: `Case context written: ${contextPath}\nReport path: ${path}\nReport contract path: ${contractPath} — write the closed-schema JSON contract there (evidence_ids + coverage_refs must reference only this case's items); status='reported' is rejected until it validates.\n${formatCase(record)}`,
1848
1796
  },
1849
1797
  ],
1850
- details: { path, contextPath, record },
1798
+ details: { path, contextPath, contractPath, record },
1851
1799
  };
1852
1800
  },
1853
1801
 
@@ -1865,244 +1813,6 @@ ${formatCaseDetail(record)}`,
1865
1813
  },
1866
1814
  });
1867
1815
 
1868
- // ── Command: /xp (toggle offensive XP mode) ──
1869
-
1870
- pi.registerCommand("xp", {
1871
- description:
1872
- "Toggle casefile XP (offensive) mode. Bare /xp and /xp on select SWARM, the bounded multi-agent workflow. LITE keeps XP single-agent. OFF (default) keeps context quiet for normal dev work. Usage: /xp [on|lite|swarm|off]",
1873
- handler: async (args, ctx) => {
1874
- const next = parseXpModeArg(args ?? "", readXpMode());
1875
- writeXpMode(next);
1876
- // Re-enabling after a mid-session /xp off must re-inject the workflow
1877
- // on the next prompt — otherwise workflowInjected (module-level, set on
1878
- // first enable) stays true and the workflow never comes back until the
1879
- // process restarts.
1880
- if (next !== "off") workflowInjected = false;
1881
- ctx.ui.notify(
1882
- `Casefile XP mode: ${next.toUpperCase()} (takes effect on the next prompt)`,
1883
- next === "off" ? "warning" : "info",
1884
- );
1885
- },
1886
- });
1887
-
1888
- // ── Tool: PipelineSubmit ──
1889
-
1890
- registerCaseTool({
1891
- name: "PipelineSubmit",
1892
- label: "Submit Stage Output",
1893
- description:
1894
- "Submit a pipeline stage's output (hunt, trace, skeptic, validate, chain, report) through the validation gate. Validates required fields against the stage spec (mirrors schemas/*.json), applies the deterministic pre-filter (test-path and file-existence filters on hunt findings, trivial dedup by file+class+line), and counts repair attempts (max 2, then rejected). A stage cannot advance on an invalid output — submit fixed output until accepted.",
1895
- promptSnippet: "Validate and submit a pipeline stage's output",
1896
- promptGuidelines: [
1897
- "Every delegated stage output and every main-agent VALIDATE/REPORT output must go through PipelineSubmit before the next stage starts — do not eyeball schemas.",
1898
- "verdict repair → fix the listed fields and re-submit the same output; budget is 2 attempts per finding, then rejected.",
1899
- "Skeptic: unparseable/schema-invalid = no verdict (repair/re-dispatch); only schema-valid DISPROVEN kills, and schema-valid UNDETERMINED blocks validation.",
1900
- "Tracer crash/invalid output = no trace verdict; repair or re-dispatch.",
1901
- 'Only schema-valid trace_result: "UNREACHABLE" blocks advancement as a proven unreachable path; schema-valid UNDETERMINED blocks validation until resolved.',
1902
- "Test-path findings and hallucinated files are rejected by the pre-filter, not repairable — the finding itself is noise.",
1903
- ],
1904
- parameters: Type.Object(
1905
- {
1906
- run_id: Type.String({
1907
- description: "Pipeline run identifier (same as the scratchpad run_id)",
1908
- }),
1909
- stage: Type.String({
1910
- enum: [...SUBMIT_STAGES],
1911
- description: "Pipeline stage: hunt | trace | skeptic | validate | chain | report",
1912
- }),
1913
- output: Type.Union([Type.String(), Type.Object({}, { additionalProperties: true })], {
1914
- description: "The stage output as a JSON object or JSON string (code fences tolerated)",
1915
- }),
1916
- },
1917
- { additionalProperties: false },
1918
- ),
1919
-
1920
- async execute(_id, params, _signal, _onUpdate, _ctx) {
1921
- const result = pipeline_submit(
1922
- params.run_id as string,
1923
- params.stage as SubmitStage,
1924
- params.output,
1925
- );
1926
- const statusLine =
1927
- result.verdict === "accepted"
1928
- ? `ACCEPTED (${params.stage}) — artifact: ${result.artifact}`
1929
- : result.verdict === "repair"
1930
- ? `REPAIR (attempt ${result.repair_attempt}/2) — fix these and re-submit:\n - ${result.errors.join("\n - ")}`
1931
- : `REJECTED — ${result.errors.join("\n")}`;
1932
- if (result.verdict !== "accepted") throw new Error(statusLine);
1933
- return {
1934
- content: [{ type: "text", text: statusLine }],
1935
- details: result as unknown as Record<string, unknown>,
1936
- };
1937
- },
1938
-
1939
- renderCall(args, theme) {
1940
- return callLine(theme, "PipelineSubmit", `${args.stage ?? ""}`);
1941
- },
1942
-
1943
- renderResult(result, _opts, theme) {
1944
- const details = result.details as { verdict?: string; repair_attempt?: number } | undefined;
1945
- if (details?.verdict === "accepted") {
1946
- return new Text(theme.fg("success", "✓ PipelineSubmit accepted"), 0, 0);
1947
- }
1948
- if (details?.verdict === "repair") {
1949
- return new Text(
1950
- theme.fg("warning", `↷ PipelineSubmit repair ${details.repair_attempt}/2`),
1951
- 0,
1952
- 0,
1953
- );
1954
- }
1955
- return new Text(theme.fg("error", "✗ PipelineSubmit rejected"), 0, 0);
1956
- },
1957
- });
1958
-
1959
- // ── Tool: ScratchpadInit ──
1960
-
1961
- registerCaseTool({
1962
- name: "ScratchpadInit",
1963
- label: "Init Scratchpad",
1964
- description:
1965
- "Initialize a crash-recoverable artifact store for a pipeline run. Creates the directory structure and an initial state.json checkpoint. Idempotent — safe to call on resume without --fresh; returns the existing checkpoint if the run already exists.",
1966
- promptSnippet: "Initialize the pipeline artifact store for a run",
1967
- promptGuidelines: [
1968
- "Call ScratchpadInit once at the start of a pipeline run (or on resume before ScratchpadResume).",
1969
- "The run_id is arbitrary but should be unique per pipeline run — typically <target>-<timestamp>.",
1970
- "On resume, ScratchpadInit returns the existing checkpoint without wiping it; pair with ScratchpadResume to skip completed phases.",
1971
- ],
1972
- parameters: RunIdSchema,
1973
-
1974
- async execute(_id, params, _signal, _onUpdate, _ctx) {
1975
- const cp = scratchpad_init(params.run_id as string);
1976
- return {
1977
- content: [
1978
- {
1979
- type: "text",
1980
- text: `Scratchpad initialized for run ${cp.run_id}.\nCompleted phases: ${cp.completed_phases.length ? cp.completed_phases.join(", ") : "none"}`,
1981
- },
1982
- ],
1983
- details: { checkpoint: cp },
1984
- };
1985
- },
1986
-
1987
- renderCall(args, theme) {
1988
- return callLine(theme, "ScratchpadInit", (args.run_id as string) ?? "");
1989
- },
1990
-
1991
- renderResult(result, _opts, theme) {
1992
- const cp = (result.details as { checkpoint: { run_id: string } } | undefined)?.checkpoint;
1993
- return new Text(`${theme.fg("success", "✓ ")}ScratchpadInit ${cp?.run_id ?? ""}`, 0, 0);
1994
- },
1995
- });
1996
-
1997
- // ── Tool: ScratchpadResume ──
1998
-
1999
- registerCaseTool({
2000
- name: "ScratchpadResume",
2001
- label: "Resume Scratchpad",
2002
- description:
2003
- "Read the checkpoint and artifact listing for a pipeline run to decide where to resume. Returns the next phase to run (or null if done) and which phases already completed. Returns null if the run does not exist.",
2004
- promptSnippet: "Check pipeline resume state — which phases are done",
2005
- promptGuidelines: [
2006
- "Call ScratchpadResume at pipeline start to determine where to resume. If it returns a checkpoint, skip completed phases (check ScratchpadPhaseDone before each dispatch) and continue from next_phase.",
2007
- "If ScratchpadResume returns null, the run has no checkpoint — call ScratchpadInit to start fresh.",
2008
- "Use ScratchpadPhaseDone before dispatching each stage to avoid re-running completed phases (idempotent resume).",
2009
- ],
2010
- parameters: RunIdSchema,
2011
-
2012
- async execute(_id, params, _signal, _onUpdate, _ctx) {
2013
- const resume = scratchpad_resume(params.run_id as string);
2014
- if (!resume) {
2015
- return {
2016
- content: [
2017
- {
2018
- type: "text",
2019
- text: `No scratchpad found for run ${params.run_id}. Call ScratchpadInit to start a new run.`,
2020
- },
2021
- ],
2022
- details: { resume: null },
2023
- };
2024
- }
2025
- const cp = resume.checkpoint;
2026
- return {
2027
- content: [
2028
- {
2029
- type: "text",
2030
- text:
2031
- `Resume run ${cp.run_id}:\n` +
2032
- `Completed phases: ${cp.completed_phases.length ? cp.completed_phases.join(", ") : "none"}\n` +
2033
- `Next phase: ${resume.next_phase ?? "none (run is done)"}`,
2034
- },
2035
- ],
2036
- details: { resume },
2037
- };
2038
- },
2039
-
2040
- renderCall(args, theme) {
2041
- return callLine(theme, "ScratchpadResume", (args.run_id as string) ?? "");
2042
- },
2043
-
2044
- renderResult(result, _opts, theme) {
2045
- const resume = (result.details as { resume: ScratchpadResume | null } | undefined)?.resume;
2046
- if (!resume) return new Text(theme.fg("warning", "↷ ScratchpadResume — no run found"), 0, 0);
2047
- return new Text(
2048
- theme.fg("success", "✓ ") +
2049
- `ScratchpadResume ${resume.checkpoint.run_id} → next: ${resume.next_phase ?? "done"}`,
2050
- 0,
2051
- 0,
2052
- );
2053
- },
2054
- });
2055
-
2056
- // ── Tool: ScratchpadCheckpoint ──
2057
-
2058
- registerCaseTool({
2059
- name: "ScratchpadCheckpoint",
2060
- label: "Checkpoint Phase",
2061
- description:
2062
- "Mark a pipeline phase as complete in the scratchpad state.json. Records the completion timestamp, key IDs, and an optional summary. Idempotent — re-checkpointing a phase overwrites its summary/IDs without duplicating the completed_phases entry.",
2063
- promptSnippet: "Record a pipeline phase as complete",
2064
- promptGuidelines: [
2065
- "Call ScratchpadCheckpoint after every phase completes: ScratchpadCheckpoint(run_id, phase, { ids, summary }).",
2066
- "ids are the key case/finding IDs the phase produced — used by resume to reconstruct state.",
2067
- "Keep completed_phases in pipeline order; the checkpoint sorts automatically.",
2068
- ],
2069
- parameters: ScratchpadCheckpointSchema,
2070
-
2071
- async execute(_id, params, _signal, _onUpdate, _ctx) {
2072
- const cp = scratchpad_checkpoint(params.run_id as string, params.phase as ScratchpadPhase, {
2073
- ids: params.ids as string[] | undefined,
2074
- summary: params.summary as string | undefined,
2075
- });
2076
- return {
2077
- content: [
2078
- {
2079
- type: "text",
2080
- text:
2081
- `Phase ${params.phase} checkpointed for run ${cp.run_id}.\n` +
2082
- `Completed phases: ${cp.completed_phases.join(", ")}`,
2083
- },
2084
- ],
2085
- details: { checkpoint: cp },
2086
- };
2087
- },
2088
-
2089
- renderCall(args, theme) {
2090
- return callLine(theme, "ScratchpadCheckpoint", `${args.run_id ?? ""} ${args.phase ?? ""}`);
2091
- },
2092
-
2093
- renderResult(result, _opts, theme) {
2094
- const cp = (
2095
- result.details as { checkpoint: { run_id: string; completed_phases: string[] } } | undefined
2096
- )?.checkpoint;
2097
- return new Text(
2098
- theme.fg("success", "✓ ") +
2099
- `ScratchpadCheckpoint ${cp?.run_id ?? ""} — ${cp?.completed_phases.length ?? 0} phases done`,
2100
- 0,
2101
- 0,
2102
- );
2103
- },
2104
- });
2105
-
2106
1816
  // ── Tool: ScratchpadWrite ──
2107
1817
 
2108
1818
  registerCaseTool({
@@ -2110,7 +1820,7 @@ ${formatCaseDetail(record)}`,
2110
1820
  label: "Write Artifact",
2111
1821
  description:
2112
1822
  "Write an intermediate artifact (recon map, trace output, verification log) to a phase's subdirectory in the scratchpad. Overwrites if the name exists. Artifact names are sanitized — path traversal is blocked.",
2113
- promptSnippet: "Save a pipeline artifact to the scratchpad",
1823
+ promptSnippet: "Save a run artifact to the scratchpad",
2114
1824
  promptGuidelines: [
2115
1825
  "Agents write artifacts to the scratchpad, not to each other's output files (prevents an echo chamber).",
2116
1826
  "The casefile owns state transitions; the scratchpad owns artifacts. Use ScratchpadWrite for bulky intermediate outputs, not CaseUpdate.",
@@ -2156,7 +1866,7 @@ ${formatCaseDetail(record)}`,
2156
1866
  label: "Read Artifact",
2157
1867
  description:
2158
1868
  "Read an artifact from a phase's subdirectory in the scratchpad. Returns null if the artifact is missing. Use to resume a phase from a prior run's intermediate output.",
2159
- promptSnippet: "Read a pipeline artifact from the scratchpad",
1869
+ promptSnippet: "Read a run artifact from the scratchpad",
2160
1870
  promptGuidelines: [
2161
1871
  "On resume, ScratchpadRead retrieves a prior phase's intermediate output so the next phase can proceed without re-running it.",
2162
1872
  "Returns null for missing artifacts — treat as 'not yet produced' rather than an error.",
@@ -2206,60 +1916,16 @@ ${formatCaseDetail(record)}`,
2206
1916
  },
2207
1917
  });
2208
1918
 
2209
- // ── Tool: ScratchpadPhaseDone ──
2210
-
2211
- registerCaseTool({
2212
- name: "ScratchpadPhaseDone",
2213
- label: "Phase Done?",
2214
- description:
2215
- "Check whether a phase has already been checkpointed in the scratchpad — for idempotent re-run. Returns true if the phase is complete; skip re-dispatching it on resume.",
2216
- promptSnippet: "Check if a pipeline phase is already complete",
2217
- promptGuidelines: [
2218
- "Call ScratchpadPhaseDone before dispatching each stage to avoid re-running completed phases on resume.",
2219
- "A completed phase with a checkpoint is a no-op on re-run — skip it and continue to the next incomplete phase.",
2220
- ],
2221
- parameters: ScratchpadPhaseDoneSchema,
2222
-
2223
- async execute(_id, params, _signal, _onUpdate, _ctx) {
2224
- const done = scratchpad_phase_done(params.run_id as string, params.phase as ScratchpadPhase);
2225
- return {
2226
- content: [
2227
- {
2228
- type: "text",
2229
- text: `Phase ${params.phase} for run ${params.run_id}: ${done ? "DONE (skip on resume)" : "not done"}`,
2230
- },
2231
- ],
2232
- details: { phase: params.phase, done },
2233
- };
2234
- },
2235
-
2236
- renderCall(args, theme) {
2237
- return callLine(theme, "ScratchpadPhaseDone", `${args.run_id ?? ""} ${args.phase ?? ""}`);
2238
- },
2239
-
2240
- renderResult(result, _opts, theme) {
2241
- const done = (result.details as { done?: boolean } | undefined)?.done;
2242
- return new Text(
2243
- done
2244
- ? theme.fg("success", "✓ ScratchpadPhaseDone — done")
2245
- : theme.fg("warning", "↷ ScratchpadPhaseDone — not done"),
2246
- 0,
2247
- 0,
2248
- );
2249
- },
2250
- });
2251
-
2252
1919
  // ── Tool: ScratchpadClear ──
2253
1920
 
2254
1921
  registerCaseTool({
2255
1922
  name: "ScratchpadClear",
2256
1923
  label: "Clear Run",
2257
1924
  description:
2258
- "Clear a single pipeline run's scratchpad directory. Used by --fresh for one run. Does not touch other runs. The run must be re-initialized with ScratchpadInit afterward.",
2259
- promptSnippet: "Clear one pipeline run's artifacts",
1925
+ "Clear a single run's scratchpad directory to force a fresh start for that run. Does not touch other runs. Directories are recreated automatically on the next write.",
1926
+ promptSnippet: "Clear one run's artifacts",
2260
1927
  promptGuidelines: [
2261
- "Use ScratchpadClear to force a fresh start for a single run (--fresh). It deletes that run's directory only.",
2262
- "After clearing, call ScratchpadInit to recreate the directory structure before writing artifacts.",
1928
+ "Use ScratchpadClear to force a fresh start for a single run. It deletes that run's directory only.",
2263
1929
  ],
2264
1930
  parameters: RunIdSchema,
2265
1931
 
@@ -2269,7 +1935,7 @@ ${formatCaseDetail(record)}`,
2269
1935
  content: [
2270
1936
  {
2271
1937
  type: "text",
2272
- text: `Scratchpad cleared for run ${params.run_id}. Call ScratchpadInit to start a new run.`,
1938
+ text: `Scratchpad cleared for run ${params.run_id}.`,
2273
1939
  },
2274
1940
  ],
2275
1941
  details: { run_id: params.run_id, cleared: true },
@@ -2319,22 +1985,19 @@ ${formatCaseDetail(record)}`,
2319
1985
  }
2320
1986
  });
2321
1987
 
2322
- // ── Event: Inject cyber workflow into system prompt ──
2323
- // XP (offensive) mode is OFF by default so normal dev work stays quiet.
2324
- // When enabled, the cyber workflow is injected ONCE per session (first
2325
- // prompt); the active case list refreshes every prompt because it changes
2326
- // as cases are added. Injecting into event.systemPrompt (not as a
2327
- // conversation message) avoids session bloat from repeated message entries.
1988
+ // ── Event: Inject the recon workflow + active-case list into the prompt ──
1989
+ // The recon workflow is injected ONCE per session (first prompt); the active
1990
+ // case list refreshes every prompt because it changes as cases are added.
1991
+ // Injecting into event.systemPrompt (not as a conversation message) avoids
1992
+ // session bloat from repeated message entries.
2328
1993
  let workflowInjected = false;
2329
1994
 
2330
1995
  pi.on("before_agent_start", async (event) => {
2331
- const mode = readXpMode();
2332
- if (mode === "off") return;
2333
1996
  // Skip subagent child processes: pi-subagents runs each child in its own
2334
1997
  // pi process (PI_SUBAGENT_CHILD=1) with this extension loaded. Injecting
2335
1998
  // the workflow + entire active-case ledger into every child dispatch is a
2336
- // token multiplier (N subagents × workflow + growing case list per turn) —
2337
- // workers get what they need via their task and tool guidelines.
1999
+ // token multiplier (N children × workflow + growing case list per turn) —
2000
+ // recon workers get what they need via their task, not the coordinator's.
2338
2001
  if (isSubagentProcess()) return;
2339
2002
 
2340
2003
  const includeWorkflow = !workflowInjected;
@@ -2343,15 +2006,13 @@ ${formatCaseDetail(record)}`,
2343
2006
  try {
2344
2007
  active = readActiveCases();
2345
2008
  } catch {
2346
- // No database yet — still inject workflow.
2009
+ // No database yet — still inject the workflow.
2347
2010
  }
2348
2011
 
2349
- const injection = buildAgentInjection(active, includeWorkflow, mode);
2012
+ const injection = buildAgentInjection(active, includeWorkflow);
2350
2013
  if (!injection) return; // workflow already injected, no active cases
2351
2014
  workflowInjected = true;
2352
2015
 
2353
- // Inject workflow FIRST (before skills) so the attacker mindset is
2354
- // prominent, not buried at the end of a long system prompt.
2355
2016
  return {
2356
2017
  systemPrompt: `${injection}\n\n${event.systemPrompt ?? ""}`,
2357
2018
  };