@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/README.md +4 -3
- package/package.json +1 -1
- package/skills/casefile/SKILL.md +4 -3
- package/src/index.ts +274 -215
- package/src/ledger.ts +393 -205
- package/src/pipeline-submit.ts +68 -13
- package/src/poc-runner.ts +167 -63
- package/src/scratchpad.ts +28 -4
- package/src/workflow.ts +79 -16
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 {
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
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: "
|
|
1111
|
+
label: "Run PoC Evidence",
|
|
1029
1112
|
description:
|
|
1030
|
-
"
|
|
1031
|
-
promptSnippet: "
|
|
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,
|
|
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
|
-
"
|
|
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 —
|
|
1046
|
-
// 30s (plus first-time image pull), so fail cheap when the case
|
|
1047
|
-
// advance anyway (missing, wrong status, missing required fields,
|
|
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
|
-
|
|
1059
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
1100
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1176
|
-
|
|
1177
|
-
//
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
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
|
-
|
|
1210
|
-
|
|
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
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
{ run
|
|
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 (
|
|
1202
|
+
if (r.evidenceError) {
|
|
1222
1203
|
return fail(
|
|
1223
|
-
`
|
|
1224
|
-
|
|
1225
|
-
|
|
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
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
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
|
-
|
|
1266
|
-
const result = promoteFindingResult(
|
|
1235
|
+
const bundle: PendingConfirmation = {
|
|
1267
1236
|
caseId,
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
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:
|
|
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,
|
|
1336
|
+
details: { record, promoted, changed: result.changed },
|
|
1283
1337
|
};
|
|
1284
1338
|
},
|
|
1285
1339
|
|
|
1286
1340
|
renderCall(args, theme) {
|
|
1287
|
-
return callLine(theme, "
|
|
1341
|
+
return callLine(theme, "ConfirmFinding", (args.id as string) ?? "");
|
|
1288
1342
|
},
|
|
1289
1343
|
|
|
1290
|
-
renderResult(result,
|
|
1291
|
-
const details = result.details as {
|
|
1292
|
-
|
|
1293
|
-
|
|
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
|
|