@xaccefy/pi-casefile 0.10.0 → 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 +4 -3
- package/package.json +1 -1
- package/src/confirmation.ts +226 -4
- package/src/evidence.ts +127 -0
- package/src/harness-verify.ts +19 -42
- package/src/index.ts +182 -417
- package/src/ledger-internal.ts +118 -4
- package/src/ledger.ts +451 -120
- package/src/poc-runner.ts +41 -12
- package/src/scratchpad.ts +88 -143
- package/src/workflow.ts +6 -2
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
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
10
|
import { readFileSync } from "node:fs";
|
|
11
|
-
import { join } from "node:path";
|
|
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,
|
|
@@ -69,8 +69,8 @@ import {
|
|
|
69
69
|
storePendingConfirmation,
|
|
70
70
|
unlinkCasesResult,
|
|
71
71
|
updateCaseResult,
|
|
72
|
+
writeCaseContext,
|
|
72
73
|
} from "./ledger.ts";
|
|
73
|
-
import { writeCaseContext } from "./ledger.ts";
|
|
74
74
|
import {
|
|
75
75
|
type OobOracleConfig,
|
|
76
76
|
type ProvisionedCallback,
|
|
@@ -83,13 +83,8 @@ import {
|
|
|
83
83
|
detectWorkspaceRoot,
|
|
84
84
|
SCRATCHPAD_PHASES,
|
|
85
85
|
type ScratchpadPhase,
|
|
86
|
-
type ScratchpadResume,
|
|
87
|
-
scratchpad_checkpoint,
|
|
88
86
|
scratchpad_clear,
|
|
89
|
-
scratchpad_init,
|
|
90
|
-
scratchpad_phase_done,
|
|
91
87
|
scratchpad_read,
|
|
92
|
-
scratchpad_resume,
|
|
93
88
|
scratchpad_write,
|
|
94
89
|
setScratchpadRoot,
|
|
95
90
|
} from "./scratchpad.ts";
|
|
@@ -144,6 +139,21 @@ const CommonFields = {
|
|
|
144
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.",
|
|
145
140
|
}),
|
|
146
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
|
+
),
|
|
147
157
|
};
|
|
148
158
|
|
|
149
159
|
// ── Tool: CaseAdd ─────────────────────────────────────────────────────
|
|
@@ -190,6 +200,43 @@ const EvidenceAddSchema = Type.Object(
|
|
|
190
200
|
{ additionalProperties: false },
|
|
191
201
|
);
|
|
192
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
|
+
|
|
193
240
|
// ── Tool: PromoteFinding (phase 1) / ConfirmFinding (phase 2) ──────────
|
|
194
241
|
//
|
|
195
242
|
// Confirmation is TWO-PHASE and main-agent-owned: PromoteFinding runs the PoC
|
|
@@ -238,6 +285,23 @@ const PromoteSchema = Type.Object(
|
|
|
238
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.",
|
|
239
286
|
}),
|
|
240
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
|
+
),
|
|
241
305
|
},
|
|
242
306
|
{ additionalProperties: false },
|
|
243
307
|
);
|
|
@@ -289,6 +353,12 @@ const ConfirmSchema = Type.Object(
|
|
|
289
353
|
"Why a causal reflection canary is not meaningful for this exploit class. Required when canary_assessment=not_applicable.",
|
|
290
354
|
}),
|
|
291
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
|
+
),
|
|
292
362
|
model: Type.Optional(
|
|
293
363
|
Type.String({ description: "Which model judged (recorded for the accuracy ledger)" }),
|
|
294
364
|
),
|
|
@@ -385,7 +455,7 @@ const ScratchpadPhaseSchema = Type.String({
|
|
|
385
455
|
"Pipeline phase: recon | hunt | trace | skeptic | validate | chain | patch | report (legacy gapfil is accepted for older runs)",
|
|
386
456
|
});
|
|
387
457
|
|
|
388
|
-
/** run_id-only schema, shared by Scratchpad
|
|
458
|
+
/** run_id-only schema, shared by Scratchpad tools. */
|
|
389
459
|
const RunIdSchema = Type.Object(
|
|
390
460
|
{
|
|
391
461
|
run_id: Type.String({ description: "Pipeline run identifier" }),
|
|
@@ -393,20 +463,6 @@ const RunIdSchema = Type.Object(
|
|
|
393
463
|
{ additionalProperties: false },
|
|
394
464
|
);
|
|
395
465
|
|
|
396
|
-
const ScratchpadCheckpointSchema = Type.Object(
|
|
397
|
-
{
|
|
398
|
-
run_id: Type.String({ description: "Run identifier" }),
|
|
399
|
-
phase: ScratchpadPhaseSchema,
|
|
400
|
-
ids: Type.Optional(
|
|
401
|
-
Type.Array(Type.String(), {
|
|
402
|
-
description: "Key IDs produced by this phase (case IDs, finding IDs)",
|
|
403
|
-
}),
|
|
404
|
-
),
|
|
405
|
-
summary: Type.Optional(Type.String({ description: "One-line summary of phase completion" })),
|
|
406
|
-
},
|
|
407
|
-
{ additionalProperties: false },
|
|
408
|
-
);
|
|
409
|
-
|
|
410
466
|
const ScratchpadWriteSchema = Type.Object(
|
|
411
467
|
{
|
|
412
468
|
run_id: Type.String({ description: "Run identifier" }),
|
|
@@ -428,14 +484,6 @@ const ScratchpadReadSchema = Type.Object(
|
|
|
428
484
|
{ additionalProperties: false },
|
|
429
485
|
);
|
|
430
486
|
|
|
431
|
-
const ScratchpadPhaseDoneSchema = Type.Object(
|
|
432
|
-
{
|
|
433
|
-
run_id: Type.String({ description: "Run identifier" }),
|
|
434
|
-
phase: ScratchpadPhaseSchema,
|
|
435
|
-
},
|
|
436
|
-
{ additionalProperties: false },
|
|
437
|
-
);
|
|
438
|
-
|
|
439
487
|
interface Theme {
|
|
440
488
|
fg(color: string, text: string): string;
|
|
441
489
|
bold(text: string): string;
|
|
@@ -733,7 +781,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
733
781
|
// already-loaded extension or reveal a tool that was omitted at startup.
|
|
734
782
|
const startedAsSubagent = process.env.PI_SUBAGENT_CHILD === "1";
|
|
735
783
|
const isSubagentProcess = () => startedAsSubagent || process.env.PI_SUBAGENT_CHILD === "1";
|
|
736
|
-
// Pin the workspace root ONCE at extension load. Every scratchpad
|
|
784
|
+
// Pin the workspace root ONCE at extension load. Every scratchpad
|
|
737
785
|
// / PoC-path lookup otherwise re-walks the ambient cwd on each call — a
|
|
738
786
|
// mid-session `cd` would split state across two .scratchpad roots and
|
|
739
787
|
// misroot the hunt file-existence filter. The PoC runner reads PI_POC_ROOT
|
|
@@ -790,7 +838,13 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
790
838
|
parameters: AddSchema,
|
|
791
839
|
|
|
792
840
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
793
|
-
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
|
+
});
|
|
794
848
|
const record = result.record;
|
|
795
849
|
return {
|
|
796
850
|
content: [
|
|
@@ -842,8 +896,14 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
842
896
|
parameters: UpdateSchema,
|
|
843
897
|
|
|
844
898
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
845
|
-
const { id, ...
|
|
846
|
-
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);
|
|
847
907
|
const record = result.record;
|
|
848
908
|
return {
|
|
849
909
|
content: [
|
|
@@ -908,7 +968,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
908
968
|
content: [
|
|
909
969
|
{
|
|
910
970
|
type: "text",
|
|
911
|
-
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)}`,
|
|
912
972
|
},
|
|
913
973
|
],
|
|
914
974
|
details: { item, record },
|
|
@@ -938,158 +998,6 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
938
998
|
},
|
|
939
999
|
});
|
|
940
1000
|
|
|
941
|
-
// ── Tool: CoverageAdd ──
|
|
942
|
-
|
|
943
|
-
const CoverageAddSchema = Type.Object(
|
|
944
|
-
{
|
|
945
|
-
case_id: Type.String({
|
|
946
|
-
description: "Case ID (the pipeline-run or finding case) to record coverage under",
|
|
947
|
-
}),
|
|
948
|
-
asset: Type.String({
|
|
949
|
-
description:
|
|
950
|
-
"The asset tested — copy it verbatim from the case target where shown. For scope=wide use the deployment-wide identifier.",
|
|
951
|
-
}),
|
|
952
|
-
class: Type.String({
|
|
953
|
-
description:
|
|
954
|
-
"The attack class tested (e.g. sql-injection, xss, idor, ssti, ssrf, auth-bypass, ...).",
|
|
955
|
-
}),
|
|
956
|
-
// Provider-safe string enum (per the header rule): Type.Union(Type.Literal)
|
|
957
|
-
// serializes to anyOf/const, which some providers drop — scope would
|
|
958
|
-
// arrive undefined and every explicit 'wide' verdict would silently
|
|
959
|
-
// persist as 'local', under-reporting tested classes.
|
|
960
|
-
scope: Type.String({
|
|
961
|
-
enum: [...COVERAGE_SCOPE_VALUES],
|
|
962
|
-
description:
|
|
963
|
-
"'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.",
|
|
964
|
-
}),
|
|
965
|
-
note: Type.String({
|
|
966
|
-
description:
|
|
967
|
-
"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.",
|
|
968
|
-
}),
|
|
969
|
-
evidence_item_id: Type.Optional(
|
|
970
|
-
Type.String({
|
|
971
|
-
description:
|
|
972
|
-
"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.",
|
|
973
|
-
}),
|
|
974
|
-
),
|
|
975
|
-
},
|
|
976
|
-
{ additionalProperties: false },
|
|
977
|
-
);
|
|
978
|
-
|
|
979
|
-
registerCaseTool({
|
|
980
|
-
name: "CoverageAdd",
|
|
981
|
-
label: "Record Coverage",
|
|
982
|
-
description:
|
|
983
|
-
"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.",
|
|
984
|
-
promptSnippet: "Record a tested attack class (coverage)",
|
|
985
|
-
promptGuidelines: [
|
|
986
|
-
"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.",
|
|
987
|
-
"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.",
|
|
988
|
-
"The note must describe tests you ACTUALLY RAN, not assumptions. A verdict guessed without testing can hide a real issue.",
|
|
989
|
-
"Coverage cells live on the pipeline-run case (or the target's main case); CoverageReport shows the matrix.",
|
|
990
|
-
],
|
|
991
|
-
parameters: CoverageAddSchema,
|
|
992
|
-
|
|
993
|
-
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
994
|
-
const item = recordCoverageResult(params.case_id as string, {
|
|
995
|
-
asset: params.asset as string,
|
|
996
|
-
class: params.class as string,
|
|
997
|
-
scope: (params.scope ?? "local") as CoverageScope,
|
|
998
|
-
note: params.note as string,
|
|
999
|
-
evidenceItemId: params.evidence_item_id as string | undefined,
|
|
1000
|
-
});
|
|
1001
|
-
const record = getCaseById(params.case_id as string);
|
|
1002
|
-
if (!record) throw new Error(`Case not found after coverage insert: ${params.case_id}`);
|
|
1003
|
-
return {
|
|
1004
|
-
content: [
|
|
1005
|
-
{
|
|
1006
|
-
type: "text",
|
|
1007
|
-
text: `Coverage recorded: [${item.scope}] ${item.asset} × ${item.class} — ${item.note}\n\n${formatCaseDetail(record)}`,
|
|
1008
|
-
},
|
|
1009
|
-
],
|
|
1010
|
-
details: { item, record },
|
|
1011
|
-
};
|
|
1012
|
-
},
|
|
1013
|
-
|
|
1014
|
-
renderCall(args, theme) {
|
|
1015
|
-
return callLine(
|
|
1016
|
-
theme,
|
|
1017
|
-
"CoverageAdd",
|
|
1018
|
-
`${(args.asset as string) ?? ""} [${(args.class as string) ?? ""}]`,
|
|
1019
|
-
);
|
|
1020
|
-
},
|
|
1021
|
-
|
|
1022
|
-
renderResult(result, _opts, theme) {
|
|
1023
|
-
const details = result.details as { item?: CoverageItem } | undefined;
|
|
1024
|
-
if (!details?.item) {
|
|
1025
|
-
return new Text(theme.fg("error", "✗ CoverageAdd failed"), 0, 0);
|
|
1026
|
-
}
|
|
1027
|
-
return new Text(
|
|
1028
|
-
theme.fg("success", "✓ ") +
|
|
1029
|
-
theme.fg("dim", `[${details.item.scope}] `) +
|
|
1030
|
-
truncateToWidth(`${details.item.asset} × ${details.item.class}`, 50),
|
|
1031
|
-
0,
|
|
1032
|
-
0,
|
|
1033
|
-
);
|
|
1034
|
-
},
|
|
1035
|
-
});
|
|
1036
|
-
|
|
1037
|
-
// ── Tool: CoverageReport ──
|
|
1038
|
-
|
|
1039
|
-
const CoverageReportSchema = Type.Object(
|
|
1040
|
-
{
|
|
1041
|
-
case_id: Type.String({ description: "Case ID to render the coverage matrix for" }),
|
|
1042
|
-
},
|
|
1043
|
-
{ additionalProperties: false },
|
|
1044
|
-
);
|
|
1045
|
-
|
|
1046
|
-
registerCaseTool({
|
|
1047
|
-
name: "CoverageReport",
|
|
1048
|
-
label: "Coverage Matrix",
|
|
1049
|
-
description:
|
|
1050
|
-
"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.",
|
|
1051
|
-
promptSnippet: "Show which attack classes were tested where",
|
|
1052
|
-
promptGuidelines: [
|
|
1053
|
-
"Run CoverageReport before claiming 'every class is COVERED/SKIPPED/NOT_FOUND' — the claim must match the matrix.",
|
|
1054
|
-
"A class with a wide clean verdict covers every asset — do NOT re-test it per asset.",
|
|
1055
|
-
"Classes tested with no cell recorded are invisible: record coverage as you finish each class (CoverageAdd).",
|
|
1056
|
-
],
|
|
1057
|
-
parameters: CoverageReportSchema,
|
|
1058
|
-
|
|
1059
|
-
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1060
|
-
const summary = coverageSummary(params.case_id as string);
|
|
1061
|
-
const lines: string[] = [`Coverage matrix for ${params.case_id}:`];
|
|
1062
|
-
for (const asset of summary.assets) {
|
|
1063
|
-
lines.push(`\n## ${asset}`);
|
|
1064
|
-
for (const cell of summary.byAsset[asset] ?? []) {
|
|
1065
|
-
lines.push(
|
|
1066
|
-
`- [${cell.scope}] ${cell.class} — ${cell.note}${cell.testedBy ? ` (by ${cell.testedBy})` : ""}` +
|
|
1067
|
-
(cell.evidenceItemId
|
|
1068
|
-
? ""
|
|
1069
|
-
: " ⚠ unbacked (link an artifact-backed evidence item via CoverageAdd evidence_item_id)"),
|
|
1070
|
-
);
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
if (summary.items.length === 0) {
|
|
1074
|
-
lines.push("\n(no coverage recorded yet — run CoverageAdd as each class is tested)");
|
|
1075
|
-
}
|
|
1076
|
-
return {
|
|
1077
|
-
content: [{ type: "text", text: lines.join("\n") }],
|
|
1078
|
-
details: { summary },
|
|
1079
|
-
};
|
|
1080
|
-
},
|
|
1081
|
-
|
|
1082
|
-
renderCall(args, theme) {
|
|
1083
|
-
return callLine(theme, "CoverageReport", (args.case_id as string) ?? "");
|
|
1084
|
-
},
|
|
1085
|
-
|
|
1086
|
-
renderResult(result, _opts, theme) {
|
|
1087
|
-
const details = result.details as { summary?: { items?: CoverageItem[] } } | undefined;
|
|
1088
|
-
const n = details?.summary?.items?.length ?? 0;
|
|
1089
|
-
return new Text(theme.fg("success", `✓ ${n} coverage cell(s)`), 0, 0);
|
|
1090
|
-
},
|
|
1091
|
-
});
|
|
1092
|
-
|
|
1093
1001
|
// ── Tool: PromoteFinding (phase 1) ──
|
|
1094
1002
|
|
|
1095
1003
|
if (!startedAsSubagent)
|
|
@@ -1126,7 +1034,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1126
1034
|
const caseId = params.id as string;
|
|
1127
1035
|
const current = assertPromotable(caseId);
|
|
1128
1036
|
|
|
1129
|
-
const fail = (text: string
|
|
1037
|
+
const fail = (text: string): never => {
|
|
1130
1038
|
throw new Error(text);
|
|
1131
1039
|
};
|
|
1132
1040
|
|
|
@@ -1137,11 +1045,19 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1137
1045
|
(params.mode as string | undefined) === "intra_target" ? "intra_target" : "inter_host";
|
|
1138
1046
|
const isIntra = mode === "intra_target";
|
|
1139
1047
|
if (!pocPath) {
|
|
1140
|
-
return fail("poc_path is REQUIRED: absolute path to the PoC script run by the harness."
|
|
1141
|
-
missingPocPath: true,
|
|
1142
|
-
});
|
|
1048
|
+
return fail("poc_path is REQUIRED: absolute path to the PoC script run by the harness.");
|
|
1143
1049
|
}
|
|
1144
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;
|
|
1060
|
+
}
|
|
1145
1061
|
// ── OOB callback (Tier 1, opt-in for blind classes) ──
|
|
1146
1062
|
// The operator-run oracle owns the evidence channel; the harness owns
|
|
1147
1063
|
// the secret (per-run token, provisioned before the runs and injected
|
|
@@ -1155,7 +1071,6 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1155
1071
|
if (isIntra && oobRequested) {
|
|
1156
1072
|
return fail(
|
|
1157
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.",
|
|
1158
|
-
{ intraOobConflict: true },
|
|
1159
1074
|
);
|
|
1160
1075
|
}
|
|
1161
1076
|
let oobConfig: OobOracleConfig | undefined;
|
|
@@ -1164,9 +1079,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1164
1079
|
if (oobRequested) {
|
|
1165
1080
|
const oracle = readOobOracleConfig();
|
|
1166
1081
|
if (!oracle.config) {
|
|
1167
|
-
return fail(`OOB CONFIRMATION UNAVAILABLE: ${oracle.error}
|
|
1168
|
-
oobOracleNotConfigured: true,
|
|
1169
|
-
});
|
|
1082
|
+
return fail(`OOB CONFIRMATION UNAVAILABLE: ${oracle.error}`);
|
|
1170
1083
|
}
|
|
1171
1084
|
oobConfig = oracle.config;
|
|
1172
1085
|
// Provision both identities concurrently — each is an oracle round trip.
|
|
@@ -1182,20 +1095,17 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1182
1095
|
if (!controlTarget) {
|
|
1183
1096
|
return fail(
|
|
1184
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).",
|
|
1185
|
-
{ missingControlTarget: true },
|
|
1186
1098
|
);
|
|
1187
1099
|
}
|
|
1188
1100
|
if (controlTarget === current.target) {
|
|
1189
1101
|
return fail(
|
|
1190
1102
|
"control_target must differ from the case target; a control run against the vulnerable target proves nothing.",
|
|
1191
|
-
{ controlTargetEqualsCaseTarget: true },
|
|
1192
1103
|
);
|
|
1193
1104
|
}
|
|
1194
1105
|
}
|
|
1195
1106
|
if (params.local === true && process.env.PI_POC_ALLOW_NETWORK !== "1") {
|
|
1196
1107
|
return fail(
|
|
1197
1108
|
"Networked PoC execution is operator-gated. Set PI_POC_ALLOW_NETWORK=1 to authorize the host-network sandbox for this session.",
|
|
1198
|
-
{ networkNotAuthorized: true },
|
|
1199
1109
|
);
|
|
1200
1110
|
}
|
|
1201
1111
|
if (!isIntra && !oobOnly) {
|
|
@@ -1204,7 +1114,6 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1204
1114
|
return fail(
|
|
1205
1115
|
`CONTROL AUTHORIZATION FAILED: ${controlAuthorization}. ` +
|
|
1206
1116
|
"The operator must set PI_POC_CONTROL_TARGETS to the exact approved control host/origin before this control can anchor confirmation.",
|
|
1207
|
-
{ controlNotAuthorized: true },
|
|
1208
1117
|
);
|
|
1209
1118
|
}
|
|
1210
1119
|
}
|
|
@@ -1215,9 +1124,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1215
1124
|
try {
|
|
1216
1125
|
pocHash = createHash("sha256").update(readFileSync(pocPath)).digest("hex");
|
|
1217
1126
|
} catch (e) {
|
|
1218
|
-
return fail(`Cannot read PoC script: ${(e as Error).message}
|
|
1219
|
-
sameFileCheckFailed: true,
|
|
1220
|
-
});
|
|
1127
|
+
return fail(`Cannot read PoC script: ${(e as Error).message}`);
|
|
1221
1128
|
}
|
|
1222
1129
|
if (!isIntra && !oobOnly) {
|
|
1223
1130
|
let controlHash: string | undefined;
|
|
@@ -1226,13 +1133,11 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1226
1133
|
} catch (e) {
|
|
1227
1134
|
return fail(
|
|
1228
1135
|
`Cannot read control script for the same-file check: ${(e as Error).message}`,
|
|
1229
|
-
{ sameFileCheckFailed: true },
|
|
1230
1136
|
);
|
|
1231
1137
|
}
|
|
1232
1138
|
if (pocHash !== controlHash) {
|
|
1233
1139
|
return fail(
|
|
1234
1140
|
"CONTROL CHECK FAILED: control_path must be the SAME script as poc_path (sha256 mismatch). Case remains investigating.",
|
|
1235
|
-
{ controlHashMismatch: true },
|
|
1236
1141
|
);
|
|
1237
1142
|
}
|
|
1238
1143
|
}
|
|
@@ -1269,7 +1174,6 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1269
1174
|
`${mode} run did not complete or output capture was incomplete` +
|
|
1270
1175
|
(r.infraError ? ` (infra: ${r.output.trim()})` : "") +
|
|
1271
1176
|
". A crash is not evidence. Case remains investigating.",
|
|
1272
|
-
{ run: r, pocCrashed: true },
|
|
1273
1177
|
);
|
|
1274
1178
|
}
|
|
1275
1179
|
if (r.evidenceError) {
|
|
@@ -1277,13 +1181,10 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1277
1181
|
`EVIDENCE CONTRACT FAILED (${mode} run): ${r.evidenceError}. ` +
|
|
1278
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 — " +
|
|
1279
1183
|
"the file is bound to this run and validated by the harness. Case remains investigating.",
|
|
1280
|
-
{ run: r, evidenceError: r.evidenceError },
|
|
1281
1184
|
);
|
|
1282
1185
|
}
|
|
1283
1186
|
if (!r.evidence || !r.evidenceSha256 || !r.nonce) {
|
|
1284
|
-
return fail(`${mode} run produced no evidence. Case remains investigating
|
|
1285
|
-
run: r,
|
|
1286
|
-
});
|
|
1187
|
+
return fail(`${mode} run produced no evidence. Case remains investigating.`);
|
|
1287
1188
|
}
|
|
1288
1189
|
return {
|
|
1289
1190
|
mode,
|
|
@@ -1313,7 +1214,6 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1313
1214
|
if (oobRequested && targetRuns.some((r) => r.evidence.verify.canary !== undefined)) {
|
|
1314
1215
|
return fail(
|
|
1315
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.",
|
|
1316
|
-
{ canaryOobConflict: true },
|
|
1317
1217
|
);
|
|
1318
1218
|
}
|
|
1319
1219
|
const allowPrivateReplay = process.env.PI_POC_ALLOW_PRIVATE_REPLAY === "1";
|
|
@@ -1327,13 +1227,11 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1327
1227
|
if (ev0.verify.mode !== "intra_target") {
|
|
1328
1228
|
return fail(
|
|
1329
1229
|
"INTRA-TARGET FAILED: the PoC's evidence.json must set verify.mode='intra_target' when promoting in intra-target mode.",
|
|
1330
|
-
{ intraModeMismatch: true },
|
|
1331
1230
|
);
|
|
1332
1231
|
}
|
|
1333
1232
|
if (!ev0.baseline) {
|
|
1334
1233
|
return fail(
|
|
1335
1234
|
"INTRA-TARGET FAILED: evidence.json must include a baseline — a legitimate same-host request whose response must NOT satisfy the attack predicate.",
|
|
1336
|
-
{ intraBaselineMissing: true },
|
|
1337
1235
|
);
|
|
1338
1236
|
}
|
|
1339
1237
|
harnessVerified = await replayIntraTarget(ev0, caseTarget, {
|
|
@@ -1377,6 +1275,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1377
1275
|
mode,
|
|
1378
1276
|
targetRuns,
|
|
1379
1277
|
harnessVerified,
|
|
1278
|
+
...(panelVotes ? { panelVotes } : {}),
|
|
1380
1279
|
...(callbackVerified && targetCallback && controlCallback
|
|
1381
1280
|
? {
|
|
1382
1281
|
callbackVerified,
|
|
@@ -1393,9 +1292,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1393
1292
|
try {
|
|
1394
1293
|
record = storePendingConfirmation(caseId, bundle);
|
|
1395
1294
|
} catch (e) {
|
|
1396
|
-
return fail(`Pending confirmation rejected: ${(e as Error).message}
|
|
1397
|
-
storeRejected: true,
|
|
1398
|
-
});
|
|
1295
|
+
return fail(`Pending confirmation rejected: ${(e as Error).message}`);
|
|
1399
1296
|
}
|
|
1400
1297
|
|
|
1401
1298
|
return {
|
|
@@ -1602,6 +1499,65 @@ ${formatCaseDetail(record)}`,
|
|
|
1602
1499
|
},
|
|
1603
1500
|
});
|
|
1604
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
|
+
|
|
1605
1561
|
// ── Tool: CaseGet ──
|
|
1606
1562
|
|
|
1607
1563
|
registerCaseTool({
|
|
@@ -1831,15 +1787,15 @@ ${formatCaseDetail(record)}`,
|
|
|
1831
1787
|
parameters: IdSchema,
|
|
1832
1788
|
|
|
1833
1789
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1834
|
-
const { path, contextPath, record } = writeCaseContext(params.id as string);
|
|
1790
|
+
const { path, contextPath, contractPath, record } = writeCaseContext(params.id as string);
|
|
1835
1791
|
return {
|
|
1836
1792
|
content: [
|
|
1837
1793
|
{
|
|
1838
1794
|
type: "text",
|
|
1839
|
-
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)}`,
|
|
1840
1796
|
},
|
|
1841
1797
|
],
|
|
1842
|
-
details: { path, contextPath, record },
|
|
1798
|
+
details: { path, contextPath, contractPath, record },
|
|
1843
1799
|
};
|
|
1844
1800
|
},
|
|
1845
1801
|
|
|
@@ -1857,153 +1813,6 @@ ${formatCaseDetail(record)}`,
|
|
|
1857
1813
|
},
|
|
1858
1814
|
});
|
|
1859
1815
|
|
|
1860
|
-
// ── Tool: ScratchpadInit ──
|
|
1861
|
-
|
|
1862
|
-
registerCaseTool({
|
|
1863
|
-
name: "ScratchpadInit",
|
|
1864
|
-
label: "Init Scratchpad",
|
|
1865
|
-
description:
|
|
1866
|
-
"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.",
|
|
1867
|
-
promptSnippet: "Initialize the pipeline artifact store for a run",
|
|
1868
|
-
promptGuidelines: [
|
|
1869
|
-
"Call ScratchpadInit once at the start of a pipeline run (or on resume before ScratchpadResume).",
|
|
1870
|
-
"The run_id is arbitrary but should be unique per pipeline run — typically <target>-<timestamp>.",
|
|
1871
|
-
"On resume, ScratchpadInit returns the existing checkpoint without wiping it; pair with ScratchpadResume to skip completed phases.",
|
|
1872
|
-
],
|
|
1873
|
-
parameters: RunIdSchema,
|
|
1874
|
-
|
|
1875
|
-
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1876
|
-
const cp = scratchpad_init(params.run_id as string);
|
|
1877
|
-
return {
|
|
1878
|
-
content: [
|
|
1879
|
-
{
|
|
1880
|
-
type: "text",
|
|
1881
|
-
text: `Scratchpad initialized for run ${cp.run_id}.\nCompleted phases: ${cp.completed_phases.length ? cp.completed_phases.join(", ") : "none"}`,
|
|
1882
|
-
},
|
|
1883
|
-
],
|
|
1884
|
-
details: { checkpoint: cp },
|
|
1885
|
-
};
|
|
1886
|
-
},
|
|
1887
|
-
|
|
1888
|
-
renderCall(args, theme) {
|
|
1889
|
-
return callLine(theme, "ScratchpadInit", (args.run_id as string) ?? "");
|
|
1890
|
-
},
|
|
1891
|
-
|
|
1892
|
-
renderResult(result, _opts, theme) {
|
|
1893
|
-
const cp = (result.details as { checkpoint: { run_id: string } } | undefined)?.checkpoint;
|
|
1894
|
-
return new Text(`${theme.fg("success", "✓ ")}ScratchpadInit ${cp?.run_id ?? ""}`, 0, 0);
|
|
1895
|
-
},
|
|
1896
|
-
});
|
|
1897
|
-
|
|
1898
|
-
// ── Tool: ScratchpadResume ──
|
|
1899
|
-
|
|
1900
|
-
registerCaseTool({
|
|
1901
|
-
name: "ScratchpadResume",
|
|
1902
|
-
label: "Resume Scratchpad",
|
|
1903
|
-
description:
|
|
1904
|
-
"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.",
|
|
1905
|
-
promptSnippet: "Check pipeline resume state — which phases are done",
|
|
1906
|
-
promptGuidelines: [
|
|
1907
|
-
"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.",
|
|
1908
|
-
"If ScratchpadResume returns null, the run has no checkpoint — call ScratchpadInit to start fresh.",
|
|
1909
|
-
"Use ScratchpadPhaseDone before dispatching each stage to avoid re-running completed phases (idempotent resume).",
|
|
1910
|
-
],
|
|
1911
|
-
parameters: RunIdSchema,
|
|
1912
|
-
|
|
1913
|
-
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1914
|
-
const resume = scratchpad_resume(params.run_id as string);
|
|
1915
|
-
if (!resume) {
|
|
1916
|
-
return {
|
|
1917
|
-
content: [
|
|
1918
|
-
{
|
|
1919
|
-
type: "text",
|
|
1920
|
-
text: `No scratchpad found for run ${params.run_id}. Call ScratchpadInit to start a new run.`,
|
|
1921
|
-
},
|
|
1922
|
-
],
|
|
1923
|
-
details: { resume: null },
|
|
1924
|
-
};
|
|
1925
|
-
}
|
|
1926
|
-
const cp = resume.checkpoint;
|
|
1927
|
-
return {
|
|
1928
|
-
content: [
|
|
1929
|
-
{
|
|
1930
|
-
type: "text",
|
|
1931
|
-
text:
|
|
1932
|
-
`Resume run ${cp.run_id}:\n` +
|
|
1933
|
-
`Completed phases: ${cp.completed_phases.length ? cp.completed_phases.join(", ") : "none"}\n` +
|
|
1934
|
-
`Next phase: ${resume.next_phase ?? "none (run is done)"}`,
|
|
1935
|
-
},
|
|
1936
|
-
],
|
|
1937
|
-
details: { resume },
|
|
1938
|
-
};
|
|
1939
|
-
},
|
|
1940
|
-
|
|
1941
|
-
renderCall(args, theme) {
|
|
1942
|
-
return callLine(theme, "ScratchpadResume", (args.run_id as string) ?? "");
|
|
1943
|
-
},
|
|
1944
|
-
|
|
1945
|
-
renderResult(result, _opts, theme) {
|
|
1946
|
-
const resume = (result.details as { resume: ScratchpadResume | null } | undefined)?.resume;
|
|
1947
|
-
if (!resume) return new Text(theme.fg("warning", "↷ ScratchpadResume — no run found"), 0, 0);
|
|
1948
|
-
return new Text(
|
|
1949
|
-
theme.fg("success", "✓ ") +
|
|
1950
|
-
`ScratchpadResume ${resume.checkpoint.run_id} → next: ${resume.next_phase ?? "done"}`,
|
|
1951
|
-
0,
|
|
1952
|
-
0,
|
|
1953
|
-
);
|
|
1954
|
-
},
|
|
1955
|
-
});
|
|
1956
|
-
|
|
1957
|
-
// ── Tool: ScratchpadCheckpoint ──
|
|
1958
|
-
|
|
1959
|
-
registerCaseTool({
|
|
1960
|
-
name: "ScratchpadCheckpoint",
|
|
1961
|
-
label: "Checkpoint Phase",
|
|
1962
|
-
description:
|
|
1963
|
-
"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.",
|
|
1964
|
-
promptSnippet: "Record a pipeline phase as complete",
|
|
1965
|
-
promptGuidelines: [
|
|
1966
|
-
"Call ScratchpadCheckpoint after every phase completes: ScratchpadCheckpoint(run_id, phase, { ids, summary }).",
|
|
1967
|
-
"ids are the key case/finding IDs the phase produced — used by resume to reconstruct state.",
|
|
1968
|
-
"Keep completed_phases in pipeline order; the checkpoint sorts automatically.",
|
|
1969
|
-
],
|
|
1970
|
-
parameters: ScratchpadCheckpointSchema,
|
|
1971
|
-
|
|
1972
|
-
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1973
|
-
const cp = scratchpad_checkpoint(params.run_id as string, params.phase as ScratchpadPhase, {
|
|
1974
|
-
ids: params.ids as string[] | undefined,
|
|
1975
|
-
summary: params.summary as string | undefined,
|
|
1976
|
-
});
|
|
1977
|
-
return {
|
|
1978
|
-
content: [
|
|
1979
|
-
{
|
|
1980
|
-
type: "text",
|
|
1981
|
-
text:
|
|
1982
|
-
`Phase ${params.phase} checkpointed for run ${cp.run_id}.\n` +
|
|
1983
|
-
`Completed phases: ${cp.completed_phases.join(", ")}`,
|
|
1984
|
-
},
|
|
1985
|
-
],
|
|
1986
|
-
details: { checkpoint: cp },
|
|
1987
|
-
};
|
|
1988
|
-
},
|
|
1989
|
-
|
|
1990
|
-
renderCall(args, theme) {
|
|
1991
|
-
return callLine(theme, "ScratchpadCheckpoint", `${args.run_id ?? ""} ${args.phase ?? ""}`);
|
|
1992
|
-
},
|
|
1993
|
-
|
|
1994
|
-
renderResult(result, _opts, theme) {
|
|
1995
|
-
const cp = (
|
|
1996
|
-
result.details as { checkpoint: { run_id: string; completed_phases: string[] } } | undefined
|
|
1997
|
-
)?.checkpoint;
|
|
1998
|
-
return new Text(
|
|
1999
|
-
theme.fg("success", "✓ ") +
|
|
2000
|
-
`ScratchpadCheckpoint ${cp?.run_id ?? ""} — ${cp?.completed_phases.length ?? 0} phases done`,
|
|
2001
|
-
0,
|
|
2002
|
-
0,
|
|
2003
|
-
);
|
|
2004
|
-
},
|
|
2005
|
-
});
|
|
2006
|
-
|
|
2007
1816
|
// ── Tool: ScratchpadWrite ──
|
|
2008
1817
|
|
|
2009
1818
|
registerCaseTool({
|
|
@@ -2011,7 +1820,7 @@ ${formatCaseDetail(record)}`,
|
|
|
2011
1820
|
label: "Write Artifact",
|
|
2012
1821
|
description:
|
|
2013
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.",
|
|
2014
|
-
promptSnippet: "Save a
|
|
1823
|
+
promptSnippet: "Save a run artifact to the scratchpad",
|
|
2015
1824
|
promptGuidelines: [
|
|
2016
1825
|
"Agents write artifacts to the scratchpad, not to each other's output files (prevents an echo chamber).",
|
|
2017
1826
|
"The casefile owns state transitions; the scratchpad owns artifacts. Use ScratchpadWrite for bulky intermediate outputs, not CaseUpdate.",
|
|
@@ -2057,7 +1866,7 @@ ${formatCaseDetail(record)}`,
|
|
|
2057
1866
|
label: "Read Artifact",
|
|
2058
1867
|
description:
|
|
2059
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.",
|
|
2060
|
-
promptSnippet: "Read a
|
|
1869
|
+
promptSnippet: "Read a run artifact from the scratchpad",
|
|
2061
1870
|
promptGuidelines: [
|
|
2062
1871
|
"On resume, ScratchpadRead retrieves a prior phase's intermediate output so the next phase can proceed without re-running it.",
|
|
2063
1872
|
"Returns null for missing artifacts — treat as 'not yet produced' rather than an error.",
|
|
@@ -2107,60 +1916,16 @@ ${formatCaseDetail(record)}`,
|
|
|
2107
1916
|
},
|
|
2108
1917
|
});
|
|
2109
1918
|
|
|
2110
|
-
// ── Tool: ScratchpadPhaseDone ──
|
|
2111
|
-
|
|
2112
|
-
registerCaseTool({
|
|
2113
|
-
name: "ScratchpadPhaseDone",
|
|
2114
|
-
label: "Phase Done?",
|
|
2115
|
-
description:
|
|
2116
|
-
"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.",
|
|
2117
|
-
promptSnippet: "Check if a pipeline phase is already complete",
|
|
2118
|
-
promptGuidelines: [
|
|
2119
|
-
"Call ScratchpadPhaseDone before dispatching each stage to avoid re-running completed phases on resume.",
|
|
2120
|
-
"A completed phase with a checkpoint is a no-op on re-run — skip it and continue to the next incomplete phase.",
|
|
2121
|
-
],
|
|
2122
|
-
parameters: ScratchpadPhaseDoneSchema,
|
|
2123
|
-
|
|
2124
|
-
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
2125
|
-
const done = scratchpad_phase_done(params.run_id as string, params.phase as ScratchpadPhase);
|
|
2126
|
-
return {
|
|
2127
|
-
content: [
|
|
2128
|
-
{
|
|
2129
|
-
type: "text",
|
|
2130
|
-
text: `Phase ${params.phase} for run ${params.run_id}: ${done ? "DONE (skip on resume)" : "not done"}`,
|
|
2131
|
-
},
|
|
2132
|
-
],
|
|
2133
|
-
details: { phase: params.phase, done },
|
|
2134
|
-
};
|
|
2135
|
-
},
|
|
2136
|
-
|
|
2137
|
-
renderCall(args, theme) {
|
|
2138
|
-
return callLine(theme, "ScratchpadPhaseDone", `${args.run_id ?? ""} ${args.phase ?? ""}`);
|
|
2139
|
-
},
|
|
2140
|
-
|
|
2141
|
-
renderResult(result, _opts, theme) {
|
|
2142
|
-
const done = (result.details as { done?: boolean } | undefined)?.done;
|
|
2143
|
-
return new Text(
|
|
2144
|
-
done
|
|
2145
|
-
? theme.fg("success", "✓ ScratchpadPhaseDone — done")
|
|
2146
|
-
: theme.fg("warning", "↷ ScratchpadPhaseDone — not done"),
|
|
2147
|
-
0,
|
|
2148
|
-
0,
|
|
2149
|
-
);
|
|
2150
|
-
},
|
|
2151
|
-
});
|
|
2152
|
-
|
|
2153
1919
|
// ── Tool: ScratchpadClear ──
|
|
2154
1920
|
|
|
2155
1921
|
registerCaseTool({
|
|
2156
1922
|
name: "ScratchpadClear",
|
|
2157
1923
|
label: "Clear Run",
|
|
2158
1924
|
description:
|
|
2159
|
-
"Clear a single
|
|
2160
|
-
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",
|
|
2161
1927
|
promptGuidelines: [
|
|
2162
|
-
"Use ScratchpadClear to force a fresh start for a single run
|
|
2163
|
-
"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.",
|
|
2164
1929
|
],
|
|
2165
1930
|
parameters: RunIdSchema,
|
|
2166
1931
|
|
|
@@ -2170,7 +1935,7 @@ ${formatCaseDetail(record)}`,
|
|
|
2170
1935
|
content: [
|
|
2171
1936
|
{
|
|
2172
1937
|
type: "text",
|
|
2173
|
-
text: `Scratchpad cleared for run ${params.run_id}
|
|
1938
|
+
text: `Scratchpad cleared for run ${params.run_id}.`,
|
|
2174
1939
|
},
|
|
2175
1940
|
],
|
|
2176
1941
|
details: { run_id: params.run_id, cleared: true },
|