@xaccefy/pi-casefile 0.9.4 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -67
- package/package.json +13 -16
- package/src/confirmation.ts +729 -0
- package/src/evidence.ts +4 -4
- package/src/index.ts +189 -293
- package/src/ledger-internal.ts +321 -0
- package/src/ledger.ts +75 -1142
- package/src/oob-oracle.ts +279 -0
- package/src/poc-runner.ts +10 -0
- package/src/scratchpad.ts +5 -6
- package/src/workflow.ts +46 -327
- package/skills/casefile/SKILL.md +0 -44
- package/src/ledger-worker-entry.ts +0 -35
- package/src/ledger-worker.ts +0 -77
- package/src/pipeline-submit.ts +0 -797
package/src/index.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Casefile — offensive security case tracker for Pi.
|
|
3
3
|
*
|
|
4
|
-
* Tools: CaseAdd, CaseUpdate, PromoteFinding, ConfirmFinding, EvidenceAdd, CoverageAdd, CoverageReport,
|
|
4
|
+
* Tools: CaseAdd, CaseUpdate, PromoteFinding, ConfirmFinding, EvidenceAdd, CoverageAdd, CoverageReport, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseContext, ScratchpadInit, ScratchpadResume, ScratchpadCheckpoint, ScratchpadWrite, ScratchpadRead, ScratchpadPhaseDone, ScratchpadClear
|
|
5
5
|
* Command: /casefile — interactive dashboard
|
|
6
|
-
* Event: before_agent_start — injects
|
|
6
|
+
* Event: before_agent_start — injects the recon workflow once per session, refreshes the active case list per prompt
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { createHash } from "node:crypto";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
12
|
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
14
14
|
import { type TSchema, Type } from "typebox";
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
LINK_KIND_VALUES,
|
|
56
56
|
linkCasesResult,
|
|
57
57
|
type MainAgentVerification,
|
|
58
|
+
type OobVerification,
|
|
58
59
|
type PendingConfirmation,
|
|
59
60
|
type PocEvidenceRun,
|
|
60
61
|
PRIORITY_VALUES,
|
|
@@ -69,8 +70,14 @@ import {
|
|
|
69
70
|
unlinkCasesResult,
|
|
70
71
|
updateCaseResult,
|
|
71
72
|
} from "./ledger.ts";
|
|
72
|
-
import {
|
|
73
|
-
import {
|
|
73
|
+
import { writeCaseContext } from "./ledger.ts";
|
|
74
|
+
import {
|
|
75
|
+
type OobOracleConfig,
|
|
76
|
+
type ProvisionedCallback,
|
|
77
|
+
provisionCallback,
|
|
78
|
+
readOobOracleConfig,
|
|
79
|
+
verifyOobDifferential,
|
|
80
|
+
} from "./oob-oracle.ts";
|
|
74
81
|
import { type PocRun, type PocRunOptions, runPoc } from "./poc-runner.ts";
|
|
75
82
|
import {
|
|
76
83
|
detectWorkspaceRoot,
|
|
@@ -86,11 +93,7 @@ import {
|
|
|
86
93
|
scratchpad_write,
|
|
87
94
|
setScratchpadRoot,
|
|
88
95
|
} from "./scratchpad.ts";
|
|
89
|
-
import {
|
|
90
|
-
STATIC_CYBER_WORKFLOW,
|
|
91
|
-
STATIC_CYBER_WORKFLOW_LITE,
|
|
92
|
-
STATIC_CYBER_WORKFLOW_OMP,
|
|
93
|
-
} from "./workflow.ts";
|
|
96
|
+
import { STATIC_RECON_WORKFLOW, STATIC_RECON_WORKFLOW_OMP } from "./workflow.ts";
|
|
94
97
|
|
|
95
98
|
// ── Schemas ───────────────────────────────────────────────────────────
|
|
96
99
|
|
|
@@ -135,6 +138,12 @@ const CommonFields = {
|
|
|
135
138
|
description: "Documented attempt to disprove the finding before confirmation",
|
|
136
139
|
}),
|
|
137
140
|
),
|
|
141
|
+
invariant: Type.Optional(
|
|
142
|
+
Type.String({
|
|
143
|
+
description:
|
|
144
|
+
"The security invariant this finding violates — the rule broken (e.g. 'a user cannot read another user's orders'). Confirmation checks the invariant is actually violated, not just that a request returned 200.",
|
|
145
|
+
}),
|
|
146
|
+
),
|
|
138
147
|
};
|
|
139
148
|
|
|
140
149
|
// ── Tool: CaseAdd ─────────────────────────────────────────────────────
|
|
@@ -226,7 +235,7 @@ const PromoteSchema = Type.Object(
|
|
|
226
235
|
oob: Type.Optional(
|
|
227
236
|
Type.Boolean({
|
|
228
237
|
description:
|
|
229
|
-
"
|
|
238
|
+
"Blind/OOB confirmation via the operator-run oracle (PI_OOB_ORACLE_URL). The harness provisions per-run callback tokens, injects PI_POC_CALLBACK_DOMAIN into the runs, and polls the oracle itself: promotion requires target-token interactions, ZERO control-token interactions, attested source separation (PI_OOB_SOURCE_SEPARATED=1), and self-source/missing-src_ip interactions are rejected. Without an oracle this fails closed.",
|
|
230
239
|
}),
|
|
231
240
|
),
|
|
232
241
|
},
|
|
@@ -365,7 +374,7 @@ const UnlinkSchema = Type.Object(
|
|
|
365
374
|
|
|
366
375
|
// ── Tool: Scratchpad ─────────────────────────────────────────────────
|
|
367
376
|
//
|
|
368
|
-
// The scratchpad is
|
|
377
|
+
// The scratchpad is a crash-recoverable working-notes store for a run.
|
|
369
378
|
// The casefile owns state transitions; the scratchpad owns artifacts
|
|
370
379
|
// (recon maps, trace outputs, verification logs). Resume re-reads
|
|
371
380
|
// artifacts; it does not re-run completed phases (idempotent).
|
|
@@ -605,10 +614,8 @@ class CasefileDashboard {
|
|
|
605
614
|
}
|
|
606
615
|
|
|
607
616
|
// ── Context injection ─────────────────────────────────────────────────
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
|
|
611
|
-
// workflow.ts contains the full text
|
|
617
|
+
// The active case list is injected via before_agent_start (once per user
|
|
618
|
+
// prompt, not every tool turn) so open cases stay visible in context.
|
|
612
619
|
|
|
613
620
|
function sanitizeContextText(v?: string, max = 160): string | undefined {
|
|
614
621
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: strip C0 controls from untrusted case text
|
|
@@ -694,101 +701,30 @@ function buildCaseListContext(records: CaseRecord[]): string {
|
|
|
694
701
|
|
|
695
702
|
/**
|
|
696
703
|
* Detect the extension host. OMP is a fork of Pi: both load the same
|
|
697
|
-
* `pi`-manifest extensions, but subagent dispatch differs (pi-subagents'
|
|
704
|
+
* `pi`-manifest extensions, but recon subagent dispatch differs (pi-subagents'
|
|
698
705
|
* `subagent({workflowScript})` vs OMP's native `task`). The entry script path
|
|
699
|
-
* carries the host package
|
|
700
|
-
* `@earendil-works/pi-coding-agent` under Pi.
|
|
706
|
+
* carries the host package.
|
|
701
707
|
*/
|
|
702
|
-
|
|
708
|
+
function detectHost(): "omp" | "pi" {
|
|
703
709
|
const argv = process.argv.join(" ");
|
|
704
710
|
if (argv.includes("@oh-my-pi")) return "omp";
|
|
705
711
|
return "pi";
|
|
706
712
|
}
|
|
707
713
|
|
|
708
714
|
/**
|
|
709
|
-
*
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
*
|
|
713
|
-
*
|
|
714
|
-
* mode selects the workflow text: "lite" injects the single-agent workflow
|
|
715
|
-
* (no subagent dispatch), "swarm" gets the full subagent pipeline,
|
|
716
|
-
* rendered for the host's dispatch convention (pi-subagents vs OMP task).
|
|
715
|
+
* Per-prompt injection. The recon workflow is session-scope guidance — it never
|
|
716
|
+
* changes — so it is injected once (first prompt, includeWorkflow=true). The
|
|
717
|
+
* active case list DOES change as cases are added, so it refreshes every prompt.
|
|
718
|
+
* The workflow text is rendered for the host's dispatch convention.
|
|
717
719
|
*/
|
|
718
|
-
function buildAgentInjection(
|
|
719
|
-
active: CaseRecord[],
|
|
720
|
-
includeWorkflow: boolean,
|
|
721
|
-
mode: XpMode = "swarm",
|
|
722
|
-
): string {
|
|
720
|
+
function buildAgentInjection(active: CaseRecord[], includeWorkflow: boolean): string {
|
|
723
721
|
const caseList = buildCaseListContext(active);
|
|
724
722
|
if (!includeWorkflow) return caseList;
|
|
725
|
-
const workflow =
|
|
726
|
-
mode === "lite"
|
|
727
|
-
? STATIC_CYBER_WORKFLOW_LITE
|
|
728
|
-
: detectHost() === "omp"
|
|
729
|
-
? STATIC_CYBER_WORKFLOW_OMP
|
|
730
|
-
: STATIC_CYBER_WORKFLOW;
|
|
723
|
+
const workflow = detectHost() === "omp" ? STATIC_RECON_WORKFLOW_OMP : STATIC_RECON_WORKFLOW;
|
|
731
724
|
// Workflow FIRST for prominence, then case list as reference data.
|
|
732
725
|
return caseList ? `${workflow}\n\n${caseList}` : workflow;
|
|
733
726
|
}
|
|
734
727
|
|
|
735
|
-
// ── XP (offensive / exploit) mode toggle ─────────────────────────────
|
|
736
|
-
// Casefile historically injected the cyber workflow into every prompt.
|
|
737
|
-
// For normal dev work that is just noise, so XP mode defaults OFF. Enable
|
|
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.
|
|
743
|
-
// Pure helpers exported for unit tests.
|
|
744
|
-
|
|
745
|
-
export const XP_MODE_ENV = "PI_XP_MODE";
|
|
746
|
-
export type XpMode = "swarm" | "off" | "lite";
|
|
747
|
-
|
|
748
|
-
export function getXpModeStatePath(): string {
|
|
749
|
-
return join(dirname(getCasefilePath()), "xp-mode");
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
export function readXpMode(
|
|
753
|
-
envValue: string | undefined = process.env[XP_MODE_ENV],
|
|
754
|
-
statePath: string = getXpModeStatePath(),
|
|
755
|
-
): XpMode {
|
|
756
|
-
const env = (envValue ?? "").trim().toLowerCase();
|
|
757
|
-
if (env === "swarm") return "swarm";
|
|
758
|
-
if (env === "on" || env === "1" || env === "true") return "swarm";
|
|
759
|
-
if (env === "lite") return "lite";
|
|
760
|
-
if (env === "off" || env === "0" || env === "false") return "off";
|
|
761
|
-
try {
|
|
762
|
-
if (existsSync(statePath)) {
|
|
763
|
-
const v = readFileSync(statePath, "utf8").trim().toLowerCase();
|
|
764
|
-
if (v === "swarm" || v === "on") return "swarm";
|
|
765
|
-
if (v === "lite") return "lite";
|
|
766
|
-
if (v === "off") return "off";
|
|
767
|
-
}
|
|
768
|
-
} catch {
|
|
769
|
-
// ignore and fall through to default
|
|
770
|
-
}
|
|
771
|
-
return "off";
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
export function writeXpMode(state: XpMode, statePath: string = getXpModeStatePath()): void {
|
|
775
|
-
try {
|
|
776
|
-
writeFileSync(statePath, state, "utf8");
|
|
777
|
-
} catch {
|
|
778
|
-
// best-effort; env var can still override at runtime
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
export function parseXpModeArg(args: string, current: XpMode): XpMode {
|
|
783
|
-
const arg = (args ?? "").trim().toLowerCase();
|
|
784
|
-
if (arg === "swarm") return "swarm";
|
|
785
|
-
if (arg === "on") return "swarm";
|
|
786
|
-
if (arg === "off") return "off";
|
|
787
|
-
if (arg === "lite") return "lite";
|
|
788
|
-
// Bare /xp is the low-ceremony path: toggle the default XP workflow on/off.
|
|
789
|
-
return current === "off" ? "swarm" : "off";
|
|
790
|
-
}
|
|
791
|
-
|
|
792
728
|
// ── Main extension ────────────────────────────────────────────────────
|
|
793
729
|
|
|
794
730
|
export default function casefileExtension(pi: ExtensionAPI) {
|
|
@@ -845,6 +781,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
845
781
|
promptGuidelines: [
|
|
846
782
|
"Use CaseAdd for a new security lead. New cases start as status='hypothesis' or 'investigating' — promote later with CaseUpdate.",
|
|
847
783
|
"disproveIf is REQUIRED on CaseAdd: name the falsification conditions (what would disprove this hypothesis). A hypothesis that can't say what kills it isn't a hypothesis yet.",
|
|
784
|
+
"Declare the invariant: the security rule the finding would violate (e.g. 'a user cannot read another user's orders'). Confirmation checks the invariant is actually broken, not just that a request succeeded — a reproduction without a violated invariant is a mechanism, not a vulnerability.",
|
|
848
785
|
"Check the injected case list or CaseList/CaseSearch first. Do not add a duplicate for the same title/scope.",
|
|
849
786
|
"CaseAdd rejects exact and NEAR-duplicates (same target + overlapping title, e.g. parallel-subagent re-phrasings). A near-duplicate result → continue the existing case ID via CaseUpdate, don't create a new one.",
|
|
850
787
|
"confirmed/reported only via their gates: proof in poc + PromoteFinding for confirmed; CaseContext + report for reported.",
|
|
@@ -1169,8 +1106,9 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1169
1106
|
"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
1107
|
"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
1108
|
"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.",
|
|
1109
|
+
"oob=true unlocks blind/OOB classes (SSRF, blind XSS, XXE): the harness provisions per-run callback tokens via the operator's oracle (PI_OOB_ORACLE_URL), injects PI_POC_CALLBACK_DOMAIN into the runs, and polls the oracle itself — target-token interactions with ZERO control-token interactions are required. Promotion additionally requires attested source separation (PI_OOB_SOURCE_SEPARATED=1); without it the verification stays diagnostic.",
|
|
1172
1110
|
"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
|
|
1111
|
+
"Blind/OOB classes fail closed unless the operator configured an OOB oracle; self-interactions (PI_OOB_SELF_IPS) are rejected and never counted as target hits.",
|
|
1174
1112
|
"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
1113
|
],
|
|
1176
1114
|
parameters: PromoteSchema,
|
|
@@ -1203,13 +1141,47 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1203
1141
|
missingPocPath: true,
|
|
1204
1142
|
});
|
|
1205
1143
|
}
|
|
1206
|
-
|
|
1207
|
-
//
|
|
1208
|
-
//
|
|
1209
|
-
|
|
1144
|
+
const caseTarget = current.target ?? "";
|
|
1145
|
+
// ── OOB callback (Tier 1, opt-in for blind classes) ──
|
|
1146
|
+
// The operator-run oracle owns the evidence channel; the harness owns
|
|
1147
|
+
// the secret (per-run token, provisioned before the runs and injected
|
|
1148
|
+
// as env — the value does not exist when the script was written).
|
|
1149
|
+
// Without an oracle this stays fail-closed.
|
|
1150
|
+
const oobRequested = params.oob === true;
|
|
1151
|
+
// Intra-target + OOB is rejected up front: intra_target's
|
|
1152
|
+
// discriminating variable is identity/parameter on the SAME host;
|
|
1153
|
+
// mixing it with a callback differential would make precedence
|
|
1154
|
+
// ambiguous. OOB is for inter-host/blind classes.
|
|
1155
|
+
if (isIntra && oobRequested) {
|
|
1156
|
+
return fail(
|
|
1157
|
+
"mode:'intra_target' cannot be combined with oob:true — intra-target proof uses a same-host baseline request, not a callback channel. Use one or the other.",
|
|
1158
|
+
{ intraOobConflict: true },
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
let oobConfig: OobOracleConfig | undefined;
|
|
1162
|
+
let targetCallback: ProvisionedCallback | undefined;
|
|
1163
|
+
let controlCallback: ProvisionedCallback | undefined;
|
|
1164
|
+
if (oobRequested) {
|
|
1165
|
+
const oracle = readOobOracleConfig();
|
|
1166
|
+
if (!oracle.config) {
|
|
1167
|
+
return fail(`OOB CONFIRMATION UNAVAILABLE: ${oracle.error}`, {
|
|
1168
|
+
oobOracleNotConfigured: true,
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
oobConfig = oracle.config;
|
|
1172
|
+
// Provision both identities concurrently — each is an oracle round trip.
|
|
1173
|
+
[targetCallback, controlCallback] = await Promise.all([
|
|
1174
|
+
provisionCallback(oobConfig),
|
|
1175
|
+
provisionCallback(oobConfig),
|
|
1176
|
+
]);
|
|
1177
|
+
}
|
|
1178
|
+
// OOB-only bundles (blind classes, no operator-approved control host)
|
|
1179
|
+
// prove target-dependence via the token differential instead.
|
|
1180
|
+
const oobOnly = oobRequested && !controlTarget;
|
|
1181
|
+
if (!isIntra && !oobOnly) {
|
|
1210
1182
|
if (!controlTarget) {
|
|
1211
1183
|
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.",
|
|
1184
|
+
"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; for blind/OOB classes pass oob=true (with or without a control target).",
|
|
1213
1185
|
{ missingControlTarget: true },
|
|
1214
1186
|
);
|
|
1215
1187
|
}
|
|
@@ -1226,7 +1198,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1226
1198
|
{ networkNotAuthorized: true },
|
|
1227
1199
|
);
|
|
1228
1200
|
}
|
|
1229
|
-
if (!isIntra) {
|
|
1201
|
+
if (!isIntra && !oobOnly) {
|
|
1230
1202
|
const controlAuthorization = controlTargetAuthorizationError(controlTarget);
|
|
1231
1203
|
if (controlAuthorization) {
|
|
1232
1204
|
return fail(
|
|
@@ -1247,7 +1219,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1247
1219
|
sameFileCheckFailed: true,
|
|
1248
1220
|
});
|
|
1249
1221
|
}
|
|
1250
|
-
if (!isIntra) {
|
|
1222
|
+
if (!isIntra && !oobOnly) {
|
|
1251
1223
|
let controlHash: string | undefined;
|
|
1252
1224
|
try {
|
|
1253
1225
|
controlHash = createHash("sha256").update(readFileSync(controlPath)).digest("hex");
|
|
@@ -1265,24 +1237,22 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1265
1237
|
}
|
|
1266
1238
|
}
|
|
1267
1239
|
|
|
1268
|
-
// ── OOB callback
|
|
1269
|
-
const oobRequested = params.oob === true;
|
|
1270
|
-
if (oobRequested) {
|
|
1271
|
-
return fail(
|
|
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 },
|
|
1274
|
-
);
|
|
1275
|
-
}
|
|
1240
|
+
// ── OOB callback tokens were provisioned above, before the runs ──
|
|
1276
1241
|
const runOptions = (pocMode: string, target: string): PocRunOptions => ({
|
|
1277
1242
|
network: params.local === true ? "host" : "none",
|
|
1278
1243
|
local: params.local === true,
|
|
1279
1244
|
env: {
|
|
1280
1245
|
PI_POC_MODE: pocMode,
|
|
1281
1246
|
PI_POC_TARGET: target,
|
|
1247
|
+
...(oobRequested && targetCallback && controlCallback
|
|
1248
|
+
? {
|
|
1249
|
+
PI_POC_CALLBACK_DOMAIN:
|
|
1250
|
+
pocMode === "control" ? controlCallback.domain : targetCallback.domain,
|
|
1251
|
+
}
|
|
1252
|
+
: {}),
|
|
1282
1253
|
},
|
|
1283
1254
|
});
|
|
1284
1255
|
|
|
1285
|
-
const caseTarget = current.target ?? "";
|
|
1286
1256
|
// Determinism: TWO target runs. Exit 0 is run integrity only; nonce-bound
|
|
1287
1257
|
// body evidence plus the harness-owned differential replay (inter-host
|
|
1288
1258
|
// control, or intra-target same-host baseline) form the machine gate.
|
|
@@ -1336,8 +1306,18 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1336
1306
|
evidenceRun(run2, "poc", caseTarget),
|
|
1337
1307
|
];
|
|
1338
1308
|
|
|
1309
|
+
// Reflection canary + OOB is rejected after run 1 (the canary is
|
|
1310
|
+
// declared inside evidence.json): the canary path requires a harness
|
|
1311
|
+
// response transcript, which OOB-only bundles never produce — the
|
|
1312
|
+
// per-run callback token IS the causality signal there.
|
|
1313
|
+
if (oobRequested && targetRuns.some((r) => r.evidence.verify.canary !== undefined)) {
|
|
1314
|
+
return fail(
|
|
1315
|
+
"verify.canary cannot be combined with oob:true — the per-run callback token already provides a harness-owned causality signal. Remove the {{PI_POC_CANARY}} placeholder and verify.canary from evidence.json, then re-promote.",
|
|
1316
|
+
{ canaryOobConflict: true },
|
|
1317
|
+
);
|
|
1318
|
+
}
|
|
1339
1319
|
const allowPrivateReplay = process.env.PI_POC_ALLOW_PRIVATE_REPLAY === "1";
|
|
1340
|
-
let harnessVerified: HarnessVerifyResult;
|
|
1320
|
+
let harnessVerified: HarnessVerifyResult | undefined;
|
|
1341
1321
|
let controlRun: PocEvidenceRun | undefined;
|
|
1342
1322
|
if (isIntra) {
|
|
1343
1323
|
// Intra-target: prove target-dependence with the evidence's same-host
|
|
@@ -1359,7 +1339,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1359
1339
|
harnessVerified = await replayIntraTarget(ev0, caseTarget, {
|
|
1360
1340
|
allowPrivate: allowPrivateReplay,
|
|
1361
1341
|
});
|
|
1362
|
-
} else {
|
|
1342
|
+
} else if (!oobOnly) {
|
|
1363
1343
|
// Inter-host (Tier 2): the harness executes the SAME request template
|
|
1364
1344
|
// against target and operator-approved control, applying the target's
|
|
1365
1345
|
// predicates to both. DNS is pinned at connect time.
|
|
@@ -1375,6 +1355,20 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1375
1355
|
{ allowPrivate: allowPrivateReplay },
|
|
1376
1356
|
);
|
|
1377
1357
|
}
|
|
1358
|
+
// OOB differential: poll the oracle for both run tokens. The ledger's
|
|
1359
|
+
// assertMachineConfirmation consumes this BEFORE the response-diff
|
|
1360
|
+
// requirement — blind classes pass via this path when the oracle saw
|
|
1361
|
+
// the target token and NOT the control token under attested source
|
|
1362
|
+
// separation.
|
|
1363
|
+
let callbackVerified: OobVerification | undefined;
|
|
1364
|
+
if (oobConfig && targetCallback && controlCallback) {
|
|
1365
|
+
callbackVerified = (
|
|
1366
|
+
await verifyOobDifferential({
|
|
1367
|
+
targetToken: targetCallback.token,
|
|
1368
|
+
controlToken: controlCallback.token,
|
|
1369
|
+
})
|
|
1370
|
+
).verification;
|
|
1371
|
+
}
|
|
1378
1372
|
const bundle: PendingConfirmation = {
|
|
1379
1373
|
caseId,
|
|
1380
1374
|
ranAt: new Date().toISOString(),
|
|
@@ -1383,7 +1377,16 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1383
1377
|
mode,
|
|
1384
1378
|
targetRuns,
|
|
1385
1379
|
harnessVerified,
|
|
1386
|
-
...(
|
|
1380
|
+
...(callbackVerified && targetCallback && controlCallback
|
|
1381
|
+
? {
|
|
1382
|
+
callbackVerified,
|
|
1383
|
+
oobTokens: {
|
|
1384
|
+
targetToken: targetCallback.token,
|
|
1385
|
+
controlToken: controlCallback.token,
|
|
1386
|
+
},
|
|
1387
|
+
}
|
|
1388
|
+
: {}),
|
|
1389
|
+
...(!(isIntra || oobOnly) ? { controlPath, controlTarget, controlRun } : {}),
|
|
1387
1390
|
};
|
|
1388
1391
|
|
|
1389
1392
|
let record: CaseRecord;
|
|
@@ -1404,7 +1407,11 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1404
1407
|
`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
1408
|
`Evidence sha256: ${targetRuns[0].evidenceSha256}\n` +
|
|
1406
1409
|
`PoC script sha256 (at run time): ${pocHash}\n` +
|
|
1407
|
-
`Harness verify replay: ${harnessVerified
|
|
1410
|
+
`Harness verify replay: ${harnessVerified?.attempted ? (harnessVerified.pass ? `PASS (status ${harnessVerified.status})` : `FAILED — ${harnessVerified.note}`) : (harnessVerified?.note ?? "not run")}
|
|
1411
|
+
` +
|
|
1412
|
+
(callbackVerified
|
|
1413
|
+
? `OOB oracle: target-token hits ${callbackVerified.targetHits}, control-token hits ${callbackVerified.controlHits}, source-separated: ${String(callbackVerified.sourceSeparated)} — ${callbackVerified.note}\n`
|
|
1414
|
+
: "") +
|
|
1408
1415
|
`\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
1416
|
},
|
|
1410
1417
|
],
|
|
@@ -1454,15 +1461,18 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1454
1461
|
name: "ConfirmFinding",
|
|
1455
1462
|
label: "Main-Agent Confirmation",
|
|
1456
1463
|
description:
|
|
1457
|
-
"Phase 2 of confirmation, reserved for the main/coordinator agent: commit or refuse promotion after
|
|
1458
|
-
promptSnippet: "Main agent: independently
|
|
1464
|
+
"Phase 2 of confirmation, reserved for the main/coordinator agent: commit or refuse promotion after independently re-testing the finding. 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. Three verdicts: CONFIRMED (you reproduced real impact), NOT_CONFIRMED (you POSITIVELY disproved it), INCONCLUSIVE (you could neither reproduce nor disprove — the case is preserved for manual review, never dropped).",
|
|
1465
|
+
promptSnippet: "Main agent: independently re-test, then commit or refuse PoC confirmation",
|
|
1459
1466
|
promptGuidelines: [
|
|
1460
1467
|
"Run only in the main/coordinator agent after PromoteFinding returns. Do not dispatch a worker to decide or author this verdict.",
|
|
1461
|
-
"
|
|
1462
|
-
"
|
|
1463
|
-
"
|
|
1464
|
-
"
|
|
1465
|
-
"
|
|
1468
|
+
"Verify with DISBELIEF: assume the finding is a false positive until your OWN re-test proves otherwise. Reproduce the exact observable yourself from the primary evidence (not the hunter's narrative), with a negative/baseline control — a difference you cannot tie to the control is not proof. ConfirmFinding itself re-sends the immutable verify request against target and operator-approved control so phase 2 has a harness-owned transcript.",
|
|
1469
|
+
"Provenance: the proof must exercise THIS finding's own mechanism. Evidence obtained through a DIFFERENT bug (e.g. 'SQLi' proven by dumping the DB via an RCE) does not confirm it — that is INCONCLUSIVE at best.",
|
|
1470
|
+
"Kill the cheapest benign explanation: is this the technology's intended behavior? Did the attacker supply the 'secret' themselves (circular)? Is the claimed C/I/A impact actually demonstrated?",
|
|
1471
|
+
"Want a second pair of eyes? Dispatch a read-only skeptic subagent to re-test — it CANNOT confirm (only the main agent commits). You review its verdict and commit it here.",
|
|
1472
|
+
"CONFIRMED requires differential: 'target_only', re_execution_note, and disconfirmation_attempt (your failed disproof). A verdict missing any of these is rejected. Set canary_assessment='verified' when the immutable request declared a canary; otherwise not_applicable with a reason.",
|
|
1473
|
+
"NOT_CONFIRMED means you POSITIVELY disproved it (by-design, circular, mislabeled, no impact). Never mark NOT_CONFIRMED merely because you could not reproduce it.",
|
|
1474
|
+
"INCONCLUSIVE when you could neither reproduce nor disprove (needs auth, a second account, specific state, timing, or a blind/stored trigger you cannot observe). The case stays investigating and is preserved for manual review — dropping a real finding is worse than keeping an unproven one.",
|
|
1475
|
+
"Every verdict consumes the attempt: a fresh PromoteFinding run is required to try again. Never CaseUpdate status='confirmed' directly — always PromoteFinding + ConfirmFinding.",
|
|
1466
1476
|
],
|
|
1467
1477
|
parameters: ConfirmSchema,
|
|
1468
1478
|
|
|
@@ -1493,6 +1503,46 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1493
1503
|
replay = await replayIntraTarget(bundle.targetRuns[0].evidence, caseTargetForReplay, {
|
|
1494
1504
|
allowPrivate,
|
|
1495
1505
|
});
|
|
1506
|
+
} else if (bundle.callbackVerified?.attempted && bundle.oobTokens) {
|
|
1507
|
+
// OOB differential: fresh harness-owned re-poll of BOTH run tokens.
|
|
1508
|
+
// Re-polling at confirm time catches interactions that landed after
|
|
1509
|
+
// phase 1 (e.g. a delayed control-token hit) — the verdict is bound
|
|
1510
|
+
// to this fresh observation, not the stored one.
|
|
1511
|
+
const { verification } = await verifyOobDifferential({
|
|
1512
|
+
targetToken: bundle.oobTokens.targetToken,
|
|
1513
|
+
controlToken: bundle.oobTokens.controlToken,
|
|
1514
|
+
});
|
|
1515
|
+
const oobPass =
|
|
1516
|
+
verification.targetHits > 0 &&
|
|
1517
|
+
verification.controlHits === 0 &&
|
|
1518
|
+
verification.sourceSeparated === true;
|
|
1519
|
+
replay = {
|
|
1520
|
+
attempted: true,
|
|
1521
|
+
pass: oobPass,
|
|
1522
|
+
target: {
|
|
1523
|
+
attempted: true,
|
|
1524
|
+
matched: verification.targetHits > 0,
|
|
1525
|
+
url: bundle.targetRuns[0].evidence.verify.url,
|
|
1526
|
+
note: verification.note,
|
|
1527
|
+
},
|
|
1528
|
+
control: {
|
|
1529
|
+
attempted: true,
|
|
1530
|
+
matched: verification.controlHits > 0,
|
|
1531
|
+
url: bundle.targetRuns[0].evidence.verify.url,
|
|
1532
|
+
note: `${verification.controlHits} control-token interaction(s)`,
|
|
1533
|
+
},
|
|
1534
|
+
differential:
|
|
1535
|
+
verification.targetHits > 0
|
|
1536
|
+
? verification.controlHits === 0
|
|
1537
|
+
? "target_only"
|
|
1538
|
+
: "both"
|
|
1539
|
+
: "neither",
|
|
1540
|
+
note: `harness OOB re-poll: ${verification.note}`,
|
|
1541
|
+
};
|
|
1542
|
+
} else if (bundle.callbackVerified?.attempted) {
|
|
1543
|
+
throw new Error(
|
|
1544
|
+
"OOB bundle lacks its provisioned tokens (pre-token-storage ledger) — re-run PromoteFinding for a fresh bundle",
|
|
1545
|
+
);
|
|
1496
1546
|
} else {
|
|
1497
1547
|
if (!bundle.controlTarget) {
|
|
1498
1548
|
throw new Error("inter-host confirmation requires a control target");
|
|
@@ -1525,7 +1575,10 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1525
1575
|
text: promoted
|
|
1526
1576
|
? `Main agent CONFIRMED. Case promoted:
|
|
1527
1577
|
${formatCaseDetail(record)}`
|
|
1528
|
-
:
|
|
1578
|
+
: parsedVerdict.verdict.verdict === "INCONCLUSIVE"
|
|
1579
|
+
? `Main agent INCONCLUSIVE — case stays investigating, preserved for manual review (not disproved):
|
|
1580
|
+
${formatCaseDetail(record)}`
|
|
1581
|
+
: `Main agent NOT_CONFIRMED — case stays investigating (attempt recorded):
|
|
1529
1582
|
${formatCaseDetail(record)}`,
|
|
1530
1583
|
},
|
|
1531
1584
|
],
|
|
@@ -1763,67 +1816,6 @@ ${formatCaseDetail(record)}`,
|
|
|
1763
1816
|
},
|
|
1764
1817
|
});
|
|
1765
1818
|
|
|
1766
|
-
// ── Tool: ChainSuggest ──
|
|
1767
|
-
|
|
1768
|
-
const ChainSuggestSchema = Type.Object(
|
|
1769
|
-
{
|
|
1770
|
-
case_id: Type.Optional(
|
|
1771
|
-
Type.String({
|
|
1772
|
-
description:
|
|
1773
|
-
"Optional: scope suggestions to this case and its linked cases. Omit to scan all non-terminal cases.",
|
|
1774
|
-
}),
|
|
1775
|
-
),
|
|
1776
|
-
},
|
|
1777
|
-
{ additionalProperties: false },
|
|
1778
|
-
);
|
|
1779
|
-
|
|
1780
|
-
registerCaseTool({
|
|
1781
|
-
name: "ChainSuggest",
|
|
1782
|
-
label: "Suggest Exploit Chains",
|
|
1783
|
-
description:
|
|
1784
|
-
"Scan non-terminal cases for exploitable chains (credential+endpoint→ATO, open-redirect+OAuth→token theft, XSS+state-changing→CSRF bypass, IDOR+user-data→mass leak, SSTI→RCE, race+payment→financial, info-disclosure+SSRF). Returns ranked candidates with confidence and a suggested link kind — the agent decides whether to CaseLink them or open an escalation case. Catches chain combinations the model may have missed.",
|
|
1785
|
-
promptSnippet: "Find missed exploit-chain combinations",
|
|
1786
|
-
promptGuidelines: [
|
|
1787
|
-
"Run ChainSuggest before concluding an engagement — low-severity findings that chain into high-impact (ATO, token theft, mass leak) are the ones triage cares about.",
|
|
1788
|
-
"A suggestion is a HYPOTHESIS to verify, not a finding: test the chained behavior on the live target before linking or promoting anything.",
|
|
1789
|
-
"Chain a suggested pair with CaseLink (suggested kind) or open a new escalation case with status=hypothesis.",
|
|
1790
|
-
],
|
|
1791
|
-
parameters: ChainSuggestSchema,
|
|
1792
|
-
|
|
1793
|
-
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1794
|
-
const suggestions = await suggestChainsAsync(
|
|
1795
|
-
(params.case_id as string | undefined) ?? undefined,
|
|
1796
|
-
);
|
|
1797
|
-
const lines: string[] = [
|
|
1798
|
-
suggestions.length
|
|
1799
|
-
? `${suggestions.length} chain candidate(s):`
|
|
1800
|
-
: "No chain candidates found across non-terminal cases.",
|
|
1801
|
-
];
|
|
1802
|
-
for (const s of suggestions) {
|
|
1803
|
-
const pair = s.targetId
|
|
1804
|
-
? `${s.sourceId} (${s.sourceTitle}) + ${s.targetId} (${s.targetTitle ?? ""})`
|
|
1805
|
-
: `${s.sourceId} (${s.sourceTitle})`;
|
|
1806
|
-
lines.push(
|
|
1807
|
-
`\n[${s.pattern}] conf ${s.confidence}% — ${pair}\n ${s.rationale}${s.suggestedKind ? `\n suggested link kind: ${s.suggestedKind}` : ""}`,
|
|
1808
|
-
);
|
|
1809
|
-
}
|
|
1810
|
-
return {
|
|
1811
|
-
content: [{ type: "text", text: lines.join("\n") }],
|
|
1812
|
-
details: { suggestions },
|
|
1813
|
-
};
|
|
1814
|
-
},
|
|
1815
|
-
|
|
1816
|
-
renderCall(args, theme) {
|
|
1817
|
-
return callLine(theme, "ChainSuggest", (args.case_id as string) ?? "all");
|
|
1818
|
-
},
|
|
1819
|
-
|
|
1820
|
-
renderResult(result, _opts, theme) {
|
|
1821
|
-
const details = result.details as { suggestions?: { length: number } } | undefined;
|
|
1822
|
-
const n = details?.suggestions?.length ?? 0;
|
|
1823
|
-
return new Text(theme.fg(n ? "accent" : "dim", `${n} chain candidate(s)`), 0, 0);
|
|
1824
|
-
},
|
|
1825
|
-
});
|
|
1826
|
-
|
|
1827
1819
|
// ── Tool: CaseContext ──
|
|
1828
1820
|
|
|
1829
1821
|
registerCaseTool({
|
|
@@ -1839,7 +1831,7 @@ ${formatCaseDetail(record)}`,
|
|
|
1839
1831
|
parameters: IdSchema,
|
|
1840
1832
|
|
|
1841
1833
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1842
|
-
const { path, contextPath, record } =
|
|
1834
|
+
const { path, contextPath, record } = writeCaseContext(params.id as string);
|
|
1843
1835
|
return {
|
|
1844
1836
|
content: [
|
|
1845
1837
|
{
|
|
@@ -1865,97 +1857,6 @@ ${formatCaseDetail(record)}`,
|
|
|
1865
1857
|
},
|
|
1866
1858
|
});
|
|
1867
1859
|
|
|
1868
|
-
// ── Command: /xp (toggle offensive XP mode) ──
|
|
1869
|
-
|
|
1870
|
-
pi.registerCommand("xp", {
|
|
1871
|
-
description:
|
|
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]",
|
|
1873
|
-
handler: async (args, ctx) => {
|
|
1874
|
-
const next = parseXpModeArg(args ?? "", readXpMode());
|
|
1875
|
-
writeXpMode(next);
|
|
1876
|
-
// Re-enabling after a mid-session /xp off must re-inject the workflow
|
|
1877
|
-
// on the next prompt — otherwise workflowInjected (module-level, set on
|
|
1878
|
-
// first enable) stays true and the workflow never comes back until the
|
|
1879
|
-
// process restarts.
|
|
1880
|
-
if (next !== "off") workflowInjected = false;
|
|
1881
|
-
ctx.ui.notify(
|
|
1882
|
-
`Casefile XP mode: ${next.toUpperCase()} (takes effect on the next prompt)`,
|
|
1883
|
-
next === "off" ? "warning" : "info",
|
|
1884
|
-
);
|
|
1885
|
-
},
|
|
1886
|
-
});
|
|
1887
|
-
|
|
1888
|
-
// ── Tool: PipelineSubmit ──
|
|
1889
|
-
|
|
1890
|
-
registerCaseTool({
|
|
1891
|
-
name: "PipelineSubmit",
|
|
1892
|
-
label: "Submit Stage Output",
|
|
1893
|
-
description:
|
|
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.",
|
|
1895
|
-
promptSnippet: "Validate and submit a pipeline stage's output",
|
|
1896
|
-
promptGuidelines: [
|
|
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.",
|
|
1898
|
-
"verdict repair → fix the listed fields and re-submit the same output; budget is 2 attempts per finding, then rejected.",
|
|
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.',
|
|
1902
|
-
"Test-path findings and hallucinated files are rejected by the pre-filter, not repairable — the finding itself is noise.",
|
|
1903
|
-
],
|
|
1904
|
-
parameters: Type.Object(
|
|
1905
|
-
{
|
|
1906
|
-
run_id: Type.String({
|
|
1907
|
-
description: "Pipeline run identifier (same as the scratchpad run_id)",
|
|
1908
|
-
}),
|
|
1909
|
-
stage: Type.String({
|
|
1910
|
-
enum: [...SUBMIT_STAGES],
|
|
1911
|
-
description: "Pipeline stage: hunt | trace | skeptic | validate | chain | report",
|
|
1912
|
-
}),
|
|
1913
|
-
output: Type.Union([Type.String(), Type.Object({}, { additionalProperties: true })], {
|
|
1914
|
-
description: "The stage output as a JSON object or JSON string (code fences tolerated)",
|
|
1915
|
-
}),
|
|
1916
|
-
},
|
|
1917
|
-
{ additionalProperties: false },
|
|
1918
|
-
),
|
|
1919
|
-
|
|
1920
|
-
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1921
|
-
const result = pipeline_submit(
|
|
1922
|
-
params.run_id as string,
|
|
1923
|
-
params.stage as SubmitStage,
|
|
1924
|
-
params.output,
|
|
1925
|
-
);
|
|
1926
|
-
const statusLine =
|
|
1927
|
-
result.verdict === "accepted"
|
|
1928
|
-
? `ACCEPTED (${params.stage}) — artifact: ${result.artifact}`
|
|
1929
|
-
: result.verdict === "repair"
|
|
1930
|
-
? `REPAIR (attempt ${result.repair_attempt}/2) — fix these and re-submit:\n - ${result.errors.join("\n - ")}`
|
|
1931
|
-
: `REJECTED — ${result.errors.join("\n")}`;
|
|
1932
|
-
if (result.verdict !== "accepted") throw new Error(statusLine);
|
|
1933
|
-
return {
|
|
1934
|
-
content: [{ type: "text", text: statusLine }],
|
|
1935
|
-
details: result as unknown as Record<string, unknown>,
|
|
1936
|
-
};
|
|
1937
|
-
},
|
|
1938
|
-
|
|
1939
|
-
renderCall(args, theme) {
|
|
1940
|
-
return callLine(theme, "PipelineSubmit", `${args.stage ?? ""}`);
|
|
1941
|
-
},
|
|
1942
|
-
|
|
1943
|
-
renderResult(result, _opts, theme) {
|
|
1944
|
-
const details = result.details as { verdict?: string; repair_attempt?: number } | undefined;
|
|
1945
|
-
if (details?.verdict === "accepted") {
|
|
1946
|
-
return new Text(theme.fg("success", "✓ PipelineSubmit accepted"), 0, 0);
|
|
1947
|
-
}
|
|
1948
|
-
if (details?.verdict === "repair") {
|
|
1949
|
-
return new Text(
|
|
1950
|
-
theme.fg("warning", `↷ PipelineSubmit repair ${details.repair_attempt}/2`),
|
|
1951
|
-
0,
|
|
1952
|
-
0,
|
|
1953
|
-
);
|
|
1954
|
-
}
|
|
1955
|
-
return new Text(theme.fg("error", "✗ PipelineSubmit rejected"), 0, 0);
|
|
1956
|
-
},
|
|
1957
|
-
});
|
|
1958
|
-
|
|
1959
1860
|
// ── Tool: ScratchpadInit ──
|
|
1960
1861
|
|
|
1961
1862
|
registerCaseTool({
|
|
@@ -2319,22 +2220,19 @@ ${formatCaseDetail(record)}`,
|
|
|
2319
2220
|
}
|
|
2320
2221
|
});
|
|
2321
2222
|
|
|
2322
|
-
// ── Event: Inject
|
|
2323
|
-
//
|
|
2324
|
-
//
|
|
2325
|
-
//
|
|
2326
|
-
//
|
|
2327
|
-
// conversation message) avoids session bloat from repeated message entries.
|
|
2223
|
+
// ── Event: Inject the recon workflow + active-case list into the prompt ──
|
|
2224
|
+
// The recon workflow is injected ONCE per session (first prompt); the active
|
|
2225
|
+
// case list refreshes every prompt because it changes as cases are added.
|
|
2226
|
+
// Injecting into event.systemPrompt (not as a conversation message) avoids
|
|
2227
|
+
// session bloat from repeated message entries.
|
|
2328
2228
|
let workflowInjected = false;
|
|
2329
2229
|
|
|
2330
2230
|
pi.on("before_agent_start", async (event) => {
|
|
2331
|
-
const mode = readXpMode();
|
|
2332
|
-
if (mode === "off") return;
|
|
2333
2231
|
// Skip subagent child processes: pi-subagents runs each child in its own
|
|
2334
2232
|
// pi process (PI_SUBAGENT_CHILD=1) with this extension loaded. Injecting
|
|
2335
2233
|
// the workflow + entire active-case ledger into every child dispatch is a
|
|
2336
|
-
// token multiplier (N
|
|
2337
|
-
// workers get what they need via their task
|
|
2234
|
+
// token multiplier (N children × workflow + growing case list per turn) —
|
|
2235
|
+
// recon workers get what they need via their task, not the coordinator's.
|
|
2338
2236
|
if (isSubagentProcess()) return;
|
|
2339
2237
|
|
|
2340
2238
|
const includeWorkflow = !workflowInjected;
|
|
@@ -2343,15 +2241,13 @@ ${formatCaseDetail(record)}`,
|
|
|
2343
2241
|
try {
|
|
2344
2242
|
active = readActiveCases();
|
|
2345
2243
|
} catch {
|
|
2346
|
-
// No database yet — still inject workflow.
|
|
2244
|
+
// No database yet — still inject the workflow.
|
|
2347
2245
|
}
|
|
2348
2246
|
|
|
2349
|
-
const injection = buildAgentInjection(active, includeWorkflow
|
|
2247
|
+
const injection = buildAgentInjection(active, includeWorkflow);
|
|
2350
2248
|
if (!injection) return; // workflow already injected, no active cases
|
|
2351
2249
|
workflowInjected = true;
|
|
2352
2250
|
|
|
2353
|
-
// Inject workflow FIRST (before skills) so the attacker mindset is
|
|
2354
|
-
// prominent, not buried at the end of a long system prompt.
|
|
2355
2251
|
return {
|
|
2356
2252
|
systemPrompt: `${injection}\n\n${event.systemPrompt ?? ""}`,
|
|
2357
2253
|
};
|