@xaccefy/pi-casefile 0.7.6 → 0.8.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 +13 -4
- package/package.json +1 -1
- package/src/index.ts +519 -305
- package/src/ledger.ts +850 -176
- package/src/pipeline-submit.ts +7 -17
- package/src/poc-runner.ts +63 -123
- package/src/scratchpad.ts +38 -39
- package/src/workflow.ts +36 -30
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Casefile — offensive security case tracker for Pi.
|
|
3
3
|
*
|
|
4
|
-
* Tools: CaseAdd, CaseUpdate, PromoteFinding, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseContext, PipelineSubmit, ScratchpadInit, ScratchpadResume, ScratchpadCheckpoint, ScratchpadWrite, ScratchpadRead, ScratchpadPhaseDone, ScratchpadClear
|
|
4
|
+
* Tools: CaseAdd, CaseUpdate, PromoteFinding, EvidenceAdd, CoverageAdd, CoverageReport, ChainSuggest, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseContext, PipelineSubmit, ScratchpadInit, ScratchpadResume, ScratchpadCheckpoint, ScratchpadWrite, ScratchpadRead, ScratchpadPhaseDone, ScratchpadClear
|
|
5
5
|
* Command: /casefile — interactive dashboard
|
|
6
6
|
* Event: before_agent_start — injects cyber workflow once per session, refreshes the active case list per prompt
|
|
7
7
|
*/
|
|
@@ -14,6 +14,7 @@ import { Type } from "@sinclair/typebox";
|
|
|
14
14
|
|
|
15
15
|
import {
|
|
16
16
|
addCaseResult,
|
|
17
|
+
addEvidenceItemResult,
|
|
17
18
|
assertPromotable,
|
|
18
19
|
type CaseConfidence,
|
|
19
20
|
type CaseInput,
|
|
@@ -24,7 +25,14 @@ import {
|
|
|
24
25
|
type CaseStatus,
|
|
25
26
|
type CaseUpdate,
|
|
26
27
|
CONFIDENCE_VALUES,
|
|
28
|
+
COVERAGE_SCOPE_VALUES,
|
|
29
|
+
type CoverageItem,
|
|
30
|
+
type CoverageScope,
|
|
27
31
|
countCases,
|
|
32
|
+
coverageSummary,
|
|
33
|
+
EVIDENCE_ROLE_VALUES,
|
|
34
|
+
type EvidenceItem,
|
|
35
|
+
type EvidenceRole,
|
|
28
36
|
formatCase,
|
|
29
37
|
formatCaseDetail,
|
|
30
38
|
formatCases,
|
|
@@ -36,10 +44,12 @@ import {
|
|
|
36
44
|
promoteFindingResult,
|
|
37
45
|
readActiveCases,
|
|
38
46
|
readCasefile,
|
|
47
|
+
recordCoverageResult,
|
|
39
48
|
SEARCH_FIELD_VALUES,
|
|
40
49
|
SEVERITY_VALUES,
|
|
41
50
|
STATUS_VALUES,
|
|
42
51
|
searchCases,
|
|
52
|
+
suggestChains,
|
|
43
53
|
unlinkCasesResult,
|
|
44
54
|
updateCaseResult,
|
|
45
55
|
writeCaseContext,
|
|
@@ -47,6 +57,7 @@ import {
|
|
|
47
57
|
import { pipeline_submit, SUBMIT_STAGES, type SubmitStage } from "./pipeline-submit.ts";
|
|
48
58
|
import { type PocRun, runPoc } from "./poc-runner.ts";
|
|
49
59
|
import {
|
|
60
|
+
PHASE_ORDER,
|
|
50
61
|
type ScratchpadPhase,
|
|
51
62
|
type ScratchpadResume,
|
|
52
63
|
scratchpad_checkpoint,
|
|
@@ -91,6 +102,12 @@ const CommonFields = {
|
|
|
91
102
|
description: "Explicit assumptions, unknowns, or uncertainty notes",
|
|
92
103
|
}),
|
|
93
104
|
),
|
|
105
|
+
disproveIf: Type.Optional(
|
|
106
|
+
Type.Array(Type.String(), {
|
|
107
|
+
description:
|
|
108
|
+
"Falsification conditions — what would disprove this hypothesis (REQUIRED on CaseAdd)",
|
|
109
|
+
}),
|
|
110
|
+
),
|
|
94
111
|
};
|
|
95
112
|
|
|
96
113
|
// ── Tool: CaseAdd ─────────────────────────────────────────────────────
|
|
@@ -114,6 +131,30 @@ const UpdateSchema = Type.Object(
|
|
|
114
131
|
{ additionalProperties: false },
|
|
115
132
|
);
|
|
116
133
|
|
|
134
|
+
// ── Tool: EvidenceAdd ────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
const EvidenceAddSchema = Type.Object(
|
|
137
|
+
{
|
|
138
|
+
case_id: Type.String({ description: "Case ID to attach the evidence item to" }),
|
|
139
|
+
role: Type.String({
|
|
140
|
+
enum: [...EVIDENCE_ROLE_VALUES],
|
|
141
|
+
description:
|
|
142
|
+
"Evidence role: observation | reproduction | impact | refutation | cleanup. " +
|
|
143
|
+
"refutation justifies a kill; cleanup tracks engagement cleanup items; " +
|
|
144
|
+
"reproduction is auto-recorded by the PoC gate at promote.",
|
|
145
|
+
}),
|
|
146
|
+
summary: Type.String({ description: "Short summary of this evidence item" }),
|
|
147
|
+
artifact_path: Type.Optional(
|
|
148
|
+
Type.String({
|
|
149
|
+
description:
|
|
150
|
+
"Path to the artifact file on disk. Stored as basename + SHA-256 hash " +
|
|
151
|
+
"(full path is never persisted).",
|
|
152
|
+
}),
|
|
153
|
+
),
|
|
154
|
+
},
|
|
155
|
+
{ additionalProperties: false },
|
|
156
|
+
);
|
|
157
|
+
|
|
117
158
|
// ── Tool: PromoteFinding ─────────────────────────────────────────────
|
|
118
159
|
|
|
119
160
|
const PromoteSchema = Type.Object(
|
|
@@ -133,6 +174,12 @@ const PromoteSchema = Type.Object(
|
|
|
133
174
|
"Absolute path to a disconfirmation script that tries to disprove the finding; must exit non-zero (failure to disprove)",
|
|
134
175
|
}),
|
|
135
176
|
),
|
|
177
|
+
control_path: Type.Optional(
|
|
178
|
+
Type.String({
|
|
179
|
+
description:
|
|
180
|
+
"Absolute path to a control-target script: runs the SAME PoC against a control that lacks the vulnerability. The harness checks the control run's output — if the verification_marker appears there, promotion is BLOCKED (the PoC prints the marker without the vulnerable condition). REQUIRED when local:true (live findings).",
|
|
181
|
+
}),
|
|
182
|
+
),
|
|
136
183
|
local: Type.Optional(Type.Boolean({ description: "Run locally instead of in Docker sandbox" })),
|
|
137
184
|
},
|
|
138
185
|
{ additionalProperties: false },
|
|
@@ -140,36 +187,35 @@ const PromoteSchema = Type.Object(
|
|
|
140
187
|
|
|
141
188
|
// ── Tool: CaseGet ─────────────────────────────────────────────────────
|
|
142
189
|
|
|
143
|
-
|
|
190
|
+
/** id-only schema, shared by CaseGet / CaseContext. */
|
|
191
|
+
const IdSchema = Type.Object(
|
|
144
192
|
{
|
|
145
193
|
id: Type.String({ description: "Case ID" }),
|
|
146
194
|
},
|
|
147
195
|
{ additionalProperties: false },
|
|
148
196
|
);
|
|
149
197
|
|
|
150
|
-
// ──
|
|
198
|
+
// ── Tools: CaseList / CaseSearch ──────────────────────────────────────
|
|
151
199
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
{ additionalProperties: false },
|
|
170
|
-
);
|
|
200
|
+
/** Structured filter fields, shared by the list and search schemas. */
|
|
201
|
+
const FILTER_FIELDS = {
|
|
202
|
+
status: Type.Optional(CaseStatusSchema),
|
|
203
|
+
confidence: Type.Optional(CaseConfidenceSchema),
|
|
204
|
+
severity: Type.Optional(CaseSeveritySchema),
|
|
205
|
+
minSeverity: Type.Optional(CaseSeveritySchema),
|
|
206
|
+
priority: Type.Optional(CasePrioritySchema),
|
|
207
|
+
tag: Type.Optional(Type.String({ description: "Filter by tag" })),
|
|
208
|
+
since: Type.Optional(
|
|
209
|
+
Type.String({ description: "ISO timestamp; only cases created at/after this time" }),
|
|
210
|
+
),
|
|
211
|
+
until: Type.Optional(
|
|
212
|
+
Type.String({ description: "ISO timestamp; only cases created at/before this time" }),
|
|
213
|
+
),
|
|
214
|
+
limit: Type.Optional(Type.Number({ description: "Max results (default 50)" })),
|
|
215
|
+
offset: Type.Optional(Type.Number({ description: "Skip N results for pagination" })),
|
|
216
|
+
};
|
|
171
217
|
|
|
172
|
-
|
|
218
|
+
const ListSchema = Type.Object({ ...FILTER_FIELDS }, { additionalProperties: false });
|
|
173
219
|
|
|
174
220
|
const SearchSchema = Type.Object(
|
|
175
221
|
{
|
|
@@ -180,20 +226,7 @@ const SearchSchema = Type.Object(
|
|
|
180
226
|
description: "Restrict search to a specific field",
|
|
181
227
|
}),
|
|
182
228
|
),
|
|
183
|
-
|
|
184
|
-
confidence: Type.Optional(CaseConfidenceSchema),
|
|
185
|
-
severity: Type.Optional(CaseSeveritySchema),
|
|
186
|
-
minSeverity: Type.Optional(CaseSeveritySchema),
|
|
187
|
-
priority: Type.Optional(CasePrioritySchema),
|
|
188
|
-
tag: Type.Optional(Type.String({ description: "Filter by tag" })),
|
|
189
|
-
since: Type.Optional(
|
|
190
|
-
Type.String({ description: "ISO timestamp; only cases created at/after this time" }),
|
|
191
|
-
),
|
|
192
|
-
until: Type.Optional(
|
|
193
|
-
Type.String({ description: "ISO timestamp; only cases created at/before this time" }),
|
|
194
|
-
),
|
|
195
|
-
limit: Type.Optional(Type.Number()),
|
|
196
|
-
offset: Type.Optional(Type.Number()),
|
|
229
|
+
...FILTER_FIELDS,
|
|
197
230
|
},
|
|
198
231
|
{ additionalProperties: false },
|
|
199
232
|
);
|
|
@@ -225,13 +258,6 @@ const UnlinkSchema = Type.Object(
|
|
|
225
258
|
{ additionalProperties: false },
|
|
226
259
|
);
|
|
227
260
|
|
|
228
|
-
const ContextSchema = Type.Object(
|
|
229
|
-
{
|
|
230
|
-
id: Type.String({ description: "Case ID to build the context bundle for" }),
|
|
231
|
-
},
|
|
232
|
-
{ additionalProperties: false },
|
|
233
|
-
);
|
|
234
|
-
|
|
235
261
|
// ── Tool: Scratchpad ─────────────────────────────────────────────────
|
|
236
262
|
//
|
|
237
263
|
// The scratchpad is the pipeline's crash-recoverable artifact store.
|
|
@@ -239,34 +265,16 @@ const ContextSchema = Type.Object(
|
|
|
239
265
|
// (recon maps, trace outputs, verification logs). Resume re-reads
|
|
240
266
|
// artifacts; it does not re-run completed phases (idempotent).
|
|
241
267
|
|
|
242
|
-
const SCRATCHPAD_PHASES = [
|
|
243
|
-
"recon",
|
|
244
|
-
"hunt",
|
|
245
|
-
"gapfil",
|
|
246
|
-
"trace",
|
|
247
|
-
"skeptic",
|
|
248
|
-
"validate",
|
|
249
|
-
"chain",
|
|
250
|
-
"patch",
|
|
251
|
-
"report",
|
|
252
|
-
] as const;
|
|
253
|
-
|
|
254
268
|
const ScratchpadPhaseSchema = Type.String({
|
|
255
|
-
enum: [...
|
|
269
|
+
enum: [...PHASE_ORDER],
|
|
256
270
|
description:
|
|
257
271
|
"Pipeline phase: recon | hunt | gapfil | trace | skeptic | validate | chain | patch | report",
|
|
258
272
|
});
|
|
259
273
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
run_id: Type.String({ description: "Unique run identifier for this pipeline run" }),
|
|
263
|
-
},
|
|
264
|
-
{ additionalProperties: false },
|
|
265
|
-
);
|
|
266
|
-
|
|
267
|
-
const ScratchpadResumeSchema = Type.Object(
|
|
274
|
+
/** run_id-only schema, shared by Scratchpad Init / Resume / Clear. */
|
|
275
|
+
const RunIdSchema = Type.Object(
|
|
268
276
|
{
|
|
269
|
-
run_id: Type.String({ description: "
|
|
277
|
+
run_id: Type.String({ description: "Pipeline run identifier" }),
|
|
270
278
|
},
|
|
271
279
|
{ additionalProperties: false },
|
|
272
280
|
);
|
|
@@ -314,13 +322,6 @@ const ScratchpadPhaseDoneSchema = Type.Object(
|
|
|
314
322
|
{ additionalProperties: false },
|
|
315
323
|
);
|
|
316
324
|
|
|
317
|
-
const ScratchpadClearSchema = Type.Object(
|
|
318
|
-
{
|
|
319
|
-
run_id: Type.String({ description: "Run identifier to clear (deletes that run only)" }),
|
|
320
|
-
},
|
|
321
|
-
{ additionalProperties: false },
|
|
322
|
-
);
|
|
323
|
-
|
|
324
325
|
interface Theme {
|
|
325
326
|
fg(color: string, text: string): string;
|
|
326
327
|
bold(text: string): string;
|
|
@@ -392,6 +393,57 @@ function renderCaseResult(
|
|
|
392
393
|
return theme.fg(color, prefix) + renderOneLine(details.record, theme);
|
|
393
394
|
}
|
|
394
395
|
|
|
396
|
+
/** One-line tool call header, shared by every tool's renderCall. */
|
|
397
|
+
function callLine(theme: Theme, name: string, detail?: string): Text {
|
|
398
|
+
const title = theme.fg("toolTitle", theme.bold(detail !== undefined ? `${name} ` : name));
|
|
399
|
+
return new Text(title + (detail ? theme.fg("dim", detail) : ""), 0, 0);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** CaseRecord[] page summary, shared by CaseList / CaseSearch renderResult. */
|
|
403
|
+
function renderCasePage(
|
|
404
|
+
result: { details: unknown },
|
|
405
|
+
theme: Theme,
|
|
406
|
+
noun: string,
|
|
407
|
+
expanded: boolean,
|
|
408
|
+
): Text {
|
|
409
|
+
const details = result.details as { cases?: CaseRecord[]; total?: number } | undefined;
|
|
410
|
+
const total = details?.total ?? 0;
|
|
411
|
+
const cases = details?.cases ?? [];
|
|
412
|
+
let line = theme.fg("success", "✓ ") + theme.fg("muted", `${total} ${noun}`);
|
|
413
|
+
if (expanded && cases.length > 0) {
|
|
414
|
+
line += `\n${cases.map((c) => ` ${renderOneLine(c, theme)}`).join("\n")}`;
|
|
415
|
+
}
|
|
416
|
+
return new Text(line, 0, 0);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Filtered case query, shared by CaseList / CaseSearch execute. */
|
|
420
|
+
function runCaseQuery(
|
|
421
|
+
params: Record<string, unknown>,
|
|
422
|
+
header: (count: number, total: number, offset: number) => string,
|
|
423
|
+
emptyText: string,
|
|
424
|
+
) {
|
|
425
|
+
const { cases, total } = searchCases({
|
|
426
|
+
query: params.query as string | undefined,
|
|
427
|
+
field: params.field as CaseSearchField | undefined,
|
|
428
|
+
status: params.status as CaseStatus | undefined,
|
|
429
|
+
confidence: params.confidence as CaseConfidence | undefined,
|
|
430
|
+
severity: params.severity as CaseSeverity | undefined,
|
|
431
|
+
minSeverity: params.minSeverity as CaseSeverity | undefined,
|
|
432
|
+
priority: params.priority as CasePriority | undefined,
|
|
433
|
+
tag: params.tag as string | undefined,
|
|
434
|
+
since: params.since as string | undefined,
|
|
435
|
+
until: params.until as string | undefined,
|
|
436
|
+
limit: params.limit as number | undefined,
|
|
437
|
+
offset: params.offset as number | undefined,
|
|
438
|
+
});
|
|
439
|
+
const offset = (params.offset as number | undefined) ?? 0;
|
|
440
|
+
const body = cases.length > 0 ? formatCases(cases) : emptyText;
|
|
441
|
+
return {
|
|
442
|
+
content: [{ type: "text" as const, text: `${header(cases.length, total, offset)}\n${body}` }],
|
|
443
|
+
details: { cases, total, offset },
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
395
447
|
// ── Dashboard component ──────────────────────────────────────────────
|
|
396
448
|
|
|
397
449
|
class CasefileDashboard {
|
|
@@ -656,6 +708,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
656
708
|
promptSnippet: "Record a security finding or hypothesis as a case",
|
|
657
709
|
promptGuidelines: [
|
|
658
710
|
"Use CaseAdd for a new security lead. New cases start as status='hypothesis' or 'investigating' — promote later with CaseUpdate.",
|
|
711
|
+
"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.",
|
|
659
712
|
"Check the injected case list or CaseList/CaseSearch first. Do not add a duplicate for the same title/scope.",
|
|
660
713
|
"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.",
|
|
661
714
|
"confirmed/reported only via their gates: proof in poc + PromoteFinding for confirmed; CaseContext + report for reported.",
|
|
@@ -685,12 +738,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
685
738
|
},
|
|
686
739
|
|
|
687
740
|
renderCall(args, theme) {
|
|
688
|
-
return
|
|
689
|
-
theme.fg("toolTitle", theme.bold("CaseAdd ")) +
|
|
690
|
-
theme.fg("muted", (args.title as string) ?? ""),
|
|
691
|
-
0,
|
|
692
|
-
0,
|
|
693
|
-
);
|
|
741
|
+
return callLine(theme, "CaseAdd", (args.title as string) ?? "");
|
|
694
742
|
},
|
|
695
743
|
|
|
696
744
|
renderResult(result, { expanded }, theme) {
|
|
@@ -738,12 +786,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
738
786
|
},
|
|
739
787
|
|
|
740
788
|
renderCall(args, theme) {
|
|
741
|
-
return
|
|
742
|
-
theme.fg("toolTitle", theme.bold("CaseUpdate ")) +
|
|
743
|
-
theme.fg("dim", (args.id as string) ?? ""),
|
|
744
|
-
0,
|
|
745
|
-
0,
|
|
746
|
-
);
|
|
789
|
+
return callLine(theme, "CaseUpdate", (args.id as string) ?? "");
|
|
747
790
|
},
|
|
748
791
|
|
|
749
792
|
renderResult(result, { expanded }, theme) {
|
|
@@ -764,19 +807,216 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
764
807
|
},
|
|
765
808
|
});
|
|
766
809
|
|
|
810
|
+
// ── Tool: EvidenceAdd ──
|
|
811
|
+
|
|
812
|
+
pi.registerTool({
|
|
813
|
+
name: "EvidenceAdd",
|
|
814
|
+
label: "Add Evidence Item",
|
|
815
|
+
description:
|
|
816
|
+
"Record a role-typed, artifact-backed evidence item on a case (observation, reproduction, impact, refutation, cleanup). Artifact files are stored as basename + SHA-256 — the full path is never persisted. refutation items justify a kill; cleanup items track engagement cleanup before REPORT.",
|
|
817
|
+
promptSnippet: "Record a role-typed evidence item",
|
|
818
|
+
promptGuidelines: [
|
|
819
|
+
"Use EvidenceAdd for artifact-backed evidence: raw responses, logs, screenshots, disproof attempts — anything a claim should trace back to.",
|
|
820
|
+
"role=refutation is the structural justification for a kill (killed without one requires a kill-reason token in assumptions/nextStep).",
|
|
821
|
+
"role=cleanup tracks engagement cleanup items — confirmed before REPORT for sanctioned engagements.",
|
|
822
|
+
"reproduction is auto-recorded by the PromoteFinding gate (the PoC run itself, hashed); you don't add it manually.",
|
|
823
|
+
],
|
|
824
|
+
parameters: EvidenceAddSchema,
|
|
825
|
+
|
|
826
|
+
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
827
|
+
const item = addEvidenceItemResult(params.case_id as string, {
|
|
828
|
+
role: params.role as EvidenceRole,
|
|
829
|
+
summary: params.summary as string,
|
|
830
|
+
artifactPath: params.artifact_path as string | undefined,
|
|
831
|
+
});
|
|
832
|
+
const record = getCaseById(params.case_id as string)!;
|
|
833
|
+
return {
|
|
834
|
+
content: [
|
|
835
|
+
{
|
|
836
|
+
type: "text",
|
|
837
|
+
text: `Evidence item recorded:\n[${item.role}] ${item.summary}${item.artifactPath ? ` — ${item.artifactPath} sha256:${item.sha256?.slice(0, 12)}…` : ""}\n\n${formatCaseDetail(record)}`,
|
|
838
|
+
},
|
|
839
|
+
],
|
|
840
|
+
details: { item, record },
|
|
841
|
+
};
|
|
842
|
+
},
|
|
843
|
+
|
|
844
|
+
renderCall(args, theme) {
|
|
845
|
+
return callLine(
|
|
846
|
+
theme,
|
|
847
|
+
"EvidenceAdd",
|
|
848
|
+
`${(args.case_id as string) ?? ""} [${(args.role as string) ?? ""}]`,
|
|
849
|
+
);
|
|
850
|
+
},
|
|
851
|
+
|
|
852
|
+
renderResult(result, _opts, theme) {
|
|
853
|
+
const details = result.details as { item?: EvidenceItem } | undefined;
|
|
854
|
+
if (!details?.item) {
|
|
855
|
+
return new Text(theme.fg("error", "✗ EvidenceAdd failed"), 0, 0);
|
|
856
|
+
}
|
|
857
|
+
return new Text(
|
|
858
|
+
theme.fg("success", "✓ ") +
|
|
859
|
+
theme.fg("dim", `[${details.item.role}] `) +
|
|
860
|
+
truncateToWidth(details.item.summary, 60),
|
|
861
|
+
0,
|
|
862
|
+
0,
|
|
863
|
+
);
|
|
864
|
+
},
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
// ── Tool: CoverageAdd ──
|
|
868
|
+
|
|
869
|
+
const CoverageAddSchema = Type.Object(
|
|
870
|
+
{
|
|
871
|
+
case_id: Type.String({
|
|
872
|
+
description: "Case ID (the pipeline-run or finding case) to record coverage under",
|
|
873
|
+
}),
|
|
874
|
+
asset: Type.String({
|
|
875
|
+
description:
|
|
876
|
+
"The asset tested — copy it verbatim from the case target where shown. For scope=wide use the deployment-wide identifier.",
|
|
877
|
+
}),
|
|
878
|
+
class: Type.String({
|
|
879
|
+
description:
|
|
880
|
+
"The attack class tested (e.g. sql-injection, xss, idor, ssti, ssrf, auth-bypass, ...).",
|
|
881
|
+
}),
|
|
882
|
+
scope: Type.Union(
|
|
883
|
+
COVERAGE_SCOPE_VALUES.map((s) => Type.Literal(s)),
|
|
884
|
+
{
|
|
885
|
+
description:
|
|
886
|
+
"'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.",
|
|
887
|
+
},
|
|
888
|
+
),
|
|
889
|
+
note: Type.String({
|
|
890
|
+
description:
|
|
891
|
+
"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.",
|
|
892
|
+
}),
|
|
893
|
+
},
|
|
894
|
+
{ additionalProperties: false },
|
|
895
|
+
);
|
|
896
|
+
|
|
897
|
+
pi.registerTool({
|
|
898
|
+
name: "CoverageAdd",
|
|
899
|
+
label: "Record Coverage",
|
|
900
|
+
description:
|
|
901
|
+
"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.",
|
|
902
|
+
promptSnippet: "Record a tested attack class (coverage)",
|
|
903
|
+
promptGuidelines: [
|
|
904
|
+
"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.",
|
|
905
|
+
"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.",
|
|
906
|
+
"The note must describe tests you ACTUALLY RAN, not assumptions. A verdict guessed without testing can hide a real issue.",
|
|
907
|
+
"Coverage cells live on the pipeline-run case (or the target's main case); CoverageReport shows the matrix.",
|
|
908
|
+
],
|
|
909
|
+
parameters: CoverageAddSchema,
|
|
910
|
+
|
|
911
|
+
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
912
|
+
const item = recordCoverageResult(params.case_id as string, {
|
|
913
|
+
asset: params.asset as string,
|
|
914
|
+
class: params.class as string,
|
|
915
|
+
scope: (params.scope ?? "local") as CoverageScope,
|
|
916
|
+
note: params.note as string,
|
|
917
|
+
});
|
|
918
|
+
const record = getCaseById(params.case_id as string)!;
|
|
919
|
+
return {
|
|
920
|
+
content: [
|
|
921
|
+
{
|
|
922
|
+
type: "text",
|
|
923
|
+
text: `Coverage recorded: [${item.scope}] ${item.asset} × ${item.class} — ${item.note}\n\n${formatCaseDetail(record)}`,
|
|
924
|
+
},
|
|
925
|
+
],
|
|
926
|
+
details: { item, record },
|
|
927
|
+
};
|
|
928
|
+
},
|
|
929
|
+
|
|
930
|
+
renderCall(args, theme) {
|
|
931
|
+
return callLine(
|
|
932
|
+
theme,
|
|
933
|
+
"CoverageAdd",
|
|
934
|
+
`${(args.asset as string) ?? ""} [${(args.class as string) ?? ""}]`,
|
|
935
|
+
);
|
|
936
|
+
},
|
|
937
|
+
|
|
938
|
+
renderResult(result, _opts, theme) {
|
|
939
|
+
const details = result.details as { item?: CoverageItem } | undefined;
|
|
940
|
+
if (!details?.item) {
|
|
941
|
+
return new Text(theme.fg("error", "✗ CoverageAdd failed"), 0, 0);
|
|
942
|
+
}
|
|
943
|
+
return new Text(
|
|
944
|
+
theme.fg("success", "✓ ") +
|
|
945
|
+
theme.fg("dim", `[${details.item.scope}] `) +
|
|
946
|
+
truncateToWidth(`${details.item.asset} × ${details.item.class}`, 50),
|
|
947
|
+
0,
|
|
948
|
+
0,
|
|
949
|
+
);
|
|
950
|
+
},
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
// ── Tool: CoverageReport ──
|
|
954
|
+
|
|
955
|
+
const CoverageReportSchema = Type.Object(
|
|
956
|
+
{
|
|
957
|
+
case_id: Type.String({ description: "Case ID to render the coverage matrix for" }),
|
|
958
|
+
},
|
|
959
|
+
{ additionalProperties: false },
|
|
960
|
+
);
|
|
961
|
+
|
|
962
|
+
pi.registerTool({
|
|
963
|
+
name: "CoverageReport",
|
|
964
|
+
label: "Coverage Matrix",
|
|
965
|
+
description:
|
|
966
|
+
"Render the machine-checkable coverage matrix for a case: which (asset × attack-class) cells are tested, with wide-verdict propagation. Run before deciding the hunt/gapfill is done — the plateau stop (zero new classes testable) must be visible in the matrix, not asserted in prose.",
|
|
967
|
+
promptSnippet: "Show which attack classes were tested where",
|
|
968
|
+
promptGuidelines: [
|
|
969
|
+
"Run CoverageReport before claiming 'every class is COVERED/SKIPPED/NOT_FOUND' — the claim must match the matrix.",
|
|
970
|
+
"A class with a wide clean verdict covers every asset — do NOT re-test it per asset.",
|
|
971
|
+
"Classes tested with no cell recorded are invisible: record coverage as you finish each class (CoverageAdd).",
|
|
972
|
+
],
|
|
973
|
+
parameters: CoverageReportSchema,
|
|
974
|
+
|
|
975
|
+
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
976
|
+
const summary = coverageSummary(params.case_id as string);
|
|
977
|
+
const lines: string[] = [`Coverage matrix for ${params.case_id}:`];
|
|
978
|
+
for (const asset of summary.assets) {
|
|
979
|
+
lines.push(`\n## ${asset}`);
|
|
980
|
+
for (const cell of summary.byAsset[asset] ?? []) {
|
|
981
|
+
lines.push(
|
|
982
|
+
`- [${cell.scope}] ${cell.class} — ${cell.note}${cell.testedBy ? ` (by ${cell.testedBy})` : ""}`,
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
if (summary.items.length === 0) {
|
|
987
|
+
lines.push("\n(no coverage recorded yet — run CoverageAdd as each class is tested)");
|
|
988
|
+
}
|
|
989
|
+
return {
|
|
990
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
991
|
+
details: { summary },
|
|
992
|
+
};
|
|
993
|
+
},
|
|
994
|
+
|
|
995
|
+
renderCall(args, theme) {
|
|
996
|
+
return callLine(theme, "CoverageReport", (args.case_id as string) ?? "");
|
|
997
|
+
},
|
|
998
|
+
|
|
999
|
+
renderResult(result, _opts, theme) {
|
|
1000
|
+
const details = result.details as { summary?: { items?: CoverageItem[] } } | undefined;
|
|
1001
|
+
const n = details?.summary?.items?.length ?? 0;
|
|
1002
|
+
return new Text(theme.fg("success", `✓ ${n} coverage cell(s)`), 0, 0);
|
|
1003
|
+
},
|
|
1004
|
+
});
|
|
1005
|
+
|
|
767
1006
|
// ── Tool: PromoteFinding ──
|
|
768
1007
|
|
|
769
1008
|
pi.registerTool({
|
|
770
1009
|
name: "PromoteFinding",
|
|
771
1010
|
label: "Promote Finding",
|
|
772
1011
|
description:
|
|
773
|
-
"Run an on-disk PoC script (Docker sandbox or local) and, on exit 0 + verification marker present in output, promote an investigating case to confirmed. The verification_marker proves the exploit actually worked — exit code 0 alone is NOT sufficient. Optionally run a disconfirmation script that must exit non-0 (finding survived the attempt to disprove).",
|
|
1012
|
+
"Run an on-disk PoC script (Docker sandbox or local) and, on exit 0 + verification marker present in output, promote an investigating case to confirmed. The verification_marker proves the exploit actually worked — exit code 0 alone is NOT sufficient. For live findings (local:true) a control_path script is REQUIRED: the same PoC run against a control lacking the vuln must NOT print the marker — this blocks unconditional-marker and mock-target cheats. Optionally run a disconfirmation script that must exit non-0 (finding survived the attempt to disprove).",
|
|
774
1013
|
promptSnippet: "Run a PoC and promote an investigating case to confirmed",
|
|
775
1014
|
promptGuidelines: [
|
|
776
1015
|
"Use PromoteFinding when an investigating case has a concrete PoC script on disk and you are ready to prove it.",
|
|
777
|
-
"Prerequisites: status='investigating' and non-empty poc, evidence, impact, severity, target, disconfirmation.",
|
|
1016
|
+
"Prerequisites: status='investigating' and non-empty poc, evidence, impact, severity, target, disconfirmation, plus an EvidenceAdd 'observation' item on the case (the initial signal) — the PoC gate auto-records the reproduction item.",
|
|
778
1017
|
"Default sandbox: docker run --rm --network none. Use local:true for network-dependent bugs.",
|
|
779
1018
|
"Gate: exit 0 AND verification_marker in the PoC output. The marker (e.g. 'VULN_CONFIRMED_<case-id>') must be printed only AFTER the exploit is verified (data extracted, callback received, payload reflected) — never unconditionally or before the exploit check. The marker check prevents fluke exit 0 (script crashed early) and mocked PoCs (target faked) from passing.",
|
|
1019
|
+
"control_path (REQUIRED for local:true): a script that runs the SAME PoC against a control lacking the vuln (patched replica, second account, baseline endpoint). The harness blocks promotion if the verification_marker appears in the control run's output — an unconditional-marker PoC cannot pass this.",
|
|
780
1020
|
"disconfirmation_path: a script that tries to disprove the finding; if it exits 0, promotion is blocked.",
|
|
781
1021
|
"Never CaseUpdate status='confirmed' directly — it is rejected. Always use PromoteFinding.",
|
|
782
1022
|
],
|
|
@@ -786,42 +1026,58 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
786
1026
|
// Validate promotability BEFORE running the PoC — a sandboxed run can take
|
|
787
1027
|
// 30s (plus first-time image pull), so fail cheap when the case can't
|
|
788
1028
|
// advance anyway (missing, wrong status, missing required fields).
|
|
789
|
-
|
|
1029
|
+
const caseId = params.id as string;
|
|
1030
|
+
assertPromotable(caseId);
|
|
1031
|
+
|
|
1032
|
+
// Shared blocked-promotion shape: the case stays investigating and the
|
|
1033
|
+
// caller gets the record back for context.
|
|
1034
|
+
const fail = (text: string, extra?: Record<string, unknown>) => ({
|
|
1035
|
+
content: [{ type: "text" as const, text }],
|
|
1036
|
+
isError: true,
|
|
1037
|
+
details: { record: getCaseById(caseId), ...extra },
|
|
1038
|
+
});
|
|
790
1039
|
|
|
791
1040
|
// Reject empty/whitespace markers BEFORE any PoC run — it's a param
|
|
792
1041
|
// error, so fail cheap instead of burning a (up to 30s) sandboxed run.
|
|
793
1042
|
const marker = (params.verification_marker as string | undefined)?.trim();
|
|
794
1043
|
if (!marker) {
|
|
795
|
-
return
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
1044
|
+
return fail(
|
|
1045
|
+
"verification_marker is empty or whitespace. " +
|
|
1046
|
+
"A non-empty marker printed only AFTER the PoC confirms exploitation is required — " +
|
|
1047
|
+
"exit code 0 alone is not sufficient. Case remains investigating.",
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// Live findings require control_path — check BEFORE paying for the PoC
|
|
1052
|
+
// run (an agent that forgot the control script shouldn't burn a 30s
|
|
1053
|
+
// sandboxed run to be told).
|
|
1054
|
+
if (params.local === true && !params.control_path) {
|
|
1055
|
+
return fail(
|
|
1056
|
+
"Live findings (local:true) require control_path: a script that runs the same PoC " +
|
|
1057
|
+
"against a control lacking the vuln. The harness verifies the verification_marker is " +
|
|
1058
|
+
"absent from the control run's output — that is what proves the marker is target-dependent. " +
|
|
1059
|
+
"Write the control script and retry.",
|
|
1060
|
+
{ missingControl: true },
|
|
1061
|
+
);
|
|
808
1062
|
}
|
|
809
1063
|
|
|
810
1064
|
const run = runPoc(params.poc_path as string, params.local !== true);
|
|
811
1065
|
|
|
812
1066
|
// Fail closed without throwing: non-zero PoC must leave the case investigating.
|
|
813
1067
|
if (run.exitCode !== 0) {
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
1068
|
+
return fail(
|
|
1069
|
+
`PoC failed (exit ${run.exitCode}). Case remains investigating.\nOutput:\n${run.output}`,
|
|
1070
|
+
{ run },
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// Defense-in-depth: exit 0 implies the run completed (sandbox wrapper /
|
|
1075
|
+
// local spawn semantics), but never trust a run the runner says crashed.
|
|
1076
|
+
if (!run.completed) {
|
|
1077
|
+
return fail(
|
|
1078
|
+
`PoC did NOT complete (spawn error, killed, or timeout). Case remains investigating.\nOutput:\n${run.output}`,
|
|
1079
|
+
{ run, pocCrashed: true },
|
|
1080
|
+
);
|
|
825
1081
|
}
|
|
826
1082
|
|
|
827
1083
|
// Verification marker check: exit code 0 alone is NOT sufficient.
|
|
@@ -829,63 +1085,63 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
829
1085
|
// exploit actually worked — not just that the script ran. This blocks
|
|
830
1086
|
// fluke exit 0 (crash before real logic) and mocked PoCs that don't
|
|
831
1087
|
// actually exploit the target.
|
|
832
|
-
if (!
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
{
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
`PoC exited 0 but the verification marker "${marker}" was NOT found in the output.\n` +
|
|
840
|
-
`This means the script ran but did not prove exploitation. The marker must be printed only AFTER the PoC verifies the exploit worked (data extracted, callback received, payload reflected, etc.).\n` +
|
|
841
|
-
`Do not print the marker unconditionally — print it only when the exploit is confirmed.\n\nOutput:\n${run.output}`,
|
|
842
|
-
},
|
|
843
|
-
],
|
|
844
|
-
isError: true,
|
|
845
|
-
details: { record, run, markerMissing: true },
|
|
846
|
-
};
|
|
1088
|
+
if (!run.output.includes(marker)) {
|
|
1089
|
+
return fail(
|
|
1090
|
+
`PoC exited 0 but the verification marker "${marker}" was NOT found in the output.\n` +
|
|
1091
|
+
`This means the script ran but did not prove exploitation. The marker must be printed only AFTER the PoC verifies the exploit worked (data extracted, callback received, payload reflected, etc.).\n` +
|
|
1092
|
+
`Do not print the marker unconditionally — print it only when the exploit is confirmed.\n\nOutput:\n${run.output}`,
|
|
1093
|
+
{ run, markerMissing: true },
|
|
1094
|
+
);
|
|
847
1095
|
}
|
|
848
1096
|
|
|
849
1097
|
// Run disconfirmation script if provided — must exit NON-0 (finding survived the attempt to disprove).
|
|
850
1098
|
let disconfirmationRun: PocRun | undefined;
|
|
851
1099
|
if (params.disconfirmation_path) {
|
|
852
1100
|
disconfirmationRun = runPoc(params.disconfirmation_path as string, params.local !== true);
|
|
1101
|
+
if (!disconfirmationRun.completed) {
|
|
1102
|
+
return fail(
|
|
1103
|
+
`Disconfirmation script did NOT complete (spawn error, killed, or timeout — no completion marker). ` +
|
|
1104
|
+
`A crash is not a survived disproof: fix the disconfirmation script and retry.\n` +
|
|
1105
|
+
`Output:\n${disconfirmationRun.output}`,
|
|
1106
|
+
{ run, disconfirmationRun, disconfirmationCrashed: true },
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
853
1109
|
if (disconfirmationRun.exitCode === 0) {
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
text:
|
|
860
|
-
`Disconfirmation script exited 0 (finding was disproven). ` +
|
|
861
|
-
`Case remains investigating.\nOutput:\n${disconfirmationRun.output}`,
|
|
862
|
-
},
|
|
863
|
-
],
|
|
864
|
-
isError: true,
|
|
865
|
-
details: { record, run, disconfirmationRun },
|
|
866
|
-
};
|
|
1110
|
+
return fail(
|
|
1111
|
+
`Disconfirmation script exited 0 (finding was disproven). ` +
|
|
1112
|
+
`Case remains investigating.\nOutput:\n${disconfirmationRun.output}`,
|
|
1113
|
+
{ run, disconfirmationRun },
|
|
1114
|
+
);
|
|
867
1115
|
}
|
|
868
1116
|
}
|
|
869
1117
|
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
1118
|
+
// Control-target anti-cheat check: the same PoC pointed at a control that
|
|
1119
|
+
// lacks the vuln must NOT print the marker. The control script is
|
|
1120
|
+
// agent-written, but the marker-absence check is harness-side and
|
|
1121
|
+
// deterministic — the model cannot pass it by asserting success.
|
|
1122
|
+
let controlRun: PocRun | undefined;
|
|
1123
|
+
if (params.control_path) {
|
|
1124
|
+
controlRun = runPoc(params.control_path as string, params.local !== true);
|
|
1125
|
+
if (!controlRun.completed) {
|
|
1126
|
+
return fail(
|
|
1127
|
+
`CONTROL CHECK FAILED: the control-target script did NOT complete (spawn error, killed, or timeout). ` +
|
|
1128
|
+
`A control run that never executed proves nothing about the marker — fix the control script and retry.\n` +
|
|
1129
|
+
`Control output:\n${controlRun.output}`,
|
|
1130
|
+
{ run, controlRun, controlCrashed: true },
|
|
1131
|
+
);
|
|
1132
|
+
}
|
|
1133
|
+
if (controlRun.output.includes(marker)) {
|
|
1134
|
+
return fail(
|
|
1135
|
+
`CONTROL CHECK FAILED: the verification marker "${marker}" appeared in the control-target run. ` +
|
|
1136
|
+
`The PoC prints the marker without the vulnerable condition — a cheating PoC (unconditional marker) ` +
|
|
1137
|
+
`or a broken check. Case remains investigating.\nControl output:\n${controlRun.output}`,
|
|
1138
|
+
{ run, controlRun, controlCheated: true },
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// PocRun is structurally a PocVerification — pass the runs straight through.
|
|
1144
|
+
const result = promoteFindingResult(caseId, run, disconfirmationRun, controlRun, marker);
|
|
889
1145
|
const record = result.record;
|
|
890
1146
|
return {
|
|
891
1147
|
content: [
|
|
@@ -899,12 +1155,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
899
1155
|
},
|
|
900
1156
|
|
|
901
1157
|
renderCall(args, theme) {
|
|
902
|
-
return
|
|
903
|
-
theme.fg("toolTitle", theme.bold("PromoteFinding ")) +
|
|
904
|
-
theme.fg("dim", (args.id as string) ?? ""),
|
|
905
|
-
0,
|
|
906
|
-
0,
|
|
907
|
-
);
|
|
1158
|
+
return callLine(theme, "PromoteFinding", (args.id as string) ?? "");
|
|
908
1159
|
},
|
|
909
1160
|
|
|
910
1161
|
renderResult(result, _options, theme) {
|
|
@@ -921,7 +1172,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
921
1172
|
label: "Get Case",
|
|
922
1173
|
description: "Get full details of a single case by ID.",
|
|
923
1174
|
promptSnippet: "Look up a specific case by ID",
|
|
924
|
-
parameters:
|
|
1175
|
+
parameters: IdSchema,
|
|
925
1176
|
|
|
926
1177
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
927
1178
|
const record = getCaseById(params.id as string);
|
|
@@ -935,11 +1186,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
935
1186
|
},
|
|
936
1187
|
|
|
937
1188
|
renderCall(args, theme) {
|
|
938
|
-
return
|
|
939
|
-
theme.fg("toolTitle", theme.bold("CaseGet ")) + theme.fg("dim", (args.id as string) ?? ""),
|
|
940
|
-
0,
|
|
941
|
-
0,
|
|
942
|
-
);
|
|
1189
|
+
return callLine(theme, "CaseGet", (args.id as string) ?? "");
|
|
943
1190
|
},
|
|
944
1191
|
|
|
945
1192
|
renderResult(result, _options, theme) {
|
|
@@ -961,40 +1208,19 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
961
1208
|
parameters: ListSchema,
|
|
962
1209
|
|
|
963
1210
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
priority: params.priority as CasePriority | undefined,
|
|
970
|
-
tag: params.tag,
|
|
971
|
-
since: params.since as string | undefined,
|
|
972
|
-
until: params.until as string | undefined,
|
|
973
|
-
limit: params.limit,
|
|
974
|
-
offset: params.offset,
|
|
975
|
-
});
|
|
976
|
-
const offset = params.offset ?? 0;
|
|
977
|
-
const header = `Showing ${cases.length} of ${total} cases (offset: ${offset})`;
|
|
978
|
-
const body = cases.length > 0 ? formatCases(cases) : "No cases match filters.";
|
|
979
|
-
return {
|
|
980
|
-
content: [{ type: "text", text: `${header}\n${body}` }],
|
|
981
|
-
details: { cases, total, offset },
|
|
982
|
-
};
|
|
1211
|
+
return runCaseQuery(
|
|
1212
|
+
params as Record<string, unknown>,
|
|
1213
|
+
(count, total, offset) => `Showing ${count} of ${total} cases (offset: ${offset})`,
|
|
1214
|
+
"No cases match filters.",
|
|
1215
|
+
);
|
|
983
1216
|
},
|
|
984
1217
|
|
|
985
1218
|
renderCall(_args, theme) {
|
|
986
|
-
return
|
|
1219
|
+
return callLine(theme, "CaseList");
|
|
987
1220
|
},
|
|
988
1221
|
|
|
989
1222
|
renderResult(result, { expanded }, theme) {
|
|
990
|
-
|
|
991
|
-
const total = details?.total ?? 0;
|
|
992
|
-
const cases = details?.cases ?? [];
|
|
993
|
-
let line = theme.fg("success", "✓ ") + theme.fg("muted", `${total} case(s)`);
|
|
994
|
-
if (expanded && cases.length > 0) {
|
|
995
|
-
line += `\n${cases.map((c) => ` ${renderOneLine(c, theme)}`).join("\n")}`;
|
|
996
|
-
}
|
|
997
|
-
return new Text(line, 0, 0);
|
|
1223
|
+
return renderCasePage(result, theme, "case(s)", expanded);
|
|
998
1224
|
},
|
|
999
1225
|
});
|
|
1000
1226
|
|
|
@@ -1009,46 +1235,20 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1009
1235
|
parameters: SearchSchema,
|
|
1010
1236
|
|
|
1011
1237
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
minSeverity: params.minSeverity as CaseSeverity | undefined,
|
|
1019
|
-
priority: params.priority as CasePriority | undefined,
|
|
1020
|
-
tag: params.tag,
|
|
1021
|
-
since: params.since as string | undefined,
|
|
1022
|
-
until: params.until as string | undefined,
|
|
1023
|
-
limit: params.limit,
|
|
1024
|
-
offset: params.offset,
|
|
1025
|
-
});
|
|
1026
|
-
const offset = params.offset ?? 0;
|
|
1027
|
-
const header = `Search "${params.query}"${params.field ? ` in ${params.field}` : ""}: ${cases.length} of ${total} results (offset: ${offset})`;
|
|
1028
|
-
const body = cases.length > 0 ? formatCases(cases) : "No matching cases.";
|
|
1029
|
-
return {
|
|
1030
|
-
content: [{ type: "text", text: `${header}\n${body}` }],
|
|
1031
|
-
details: { cases, total, offset },
|
|
1032
|
-
};
|
|
1238
|
+
return runCaseQuery(
|
|
1239
|
+
params as Record<string, unknown>,
|
|
1240
|
+
(count, total, offset) =>
|
|
1241
|
+
`Search "${params.query}"${params.field ? ` in ${params.field}` : ""}: ${count} of ${total} results (offset: ${offset})`,
|
|
1242
|
+
"No matching cases.",
|
|
1243
|
+
);
|
|
1033
1244
|
},
|
|
1034
1245
|
|
|
1035
1246
|
renderCall(args, theme) {
|
|
1036
|
-
return
|
|
1037
|
-
theme.fg("toolTitle", theme.bold("CaseSearch ")) + theme.fg("dim", `"${args.query}"`),
|
|
1038
|
-
0,
|
|
1039
|
-
0,
|
|
1040
|
-
);
|
|
1247
|
+
return callLine(theme, "CaseSearch", `"${args.query}"`);
|
|
1041
1248
|
},
|
|
1042
1249
|
|
|
1043
1250
|
renderResult(result, { expanded }, theme) {
|
|
1044
|
-
|
|
1045
|
-
const total = details?.total ?? 0;
|
|
1046
|
-
const cases = details?.cases ?? [];
|
|
1047
|
-
let line = theme.fg("success", "✓ ") + theme.fg("muted", `${total} result(s)`);
|
|
1048
|
-
if (expanded && cases.length > 0) {
|
|
1049
|
-
line += `\n${cases.map((c) => ` ${renderOneLine(c, theme)}`).join("\n")}`;
|
|
1050
|
-
}
|
|
1051
|
-
return new Text(line, 0, 0);
|
|
1251
|
+
return renderCasePage(result, theme, "result(s)", expanded);
|
|
1052
1252
|
},
|
|
1053
1253
|
});
|
|
1054
1254
|
|
|
@@ -1094,14 +1294,10 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1094
1294
|
|
|
1095
1295
|
renderCall(args, theme) {
|
|
1096
1296
|
const kind = args.kind ? ` [${args.kind}]` : "";
|
|
1097
|
-
return
|
|
1098
|
-
theme
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
`${(args.source_id as string) ?? ""} ↔ ${(args.target_id as string) ?? ""}${kind}`,
|
|
1102
|
-
),
|
|
1103
|
-
0,
|
|
1104
|
-
0,
|
|
1297
|
+
return callLine(
|
|
1298
|
+
theme,
|
|
1299
|
+
"CaseLink",
|
|
1300
|
+
`${(args.source_id as string) ?? ""} ↔ ${(args.target_id as string) ?? ""}${kind}`,
|
|
1105
1301
|
);
|
|
1106
1302
|
},
|
|
1107
1303
|
|
|
@@ -1163,14 +1359,10 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1163
1359
|
},
|
|
1164
1360
|
|
|
1165
1361
|
renderCall(args, theme) {
|
|
1166
|
-
return
|
|
1167
|
-
theme
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
`${(args.source_id as string) ?? ""} ↻ ${(args.target_id as string) ?? ""}`,
|
|
1171
|
-
),
|
|
1172
|
-
0,
|
|
1173
|
-
0,
|
|
1362
|
+
return callLine(
|
|
1363
|
+
theme,
|
|
1364
|
+
"CaseUnlink",
|
|
1365
|
+
`${(args.source_id as string) ?? ""} ↻ ${(args.target_id as string) ?? ""}`,
|
|
1174
1366
|
);
|
|
1175
1367
|
},
|
|
1176
1368
|
|
|
@@ -1187,6 +1379,65 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1187
1379
|
},
|
|
1188
1380
|
});
|
|
1189
1381
|
|
|
1382
|
+
// ── Tool: ChainSuggest ──
|
|
1383
|
+
|
|
1384
|
+
const ChainSuggestSchema = Type.Object(
|
|
1385
|
+
{
|
|
1386
|
+
case_id: Type.Optional(
|
|
1387
|
+
Type.String({
|
|
1388
|
+
description:
|
|
1389
|
+
"Optional: scope suggestions to this case and its linked cases. Omit to scan all non-terminal cases.",
|
|
1390
|
+
}),
|
|
1391
|
+
),
|
|
1392
|
+
},
|
|
1393
|
+
{ additionalProperties: false },
|
|
1394
|
+
);
|
|
1395
|
+
|
|
1396
|
+
pi.registerTool({
|
|
1397
|
+
name: "ChainSuggest",
|
|
1398
|
+
label: "Suggest Exploit Chains",
|
|
1399
|
+
description:
|
|
1400
|
+
"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.",
|
|
1401
|
+
promptSnippet: "Find missed exploit-chain combinations",
|
|
1402
|
+
promptGuidelines: [
|
|
1403
|
+
"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.",
|
|
1404
|
+
"A suggestion is a HYPOTHESIS to verify, not a finding: test the chained behavior on the live target before linking or promoting anything.",
|
|
1405
|
+
"Chain a suggested pair with CaseLink (suggested kind) or open a new escalation case with status=hypothesis.",
|
|
1406
|
+
],
|
|
1407
|
+
parameters: ChainSuggestSchema,
|
|
1408
|
+
|
|
1409
|
+
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1410
|
+
const suggestions = suggestChains((params.case_id as string | undefined) ?? undefined);
|
|
1411
|
+
const lines: string[] = [
|
|
1412
|
+
suggestions.length
|
|
1413
|
+
? `${suggestions.length} chain candidate(s):`
|
|
1414
|
+
: "No chain candidates found across non-terminal cases.",
|
|
1415
|
+
];
|
|
1416
|
+
for (const s of suggestions) {
|
|
1417
|
+
const pair = s.targetId
|
|
1418
|
+
? `${s.sourceId} (${s.sourceTitle}) + ${s.targetId} (${s.targetTitle ?? ""})`
|
|
1419
|
+
: `${s.sourceId} (${s.sourceTitle})`;
|
|
1420
|
+
lines.push(
|
|
1421
|
+
`\n[${s.pattern}] conf ${s.confidence}% — ${pair}\n ${s.rationale}${s.suggestedKind ? `\n suggested link kind: ${s.suggestedKind}` : ""}`,
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1424
|
+
return {
|
|
1425
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
1426
|
+
details: { suggestions },
|
|
1427
|
+
};
|
|
1428
|
+
},
|
|
1429
|
+
|
|
1430
|
+
renderCall(args, theme) {
|
|
1431
|
+
return callLine(theme, "ChainSuggest", (args.case_id as string) ?? "all");
|
|
1432
|
+
},
|
|
1433
|
+
|
|
1434
|
+
renderResult(result, _opts, theme) {
|
|
1435
|
+
const details = result.details as { suggestions?: { length: number } } | undefined;
|
|
1436
|
+
const n = details?.suggestions?.length ?? 0;
|
|
1437
|
+
return new Text(theme.fg(n ? "accent" : "dim", `${n} chain candidate(s)`), 0, 0);
|
|
1438
|
+
},
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1190
1441
|
// ── Tool: CaseContext ──
|
|
1191
1442
|
|
|
1192
1443
|
pi.registerTool({
|
|
@@ -1199,7 +1450,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1199
1450
|
"Use CaseContext only for confirmed or already reported cases. Keep hypotheses and investigating cases in the ledger until proof is captured.",
|
|
1200
1451
|
"After CaseContext, dispatch the reporter subagent (agents/reporter) to write the final report to the returned report path, then CaseUpdate(status: 'reported').",
|
|
1201
1452
|
],
|
|
1202
|
-
parameters:
|
|
1453
|
+
parameters: IdSchema,
|
|
1203
1454
|
|
|
1204
1455
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1205
1456
|
const { path, contextPath, record } = writeCaseContext(params.id as string);
|
|
@@ -1215,12 +1466,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1215
1466
|
},
|
|
1216
1467
|
|
|
1217
1468
|
renderCall(args, theme) {
|
|
1218
|
-
return
|
|
1219
|
-
theme.fg("toolTitle", theme.bold("CaseContext ")) +
|
|
1220
|
-
theme.fg("dim", (args.id as string) ?? ""),
|
|
1221
|
-
0,
|
|
1222
|
-
0,
|
|
1223
|
-
);
|
|
1469
|
+
return callLine(theme, "CaseContext", (args.id as string) ?? "");
|
|
1224
1470
|
},
|
|
1225
1471
|
|
|
1226
1472
|
renderResult(result, _options, theme) {
|
|
@@ -1298,12 +1544,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1298
1544
|
},
|
|
1299
1545
|
|
|
1300
1546
|
renderCall(args, theme) {
|
|
1301
|
-
return
|
|
1302
|
-
theme.fg("toolTitle", theme.bold("PipelineSubmit ")) +
|
|
1303
|
-
theme.fg("dim", `${args.stage ?? ""}`),
|
|
1304
|
-
0,
|
|
1305
|
-
0,
|
|
1306
|
-
);
|
|
1547
|
+
return callLine(theme, "PipelineSubmit", `${args.stage ?? ""}`);
|
|
1307
1548
|
},
|
|
1308
1549
|
|
|
1309
1550
|
renderResult(result, _opts, theme) {
|
|
@@ -1335,7 +1576,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1335
1576
|
"The run_id is arbitrary but should be unique per pipeline run — typically <target>-<timestamp>.",
|
|
1336
1577
|
"On resume, ScratchpadInit returns the existing checkpoint without wiping it; pair with ScratchpadResume to skip completed phases.",
|
|
1337
1578
|
],
|
|
1338
|
-
parameters:
|
|
1579
|
+
parameters: RunIdSchema,
|
|
1339
1580
|
|
|
1340
1581
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1341
1582
|
const cp = scratchpad_init(params.run_id as string);
|
|
@@ -1351,12 +1592,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1351
1592
|
},
|
|
1352
1593
|
|
|
1353
1594
|
renderCall(args, theme) {
|
|
1354
|
-
return
|
|
1355
|
-
theme.fg("toolTitle", theme.bold("ScratchpadInit ")) +
|
|
1356
|
-
theme.fg("dim", (args.run_id as string) ?? ""),
|
|
1357
|
-
0,
|
|
1358
|
-
0,
|
|
1359
|
-
);
|
|
1595
|
+
return callLine(theme, "ScratchpadInit", (args.run_id as string) ?? "");
|
|
1360
1596
|
},
|
|
1361
1597
|
|
|
1362
1598
|
renderResult(result, _opts, theme) {
|
|
@@ -1378,7 +1614,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1378
1614
|
"If ScratchpadResume returns null, the run has no checkpoint — call ScratchpadInit to start fresh.",
|
|
1379
1615
|
"Use ScratchpadPhaseDone before dispatching each stage to avoid re-running completed phases (idempotent resume).",
|
|
1380
1616
|
],
|
|
1381
|
-
parameters:
|
|
1617
|
+
parameters: RunIdSchema,
|
|
1382
1618
|
|
|
1383
1619
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1384
1620
|
const resume = scratchpad_resume(params.run_id as string);
|
|
@@ -1409,12 +1645,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1409
1645
|
},
|
|
1410
1646
|
|
|
1411
1647
|
renderCall(args, theme) {
|
|
1412
|
-
return
|
|
1413
|
-
theme.fg("toolTitle", theme.bold("ScratchpadResume ")) +
|
|
1414
|
-
theme.fg("dim", (args.run_id as string) ?? ""),
|
|
1415
|
-
0,
|
|
1416
|
-
0,
|
|
1417
|
-
);
|
|
1648
|
+
return callLine(theme, "ScratchpadResume", (args.run_id as string) ?? "");
|
|
1418
1649
|
},
|
|
1419
1650
|
|
|
1420
1651
|
renderResult(result, _opts, theme) {
|
|
@@ -1463,12 +1694,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1463
1694
|
},
|
|
1464
1695
|
|
|
1465
1696
|
renderCall(args, theme) {
|
|
1466
|
-
return
|
|
1467
|
-
theme.fg("toolTitle", theme.bold("ScratchpadCheckpoint ")) +
|
|
1468
|
-
theme.fg("dim", `${args.run_id ?? ""} ${args.phase ?? ""}`),
|
|
1469
|
-
0,
|
|
1470
|
-
0,
|
|
1471
|
-
);
|
|
1697
|
+
return callLine(theme, "ScratchpadCheckpoint", `${args.run_id ?? ""} ${args.phase ?? ""}`);
|
|
1472
1698
|
},
|
|
1473
1699
|
|
|
1474
1700
|
renderResult(result, _opts, theme) {
|
|
@@ -1517,11 +1743,10 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1517
1743
|
},
|
|
1518
1744
|
|
|
1519
1745
|
renderCall(args, theme) {
|
|
1520
|
-
return
|
|
1521
|
-
theme
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
0,
|
|
1746
|
+
return callLine(
|
|
1747
|
+
theme,
|
|
1748
|
+
"ScratchpadWrite",
|
|
1749
|
+
`${args.run_id ?? ""}/${args.phase ?? ""}/${args.artifact_name ?? ""}`,
|
|
1525
1750
|
);
|
|
1526
1751
|
},
|
|
1527
1752
|
|
|
@@ -1569,11 +1794,10 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1569
1794
|
},
|
|
1570
1795
|
|
|
1571
1796
|
renderCall(args, theme) {
|
|
1572
|
-
return
|
|
1573
|
-
theme
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
0,
|
|
1797
|
+
return callLine(
|
|
1798
|
+
theme,
|
|
1799
|
+
"ScratchpadRead",
|
|
1800
|
+
`${args.run_id ?? ""}/${args.phase ?? ""}/${args.artifact_name ?? ""}`,
|
|
1577
1801
|
);
|
|
1578
1802
|
},
|
|
1579
1803
|
|
|
@@ -1617,12 +1841,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1617
1841
|
},
|
|
1618
1842
|
|
|
1619
1843
|
renderCall(args, theme) {
|
|
1620
|
-
return
|
|
1621
|
-
theme.fg("toolTitle", theme.bold("ScratchpadPhaseDone ")) +
|
|
1622
|
-
theme.fg("dim", `${args.run_id ?? ""} ${args.phase ?? ""}`),
|
|
1623
|
-
0,
|
|
1624
|
-
0,
|
|
1625
|
-
);
|
|
1844
|
+
return callLine(theme, "ScratchpadPhaseDone", `${args.run_id ?? ""} ${args.phase ?? ""}`);
|
|
1626
1845
|
},
|
|
1627
1846
|
|
|
1628
1847
|
renderResult(result, _opts, theme) {
|
|
@@ -1649,7 +1868,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1649
1868
|
"Use ScratchpadClear to force a fresh start for a single run (--fresh). It deletes that run's directory only.",
|
|
1650
1869
|
"After clearing, call ScratchpadInit to recreate the directory structure before writing artifacts.",
|
|
1651
1870
|
],
|
|
1652
|
-
parameters:
|
|
1871
|
+
parameters: RunIdSchema,
|
|
1653
1872
|
|
|
1654
1873
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1655
1874
|
scratchpad_clear(params.run_id as string);
|
|
@@ -1665,12 +1884,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1665
1884
|
},
|
|
1666
1885
|
|
|
1667
1886
|
renderCall(args, theme) {
|
|
1668
|
-
return
|
|
1669
|
-
theme.fg("toolTitle", theme.bold("ScratchpadClear ")) +
|
|
1670
|
-
theme.fg("dim", (args.run_id as string) ?? ""),
|
|
1671
|
-
0,
|
|
1672
|
-
0,
|
|
1673
|
-
);
|
|
1887
|
+
return callLine(theme, "ScratchpadClear", (args.run_id as string) ?? "");
|
|
1674
1888
|
},
|
|
1675
1889
|
|
|
1676
1890
|
renderResult(_result, _opts, theme) {
|