@xaccefy/pi-casefile 0.8.3 → 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
@@ -12,10 +12,16 @@ import { dirname, join } from "node:path";
12
12
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
13
  import { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
14
14
  import { Type } from "typebox";
15
-
15
+ import {
16
+ 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
 
@@ -161,7 +175,14 @@ const EvidenceAddSchema = Type.Object(
161
175
  { additionalProperties: false },
162
176
  );
163
177
 
164
- // ── 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.
165
186
 
166
187
  const PromoteSchema = Type.Object(
167
188
  {
@@ -169,28 +190,14 @@ const PromoteSchema = Type.Object(
169
190
  poc_path: Type.String({
170
191
  description: "Absolute path to the PoC script on disk",
171
192
  }),
172
- verification_marker: Type.String({
173
- minLength: 1,
174
- description:
175
- "Unique string the PoC must print AFTER verifying the exploit worked (data extracted, callback received, payload reflected). The gate checks output contains this marker — exit code 0 alone is NOT sufficient; the marker prevents fluke exit 0 and mocked PoCs. Example: 'VULN_CONFIRMED_<case-id>'. Never print it unconditionally or before the exploit check.",
176
- }),
177
- disconfirmation_path: Type.String({
178
- description:
179
- "REQUIRED for EVERY promotion: absolute path to a disconfirmation script that tries to disprove the finding; it must complete and exit non-zero (finding survived disproof).",
180
- }),
181
193
  control_path: Type.String({
182
194
  description:
183
- "REQUIRED for EVERY promotion: absolute path to the SAME script as poc_path (sha256-enforced). The harness runs it in control mode against control_target and blocks if verification_marker appears.",
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.",
184
196
  }),
185
197
  control_target: Type.String({
186
198
  minLength: 1,
187
199
  description:
188
- "REQUIRED for EVERY promotion: target passed to the same PoC in control mode. Must be distinct from the case target and lack the vulnerability (patched replica, second account, baseline endpoint).",
189
- }),
190
- control_liveness_marker: Type.String({
191
- minLength: 1,
192
- description:
193
- "REQUIRED for EVERY promotion: a unique string the control script must print AFTER successfully reaching/exercising the control target (e.g. 'CONTROL_REACHED_<case-id>'). The harness blocks promotion if the control output lacks it — a control that never reached its target (unreachable host, wrong port, early exit) is not a clean verdict. Must differ from verification_marker.",
200
+ "REQUIRED: a distinct baseline target that lacks the vulnerability (patched replica, second account, baseline endpoint).",
194
201
  }),
195
202
  local: Type.Optional(
196
203
  Type.Boolean({
@@ -202,6 +209,51 @@ const PromoteSchema = Type.Object(
202
209
  { additionalProperties: false },
203
210
  );
204
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
+
205
257
  // ── Tool: CaseGet ─────────────────────────────────────────────────────
206
258
 
207
259
  /** id-only schema, shared by CaseGet / CaseContext. */
@@ -604,6 +656,19 @@ function buildCaseListContext(records: CaseRecord[]): string {
604
656
  return lines.join("\n");
605
657
  }
606
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
+
607
672
  /**
608
673
  * Builds the per-prompt injection. The cyber workflow is session-scope data —
609
674
  * it never changes — so the caller passes includeWorkflow=true exactly once
@@ -611,7 +676,8 @@ function buildCaseListContext(records: CaseRecord[]): string {
611
676
  * case list DOES change as cases are added, so it is refreshed every prompt.
612
677
  *
613
678
  * mode selects the workflow text: "lite" injects the single-agent workflow
614
- * (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).
615
681
  */
616
682
  function buildAgentInjection(
617
683
  active: CaseRecord[],
@@ -620,7 +686,12 @@ function buildAgentInjection(
620
686
  ): string {
621
687
  const caseList = buildCaseListContext(active);
622
688
  if (!includeWorkflow) return caseList;
623
- 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;
624
695
  // Workflow FIRST for prominence, then case list as reference data.
625
696
  return caseList ? `${workflow}\n\n${caseList}` : workflow;
626
697
  }
@@ -681,6 +752,15 @@ export function parseXpModeArg(args: string, current: XpMode): XpMode {
681
752
  // ── Main extension ────────────────────────────────────────────────────
682
753
 
683
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
+
684
764
  // ── Diagnostic Error Handler Middleware ──
685
765
  const originalRegisterTool = pi.registerTool.bind(pi);
686
766
  pi.registerTool = (spec: any) => {
@@ -887,13 +967,15 @@ export default function casefileExtension(pi: ExtensionAPI) {
887
967
  description:
888
968
  "The attack class tested (e.g. sql-injection, xss, idor, ssti, ssrf, auth-bypass, ...).",
889
969
  }),
890
- scope: Type.Union(
891
- COVERAGE_SCOPE_VALUES.map((s) => Type.Literal(s)),
892
- {
893
- description:
894
- "'wide' if the verdict applies to the whole deployment/account/host (recorded ONCE, applies to every asset of the deployment — do NOT re-test it per asset); 'local' if specific to this one asset.",
895
- },
896
- ),
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
+ }),
897
979
  note: Type.String({
898
980
  description:
899
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.",
@@ -1022,70 +1104,47 @@ export default function casefileExtension(pi: ExtensionAPI) {
1022
1104
  });
1023
1105
 
1024
1106
  // ── Tool: PromoteFinding ──
1107
+ // ── Tool: PromoteFinding (phase 1) ──
1025
1108
 
1026
1109
  pi.registerTool({
1027
1110
  name: "PromoteFinding",
1028
- label: "Promote Finding",
1111
+ label: "Run PoC Evidence",
1029
1112
  description:
1030
- "Run an on-disk PoC script (Docker sandbox or host-network sandbox) and, on exit 0 + verification marker present in output, promote an investigating case to confirmed. The verification_marker proves the exploit worked exit code 0 alone is NOT sufficient. EVERY promotion REQUIRES disconfirmation_path, same-script control_path, distinct control_target, and control_liveness_marker. The harness blocks promotion if output capture is incomplete, if the control prints the vuln marker, if liveness is absent, or if control/disconfirmation crash. Host execution is never agent-selectable — local:true uses a host-network Docker sandbox; true host runs need PI_POC_ALLOW_LOCAL=1.",
1031
- promptSnippet: "Run a PoC and promote an investigating case to confirmed",
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",
1032
1115
  promptGuidelines: [
1033
1116
  "Use PromoteFinding when an investigating case has a concrete PoC script on disk and you are ready to prove it.",
1034
- "Prerequisites: status='investigating' and non-empty poc, evidence, impact, severity, target, disconfirmation, plus an artifact-backed EvidenceAdd 'observation' item on the case (the initial signal, with artifact_path) the PoC gate auto-records the reproduction item.",
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).",
1035
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).",
1036
- "Gate: exit 0 AND verification_marker in the PoC output. The marker (e.g. 'VULN_CONFIRMED_<case-id>') must be printed only AFTER the exploit is verified (data extracted, callback received, payload reflected) never unconditionally or before the exploit check. The marker check prevents fluke exit 0 (script crashed early) and mocked PoCs (target faked) from passing.",
1037
- "control_path (REQUIRED): the SAME script as poc_path (sha256-equality is ENFORCED). The harness runs it with PI_POC_MODE=control and PI_POC_TARGET=control_target. The control must not print the verification marker and must print the liveness marker.",
1038
- "control_target + control_liveness_marker (REQUIRED): control_target is the distinct baseline target. The liveness marker is printed only AFTER reaching/exercising it; absent liveness blocks promotion.",
1039
- "disconfirmation_path: a script that tries to disprove the finding; if it exits 0, promotion is blocked. REQUIRED for EVERY promotion — the prose disconfirmation field is not enough at any severity (a case filed low/medium must not skip the run and be re-raised afterwards).",
1040
- "Never CaseUpdate status='confirmed' directly — it is rejected. Always use PromoteFinding.",
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.",
1041
1122
  ],
1042
1123
  parameters: PromoteSchema,
1043
1124
 
1044
1125
  async execute(_id, params, _signal, _onUpdate, _ctx) {
1045
- // Validate promotability BEFORE running the PoC — a sandboxed run can take
1046
- // 30s (plus first-time image pull), so fail cheap when the case can't
1047
- // advance anyway (missing, wrong status, missing required fields, missing
1048
- // artifact-backed observation evidence).
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).
1049
1130
  const caseId = params.id as string;
1050
1131
  const current = assertPromotable(caseId);
1051
1132
 
1052
- // Shared blocked-promotion shape: the case stays investigating and the
1053
- // caller gets the record back for context.
1054
1133
  const fail = (text: string, _extra?: Record<string, unknown>): never => {
1055
1134
  throw new Error(text);
1056
1135
  };
1057
1136
 
1058
- // Reject empty/whitespace markers BEFORE any PoC run it's a param
1059
- // error, so fail cheap instead of burning a (up to 30s) sandboxed run.
1060
- const marker = (params.verification_marker as string | undefined)?.trim();
1061
- if (!marker) {
1062
- return fail(
1063
- "verification_marker is empty or whitespace. " +
1064
- "A non-empty marker printed only AFTER the PoC confirms exploitation is required — " +
1065
- "exit code 0 alone is not sufficient. Case remains investigating.",
1066
- );
1067
- }
1068
-
1069
- // Control-target anti-cheat is mandatory for EVERY promotion — sandboxed
1070
- // and live alike. It is the only deterministic check that the marker is
1071
- // target-dependent; skipping it for the default sandboxed mode would let
1072
- // an unconditional-marker PoC pass untouched. Check BEFORE paying for the
1073
- // PoC run.
1074
- const controlPath = (params.control_path as string | undefined)?.trim();
1137
+ const controlPath = (params.control_path as string | undefined)?.trim() ?? "";
1138
+ const controlTarget = (params.control_target as string | undefined)?.trim() ?? "";
1075
1139
  if (!controlPath) {
1076
1140
  return fail(
1077
- "control_path is REQUIRED for every promotion (sandboxed and live alike): a script that runs " +
1078
- "the SAME PoC against a control lacking the vuln (patched replica, second account, baseline endpoint). " +
1079
- "The harness verifies the verification_marker is absent from the control run's output — that is what " +
1080
- "proves the marker is target-dependent. Write the control script and retry.",
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.",
1081
1142
  { missingControl: true },
1082
1143
  );
1083
1144
  }
1084
-
1085
- const controlTarget = (params.control_target as string | undefined)?.trim();
1086
1145
  if (!controlTarget) {
1087
1146
  return fail(
1088
- "control_target is REQUIRED for every promotion: a distinct baseline target that lacks the vulnerability.",
1147
+ "control_target is REQUIRED: a distinct baseline target that lacks the vulnerability.",
1089
1148
  { missingControlTarget: true },
1090
1149
  );
1091
1150
  }
@@ -1096,48 +1155,8 @@ export default function casefileExtension(pi: ExtensionAPI) {
1096
1155
  );
1097
1156
  }
1098
1157
 
1099
- // The control must ALSO prove it reached its target: without a liveness
1100
- // marker, a control pointed at an unreachable host / wrong port / early
1101
- // exit would show "marker absent" for reasons unrelated to the vuln.
1102
- const livenessMarker = (params.control_liveness_marker as string | undefined)?.trim();
1103
- if (!livenessMarker) {
1104
- return fail(
1105
- "control_liveness_marker is REQUIRED for every promotion: a unique string the control script " +
1106
- "prints ONLY AFTER reaching/exercising the control target (e.g. 'CONTROL_REACHED_<case-id>'). " +
1107
- "The harness blocks promotion if the control output lacks it — a control that never reached its " +
1108
- "target proves nothing. Must differ from verification_marker.",
1109
- { missingLivenessMarker: true },
1110
- );
1111
- }
1112
- if (livenessMarker === marker) {
1113
- return fail(
1114
- "control_liveness_marker must differ from verification_marker — the liveness marker proves the " +
1115
- "control reached its target, the verification marker proves the vuln fired. Use distinct strings.",
1116
- { livenessEqualsMarker: true },
1117
- );
1118
- }
1119
-
1120
- // EXECUTED disconfirmation is required for EVERY promotion — the prose
1121
- // `disconfirmation` field cannot carry the disprove-attempt. Making it
1122
- // unconditional (not severity-keyed) also kills the ordering attack:
1123
- // a case filed as low/medium could previously skip the run, promote,
1124
- // then be re-raised to high/critical with no executed disproof.
1125
- const disconfirmationPath = (params.disconfirmation_path as string | undefined)?.trim();
1126
- if (!disconfirmationPath) {
1127
- return fail(
1128
- "disconfirmation_path is REQUIRED for every promotion: a script that tries to " +
1129
- "disprove the finding and must exit non-zero (the finding survived the attempt). " +
1130
- "The prose disconfirmation field is not enough. Write the disconfirmation script and retry.",
1131
- { missingDisconfirmation: true },
1132
- );
1133
- }
1134
-
1135
- // Same-file contract (anti-cheat): the control must be the SAME script as
1136
- // the PoC, differing only via the harness-set PI_POC_MODE env var. Two
1137
- // agent-written files (poc prints the marker unconditionally, control
1138
- // prints the liveness marker) pass every string check — the "same script"
1139
- // recommendation in the docs is now enforced: sha256(control) must equal
1140
- // sha256(poc). Hash BEFORE any run so a mismatch fails cheap.
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.
1141
1160
  const pocPath = (params.poc_path as string | undefined)?.trim() ?? "";
1142
1161
  let pocHash: string | undefined;
1143
1162
  let controlHash: string | undefined;
@@ -1152,19 +1171,11 @@ export default function casefileExtension(pi: ExtensionAPI) {
1152
1171
  }
1153
1172
  if (pocHash !== controlHash) {
1154
1173
  return fail(
1155
- "CONTROL CHECK FAILED: control_path must be the SAME script as poc_path " +
1156
- "(sha256 mismatch). The control run is only meaningful as the same PoC " +
1157
- "pointed at a distinct control_target via PI_POC_MODE=control and PI_POC_TARGET. " +
1158
- "A separately written control file proves nothing. Case remains investigating.",
1159
- { run: undefined, controlHashMismatch: true },
1174
+ "CONTROL CHECK FAILED: control_path must be the SAME script as poc_path (sha256 mismatch). Case remains investigating.",
1175
+ { controlHashMismatch: true },
1160
1176
  );
1161
1177
  }
1162
1178
 
1163
- // local:true now means "network access": with the operator's
1164
- // PI_POC_ALLOW_LOCAL=1 opt-in the run executes on the host; WITHOUT the
1165
- // opt-in it uses a Docker sandbox with --network host (same FS/cap/user
1166
- // isolation) and fails closed when Docker is unavailable too. Host
1167
- // execution is never agent-selectable on its own.
1168
1179
  const runOptions = (pocMode: string, target: string): PocRunOptions => ({
1169
1180
  network: params.local === true ? "host" : "none",
1170
1181
  local: params.local === true,
@@ -1172,125 +1183,173 @@ export default function casefileExtension(pi: ExtensionAPI) {
1172
1183
  });
1173
1184
 
1174
1185
  const caseTarget = current.target ?? "";
1175
- const run = runPoc(pocPath, runOptions("poc", caseTarget));
1176
-
1177
- // Fail closed without throwing: non-zero PoC must leave the case investigating.
1178
- if (run.exitCode !== 0) {
1179
- return fail(
1180
- `PoC failed (exit ${run.exitCode}). Case remains investigating.\nOutput:\n${run.output}`,
1181
- { run },
1182
- );
1183
- }
1184
-
1185
- // Defense-in-depth: exit 0 implies the run completed (sandbox wrapper /
1186
- // local spawn semantics), but never trust a run the runner says crashed.
1187
- if (!run.completed || !run.outputComplete) {
1188
- return fail(
1189
- `PoC did NOT complete or output capture was incomplete. Case remains investigating.\nOutput:\n${run.output}`,
1190
- { run, pocCrashed: true },
1191
- );
1192
- }
1193
-
1194
- // Verification marker check: exit code 0 alone is NOT sufficient.
1195
- // The PoC must print the verification_marker, proving the exploit
1196
- // actually worked — not just that the script ran. The check runs on
1197
- // rawOutput (untruncated) so a script printing its marker past the
1198
- // 4000-char display window cannot hide it.
1199
- const pocOut = run.rawOutput ?? run.output;
1200
- if (!pocOut.includes(marker)) {
1201
- return fail(
1202
- `PoC exited 0 but the verification marker "${marker}" was NOT found in the output.\n` +
1203
- `This means the script ran but did not prove exploitation. The marker must be printed only AFTER the PoC verifies the exploit worked (data extracted, callback received, payload reflected, etc.).\n` +
1204
- `Do not print the marker unconditionally — print it only when the exploit is confirmed.\n\nOutput:\n${run.output}`,
1205
- { run, markerMissing: true },
1206
- );
1207
- }
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));
1208
1192
 
1209
- // Run disconfirmation script must exit NON-0 (finding survived the attempt to disprove).
1210
- let disconfirmationRun: PocRun | undefined;
1211
- if (disconfirmationPath) {
1212
- disconfirmationRun = runPoc(disconfirmationPath, runOptions("disconfirmation", caseTarget));
1213
- if (!disconfirmationRun.completed || !disconfirmationRun.outputComplete) {
1193
+ const evidenceRun = (r: PocRun, mode: "poc" | "control", target: string): PocEvidenceRun => {
1194
+ if (!r.completed || !r.outputComplete) {
1214
1195
  return fail(
1215
- `Disconfirmation script did NOT complete (spawn error, killed, or timeout no completion marker). ` +
1216
- `A crash is not a survived disproof: fix the disconfirmation script and retry.\n` +
1217
- `Output:\n${disconfirmationRun.output}`,
1218
- { run, disconfirmationRun, disconfirmationCrashed: true },
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 },
1219
1200
  );
1220
1201
  }
1221
- if (disconfirmationRun.exitCode === 0) {
1202
+ if (r.evidenceError) {
1222
1203
  return fail(
1223
- `Disconfirmation script exited 0 (finding was disproven). ` +
1224
- `Case remains investigating.\nOutput:\n${disconfirmationRun.output}`,
1225
- { 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 },
1226
1208
  );
1227
1209
  }
1228
- }
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
+ };
1229
1228
 
1230
- // Control-target anti-cheat check: the same PoC pointed at a control that
1231
- // lacks the vuln must NOT print the marker. The control script is
1232
- // agent-written, but the marker-absence check is harness-side and
1233
- // deterministic — the model cannot pass it by asserting success. The
1234
- // liveness-marker check closes the "control pointed at an unreachable
1235
- // host / exited early" hole: the control must prove it reached its target.
1236
- const controlRun = runPoc(controlPath, runOptions("control", controlTarget));
1237
- if (!controlRun.completed || !controlRun.outputComplete) {
1238
- return fail(
1239
- `CONTROL CHECK FAILED: the control-target script did NOT complete (spawn error, killed, or timeout). ` +
1240
- `A control run that never executed proves nothing about the marker — fix the control script and retry.\n` +
1241
- `Control output:\n${controlRun.output}`,
1242
- { run, controlRun, controlCrashed: true },
1243
- );
1244
- }
1245
- // Marker-absence + liveness checks run on the UNTRUNCATED control output.
1246
- const controlOut = controlRun.rawOutput ?? controlRun.output;
1247
- if (controlOut.includes(marker)) {
1248
- return fail(
1249
- `CONTROL CHECK FAILED: the verification marker "${marker}" appeared in the control-target run. ` +
1250
- `The PoC prints the marker without the vulnerable condition — a cheating PoC (unconditional marker) ` +
1251
- `or a broken check. Case remains investigating.\nControl output:\n${controlRun.output}`,
1252
- { run, controlRun, controlCheated: true },
1253
- );
1254
- }
1255
- if (!controlOut.includes(livenessMarker)) {
1256
- return fail(
1257
- `CONTROL CHECK FAILED: the control-target run completed but the control_liveness_marker "${livenessMarker}" ` +
1258
- `was NOT found in its output. The control must print the liveness marker only AFTER reaching/exercising ` +
1259
- `the control target — an unreachable host, wrong port, or early exit is not a valid control verdict. ` +
1260
- `Case remains investigating.\nControl output:\n${controlRun.output}`,
1261
- { run, controlRun, controlLivenessMissing: true },
1262
- );
1263
- }
1229
+ const targetRuns: [PocEvidenceRun, PocEvidenceRun] = [
1230
+ evidenceRun(run1, "poc", caseTarget),
1231
+ evidenceRun(run2, "poc", caseTarget),
1232
+ ];
1233
+ const control = evidenceRun(controlRun, "control", controlTarget);
1264
1234
 
1265
- // PocRun is structurally a PocVerification — pass the runs straight through.
1266
- const result = promoteFindingResult(
1235
+ const bundle: PendingConfirmation = {
1267
1236
  caseId,
1268
- run,
1269
- disconfirmationRun,
1270
- controlRun,
1271
- marker,
1272
- 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,
1273
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);
1274
1323
  const record = result.record;
1324
+ const promoted = record.status === "confirmed";
1275
1325
  return {
1276
1326
  content: [
1277
1327
  {
1278
1328
  type: "text",
1279
- 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)}`,
1280
1334
  },
1281
1335
  ],
1282
- details: { record, run },
1336
+ details: { record, promoted, changed: result.changed },
1283
1337
  };
1284
1338
  },
1285
1339
 
1286
1340
  renderCall(args, theme) {
1287
- return callLine(theme, "PromoteFinding", (args.id as string) ?? "");
1341
+ return callLine(theme, "ConfirmFinding", (args.id as string) ?? "");
1288
1342
  },
1289
1343
 
1290
- renderResult(result, _options, theme) {
1291
- const details = result.details as { run?: { exitCode: number } } | undefined;
1292
- const success = details?.run?.exitCode === 0;
1293
- return new Text(renderCaseResult(result, theme, success ? "✓ " : "✗ ", "✗ "), 0, 0);
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
+ );
1294
1353
  },
1295
1354
  });
1296
1355