@xaccefy/pi-casefile 0.9.1 → 0.9.3
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/LICENSE +1 -1
- package/README.md +12 -8
- package/package.json +2 -2
- package/skills/casefile/SKILL.md +3 -3
- package/src/evidence.ts +22 -0
- package/src/harness-verify.ts +139 -34
- package/src/index.ts +401 -309
- package/src/ledger.ts +152 -35
- package/src/pipeline-submit.ts +34 -42
- package/src/scratchpad.ts +18 -5
- package/src/workflow.ts +52 -38
package/src/index.ts
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
import { createHash } from "node:crypto";
|
|
10
10
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { dirname, join } from "node:path";
|
|
12
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
14
|
-
import { Type } from "typebox";
|
|
14
|
+
import { type TSchema, Type } from "typebox";
|
|
15
15
|
import {
|
|
16
16
|
CANARY_ASSESSMENT_VALUES,
|
|
17
17
|
CONFIRM_DIFFERENTIAL_VALUES,
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
controlTargetAuthorizationError,
|
|
24
24
|
type HarnessVerifyResult,
|
|
25
25
|
replayDifferential,
|
|
26
|
+
replayIntraTarget,
|
|
26
27
|
} from "./harness-verify.ts";
|
|
27
28
|
import {
|
|
28
29
|
addCaseResult,
|
|
@@ -73,7 +74,7 @@ import { pipeline_submit, SUBMIT_STAGES, type SubmitStage } from "./pipeline-sub
|
|
|
73
74
|
import { type PocRun, type PocRunOptions, runPoc } from "./poc-runner.ts";
|
|
74
75
|
import {
|
|
75
76
|
detectWorkspaceRoot,
|
|
76
|
-
|
|
77
|
+
SCRATCHPAD_PHASES,
|
|
77
78
|
type ScratchpadPhase,
|
|
78
79
|
type ScratchpadResume,
|
|
79
80
|
scratchpad_checkpoint,
|
|
@@ -182,12 +183,13 @@ const EvidenceAddSchema = Type.Object(
|
|
|
182
183
|
|
|
183
184
|
// ── Tool: PromoteFinding (phase 1) / ConfirmFinding (phase 2) ──────────
|
|
184
185
|
//
|
|
185
|
-
// Confirmation is TWO-PHASE
|
|
186
|
-
//
|
|
187
|
-
// bundle
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
186
|
+
// Confirmation is TWO-PHASE and main-agent-owned: PromoteFinding runs the PoC
|
|
187
|
+
// 2x + control, validates nonce-bound evidence.json, and records the pending
|
|
188
|
+
// bundle; ConfirmFinding then performs the main coordinator's review/replay and
|
|
189
|
+
// commits or refuses the verdict. Subagents may gather or challenge evidence,
|
|
190
|
+
// but they cannot run validation or confirmation gates. Zero exit is necessary
|
|
191
|
+
// run integrity and markers are diagnostic only; the machine records
|
|
192
|
+
// predicate/canary differentials and the main agent owns the semantic judgment.
|
|
191
193
|
|
|
192
194
|
const PromoteSchema = Type.Object(
|
|
193
195
|
{
|
|
@@ -195,15 +197,26 @@ const PromoteSchema = Type.Object(
|
|
|
195
197
|
poc_path: Type.String({
|
|
196
198
|
description: "Absolute path to the PoC script on disk",
|
|
197
199
|
}),
|
|
198
|
-
control_path: Type.
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
200
|
+
control_path: Type.Optional(
|
|
201
|
+
Type.String({
|
|
202
|
+
description:
|
|
203
|
+
"Optional absolute path to the SAME script as poc_path (sha256-equality is ENFORCED). Defaults to poc_path. The harness runs it with PI_POC_MODE=control and PI_POC_TARGET=control_target.",
|
|
204
|
+
}),
|
|
205
|
+
),
|
|
206
|
+
mode: Type.Optional(
|
|
207
|
+
Type.String({
|
|
208
|
+
enum: ["inter_host", "intra_target"],
|
|
209
|
+
description:
|
|
210
|
+
"Differential shape. 'inter_host' (default) proves target-dependence with a distinct patched control host — for body-carried proof (file read, injection exfil, info leak, reflection). 'intra_target' proves it with a legitimate same-host baseline request declared in the evidence — for access-control / business-logic classes (IDOR, auth bypass, privilege escalation, logic flaws) where the discriminating variable is identity or a parameter, not the host. In intra_target the evidence must set verify.mode='intra_target' and include a baseline; control_target/control_path are not used.",
|
|
211
|
+
}),
|
|
212
|
+
),
|
|
213
|
+
control_target: Type.Optional(
|
|
214
|
+
Type.String({
|
|
215
|
+
minLength: 1,
|
|
216
|
+
description:
|
|
217
|
+
"REQUIRED for mode='inter_host': a distinct baseline target that lacks the vulnerability and is operator-approved through PI_POC_CONTROL_TARGETS (patched replica, second account, baseline service). Not used for mode='intra_target'.",
|
|
218
|
+
}),
|
|
219
|
+
),
|
|
207
220
|
local: Type.Optional(
|
|
208
221
|
Type.Boolean({
|
|
209
222
|
description:
|
|
@@ -358,9 +371,9 @@ const UnlinkSchema = Type.Object(
|
|
|
358
371
|
// artifacts; it does not re-run completed phases (idempotent).
|
|
359
372
|
|
|
360
373
|
const ScratchpadPhaseSchema = Type.String({
|
|
361
|
-
enum: [...
|
|
374
|
+
enum: [...SCRATCHPAD_PHASES],
|
|
362
375
|
description:
|
|
363
|
-
"Pipeline phase: recon | hunt |
|
|
376
|
+
"Pipeline phase: recon | hunt | trace | skeptic | validate | chain | patch | report (legacy gapfil is accepted for older runs)",
|
|
364
377
|
});
|
|
365
378
|
|
|
366
379
|
/** run_id-only schema, shared by Scratchpad Init / Resume / Clear. */
|
|
@@ -699,13 +712,13 @@ export function detectHost(): "omp" | "pi" {
|
|
|
699
712
|
* case list DOES change as cases are added, so it is refreshed every prompt.
|
|
700
713
|
*
|
|
701
714
|
* mode selects the workflow text: "lite" injects the single-agent workflow
|
|
702
|
-
* (no subagent dispatch),
|
|
715
|
+
* (no subagent dispatch), "swarm" gets the full subagent pipeline,
|
|
703
716
|
* rendered for the host's dispatch convention (pi-subagents vs OMP task).
|
|
704
717
|
*/
|
|
705
718
|
function buildAgentInjection(
|
|
706
719
|
active: CaseRecord[],
|
|
707
720
|
includeWorkflow: boolean,
|
|
708
|
-
mode: XpMode = "
|
|
721
|
+
mode: XpMode = "swarm",
|
|
709
722
|
): string {
|
|
710
723
|
const caseList = buildCaseListContext(active);
|
|
711
724
|
if (!includeWorkflow) return caseList;
|
|
@@ -722,13 +735,15 @@ function buildAgentInjection(
|
|
|
722
735
|
// ── XP (offensive / exploit) mode toggle ─────────────────────────────
|
|
723
736
|
// Casefile historically injected the cyber workflow into every prompt.
|
|
724
737
|
// For normal dev work that is just noise, so XP mode defaults OFF. Enable
|
|
725
|
-
//
|
|
726
|
-
//
|
|
727
|
-
// /xp
|
|
738
|
+
// swarm for the bounded multi-agent variant, or lite for the single-agent
|
|
739
|
+
// attacker discipline. Toggle with /xp (off <-> swarm), or set explicitly with
|
|
740
|
+
// /xp on|lite|swarm|off. "on" means the default enabled SWARM mode; use "lite"
|
|
741
|
+
// for no subagent dispatch.
|
|
742
|
+
// Override per-session with PI_XP_MODE.
|
|
728
743
|
// Pure helpers exported for unit tests.
|
|
729
744
|
|
|
730
745
|
export const XP_MODE_ENV = "PI_XP_MODE";
|
|
731
|
-
export type XpMode = "
|
|
746
|
+
export type XpMode = "swarm" | "off" | "lite";
|
|
732
747
|
|
|
733
748
|
export function getXpModeStatePath(): string {
|
|
734
749
|
return join(dirname(getCasefilePath()), "xp-mode");
|
|
@@ -739,13 +754,14 @@ export function readXpMode(
|
|
|
739
754
|
statePath: string = getXpModeStatePath(),
|
|
740
755
|
): XpMode {
|
|
741
756
|
const env = (envValue ?? "").trim().toLowerCase();
|
|
742
|
-
if (env === "
|
|
757
|
+
if (env === "swarm") return "swarm";
|
|
758
|
+
if (env === "on" || env === "1" || env === "true") return "swarm";
|
|
743
759
|
if (env === "lite") return "lite";
|
|
744
760
|
if (env === "off" || env === "0" || env === "false") return "off";
|
|
745
761
|
try {
|
|
746
762
|
if (existsSync(statePath)) {
|
|
747
763
|
const v = readFileSync(statePath, "utf8").trim().toLowerCase();
|
|
748
|
-
if (v === "on") return "
|
|
764
|
+
if (v === "swarm" || v === "on") return "swarm";
|
|
749
765
|
if (v === "lite") return "lite";
|
|
750
766
|
if (v === "off") return "off";
|
|
751
767
|
}
|
|
@@ -765,11 +781,12 @@ export function writeXpMode(state: XpMode, statePath: string = getXpModeStatePat
|
|
|
765
781
|
|
|
766
782
|
export function parseXpModeArg(args: string, current: XpMode): XpMode {
|
|
767
783
|
const arg = (args ?? "").trim().toLowerCase();
|
|
768
|
-
if (arg === "
|
|
784
|
+
if (arg === "swarm") return "swarm";
|
|
785
|
+
if (arg === "on") return "swarm";
|
|
769
786
|
if (arg === "off") return "off";
|
|
770
787
|
if (arg === "lite") return "lite";
|
|
771
|
-
// Bare /xp
|
|
772
|
-
return current === "
|
|
788
|
+
// Bare /xp is the low-ceremony path: toggle the default XP workflow on/off.
|
|
789
|
+
return current === "off" ? "swarm" : "off";
|
|
773
790
|
}
|
|
774
791
|
|
|
775
792
|
// ── Main extension ────────────────────────────────────────────────────
|
|
@@ -789,34 +806,37 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
789
806
|
setScratchpadRoot(workspaceRoot);
|
|
790
807
|
process.env.PI_POC_ROOT ??= workspaceRoot;
|
|
791
808
|
|
|
792
|
-
// ── Diagnostic Error Handler
|
|
793
|
-
const
|
|
794
|
-
|
|
809
|
+
// ── Diagnostic Error Handler ──
|
|
810
|
+
const registerCaseTool = <TParams extends TSchema, TDetails = unknown, TState = unknown>(
|
|
811
|
+
spec: ToolDefinition<TParams, TDetails, TState>,
|
|
812
|
+
) => {
|
|
795
813
|
const origExecute = spec.execute;
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
814
|
+
pi.registerTool({
|
|
815
|
+
...spec,
|
|
816
|
+
execute: async (...args: Parameters<typeof origExecute>) => {
|
|
817
|
+
try {
|
|
818
|
+
return await origExecute(...args);
|
|
819
|
+
} catch (err) {
|
|
820
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
821
|
+
let hint = "";
|
|
822
|
+
if (
|
|
823
|
+
message.includes("SQLITE") ||
|
|
824
|
+
message.includes("database") ||
|
|
825
|
+
message.includes("permission") ||
|
|
826
|
+
message.includes("readonly") ||
|
|
827
|
+
message.includes("lock")
|
|
828
|
+
) {
|
|
829
|
+
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`;
|
|
830
|
+
}
|
|
831
|
+
throw new Error(`${spec.name} failed: ${message}${hint}`, { cause: err });
|
|
810
832
|
}
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
};
|
|
814
|
-
originalRegisterTool(spec);
|
|
833
|
+
},
|
|
834
|
+
});
|
|
815
835
|
};
|
|
816
836
|
|
|
817
837
|
// ── Tool: CaseAdd ──
|
|
818
838
|
|
|
819
|
-
|
|
839
|
+
registerCaseTool({
|
|
820
840
|
name: "CaseAdd",
|
|
821
841
|
label: "Add Case",
|
|
822
842
|
description:
|
|
@@ -870,7 +890,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
870
890
|
|
|
871
891
|
// ── Tool: CaseUpdate ──
|
|
872
892
|
|
|
873
|
-
|
|
893
|
+
registerCaseTool({
|
|
874
894
|
name: "CaseUpdate",
|
|
875
895
|
label: "Update Case",
|
|
876
896
|
description:
|
|
@@ -925,7 +945,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
925
945
|
|
|
926
946
|
// ── Tool: EvidenceAdd ──
|
|
927
947
|
|
|
928
|
-
|
|
948
|
+
registerCaseTool({
|
|
929
949
|
name: "EvidenceAdd",
|
|
930
950
|
label: "Add Evidence Item",
|
|
931
951
|
description:
|
|
@@ -945,7 +965,8 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
945
965
|
summary: params.summary as string,
|
|
946
966
|
artifactPath: params.artifact_path as string | undefined,
|
|
947
967
|
});
|
|
948
|
-
const record = getCaseById(params.case_id as string)
|
|
968
|
+
const record = getCaseById(params.case_id as string);
|
|
969
|
+
if (!record) throw new Error(`Case not found after evidence insert: ${params.case_id}`);
|
|
949
970
|
return {
|
|
950
971
|
content: [
|
|
951
972
|
{
|
|
@@ -1018,7 +1039,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1018
1039
|
{ additionalProperties: false },
|
|
1019
1040
|
);
|
|
1020
1041
|
|
|
1021
|
-
|
|
1042
|
+
registerCaseTool({
|
|
1022
1043
|
name: "CoverageAdd",
|
|
1023
1044
|
label: "Record Coverage",
|
|
1024
1045
|
description:
|
|
@@ -1040,7 +1061,8 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1040
1061
|
note: params.note as string,
|
|
1041
1062
|
evidenceItemId: params.evidence_item_id as string | undefined,
|
|
1042
1063
|
});
|
|
1043
|
-
const record = getCaseById(params.case_id as string)
|
|
1064
|
+
const record = getCaseById(params.case_id as string);
|
|
1065
|
+
if (!record) throw new Error(`Case not found after coverage insert: ${params.case_id}`);
|
|
1044
1066
|
return {
|
|
1045
1067
|
content: [
|
|
1046
1068
|
{
|
|
@@ -1084,11 +1106,11 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1084
1106
|
{ additionalProperties: false },
|
|
1085
1107
|
);
|
|
1086
1108
|
|
|
1087
|
-
|
|
1109
|
+
registerCaseTool({
|
|
1088
1110
|
name: "CoverageReport",
|
|
1089
1111
|
label: "Coverage Matrix",
|
|
1090
1112
|
description:
|
|
1091
|
-
"Render the machine-checkable coverage matrix for a case: which (asset × attack-class) cells are tested, with wide-verdict propagation. Run before deciding
|
|
1113
|
+
"Render the machine-checkable coverage matrix for a case: which (asset × attack-class) cells are tested, with wide-verdict propagation. Run before deciding HUNT coverage is done — the plateau stop (zero new classes testable) must be visible in the matrix, not asserted in prose.",
|
|
1092
1114
|
promptSnippet: "Show which attack classes were tested where",
|
|
1093
1115
|
promptGuidelines: [
|
|
1094
1116
|
"Run CoverageReport before claiming 'every class is COVERED/SKIPPED/NOT_FOUND' — the claim must match the matrix.",
|
|
@@ -1133,239 +1155,294 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1133
1155
|
|
|
1134
1156
|
// ── Tool: PromoteFinding (phase 1) ──
|
|
1135
1157
|
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
// Validate promotability BEFORE running the PoC — each sandboxed run can
|
|
1156
|
-
// take 30s (plus first-time image pull), so fail cheap when the case
|
|
1157
|
-
// can't advance anyway (missing, wrong status, missing required fields,
|
|
1158
|
-
// missing artifact-backed observation evidence).
|
|
1159
|
-
const caseId = params.id as string;
|
|
1160
|
-
const current = assertPromotable(caseId);
|
|
1161
|
-
|
|
1162
|
-
const fail = (text: string, _extra?: Record<string, unknown>): never => {
|
|
1163
|
-
throw new Error(text);
|
|
1164
|
-
};
|
|
1165
|
-
|
|
1166
|
-
const controlPath = (params.control_path as string | undefined)?.trim() ?? "";
|
|
1167
|
-
const controlTarget = (params.control_target as string | undefined)?.trim() ?? "";
|
|
1168
|
-
if (!controlPath) {
|
|
1169
|
-
return fail(
|
|
1170
|
-
"control_path is REQUIRED: the SAME script as poc_path (sha256-equality is ENFORCED), run by the harness with PI_POC_MODE=control and PI_POC_TARGET=control_target.",
|
|
1171
|
-
{ missingControl: true },
|
|
1172
|
-
);
|
|
1173
|
-
}
|
|
1174
|
-
if (!controlTarget) {
|
|
1175
|
-
return fail(
|
|
1176
|
-
"control_target is REQUIRED: a distinct baseline target that lacks the vulnerability.",
|
|
1177
|
-
{ missingControlTarget: true },
|
|
1178
|
-
);
|
|
1179
|
-
}
|
|
1180
|
-
if (controlTarget === current.target) {
|
|
1181
|
-
return fail(
|
|
1182
|
-
"control_target must differ from the case target; a control run against the vulnerable target proves nothing.",
|
|
1183
|
-
{ controlTargetEqualsCaseTarget: true },
|
|
1184
|
-
);
|
|
1185
|
-
}
|
|
1186
|
-
if (params.local === true && process.env.PI_POC_ALLOW_NETWORK !== "1") {
|
|
1187
|
-
return fail(
|
|
1188
|
-
"Networked PoC execution is operator-gated. Set PI_POC_ALLOW_NETWORK=1 to authorize the host-network sandbox for this session.",
|
|
1189
|
-
{ networkNotAuthorized: true },
|
|
1190
|
-
);
|
|
1191
|
-
}
|
|
1192
|
-
const controlAuthorization = controlTargetAuthorizationError(controlTarget);
|
|
1193
|
-
if (controlAuthorization) {
|
|
1194
|
-
return fail(
|
|
1195
|
-
`CONTROL AUTHORIZATION FAILED: ${controlAuthorization}. ` +
|
|
1196
|
-
"The operator must set PI_POC_CONTROL_TARGETS to the exact approved control host/origin before this control can anchor confirmation.",
|
|
1197
|
-
{ controlNotAuthorized: true },
|
|
1198
|
-
);
|
|
1199
|
-
}
|
|
1158
|
+
if (!startedAsSubagent)
|
|
1159
|
+
registerCaseTool({
|
|
1160
|
+
name: "PromoteFinding",
|
|
1161
|
+
label: "Run PoC Evidence",
|
|
1162
|
+
description:
|
|
1163
|
+
"Main-agent phase 1 of confirmation: run the same PoC twice against the case target and once against an operator-approved control_target, validate nonce-bound evidence.json with a response-body assertion, then have the harness execute one immutable HTTP request template against both target and control. control_path defaults to poc_path; if supplied, sha256 equality is enforced. The machine records a predicate differential, or a stronger canary differential when a reflection placeholder is requested and observed only on target; neither is automatically a vulnerability verdict. Exit 0 is necessary run integrity, never proof. Networked execution, controls, and private replay are operator-gated. Blind/OOB confirmation fails closed until source separation exists. Records a pending bundle for main-agent semantic review via ConfirmFinding. Worker/subagent processes are rejected.",
|
|
1164
|
+
promptSnippet:
|
|
1165
|
+
"Phase 1: run PoC evidence (target x2 + control) and record the pending bundle",
|
|
1166
|
+
promptGuidelines: [
|
|
1167
|
+
"Use PromoteFinding only from the main/coordinator agent when an investigating case has a concrete PoC script on disk and you are ready to subject its claim to the machine gate.",
|
|
1168
|
+
"Prerequisites: status='investigating' and non-empty poc, evidence, impact, severity, target, plus an artifact-backed EvidenceAdd 'observation' item on the case (the initial signal, with artifact_path). The final disconfirmation comes from the main agent at confirm time.",
|
|
1169
|
+
"The PoC MUST write evidence.json to $PI_POC_EVIDENCE_DIR: { nonce (echo $PI_POC_NONCE), claim, verify: { method, url, expect: { status?, body_contains/body_regex } }, observations }. A non-empty body predicate is mandatory; status-only evidence is rejected. verify.url must belong to the case target.",
|
|
1170
|
+
"For reflection-capable requests, place {{PI_POC_CANARY}} exactly once in verify.url/body/header values and declare verify.canary={mode:'reflection',placeholder:'{{PI_POC_CANARY}}'}. The harness substitutes an unpredictable value only after the PoC exits and requires target-only reflection; the raw token is not persisted.",
|
|
1171
|
+
"control_path is optional and defaults to poc_path; if supplied, it must be the SAME script as poc_path. control_target must be pre-approved by the operator in PI_POC_CONTROL_TARGETS. The harness derives the control request from the target request, changes only its origin, and applies the same predicates to two conclusive responses.",
|
|
1172
|
+
"local:true requires PI_POC_ALLOW_NETWORK=1. Private/internal harness replay additionally requires PI_POC_ALLOW_PRIVATE_REPLAY=1. Neither silently falls back to a model verdict.",
|
|
1173
|
+
"Blind/OOB classes are not promotable through the built-in loopback listener because the PoC can self-call it; obtain a direct-response or state oracle, otherwise keep the case investigating.",
|
|
1174
|
+
"After the bundle is recorded, stay in the main agent: inspect the script/evidence, attempt disconfirmation, and call ConfirmFinding itself; that call performs a fresh harness-owned target/control replay. Never delegate validation/confirmation and never CaseUpdate status='confirmed' directly.",
|
|
1175
|
+
],
|
|
1176
|
+
parameters: PromoteSchema,
|
|
1200
1177
|
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
);
|
|
1214
|
-
}
|
|
1215
|
-
if (pocHash !== controlHash) {
|
|
1216
|
-
return fail(
|
|
1217
|
-
"CONTROL CHECK FAILED: control_path must be the SAME script as poc_path (sha256 mismatch). Case remains investigating.",
|
|
1218
|
-
{ controlHashMismatch: true },
|
|
1219
|
-
);
|
|
1220
|
-
}
|
|
1178
|
+
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1179
|
+
if (isSubagentProcess()) {
|
|
1180
|
+
throw new Error(
|
|
1181
|
+
"PromoteFinding is reserved for the main/coordinator agent. A worker or subagent may gather evidence but cannot run validation or create a promotion bundle.",
|
|
1182
|
+
);
|
|
1183
|
+
}
|
|
1184
|
+
// Validate promotability BEFORE running the PoC — each sandboxed run can
|
|
1185
|
+
// take 30s (plus first-time image pull), so fail cheap when the case
|
|
1186
|
+
// can't advance anyway (missing, wrong status, missing required fields,
|
|
1187
|
+
// missing artifact-backed observation evidence).
|
|
1188
|
+
const caseId = params.id as string;
|
|
1189
|
+
const current = assertPromotable(caseId);
|
|
1221
1190
|
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
return fail(
|
|
1226
|
-
"OOB confirmation is fail-closed: the built-in loopback listener is reachable by the PoC and cannot prove the target caused a callback. A source-separated, operator-owned callback service is required before blind findings can be promoted.",
|
|
1227
|
-
{ oobSourceSeparationRequired: true },
|
|
1228
|
-
);
|
|
1229
|
-
}
|
|
1230
|
-
const runOptions = (pocMode: string, target: string): PocRunOptions => ({
|
|
1231
|
-
network: params.local === true ? "host" : "none",
|
|
1232
|
-
local: params.local === true,
|
|
1233
|
-
env: {
|
|
1234
|
-
PI_POC_MODE: pocMode,
|
|
1235
|
-
PI_POC_TARGET: target,
|
|
1236
|
-
},
|
|
1237
|
-
});
|
|
1191
|
+
const fail = (text: string, _extra?: Record<string, unknown>): never => {
|
|
1192
|
+
throw new Error(text);
|
|
1193
|
+
};
|
|
1238
1194
|
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1195
|
+
const pocPath = (params.poc_path as string | undefined)?.trim() ?? "";
|
|
1196
|
+
const controlPath = (params.control_path as string | undefined)?.trim() || pocPath;
|
|
1197
|
+
const controlTarget = (params.control_target as string | undefined)?.trim() ?? "";
|
|
1198
|
+
const mode: "inter_host" | "intra_target" =
|
|
1199
|
+
(params.mode as string | undefined) === "intra_target" ? "intra_target" : "inter_host";
|
|
1200
|
+
const isIntra = mode === "intra_target";
|
|
1201
|
+
if (!pocPath) {
|
|
1202
|
+
return fail("poc_path is REQUIRED: absolute path to the PoC script run by the harness.", {
|
|
1203
|
+
missingPocPath: true,
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
// Control-target preconditions apply only to the inter-host differential.
|
|
1207
|
+
// Intra-target proves target-dependence with a same-host baseline request
|
|
1208
|
+
// carried in the evidence, so it needs no control target or control script.
|
|
1209
|
+
if (!isIntra) {
|
|
1210
|
+
if (!controlTarget) {
|
|
1211
|
+
return fail(
|
|
1212
|
+
"control_target is REQUIRED for inter-host mode: a distinct baseline target that lacks the vulnerability. For access-control/logic bugs use mode='intra_target' with an evidence baseline instead.",
|
|
1213
|
+
{ missingControlTarget: true },
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
if (controlTarget === current.target) {
|
|
1217
|
+
return fail(
|
|
1218
|
+
"control_target must differ from the case target; a control run against the vulnerable target proves nothing.",
|
|
1219
|
+
{ controlTargetEqualsCaseTarget: true },
|
|
1220
|
+
);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
if (params.local === true && process.env.PI_POC_ALLOW_NETWORK !== "1") {
|
|
1252
1224
|
return fail(
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
". A crash is not evidence. Case remains investigating.",
|
|
1256
|
-
{ run: r, pocCrashed: true },
|
|
1225
|
+
"Networked PoC execution is operator-gated. Set PI_POC_ALLOW_NETWORK=1 to authorize the host-network sandbox for this session.",
|
|
1226
|
+
{ networkNotAuthorized: true },
|
|
1257
1227
|
);
|
|
1258
1228
|
}
|
|
1259
|
-
if (
|
|
1229
|
+
if (!isIntra) {
|
|
1230
|
+
const controlAuthorization = controlTargetAuthorizationError(controlTarget);
|
|
1231
|
+
if (controlAuthorization) {
|
|
1232
|
+
return fail(
|
|
1233
|
+
`CONTROL AUTHORIZATION FAILED: ${controlAuthorization}. ` +
|
|
1234
|
+
"The operator must set PI_POC_CONTROL_TARGETS to the exact approved control host/origin before this control can anchor confirmation.",
|
|
1235
|
+
{ controlNotAuthorized: true },
|
|
1236
|
+
);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// Anti-cheat: hash the PoC (always) and, for inter-host, require the
|
|
1241
|
+
// control script to be the SAME bytes (differing only via harness env).
|
|
1242
|
+
let pocHash: string | undefined;
|
|
1243
|
+
try {
|
|
1244
|
+
pocHash = createHash("sha256").update(readFileSync(pocPath)).digest("hex");
|
|
1245
|
+
} catch (e) {
|
|
1246
|
+
return fail(`Cannot read PoC script: ${(e as Error).message}`, {
|
|
1247
|
+
sameFileCheckFailed: true,
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
if (!isIntra) {
|
|
1251
|
+
let controlHash: string | undefined;
|
|
1252
|
+
try {
|
|
1253
|
+
controlHash = createHash("sha256").update(readFileSync(controlPath)).digest("hex");
|
|
1254
|
+
} catch (e) {
|
|
1255
|
+
return fail(
|
|
1256
|
+
`Cannot read control script for the same-file check: ${(e as Error).message}`,
|
|
1257
|
+
{ sameFileCheckFailed: true },
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
if (pocHash !== controlHash) {
|
|
1261
|
+
return fail(
|
|
1262
|
+
"CONTROL CHECK FAILED: control_path must be the SAME script as poc_path (sha256 mismatch). Case remains investigating.",
|
|
1263
|
+
{ controlHashMismatch: true },
|
|
1264
|
+
);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// ── OOB callback (Tier 1, opt-in for blind classes) ──
|
|
1269
|
+
const oobRequested = params.oob === true;
|
|
1270
|
+
if (oobRequested) {
|
|
1260
1271
|
return fail(
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
"the file is bound to this run and validated by the harness. Case remains investigating.",
|
|
1264
|
-
{ run: r, evidenceError: r.evidenceError },
|
|
1272
|
+
"OOB confirmation is fail-closed: the built-in loopback listener is reachable by the PoC and cannot prove the target caused a callback. A source-separated, operator-owned callback service is required before blind findings can be promoted.",
|
|
1273
|
+
{ oobSourceSeparationRequired: true },
|
|
1265
1274
|
);
|
|
1266
1275
|
}
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1276
|
+
const runOptions = (pocMode: string, target: string): PocRunOptions => ({
|
|
1277
|
+
network: params.local === true ? "host" : "none",
|
|
1278
|
+
local: params.local === true,
|
|
1279
|
+
env: {
|
|
1280
|
+
PI_POC_MODE: pocMode,
|
|
1281
|
+
PI_POC_TARGET: target,
|
|
1282
|
+
},
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
const caseTarget = current.target ?? "";
|
|
1286
|
+
// Determinism: TWO target runs. Exit 0 is run integrity only; nonce-bound
|
|
1287
|
+
// body evidence plus the harness-owned differential replay (inter-host
|
|
1288
|
+
// control, or intra-target same-host baseline) form the machine gate.
|
|
1289
|
+
const run1 = runPoc(pocPath, runOptions("poc", caseTarget));
|
|
1290
|
+
const run2 = runPoc(pocPath, runOptions("poc", caseTarget));
|
|
1291
|
+
|
|
1292
|
+
const evidenceRun = (
|
|
1293
|
+
r: PocRun,
|
|
1294
|
+
mode: "poc" | "control",
|
|
1295
|
+
target: string,
|
|
1296
|
+
): PocEvidenceRun => {
|
|
1297
|
+
if (!r.completed || !r.outputComplete) {
|
|
1298
|
+
return fail(
|
|
1299
|
+
`${mode} run did not complete or output capture was incomplete` +
|
|
1300
|
+
(r.infraError ? ` (infra: ${r.output.trim()})` : "") +
|
|
1301
|
+
". A crash is not evidence. Case remains investigating.",
|
|
1302
|
+
{ run: r, pocCrashed: true },
|
|
1303
|
+
);
|
|
1304
|
+
}
|
|
1305
|
+
if (r.evidenceError) {
|
|
1306
|
+
return fail(
|
|
1307
|
+
`EVIDENCE CONTRACT FAILED (${mode} run): ${r.evidenceError}. ` +
|
|
1308
|
+
"The PoC must write evidence.json to $PI_POC_EVIDENCE_DIR — { nonce (echo $PI_POC_NONCE), claim, verify: { method, url, expect: { status?, body_contains / body_regex } }, observations }; a response-body assertion is mandatory — " +
|
|
1309
|
+
"the file is bound to this run and validated by the harness. Case remains investigating.",
|
|
1310
|
+
{ run: r, evidenceError: r.evidenceError },
|
|
1311
|
+
);
|
|
1312
|
+
}
|
|
1313
|
+
if (!r.evidence || !r.evidenceSha256 || !r.nonce) {
|
|
1314
|
+
return fail(`${mode} run produced no evidence. Case remains investigating.`, {
|
|
1315
|
+
run: r,
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
return {
|
|
1319
|
+
mode,
|
|
1320
|
+
target,
|
|
1321
|
+
nonce: r.nonce,
|
|
1322
|
+
ranAt: r.ranAt,
|
|
1323
|
+
exitCode: r.exitCode,
|
|
1324
|
+
sandbox: r.sandbox,
|
|
1325
|
+
completed: r.completed,
|
|
1326
|
+
outputComplete: r.outputComplete,
|
|
1327
|
+
output: r.output ?? "",
|
|
1328
|
+
evidence: r.evidence,
|
|
1329
|
+
evidenceSha256: r.evidenceSha256,
|
|
1330
|
+
evidencePath: r.evidencePath,
|
|
1331
|
+
};
|
|
1332
|
+
};
|
|
1333
|
+
|
|
1334
|
+
const targetRuns: [PocEvidenceRun, PocEvidenceRun] = [
|
|
1335
|
+
evidenceRun(run1, "poc", caseTarget),
|
|
1336
|
+
evidenceRun(run2, "poc", caseTarget),
|
|
1337
|
+
];
|
|
1338
|
+
|
|
1339
|
+
const allowPrivateReplay = process.env.PI_POC_ALLOW_PRIVATE_REPLAY === "1";
|
|
1340
|
+
let harnessVerified: HarnessVerifyResult;
|
|
1341
|
+
let controlRun: PocEvidenceRun | undefined;
|
|
1342
|
+
if (isIntra) {
|
|
1343
|
+
// Intra-target: prove target-dependence with the evidence's same-host
|
|
1344
|
+
// baseline request — no separate control run. The harness sends attack +
|
|
1345
|
+
// baseline to the case target and requires the proof on attack only.
|
|
1346
|
+
const ev0 = targetRuns[0].evidence;
|
|
1347
|
+
if (ev0.verify.mode !== "intra_target") {
|
|
1348
|
+
return fail(
|
|
1349
|
+
"INTRA-TARGET FAILED: the PoC's evidence.json must set verify.mode='intra_target' when promoting in intra-target mode.",
|
|
1350
|
+
{ intraModeMismatch: true },
|
|
1351
|
+
);
|
|
1352
|
+
}
|
|
1353
|
+
if (!ev0.baseline) {
|
|
1354
|
+
return fail(
|
|
1355
|
+
"INTRA-TARGET FAILED: evidence.json must include a baseline — a legitimate same-host request whose response must NOT satisfy the attack predicate.",
|
|
1356
|
+
{ intraBaselineMissing: true },
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1359
|
+
harnessVerified = await replayIntraTarget(ev0, caseTarget, {
|
|
1360
|
+
allowPrivate: allowPrivateReplay,
|
|
1270
1361
|
});
|
|
1362
|
+
} else {
|
|
1363
|
+
// Inter-host (Tier 2): the harness executes the SAME request template
|
|
1364
|
+
// against target and operator-approved control, applying the target's
|
|
1365
|
+
// predicates to both. DNS is pinned at connect time.
|
|
1366
|
+
controlRun = evidenceRun(
|
|
1367
|
+
runPoc(controlPath, runOptions("control", controlTarget)),
|
|
1368
|
+
"control",
|
|
1369
|
+
controlTarget,
|
|
1370
|
+
);
|
|
1371
|
+
harnessVerified = await replayDifferential(
|
|
1372
|
+
targetRuns[0].evidence,
|
|
1373
|
+
caseTarget,
|
|
1374
|
+
controlTarget,
|
|
1375
|
+
{ allowPrivate: allowPrivateReplay },
|
|
1376
|
+
);
|
|
1271
1377
|
}
|
|
1272
|
-
|
|
1378
|
+
const bundle: PendingConfirmation = {
|
|
1379
|
+
caseId,
|
|
1380
|
+
ranAt: new Date().toISOString(),
|
|
1381
|
+
pocPath,
|
|
1382
|
+
pocSha256: pocHash,
|
|
1273
1383
|
mode,
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
exitCode: r.exitCode,
|
|
1278
|
-
sandbox: r.sandbox,
|
|
1279
|
-
completed: r.completed,
|
|
1280
|
-
outputComplete: r.outputComplete,
|
|
1281
|
-
output: r.output ?? "",
|
|
1282
|
-
evidence: r.evidence,
|
|
1283
|
-
evidenceSha256: r.evidenceSha256,
|
|
1284
|
-
evidencePath: r.evidencePath,
|
|
1384
|
+
targetRuns,
|
|
1385
|
+
harnessVerified,
|
|
1386
|
+
...(isIntra ? {} : { controlPath, controlTarget, controlRun }),
|
|
1285
1387
|
};
|
|
1286
|
-
};
|
|
1287
1388
|
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
caseTarget,
|
|
1297
|
-
controlTarget,
|
|
1298
|
-
{ allowPrivate: process.env.PI_POC_ALLOW_PRIVATE_REPLAY === "1" },
|
|
1299
|
-
);
|
|
1300
|
-
|
|
1301
|
-
const bundle: PendingConfirmation = {
|
|
1302
|
-
caseId,
|
|
1303
|
-
ranAt: new Date().toISOString(),
|
|
1304
|
-
pocPath,
|
|
1305
|
-
pocSha256: pocHash,
|
|
1306
|
-
controlPath,
|
|
1307
|
-
controlTarget,
|
|
1308
|
-
targetRuns,
|
|
1309
|
-
controlRun: control,
|
|
1310
|
-
harnessVerified,
|
|
1311
|
-
};
|
|
1312
|
-
|
|
1313
|
-
let record: CaseRecord;
|
|
1314
|
-
try {
|
|
1315
|
-
record = storePendingConfirmation(caseId, bundle);
|
|
1316
|
-
} catch (e) {
|
|
1317
|
-
return fail(`Pending confirmation rejected: ${(e as Error).message}`, {
|
|
1318
|
-
storeRejected: true,
|
|
1319
|
-
});
|
|
1320
|
-
}
|
|
1389
|
+
let record: CaseRecord;
|
|
1390
|
+
try {
|
|
1391
|
+
record = storePendingConfirmation(caseId, bundle);
|
|
1392
|
+
} catch (e) {
|
|
1393
|
+
return fail(`Pending confirmation rejected: ${(e as Error).message}`, {
|
|
1394
|
+
storeRejected: true,
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1321
1397
|
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1398
|
+
return {
|
|
1399
|
+
content: [
|
|
1400
|
+
{
|
|
1401
|
+
type: "text",
|
|
1402
|
+
text:
|
|
1403
|
+
`Phase 1 complete — evidence bundle recorded on ${caseId} (expires in 1h).\n` +
|
|
1404
|
+
`Mode: ${mode}. ${isIntra ? "Target runs: 2, same-host baseline differential" : "Target runs: 2, Control run: 1"} — all with validated nonce-bound evidence.json.\n` +
|
|
1405
|
+
`Evidence sha256: ${targetRuns[0].evidenceSha256}\n` +
|
|
1406
|
+
`PoC script sha256 (at run time): ${pocHash}\n` +
|
|
1407
|
+
`Harness verify replay: ${harnessVerified.attempted ? (harnessVerified.pass ? `PASS (status ${harnessVerified.status})` : `FAILED — ${harnessVerified.note}`) : harnessVerified.note}\n` +
|
|
1408
|
+
`\nMAIN-AGENT REVIEW REQUIRED (do not delegate): inspect case ${caseId}, PoC ${pocPath}, ${isIntra ? "same-host baseline" : `control ${controlTarget}`}, evidence ${targetRuns[0].evidenceSha256}, and PoC hash ${pocHash}. Hunt for a trivial predicate or fabricated differential and perform a concrete disconfirmation attempt, then call ConfirmFinding yourself. A CONFIRMED call performs and stores a fresh harness-owned ${isIntra ? "attack/baseline" : "target/control"} replay; NOT_CONFIRMED keeps the case investigating.`,
|
|
1409
|
+
},
|
|
1410
|
+
],
|
|
1411
|
+
details: {
|
|
1412
|
+
record,
|
|
1413
|
+
bundle: {
|
|
1414
|
+
caseId,
|
|
1415
|
+
ranAt: bundle.ranAt,
|
|
1416
|
+
mode,
|
|
1417
|
+
pocPath,
|
|
1418
|
+
controlPath: isIntra ? undefined : controlPath,
|
|
1419
|
+
controlTarget: isIntra ? undefined : controlTarget,
|
|
1420
|
+
pocSha256: pocHash,
|
|
1421
|
+
evidenceSha256: targetRuns[0].evidenceSha256,
|
|
1422
|
+
harnessVerified,
|
|
1423
|
+
},
|
|
1346
1424
|
},
|
|
1347
|
-
}
|
|
1348
|
-
}
|
|
1349
|
-
},
|
|
1425
|
+
};
|
|
1426
|
+
},
|
|
1350
1427
|
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1428
|
+
renderCall(args, theme) {
|
|
1429
|
+
return callLine(theme, "PromoteFinding", (args.id as string) ?? "");
|
|
1430
|
+
},
|
|
1354
1431
|
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1432
|
+
renderResult(result, _opts, theme) {
|
|
1433
|
+
const details = result.details as { bundle?: { evidenceSha256?: string } } | undefined;
|
|
1434
|
+
if (!details?.bundle) {
|
|
1435
|
+
return new Text(theme.fg("error", "✗ PromoteFinding failed"), 0, 0);
|
|
1436
|
+
}
|
|
1437
|
+
return new Text(
|
|
1438
|
+
theme.fg("success", "✓ ") +
|
|
1439
|
+
theme.fg("dim", "evidence bundle ") +
|
|
1440
|
+
theme.fg("muted", details.bundle.evidenceSha256?.slice(0, 12) ?? ""),
|
|
1441
|
+
0,
|
|
1442
|
+
0,
|
|
1443
|
+
);
|
|
1444
|
+
},
|
|
1445
|
+
});
|
|
1369
1446
|
|
|
1370
1447
|
// ── Tool: ConfirmFinding (phase 2) ──
|
|
1371
1448
|
|
|
@@ -1373,7 +1450,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1373
1450
|
// execute-time check remains as defense in depth if process state changes
|
|
1374
1451
|
// after registration or another integration forwards a stale tool handle.
|
|
1375
1452
|
if (!startedAsSubagent)
|
|
1376
|
-
|
|
1453
|
+
registerCaseTool({
|
|
1377
1454
|
name: "ConfirmFinding",
|
|
1378
1455
|
label: "Main-Agent Confirmation",
|
|
1379
1456
|
description:
|
|
@@ -1392,7 +1469,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1392
1469
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1393
1470
|
if (isSubagentProcess()) {
|
|
1394
1471
|
throw new Error(
|
|
1395
|
-
"ConfirmFinding is reserved for the main/coordinator agent. A worker or subagent may
|
|
1472
|
+
"ConfirmFinding is reserved for the main/coordinator agent. A worker or subagent may gather or challenge evidence but cannot run validation or confirm a PoC.",
|
|
1396
1473
|
);
|
|
1397
1474
|
}
|
|
1398
1475
|
const caseId = params.id as string;
|
|
@@ -1408,16 +1485,29 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1408
1485
|
if (!bundle) {
|
|
1409
1486
|
throw new Error("No pending confirmation on this case — run PromoteFinding first");
|
|
1410
1487
|
}
|
|
1411
|
-
const
|
|
1412
|
-
|
|
1413
|
-
|
|
1488
|
+
const allowPrivate = process.env.PI_POC_ALLOW_PRIVATE_REPLAY === "1";
|
|
1489
|
+
const caseTargetForReplay = current.target ?? bundle.targetRuns[0].target;
|
|
1490
|
+
let replay: HarnessVerifyResult;
|
|
1491
|
+
if (bundle.mode === "intra_target") {
|
|
1492
|
+
// Same-host attack-vs-baseline replay; no control target to authorize.
|
|
1493
|
+
replay = await replayIntraTarget(bundle.targetRuns[0].evidence, caseTargetForReplay, {
|
|
1494
|
+
allowPrivate,
|
|
1495
|
+
});
|
|
1496
|
+
} else {
|
|
1497
|
+
if (!bundle.controlTarget) {
|
|
1498
|
+
throw new Error("inter-host confirmation requires a control target");
|
|
1499
|
+
}
|
|
1500
|
+
const controlAuthorizationError = controlTargetAuthorizationError(bundle.controlTarget);
|
|
1501
|
+
if (controlAuthorizationError) {
|
|
1502
|
+
throw new Error(`CONTROL AUTHORIZATION FAILED: ${controlAuthorizationError}`);
|
|
1503
|
+
}
|
|
1504
|
+
replay = await replayDifferential(
|
|
1505
|
+
bundle.targetRuns[0].evidence,
|
|
1506
|
+
caseTargetForReplay,
|
|
1507
|
+
bundle.controlTarget,
|
|
1508
|
+
{ allowPrivate },
|
|
1509
|
+
);
|
|
1414
1510
|
}
|
|
1415
|
-
const replay = await replayDifferential(
|
|
1416
|
-
bundle.targetRuns[0].evidence,
|
|
1417
|
-
current.target ?? bundle.targetRuns[0].target,
|
|
1418
|
-
bundle.controlTarget,
|
|
1419
|
-
{ allowPrivate: process.env.PI_POC_ALLOW_PRIVATE_REPLAY === "1" },
|
|
1420
|
-
);
|
|
1421
1511
|
phase2Verification = {
|
|
1422
1512
|
at: new Date().toISOString(),
|
|
1423
1513
|
result: replay,
|
|
@@ -1461,7 +1551,7 @@ ${formatCaseDetail(record)}`,
|
|
|
1461
1551
|
|
|
1462
1552
|
// ── Tool: CaseGet ──
|
|
1463
1553
|
|
|
1464
|
-
|
|
1554
|
+
registerCaseTool({
|
|
1465
1555
|
name: "CaseGet",
|
|
1466
1556
|
label: "Get Case",
|
|
1467
1557
|
description: "Get full details of a single case by ID.",
|
|
@@ -1490,7 +1580,7 @@ ${formatCaseDetail(record)}`,
|
|
|
1490
1580
|
|
|
1491
1581
|
// ── Tool: CaseList ──
|
|
1492
1582
|
|
|
1493
|
-
|
|
1583
|
+
registerCaseTool({
|
|
1494
1584
|
name: "CaseList",
|
|
1495
1585
|
label: "List Cases",
|
|
1496
1586
|
description:
|
|
@@ -1520,7 +1610,7 @@ ${formatCaseDetail(record)}`,
|
|
|
1520
1610
|
|
|
1521
1611
|
// ── Tool: CaseSearch ──
|
|
1522
1612
|
|
|
1523
|
-
|
|
1613
|
+
registerCaseTool({
|
|
1524
1614
|
name: "CaseSearch",
|
|
1525
1615
|
label: "Search Cases",
|
|
1526
1616
|
description:
|
|
@@ -1548,7 +1638,7 @@ ${formatCaseDetail(record)}`,
|
|
|
1548
1638
|
|
|
1549
1639
|
// ── Tool: CaseLink ──
|
|
1550
1640
|
|
|
1551
|
-
|
|
1641
|
+
registerCaseTool({
|
|
1552
1642
|
name: "CaseLink",
|
|
1553
1643
|
label: "Link Cases",
|
|
1554
1644
|
description:
|
|
@@ -1620,7 +1710,7 @@ ${formatCaseDetail(record)}`,
|
|
|
1620
1710
|
|
|
1621
1711
|
// ── Tool: CaseUnlink ──
|
|
1622
1712
|
|
|
1623
|
-
|
|
1713
|
+
registerCaseTool({
|
|
1624
1714
|
name: "CaseUnlink",
|
|
1625
1715
|
label: "Unlink Cases",
|
|
1626
1716
|
description: "Remove a bidirectional link between two cases.",
|
|
@@ -1687,7 +1777,7 @@ ${formatCaseDetail(record)}`,
|
|
|
1687
1777
|
{ additionalProperties: false },
|
|
1688
1778
|
);
|
|
1689
1779
|
|
|
1690
|
-
|
|
1780
|
+
registerCaseTool({
|
|
1691
1781
|
name: "ChainSuggest",
|
|
1692
1782
|
label: "Suggest Exploit Chains",
|
|
1693
1783
|
description:
|
|
@@ -1736,15 +1826,15 @@ ${formatCaseDetail(record)}`,
|
|
|
1736
1826
|
|
|
1737
1827
|
// ── Tool: CaseContext ──
|
|
1738
1828
|
|
|
1739
|
-
|
|
1829
|
+
registerCaseTool({
|
|
1740
1830
|
name: "CaseContext",
|
|
1741
1831
|
label: "Generate Case Context",
|
|
1742
1832
|
description:
|
|
1743
|
-
"Generate the case context bundle for a confirmed or reported case under the casefile report directory (next to the casefile DB): full evidence, PoC verification log, disconfirmation attempt, links, and timeline, plus the target report path. The
|
|
1744
|
-
promptSnippet: "Generate case context for the report
|
|
1833
|
+
"Generate the case context bundle for a confirmed or reported case under the casefile report directory (next to the casefile DB): full evidence, PoC verification log, disconfirmation attempt, links, and timeline, plus the target report path. The main agent turns this context into the final polished H1-style report. Hypothesis/investigating/blocked/killed cases are rejected — promote to confirmed first.",
|
|
1834
|
+
promptSnippet: "Generate case context for the final report",
|
|
1745
1835
|
promptGuidelines: [
|
|
1746
1836
|
"Use CaseContext only for confirmed or already reported cases. Keep hypotheses and investigating cases in the ledger until proof is captured.",
|
|
1747
|
-
"After CaseContext,
|
|
1837
|
+
"After CaseContext, write the final report to the returned report path yourself, then CaseUpdate(status: 'reported').",
|
|
1748
1838
|
],
|
|
1749
1839
|
parameters: IdSchema,
|
|
1750
1840
|
|
|
@@ -1754,7 +1844,7 @@ ${formatCaseDetail(record)}`,
|
|
|
1754
1844
|
content: [
|
|
1755
1845
|
{
|
|
1756
1846
|
type: "text",
|
|
1757
|
-
text: `Case context written: ${contextPath}\nReport path
|
|
1847
|
+
text: `Case context written: ${contextPath}\nReport path: ${path}\n${formatCase(record)}`,
|
|
1758
1848
|
},
|
|
1759
1849
|
],
|
|
1760
1850
|
details: { path, contextPath, record },
|
|
@@ -1779,7 +1869,7 @@ ${formatCaseDetail(record)}`,
|
|
|
1779
1869
|
|
|
1780
1870
|
pi.registerCommand("xp", {
|
|
1781
1871
|
description:
|
|
1782
|
-
"Toggle casefile XP (offensive) mode.
|
|
1872
|
+
"Toggle casefile XP (offensive) mode. Bare /xp and /xp on select SWARM, the bounded multi-agent workflow. LITE keeps XP single-agent. OFF (default) keeps context quiet for normal dev work. Usage: /xp [on|lite|swarm|off]",
|
|
1783
1873
|
handler: async (args, ctx) => {
|
|
1784
1874
|
const next = parseXpModeArg(args ?? "", readXpMode());
|
|
1785
1875
|
writeXpMode(next);
|
|
@@ -1790,23 +1880,25 @@ ${formatCaseDetail(record)}`,
|
|
|
1790
1880
|
if (next !== "off") workflowInjected = false;
|
|
1791
1881
|
ctx.ui.notify(
|
|
1792
1882
|
`Casefile XP mode: ${next.toUpperCase()} (takes effect on the next prompt)`,
|
|
1793
|
-
next === "
|
|
1883
|
+
next === "off" ? "warning" : "info",
|
|
1794
1884
|
);
|
|
1795
1885
|
},
|
|
1796
1886
|
});
|
|
1797
1887
|
|
|
1798
1888
|
// ── Tool: PipelineSubmit ──
|
|
1799
1889
|
|
|
1800
|
-
|
|
1890
|
+
registerCaseTool({
|
|
1801
1891
|
name: "PipelineSubmit",
|
|
1802
1892
|
label: "Submit Stage Output",
|
|
1803
1893
|
description:
|
|
1804
1894
|
"Submit a pipeline stage's output (hunt, trace, skeptic, validate, chain, report) through the validation gate. Validates required fields against the stage spec (mirrors schemas/*.json), applies the deterministic pre-filter (test-path and file-existence filters on hunt findings, trivial dedup by file+class+line), and counts repair attempts (max 2, then rejected). A stage cannot advance on an invalid output — submit fixed output until accepted.",
|
|
1805
1895
|
promptSnippet: "Validate and submit a pipeline stage's output",
|
|
1806
1896
|
promptGuidelines: [
|
|
1807
|
-
"Every stage output
|
|
1897
|
+
"Every delegated stage output and every main-agent VALIDATE/REPORT output must go through PipelineSubmit before the next stage starts — do not eyeball schemas.",
|
|
1808
1898
|
"verdict repair → fix the listed fields and re-submit the same output; budget is 2 attempts per finding, then rejected.",
|
|
1809
|
-
"Skeptic: unparseable/schema-invalid =
|
|
1899
|
+
"Skeptic: unparseable/schema-invalid = no verdict (repair/re-dispatch); only schema-valid DISPROVEN kills, and schema-valid UNDETERMINED blocks validation.",
|
|
1900
|
+
"Tracer crash/invalid output = no trace verdict; repair or re-dispatch.",
|
|
1901
|
+
'Only schema-valid trace_result: "UNREACHABLE" blocks advancement as a proven unreachable path; schema-valid UNDETERMINED blocks validation until resolved.',
|
|
1810
1902
|
"Test-path findings and hallucinated files are rejected by the pre-filter, not repairable — the finding itself is noise.",
|
|
1811
1903
|
],
|
|
1812
1904
|
parameters: Type.Object(
|
|
@@ -1866,7 +1958,7 @@ ${formatCaseDetail(record)}`,
|
|
|
1866
1958
|
|
|
1867
1959
|
// ── Tool: ScratchpadInit ──
|
|
1868
1960
|
|
|
1869
|
-
|
|
1961
|
+
registerCaseTool({
|
|
1870
1962
|
name: "ScratchpadInit",
|
|
1871
1963
|
label: "Init Scratchpad",
|
|
1872
1964
|
description:
|
|
@@ -1904,7 +1996,7 @@ ${formatCaseDetail(record)}`,
|
|
|
1904
1996
|
|
|
1905
1997
|
// ── Tool: ScratchpadResume ──
|
|
1906
1998
|
|
|
1907
|
-
|
|
1999
|
+
registerCaseTool({
|
|
1908
2000
|
name: "ScratchpadResume",
|
|
1909
2001
|
label: "Resume Scratchpad",
|
|
1910
2002
|
description:
|
|
@@ -1963,7 +2055,7 @@ ${formatCaseDetail(record)}`,
|
|
|
1963
2055
|
|
|
1964
2056
|
// ── Tool: ScratchpadCheckpoint ──
|
|
1965
2057
|
|
|
1966
|
-
|
|
2058
|
+
registerCaseTool({
|
|
1967
2059
|
name: "ScratchpadCheckpoint",
|
|
1968
2060
|
label: "Checkpoint Phase",
|
|
1969
2061
|
description:
|
|
@@ -2013,7 +2105,7 @@ ${formatCaseDetail(record)}`,
|
|
|
2013
2105
|
|
|
2014
2106
|
// ── Tool: ScratchpadWrite ──
|
|
2015
2107
|
|
|
2016
|
-
|
|
2108
|
+
registerCaseTool({
|
|
2017
2109
|
name: "ScratchpadWrite",
|
|
2018
2110
|
label: "Write Artifact",
|
|
2019
2111
|
description:
|
|
@@ -2059,7 +2151,7 @@ ${formatCaseDetail(record)}`,
|
|
|
2059
2151
|
|
|
2060
2152
|
// ── Tool: ScratchpadRead ──
|
|
2061
2153
|
|
|
2062
|
-
|
|
2154
|
+
registerCaseTool({
|
|
2063
2155
|
name: "ScratchpadRead",
|
|
2064
2156
|
label: "Read Artifact",
|
|
2065
2157
|
description:
|
|
@@ -2116,7 +2208,7 @@ ${formatCaseDetail(record)}`,
|
|
|
2116
2208
|
|
|
2117
2209
|
// ── Tool: ScratchpadPhaseDone ──
|
|
2118
2210
|
|
|
2119
|
-
|
|
2211
|
+
registerCaseTool({
|
|
2120
2212
|
name: "ScratchpadPhaseDone",
|
|
2121
2213
|
label: "Phase Done?",
|
|
2122
2214
|
description:
|
|
@@ -2159,7 +2251,7 @@ ${formatCaseDetail(record)}`,
|
|
|
2159
2251
|
|
|
2160
2252
|
// ── Tool: ScratchpadClear ──
|
|
2161
2253
|
|
|
2162
|
-
|
|
2254
|
+
registerCaseTool({
|
|
2163
2255
|
name: "ScratchpadClear",
|
|
2164
2256
|
label: "Clear Run",
|
|
2165
2257
|
description:
|