@xaccefy/pi-casefile 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -11,11 +11,17 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
11
11
  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
- import { Type } from "@sinclair/typebox";
15
-
14
+ import { Type } from "typebox";
15
+ import {
16
+ CONFIRM_DIFFERENTIAL_VALUES,
17
+ CONFIRM_VERDICT_VALUES,
18
+ type ConfirmerVerdict,
19
+ SEVERITY_MATCH_VALUES,
20
+ } from "./evidence.ts";
16
21
  import {
17
22
  addCaseResult,
18
23
  addEvidenceItemResult,
24
+ applyConfirmationResult,
19
25
  assertPromotable,
20
26
  type CaseConfidence,
21
27
  type CaseInput,
@@ -41,8 +47,9 @@ import {
41
47
  getCasefilePath,
42
48
  LINK_KIND_VALUES,
43
49
  linkCasesResult,
50
+ type PendingConfirmation,
51
+ type PocEvidenceRun,
44
52
  PRIORITY_VALUES,
45
- promoteFindingResult,
46
53
  readActiveCases,
47
54
  readCasefile,
48
55
  recordCoverageResult,
@@ -50,6 +57,7 @@ import {
50
57
  SEVERITY_VALUES,
51
58
  STATUS_VALUES,
52
59
  searchCases,
60
+ storePendingConfirmation,
53
61
  suggestChains,
54
62
  unlinkCasesResult,
55
63
  updateCaseResult,
@@ -58,6 +66,7 @@ import {
58
66
  import { pipeline_submit, SUBMIT_STAGES, type SubmitStage } from "./pipeline-submit.ts";
59
67
  import { type PocRun, type PocRunOptions, runPoc } from "./poc-runner.ts";
60
68
  import {
69
+ detectWorkspaceRoot,
61
70
  PHASE_ORDER,
62
71
  type ScratchpadPhase,
63
72
  type ScratchpadResume,
@@ -68,8 +77,13 @@ import {
68
77
  scratchpad_read,
69
78
  scratchpad_resume,
70
79
  scratchpad_write,
80
+ setScratchpadRoot,
71
81
  } from "./scratchpad.ts";
72
- import { STATIC_CYBER_WORKFLOW, STATIC_CYBER_WORKFLOW_LITE } from "./workflow.ts";
82
+ import {
83
+ STATIC_CYBER_WORKFLOW,
84
+ STATIC_CYBER_WORKFLOW_LITE,
85
+ STATIC_CYBER_WORKFLOW_OMP,
86
+ } from "./workflow.ts";
73
87
 
74
88
  // ── Schemas ───────────────────────────────────────────────────────────
75
89
 
@@ -109,6 +123,11 @@ const CommonFields = {
109
123
  "Falsification conditions — what would disprove this hypothesis (REQUIRED on CaseAdd)",
110
124
  }),
111
125
  ),
126
+ disconfirmation: Type.Optional(
127
+ Type.String({
128
+ description: "Documented attempt to disprove the finding before confirmation",
129
+ }),
130
+ ),
112
131
  };
113
132
 
114
133
  // ── Tool: CaseAdd ─────────────────────────────────────────────────────
@@ -156,7 +175,14 @@ const EvidenceAddSchema = Type.Object(
156
175
  { additionalProperties: false },
157
176
  );
158
177
 
159
- // ── Tool: PromoteFinding ─────────────────────────────────────────────
178
+ // ── Tool: PromoteFinding (phase 1) / ConfirmFinding (phase 2) ──────────
179
+ //
180
+ // Confirmation is TWO-PHASE because tools cannot dispatch subagents: the
181
+ // coordinator runs PromoteFinding (harness runs the PoC 2x + control,
182
+ // validates nonce-bound evidence.json, records the bundle), dispatches the
183
+ // confirmer subagent (fresh context, re-executes the verify request), then
184
+ // commits the verdict via ConfirmFinding. Exit codes and markers are
185
+ // diagnostics — the gate is evidence + verdict.
160
186
 
161
187
  const PromoteSchema = Type.Object(
162
188
  {
@@ -164,25 +190,14 @@ const PromoteSchema = Type.Object(
164
190
  poc_path: Type.String({
165
191
  description: "Absolute path to the PoC script on disk",
166
192
  }),
167
- verification_marker: Type.String({
168
- minLength: 1,
169
- description:
170
- "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.",
171
- }),
172
- disconfirmation_path: Type.Optional(
173
- Type.String({
174
- description:
175
- "Absolute path to a disconfirmation script that tries to disprove the finding; must exit non-zero (failure to disprove). REQUIRED for severity high/critical findings.",
176
- }),
177
- ),
178
193
  control_path: Type.String({
179
194
  description:
180
- "REQUIRED for EVERY promotion (sandboxed and live alike): absolute path to a control-target script that runs the SAME PoC against a control that lacks the vulnerability (patched replica, second account, baseline endpoint). The harness blocks promotion if the verification_marker appears in the control output — an unconditional-marker/mock PoC cannot pass. Best form: the same parameterized script, branching on the PI_POC_MODE env var (poc | control) the harness sets on every run.",
195
+ "REQUIRED: absolute path to the SAME script as poc_path (sha256-equality is ENFORCED). The harness runs it with PI_POC_MODE=control and PI_POC_TARGET=control_target.",
181
196
  }),
182
- control_liveness_marker: Type.String({
197
+ control_target: Type.String({
183
198
  minLength: 1,
184
199
  description:
185
- "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.",
200
+ "REQUIRED: a distinct baseline target that lacks the vulnerability (patched replica, second account, baseline endpoint).",
186
201
  }),
187
202
  local: Type.Optional(
188
203
  Type.Boolean({
@@ -194,6 +209,51 @@ const PromoteSchema = Type.Object(
194
209
  { additionalProperties: false },
195
210
  );
196
211
 
212
+ const ConfirmSchema = Type.Object(
213
+ {
214
+ id: Type.String({ description: "Case ID with a pending confirmation" }),
215
+ verdict: Type.Object(
216
+ {
217
+ verdict: Type.String({ enum: [...CONFIRM_VERDICT_VALUES] }),
218
+ reasoning: Type.String({
219
+ description: "Why the evidence does or does not demonstrate the claim",
220
+ }),
221
+ evidence_reviewed: Type.Array(Type.String(), {
222
+ description: "Files/evidence the confirmer actually reviewed",
223
+ }),
224
+ re_executed: Type.Boolean({
225
+ description:
226
+ "True iff the confirmer re-sent the verify request itself. Mandatory for CONFIRMED.",
227
+ }),
228
+ re_execution_note: Type.Optional(
229
+ Type.String({ description: "What the confirmer observed when re-executing" }),
230
+ ),
231
+ differential: Type.String({
232
+ enum: [...CONFIRM_DIFFERENTIAL_VALUES],
233
+ description: "Target vs control evidence comparison. CONFIRMED requires target_only.",
234
+ }),
235
+ severity_match: Type.Optional(
236
+ Type.String({
237
+ enum: [...SEVERITY_MATCH_VALUES],
238
+ description: "Claimed severity vs what the evidence shows",
239
+ }),
240
+ ),
241
+ disconfirmation_attempt: Type.Optional(
242
+ Type.String({
243
+ description:
244
+ "The confirmer's own failed attempt to disprove — becomes the case's disconfirmation",
245
+ }),
246
+ ),
247
+ model: Type.Optional(
248
+ Type.String({ description: "Which model judged (recorded for the accuracy ledger)" }),
249
+ ),
250
+ },
251
+ { additionalProperties: false },
252
+ ),
253
+ },
254
+ { additionalProperties: false },
255
+ );
256
+
197
257
  // ── Tool: CaseGet ─────────────────────────────────────────────────────
198
258
 
199
259
  /** id-only schema, shared by CaseGet / CaseContext. */
@@ -596,6 +656,19 @@ function buildCaseListContext(records: CaseRecord[]): string {
596
656
  return lines.join("\n");
597
657
  }
598
658
 
659
+ /**
660
+ * Detect the extension host. OMP is a fork of Pi: both load the same
661
+ * `pi`-manifest extensions, but subagent dispatch differs (pi-subagents'
662
+ * `subagent({workflowScript})` vs OMP's native `task`). The entry script path
663
+ * carries the host package: `@oh-my-pi/pi-coding-agent/dist/cli.js` under OMP,
664
+ * `@earendil-works/pi-coding-agent` under Pi.
665
+ */
666
+ export function detectHost(): "omp" | "pi" {
667
+ const argv = process.argv.join(" ");
668
+ if (argv.includes("@oh-my-pi")) return "omp";
669
+ return "pi";
670
+ }
671
+
599
672
  /**
600
673
  * Builds the per-prompt injection. The cyber workflow is session-scope data —
601
674
  * it never changes — so the caller passes includeWorkflow=true exactly once
@@ -603,7 +676,8 @@ function buildCaseListContext(records: CaseRecord[]): string {
603
676
  * case list DOES change as cases are added, so it is refreshed every prompt.
604
677
  *
605
678
  * mode selects the workflow text: "lite" injects the single-agent workflow
606
- * (no subagent dispatch), anything else gets the full subagent pipeline.
679
+ * (no subagent dispatch), anything else gets the full subagent pipeline,
680
+ * rendered for the host's dispatch convention (pi-subagents vs OMP task).
607
681
  */
608
682
  function buildAgentInjection(
609
683
  active: CaseRecord[],
@@ -612,7 +686,12 @@ function buildAgentInjection(
612
686
  ): string {
613
687
  const caseList = buildCaseListContext(active);
614
688
  if (!includeWorkflow) return caseList;
615
- const workflow = mode === "lite" ? STATIC_CYBER_WORKFLOW_LITE : STATIC_CYBER_WORKFLOW;
689
+ const workflow =
690
+ mode === "lite"
691
+ ? STATIC_CYBER_WORKFLOW_LITE
692
+ : detectHost() === "omp"
693
+ ? STATIC_CYBER_WORKFLOW_OMP
694
+ : STATIC_CYBER_WORKFLOW;
616
695
  // Workflow FIRST for prominence, then case list as reference data.
617
696
  return caseList ? `${workflow}\n\n${caseList}` : workflow;
618
697
  }
@@ -673,6 +752,15 @@ export function parseXpModeArg(args: string, current: XpMode): XpMode {
673
752
  // ── Main extension ────────────────────────────────────────────────────
674
753
 
675
754
  export default function casefileExtension(pi: ExtensionAPI) {
755
+ // Pin the workspace root ONCE at extension load. Every scratchpad / pipeline
756
+ // / PoC-path lookup otherwise re-walks the ambient cwd on each call — a
757
+ // mid-session `cd` would split state across two .scratchpad roots and
758
+ // misroot the hunt file-existence filter. The PoC runner reads PI_POC_ROOT
759
+ // (set only when the operator hasn't pinned it explicitly).
760
+ const workspaceRoot = detectWorkspaceRoot();
761
+ setScratchpadRoot(workspaceRoot);
762
+ process.env.PI_POC_ROOT ??= workspaceRoot;
763
+
676
764
  // ── Diagnostic Error Handler Middleware ──
677
765
  const originalRegisterTool = pi.registerTool.bind(pi);
678
766
  pi.registerTool = (spec: any) => {
@@ -692,16 +780,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
692
780
  ) {
693
781
  hint = `\n\nHint: A database access error occurred on the casefile SQLite ledger.\nTo troubleshoot:\n 1. Check filesystem read/write permissions for the database path: ${getCasefilePath()}.\n 2. If using a locked folder, you can override the ledger location by setting:\n export PI_CASEFILE_PATH=/your/writable/directory/casefile.db`;
694
782
  }
695
- return {
696
- content: [
697
- {
698
- type: "text" as const,
699
- text: `${spec.name} failed: ${message}${hint}`,
700
- },
701
- ],
702
- isError: true,
703
- details: { error: message },
704
- };
783
+ throw new Error(`${spec.name} failed: ${message}${hint}`, { cause: err });
705
784
  }
706
785
  };
707
786
  originalRegisterTool(spec);
@@ -888,13 +967,15 @@ export default function casefileExtension(pi: ExtensionAPI) {
888
967
  description:
889
968
  "The attack class tested (e.g. sql-injection, xss, idor, ssti, ssrf, auth-bypass, ...).",
890
969
  }),
891
- scope: Type.Union(
892
- COVERAGE_SCOPE_VALUES.map((s) => Type.Literal(s)),
893
- {
894
- description:
895
- "'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.",
896
- },
897
- ),
970
+ // Provider-safe string enum (per the header rule): Type.Union(Type.Literal)
971
+ // serializes to anyOf/const, which some providers drop — scope would
972
+ // arrive undefined and every explicit 'wide' verdict would silently
973
+ // persist as 'local', under-reporting tested classes.
974
+ scope: Type.String({
975
+ enum: [...COVERAGE_SCOPE_VALUES],
976
+ description:
977
+ "'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.",
978
+ }),
898
979
  note: Type.String({
899
980
  description:
900
981
  "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.",
@@ -1023,110 +1104,59 @@ export default function casefileExtension(pi: ExtensionAPI) {
1023
1104
  });
1024
1105
 
1025
1106
  // ── Tool: PromoteFinding ──
1107
+ // ── Tool: PromoteFinding (phase 1) ──
1026
1108
 
1027
1109
  pi.registerTool({
1028
1110
  name: "PromoteFinding",
1029
- label: "Promote Finding",
1111
+ label: "Run PoC Evidence",
1030
1112
  description:
1031
- "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 actually worked — exit code 0 alone is NOT sufficient. EVERY promotion (sandboxed and live alike) REQUIRES: (1) a disconfirmation_path script that must exit non-zero (the finding survived the attempt to disprove it); (2) a control_path that is the SAME script as the PoC (sha256-enforced — a separately written control file is rejected), run in control mode via PI_POC_MODE; (3) a control_liveness_marker the control must print after reaching its target. The harness blocks promotion if the vuln marker appears in the (untruncated) control output, if the liveness marker is absent, or if the control/disconfirmation scripts crash. Host execution is never agent-selectable — local:true uses a host-network Docker sandbox; true host runs need the operator's PI_POC_ALLOW_LOCAL=1.",
1032
- promptSnippet: "Run a PoC and promote an investigating case to confirmed",
1113
+ "Phase 1 of confirmation: run the PoC twice against the case target plus once against control_target (same script, sha256-enforced), then validate the nonce-bound evidence.json each run writes to $PI_POC_EVIDENCE_DIR. Records a pending confirmation bundle (expires in 1h) and returns the confirmer dispatch instruction. Exit codes and markers are DIAGNOSTICS the gate is evidence + confirmer verdict. After PromoteFinding: dispatch the confirmer subagent, then commit its verdict with ConfirmFinding. Host execution is never agent-selectable — local:true uses a host-network Docker sandbox; true host runs need PI_POC_ALLOW_LOCAL=1.",
1114
+ promptSnippet: "Phase 1: run PoC evidence (target x2 + control) and record the pending bundle",
1033
1115
  promptGuidelines: [
1034
1116
  "Use PromoteFinding when an investigating case has a concrete PoC script on disk and you are ready to prove it.",
1035
- "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.",
1117
+ "Prerequisites: status='investigating' and non-empty poc, evidence, impact, severity, target, plus an artifact-backed EvidenceAdd 'observation' item on the case (the initial signal, with artifact_path). The disconfirmation comes from the confirmer at confirm time.",
1118
+ "The PoC MUST write evidence.json to $PI_POC_EVIDENCE_DIR: { nonce (echo $PI_POC_NONCE), claim, verify: { method, url, expect: { status/body_contains/body_regex } }, observations }. The harness validates it and binds it to the run — missing/invalid/misnonced evidence blocks promotion.",
1119
+ "control_path (REQUIRED): the SAME script as poc_path (sha256-equality is ENFORCED). The harness runs it with PI_POC_MODE=control and PI_POC_TARGET=control_target. The control's evidence must DIFFER from the target's (not target-dependent → blocked).",
1036
1120
  "Default sandbox: docker run --rm --network none. Use local:true for network-dependent bugs (host-network sandbox; host execution needs operator PI_POC_ALLOW_LOCAL=1).",
1037
- "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.",
1038
- "control_path (REQUIRED for EVERY promotion): the SAME script as poc_path (sha256-equality is ENFORCED — a separately written control file is rejected). One parameterized script — read the target from the PI_POC_TARGET env var and branch on PI_POC_MODE (poc | control) — the control run is literally the same script in control mode against a control lacking the vuln (patched replica, second account, baseline endpoint). The harness blocks promotion if the verification_marker appears in the control run's output (checked on the untruncated output) — an unconditional-marker PoC cannot pass this.",
1039
- "control_liveness_marker (REQUIRED): a unique string the control script prints only AFTER reaching/exercising the control target (e.g. 'CONTROL_REACHED_<case-id>'). The harness blocks promotion if the control output lacks it — a control pointed at an unreachable host, wrong port, or exiting before the check is NOT a clean verdict.",
1040
- "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).",
1041
- "Never CaseUpdate status='confirmed' directly — it is rejected. Always use PromoteFinding.",
1121
+ "After the bundle is recorded, dispatch the confirmer subagent (agents/confirmer.md, fresh context, different model) and commit its verdict with ConfirmFinding. Never CaseUpdate status='confirmed' directly.",
1042
1122
  ],
1043
1123
  parameters: PromoteSchema,
1044
1124
 
1045
1125
  async execute(_id, params, _signal, _onUpdate, _ctx) {
1046
- // Validate promotability BEFORE running the PoC — a sandboxed run can take
1047
- // 30s (plus first-time image pull), so fail cheap when the case can't
1048
- // advance anyway (missing, wrong status, missing required fields, missing
1049
- // artifact-backed observation evidence).
1126
+ // Validate promotability BEFORE running the PoC — each sandboxed run can
1127
+ // take 30s (plus first-time image pull), so fail cheap when the case
1128
+ // can't advance anyway (missing, wrong status, missing required fields,
1129
+ // missing artifact-backed observation evidence).
1050
1130
  const caseId = params.id as string;
1051
1131
  const current = assertPromotable(caseId);
1052
1132
 
1053
- // Shared blocked-promotion shape: the case stays investigating and the
1054
- // caller gets the record back for context.
1055
- const fail = (text: string, extra?: Record<string, unknown>) => ({
1056
- content: [{ type: "text" as const, text }],
1057
- isError: true,
1058
- details: { record: getCaseById(caseId), ...extra },
1059
- });
1060
-
1061
- // Reject empty/whitespace markers BEFORE any PoC run — it's a param
1062
- // error, so fail cheap instead of burning a (up to 30s) sandboxed run.
1063
- const marker = (params.verification_marker as string | undefined)?.trim();
1064
- if (!marker) {
1065
- return fail(
1066
- "verification_marker is empty or whitespace. " +
1067
- "A non-empty marker printed only AFTER the PoC confirms exploitation is required — " +
1068
- "exit code 0 alone is not sufficient. Case remains investigating.",
1069
- );
1070
- }
1133
+ const fail = (text: string, _extra?: Record<string, unknown>): never => {
1134
+ throw new Error(text);
1135
+ };
1071
1136
 
1072
- // Control-target anti-cheat is mandatory for EVERY promotion sandboxed
1073
- // and live alike. It is the only deterministic check that the marker is
1074
- // target-dependent; skipping it for the default sandboxed mode would let
1075
- // an unconditional-marker PoC pass untouched. Check BEFORE paying for the
1076
- // PoC run.
1077
- const controlPath = (params.control_path as string | undefined)?.trim();
1137
+ const controlPath = (params.control_path as string | undefined)?.trim() ?? "";
1138
+ const controlTarget = (params.control_target as string | undefined)?.trim() ?? "";
1078
1139
  if (!controlPath) {
1079
1140
  return fail(
1080
- "control_path is REQUIRED for every promotion (sandboxed and live alike): a script that runs " +
1081
- "the SAME PoC against a control lacking the vuln (patched replica, second account, baseline endpoint). " +
1082
- "The harness verifies the verification_marker is absent from the control run's output — that is what " +
1083
- "proves the marker is target-dependent. Write the control script and retry.",
1141
+ "control_path is REQUIRED: the SAME script as poc_path (sha256-equality is ENFORCED), run by the harness with PI_POC_MODE=control and PI_POC_TARGET=control_target.",
1084
1142
  { missingControl: true },
1085
1143
  );
1086
1144
  }
1087
-
1088
- // The control must ALSO prove it reached its target: without a liveness
1089
- // marker, a control pointed at an unreachable host / wrong port / early
1090
- // exit would show "marker absent" for reasons unrelated to the vuln.
1091
- const livenessMarker = (params.control_liveness_marker as string | undefined)?.trim();
1092
- if (!livenessMarker) {
1093
- return fail(
1094
- "control_liveness_marker is REQUIRED for every promotion: a unique string the control script " +
1095
- "prints ONLY AFTER reaching/exercising the control target (e.g. 'CONTROL_REACHED_<case-id>'). " +
1096
- "The harness blocks promotion if the control output lacks it — a control that never reached its " +
1097
- "target proves nothing. Must differ from verification_marker.",
1098
- { missingLivenessMarker: true },
1099
- );
1100
- }
1101
- if (livenessMarker === marker) {
1145
+ if (!controlTarget) {
1102
1146
  return fail(
1103
- "control_liveness_marker must differ from verification_marker the liveness marker proves the " +
1104
- "control reached its target, the verification marker proves the vuln fired. Use distinct strings.",
1105
- { livenessEqualsMarker: true },
1147
+ "control_target is REQUIRED: a distinct baseline target that lacks the vulnerability.",
1148
+ { missingControlTarget: true },
1106
1149
  );
1107
1150
  }
1108
-
1109
- // EXECUTED disconfirmation is required for EVERY promotion — the prose
1110
- // `disconfirmation` field cannot carry the disprove-attempt. Making it
1111
- // unconditional (not severity-keyed) also kills the ordering attack:
1112
- // a case filed as low/medium could previously skip the run, promote,
1113
- // then be re-raised to high/critical with no executed disproof.
1114
- const disconfirmationPath = (params.disconfirmation_path as string | undefined)?.trim();
1115
- if (!disconfirmationPath) {
1151
+ if (controlTarget === current.target) {
1116
1152
  return fail(
1117
- "disconfirmation_path is REQUIRED for every promotion: a script that tries to " +
1118
- "disprove the finding and must exit non-zero (the finding survived the attempt). " +
1119
- "The prose disconfirmation field is not enough. Write the disconfirmation script and retry.",
1120
- { missingDisconfirmation: true },
1153
+ "control_target must differ from the case target; a control run against the vulnerable target proves nothing.",
1154
+ { controlTargetEqualsCaseTarget: true },
1121
1155
  );
1122
1156
  }
1123
1157
 
1124
- // Same-file contract (anti-cheat): the control must be the SAME script as
1125
- // the PoC, differing only via the harness-set PI_POC_MODE env var. Two
1126
- // agent-written files (poc prints the marker unconditionally, control
1127
- // prints the liveness marker) pass every string check — the "same script"
1128
- // recommendation in the docs is now enforced: sha256(control) must equal
1129
- // sha256(poc). Hash BEFORE any run so a mismatch fails cheap.
1158
+ // Same-file contract (anti-cheat): control must be the SAME bytes as the
1159
+ // PoC, differing only via the harness-set env. Check BEFORE any run.
1130
1160
  const pocPath = (params.poc_path as string | undefined)?.trim() ?? "";
1131
1161
  let pocHash: string | undefined;
1132
1162
  let controlHash: string | undefined;
@@ -1141,146 +1171,185 @@ export default function casefileExtension(pi: ExtensionAPI) {
1141
1171
  }
1142
1172
  if (pocHash !== controlHash) {
1143
1173
  return fail(
1144
- "CONTROL CHECK FAILED: control_path must be the SAME script as poc_path " +
1145
- "(sha256 mismatch). The control run is only meaningful as the same PoC " +
1146
- "pointed at a control target via PI_POC_MODE=control — a separately written " +
1147
- "control file proves nothing (the model writes both files). Parameterize " +
1148
- "one script: branch on PI_POC_MODE (poc | control) and read the target from " +
1149
- "PI_POC_TARGET. Case remains investigating.",
1150
- { run: undefined, controlHashMismatch: true },
1174
+ "CONTROL CHECK FAILED: control_path must be the SAME script as poc_path (sha256 mismatch). Case remains investigating.",
1175
+ { controlHashMismatch: true },
1151
1176
  );
1152
1177
  }
1153
1178
 
1154
- // local:true now means "network access": with the operator's
1155
- // PI_POC_ALLOW_LOCAL=1 opt-in the run executes on the host; WITHOUT the
1156
- // opt-in it uses a Docker sandbox with --network host (same FS/cap/user
1157
- // isolation) and fails closed when Docker is unavailable too. Host
1158
- // execution is never agent-selectable on its own.
1159
- const runOptions = (pocMode: string): PocRunOptions => ({
1179
+ const runOptions = (pocMode: string, target: string): PocRunOptions => ({
1160
1180
  network: params.local === true ? "host" : "none",
1161
1181
  local: params.local === true,
1162
- env: { PI_POC_MODE: pocMode, PI_POC_TARGET: current.target ?? "" },
1182
+ env: { PI_POC_MODE: pocMode, PI_POC_TARGET: target },
1163
1183
  });
1164
1184
 
1165
- const run = runPoc(pocPath, runOptions("poc"));
1166
-
1167
- // Fail closed without throwing: non-zero PoC must leave the case investigating.
1168
- if (run.exitCode !== 0) {
1169
- return fail(
1170
- `PoC failed (exit ${run.exitCode}). Case remains investigating.\nOutput:\n${run.output}`,
1171
- { run },
1172
- );
1173
- }
1174
-
1175
- // Defense-in-depth: exit 0 implies the run completed (sandbox wrapper /
1176
- // local spawn semantics), but never trust a run the runner says crashed.
1177
- if (!run.completed) {
1178
- return fail(
1179
- `PoC did NOT complete (spawn error, killed, or timeout). Case remains investigating.\nOutput:\n${run.output}`,
1180
- { run, pocCrashed: true },
1181
- );
1182
- }
1183
-
1184
- // Verification marker check: exit code 0 alone is NOT sufficient.
1185
- // The PoC must print the verification_marker, proving the exploit
1186
- // actually worked — not just that the script ran. The check runs on
1187
- // rawOutput (untruncated) so a script printing its marker past the
1188
- // 4000-char display window cannot hide it.
1189
- const pocOut = run.rawOutput ?? run.output;
1190
- if (!pocOut.includes(marker)) {
1191
- return fail(
1192
- `PoC exited 0 but the verification marker "${marker}" was NOT found in the output.\n` +
1193
- `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` +
1194
- `Do not print the marker unconditionally — print it only when the exploit is confirmed.\n\nOutput:\n${run.output}`,
1195
- { run, markerMissing: true },
1196
- );
1197
- }
1185
+ const caseTarget = current.target ?? "";
1186
+ // Determinism: TWO target runs + one control run. Exit codes are
1187
+ // diagnostics; completion, evidence validity, determinism, and the
1188
+ // differential are the gate.
1189
+ const run1 = runPoc(pocPath, runOptions("poc", caseTarget));
1190
+ const run2 = runPoc(pocPath, runOptions("poc", caseTarget));
1191
+ const controlRun = runPoc(controlPath, runOptions("control", controlTarget));
1198
1192
 
1199
- // Run disconfirmation script must exit NON-0 (finding survived the attempt to disprove).
1200
- let disconfirmationRun: PocRun | undefined;
1201
- if (disconfirmationPath) {
1202
- disconfirmationRun = runPoc(disconfirmationPath, runOptions("disconfirmation"));
1203
- if (!disconfirmationRun.completed) {
1193
+ const evidenceRun = (r: PocRun, mode: "poc" | "control", target: string): PocEvidenceRun => {
1194
+ if (!r.completed || !r.outputComplete) {
1204
1195
  return fail(
1205
- `Disconfirmation script did NOT complete (spawn error, killed, or timeout no completion marker). ` +
1206
- `A crash is not a survived disproof: fix the disconfirmation script and retry.\n` +
1207
- `Output:\n${disconfirmationRun.output}`,
1208
- { run, disconfirmationRun, disconfirmationCrashed: true },
1196
+ `${mode} run did not complete or output capture was incomplete` +
1197
+ (r.infraError ? ` (infra: ${r.output.trim()})` : "") +
1198
+ ". A crash is not evidence. Case remains investigating.",
1199
+ { run: r, pocCrashed: true },
1209
1200
  );
1210
1201
  }
1211
- if (disconfirmationRun.exitCode === 0) {
1202
+ if (r.evidenceError) {
1212
1203
  return fail(
1213
- `Disconfirmation script exited 0 (finding was disproven). ` +
1214
- `Case remains investigating.\nOutput:\n${disconfirmationRun.output}`,
1215
- { run, disconfirmationRun },
1204
+ `EVIDENCE CONTRACT FAILED (${mode} run): ${r.evidenceError}. ` +
1205
+ "The PoC must write evidence.json to $PI_POC_EVIDENCE_DIR — { nonce (echo $PI_POC_NONCE), claim, verify: { method, url, expect: { status / body_contains / body_regex } }, observations } — " +
1206
+ "the file is bound to this run and validated by the harness. Case remains investigating.",
1207
+ { run: r, evidenceError: r.evidenceError },
1216
1208
  );
1217
1209
  }
1218
- }
1210
+ if (!r.evidence || !r.evidenceSha256 || !r.nonce) {
1211
+ return fail(`${mode} run produced no evidence. Case remains investigating.`, { run: r });
1212
+ }
1213
+ return {
1214
+ mode,
1215
+ target,
1216
+ nonce: r.nonce,
1217
+ ranAt: r.ranAt,
1218
+ exitCode: r.exitCode,
1219
+ sandbox: r.sandbox,
1220
+ completed: r.completed,
1221
+ outputComplete: r.outputComplete,
1222
+ output: r.output ?? "",
1223
+ evidence: r.evidence,
1224
+ evidenceSha256: r.evidenceSha256,
1225
+ evidencePath: r.evidencePath,
1226
+ };
1227
+ };
1219
1228
 
1220
- // Control-target anti-cheat check: the same PoC pointed at a control that
1221
- // lacks the vuln must NOT print the marker. The control script is
1222
- // agent-written, but the marker-absence check is harness-side and
1223
- // deterministic — the model cannot pass it by asserting success. The
1224
- // liveness-marker check closes the "control pointed at an unreachable
1225
- // host / exited early" hole: the control must prove it reached its target.
1226
- const controlRun = runPoc(controlPath, runOptions("control"));
1227
- if (!controlRun.completed) {
1228
- return fail(
1229
- `CONTROL CHECK FAILED: the control-target script did NOT complete (spawn error, killed, or timeout). ` +
1230
- `A control run that never executed proves nothing about the marker — fix the control script and retry.\n` +
1231
- `Control output:\n${controlRun.output}`,
1232
- { run, controlRun, controlCrashed: true },
1233
- );
1234
- }
1235
- // Marker-absence + liveness checks run on the UNTRUNCATED control output.
1236
- const controlOut = controlRun.rawOutput ?? controlRun.output;
1237
- if (controlOut.includes(marker)) {
1238
- return fail(
1239
- `CONTROL CHECK FAILED: the verification marker "${marker}" appeared in the control-target run. ` +
1240
- `The PoC prints the marker without the vulnerable condition — a cheating PoC (unconditional marker) ` +
1241
- `or a broken check. Case remains investigating.\nControl output:\n${controlRun.output}`,
1242
- { run, controlRun, controlCheated: true },
1243
- );
1244
- }
1245
- if (!controlOut.includes(livenessMarker)) {
1246
- return fail(
1247
- `CONTROL CHECK FAILED: the control-target run completed but the control_liveness_marker "${livenessMarker}" ` +
1248
- `was NOT found in its output. The control must print the liveness marker only AFTER reaching/exercising ` +
1249
- `the control target — an unreachable host, wrong port, or early exit is not a valid control verdict. ` +
1250
- `Case remains investigating.\nControl output:\n${controlRun.output}`,
1251
- { run, controlRun, controlLivenessMissing: true },
1252
- );
1253
- }
1229
+ const targetRuns: [PocEvidenceRun, PocEvidenceRun] = [
1230
+ evidenceRun(run1, "poc", caseTarget),
1231
+ evidenceRun(run2, "poc", caseTarget),
1232
+ ];
1233
+ const control = evidenceRun(controlRun, "control", controlTarget);
1254
1234
 
1255
- // PocRun is structurally a PocVerification — pass the runs straight through.
1256
- const result = promoteFindingResult(
1235
+ const bundle: PendingConfirmation = {
1257
1236
  caseId,
1258
- run,
1259
- disconfirmationRun,
1260
- controlRun,
1261
- marker,
1262
- livenessMarker,
1237
+ ranAt: new Date().toISOString(),
1238
+ pocPath,
1239
+ pocSha256: pocHash,
1240
+ controlPath,
1241
+ controlTarget,
1242
+ targetRuns,
1243
+ controlRun: control,
1244
+ };
1245
+
1246
+ let record: CaseRecord;
1247
+ try {
1248
+ record = storePendingConfirmation(caseId, bundle);
1249
+ } catch (e) {
1250
+ return fail(`Pending confirmation rejected: ${(e as Error).message}`, {
1251
+ storeRejected: true,
1252
+ });
1253
+ }
1254
+
1255
+ return {
1256
+ content: [
1257
+ {
1258
+ type: "text",
1259
+ text:
1260
+ `Phase 1 complete — evidence bundle recorded on ${caseId} (expires in 1h).\n` +
1261
+ `Target runs: 2, Control run: 1 — all with validated nonce-bound evidence.json.\n` +
1262
+ `Evidence sha256: ${targetRuns[0].evidenceSha256}\n` +
1263
+ `PoC script sha256 (at run time): ${pocHash}\n\n` +
1264
+ (detectHost() === "omp"
1265
+ ? `DISPATCH THE CONFIRMER now: task({ context: 'fresh', tasks: [{ name: 'confirm-${caseId}-1', agent: 'confirmer', task: 'Verify the PoC evidence for case ${caseId} (poc_path=${pocPath}, control_target=${controlTarget}, evidence_sha256=${targetRuns[0].evidenceSha256}, poc_sha256=${pocHash}). Assume fabricated, prove real. Re-send the verify request yourself. Return the verdict.' }] })\n`
1266
+ : `DISPATCH THE CONFIRMER now: subagent({ workflowScript: "return runs.run('confirm-${caseId}-1', { agent: 'confirmer', task: 'Verify the PoC evidence for case ${caseId} (poc_path=${pocPath}, control_target=${controlTarget}, evidence_sha256=${targetRuns[0].evidenceSha256}, poc_sha256=${pocHash}). Assume fabricated, prove real. Re-send the verify request yourself. Return the verdict.' })", context: 'fresh', async: true })\n`) +
1267
+ "Then commit the verdict with ConfirmFinding(case_id, verdict) — CONFIRMED promotes, NOT_CONFIRMED keeps investigating.",
1268
+ },
1269
+ ],
1270
+ details: {
1271
+ record,
1272
+ bundle: {
1273
+ caseId,
1274
+ ranAt: bundle.ranAt,
1275
+ pocPath,
1276
+ controlPath,
1277
+ controlTarget,
1278
+ pocSha256: pocHash,
1279
+ evidenceSha256: targetRuns[0].evidenceSha256,
1280
+ },
1281
+ },
1282
+ };
1283
+ },
1284
+
1285
+ renderCall(args, theme) {
1286
+ return callLine(theme, "PromoteFinding", (args.id as string) ?? "");
1287
+ },
1288
+
1289
+ renderResult(result, _opts, theme) {
1290
+ const details = result.details as { bundle?: { evidenceSha256?: string } } | undefined;
1291
+ if (!details?.bundle) {
1292
+ return new Text(theme.fg("error", "✗ PromoteFinding failed"), 0, 0);
1293
+ }
1294
+ return new Text(
1295
+ theme.fg("success", "✓ ") +
1296
+ theme.fg("dim", "evidence bundle ") +
1297
+ theme.fg("muted", details.bundle.evidenceSha256?.slice(0, 12) ?? ""),
1298
+ 0,
1299
+ 0,
1263
1300
  );
1301
+ },
1302
+ });
1303
+
1304
+ // ── Tool: ConfirmFinding (phase 2) ──
1305
+
1306
+ pi.registerTool({
1307
+ name: "ConfirmFinding",
1308
+ label: "Commit Confirmer Verdict",
1309
+ description:
1310
+ "Phase 2 of confirmation: commit (or refuse) a promotion on the confirmer subagent's verdict. CONFIRMED requires a target-only differential, re_executed: true (the confirmer re-sent the verify request itself), a disconfirmation_attempt (becomes the case's disconfirmation), and the pending bundle from PromoteFinding still valid (nonce-bound evidence, determinism, control differential, PoC script unchanged — checked again at the ledger). NOT_CONFIRMED records the verdict and keeps the case investigating — no tie-breaker.",
1311
+ promptSnippet: "Commit the confirmer verdict — promote or keep investigating",
1312
+ promptGuidelines: [
1313
+ "Run after PromoteFinding + the confirmer dispatch. The verdict comes from the confirmer subagent output, not from the writer.",
1314
+ "CONFIRMED requires differential: 'target_only', re_executed: true, and disconfirmation_attempt (the confirmer's own failed disproof). A verdict missing any of these is rejected.",
1315
+ "NOT_CONFIRMED is final for that attempt — the case stays investigating with the reasoning recorded in assumptions. Re-dispatch a new confirmer if you want a second opinion; every attempt is recorded.",
1316
+ "Never CaseUpdate status='confirmed' directly — it is rejected. Always use PromoteFinding + ConfirmFinding.",
1317
+ ],
1318
+ parameters: ConfirmSchema,
1319
+
1320
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
1321
+ const caseId = params.id as string;
1322
+ const result = applyConfirmationResult(caseId, params.verdict as ConfirmerVerdict);
1264
1323
  const record = result.record;
1324
+ const promoted = record.status === "confirmed";
1265
1325
  return {
1266
1326
  content: [
1267
1327
  {
1268
1328
  type: "text",
1269
- text: `PoC verified (exit ${run.exitCode}). Case promoted to confirmed:\n${formatCaseDetail(record)}`,
1329
+ text: promoted
1330
+ ? `Confirmer CONFIRMED. Case promoted:
1331
+ ${formatCaseDetail(record)}`
1332
+ : `Confirmer NOT_CONFIRMED — case stays investigating (attempt recorded):
1333
+ ${formatCaseDetail(record)}`,
1270
1334
  },
1271
1335
  ],
1272
- details: { record, run },
1336
+ details: { record, promoted, changed: result.changed },
1273
1337
  };
1274
1338
  },
1275
1339
 
1276
1340
  renderCall(args, theme) {
1277
- return callLine(theme, "PromoteFinding", (args.id as string) ?? "");
1341
+ return callLine(theme, "ConfirmFinding", (args.id as string) ?? "");
1278
1342
  },
1279
1343
 
1280
- renderResult(result, _options, theme) {
1281
- const details = result.details as { run?: { exitCode: number } } | undefined;
1282
- const success = details?.run?.exitCode === 0;
1283
- return new Text(renderCaseResult(result, theme, success ? "✓ " : "✗ ", "✗ "), 0, 0);
1344
+ renderResult(result, _opts, theme) {
1345
+ const details = result.details as { promoted?: boolean } | undefined;
1346
+ return new Text(
1347
+ details?.promoted
1348
+ ? theme.fg("success", "✓ Promoted")
1349
+ : theme.fg("warning", "↷ Not confirmed"),
1350
+ 0,
1351
+ 0,
1352
+ );
1284
1353
  },
1285
1354
  });
1286
1355
 
@@ -1660,9 +1729,9 @@ export default function casefileExtension(pi: ExtensionAPI) {
1660
1729
  : result.verdict === "repair"
1661
1730
  ? `REPAIR (attempt ${result.repair_attempt}/2) — fix these and re-submit:\n - ${result.errors.join("\n - ")}`
1662
1731
  : `REJECTED — ${result.errors.join("\n")}`;
1732
+ if (result.verdict !== "accepted") throw new Error(statusLine);
1663
1733
  return {
1664
1734
  content: [{ type: "text", text: statusLine }],
1665
- isError: result.verdict !== "accepted",
1666
1735
  details: result as unknown as Record<string, unknown>,
1667
1736
  };
1668
1737
  },