@xaccefy/pi-casefile 0.9.4 → 0.10.1
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 +32 -67
- package/package.json +13 -16
- package/src/confirmation.ts +951 -0
- package/src/evidence.ts +131 -4
- package/src/harness-verify.ts +19 -42
- package/src/index.ts +362 -701
- package/src/ledger-internal.ts +435 -0
- package/src/ledger.ts +519 -1255
- package/src/oob-oracle.ts +279 -0
- package/src/poc-runner.ts +51 -12
- package/src/scratchpad.ts +92 -148
- package/src/workflow.ts +48 -325
- 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,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Casefile — offensive security case tracker for Pi.
|
|
3
3
|
*
|
|
4
|
-
* Tools: CaseAdd, CaseUpdate, PromoteFinding, ConfirmFinding, EvidenceAdd, CoverageAdd,
|
|
4
|
+
* Tools: CaseAdd, CaseUpdate, PromoteFinding, ConfirmFinding, EvidenceAdd, CoverageAdd, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseContext, ScratchpadWrite, ScratchpadRead, 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 { dirname, join } from "node:path";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
12
11
|
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
13
12
|
import { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
14
13
|
import { type TSchema, Type } from "typebox";
|
|
@@ -16,8 +15,10 @@ import {
|
|
|
16
15
|
CANARY_ASSESSMENT_VALUES,
|
|
17
16
|
CONFIRM_DIFFERENTIAL_VALUES,
|
|
18
17
|
CONFIRM_VERDICT_VALUES,
|
|
18
|
+
PANEL_VERDICT_VALUES,
|
|
19
19
|
SEVERITY_MATCH_VALUES,
|
|
20
20
|
validateMainAgentVerdict,
|
|
21
|
+
validatePanelVotes,
|
|
21
22
|
} from "./evidence.ts";
|
|
22
23
|
import {
|
|
23
24
|
controlTargetAuthorizationError,
|
|
@@ -43,7 +44,6 @@ import {
|
|
|
43
44
|
type CoverageItem,
|
|
44
45
|
type CoverageScope,
|
|
45
46
|
countCases,
|
|
46
|
-
coverageSummary,
|
|
47
47
|
EVIDENCE_ROLE_VALUES,
|
|
48
48
|
type EvidenceItem,
|
|
49
49
|
type EvidenceRole,
|
|
@@ -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,
|
|
@@ -68,29 +69,26 @@ import {
|
|
|
68
69
|
storePendingConfirmation,
|
|
69
70
|
unlinkCasesResult,
|
|
70
71
|
updateCaseResult,
|
|
72
|
+
writeCaseContext,
|
|
71
73
|
} from "./ledger.ts";
|
|
72
|
-
import {
|
|
73
|
-
|
|
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,
|
|
77
84
|
SCRATCHPAD_PHASES,
|
|
78
85
|
type ScratchpadPhase,
|
|
79
|
-
type ScratchpadResume,
|
|
80
|
-
scratchpad_checkpoint,
|
|
81
86
|
scratchpad_clear,
|
|
82
|
-
scratchpad_init,
|
|
83
|
-
scratchpad_phase_done,
|
|
84
87
|
scratchpad_read,
|
|
85
|
-
scratchpad_resume,
|
|
86
88
|
scratchpad_write,
|
|
87
89
|
setScratchpadRoot,
|
|
88
90
|
} from "./scratchpad.ts";
|
|
89
|
-
import {
|
|
90
|
-
STATIC_CYBER_WORKFLOW,
|
|
91
|
-
STATIC_CYBER_WORKFLOW_LITE,
|
|
92
|
-
STATIC_CYBER_WORKFLOW_OMP,
|
|
93
|
-
} from "./workflow.ts";
|
|
91
|
+
import { STATIC_RECON_WORKFLOW, STATIC_RECON_WORKFLOW_OMP } from "./workflow.ts";
|
|
94
92
|
|
|
95
93
|
// ── Schemas ───────────────────────────────────────────────────────────
|
|
96
94
|
|
|
@@ -135,6 +133,27 @@ const CommonFields = {
|
|
|
135
133
|
description: "Documented attempt to disprove the finding before confirmation",
|
|
136
134
|
}),
|
|
137
135
|
),
|
|
136
|
+
invariant: Type.Optional(
|
|
137
|
+
Type.String({
|
|
138
|
+
description:
|
|
139
|
+
"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.",
|
|
140
|
+
}),
|
|
141
|
+
),
|
|
142
|
+
retry_policy: Type.Optional(
|
|
143
|
+
Type.Object(
|
|
144
|
+
{
|
|
145
|
+
max_attempts: Type.Number({
|
|
146
|
+
description: "Max attempts a phase may take for this case (integer 1–10)",
|
|
147
|
+
}),
|
|
148
|
+
fallback_models: Type.Optional(
|
|
149
|
+
Type.Array(Type.String(), {
|
|
150
|
+
description: "Fallback model identifiers to try when the primary model fails (≤8)",
|
|
151
|
+
}),
|
|
152
|
+
),
|
|
153
|
+
},
|
|
154
|
+
{ additionalProperties: false },
|
|
155
|
+
),
|
|
156
|
+
),
|
|
138
157
|
};
|
|
139
158
|
|
|
140
159
|
// ── Tool: CaseAdd ─────────────────────────────────────────────────────
|
|
@@ -181,6 +200,43 @@ const EvidenceAddSchema = Type.Object(
|
|
|
181
200
|
{ additionalProperties: false },
|
|
182
201
|
);
|
|
183
202
|
|
|
203
|
+
// ── Tool: CoverageAdd ─────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
const CoverageAddSchema = Type.Object(
|
|
206
|
+
{
|
|
207
|
+
case_id: Type.String({
|
|
208
|
+
description: "Case ID (the finding case or target's main case) to record coverage under",
|
|
209
|
+
}),
|
|
210
|
+
asset: Type.String({
|
|
211
|
+
description:
|
|
212
|
+
"The asset tested — copy it verbatim from the case target where shown. For scope=wide use the deployment-wide identifier.",
|
|
213
|
+
}),
|
|
214
|
+
class: Type.String({
|
|
215
|
+
description:
|
|
216
|
+
"The attack class tested (e.g. sql-injection, xss, idor, ssti, ssrf, auth-bypass, ...).",
|
|
217
|
+
}),
|
|
218
|
+
// Provider-safe string enum (per the header rule): Type.Union(Type.Literal)
|
|
219
|
+
// serializes as anyOf/const, which some providers drop — scope would
|
|
220
|
+
// arrive undefined and every explicit 'wide' verdict would silently
|
|
221
|
+
// persist as 'local', under-reporting tested classes.
|
|
222
|
+
scope: Type.String({
|
|
223
|
+
enum: [...COVERAGE_SCOPE_VALUES],
|
|
224
|
+
description:
|
|
225
|
+
"'wide' if the verdict applies to the whole deployment/account/host (recorded ONCE, applies to every asset of the deployment — do NOT re-test it per asset); 'local' if specific to this one asset.",
|
|
226
|
+
}),
|
|
227
|
+
note: Type.String({
|
|
228
|
+
description: "Short note: techniques tried · result · key gap.",
|
|
229
|
+
}),
|
|
230
|
+
evidence_item_id: Type.Optional(
|
|
231
|
+
Type.String({
|
|
232
|
+
description:
|
|
233
|
+
"Optional artifact-backed evidence item (EvidenceAdd, on this case) backing the tested verdict. Cells without one render as unbacked.",
|
|
234
|
+
}),
|
|
235
|
+
),
|
|
236
|
+
},
|
|
237
|
+
{ additionalProperties: false },
|
|
238
|
+
);
|
|
239
|
+
|
|
184
240
|
// ── Tool: PromoteFinding (phase 1) / ConfirmFinding (phase 2) ──────────
|
|
185
241
|
//
|
|
186
242
|
// Confirmation is TWO-PHASE and main-agent-owned: PromoteFinding runs the PoC
|
|
@@ -226,9 +282,26 @@ const PromoteSchema = Type.Object(
|
|
|
226
282
|
oob: Type.Optional(
|
|
227
283
|
Type.Boolean({
|
|
228
284
|
description:
|
|
229
|
-
"
|
|
285
|
+
"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
286
|
}),
|
|
231
287
|
),
|
|
288
|
+
panel_votes: Type.Optional(
|
|
289
|
+
Type.Array(
|
|
290
|
+
Type.Object(
|
|
291
|
+
{
|
|
292
|
+
verdict: Type.String({ enum: [...PANEL_VERDICT_VALUES] }),
|
|
293
|
+
rationale: Type.String({ description: "Why this voter reached its verdict" }),
|
|
294
|
+
model: Type.String({ description: "Which model voted" }),
|
|
295
|
+
at: Type.Optional(Type.String({ description: "Vote timestamp (ISO)" })),
|
|
296
|
+
},
|
|
297
|
+
{ additionalProperties: false },
|
|
298
|
+
),
|
|
299
|
+
{
|
|
300
|
+
description:
|
|
301
|
+
"Optional pre-gate panel votes (≤5). CONFIRMED later requires a 2/3 exploit quorum or an explicit override note on the verdict; votes never commit anything.",
|
|
302
|
+
},
|
|
303
|
+
),
|
|
304
|
+
),
|
|
232
305
|
},
|
|
233
306
|
{ additionalProperties: false },
|
|
234
307
|
);
|
|
@@ -280,6 +353,12 @@ const ConfirmSchema = Type.Object(
|
|
|
280
353
|
"Why a causal reflection canary is not meaningful for this exploit class. Required when canary_assessment=not_applicable.",
|
|
281
354
|
}),
|
|
282
355
|
),
|
|
356
|
+
panel_override_note: Type.Optional(
|
|
357
|
+
Type.String({
|
|
358
|
+
description:
|
|
359
|
+
"Why CONFIRMED proceeds without a 2/3 exploit panel quorum (panel skipped, unavailable, or documented disagreement). Required for CONFIRMED whenever quorum was not reached.",
|
|
360
|
+
}),
|
|
361
|
+
),
|
|
283
362
|
model: Type.Optional(
|
|
284
363
|
Type.String({ description: "Which model judged (recorded for the accuracy ledger)" }),
|
|
285
364
|
),
|
|
@@ -365,7 +444,7 @@ const UnlinkSchema = Type.Object(
|
|
|
365
444
|
|
|
366
445
|
// ── Tool: Scratchpad ─────────────────────────────────────────────────
|
|
367
446
|
//
|
|
368
|
-
// The scratchpad is
|
|
447
|
+
// The scratchpad is a crash-recoverable working-notes store for a run.
|
|
369
448
|
// The casefile owns state transitions; the scratchpad owns artifacts
|
|
370
449
|
// (recon maps, trace outputs, verification logs). Resume re-reads
|
|
371
450
|
// artifacts; it does not re-run completed phases (idempotent).
|
|
@@ -376,7 +455,7 @@ const ScratchpadPhaseSchema = Type.String({
|
|
|
376
455
|
"Pipeline phase: recon | hunt | trace | skeptic | validate | chain | patch | report (legacy gapfil is accepted for older runs)",
|
|
377
456
|
});
|
|
378
457
|
|
|
379
|
-
/** run_id-only schema, shared by Scratchpad
|
|
458
|
+
/** run_id-only schema, shared by Scratchpad tools. */
|
|
380
459
|
const RunIdSchema = Type.Object(
|
|
381
460
|
{
|
|
382
461
|
run_id: Type.String({ description: "Pipeline run identifier" }),
|
|
@@ -384,20 +463,6 @@ const RunIdSchema = Type.Object(
|
|
|
384
463
|
{ additionalProperties: false },
|
|
385
464
|
);
|
|
386
465
|
|
|
387
|
-
const ScratchpadCheckpointSchema = Type.Object(
|
|
388
|
-
{
|
|
389
|
-
run_id: Type.String({ description: "Run identifier" }),
|
|
390
|
-
phase: ScratchpadPhaseSchema,
|
|
391
|
-
ids: Type.Optional(
|
|
392
|
-
Type.Array(Type.String(), {
|
|
393
|
-
description: "Key IDs produced by this phase (case IDs, finding IDs)",
|
|
394
|
-
}),
|
|
395
|
-
),
|
|
396
|
-
summary: Type.Optional(Type.String({ description: "One-line summary of phase completion" })),
|
|
397
|
-
},
|
|
398
|
-
{ additionalProperties: false },
|
|
399
|
-
);
|
|
400
|
-
|
|
401
466
|
const ScratchpadWriteSchema = Type.Object(
|
|
402
467
|
{
|
|
403
468
|
run_id: Type.String({ description: "Run identifier" }),
|
|
@@ -419,14 +484,6 @@ const ScratchpadReadSchema = Type.Object(
|
|
|
419
484
|
{ additionalProperties: false },
|
|
420
485
|
);
|
|
421
486
|
|
|
422
|
-
const ScratchpadPhaseDoneSchema = Type.Object(
|
|
423
|
-
{
|
|
424
|
-
run_id: Type.String({ description: "Run identifier" }),
|
|
425
|
-
phase: ScratchpadPhaseSchema,
|
|
426
|
-
},
|
|
427
|
-
{ additionalProperties: false },
|
|
428
|
-
);
|
|
429
|
-
|
|
430
487
|
interface Theme {
|
|
431
488
|
fg(color: string, text: string): string;
|
|
432
489
|
bold(text: string): string;
|
|
@@ -605,10 +662,8 @@ class CasefileDashboard {
|
|
|
605
662
|
}
|
|
606
663
|
|
|
607
664
|
// ── Context injection ─────────────────────────────────────────────────
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
|
|
611
|
-
// workflow.ts contains the full text
|
|
665
|
+
// The active case list is injected via before_agent_start (once per user
|
|
666
|
+
// prompt, not every tool turn) so open cases stay visible in context.
|
|
612
667
|
|
|
613
668
|
function sanitizeContextText(v?: string, max = 160): string | undefined {
|
|
614
669
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: strip C0 controls from untrusted case text
|
|
@@ -694,101 +749,30 @@ function buildCaseListContext(records: CaseRecord[]): string {
|
|
|
694
749
|
|
|
695
750
|
/**
|
|
696
751
|
* Detect the extension host. OMP is a fork of Pi: both load the same
|
|
697
|
-
* `pi`-manifest extensions, but subagent dispatch differs (pi-subagents'
|
|
752
|
+
* `pi`-manifest extensions, but recon subagent dispatch differs (pi-subagents'
|
|
698
753
|
* `subagent({workflowScript})` vs OMP's native `task`). The entry script path
|
|
699
|
-
* carries the host package
|
|
700
|
-
* `@earendil-works/pi-coding-agent` under Pi.
|
|
754
|
+
* carries the host package.
|
|
701
755
|
*/
|
|
702
|
-
|
|
756
|
+
function detectHost(): "omp" | "pi" {
|
|
703
757
|
const argv = process.argv.join(" ");
|
|
704
758
|
if (argv.includes("@oh-my-pi")) return "omp";
|
|
705
759
|
return "pi";
|
|
706
760
|
}
|
|
707
761
|
|
|
708
762
|
/**
|
|
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).
|
|
763
|
+
* Per-prompt injection. The recon workflow is session-scope guidance — it never
|
|
764
|
+
* changes — so it is injected once (first prompt, includeWorkflow=true). The
|
|
765
|
+
* active case list DOES change as cases are added, so it refreshes every prompt.
|
|
766
|
+
* The workflow text is rendered for the host's dispatch convention.
|
|
717
767
|
*/
|
|
718
|
-
function buildAgentInjection(
|
|
719
|
-
active: CaseRecord[],
|
|
720
|
-
includeWorkflow: boolean,
|
|
721
|
-
mode: XpMode = "swarm",
|
|
722
|
-
): string {
|
|
768
|
+
function buildAgentInjection(active: CaseRecord[], includeWorkflow: boolean): string {
|
|
723
769
|
const caseList = buildCaseListContext(active);
|
|
724
770
|
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;
|
|
771
|
+
const workflow = detectHost() === "omp" ? STATIC_RECON_WORKFLOW_OMP : STATIC_RECON_WORKFLOW;
|
|
731
772
|
// Workflow FIRST for prominence, then case list as reference data.
|
|
732
773
|
return caseList ? `${workflow}\n\n${caseList}` : workflow;
|
|
733
774
|
}
|
|
734
775
|
|
|
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
776
|
// ── Main extension ────────────────────────────────────────────────────
|
|
793
777
|
|
|
794
778
|
export default function casefileExtension(pi: ExtensionAPI) {
|
|
@@ -797,7 +781,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
797
781
|
// already-loaded extension or reveal a tool that was omitted at startup.
|
|
798
782
|
const startedAsSubagent = process.env.PI_SUBAGENT_CHILD === "1";
|
|
799
783
|
const isSubagentProcess = () => startedAsSubagent || process.env.PI_SUBAGENT_CHILD === "1";
|
|
800
|
-
// Pin the workspace root ONCE at extension load. Every scratchpad
|
|
784
|
+
// Pin the workspace root ONCE at extension load. Every scratchpad
|
|
801
785
|
// / PoC-path lookup otherwise re-walks the ambient cwd on each call — a
|
|
802
786
|
// mid-session `cd` would split state across two .scratchpad roots and
|
|
803
787
|
// misroot the hunt file-existence filter. The PoC runner reads PI_POC_ROOT
|
|
@@ -845,6 +829,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
845
829
|
promptGuidelines: [
|
|
846
830
|
"Use CaseAdd for a new security lead. New cases start as status='hypothesis' or 'investigating' — promote later with CaseUpdate.",
|
|
847
831
|
"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.",
|
|
832
|
+
"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
833
|
"Check the injected case list or CaseList/CaseSearch first. Do not add a duplicate for the same title/scope.",
|
|
849
834
|
"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
835
|
"confirmed/reported only via their gates: proof in poc + PromoteFinding for confirmed; CaseContext + report for reported.",
|
|
@@ -853,7 +838,13 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
853
838
|
parameters: AddSchema,
|
|
854
839
|
|
|
855
840
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
856
|
-
const
|
|
841
|
+
const { retry_policy, ...rest } = params as Record<string, unknown>;
|
|
842
|
+
const result = addCaseResult({
|
|
843
|
+
...(rest as CaseInput),
|
|
844
|
+
...(retry_policy !== undefined
|
|
845
|
+
? { retryPolicy: retry_policy as CaseInput["retryPolicy"] }
|
|
846
|
+
: {}),
|
|
847
|
+
});
|
|
857
848
|
const record = result.record;
|
|
858
849
|
return {
|
|
859
850
|
content: [
|
|
@@ -905,8 +896,14 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
905
896
|
parameters: UpdateSchema,
|
|
906
897
|
|
|
907
898
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
908
|
-
const { id, ...
|
|
909
|
-
const
|
|
899
|
+
const { id, retry_policy, ...rest } = params as Record<string, unknown>;
|
|
900
|
+
const update = {
|
|
901
|
+
...(rest as CaseUpdate),
|
|
902
|
+
...(retry_policy !== undefined
|
|
903
|
+
? { retryPolicy: retry_policy as CaseUpdate["retryPolicy"] }
|
|
904
|
+
: {}),
|
|
905
|
+
};
|
|
906
|
+
const result = updateCaseResult(id as string, update);
|
|
910
907
|
const record = result.record;
|
|
911
908
|
return {
|
|
912
909
|
content: [
|
|
@@ -971,7 +968,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
971
968
|
content: [
|
|
972
969
|
{
|
|
973
970
|
type: "text",
|
|
974
|
-
text: `Evidence item recorded:\n[${item.role}] ${item.summary}${item.artifactPath ? ` — ${item.artifactPath} sha256:${item.sha256?.slice(0, 12)}…` : ""}\n\n${formatCaseDetail(record)}`,
|
|
971
|
+
text: `Evidence item recorded:\n[${item.role}] ${item.summary}${item.artifactPath ? ` — ${item.artifactPath} sha256:${item.sha256?.slice(0, 12)}…` : ""}${item.containsSecret ? `\n⚠ Artifact contains suspected secrets (${item.secretFindings?.join(", ")}) — stored and hashed, but REDACT these values in any export or report.` : ""}\n\n${formatCaseDetail(record)}`,
|
|
975
972
|
},
|
|
976
973
|
],
|
|
977
974
|
details: { item, record },
|
|
@@ -1001,158 +998,6 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1001
998
|
},
|
|
1002
999
|
});
|
|
1003
1000
|
|
|
1004
|
-
// ── Tool: CoverageAdd ──
|
|
1005
|
-
|
|
1006
|
-
const CoverageAddSchema = Type.Object(
|
|
1007
|
-
{
|
|
1008
|
-
case_id: Type.String({
|
|
1009
|
-
description: "Case ID (the pipeline-run or finding case) to record coverage under",
|
|
1010
|
-
}),
|
|
1011
|
-
asset: Type.String({
|
|
1012
|
-
description:
|
|
1013
|
-
"The asset tested — copy it verbatim from the case target where shown. For scope=wide use the deployment-wide identifier.",
|
|
1014
|
-
}),
|
|
1015
|
-
class: Type.String({
|
|
1016
|
-
description:
|
|
1017
|
-
"The attack class tested (e.g. sql-injection, xss, idor, ssti, ssrf, auth-bypass, ...).",
|
|
1018
|
-
}),
|
|
1019
|
-
// Provider-safe string enum (per the header rule): Type.Union(Type.Literal)
|
|
1020
|
-
// serializes to anyOf/const, which some providers drop — scope would
|
|
1021
|
-
// arrive undefined and every explicit 'wide' verdict would silently
|
|
1022
|
-
// persist as 'local', under-reporting tested classes.
|
|
1023
|
-
scope: Type.String({
|
|
1024
|
-
enum: [...COVERAGE_SCOPE_VALUES],
|
|
1025
|
-
description:
|
|
1026
|
-
"'wide' if the verdict applies to the whole deployment/account/host (recorded ONCE, applies to every asset of the deployment — do NOT re-test it per asset); 'local' if specific to this one asset.",
|
|
1027
|
-
}),
|
|
1028
|
-
note: Type.String({
|
|
1029
|
-
description:
|
|
1030
|
-
"Short note of the tests ACTUALLY RUN and the verdict: techniques tried · result · key gap. A verdict guessed without testing can hide a real issue.",
|
|
1031
|
-
}),
|
|
1032
|
-
evidence_item_id: Type.Optional(
|
|
1033
|
-
Type.String({
|
|
1034
|
-
description:
|
|
1035
|
-
"Evidence item id backing this tested verdict (must be an artifact-backed EvidenceAdd item on this case). Cells without a backing item render as 'unbacked' in CoverageReport — 'tested' claims must be machine-checkable, not prose-only.",
|
|
1036
|
-
}),
|
|
1037
|
-
),
|
|
1038
|
-
},
|
|
1039
|
-
{ additionalProperties: false },
|
|
1040
|
-
);
|
|
1041
|
-
|
|
1042
|
-
registerCaseTool({
|
|
1043
|
-
name: "CoverageAdd",
|
|
1044
|
-
label: "Record Coverage",
|
|
1045
|
-
description:
|
|
1046
|
-
"Record what you tested so it is not re-tested. Call AFTER finishing a CLASS of issue, for BOTH outcomes (found or clean — a clean result is just as important to record). scope=wide: the verdict is a property of the whole deployment, recorded once and applied to every later asset (do NOT re-test per asset); scope=local: specific to this one asset. The cell's existence marks that class tested for that asset.",
|
|
1047
|
-
promptSnippet: "Record a tested attack class (coverage)",
|
|
1048
|
-
promptGuidelines: [
|
|
1049
|
-
"Record a coverage cell after you finish testing a class on an asset — found OR clean. Clean results are what make 'every class is COVERED' machine-checkable.",
|
|
1050
|
-
"scope=wide for deployment-wide verdicts (record once, applies to every asset of the deployment — do NOT re-test it per asset). scope=local for single-asset verdicts.",
|
|
1051
|
-
"The note must describe tests you ACTUALLY RAN, not assumptions. A verdict guessed without testing can hide a real issue.",
|
|
1052
|
-
"Coverage cells live on the pipeline-run case (or the target's main case); CoverageReport shows the matrix.",
|
|
1053
|
-
],
|
|
1054
|
-
parameters: CoverageAddSchema,
|
|
1055
|
-
|
|
1056
|
-
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1057
|
-
const item = recordCoverageResult(params.case_id as string, {
|
|
1058
|
-
asset: params.asset as string,
|
|
1059
|
-
class: params.class as string,
|
|
1060
|
-
scope: (params.scope ?? "local") as CoverageScope,
|
|
1061
|
-
note: params.note as string,
|
|
1062
|
-
evidenceItemId: params.evidence_item_id as string | undefined,
|
|
1063
|
-
});
|
|
1064
|
-
const record = getCaseById(params.case_id as string);
|
|
1065
|
-
if (!record) throw new Error(`Case not found after coverage insert: ${params.case_id}`);
|
|
1066
|
-
return {
|
|
1067
|
-
content: [
|
|
1068
|
-
{
|
|
1069
|
-
type: "text",
|
|
1070
|
-
text: `Coverage recorded: [${item.scope}] ${item.asset} × ${item.class} — ${item.note}\n\n${formatCaseDetail(record)}`,
|
|
1071
|
-
},
|
|
1072
|
-
],
|
|
1073
|
-
details: { item, record },
|
|
1074
|
-
};
|
|
1075
|
-
},
|
|
1076
|
-
|
|
1077
|
-
renderCall(args, theme) {
|
|
1078
|
-
return callLine(
|
|
1079
|
-
theme,
|
|
1080
|
-
"CoverageAdd",
|
|
1081
|
-
`${(args.asset as string) ?? ""} [${(args.class as string) ?? ""}]`,
|
|
1082
|
-
);
|
|
1083
|
-
},
|
|
1084
|
-
|
|
1085
|
-
renderResult(result, _opts, theme) {
|
|
1086
|
-
const details = result.details as { item?: CoverageItem } | undefined;
|
|
1087
|
-
if (!details?.item) {
|
|
1088
|
-
return new Text(theme.fg("error", "✗ CoverageAdd failed"), 0, 0);
|
|
1089
|
-
}
|
|
1090
|
-
return new Text(
|
|
1091
|
-
theme.fg("success", "✓ ") +
|
|
1092
|
-
theme.fg("dim", `[${details.item.scope}] `) +
|
|
1093
|
-
truncateToWidth(`${details.item.asset} × ${details.item.class}`, 50),
|
|
1094
|
-
0,
|
|
1095
|
-
0,
|
|
1096
|
-
);
|
|
1097
|
-
},
|
|
1098
|
-
});
|
|
1099
|
-
|
|
1100
|
-
// ── Tool: CoverageReport ──
|
|
1101
|
-
|
|
1102
|
-
const CoverageReportSchema = Type.Object(
|
|
1103
|
-
{
|
|
1104
|
-
case_id: Type.String({ description: "Case ID to render the coverage matrix for" }),
|
|
1105
|
-
},
|
|
1106
|
-
{ additionalProperties: false },
|
|
1107
|
-
);
|
|
1108
|
-
|
|
1109
|
-
registerCaseTool({
|
|
1110
|
-
name: "CoverageReport",
|
|
1111
|
-
label: "Coverage Matrix",
|
|
1112
|
-
description:
|
|
1113
|
-
"Render the machine-checkable coverage matrix for a case: which (asset × attack-class) cells are tested, with wide-verdict propagation. Run before deciding HUNT coverage is done — the plateau stop (zero new classes testable) must be visible in the matrix, not asserted in prose.",
|
|
1114
|
-
promptSnippet: "Show which attack classes were tested where",
|
|
1115
|
-
promptGuidelines: [
|
|
1116
|
-
"Run CoverageReport before claiming 'every class is COVERED/SKIPPED/NOT_FOUND' — the claim must match the matrix.",
|
|
1117
|
-
"A class with a wide clean verdict covers every asset — do NOT re-test it per asset.",
|
|
1118
|
-
"Classes tested with no cell recorded are invisible: record coverage as you finish each class (CoverageAdd).",
|
|
1119
|
-
],
|
|
1120
|
-
parameters: CoverageReportSchema,
|
|
1121
|
-
|
|
1122
|
-
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1123
|
-
const summary = coverageSummary(params.case_id as string);
|
|
1124
|
-
const lines: string[] = [`Coverage matrix for ${params.case_id}:`];
|
|
1125
|
-
for (const asset of summary.assets) {
|
|
1126
|
-
lines.push(`\n## ${asset}`);
|
|
1127
|
-
for (const cell of summary.byAsset[asset] ?? []) {
|
|
1128
|
-
lines.push(
|
|
1129
|
-
`- [${cell.scope}] ${cell.class} — ${cell.note}${cell.testedBy ? ` (by ${cell.testedBy})` : ""}` +
|
|
1130
|
-
(cell.evidenceItemId
|
|
1131
|
-
? ""
|
|
1132
|
-
: " ⚠ unbacked (link an artifact-backed evidence item via CoverageAdd evidence_item_id)"),
|
|
1133
|
-
);
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
if (summary.items.length === 0) {
|
|
1137
|
-
lines.push("\n(no coverage recorded yet — run CoverageAdd as each class is tested)");
|
|
1138
|
-
}
|
|
1139
|
-
return {
|
|
1140
|
-
content: [{ type: "text", text: lines.join("\n") }],
|
|
1141
|
-
details: { summary },
|
|
1142
|
-
};
|
|
1143
|
-
},
|
|
1144
|
-
|
|
1145
|
-
renderCall(args, theme) {
|
|
1146
|
-
return callLine(theme, "CoverageReport", (args.case_id as string) ?? "");
|
|
1147
|
-
},
|
|
1148
|
-
|
|
1149
|
-
renderResult(result, _opts, theme) {
|
|
1150
|
-
const details = result.details as { summary?: { items?: CoverageItem[] } } | undefined;
|
|
1151
|
-
const n = details?.summary?.items?.length ?? 0;
|
|
1152
|
-
return new Text(theme.fg("success", `✓ ${n} coverage cell(s)`), 0, 0);
|
|
1153
|
-
},
|
|
1154
|
-
});
|
|
1155
|
-
|
|
1156
1001
|
// ── Tool: PromoteFinding (phase 1) ──
|
|
1157
1002
|
|
|
1158
1003
|
if (!startedAsSubagent)
|
|
@@ -1169,8 +1014,9 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1169
1014
|
"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
1015
|
"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
1016
|
"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.",
|
|
1017
|
+
"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
1018
|
"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
|
|
1019
|
+
"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
1020
|
"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
1021
|
],
|
|
1176
1022
|
parameters: PromoteSchema,
|
|
@@ -1188,7 +1034,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1188
1034
|
const caseId = params.id as string;
|
|
1189
1035
|
const current = assertPromotable(caseId);
|
|
1190
1036
|
|
|
1191
|
-
const fail = (text: string
|
|
1037
|
+
const fail = (text: string): never => {
|
|
1192
1038
|
throw new Error(text);
|
|
1193
1039
|
};
|
|
1194
1040
|
|
|
@@ -1199,40 +1045,75 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1199
1045
|
(params.mode as string | undefined) === "intra_target" ? "intra_target" : "inter_host";
|
|
1200
1046
|
const isIntra = mode === "intra_target";
|
|
1201
1047
|
if (!pocPath) {
|
|
1202
|
-
return fail("poc_path is REQUIRED: absolute path to the PoC script run by the harness."
|
|
1203
|
-
|
|
1204
|
-
|
|
1048
|
+
return fail("poc_path is REQUIRED: absolute path to the PoC script run by the harness.");
|
|
1049
|
+
}
|
|
1050
|
+
const caseTarget = current.target ?? "";
|
|
1051
|
+
// Panel votes (advisory pre-gate): validated here so a malformed panel
|
|
1052
|
+
// is rejected before any sandboxed run is paid for.
|
|
1053
|
+
let panelVotes: PendingConfirmation["panelVotes"];
|
|
1054
|
+
if (params.panel_votes !== undefined) {
|
|
1055
|
+
const parsedVotes = validatePanelVotes(params.panel_votes);
|
|
1056
|
+
if (!parsedVotes.ok) {
|
|
1057
|
+
return fail(`Invalid panel_votes: ${parsedVotes.error}`);
|
|
1058
|
+
}
|
|
1059
|
+
panelVotes = parsedVotes.votes;
|
|
1205
1060
|
}
|
|
1206
|
-
//
|
|
1207
|
-
//
|
|
1208
|
-
//
|
|
1209
|
-
|
|
1061
|
+
// ── OOB callback (Tier 1, opt-in for blind classes) ──
|
|
1062
|
+
// The operator-run oracle owns the evidence channel; the harness owns
|
|
1063
|
+
// the secret (per-run token, provisioned before the runs and injected
|
|
1064
|
+
// as env — the value does not exist when the script was written).
|
|
1065
|
+
// Without an oracle this stays fail-closed.
|
|
1066
|
+
const oobRequested = params.oob === true;
|
|
1067
|
+
// Intra-target + OOB is rejected up front: intra_target's
|
|
1068
|
+
// discriminating variable is identity/parameter on the SAME host;
|
|
1069
|
+
// mixing it with a callback differential would make precedence
|
|
1070
|
+
// ambiguous. OOB is for inter-host/blind classes.
|
|
1071
|
+
if (isIntra && oobRequested) {
|
|
1072
|
+
return fail(
|
|
1073
|
+
"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.",
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
let oobConfig: OobOracleConfig | undefined;
|
|
1077
|
+
let targetCallback: ProvisionedCallback | undefined;
|
|
1078
|
+
let controlCallback: ProvisionedCallback | undefined;
|
|
1079
|
+
if (oobRequested) {
|
|
1080
|
+
const oracle = readOobOracleConfig();
|
|
1081
|
+
if (!oracle.config) {
|
|
1082
|
+
return fail(`OOB CONFIRMATION UNAVAILABLE: ${oracle.error}`);
|
|
1083
|
+
}
|
|
1084
|
+
oobConfig = oracle.config;
|
|
1085
|
+
// Provision both identities concurrently — each is an oracle round trip.
|
|
1086
|
+
[targetCallback, controlCallback] = await Promise.all([
|
|
1087
|
+
provisionCallback(oobConfig),
|
|
1088
|
+
provisionCallback(oobConfig),
|
|
1089
|
+
]);
|
|
1090
|
+
}
|
|
1091
|
+
// OOB-only bundles (blind classes, no operator-approved control host)
|
|
1092
|
+
// prove target-dependence via the token differential instead.
|
|
1093
|
+
const oobOnly = oobRequested && !controlTarget;
|
|
1094
|
+
if (!isIntra && !oobOnly) {
|
|
1210
1095
|
if (!controlTarget) {
|
|
1211
1096
|
return fail(
|
|
1212
|
-
"control_target is REQUIRED for inter-host mode: a distinct baseline target that lacks the vulnerability. For access-control/logic bugs use mode='intra_target' with an evidence baseline instead.",
|
|
1213
|
-
{ missingControlTarget: true },
|
|
1097
|
+
"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).",
|
|
1214
1098
|
);
|
|
1215
1099
|
}
|
|
1216
1100
|
if (controlTarget === current.target) {
|
|
1217
1101
|
return fail(
|
|
1218
1102
|
"control_target must differ from the case target; a control run against the vulnerable target proves nothing.",
|
|
1219
|
-
{ controlTargetEqualsCaseTarget: true },
|
|
1220
1103
|
);
|
|
1221
1104
|
}
|
|
1222
1105
|
}
|
|
1223
1106
|
if (params.local === true && process.env.PI_POC_ALLOW_NETWORK !== "1") {
|
|
1224
1107
|
return fail(
|
|
1225
1108
|
"Networked PoC execution is operator-gated. Set PI_POC_ALLOW_NETWORK=1 to authorize the host-network sandbox for this session.",
|
|
1226
|
-
{ networkNotAuthorized: true },
|
|
1227
1109
|
);
|
|
1228
1110
|
}
|
|
1229
|
-
if (!isIntra) {
|
|
1111
|
+
if (!isIntra && !oobOnly) {
|
|
1230
1112
|
const controlAuthorization = controlTargetAuthorizationError(controlTarget);
|
|
1231
1113
|
if (controlAuthorization) {
|
|
1232
1114
|
return fail(
|
|
1233
1115
|
`CONTROL AUTHORIZATION FAILED: ${controlAuthorization}. ` +
|
|
1234
1116
|
"The operator must set PI_POC_CONTROL_TARGETS to the exact approved control host/origin before this control can anchor confirmation.",
|
|
1235
|
-
{ controlNotAuthorized: true },
|
|
1236
1117
|
);
|
|
1237
1118
|
}
|
|
1238
1119
|
}
|
|
@@ -1243,46 +1124,40 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1243
1124
|
try {
|
|
1244
1125
|
pocHash = createHash("sha256").update(readFileSync(pocPath)).digest("hex");
|
|
1245
1126
|
} catch (e) {
|
|
1246
|
-
return fail(`Cannot read PoC script: ${(e as Error).message}
|
|
1247
|
-
sameFileCheckFailed: true,
|
|
1248
|
-
});
|
|
1127
|
+
return fail(`Cannot read PoC script: ${(e as Error).message}`);
|
|
1249
1128
|
}
|
|
1250
|
-
if (!isIntra) {
|
|
1129
|
+
if (!isIntra && !oobOnly) {
|
|
1251
1130
|
let controlHash: string | undefined;
|
|
1252
1131
|
try {
|
|
1253
1132
|
controlHash = createHash("sha256").update(readFileSync(controlPath)).digest("hex");
|
|
1254
1133
|
} catch (e) {
|
|
1255
1134
|
return fail(
|
|
1256
1135
|
`Cannot read control script for the same-file check: ${(e as Error).message}`,
|
|
1257
|
-
{ sameFileCheckFailed: true },
|
|
1258
1136
|
);
|
|
1259
1137
|
}
|
|
1260
1138
|
if (pocHash !== controlHash) {
|
|
1261
1139
|
return fail(
|
|
1262
1140
|
"CONTROL CHECK FAILED: control_path must be the SAME script as poc_path (sha256 mismatch). Case remains investigating.",
|
|
1263
|
-
{ controlHashMismatch: true },
|
|
1264
1141
|
);
|
|
1265
1142
|
}
|
|
1266
1143
|
}
|
|
1267
1144
|
|
|
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
|
-
}
|
|
1145
|
+
// ── OOB callback tokens were provisioned above, before the runs ──
|
|
1276
1146
|
const runOptions = (pocMode: string, target: string): PocRunOptions => ({
|
|
1277
1147
|
network: params.local === true ? "host" : "none",
|
|
1278
1148
|
local: params.local === true,
|
|
1279
1149
|
env: {
|
|
1280
1150
|
PI_POC_MODE: pocMode,
|
|
1281
1151
|
PI_POC_TARGET: target,
|
|
1152
|
+
...(oobRequested && targetCallback && controlCallback
|
|
1153
|
+
? {
|
|
1154
|
+
PI_POC_CALLBACK_DOMAIN:
|
|
1155
|
+
pocMode === "control" ? controlCallback.domain : targetCallback.domain,
|
|
1156
|
+
}
|
|
1157
|
+
: {}),
|
|
1282
1158
|
},
|
|
1283
1159
|
});
|
|
1284
1160
|
|
|
1285
|
-
const caseTarget = current.target ?? "";
|
|
1286
1161
|
// Determinism: TWO target runs. Exit 0 is run integrity only; nonce-bound
|
|
1287
1162
|
// body evidence plus the harness-owned differential replay (inter-host
|
|
1288
1163
|
// control, or intra-target same-host baseline) form the machine gate.
|
|
@@ -1299,7 +1174,6 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1299
1174
|
`${mode} run did not complete or output capture was incomplete` +
|
|
1300
1175
|
(r.infraError ? ` (infra: ${r.output.trim()})` : "") +
|
|
1301
1176
|
". A crash is not evidence. Case remains investigating.",
|
|
1302
|
-
{ run: r, pocCrashed: true },
|
|
1303
1177
|
);
|
|
1304
1178
|
}
|
|
1305
1179
|
if (r.evidenceError) {
|
|
@@ -1307,13 +1181,10 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1307
1181
|
`EVIDENCE CONTRACT FAILED (${mode} run): ${r.evidenceError}. ` +
|
|
1308
1182
|
"The PoC must write evidence.json to $PI_POC_EVIDENCE_DIR — { nonce (echo $PI_POC_NONCE), claim, verify: { method, url, expect: { status?, body_contains / body_regex } }, observations }; a response-body assertion is mandatory — " +
|
|
1309
1183
|
"the file is bound to this run and validated by the harness. Case remains investigating.",
|
|
1310
|
-
{ run: r, evidenceError: r.evidenceError },
|
|
1311
1184
|
);
|
|
1312
1185
|
}
|
|
1313
1186
|
if (!r.evidence || !r.evidenceSha256 || !r.nonce) {
|
|
1314
|
-
return fail(`${mode} run produced no evidence. Case remains investigating
|
|
1315
|
-
run: r,
|
|
1316
|
-
});
|
|
1187
|
+
return fail(`${mode} run produced no evidence. Case remains investigating.`);
|
|
1317
1188
|
}
|
|
1318
1189
|
return {
|
|
1319
1190
|
mode,
|
|
@@ -1336,8 +1207,17 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1336
1207
|
evidenceRun(run2, "poc", caseTarget),
|
|
1337
1208
|
];
|
|
1338
1209
|
|
|
1210
|
+
// Reflection canary + OOB is rejected after run 1 (the canary is
|
|
1211
|
+
// declared inside evidence.json): the canary path requires a harness
|
|
1212
|
+
// response transcript, which OOB-only bundles never produce — the
|
|
1213
|
+
// per-run callback token IS the causality signal there.
|
|
1214
|
+
if (oobRequested && targetRuns.some((r) => r.evidence.verify.canary !== undefined)) {
|
|
1215
|
+
return fail(
|
|
1216
|
+
"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.",
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1339
1219
|
const allowPrivateReplay = process.env.PI_POC_ALLOW_PRIVATE_REPLAY === "1";
|
|
1340
|
-
let harnessVerified: HarnessVerifyResult;
|
|
1220
|
+
let harnessVerified: HarnessVerifyResult | undefined;
|
|
1341
1221
|
let controlRun: PocEvidenceRun | undefined;
|
|
1342
1222
|
if (isIntra) {
|
|
1343
1223
|
// Intra-target: prove target-dependence with the evidence's same-host
|
|
@@ -1347,19 +1227,17 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1347
1227
|
if (ev0.verify.mode !== "intra_target") {
|
|
1348
1228
|
return fail(
|
|
1349
1229
|
"INTRA-TARGET FAILED: the PoC's evidence.json must set verify.mode='intra_target' when promoting in intra-target mode.",
|
|
1350
|
-
{ intraModeMismatch: true },
|
|
1351
1230
|
);
|
|
1352
1231
|
}
|
|
1353
1232
|
if (!ev0.baseline) {
|
|
1354
1233
|
return fail(
|
|
1355
1234
|
"INTRA-TARGET FAILED: evidence.json must include a baseline — a legitimate same-host request whose response must NOT satisfy the attack predicate.",
|
|
1356
|
-
{ intraBaselineMissing: true },
|
|
1357
1235
|
);
|
|
1358
1236
|
}
|
|
1359
1237
|
harnessVerified = await replayIntraTarget(ev0, caseTarget, {
|
|
1360
1238
|
allowPrivate: allowPrivateReplay,
|
|
1361
1239
|
});
|
|
1362
|
-
} else {
|
|
1240
|
+
} else if (!oobOnly) {
|
|
1363
1241
|
// Inter-host (Tier 2): the harness executes the SAME request template
|
|
1364
1242
|
// against target and operator-approved control, applying the target's
|
|
1365
1243
|
// predicates to both. DNS is pinned at connect time.
|
|
@@ -1375,6 +1253,20 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1375
1253
|
{ allowPrivate: allowPrivateReplay },
|
|
1376
1254
|
);
|
|
1377
1255
|
}
|
|
1256
|
+
// OOB differential: poll the oracle for both run tokens. The ledger's
|
|
1257
|
+
// assertMachineConfirmation consumes this BEFORE the response-diff
|
|
1258
|
+
// requirement — blind classes pass via this path when the oracle saw
|
|
1259
|
+
// the target token and NOT the control token under attested source
|
|
1260
|
+
// separation.
|
|
1261
|
+
let callbackVerified: OobVerification | undefined;
|
|
1262
|
+
if (oobConfig && targetCallback && controlCallback) {
|
|
1263
|
+
callbackVerified = (
|
|
1264
|
+
await verifyOobDifferential({
|
|
1265
|
+
targetToken: targetCallback.token,
|
|
1266
|
+
controlToken: controlCallback.token,
|
|
1267
|
+
})
|
|
1268
|
+
).verification;
|
|
1269
|
+
}
|
|
1378
1270
|
const bundle: PendingConfirmation = {
|
|
1379
1271
|
caseId,
|
|
1380
1272
|
ranAt: new Date().toISOString(),
|
|
@@ -1383,16 +1275,24 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1383
1275
|
mode,
|
|
1384
1276
|
targetRuns,
|
|
1385
1277
|
harnessVerified,
|
|
1386
|
-
...(
|
|
1278
|
+
...(panelVotes ? { panelVotes } : {}),
|
|
1279
|
+
...(callbackVerified && targetCallback && controlCallback
|
|
1280
|
+
? {
|
|
1281
|
+
callbackVerified,
|
|
1282
|
+
oobTokens: {
|
|
1283
|
+
targetToken: targetCallback.token,
|
|
1284
|
+
controlToken: controlCallback.token,
|
|
1285
|
+
},
|
|
1286
|
+
}
|
|
1287
|
+
: {}),
|
|
1288
|
+
...(!(isIntra || oobOnly) ? { controlPath, controlTarget, controlRun } : {}),
|
|
1387
1289
|
};
|
|
1388
1290
|
|
|
1389
1291
|
let record: CaseRecord;
|
|
1390
1292
|
try {
|
|
1391
1293
|
record = storePendingConfirmation(caseId, bundle);
|
|
1392
1294
|
} catch (e) {
|
|
1393
|
-
return fail(`Pending confirmation rejected: ${(e as Error).message}
|
|
1394
|
-
storeRejected: true,
|
|
1395
|
-
});
|
|
1295
|
+
return fail(`Pending confirmation rejected: ${(e as Error).message}`);
|
|
1396
1296
|
}
|
|
1397
1297
|
|
|
1398
1298
|
return {
|
|
@@ -1404,7 +1304,11 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1404
1304
|
`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
1305
|
`Evidence sha256: ${targetRuns[0].evidenceSha256}\n` +
|
|
1406
1306
|
`PoC script sha256 (at run time): ${pocHash}\n` +
|
|
1407
|
-
`Harness verify replay: ${harnessVerified
|
|
1307
|
+
`Harness verify replay: ${harnessVerified?.attempted ? (harnessVerified.pass ? `PASS (status ${harnessVerified.status})` : `FAILED — ${harnessVerified.note}`) : (harnessVerified?.note ?? "not run")}
|
|
1308
|
+
` +
|
|
1309
|
+
(callbackVerified
|
|
1310
|
+
? `OOB oracle: target-token hits ${callbackVerified.targetHits}, control-token hits ${callbackVerified.controlHits}, source-separated: ${String(callbackVerified.sourceSeparated)} — ${callbackVerified.note}\n`
|
|
1311
|
+
: "") +
|
|
1408
1312
|
`\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
1313
|
},
|
|
1410
1314
|
],
|
|
@@ -1454,15 +1358,18 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1454
1358
|
name: "ConfirmFinding",
|
|
1455
1359
|
label: "Main-Agent Confirmation",
|
|
1456
1360
|
description:
|
|
1457
|
-
"Phase 2 of confirmation, reserved for the main/coordinator agent: commit or refuse promotion after
|
|
1458
|
-
promptSnippet: "Main agent: independently
|
|
1361
|
+
"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).",
|
|
1362
|
+
promptSnippet: "Main agent: independently re-test, then commit or refuse PoC confirmation",
|
|
1459
1363
|
promptGuidelines: [
|
|
1460
1364
|
"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
|
-
"
|
|
1365
|
+
"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.",
|
|
1366
|
+
"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.",
|
|
1367
|
+
"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?",
|
|
1368
|
+
"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.",
|
|
1369
|
+
"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.",
|
|
1370
|
+
"NOT_CONFIRMED means you POSITIVELY disproved it (by-design, circular, mislabeled, no impact). Never mark NOT_CONFIRMED merely because you could not reproduce it.",
|
|
1371
|
+
"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.",
|
|
1372
|
+
"Every verdict consumes the attempt: a fresh PromoteFinding run is required to try again. Never CaseUpdate status='confirmed' directly — always PromoteFinding + ConfirmFinding.",
|
|
1466
1373
|
],
|
|
1467
1374
|
parameters: ConfirmSchema,
|
|
1468
1375
|
|
|
@@ -1493,6 +1400,46 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1493
1400
|
replay = await replayIntraTarget(bundle.targetRuns[0].evidence, caseTargetForReplay, {
|
|
1494
1401
|
allowPrivate,
|
|
1495
1402
|
});
|
|
1403
|
+
} else if (bundle.callbackVerified?.attempted && bundle.oobTokens) {
|
|
1404
|
+
// OOB differential: fresh harness-owned re-poll of BOTH run tokens.
|
|
1405
|
+
// Re-polling at confirm time catches interactions that landed after
|
|
1406
|
+
// phase 1 (e.g. a delayed control-token hit) — the verdict is bound
|
|
1407
|
+
// to this fresh observation, not the stored one.
|
|
1408
|
+
const { verification } = await verifyOobDifferential({
|
|
1409
|
+
targetToken: bundle.oobTokens.targetToken,
|
|
1410
|
+
controlToken: bundle.oobTokens.controlToken,
|
|
1411
|
+
});
|
|
1412
|
+
const oobPass =
|
|
1413
|
+
verification.targetHits > 0 &&
|
|
1414
|
+
verification.controlHits === 0 &&
|
|
1415
|
+
verification.sourceSeparated === true;
|
|
1416
|
+
replay = {
|
|
1417
|
+
attempted: true,
|
|
1418
|
+
pass: oobPass,
|
|
1419
|
+
target: {
|
|
1420
|
+
attempted: true,
|
|
1421
|
+
matched: verification.targetHits > 0,
|
|
1422
|
+
url: bundle.targetRuns[0].evidence.verify.url,
|
|
1423
|
+
note: verification.note,
|
|
1424
|
+
},
|
|
1425
|
+
control: {
|
|
1426
|
+
attempted: true,
|
|
1427
|
+
matched: verification.controlHits > 0,
|
|
1428
|
+
url: bundle.targetRuns[0].evidence.verify.url,
|
|
1429
|
+
note: `${verification.controlHits} control-token interaction(s)`,
|
|
1430
|
+
},
|
|
1431
|
+
differential:
|
|
1432
|
+
verification.targetHits > 0
|
|
1433
|
+
? verification.controlHits === 0
|
|
1434
|
+
? "target_only"
|
|
1435
|
+
: "both"
|
|
1436
|
+
: "neither",
|
|
1437
|
+
note: `harness OOB re-poll: ${verification.note}`,
|
|
1438
|
+
};
|
|
1439
|
+
} else if (bundle.callbackVerified?.attempted) {
|
|
1440
|
+
throw new Error(
|
|
1441
|
+
"OOB bundle lacks its provisioned tokens (pre-token-storage ledger) — re-run PromoteFinding for a fresh bundle",
|
|
1442
|
+
);
|
|
1496
1443
|
} else {
|
|
1497
1444
|
if (!bundle.controlTarget) {
|
|
1498
1445
|
throw new Error("inter-host confirmation requires a control target");
|
|
@@ -1525,7 +1472,10 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1525
1472
|
text: promoted
|
|
1526
1473
|
? `Main agent CONFIRMED. Case promoted:
|
|
1527
1474
|
${formatCaseDetail(record)}`
|
|
1528
|
-
:
|
|
1475
|
+
: parsedVerdict.verdict.verdict === "INCONCLUSIVE"
|
|
1476
|
+
? `Main agent INCONCLUSIVE — case stays investigating, preserved for manual review (not disproved):
|
|
1477
|
+
${formatCaseDetail(record)}`
|
|
1478
|
+
: `Main agent NOT_CONFIRMED — case stays investigating (attempt recorded):
|
|
1529
1479
|
${formatCaseDetail(record)}`,
|
|
1530
1480
|
},
|
|
1531
1481
|
],
|
|
@@ -1549,6 +1499,65 @@ ${formatCaseDetail(record)}`,
|
|
|
1549
1499
|
},
|
|
1550
1500
|
});
|
|
1551
1501
|
|
|
1502
|
+
// ── Tool: CoverageAdd ──
|
|
1503
|
+
|
|
1504
|
+
registerCaseTool({
|
|
1505
|
+
name: "CoverageAdd",
|
|
1506
|
+
label: "Record Coverage Cell",
|
|
1507
|
+
description:
|
|
1508
|
+
"Record a tested (asset × attack-class) coverage cell on a case — for BOTH outcomes (found or clean). Clean results make 'every class is covered' machine-checkable. scope='wide' records a deployment-wide verdict ONCE (do not re-test per asset); 'local' is asset-specific. Cells can carry an artifact-backed evidence item; unbacked cells render as such in the report contract gate.",
|
|
1509
|
+
promptSnippet: "Record a tested coverage cell (found or clean)",
|
|
1510
|
+
promptGuidelines: [
|
|
1511
|
+
"Use CoverageAdd whenever you finish testing a class on an asset — a clean 'no injection on /api/orders' verdict is just as load-bearing as a finding.",
|
|
1512
|
+
"scope='wide' when the verdict is a property of the whole deployment (record once — do NOT re-test per asset); scope='local' for one asset.",
|
|
1513
|
+
"Reference an artifact-backed EvidenceAdd item via evidence_item_id so the tested verdict is machine-checkable, not prose-only.",
|
|
1514
|
+
],
|
|
1515
|
+
parameters: CoverageAddSchema,
|
|
1516
|
+
|
|
1517
|
+
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1518
|
+
const item = recordCoverageResult(params.case_id as string, {
|
|
1519
|
+
asset: params.asset as string,
|
|
1520
|
+
class: params.class as string,
|
|
1521
|
+
scope: params.scope as CoverageScope,
|
|
1522
|
+
note: params.note as string,
|
|
1523
|
+
evidenceItemId: params.evidence_item_id as string | undefined,
|
|
1524
|
+
});
|
|
1525
|
+
const record = getCaseById(params.case_id as string);
|
|
1526
|
+
if (!record) throw new Error(`Case not found after coverage insert: ${params.case_id}`);
|
|
1527
|
+
return {
|
|
1528
|
+
content: [
|
|
1529
|
+
{
|
|
1530
|
+
type: "text",
|
|
1531
|
+
text: `Coverage cell recorded:\n[${item.scope}] ${item.asset} × ${item.class} — ${item.note}${item.evidenceItemId ? ` (backed by ${item.evidenceItemId})` : " (unbacked — attach an EvidenceAdd item to make it machine-checkable)"}\n\n${formatCaseDetail(record)}`,
|
|
1532
|
+
},
|
|
1533
|
+
],
|
|
1534
|
+
details: { item, record },
|
|
1535
|
+
};
|
|
1536
|
+
},
|
|
1537
|
+
|
|
1538
|
+
renderCall(args, theme) {
|
|
1539
|
+
return callLine(
|
|
1540
|
+
theme,
|
|
1541
|
+
"CoverageAdd",
|
|
1542
|
+
`${(args.case_id as string) ?? ""} ${(args.asset as string) ?? ""}×${(args.class as string) ?? ""}`,
|
|
1543
|
+
);
|
|
1544
|
+
},
|
|
1545
|
+
|
|
1546
|
+
renderResult(result, _opts, theme) {
|
|
1547
|
+
const details = result.details as { item?: CoverageItem } | undefined;
|
|
1548
|
+
if (!details?.item) {
|
|
1549
|
+
return new Text(theme.fg("error", "✗ CoverageAdd failed"), 0, 0);
|
|
1550
|
+
}
|
|
1551
|
+
return new Text(
|
|
1552
|
+
theme.fg("success", "✓ ") +
|
|
1553
|
+
theme.fg("dim", `[${details.item.scope}] `) +
|
|
1554
|
+
truncateToWidth(`${details.item.asset} × ${details.item.class}`, 60),
|
|
1555
|
+
0,
|
|
1556
|
+
0,
|
|
1557
|
+
);
|
|
1558
|
+
},
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1552
1561
|
// ── Tool: CaseGet ──
|
|
1553
1562
|
|
|
1554
1563
|
registerCaseTool({
|
|
@@ -1763,67 +1772,6 @@ ${formatCaseDetail(record)}`,
|
|
|
1763
1772
|
},
|
|
1764
1773
|
});
|
|
1765
1774
|
|
|
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
1775
|
// ── Tool: CaseContext ──
|
|
1828
1776
|
|
|
1829
1777
|
registerCaseTool({
|
|
@@ -1839,15 +1787,15 @@ ${formatCaseDetail(record)}`,
|
|
|
1839
1787
|
parameters: IdSchema,
|
|
1840
1788
|
|
|
1841
1789
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1842
|
-
const { path, contextPath, record } =
|
|
1790
|
+
const { path, contextPath, contractPath, record } = writeCaseContext(params.id as string);
|
|
1843
1791
|
return {
|
|
1844
1792
|
content: [
|
|
1845
1793
|
{
|
|
1846
1794
|
type: "text",
|
|
1847
|
-
text: `Case context written: ${contextPath}\nReport path: ${path}\n${formatCase(record)}`,
|
|
1795
|
+
text: `Case context written: ${contextPath}\nReport path: ${path}\nReport contract path: ${contractPath} — write the closed-schema JSON contract there (evidence_ids + coverage_refs must reference only this case's items); status='reported' is rejected until it validates.\n${formatCase(record)}`,
|
|
1848
1796
|
},
|
|
1849
1797
|
],
|
|
1850
|
-
details: { path, contextPath, record },
|
|
1798
|
+
details: { path, contextPath, contractPath, record },
|
|
1851
1799
|
};
|
|
1852
1800
|
},
|
|
1853
1801
|
|
|
@@ -1865,244 +1813,6 @@ ${formatCaseDetail(record)}`,
|
|
|
1865
1813
|
},
|
|
1866
1814
|
});
|
|
1867
1815
|
|
|
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
|
-
// ── Tool: ScratchpadInit ──
|
|
1960
|
-
|
|
1961
|
-
registerCaseTool({
|
|
1962
|
-
name: "ScratchpadInit",
|
|
1963
|
-
label: "Init Scratchpad",
|
|
1964
|
-
description:
|
|
1965
|
-
"Initialize a crash-recoverable artifact store for a pipeline run. Creates the directory structure and an initial state.json checkpoint. Idempotent — safe to call on resume without --fresh; returns the existing checkpoint if the run already exists.",
|
|
1966
|
-
promptSnippet: "Initialize the pipeline artifact store for a run",
|
|
1967
|
-
promptGuidelines: [
|
|
1968
|
-
"Call ScratchpadInit once at the start of a pipeline run (or on resume before ScratchpadResume).",
|
|
1969
|
-
"The run_id is arbitrary but should be unique per pipeline run — typically <target>-<timestamp>.",
|
|
1970
|
-
"On resume, ScratchpadInit returns the existing checkpoint without wiping it; pair with ScratchpadResume to skip completed phases.",
|
|
1971
|
-
],
|
|
1972
|
-
parameters: RunIdSchema,
|
|
1973
|
-
|
|
1974
|
-
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1975
|
-
const cp = scratchpad_init(params.run_id as string);
|
|
1976
|
-
return {
|
|
1977
|
-
content: [
|
|
1978
|
-
{
|
|
1979
|
-
type: "text",
|
|
1980
|
-
text: `Scratchpad initialized for run ${cp.run_id}.\nCompleted phases: ${cp.completed_phases.length ? cp.completed_phases.join(", ") : "none"}`,
|
|
1981
|
-
},
|
|
1982
|
-
],
|
|
1983
|
-
details: { checkpoint: cp },
|
|
1984
|
-
};
|
|
1985
|
-
},
|
|
1986
|
-
|
|
1987
|
-
renderCall(args, theme) {
|
|
1988
|
-
return callLine(theme, "ScratchpadInit", (args.run_id as string) ?? "");
|
|
1989
|
-
},
|
|
1990
|
-
|
|
1991
|
-
renderResult(result, _opts, theme) {
|
|
1992
|
-
const cp = (result.details as { checkpoint: { run_id: string } } | undefined)?.checkpoint;
|
|
1993
|
-
return new Text(`${theme.fg("success", "✓ ")}ScratchpadInit ${cp?.run_id ?? ""}`, 0, 0);
|
|
1994
|
-
},
|
|
1995
|
-
});
|
|
1996
|
-
|
|
1997
|
-
// ── Tool: ScratchpadResume ──
|
|
1998
|
-
|
|
1999
|
-
registerCaseTool({
|
|
2000
|
-
name: "ScratchpadResume",
|
|
2001
|
-
label: "Resume Scratchpad",
|
|
2002
|
-
description:
|
|
2003
|
-
"Read the checkpoint and artifact listing for a pipeline run to decide where to resume. Returns the next phase to run (or null if done) and which phases already completed. Returns null if the run does not exist.",
|
|
2004
|
-
promptSnippet: "Check pipeline resume state — which phases are done",
|
|
2005
|
-
promptGuidelines: [
|
|
2006
|
-
"Call ScratchpadResume at pipeline start to determine where to resume. If it returns a checkpoint, skip completed phases (check ScratchpadPhaseDone before each dispatch) and continue from next_phase.",
|
|
2007
|
-
"If ScratchpadResume returns null, the run has no checkpoint — call ScratchpadInit to start fresh.",
|
|
2008
|
-
"Use ScratchpadPhaseDone before dispatching each stage to avoid re-running completed phases (idempotent resume).",
|
|
2009
|
-
],
|
|
2010
|
-
parameters: RunIdSchema,
|
|
2011
|
-
|
|
2012
|
-
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
2013
|
-
const resume = scratchpad_resume(params.run_id as string);
|
|
2014
|
-
if (!resume) {
|
|
2015
|
-
return {
|
|
2016
|
-
content: [
|
|
2017
|
-
{
|
|
2018
|
-
type: "text",
|
|
2019
|
-
text: `No scratchpad found for run ${params.run_id}. Call ScratchpadInit to start a new run.`,
|
|
2020
|
-
},
|
|
2021
|
-
],
|
|
2022
|
-
details: { resume: null },
|
|
2023
|
-
};
|
|
2024
|
-
}
|
|
2025
|
-
const cp = resume.checkpoint;
|
|
2026
|
-
return {
|
|
2027
|
-
content: [
|
|
2028
|
-
{
|
|
2029
|
-
type: "text",
|
|
2030
|
-
text:
|
|
2031
|
-
`Resume run ${cp.run_id}:\n` +
|
|
2032
|
-
`Completed phases: ${cp.completed_phases.length ? cp.completed_phases.join(", ") : "none"}\n` +
|
|
2033
|
-
`Next phase: ${resume.next_phase ?? "none (run is done)"}`,
|
|
2034
|
-
},
|
|
2035
|
-
],
|
|
2036
|
-
details: { resume },
|
|
2037
|
-
};
|
|
2038
|
-
},
|
|
2039
|
-
|
|
2040
|
-
renderCall(args, theme) {
|
|
2041
|
-
return callLine(theme, "ScratchpadResume", (args.run_id as string) ?? "");
|
|
2042
|
-
},
|
|
2043
|
-
|
|
2044
|
-
renderResult(result, _opts, theme) {
|
|
2045
|
-
const resume = (result.details as { resume: ScratchpadResume | null } | undefined)?.resume;
|
|
2046
|
-
if (!resume) return new Text(theme.fg("warning", "↷ ScratchpadResume — no run found"), 0, 0);
|
|
2047
|
-
return new Text(
|
|
2048
|
-
theme.fg("success", "✓ ") +
|
|
2049
|
-
`ScratchpadResume ${resume.checkpoint.run_id} → next: ${resume.next_phase ?? "done"}`,
|
|
2050
|
-
0,
|
|
2051
|
-
0,
|
|
2052
|
-
);
|
|
2053
|
-
},
|
|
2054
|
-
});
|
|
2055
|
-
|
|
2056
|
-
// ── Tool: ScratchpadCheckpoint ──
|
|
2057
|
-
|
|
2058
|
-
registerCaseTool({
|
|
2059
|
-
name: "ScratchpadCheckpoint",
|
|
2060
|
-
label: "Checkpoint Phase",
|
|
2061
|
-
description:
|
|
2062
|
-
"Mark a pipeline phase as complete in the scratchpad state.json. Records the completion timestamp, key IDs, and an optional summary. Idempotent — re-checkpointing a phase overwrites its summary/IDs without duplicating the completed_phases entry.",
|
|
2063
|
-
promptSnippet: "Record a pipeline phase as complete",
|
|
2064
|
-
promptGuidelines: [
|
|
2065
|
-
"Call ScratchpadCheckpoint after every phase completes: ScratchpadCheckpoint(run_id, phase, { ids, summary }).",
|
|
2066
|
-
"ids are the key case/finding IDs the phase produced — used by resume to reconstruct state.",
|
|
2067
|
-
"Keep completed_phases in pipeline order; the checkpoint sorts automatically.",
|
|
2068
|
-
],
|
|
2069
|
-
parameters: ScratchpadCheckpointSchema,
|
|
2070
|
-
|
|
2071
|
-
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
2072
|
-
const cp = scratchpad_checkpoint(params.run_id as string, params.phase as ScratchpadPhase, {
|
|
2073
|
-
ids: params.ids as string[] | undefined,
|
|
2074
|
-
summary: params.summary as string | undefined,
|
|
2075
|
-
});
|
|
2076
|
-
return {
|
|
2077
|
-
content: [
|
|
2078
|
-
{
|
|
2079
|
-
type: "text",
|
|
2080
|
-
text:
|
|
2081
|
-
`Phase ${params.phase} checkpointed for run ${cp.run_id}.\n` +
|
|
2082
|
-
`Completed phases: ${cp.completed_phases.join(", ")}`,
|
|
2083
|
-
},
|
|
2084
|
-
],
|
|
2085
|
-
details: { checkpoint: cp },
|
|
2086
|
-
};
|
|
2087
|
-
},
|
|
2088
|
-
|
|
2089
|
-
renderCall(args, theme) {
|
|
2090
|
-
return callLine(theme, "ScratchpadCheckpoint", `${args.run_id ?? ""} ${args.phase ?? ""}`);
|
|
2091
|
-
},
|
|
2092
|
-
|
|
2093
|
-
renderResult(result, _opts, theme) {
|
|
2094
|
-
const cp = (
|
|
2095
|
-
result.details as { checkpoint: { run_id: string; completed_phases: string[] } } | undefined
|
|
2096
|
-
)?.checkpoint;
|
|
2097
|
-
return new Text(
|
|
2098
|
-
theme.fg("success", "✓ ") +
|
|
2099
|
-
`ScratchpadCheckpoint ${cp?.run_id ?? ""} — ${cp?.completed_phases.length ?? 0} phases done`,
|
|
2100
|
-
0,
|
|
2101
|
-
0,
|
|
2102
|
-
);
|
|
2103
|
-
},
|
|
2104
|
-
});
|
|
2105
|
-
|
|
2106
1816
|
// ── Tool: ScratchpadWrite ──
|
|
2107
1817
|
|
|
2108
1818
|
registerCaseTool({
|
|
@@ -2110,7 +1820,7 @@ ${formatCaseDetail(record)}`,
|
|
|
2110
1820
|
label: "Write Artifact",
|
|
2111
1821
|
description:
|
|
2112
1822
|
"Write an intermediate artifact (recon map, trace output, verification log) to a phase's subdirectory in the scratchpad. Overwrites if the name exists. Artifact names are sanitized — path traversal is blocked.",
|
|
2113
|
-
promptSnippet: "Save a
|
|
1823
|
+
promptSnippet: "Save a run artifact to the scratchpad",
|
|
2114
1824
|
promptGuidelines: [
|
|
2115
1825
|
"Agents write artifacts to the scratchpad, not to each other's output files (prevents an echo chamber).",
|
|
2116
1826
|
"The casefile owns state transitions; the scratchpad owns artifacts. Use ScratchpadWrite for bulky intermediate outputs, not CaseUpdate.",
|
|
@@ -2156,7 +1866,7 @@ ${formatCaseDetail(record)}`,
|
|
|
2156
1866
|
label: "Read Artifact",
|
|
2157
1867
|
description:
|
|
2158
1868
|
"Read an artifact from a phase's subdirectory in the scratchpad. Returns null if the artifact is missing. Use to resume a phase from a prior run's intermediate output.",
|
|
2159
|
-
promptSnippet: "Read a
|
|
1869
|
+
promptSnippet: "Read a run artifact from the scratchpad",
|
|
2160
1870
|
promptGuidelines: [
|
|
2161
1871
|
"On resume, ScratchpadRead retrieves a prior phase's intermediate output so the next phase can proceed without re-running it.",
|
|
2162
1872
|
"Returns null for missing artifacts — treat as 'not yet produced' rather than an error.",
|
|
@@ -2206,60 +1916,16 @@ ${formatCaseDetail(record)}`,
|
|
|
2206
1916
|
},
|
|
2207
1917
|
});
|
|
2208
1918
|
|
|
2209
|
-
// ── Tool: ScratchpadPhaseDone ──
|
|
2210
|
-
|
|
2211
|
-
registerCaseTool({
|
|
2212
|
-
name: "ScratchpadPhaseDone",
|
|
2213
|
-
label: "Phase Done?",
|
|
2214
|
-
description:
|
|
2215
|
-
"Check whether a phase has already been checkpointed in the scratchpad — for idempotent re-run. Returns true if the phase is complete; skip re-dispatching it on resume.",
|
|
2216
|
-
promptSnippet: "Check if a pipeline phase is already complete",
|
|
2217
|
-
promptGuidelines: [
|
|
2218
|
-
"Call ScratchpadPhaseDone before dispatching each stage to avoid re-running completed phases on resume.",
|
|
2219
|
-
"A completed phase with a checkpoint is a no-op on re-run — skip it and continue to the next incomplete phase.",
|
|
2220
|
-
],
|
|
2221
|
-
parameters: ScratchpadPhaseDoneSchema,
|
|
2222
|
-
|
|
2223
|
-
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
2224
|
-
const done = scratchpad_phase_done(params.run_id as string, params.phase as ScratchpadPhase);
|
|
2225
|
-
return {
|
|
2226
|
-
content: [
|
|
2227
|
-
{
|
|
2228
|
-
type: "text",
|
|
2229
|
-
text: `Phase ${params.phase} for run ${params.run_id}: ${done ? "DONE (skip on resume)" : "not done"}`,
|
|
2230
|
-
},
|
|
2231
|
-
],
|
|
2232
|
-
details: { phase: params.phase, done },
|
|
2233
|
-
};
|
|
2234
|
-
},
|
|
2235
|
-
|
|
2236
|
-
renderCall(args, theme) {
|
|
2237
|
-
return callLine(theme, "ScratchpadPhaseDone", `${args.run_id ?? ""} ${args.phase ?? ""}`);
|
|
2238
|
-
},
|
|
2239
|
-
|
|
2240
|
-
renderResult(result, _opts, theme) {
|
|
2241
|
-
const done = (result.details as { done?: boolean } | undefined)?.done;
|
|
2242
|
-
return new Text(
|
|
2243
|
-
done
|
|
2244
|
-
? theme.fg("success", "✓ ScratchpadPhaseDone — done")
|
|
2245
|
-
: theme.fg("warning", "↷ ScratchpadPhaseDone — not done"),
|
|
2246
|
-
0,
|
|
2247
|
-
0,
|
|
2248
|
-
);
|
|
2249
|
-
},
|
|
2250
|
-
});
|
|
2251
|
-
|
|
2252
1919
|
// ── Tool: ScratchpadClear ──
|
|
2253
1920
|
|
|
2254
1921
|
registerCaseTool({
|
|
2255
1922
|
name: "ScratchpadClear",
|
|
2256
1923
|
label: "Clear Run",
|
|
2257
1924
|
description:
|
|
2258
|
-
"Clear a single
|
|
2259
|
-
promptSnippet: "Clear one
|
|
1925
|
+
"Clear a single run's scratchpad directory to force a fresh start for that run. Does not touch other runs. Directories are recreated automatically on the next write.",
|
|
1926
|
+
promptSnippet: "Clear one run's artifacts",
|
|
2260
1927
|
promptGuidelines: [
|
|
2261
|
-
"Use ScratchpadClear to force a fresh start for a single run
|
|
2262
|
-
"After clearing, call ScratchpadInit to recreate the directory structure before writing artifacts.",
|
|
1928
|
+
"Use ScratchpadClear to force a fresh start for a single run. It deletes that run's directory only.",
|
|
2263
1929
|
],
|
|
2264
1930
|
parameters: RunIdSchema,
|
|
2265
1931
|
|
|
@@ -2269,7 +1935,7 @@ ${formatCaseDetail(record)}`,
|
|
|
2269
1935
|
content: [
|
|
2270
1936
|
{
|
|
2271
1937
|
type: "text",
|
|
2272
|
-
text: `Scratchpad cleared for run ${params.run_id}
|
|
1938
|
+
text: `Scratchpad cleared for run ${params.run_id}.`,
|
|
2273
1939
|
},
|
|
2274
1940
|
],
|
|
2275
1941
|
details: { run_id: params.run_id, cleared: true },
|
|
@@ -2319,22 +1985,19 @@ ${formatCaseDetail(record)}`,
|
|
|
2319
1985
|
}
|
|
2320
1986
|
});
|
|
2321
1987
|
|
|
2322
|
-
// ── Event: Inject
|
|
2323
|
-
//
|
|
2324
|
-
//
|
|
2325
|
-
//
|
|
2326
|
-
//
|
|
2327
|
-
// conversation message) avoids session bloat from repeated message entries.
|
|
1988
|
+
// ── Event: Inject the recon workflow + active-case list into the prompt ──
|
|
1989
|
+
// The recon workflow is injected ONCE per session (first prompt); the active
|
|
1990
|
+
// case list refreshes every prompt because it changes as cases are added.
|
|
1991
|
+
// Injecting into event.systemPrompt (not as a conversation message) avoids
|
|
1992
|
+
// session bloat from repeated message entries.
|
|
2328
1993
|
let workflowInjected = false;
|
|
2329
1994
|
|
|
2330
1995
|
pi.on("before_agent_start", async (event) => {
|
|
2331
|
-
const mode = readXpMode();
|
|
2332
|
-
if (mode === "off") return;
|
|
2333
1996
|
// Skip subagent child processes: pi-subagents runs each child in its own
|
|
2334
1997
|
// pi process (PI_SUBAGENT_CHILD=1) with this extension loaded. Injecting
|
|
2335
1998
|
// 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
|
|
1999
|
+
// token multiplier (N children × workflow + growing case list per turn) —
|
|
2000
|
+
// recon workers get what they need via their task, not the coordinator's.
|
|
2338
2001
|
if (isSubagentProcess()) return;
|
|
2339
2002
|
|
|
2340
2003
|
const includeWorkflow = !workflowInjected;
|
|
@@ -2343,15 +2006,13 @@ ${formatCaseDetail(record)}`,
|
|
|
2343
2006
|
try {
|
|
2344
2007
|
active = readActiveCases();
|
|
2345
2008
|
} catch {
|
|
2346
|
-
// No database yet — still inject workflow.
|
|
2009
|
+
// No database yet — still inject the workflow.
|
|
2347
2010
|
}
|
|
2348
2011
|
|
|
2349
|
-
const injection = buildAgentInjection(active, includeWorkflow
|
|
2012
|
+
const injection = buildAgentInjection(active, includeWorkflow);
|
|
2350
2013
|
if (!injection) return; // workflow already injected, no active cases
|
|
2351
2014
|
workflowInjected = true;
|
|
2352
2015
|
|
|
2353
|
-
// Inject workflow FIRST (before skills) so the attacker mindset is
|
|
2354
|
-
// prominent, not buried at the end of a long system prompt.
|
|
2355
2016
|
return {
|
|
2356
2017
|
systemPrompt: `${injection}\n\n${event.systemPrompt ?? ""}`,
|
|
2357
2018
|
};
|