@xaccefy/pi-casefile 0.9.0 → 0.9.2
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 +18 -11
- package/package.json +13 -4
- package/skills/casefile/SKILL.md +5 -5
- package/src/evidence.ts +502 -0
- package/src/harness-verify.ts +693 -0
- package/src/index.ts +449 -321
- package/src/ledger-worker-entry.ts +35 -0
- package/src/ledger-worker.ts +77 -0
- package/src/ledger.ts +503 -71
- package/src/pipeline-submit.ts +122 -75
- package/src/poc-runner.ts +67 -20
- package/src/safe-state.ts +108 -0
- package/src/scratchpad.ts +57 -29
- package/src/workflow.ts +51 -46
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Casefile — offensive security case tracker for Pi.
|
|
3
3
|
*
|
|
4
|
-
* Tools: CaseAdd, CaseUpdate, PromoteFinding, EvidenceAdd, CoverageAdd, CoverageReport, ChainSuggest, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseContext, PipelineSubmit, ScratchpadInit, ScratchpadResume, ScratchpadCheckpoint, ScratchpadWrite, ScratchpadRead, ScratchpadPhaseDone, ScratchpadClear
|
|
4
|
+
* Tools: CaseAdd, CaseUpdate, PromoteFinding, ConfirmFinding, EvidenceAdd, CoverageAdd, CoverageReport, ChainSuggest, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseContext, PipelineSubmit, ScratchpadInit, ScratchpadResume, ScratchpadCheckpoint, ScratchpadWrite, ScratchpadRead, ScratchpadPhaseDone, ScratchpadClear
|
|
5
5
|
* Command: /casefile — interactive dashboard
|
|
6
6
|
* Event: before_agent_start — injects cyber workflow once per session, refreshes the active case list per prompt
|
|
7
7
|
*/
|
|
@@ -9,15 +9,17 @@
|
|
|
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
|
+
CANARY_ASSESSMENT_VALUES,
|
|
16
17
|
CONFIRM_DIFFERENTIAL_VALUES,
|
|
17
18
|
CONFIRM_VERDICT_VALUES,
|
|
18
|
-
type ConfirmerVerdict,
|
|
19
19
|
SEVERITY_MATCH_VALUES,
|
|
20
|
+
validateMainAgentVerdict,
|
|
20
21
|
} from "./evidence.ts";
|
|
22
|
+
import { controlTargetAuthorizationError, replayDifferential } from "./harness-verify.ts";
|
|
21
23
|
import {
|
|
22
24
|
addCaseResult,
|
|
23
25
|
addEvidenceItemResult,
|
|
@@ -47,6 +49,7 @@ import {
|
|
|
47
49
|
getCasefilePath,
|
|
48
50
|
LINK_KIND_VALUES,
|
|
49
51
|
linkCasesResult,
|
|
52
|
+
type MainAgentVerification,
|
|
50
53
|
type PendingConfirmation,
|
|
51
54
|
type PocEvidenceRun,
|
|
52
55
|
PRIORITY_VALUES,
|
|
@@ -58,16 +61,15 @@ import {
|
|
|
58
61
|
STATUS_VALUES,
|
|
59
62
|
searchCases,
|
|
60
63
|
storePendingConfirmation,
|
|
61
|
-
suggestChains,
|
|
62
64
|
unlinkCasesResult,
|
|
63
65
|
updateCaseResult,
|
|
64
|
-
writeCaseContext,
|
|
65
66
|
} from "./ledger.ts";
|
|
67
|
+
import { suggestChainsAsync, writeCaseContextAsync } from "./ledger-worker.ts";
|
|
66
68
|
import { pipeline_submit, SUBMIT_STAGES, type SubmitStage } from "./pipeline-submit.ts";
|
|
67
69
|
import { type PocRun, type PocRunOptions, runPoc } from "./poc-runner.ts";
|
|
68
70
|
import {
|
|
69
71
|
detectWorkspaceRoot,
|
|
70
|
-
|
|
72
|
+
SCRATCHPAD_PHASES,
|
|
71
73
|
type ScratchpadPhase,
|
|
72
74
|
type ScratchpadResume,
|
|
73
75
|
scratchpad_checkpoint,
|
|
@@ -167,8 +169,7 @@ const EvidenceAddSchema = Type.Object(
|
|
|
167
169
|
artifact_path: Type.Optional(
|
|
168
170
|
Type.String({
|
|
169
171
|
description:
|
|
170
|
-
"Path to
|
|
171
|
-
"(full path is never persisted).",
|
|
172
|
+
"Path to a regular, non-symlink artifact inside the workspace. The bytes are copied durably and stored as basename + SHA-256 (full source path is never persisted).",
|
|
172
173
|
}),
|
|
173
174
|
),
|
|
174
175
|
},
|
|
@@ -177,12 +178,13 @@ const EvidenceAddSchema = Type.Object(
|
|
|
177
178
|
|
|
178
179
|
// ── Tool: PromoteFinding (phase 1) / ConfirmFinding (phase 2) ──────────
|
|
179
180
|
//
|
|
180
|
-
// Confirmation is TWO-PHASE
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
181
|
+
// Confirmation is TWO-PHASE and main-agent-owned: PromoteFinding runs the PoC
|
|
182
|
+
// 2x + control, validates nonce-bound evidence.json, and records the pending
|
|
183
|
+
// bundle; ConfirmFinding then performs the main coordinator's review/replay and
|
|
184
|
+
// commits or refuses the verdict. Subagents may gather or challenge evidence,
|
|
185
|
+
// but they cannot run validation or confirmation gates. Zero exit is necessary
|
|
186
|
+
// run integrity and markers are diagnostic only; the machine records
|
|
187
|
+
// predicate/canary differentials and the main agent owns the semantic judgment.
|
|
186
188
|
|
|
187
189
|
const PromoteSchema = Type.Object(
|
|
188
190
|
{
|
|
@@ -190,19 +192,27 @@ const PromoteSchema = Type.Object(
|
|
|
190
192
|
poc_path: Type.String({
|
|
191
193
|
description: "Absolute path to the PoC script on disk",
|
|
192
194
|
}),
|
|
193
|
-
control_path: Type.
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
195
|
+
control_path: Type.Optional(
|
|
196
|
+
Type.String({
|
|
197
|
+
description:
|
|
198
|
+
"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.",
|
|
199
|
+
}),
|
|
200
|
+
),
|
|
197
201
|
control_target: Type.String({
|
|
198
202
|
minLength: 1,
|
|
199
203
|
description:
|
|
200
|
-
"REQUIRED: a distinct baseline target that lacks the vulnerability (patched replica, second account, baseline
|
|
204
|
+
"REQUIRED: a distinct baseline target that lacks the vulnerability and is operator-approved through PI_POC_CONTROL_TARGETS (patched replica, second account, baseline service).",
|
|
201
205
|
}),
|
|
202
206
|
local: Type.Optional(
|
|
203
207
|
Type.Boolean({
|
|
204
208
|
description:
|
|
205
|
-
"Run with network access
|
|
209
|
+
"Run with network access instead of --network none. Requires operator authorization via PI_POC_ALLOW_NETWORK=1. True host fallback additionally requires PI_POC_ALLOW_LOCAL=1.",
|
|
210
|
+
}),
|
|
211
|
+
),
|
|
212
|
+
oob: Type.Optional(
|
|
213
|
+
Type.Boolean({
|
|
214
|
+
description:
|
|
215
|
+
"Reserved for source-separated out-of-band verification. Currently fails closed because a loopback listener reachable by the PoC cannot prove target causation.",
|
|
206
216
|
}),
|
|
207
217
|
),
|
|
208
218
|
},
|
|
@@ -219,14 +229,13 @@ const ConfirmSchema = Type.Object(
|
|
|
219
229
|
description: "Why the evidence does or does not demonstrate the claim",
|
|
220
230
|
}),
|
|
221
231
|
evidence_reviewed: Type.Array(Type.String(), {
|
|
222
|
-
description: "Files/evidence the
|
|
223
|
-
}),
|
|
224
|
-
re_executed: Type.Boolean({
|
|
225
|
-
description:
|
|
226
|
-
"True iff the confirmer re-sent the verify request itself. Mandatory for CONFIRMED.",
|
|
232
|
+
description: "Files/evidence the main agent actually reviewed",
|
|
227
233
|
}),
|
|
228
234
|
re_execution_note: Type.Optional(
|
|
229
|
-
Type.String({
|
|
235
|
+
Type.String({
|
|
236
|
+
description:
|
|
237
|
+
"What the main agent observed during review and the fresh harness-owned target/control replay. Mandatory for CONFIRMED.",
|
|
238
|
+
}),
|
|
230
239
|
),
|
|
231
240
|
differential: Type.String({
|
|
232
241
|
enum: [...CONFIRM_DIFFERENTIAL_VALUES],
|
|
@@ -241,7 +250,20 @@ const ConfirmSchema = Type.Object(
|
|
|
241
250
|
disconfirmation_attempt: Type.Optional(
|
|
242
251
|
Type.String({
|
|
243
252
|
description:
|
|
244
|
-
"The
|
|
253
|
+
"The main agent's own failed attempt to disprove — becomes the case's disconfirmation",
|
|
254
|
+
}),
|
|
255
|
+
),
|
|
256
|
+
canary_assessment: Type.Optional(
|
|
257
|
+
Type.String({
|
|
258
|
+
enum: [...CANARY_ASSESSMENT_VALUES],
|
|
259
|
+
description:
|
|
260
|
+
"verified when the replay carried a harness-generated reflection canary; otherwise not_applicable with a concrete reason",
|
|
261
|
+
}),
|
|
262
|
+
),
|
|
263
|
+
canary_reason: Type.Optional(
|
|
264
|
+
Type.String({
|
|
265
|
+
description:
|
|
266
|
+
"Why a causal reflection canary is not meaningful for this exploit class. Required when canary_assessment=not_applicable.",
|
|
245
267
|
}),
|
|
246
268
|
),
|
|
247
269
|
model: Type.Optional(
|
|
@@ -335,9 +357,9 @@ const UnlinkSchema = Type.Object(
|
|
|
335
357
|
// artifacts; it does not re-run completed phases (idempotent).
|
|
336
358
|
|
|
337
359
|
const ScratchpadPhaseSchema = Type.String({
|
|
338
|
-
enum: [...
|
|
360
|
+
enum: [...SCRATCHPAD_PHASES],
|
|
339
361
|
description:
|
|
340
|
-
"Pipeline phase: recon | hunt |
|
|
362
|
+
"Pipeline phase: recon | hunt | trace | skeptic | validate | chain | patch | report (legacy gapfil is accepted for older runs)",
|
|
341
363
|
});
|
|
342
364
|
|
|
343
365
|
/** run_id-only schema, shared by Scratchpad Init / Resume / Clear. */
|
|
@@ -676,13 +698,13 @@ export function detectHost(): "omp" | "pi" {
|
|
|
676
698
|
* case list DOES change as cases are added, so it is refreshed every prompt.
|
|
677
699
|
*
|
|
678
700
|
* mode selects the workflow text: "lite" injects the single-agent workflow
|
|
679
|
-
* (no subagent dispatch),
|
|
701
|
+
* (no subagent dispatch), "swarm" gets the full subagent pipeline,
|
|
680
702
|
* rendered for the host's dispatch convention (pi-subagents vs OMP task).
|
|
681
703
|
*/
|
|
682
704
|
function buildAgentInjection(
|
|
683
705
|
active: CaseRecord[],
|
|
684
706
|
includeWorkflow: boolean,
|
|
685
|
-
mode: XpMode = "
|
|
707
|
+
mode: XpMode = "swarm",
|
|
686
708
|
): string {
|
|
687
709
|
const caseList = buildCaseListContext(active);
|
|
688
710
|
if (!includeWorkflow) return caseList;
|
|
@@ -699,13 +721,15 @@ function buildAgentInjection(
|
|
|
699
721
|
// ── XP (offensive / exploit) mode toggle ─────────────────────────────
|
|
700
722
|
// Casefile historically injected the cyber workflow into every prompt.
|
|
701
723
|
// For normal dev work that is just noise, so XP mode defaults OFF. Enable
|
|
702
|
-
//
|
|
703
|
-
//
|
|
704
|
-
// /xp
|
|
724
|
+
// swarm for the bounded multi-agent variant, or lite for the single-agent
|
|
725
|
+
// attacker discipline. Toggle with /xp (off <-> swarm), or set explicitly with
|
|
726
|
+
// /xp on|lite|swarm|off. "on" means the default enabled SWARM mode; use "lite"
|
|
727
|
+
// for no subagent dispatch.
|
|
728
|
+
// Override per-session with PI_XP_MODE.
|
|
705
729
|
// Pure helpers exported for unit tests.
|
|
706
730
|
|
|
707
731
|
export const XP_MODE_ENV = "PI_XP_MODE";
|
|
708
|
-
export type XpMode = "
|
|
732
|
+
export type XpMode = "swarm" | "off" | "lite";
|
|
709
733
|
|
|
710
734
|
export function getXpModeStatePath(): string {
|
|
711
735
|
return join(dirname(getCasefilePath()), "xp-mode");
|
|
@@ -716,13 +740,14 @@ export function readXpMode(
|
|
|
716
740
|
statePath: string = getXpModeStatePath(),
|
|
717
741
|
): XpMode {
|
|
718
742
|
const env = (envValue ?? "").trim().toLowerCase();
|
|
719
|
-
if (env === "
|
|
743
|
+
if (env === "swarm") return "swarm";
|
|
744
|
+
if (env === "on" || env === "1" || env === "true") return "swarm";
|
|
720
745
|
if (env === "lite") return "lite";
|
|
721
746
|
if (env === "off" || env === "0" || env === "false") return "off";
|
|
722
747
|
try {
|
|
723
748
|
if (existsSync(statePath)) {
|
|
724
749
|
const v = readFileSync(statePath, "utf8").trim().toLowerCase();
|
|
725
|
-
if (v === "on") return "
|
|
750
|
+
if (v === "swarm" || v === "on") return "swarm";
|
|
726
751
|
if (v === "lite") return "lite";
|
|
727
752
|
if (v === "off") return "off";
|
|
728
753
|
}
|
|
@@ -742,16 +767,22 @@ export function writeXpMode(state: XpMode, statePath: string = getXpModeStatePat
|
|
|
742
767
|
|
|
743
768
|
export function parseXpModeArg(args: string, current: XpMode): XpMode {
|
|
744
769
|
const arg = (args ?? "").trim().toLowerCase();
|
|
745
|
-
if (arg === "
|
|
770
|
+
if (arg === "swarm") return "swarm";
|
|
771
|
+
if (arg === "on") return "swarm";
|
|
746
772
|
if (arg === "off") return "off";
|
|
747
773
|
if (arg === "lite") return "lite";
|
|
748
|
-
// Bare /xp
|
|
749
|
-
return current === "
|
|
774
|
+
// Bare /xp is the low-ceremony path: toggle the default XP workflow on/off.
|
|
775
|
+
return current === "off" ? "swarm" : "off";
|
|
750
776
|
}
|
|
751
777
|
|
|
752
778
|
// ── Main extension ────────────────────────────────────────────────────
|
|
753
779
|
|
|
754
780
|
export default function casefileExtension(pi: ExtensionAPI) {
|
|
781
|
+
// Process role is immutable for this extension instance. A worker may spawn
|
|
782
|
+
// shells, but unsetting PI_SUBAGENT_CHILD in a child shell cannot upgrade the
|
|
783
|
+
// already-loaded extension or reveal a tool that was omitted at startup.
|
|
784
|
+
const startedAsSubagent = process.env.PI_SUBAGENT_CHILD === "1";
|
|
785
|
+
const isSubagentProcess = () => startedAsSubagent || process.env.PI_SUBAGENT_CHILD === "1";
|
|
755
786
|
// Pin the workspace root ONCE at extension load. Every scratchpad / pipeline
|
|
756
787
|
// / PoC-path lookup otherwise re-walks the ambient cwd on each call — a
|
|
757
788
|
// mid-session `cd` would split state across two .scratchpad roots and
|
|
@@ -761,34 +792,37 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
761
792
|
setScratchpadRoot(workspaceRoot);
|
|
762
793
|
process.env.PI_POC_ROOT ??= workspaceRoot;
|
|
763
794
|
|
|
764
|
-
// ── Diagnostic Error Handler
|
|
765
|
-
const
|
|
766
|
-
|
|
795
|
+
// ── Diagnostic Error Handler ──
|
|
796
|
+
const registerCaseTool = <TParams extends TSchema, TDetails = unknown, TState = unknown>(
|
|
797
|
+
spec: ToolDefinition<TParams, TDetails, TState>,
|
|
798
|
+
) => {
|
|
767
799
|
const origExecute = spec.execute;
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
800
|
+
pi.registerTool({
|
|
801
|
+
...spec,
|
|
802
|
+
execute: async (...args: Parameters<typeof origExecute>) => {
|
|
803
|
+
try {
|
|
804
|
+
return await origExecute(...args);
|
|
805
|
+
} catch (err) {
|
|
806
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
807
|
+
let hint = "";
|
|
808
|
+
if (
|
|
809
|
+
message.includes("SQLITE") ||
|
|
810
|
+
message.includes("database") ||
|
|
811
|
+
message.includes("permission") ||
|
|
812
|
+
message.includes("readonly") ||
|
|
813
|
+
message.includes("lock")
|
|
814
|
+
) {
|
|
815
|
+
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`;
|
|
816
|
+
}
|
|
817
|
+
throw new Error(`${spec.name} failed: ${message}${hint}`, { cause: err });
|
|
782
818
|
}
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
};
|
|
786
|
-
originalRegisterTool(spec);
|
|
819
|
+
},
|
|
820
|
+
});
|
|
787
821
|
};
|
|
788
822
|
|
|
789
823
|
// ── Tool: CaseAdd ──
|
|
790
824
|
|
|
791
|
-
|
|
825
|
+
registerCaseTool({
|
|
792
826
|
name: "CaseAdd",
|
|
793
827
|
label: "Add Case",
|
|
794
828
|
description:
|
|
@@ -842,7 +876,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
842
876
|
|
|
843
877
|
// ── Tool: CaseUpdate ──
|
|
844
878
|
|
|
845
|
-
|
|
879
|
+
registerCaseTool({
|
|
846
880
|
name: "CaseUpdate",
|
|
847
881
|
label: "Update Case",
|
|
848
882
|
description:
|
|
@@ -897,11 +931,11 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
897
931
|
|
|
898
932
|
// ── Tool: EvidenceAdd ──
|
|
899
933
|
|
|
900
|
-
|
|
934
|
+
registerCaseTool({
|
|
901
935
|
name: "EvidenceAdd",
|
|
902
936
|
label: "Add Evidence Item",
|
|
903
937
|
description:
|
|
904
|
-
"Record a role-typed, artifact-backed evidence item on a case
|
|
938
|
+
"Record a role-typed, artifact-backed evidence item on a case. Artifact reads are restricted to regular, non-symlink files inside the workspace; bytes are copied durably and stored as basename + SHA-256. refutation items justify a kill; cleanup items track engagement cleanup before REPORT.",
|
|
905
939
|
promptSnippet: "Record a role-typed evidence item",
|
|
906
940
|
promptGuidelines: [
|
|
907
941
|
"Use EvidenceAdd for artifact-backed evidence: raw responses, logs, screenshots, disproof attempts — anything a claim should trace back to.",
|
|
@@ -917,7 +951,8 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
917
951
|
summary: params.summary as string,
|
|
918
952
|
artifactPath: params.artifact_path as string | undefined,
|
|
919
953
|
});
|
|
920
|
-
const record = getCaseById(params.case_id as string)
|
|
954
|
+
const record = getCaseById(params.case_id as string);
|
|
955
|
+
if (!record) throw new Error(`Case not found after evidence insert: ${params.case_id}`);
|
|
921
956
|
return {
|
|
922
957
|
content: [
|
|
923
958
|
{
|
|
@@ -990,7 +1025,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
990
1025
|
{ additionalProperties: false },
|
|
991
1026
|
);
|
|
992
1027
|
|
|
993
|
-
|
|
1028
|
+
registerCaseTool({
|
|
994
1029
|
name: "CoverageAdd",
|
|
995
1030
|
label: "Record Coverage",
|
|
996
1031
|
description:
|
|
@@ -1012,7 +1047,8 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1012
1047
|
note: params.note as string,
|
|
1013
1048
|
evidenceItemId: params.evidence_item_id as string | undefined,
|
|
1014
1049
|
});
|
|
1015
|
-
const record = getCaseById(params.case_id as string)
|
|
1050
|
+
const record = getCaseById(params.case_id as string);
|
|
1051
|
+
if (!record) throw new Error(`Case not found after coverage insert: ${params.case_id}`);
|
|
1016
1052
|
return {
|
|
1017
1053
|
content: [
|
|
1018
1054
|
{
|
|
@@ -1056,11 +1092,11 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1056
1092
|
{ additionalProperties: false },
|
|
1057
1093
|
);
|
|
1058
1094
|
|
|
1059
|
-
|
|
1095
|
+
registerCaseTool({
|
|
1060
1096
|
name: "CoverageReport",
|
|
1061
1097
|
label: "Coverage Matrix",
|
|
1062
1098
|
description:
|
|
1063
|
-
"Render the machine-checkable coverage matrix for a case: which (asset × attack-class) cells are tested, with wide-verdict propagation. Run before deciding
|
|
1099
|
+
"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.",
|
|
1064
1100
|
promptSnippet: "Show which attack classes were tested where",
|
|
1065
1101
|
promptGuidelines: [
|
|
1066
1102
|
"Run CoverageReport before claiming 'every class is COVERED/SKIPPED/NOT_FOUND' — the claim must match the matrix.",
|
|
@@ -1103,259 +1139,347 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1103
1139
|
},
|
|
1104
1140
|
});
|
|
1105
1141
|
|
|
1106
|
-
// ── Tool: PromoteFinding ──
|
|
1107
1142
|
// ── Tool: PromoteFinding (phase 1) ──
|
|
1108
1143
|
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
if (!controlTarget) {
|
|
1146
|
-
return fail(
|
|
1147
|
-
"control_target is REQUIRED: a distinct baseline target that lacks the vulnerability.",
|
|
1148
|
-
{ missingControlTarget: true },
|
|
1149
|
-
);
|
|
1150
|
-
}
|
|
1151
|
-
if (controlTarget === current.target) {
|
|
1152
|
-
return fail(
|
|
1153
|
-
"control_target must differ from the case target; a control run against the vulnerable target proves nothing.",
|
|
1154
|
-
{ controlTargetEqualsCaseTarget: true },
|
|
1155
|
-
);
|
|
1156
|
-
}
|
|
1157
|
-
|
|
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.
|
|
1160
|
-
const pocPath = (params.poc_path as string | undefined)?.trim() ?? "";
|
|
1161
|
-
let pocHash: string | undefined;
|
|
1162
|
-
let controlHash: string | undefined;
|
|
1163
|
-
try {
|
|
1164
|
-
pocHash = createHash("sha256").update(readFileSync(pocPath)).digest("hex");
|
|
1165
|
-
controlHash = createHash("sha256").update(readFileSync(controlPath)).digest("hex");
|
|
1166
|
-
} catch (e) {
|
|
1167
|
-
return fail(
|
|
1168
|
-
`Cannot read PoC/control scripts for the same-file check: ${(e as Error).message}`,
|
|
1169
|
-
{ sameFileCheckFailed: true },
|
|
1170
|
-
);
|
|
1171
|
-
}
|
|
1172
|
-
if (pocHash !== controlHash) {
|
|
1173
|
-
return fail(
|
|
1174
|
-
"CONTROL CHECK FAILED: control_path must be the SAME script as poc_path (sha256 mismatch). Case remains investigating.",
|
|
1175
|
-
{ controlHashMismatch: true },
|
|
1176
|
-
);
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
const runOptions = (pocMode: string, target: string): PocRunOptions => ({
|
|
1180
|
-
network: params.local === true ? "host" : "none",
|
|
1181
|
-
local: params.local === true,
|
|
1182
|
-
env: { PI_POC_MODE: pocMode, PI_POC_TARGET: target },
|
|
1183
|
-
});
|
|
1144
|
+
if (!startedAsSubagent)
|
|
1145
|
+
registerCaseTool({
|
|
1146
|
+
name: "PromoteFinding",
|
|
1147
|
+
label: "Run PoC Evidence",
|
|
1148
|
+
description:
|
|
1149
|
+
"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.",
|
|
1150
|
+
promptSnippet:
|
|
1151
|
+
"Phase 1: run PoC evidence (target x2 + control) and record the pending bundle",
|
|
1152
|
+
promptGuidelines: [
|
|
1153
|
+
"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.",
|
|
1154
|
+
"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.",
|
|
1155
|
+
"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.",
|
|
1156
|
+
"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.",
|
|
1157
|
+
"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.",
|
|
1158
|
+
"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.",
|
|
1159
|
+
"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.",
|
|
1160
|
+
"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.",
|
|
1161
|
+
],
|
|
1162
|
+
parameters: PromoteSchema,
|
|
1163
|
+
|
|
1164
|
+
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1165
|
+
if (isSubagentProcess()) {
|
|
1166
|
+
throw new Error(
|
|
1167
|
+
"PromoteFinding is reserved for the main/coordinator agent. A worker or subagent may gather evidence but cannot run validation or create a promotion bundle.",
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
1170
|
+
// Validate promotability BEFORE running the PoC — each sandboxed run can
|
|
1171
|
+
// take 30s (plus first-time image pull), so fail cheap when the case
|
|
1172
|
+
// can't advance anyway (missing, wrong status, missing required fields,
|
|
1173
|
+
// missing artifact-backed observation evidence).
|
|
1174
|
+
const caseId = params.id as string;
|
|
1175
|
+
const current = assertPromotable(caseId);
|
|
1176
|
+
|
|
1177
|
+
const fail = (text: string, _extra?: Record<string, unknown>): never => {
|
|
1178
|
+
throw new Error(text);
|
|
1179
|
+
};
|
|
1184
1180
|
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1181
|
+
const pocPath = (params.poc_path as string | undefined)?.trim() ?? "";
|
|
1182
|
+
const controlPath = (params.control_path as string | undefined)?.trim() || pocPath;
|
|
1183
|
+
const controlTarget = (params.control_target as string | undefined)?.trim() ?? "";
|
|
1184
|
+
if (!pocPath) {
|
|
1185
|
+
return fail("poc_path is REQUIRED: absolute path to the PoC script run by the harness.", {
|
|
1186
|
+
missingPocPath: true,
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
if (!controlTarget) {
|
|
1190
|
+
return fail(
|
|
1191
|
+
"control_target is REQUIRED: a distinct baseline target that lacks the vulnerability.",
|
|
1192
|
+
{ missingControlTarget: true },
|
|
1193
|
+
);
|
|
1194
|
+
}
|
|
1195
|
+
if (controlTarget === current.target) {
|
|
1196
|
+
return fail(
|
|
1197
|
+
"control_target must differ from the case target; a control run against the vulnerable target proves nothing.",
|
|
1198
|
+
{ controlTargetEqualsCaseTarget: true },
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
if (params.local === true && process.env.PI_POC_ALLOW_NETWORK !== "1") {
|
|
1202
|
+
return fail(
|
|
1203
|
+
"Networked PoC execution is operator-gated. Set PI_POC_ALLOW_NETWORK=1 to authorize the host-network sandbox for this session.",
|
|
1204
|
+
{ networkNotAuthorized: true },
|
|
1205
|
+
);
|
|
1206
|
+
}
|
|
1207
|
+
const controlAuthorization = controlTargetAuthorizationError(controlTarget);
|
|
1208
|
+
if (controlAuthorization) {
|
|
1209
|
+
return fail(
|
|
1210
|
+
`CONTROL AUTHORIZATION FAILED: ${controlAuthorization}. ` +
|
|
1211
|
+
"The operator must set PI_POC_CONTROL_TARGETS to the exact approved control host/origin before this control can anchor confirmation.",
|
|
1212
|
+
{ controlNotAuthorized: true },
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1192
1215
|
|
|
1193
|
-
|
|
1194
|
-
|
|
1216
|
+
// Same-file contract (anti-cheat): control must be the SAME bytes as the
|
|
1217
|
+
// PoC, differing only via the harness-set env. Check BEFORE any run.
|
|
1218
|
+
let pocHash: string | undefined;
|
|
1219
|
+
let controlHash: string | undefined;
|
|
1220
|
+
try {
|
|
1221
|
+
pocHash = createHash("sha256").update(readFileSync(pocPath)).digest("hex");
|
|
1222
|
+
controlHash = createHash("sha256").update(readFileSync(controlPath)).digest("hex");
|
|
1223
|
+
} catch (e) {
|
|
1195
1224
|
return fail(
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
". A crash is not evidence. Case remains investigating.",
|
|
1199
|
-
{ run: r, pocCrashed: true },
|
|
1225
|
+
`Cannot read PoC/control scripts for the same-file check: ${(e as Error).message}`,
|
|
1226
|
+
{ sameFileCheckFailed: true },
|
|
1200
1227
|
);
|
|
1201
1228
|
}
|
|
1202
|
-
if (
|
|
1229
|
+
if (pocHash !== controlHash) {
|
|
1203
1230
|
return fail(
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
"the file is bound to this run and validated by the harness. Case remains investigating.",
|
|
1207
|
-
{ run: r, evidenceError: r.evidenceError },
|
|
1231
|
+
"CONTROL CHECK FAILED: control_path must be the SAME script as poc_path (sha256 mismatch). Case remains investigating.",
|
|
1232
|
+
{ controlHashMismatch: true },
|
|
1208
1233
|
);
|
|
1209
1234
|
}
|
|
1210
|
-
|
|
1211
|
-
|
|
1235
|
+
|
|
1236
|
+
// ── OOB callback (Tier 1, opt-in for blind classes) ──
|
|
1237
|
+
const oobRequested = params.oob === true;
|
|
1238
|
+
if (oobRequested) {
|
|
1239
|
+
return fail(
|
|
1240
|
+
"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.",
|
|
1241
|
+
{ oobSourceSeparationRequired: true },
|
|
1242
|
+
);
|
|
1212
1243
|
}
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1244
|
+
const runOptions = (pocMode: string, target: string): PocRunOptions => ({
|
|
1245
|
+
network: params.local === true ? "host" : "none",
|
|
1246
|
+
local: params.local === true,
|
|
1247
|
+
env: {
|
|
1248
|
+
PI_POC_MODE: pocMode,
|
|
1249
|
+
PI_POC_TARGET: target,
|
|
1250
|
+
},
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
const caseTarget = current.target ?? "";
|
|
1254
|
+
// Determinism: TWO target runs + one control run. Exit 0 is run
|
|
1255
|
+
// integrity only; nonce-bound body evidence and the harness-owned
|
|
1256
|
+
// target/control replay form the machine gate.
|
|
1257
|
+
const run1 = runPoc(pocPath, runOptions("poc", caseTarget));
|
|
1258
|
+
const run2 = runPoc(pocPath, runOptions("poc", caseTarget));
|
|
1259
|
+
const controlRun = runPoc(controlPath, runOptions("control", controlTarget));
|
|
1260
|
+
|
|
1261
|
+
const evidenceRun = (
|
|
1262
|
+
r: PocRun,
|
|
1263
|
+
mode: "poc" | "control",
|
|
1264
|
+
target: string,
|
|
1265
|
+
): PocEvidenceRun => {
|
|
1266
|
+
if (!r.completed || !r.outputComplete) {
|
|
1267
|
+
return fail(
|
|
1268
|
+
`${mode} run did not complete or output capture was incomplete` +
|
|
1269
|
+
(r.infraError ? ` (infra: ${r.output.trim()})` : "") +
|
|
1270
|
+
". A crash is not evidence. Case remains investigating.",
|
|
1271
|
+
{ run: r, pocCrashed: true },
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1274
|
+
if (r.evidenceError) {
|
|
1275
|
+
return fail(
|
|
1276
|
+
`EVIDENCE CONTRACT FAILED (${mode} run): ${r.evidenceError}. ` +
|
|
1277
|
+
"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 — " +
|
|
1278
|
+
"the file is bound to this run and validated by the harness. Case remains investigating.",
|
|
1279
|
+
{ run: r, evidenceError: r.evidenceError },
|
|
1280
|
+
);
|
|
1281
|
+
}
|
|
1282
|
+
if (!r.evidence || !r.evidenceSha256 || !r.nonce) {
|
|
1283
|
+
return fail(`${mode} run produced no evidence. Case remains investigating.`, {
|
|
1284
|
+
run: r,
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
return {
|
|
1288
|
+
mode,
|
|
1289
|
+
target,
|
|
1290
|
+
nonce: r.nonce,
|
|
1291
|
+
ranAt: r.ranAt,
|
|
1292
|
+
exitCode: r.exitCode,
|
|
1293
|
+
sandbox: r.sandbox,
|
|
1294
|
+
completed: r.completed,
|
|
1295
|
+
outputComplete: r.outputComplete,
|
|
1296
|
+
output: r.output ?? "",
|
|
1297
|
+
evidence: r.evidence,
|
|
1298
|
+
evidenceSha256: r.evidenceSha256,
|
|
1299
|
+
evidencePath: r.evidencePath,
|
|
1300
|
+
};
|
|
1226
1301
|
};
|
|
1227
|
-
};
|
|
1228
1302
|
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
};
|
|
1303
|
+
const targetRuns: [PocEvidenceRun, PocEvidenceRun] = [
|
|
1304
|
+
evidenceRun(run1, "poc", caseTarget),
|
|
1305
|
+
evidenceRun(run2, "poc", caseTarget),
|
|
1306
|
+
];
|
|
1307
|
+
const control = evidenceRun(controlRun, "control", controlTarget);
|
|
1308
|
+
|
|
1309
|
+
// Tier 2 (docs/poc-trust-model.md): the harness executes the SAME
|
|
1310
|
+
// request template against target and operator-approved control, applying
|
|
1311
|
+
// the target's predicates to both. DNS is pinned at connect time.
|
|
1312
|
+
const harnessVerified = await replayDifferential(
|
|
1313
|
+
targetRuns[0].evidence,
|
|
1314
|
+
caseTarget,
|
|
1315
|
+
controlTarget,
|
|
1316
|
+
{ allowPrivate: process.env.PI_POC_ALLOW_PRIVATE_REPLAY === "1" },
|
|
1317
|
+
);
|
|
1245
1318
|
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1319
|
+
const bundle: PendingConfirmation = {
|
|
1320
|
+
caseId,
|
|
1321
|
+
ranAt: new Date().toISOString(),
|
|
1322
|
+
pocPath,
|
|
1323
|
+
pocSha256: pocHash,
|
|
1324
|
+
controlPath,
|
|
1325
|
+
controlTarget,
|
|
1326
|
+
targetRuns,
|
|
1327
|
+
controlRun: control,
|
|
1328
|
+
harnessVerified,
|
|
1329
|
+
};
|
|
1254
1330
|
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1331
|
+
let record: CaseRecord;
|
|
1332
|
+
try {
|
|
1333
|
+
record = storePendingConfirmation(caseId, bundle);
|
|
1334
|
+
} catch (e) {
|
|
1335
|
+
return fail(`Pending confirmation rejected: ${(e as Error).message}`, {
|
|
1336
|
+
storeRejected: true,
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
return {
|
|
1341
|
+
content: [
|
|
1342
|
+
{
|
|
1343
|
+
type: "text",
|
|
1344
|
+
text:
|
|
1345
|
+
`Phase 1 complete — evidence bundle recorded on ${caseId} (expires in 1h).\n` +
|
|
1346
|
+
`Target runs: 2, Control run: 1 — all with validated nonce-bound evidence.json.\n` +
|
|
1347
|
+
`Evidence sha256: ${targetRuns[0].evidenceSha256}\n` +
|
|
1348
|
+
`PoC script sha256 (at run time): ${pocHash}\n` +
|
|
1349
|
+
`Harness verify replay: ${harnessVerified.attempted ? (harnessVerified.pass ? `PASS (status ${harnessVerified.status})` : `FAILED — ${harnessVerified.note}`) : harnessVerified.note}\n` +
|
|
1350
|
+
`\nMAIN-AGENT REVIEW REQUIRED (do not delegate): inspect case ${caseId}, PoC ${pocPath}, 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 target/control replay; NOT_CONFIRMED keeps the case investigating.`,
|
|
1351
|
+
},
|
|
1352
|
+
],
|
|
1353
|
+
details: {
|
|
1354
|
+
record,
|
|
1355
|
+
bundle: {
|
|
1356
|
+
caseId,
|
|
1357
|
+
ranAt: bundle.ranAt,
|
|
1358
|
+
pocPath,
|
|
1359
|
+
controlPath,
|
|
1360
|
+
controlTarget,
|
|
1361
|
+
pocSha256: pocHash,
|
|
1362
|
+
evidenceSha256: targetRuns[0].evidenceSha256,
|
|
1363
|
+
harnessVerified,
|
|
1364
|
+
},
|
|
1280
1365
|
},
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
|
-
},
|
|
1366
|
+
};
|
|
1367
|
+
},
|
|
1284
1368
|
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1369
|
+
renderCall(args, theme) {
|
|
1370
|
+
return callLine(theme, "PromoteFinding", (args.id as string) ?? "");
|
|
1371
|
+
},
|
|
1288
1372
|
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1373
|
+
renderResult(result, _opts, theme) {
|
|
1374
|
+
const details = result.details as { bundle?: { evidenceSha256?: string } } | undefined;
|
|
1375
|
+
if (!details?.bundle) {
|
|
1376
|
+
return new Text(theme.fg("error", "✗ PromoteFinding failed"), 0, 0);
|
|
1377
|
+
}
|
|
1378
|
+
return new Text(
|
|
1379
|
+
theme.fg("success", "✓ ") +
|
|
1380
|
+
theme.fg("dim", "evidence bundle ") +
|
|
1381
|
+
theme.fg("muted", details.bundle.evidenceSha256?.slice(0, 12) ?? ""),
|
|
1382
|
+
0,
|
|
1383
|
+
0,
|
|
1384
|
+
);
|
|
1385
|
+
},
|
|
1386
|
+
});
|
|
1303
1387
|
|
|
1304
1388
|
// ── Tool: ConfirmFinding (phase 2) ──
|
|
1305
1389
|
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
"
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1390
|
+
// Do not expose the commit capability in a worker process at all. The
|
|
1391
|
+
// execute-time check remains as defense in depth if process state changes
|
|
1392
|
+
// after registration or another integration forwards a stale tool handle.
|
|
1393
|
+
if (!startedAsSubagent)
|
|
1394
|
+
registerCaseTool({
|
|
1395
|
+
name: "ConfirmFinding",
|
|
1396
|
+
label: "Main-Agent Confirmation",
|
|
1397
|
+
description:
|
|
1398
|
+
"Phase 2 of confirmation, reserved for the main/coordinator agent: commit or refuse promotion after personally reviewing the machine bundle. On CONFIRMED, this tool performs a fresh harness-owned target/control replay; the verdict requires a target-only differential, a concrete re_execution_note and disconfirmation_attempt, a canary assessment, and the still-valid PromoteFinding bundle. The machine transcript is evidence, not the semantic vulnerability verdict. Worker/subagent processes are rejected. NOT_CONFIRMED records the review and keeps the case investigating.",
|
|
1399
|
+
promptSnippet: "Main agent: independently review and commit or refuse PoC confirmation",
|
|
1400
|
+
promptGuidelines: [
|
|
1401
|
+
"Run only in the main/coordinator agent after PromoteFinding returns. Do not dispatch a worker to decide or author this verdict.",
|
|
1402
|
+
"Personally inspect the PoC and preserved evidence and try a concrete disconfirmation before deciding. ConfirmFinding itself re-sends the immutable verify request against target and operator-approved control so phase 2 has a harness-owned transcript.",
|
|
1403
|
+
"CONFIRMED requires differential: 'target_only', re_execution_note, and disconfirmation_attempt (the main agent's failed disproof). A verdict missing any of these is rejected.",
|
|
1404
|
+
"Set canary_assessment='verified' when the immutable request declared a canary; otherwise set not_applicable and explain why a reflection canary is not meaningful for this exploit class.",
|
|
1405
|
+
"NOT_CONFIRMED is final for that attempt — the case stays investigating with the main agent's reasoning recorded. A fresh PromoteFinding run is required for another attempt.",
|
|
1406
|
+
"Never CaseUpdate status='confirmed' directly — it is rejected. Always use PromoteFinding + ConfirmFinding.",
|
|
1407
|
+
],
|
|
1408
|
+
parameters: ConfirmSchema,
|
|
1409
|
+
|
|
1410
|
+
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1411
|
+
if (isSubagentProcess()) {
|
|
1412
|
+
throw new Error(
|
|
1413
|
+
"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.",
|
|
1414
|
+
);
|
|
1415
|
+
}
|
|
1416
|
+
const caseId = params.id as string;
|
|
1417
|
+
const parsedVerdict = validateMainAgentVerdict(params.verdict);
|
|
1418
|
+
if (!parsedVerdict.ok) {
|
|
1419
|
+
throw new Error(`Invalid main-agent confirmation verdict: ${parsedVerdict.error}`);
|
|
1420
|
+
}
|
|
1421
|
+
let phase2Verification: MainAgentVerification | undefined;
|
|
1422
|
+
if (parsedVerdict.verdict.verdict === "CONFIRMED") {
|
|
1423
|
+
const current = getCaseById(caseId);
|
|
1424
|
+
if (!current) throw new Error(`Case not found: ${caseId}`);
|
|
1425
|
+
const bundle = current.pendingConfirmation;
|
|
1426
|
+
if (!bundle) {
|
|
1427
|
+
throw new Error("No pending confirmation on this case — run PromoteFinding first");
|
|
1428
|
+
}
|
|
1429
|
+
const controlAuthorizationError = controlTargetAuthorizationError(bundle.controlTarget);
|
|
1430
|
+
if (controlAuthorizationError) {
|
|
1431
|
+
throw new Error(`CONTROL AUTHORIZATION FAILED: ${controlAuthorizationError}`);
|
|
1432
|
+
}
|
|
1433
|
+
const replay = await replayDifferential(
|
|
1434
|
+
bundle.targetRuns[0].evidence,
|
|
1435
|
+
current.target ?? bundle.targetRuns[0].target,
|
|
1436
|
+
bundle.controlTarget,
|
|
1437
|
+
{ allowPrivate: process.env.PI_POC_ALLOW_PRIVATE_REPLAY === "1" },
|
|
1438
|
+
);
|
|
1439
|
+
phase2Verification = {
|
|
1440
|
+
at: new Date().toISOString(),
|
|
1441
|
+
result: replay,
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
const result = applyConfirmationResult(caseId, parsedVerdict.verdict, phase2Verification, {
|
|
1445
|
+
startedAsSubagent: isSubagentProcess(),
|
|
1446
|
+
});
|
|
1447
|
+
const record = result.record;
|
|
1448
|
+
const promoted = record.status === "confirmed";
|
|
1449
|
+
return {
|
|
1450
|
+
content: [
|
|
1451
|
+
{
|
|
1452
|
+
type: "text",
|
|
1453
|
+
text: promoted
|
|
1454
|
+
? `Main agent CONFIRMED. Case promoted:
|
|
1331
1455
|
${formatCaseDetail(record)}`
|
|
1332
|
-
|
|
1456
|
+
: `Main agent NOT_CONFIRMED — case stays investigating (attempt recorded):
|
|
1333
1457
|
${formatCaseDetail(record)}`,
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1458
|
+
},
|
|
1459
|
+
],
|
|
1460
|
+
details: { record, promoted, changed: result.changed },
|
|
1461
|
+
};
|
|
1462
|
+
},
|
|
1339
1463
|
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1464
|
+
renderCall(args, theme) {
|
|
1465
|
+
return callLine(theme, "ConfirmFinding", (args.id as string) ?? "");
|
|
1466
|
+
},
|
|
1343
1467
|
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1468
|
+
renderResult(result, _opts, theme) {
|
|
1469
|
+
const details = result.details as { promoted?: boolean } | undefined;
|
|
1470
|
+
return new Text(
|
|
1471
|
+
details?.promoted
|
|
1472
|
+
? theme.fg("success", "✓ Promoted")
|
|
1473
|
+
: theme.fg("warning", "↷ Not confirmed"),
|
|
1474
|
+
0,
|
|
1475
|
+
0,
|
|
1476
|
+
);
|
|
1477
|
+
},
|
|
1478
|
+
});
|
|
1355
1479
|
|
|
1356
1480
|
// ── Tool: CaseGet ──
|
|
1357
1481
|
|
|
1358
|
-
|
|
1482
|
+
registerCaseTool({
|
|
1359
1483
|
name: "CaseGet",
|
|
1360
1484
|
label: "Get Case",
|
|
1361
1485
|
description: "Get full details of a single case by ID.",
|
|
@@ -1384,7 +1508,7 @@ ${formatCaseDetail(record)}`,
|
|
|
1384
1508
|
|
|
1385
1509
|
// ── Tool: CaseList ──
|
|
1386
1510
|
|
|
1387
|
-
|
|
1511
|
+
registerCaseTool({
|
|
1388
1512
|
name: "CaseList",
|
|
1389
1513
|
label: "List Cases",
|
|
1390
1514
|
description:
|
|
@@ -1414,7 +1538,7 @@ ${formatCaseDetail(record)}`,
|
|
|
1414
1538
|
|
|
1415
1539
|
// ── Tool: CaseSearch ──
|
|
1416
1540
|
|
|
1417
|
-
|
|
1541
|
+
registerCaseTool({
|
|
1418
1542
|
name: "CaseSearch",
|
|
1419
1543
|
label: "Search Cases",
|
|
1420
1544
|
description:
|
|
@@ -1442,7 +1566,7 @@ ${formatCaseDetail(record)}`,
|
|
|
1442
1566
|
|
|
1443
1567
|
// ── Tool: CaseLink ──
|
|
1444
1568
|
|
|
1445
|
-
|
|
1569
|
+
registerCaseTool({
|
|
1446
1570
|
name: "CaseLink",
|
|
1447
1571
|
label: "Link Cases",
|
|
1448
1572
|
description:
|
|
@@ -1514,7 +1638,7 @@ ${formatCaseDetail(record)}`,
|
|
|
1514
1638
|
|
|
1515
1639
|
// ── Tool: CaseUnlink ──
|
|
1516
1640
|
|
|
1517
|
-
|
|
1641
|
+
registerCaseTool({
|
|
1518
1642
|
name: "CaseUnlink",
|
|
1519
1643
|
label: "Unlink Cases",
|
|
1520
1644
|
description: "Remove a bidirectional link between two cases.",
|
|
@@ -1581,7 +1705,7 @@ ${formatCaseDetail(record)}`,
|
|
|
1581
1705
|
{ additionalProperties: false },
|
|
1582
1706
|
);
|
|
1583
1707
|
|
|
1584
|
-
|
|
1708
|
+
registerCaseTool({
|
|
1585
1709
|
name: "ChainSuggest",
|
|
1586
1710
|
label: "Suggest Exploit Chains",
|
|
1587
1711
|
description:
|
|
@@ -1595,7 +1719,9 @@ ${formatCaseDetail(record)}`,
|
|
|
1595
1719
|
parameters: ChainSuggestSchema,
|
|
1596
1720
|
|
|
1597
1721
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1598
|
-
const suggestions =
|
|
1722
|
+
const suggestions = await suggestChainsAsync(
|
|
1723
|
+
(params.case_id as string | undefined) ?? undefined,
|
|
1724
|
+
);
|
|
1599
1725
|
const lines: string[] = [
|
|
1600
1726
|
suggestions.length
|
|
1601
1727
|
? `${suggestions.length} chain candidate(s):`
|
|
@@ -1628,25 +1754,25 @@ ${formatCaseDetail(record)}`,
|
|
|
1628
1754
|
|
|
1629
1755
|
// ── Tool: CaseContext ──
|
|
1630
1756
|
|
|
1631
|
-
|
|
1757
|
+
registerCaseTool({
|
|
1632
1758
|
name: "CaseContext",
|
|
1633
1759
|
label: "Generate Case Context",
|
|
1634
1760
|
description:
|
|
1635
|
-
"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
|
|
1636
|
-
promptSnippet: "Generate case context for the report
|
|
1761
|
+
"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.",
|
|
1762
|
+
promptSnippet: "Generate case context for the final report",
|
|
1637
1763
|
promptGuidelines: [
|
|
1638
1764
|
"Use CaseContext only for confirmed or already reported cases. Keep hypotheses and investigating cases in the ledger until proof is captured.",
|
|
1639
|
-
"After CaseContext,
|
|
1765
|
+
"After CaseContext, write the final report to the returned report path yourself, then CaseUpdate(status: 'reported').",
|
|
1640
1766
|
],
|
|
1641
1767
|
parameters: IdSchema,
|
|
1642
1768
|
|
|
1643
1769
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1644
|
-
const { path, contextPath, record } =
|
|
1770
|
+
const { path, contextPath, record } = await writeCaseContextAsync(params.id as string);
|
|
1645
1771
|
return {
|
|
1646
1772
|
content: [
|
|
1647
1773
|
{
|
|
1648
1774
|
type: "text",
|
|
1649
|
-
text: `Case context written: ${contextPath}\nReport path
|
|
1775
|
+
text: `Case context written: ${contextPath}\nReport path: ${path}\n${formatCase(record)}`,
|
|
1650
1776
|
},
|
|
1651
1777
|
],
|
|
1652
1778
|
details: { path, contextPath, record },
|
|
@@ -1671,7 +1797,7 @@ ${formatCaseDetail(record)}`,
|
|
|
1671
1797
|
|
|
1672
1798
|
pi.registerCommand("xp", {
|
|
1673
1799
|
description:
|
|
1674
|
-
"Toggle casefile XP (offensive) mode.
|
|
1800
|
+
"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]",
|
|
1675
1801
|
handler: async (args, ctx) => {
|
|
1676
1802
|
const next = parseXpModeArg(args ?? "", readXpMode());
|
|
1677
1803
|
writeXpMode(next);
|
|
@@ -1682,23 +1808,25 @@ ${formatCaseDetail(record)}`,
|
|
|
1682
1808
|
if (next !== "off") workflowInjected = false;
|
|
1683
1809
|
ctx.ui.notify(
|
|
1684
1810
|
`Casefile XP mode: ${next.toUpperCase()} (takes effect on the next prompt)`,
|
|
1685
|
-
next === "
|
|
1811
|
+
next === "off" ? "warning" : "info",
|
|
1686
1812
|
);
|
|
1687
1813
|
},
|
|
1688
1814
|
});
|
|
1689
1815
|
|
|
1690
1816
|
// ── Tool: PipelineSubmit ──
|
|
1691
1817
|
|
|
1692
|
-
|
|
1818
|
+
registerCaseTool({
|
|
1693
1819
|
name: "PipelineSubmit",
|
|
1694
1820
|
label: "Submit Stage Output",
|
|
1695
1821
|
description:
|
|
1696
1822
|
"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.",
|
|
1697
1823
|
promptSnippet: "Validate and submit a pipeline stage's output",
|
|
1698
1824
|
promptGuidelines: [
|
|
1699
|
-
"Every stage output
|
|
1825
|
+
"Every delegated stage output and every main-agent VALIDATE/REPORT output must go through PipelineSubmit before the next stage starts — do not eyeball schemas.",
|
|
1700
1826
|
"verdict repair → fix the listed fields and re-submit the same output; budget is 2 attempts per finding, then rejected.",
|
|
1701
|
-
"Skeptic: unparseable/schema-invalid =
|
|
1827
|
+
"Skeptic: unparseable/schema-invalid = no verdict (repair/re-dispatch); only schema-valid DISPROVEN kills, and schema-valid UNDETERMINED blocks validation.",
|
|
1828
|
+
"Tracer crash/invalid output = no trace verdict; repair or re-dispatch.",
|
|
1829
|
+
'Only schema-valid trace_result: "UNREACHABLE" blocks advancement as a proven unreachable path; schema-valid UNDETERMINED blocks validation until resolved.',
|
|
1702
1830
|
"Test-path findings and hallucinated files are rejected by the pre-filter, not repairable — the finding itself is noise.",
|
|
1703
1831
|
],
|
|
1704
1832
|
parameters: Type.Object(
|
|
@@ -1758,7 +1886,7 @@ ${formatCaseDetail(record)}`,
|
|
|
1758
1886
|
|
|
1759
1887
|
// ── Tool: ScratchpadInit ──
|
|
1760
1888
|
|
|
1761
|
-
|
|
1889
|
+
registerCaseTool({
|
|
1762
1890
|
name: "ScratchpadInit",
|
|
1763
1891
|
label: "Init Scratchpad",
|
|
1764
1892
|
description:
|
|
@@ -1796,7 +1924,7 @@ ${formatCaseDetail(record)}`,
|
|
|
1796
1924
|
|
|
1797
1925
|
// ── Tool: ScratchpadResume ──
|
|
1798
1926
|
|
|
1799
|
-
|
|
1927
|
+
registerCaseTool({
|
|
1800
1928
|
name: "ScratchpadResume",
|
|
1801
1929
|
label: "Resume Scratchpad",
|
|
1802
1930
|
description:
|
|
@@ -1855,7 +1983,7 @@ ${formatCaseDetail(record)}`,
|
|
|
1855
1983
|
|
|
1856
1984
|
// ── Tool: ScratchpadCheckpoint ──
|
|
1857
1985
|
|
|
1858
|
-
|
|
1986
|
+
registerCaseTool({
|
|
1859
1987
|
name: "ScratchpadCheckpoint",
|
|
1860
1988
|
label: "Checkpoint Phase",
|
|
1861
1989
|
description:
|
|
@@ -1905,7 +2033,7 @@ ${formatCaseDetail(record)}`,
|
|
|
1905
2033
|
|
|
1906
2034
|
// ── Tool: ScratchpadWrite ──
|
|
1907
2035
|
|
|
1908
|
-
|
|
2036
|
+
registerCaseTool({
|
|
1909
2037
|
name: "ScratchpadWrite",
|
|
1910
2038
|
label: "Write Artifact",
|
|
1911
2039
|
description:
|
|
@@ -1951,7 +2079,7 @@ ${formatCaseDetail(record)}`,
|
|
|
1951
2079
|
|
|
1952
2080
|
// ── Tool: ScratchpadRead ──
|
|
1953
2081
|
|
|
1954
|
-
|
|
2082
|
+
registerCaseTool({
|
|
1955
2083
|
name: "ScratchpadRead",
|
|
1956
2084
|
label: "Read Artifact",
|
|
1957
2085
|
description:
|
|
@@ -2008,7 +2136,7 @@ ${formatCaseDetail(record)}`,
|
|
|
2008
2136
|
|
|
2009
2137
|
// ── Tool: ScratchpadPhaseDone ──
|
|
2010
2138
|
|
|
2011
|
-
|
|
2139
|
+
registerCaseTool({
|
|
2012
2140
|
name: "ScratchpadPhaseDone",
|
|
2013
2141
|
label: "Phase Done?",
|
|
2014
2142
|
description:
|
|
@@ -2051,7 +2179,7 @@ ${formatCaseDetail(record)}`,
|
|
|
2051
2179
|
|
|
2052
2180
|
// ── Tool: ScratchpadClear ──
|
|
2053
2181
|
|
|
2054
|
-
|
|
2182
|
+
registerCaseTool({
|
|
2055
2183
|
name: "ScratchpadClear",
|
|
2056
2184
|
label: "Clear Run",
|
|
2057
2185
|
description:
|
|
@@ -2135,7 +2263,7 @@ ${formatCaseDetail(record)}`,
|
|
|
2135
2263
|
// the workflow + entire active-case ledger into every child dispatch is a
|
|
2136
2264
|
// token multiplier (N subagents × workflow + growing case list per turn) —
|
|
2137
2265
|
// workers get what they need via their task and tool guidelines.
|
|
2138
|
-
if (
|
|
2266
|
+
if (isSubagentProcess()) return;
|
|
2139
2267
|
|
|
2140
2268
|
const includeWorkflow = !workflowInjected;
|
|
2141
2269
|
|