@xaccefy/pi-casefile 0.10.0 → 0.11.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 +6 -6
- package/package.json +1 -2
- package/src/confirmation.ts +239 -323
- package/src/evidence.ts +101 -135
- package/src/harness-verify.ts +54 -247
- package/src/index.ts +188 -745
- package/src/ledger-internal.ts +118 -4
- package/src/ledger.ts +459 -175
- package/src/poc-runner.ts +43 -14
- package/src/scratchpad.ts +88 -143
- package/src/workflow.ts +6 -2
- package/src/oob-oracle.ts +0 -279
package/src/index.ts
CHANGED
|
@@ -1,30 +1,23 @@
|
|
|
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";
|
|
15
14
|
import {
|
|
16
|
-
CANARY_ASSESSMENT_VALUES,
|
|
17
15
|
CONFIRM_DIFFERENTIAL_VALUES,
|
|
18
16
|
CONFIRM_VERDICT_VALUES,
|
|
19
17
|
SEVERITY_MATCH_VALUES,
|
|
20
18
|
validateMainAgentVerdict,
|
|
21
19
|
} from "./evidence.ts";
|
|
22
|
-
import {
|
|
23
|
-
controlTargetAuthorizationError,
|
|
24
|
-
type HarnessVerifyResult,
|
|
25
|
-
replayDifferential,
|
|
26
|
-
replayIntraTarget,
|
|
27
|
-
} from "./harness-verify.ts";
|
|
20
|
+
import { type HarnessVerifyResult, replayIntraTarget } from "./harness-verify.ts";
|
|
28
21
|
import {
|
|
29
22
|
addCaseResult,
|
|
30
23
|
addEvidenceItemResult,
|
|
@@ -43,7 +36,6 @@ import {
|
|
|
43
36
|
type CoverageItem,
|
|
44
37
|
type CoverageScope,
|
|
45
38
|
countCases,
|
|
46
|
-
coverageSummary,
|
|
47
39
|
EVIDENCE_ROLE_VALUES,
|
|
48
40
|
type EvidenceItem,
|
|
49
41
|
type EvidenceRole,
|
|
@@ -54,8 +46,6 @@ import {
|
|
|
54
46
|
getCasefilePath,
|
|
55
47
|
LINK_KIND_VALUES,
|
|
56
48
|
linkCasesResult,
|
|
57
|
-
type MainAgentVerification,
|
|
58
|
-
type OobVerification,
|
|
59
49
|
type PendingConfirmation,
|
|
60
50
|
type PocEvidenceRun,
|
|
61
51
|
PRIORITY_VALUES,
|
|
@@ -69,27 +59,15 @@ import {
|
|
|
69
59
|
storePendingConfirmation,
|
|
70
60
|
unlinkCasesResult,
|
|
71
61
|
updateCaseResult,
|
|
62
|
+
writeCaseContext,
|
|
72
63
|
} from "./ledger.ts";
|
|
73
|
-
import { writeCaseContext } from "./ledger.ts";
|
|
74
|
-
import {
|
|
75
|
-
type OobOracleConfig,
|
|
76
|
-
type ProvisionedCallback,
|
|
77
|
-
provisionCallback,
|
|
78
|
-
readOobOracleConfig,
|
|
79
|
-
verifyOobDifferential,
|
|
80
|
-
} from "./oob-oracle.ts";
|
|
81
64
|
import { type PocRun, type PocRunOptions, runPoc } from "./poc-runner.ts";
|
|
82
65
|
import {
|
|
83
66
|
detectWorkspaceRoot,
|
|
84
67
|
SCRATCHPAD_PHASES,
|
|
85
68
|
type ScratchpadPhase,
|
|
86
|
-
type ScratchpadResume,
|
|
87
|
-
scratchpad_checkpoint,
|
|
88
69
|
scratchpad_clear,
|
|
89
|
-
scratchpad_init,
|
|
90
|
-
scratchpad_phase_done,
|
|
91
70
|
scratchpad_read,
|
|
92
|
-
scratchpad_resume,
|
|
93
71
|
scratchpad_write,
|
|
94
72
|
setScratchpadRoot,
|
|
95
73
|
} from "./scratchpad.ts";
|
|
@@ -144,6 +122,21 @@ const CommonFields = {
|
|
|
144
122
|
"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
123
|
}),
|
|
146
124
|
),
|
|
125
|
+
retry_policy: Type.Optional(
|
|
126
|
+
Type.Object(
|
|
127
|
+
{
|
|
128
|
+
max_attempts: Type.Number({
|
|
129
|
+
description: "Max attempts a phase may take for this case (integer 1–10)",
|
|
130
|
+
}),
|
|
131
|
+
fallback_models: Type.Optional(
|
|
132
|
+
Type.Array(Type.String(), {
|
|
133
|
+
description: "Fallback model identifiers to try when the primary model fails (≤8)",
|
|
134
|
+
}),
|
|
135
|
+
),
|
|
136
|
+
},
|
|
137
|
+
{ additionalProperties: false },
|
|
138
|
+
),
|
|
139
|
+
),
|
|
147
140
|
};
|
|
148
141
|
|
|
149
142
|
// ── Tool: CaseAdd ─────────────────────────────────────────────────────
|
|
@@ -190,15 +183,53 @@ const EvidenceAddSchema = Type.Object(
|
|
|
190
183
|
{ additionalProperties: false },
|
|
191
184
|
);
|
|
192
185
|
|
|
186
|
+
// ── Tool: CoverageAdd ─────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
const CoverageAddSchema = Type.Object(
|
|
189
|
+
{
|
|
190
|
+
case_id: Type.String({
|
|
191
|
+
description: "Case ID (the finding case or target's main case) to record coverage under",
|
|
192
|
+
}),
|
|
193
|
+
asset: Type.String({
|
|
194
|
+
description:
|
|
195
|
+
"The asset tested — copy it verbatim from the case target where shown. For scope=wide use the deployment-wide identifier.",
|
|
196
|
+
}),
|
|
197
|
+
class: Type.String({
|
|
198
|
+
description:
|
|
199
|
+
"The attack class tested (e.g. sql-injection, xss, idor, ssti, ssrf, auth-bypass, ...).",
|
|
200
|
+
}),
|
|
201
|
+
// Provider-safe string enum (per the header rule): Type.Union(Type.Literal)
|
|
202
|
+
// serializes as anyOf/const, which some providers drop — scope would
|
|
203
|
+
// arrive undefined and every explicit 'wide' verdict would silently
|
|
204
|
+
// persist as 'local', under-reporting tested classes.
|
|
205
|
+
scope: Type.String({
|
|
206
|
+
enum: [...COVERAGE_SCOPE_VALUES],
|
|
207
|
+
description:
|
|
208
|
+
"'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.",
|
|
209
|
+
}),
|
|
210
|
+
note: Type.String({
|
|
211
|
+
description: "Short note: techniques tried · result · key gap.",
|
|
212
|
+
}),
|
|
213
|
+
evidence_item_id: Type.Optional(
|
|
214
|
+
Type.String({
|
|
215
|
+
description:
|
|
216
|
+
"Optional artifact-backed evidence item (EvidenceAdd, on this case) backing the tested verdict. Cells without one render as unbacked.",
|
|
217
|
+
}),
|
|
218
|
+
),
|
|
219
|
+
},
|
|
220
|
+
{ additionalProperties: false },
|
|
221
|
+
);
|
|
222
|
+
|
|
193
223
|
// ── Tool: PromoteFinding (phase 1) / ConfirmFinding (phase 2) ──────────
|
|
194
224
|
//
|
|
195
225
|
// Confirmation is TWO-PHASE and main-agent-owned: PromoteFinding runs the PoC
|
|
196
|
-
//
|
|
197
|
-
//
|
|
226
|
+
// twice against the case target, validates nonce-bound evidence.json, replays
|
|
227
|
+
// the attack and baseline requests itself, and records the pending bundle;
|
|
228
|
+
// ConfirmFinding then performs the main coordinator's semantic review and
|
|
198
229
|
// commits or refuses the verdict. Subagents may gather or challenge evidence,
|
|
199
230
|
// but they cannot run validation or confirmation gates. Zero exit is necessary
|
|
200
|
-
// run integrity and markers are diagnostic only; the machine records
|
|
201
|
-
// predicate
|
|
231
|
+
// run integrity and markers are diagnostic only; the machine records the
|
|
232
|
+
// predicate differential and the main agent owns the semantic judgment.
|
|
202
233
|
|
|
203
234
|
const PromoteSchema = Type.Object(
|
|
204
235
|
{
|
|
@@ -206,38 +237,12 @@ const PromoteSchema = Type.Object(
|
|
|
206
237
|
poc_path: Type.String({
|
|
207
238
|
description: "Absolute path to the PoC script on disk",
|
|
208
239
|
}),
|
|
209
|
-
control_path: Type.Optional(
|
|
210
|
-
Type.String({
|
|
211
|
-
description:
|
|
212
|
-
"Optional absolute path to the SAME script as poc_path (sha256-equality is ENFORCED). Defaults to poc_path. The harness runs it with PI_POC_MODE=control and PI_POC_TARGET=control_target.",
|
|
213
|
-
}),
|
|
214
|
-
),
|
|
215
|
-
mode: Type.Optional(
|
|
216
|
-
Type.String({
|
|
217
|
-
enum: ["inter_host", "intra_target"],
|
|
218
|
-
description:
|
|
219
|
-
"Differential shape. 'inter_host' (default) proves target-dependence with a distinct patched control host — for body-carried proof (file read, injection exfil, info leak, reflection). 'intra_target' proves it with a legitimate same-host baseline request declared in the evidence — for access-control / business-logic classes (IDOR, auth bypass, privilege escalation, logic flaws) where the discriminating variable is identity or a parameter, not the host. In intra_target the evidence must set verify.mode='intra_target' and include a baseline; control_target/control_path are not used.",
|
|
220
|
-
}),
|
|
221
|
-
),
|
|
222
|
-
control_target: Type.Optional(
|
|
223
|
-
Type.String({
|
|
224
|
-
minLength: 1,
|
|
225
|
-
description:
|
|
226
|
-
"REQUIRED for mode='inter_host': a distinct baseline target that lacks the vulnerability and is operator-approved through PI_POC_CONTROL_TARGETS (patched replica, second account, baseline service). Not used for mode='intra_target'.",
|
|
227
|
-
}),
|
|
228
|
-
),
|
|
229
240
|
local: Type.Optional(
|
|
230
241
|
Type.Boolean({
|
|
231
242
|
description:
|
|
232
243
|
"Run with network access instead of --network none. Requires operator authorization via PI_POC_ALLOW_NETWORK=1. True host fallback additionally requires PI_POC_ALLOW_LOCAL=1.",
|
|
233
244
|
}),
|
|
234
245
|
),
|
|
235
|
-
oob: Type.Optional(
|
|
236
|
-
Type.Boolean({
|
|
237
|
-
description:
|
|
238
|
-
"Blind/OOB confirmation via the operator-run oracle (PI_OOB_ORACLE_URL). The harness provisions per-run callback tokens, injects PI_POC_CALLBACK_DOMAIN into the runs, and polls the oracle itself: promotion requires target-token interactions, ZERO control-token interactions, attested source separation (PI_OOB_SOURCE_SEPARATED=1), and self-source/missing-src_ip interactions are rejected. Without an oracle this fails closed.",
|
|
239
|
-
}),
|
|
240
|
-
),
|
|
241
246
|
},
|
|
242
247
|
{ additionalProperties: false },
|
|
243
248
|
);
|
|
@@ -257,12 +262,12 @@ const ConfirmSchema = Type.Object(
|
|
|
257
262
|
re_execution_note: Type.Optional(
|
|
258
263
|
Type.String({
|
|
259
264
|
description:
|
|
260
|
-
"What the main agent observed during review
|
|
265
|
+
"What the main agent observed during review of the runs and transcripts. Mandatory for CONFIRMED.",
|
|
261
266
|
}),
|
|
262
267
|
),
|
|
263
268
|
differential: Type.String({
|
|
264
269
|
enum: [...CONFIRM_DIFFERENTIAL_VALUES],
|
|
265
|
-
description: "
|
|
270
|
+
description: "Attack vs baseline evidence comparison. CONFIRMED requires target_only.",
|
|
266
271
|
}),
|
|
267
272
|
severity_match: Type.Optional(
|
|
268
273
|
Type.String({
|
|
@@ -276,19 +281,6 @@ const ConfirmSchema = Type.Object(
|
|
|
276
281
|
"The main agent's own failed attempt to disprove — becomes the case's disconfirmation",
|
|
277
282
|
}),
|
|
278
283
|
),
|
|
279
|
-
canary_assessment: Type.Optional(
|
|
280
|
-
Type.String({
|
|
281
|
-
enum: [...CANARY_ASSESSMENT_VALUES],
|
|
282
|
-
description:
|
|
283
|
-
"verified when the replay carried a harness-generated reflection canary; otherwise not_applicable with a concrete reason",
|
|
284
|
-
}),
|
|
285
|
-
),
|
|
286
|
-
canary_reason: Type.Optional(
|
|
287
|
-
Type.String({
|
|
288
|
-
description:
|
|
289
|
-
"Why a causal reflection canary is not meaningful for this exploit class. Required when canary_assessment=not_applicable.",
|
|
290
|
-
}),
|
|
291
|
-
),
|
|
292
284
|
model: Type.Optional(
|
|
293
285
|
Type.String({ description: "Which model judged (recorded for the accuracy ledger)" }),
|
|
294
286
|
),
|
|
@@ -385,7 +377,7 @@ const ScratchpadPhaseSchema = Type.String({
|
|
|
385
377
|
"Pipeline phase: recon | hunt | trace | skeptic | validate | chain | patch | report (legacy gapfil is accepted for older runs)",
|
|
386
378
|
});
|
|
387
379
|
|
|
388
|
-
/** run_id-only schema, shared by Scratchpad
|
|
380
|
+
/** run_id-only schema, shared by Scratchpad tools. */
|
|
389
381
|
const RunIdSchema = Type.Object(
|
|
390
382
|
{
|
|
391
383
|
run_id: Type.String({ description: "Pipeline run identifier" }),
|
|
@@ -393,20 +385,6 @@ const RunIdSchema = Type.Object(
|
|
|
393
385
|
{ additionalProperties: false },
|
|
394
386
|
);
|
|
395
387
|
|
|
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
388
|
const ScratchpadWriteSchema = Type.Object(
|
|
411
389
|
{
|
|
412
390
|
run_id: Type.String({ description: "Run identifier" }),
|
|
@@ -428,14 +406,6 @@ const ScratchpadReadSchema = Type.Object(
|
|
|
428
406
|
{ additionalProperties: false },
|
|
429
407
|
);
|
|
430
408
|
|
|
431
|
-
const ScratchpadPhaseDoneSchema = Type.Object(
|
|
432
|
-
{
|
|
433
|
-
run_id: Type.String({ description: "Run identifier" }),
|
|
434
|
-
phase: ScratchpadPhaseSchema,
|
|
435
|
-
},
|
|
436
|
-
{ additionalProperties: false },
|
|
437
|
-
);
|
|
438
|
-
|
|
439
409
|
interface Theme {
|
|
440
410
|
fg(color: string, text: string): string;
|
|
441
411
|
bold(text: string): string;
|
|
@@ -733,7 +703,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
733
703
|
// already-loaded extension or reveal a tool that was omitted at startup.
|
|
734
704
|
const startedAsSubagent = process.env.PI_SUBAGENT_CHILD === "1";
|
|
735
705
|
const isSubagentProcess = () => startedAsSubagent || process.env.PI_SUBAGENT_CHILD === "1";
|
|
736
|
-
// Pin the workspace root ONCE at extension load. Every scratchpad
|
|
706
|
+
// Pin the workspace root ONCE at extension load. Every scratchpad
|
|
737
707
|
// / PoC-path lookup otherwise re-walks the ambient cwd on each call — a
|
|
738
708
|
// mid-session `cd` would split state across two .scratchpad roots and
|
|
739
709
|
// misroot the hunt file-existence filter. The PoC runner reads PI_POC_ROOT
|
|
@@ -790,7 +760,13 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
790
760
|
parameters: AddSchema,
|
|
791
761
|
|
|
792
762
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
793
|
-
const
|
|
763
|
+
const { retry_policy, ...rest } = params as Record<string, unknown>;
|
|
764
|
+
const result = addCaseResult({
|
|
765
|
+
...(rest as CaseInput),
|
|
766
|
+
...(retry_policy !== undefined
|
|
767
|
+
? { retryPolicy: retry_policy as CaseInput["retryPolicy"] }
|
|
768
|
+
: {}),
|
|
769
|
+
});
|
|
794
770
|
const record = result.record;
|
|
795
771
|
return {
|
|
796
772
|
content: [
|
|
@@ -842,8 +818,14 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
842
818
|
parameters: UpdateSchema,
|
|
843
819
|
|
|
844
820
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
845
|
-
const { id, ...
|
|
846
|
-
const
|
|
821
|
+
const { id, retry_policy, ...rest } = params as Record<string, unknown>;
|
|
822
|
+
const update = {
|
|
823
|
+
...(rest as CaseUpdate),
|
|
824
|
+
...(retry_policy !== undefined
|
|
825
|
+
? { retryPolicy: retry_policy as CaseUpdate["retryPolicy"] }
|
|
826
|
+
: {}),
|
|
827
|
+
};
|
|
828
|
+
const result = updateCaseResult(id as string, update);
|
|
847
829
|
const record = result.record;
|
|
848
830
|
return {
|
|
849
831
|
content: [
|
|
@@ -908,7 +890,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
908
890
|
content: [
|
|
909
891
|
{
|
|
910
892
|
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)}`,
|
|
893
|
+
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
894
|
},
|
|
913
895
|
],
|
|
914
896
|
details: { item, record },
|
|
@@ -938,158 +920,6 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
938
920
|
},
|
|
939
921
|
});
|
|
940
922
|
|
|
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
923
|
// ── Tool: PromoteFinding (phase 1) ──
|
|
1094
924
|
|
|
1095
925
|
if (!startedAsSubagent)
|
|
@@ -1097,19 +927,15 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1097
927
|
name: "PromoteFinding",
|
|
1098
928
|
label: "Run PoC Evidence",
|
|
1099
929
|
description:
|
|
1100
|
-
"Main-agent phase 1 of confirmation: run the same PoC twice against the case target
|
|
1101
|
-
promptSnippet:
|
|
1102
|
-
"Phase 1: run PoC evidence (target x2 + control) and record the pending bundle",
|
|
930
|
+
"Main-agent phase 1 of confirmation: run the same PoC twice against the case target, validate nonce-bound evidence.json with a response-body assertion, then have the harness replay the attack request and a legitimate same-host baseline request itself. The machine records a predicate differential (attack matched, baseline did not); that is not automatically a vulnerability verdict. Exit 0 is necessary run integrity, never proof. Networked execution and private replay are operator-gated. Records a pending bundle for main-agent semantic review via ConfirmFinding. Worker/subagent processes are rejected.",
|
|
931
|
+
promptSnippet: "Phase 1: run PoC evidence (target x2) and record the pending bundle",
|
|
1103
932
|
promptGuidelines: [
|
|
1104
933
|
"Use PromoteFinding only from the main/coordinator agent when an investigating case has a concrete PoC script on disk and you are ready to subject its claim to the machine gate.",
|
|
1105
934
|
"Prerequisites: status='investigating' and non-empty poc, evidence, impact, severity, target, plus an artifact-backed EvidenceAdd 'observation' item on the case (the initial signal, with artifact_path). The final disconfirmation comes from the main agent at confirm time.",
|
|
1106
|
-
"The PoC MUST write evidence.json to $PI_POC_EVIDENCE_DIR: { nonce (echo $PI_POC_NONCE), claim, verify: { method, url, expect: { status?, body_contains/body_regex } }, observations }. A non-empty body predicate is mandatory; status-only evidence is rejected. verify.url must belong to the case target.",
|
|
1107
|
-
"
|
|
1108
|
-
"control_path is optional and defaults to poc_path; if supplied, it must be the SAME script as poc_path. control_target must be pre-approved by the operator in PI_POC_CONTROL_TARGETS. The harness derives the control request from the target request, changes only its origin, and applies the same predicates to two conclusive responses.",
|
|
1109
|
-
"oob=true unlocks blind/OOB classes (SSRF, blind XSS, XXE): the harness provisions per-run callback tokens via the operator's oracle (PI_OOB_ORACLE_URL), injects PI_POC_CALLBACK_DOMAIN into the runs, and polls the oracle itself — target-token interactions with ZERO control-token interactions are required. Promotion additionally requires attested source separation (PI_OOB_SOURCE_SEPARATED=1); without it the verification stays diagnostic.",
|
|
935
|
+
"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, baseline }. A non-empty body predicate is mandatory; status-only evidence is rejected. verify.url and baseline.url must belong to the case target.",
|
|
936
|
+
"baseline is a legitimate same-host request whose response must NOT satisfy the attack predicate — your own account's resource for IDOR, the request without the payload for injection. It must differ from the attack request (identity or a parameter, not just whitespace).",
|
|
1110
937
|
"local:true requires PI_POC_ALLOW_NETWORK=1. Private/internal harness replay additionally requires PI_POC_ALLOW_PRIVATE_REPLAY=1. Neither silently falls back to a model verdict.",
|
|
1111
|
-
"
|
|
1112
|
-
"After the bundle is recorded, stay in the main agent: inspect the script/evidence, attempt disconfirmation, and call ConfirmFinding itself; that call performs a fresh harness-owned target/control replay. Never delegate validation/confirmation and never CaseUpdate status='confirmed' directly.",
|
|
938
|
+
"After the bundle is recorded, stay in the main agent: inspect the script/evidence, attempt disconfirmation, and call ConfirmFinding yourself. Never delegate validation/confirmation and never CaseUpdate status='confirmed' directly.",
|
|
1113
939
|
],
|
|
1114
940
|
parameters: PromoteSchema,
|
|
1115
941
|
|
|
@@ -1126,167 +952,65 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1126
952
|
const caseId = params.id as string;
|
|
1127
953
|
const current = assertPromotable(caseId);
|
|
1128
954
|
|
|
1129
|
-
const fail = (text: string
|
|
955
|
+
const fail = (text: string): never => {
|
|
1130
956
|
throw new Error(text);
|
|
1131
957
|
};
|
|
1132
958
|
|
|
1133
959
|
const pocPath = (params.poc_path as string | undefined)?.trim() ?? "";
|
|
1134
|
-
const controlPath = (params.control_path as string | undefined)?.trim() || pocPath;
|
|
1135
|
-
const controlTarget = (params.control_target as string | undefined)?.trim() ?? "";
|
|
1136
|
-
const mode: "inter_host" | "intra_target" =
|
|
1137
|
-
(params.mode as string | undefined) === "intra_target" ? "intra_target" : "inter_host";
|
|
1138
|
-
const isIntra = mode === "intra_target";
|
|
1139
960
|
if (!pocPath) {
|
|
1140
|
-
return fail("poc_path is REQUIRED: absolute path to the PoC script run by the harness."
|
|
1141
|
-
missingPocPath: true,
|
|
1142
|
-
});
|
|
961
|
+
return fail("poc_path is REQUIRED: absolute path to the PoC script run by the harness.");
|
|
1143
962
|
}
|
|
1144
963
|
const caseTarget = current.target ?? "";
|
|
1145
|
-
// ── OOB callback (Tier 1, opt-in for blind classes) ──
|
|
1146
|
-
// The operator-run oracle owns the evidence channel; the harness owns
|
|
1147
|
-
// the secret (per-run token, provisioned before the runs and injected
|
|
1148
|
-
// as env — the value does not exist when the script was written).
|
|
1149
|
-
// Without an oracle this stays fail-closed.
|
|
1150
|
-
const oobRequested = params.oob === true;
|
|
1151
|
-
// Intra-target + OOB is rejected up front: intra_target's
|
|
1152
|
-
// discriminating variable is identity/parameter on the SAME host;
|
|
1153
|
-
// mixing it with a callback differential would make precedence
|
|
1154
|
-
// ambiguous. OOB is for inter-host/blind classes.
|
|
1155
|
-
if (isIntra && oobRequested) {
|
|
1156
|
-
return fail(
|
|
1157
|
-
"mode:'intra_target' cannot be combined with oob:true — intra-target proof uses a same-host baseline request, not a callback channel. Use one or the other.",
|
|
1158
|
-
{ intraOobConflict: true },
|
|
1159
|
-
);
|
|
1160
|
-
}
|
|
1161
|
-
let oobConfig: OobOracleConfig | undefined;
|
|
1162
|
-
let targetCallback: ProvisionedCallback | undefined;
|
|
1163
|
-
let controlCallback: ProvisionedCallback | undefined;
|
|
1164
|
-
if (oobRequested) {
|
|
1165
|
-
const oracle = readOobOracleConfig();
|
|
1166
|
-
if (!oracle.config) {
|
|
1167
|
-
return fail(`OOB CONFIRMATION UNAVAILABLE: ${oracle.error}`, {
|
|
1168
|
-
oobOracleNotConfigured: true,
|
|
1169
|
-
});
|
|
1170
|
-
}
|
|
1171
|
-
oobConfig = oracle.config;
|
|
1172
|
-
// Provision both identities concurrently — each is an oracle round trip.
|
|
1173
|
-
[targetCallback, controlCallback] = await Promise.all([
|
|
1174
|
-
provisionCallback(oobConfig),
|
|
1175
|
-
provisionCallback(oobConfig),
|
|
1176
|
-
]);
|
|
1177
|
-
}
|
|
1178
|
-
// OOB-only bundles (blind classes, no operator-approved control host)
|
|
1179
|
-
// prove target-dependence via the token differential instead.
|
|
1180
|
-
const oobOnly = oobRequested && !controlTarget;
|
|
1181
|
-
if (!isIntra && !oobOnly) {
|
|
1182
|
-
if (!controlTarget) {
|
|
1183
|
-
return fail(
|
|
1184
|
-
"control_target is REQUIRED for inter-host mode: a distinct baseline target that lacks the vulnerability. For access-control/logic bugs use mode='intra_target' with an evidence baseline instead; for blind/OOB classes pass oob=true (with or without a control target).",
|
|
1185
|
-
{ missingControlTarget: true },
|
|
1186
|
-
);
|
|
1187
|
-
}
|
|
1188
|
-
if (controlTarget === current.target) {
|
|
1189
|
-
return fail(
|
|
1190
|
-
"control_target must differ from the case target; a control run against the vulnerable target proves nothing.",
|
|
1191
|
-
{ controlTargetEqualsCaseTarget: true },
|
|
1192
|
-
);
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1195
964
|
if (params.local === true && process.env.PI_POC_ALLOW_NETWORK !== "1") {
|
|
1196
965
|
return fail(
|
|
1197
966
|
"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
967
|
);
|
|
1200
968
|
}
|
|
1201
|
-
if (!isIntra && !oobOnly) {
|
|
1202
|
-
const controlAuthorization = controlTargetAuthorizationError(controlTarget);
|
|
1203
|
-
if (controlAuthorization) {
|
|
1204
|
-
return fail(
|
|
1205
|
-
`CONTROL AUTHORIZATION FAILED: ${controlAuthorization}. ` +
|
|
1206
|
-
"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
|
-
);
|
|
1209
|
-
}
|
|
1210
|
-
}
|
|
1211
969
|
|
|
1212
|
-
// Anti-cheat: hash the PoC
|
|
1213
|
-
//
|
|
970
|
+
// Anti-cheat: hash the PoC so the recorded bundle is bound to the exact
|
|
971
|
+
// bytes that ran (re-verified at confirm time).
|
|
1214
972
|
let pocHash: string | undefined;
|
|
1215
973
|
try {
|
|
1216
974
|
pocHash = createHash("sha256").update(readFileSync(pocPath)).digest("hex");
|
|
1217
975
|
} catch (e) {
|
|
1218
|
-
return fail(`Cannot read PoC script: ${(e as Error).message}
|
|
1219
|
-
sameFileCheckFailed: true,
|
|
1220
|
-
});
|
|
1221
|
-
}
|
|
1222
|
-
if (!isIntra && !oobOnly) {
|
|
1223
|
-
let controlHash: string | undefined;
|
|
1224
|
-
try {
|
|
1225
|
-
controlHash = createHash("sha256").update(readFileSync(controlPath)).digest("hex");
|
|
1226
|
-
} catch (e) {
|
|
1227
|
-
return fail(
|
|
1228
|
-
`Cannot read control script for the same-file check: ${(e as Error).message}`,
|
|
1229
|
-
{ sameFileCheckFailed: true },
|
|
1230
|
-
);
|
|
1231
|
-
}
|
|
1232
|
-
if (pocHash !== controlHash) {
|
|
1233
|
-
return fail(
|
|
1234
|
-
"CONTROL CHECK FAILED: control_path must be the SAME script as poc_path (sha256 mismatch). Case remains investigating.",
|
|
1235
|
-
{ controlHashMismatch: true },
|
|
1236
|
-
);
|
|
1237
|
-
}
|
|
976
|
+
return fail(`Cannot read PoC script: ${(e as Error).message}`);
|
|
1238
977
|
}
|
|
1239
978
|
|
|
1240
|
-
|
|
1241
|
-
const runOptions = (pocMode: string, target: string): PocRunOptions => ({
|
|
979
|
+
const runOptions = (target: string): PocRunOptions => ({
|
|
1242
980
|
network: params.local === true ? "host" : "none",
|
|
1243
981
|
local: params.local === true,
|
|
1244
982
|
env: {
|
|
1245
|
-
PI_POC_MODE:
|
|
983
|
+
PI_POC_MODE: "poc",
|
|
1246
984
|
PI_POC_TARGET: target,
|
|
1247
|
-
...(oobRequested && targetCallback && controlCallback
|
|
1248
|
-
? {
|
|
1249
|
-
PI_POC_CALLBACK_DOMAIN:
|
|
1250
|
-
pocMode === "control" ? controlCallback.domain : targetCallback.domain,
|
|
1251
|
-
}
|
|
1252
|
-
: {}),
|
|
1253
985
|
},
|
|
1254
986
|
});
|
|
1255
987
|
|
|
1256
988
|
// Determinism: TWO target runs. Exit 0 is run integrity only; nonce-bound
|
|
1257
|
-
// body evidence plus the harness-owned
|
|
1258
|
-
//
|
|
1259
|
-
const run1 = runPoc(pocPath, runOptions(
|
|
1260
|
-
const run2 = runPoc(pocPath, runOptions(
|
|
1261
|
-
|
|
1262
|
-
const evidenceRun = (
|
|
1263
|
-
r: PocRun,
|
|
1264
|
-
mode: "poc" | "control",
|
|
1265
|
-
target: string,
|
|
1266
|
-
): PocEvidenceRun => {
|
|
989
|
+
// body evidence plus the harness-owned attack/baseline replay form the
|
|
990
|
+
// machine gate.
|
|
991
|
+
const run1 = runPoc(pocPath, runOptions(caseTarget));
|
|
992
|
+
const run2 = runPoc(pocPath, runOptions(caseTarget));
|
|
993
|
+
|
|
994
|
+
const evidenceRun = (r: PocRun, target: string): PocEvidenceRun => {
|
|
1267
995
|
if (!r.completed || !r.outputComplete) {
|
|
1268
996
|
return fail(
|
|
1269
|
-
|
|
997
|
+
`PoC run did not complete or output capture was incomplete` +
|
|
1270
998
|
(r.infraError ? ` (infra: ${r.output.trim()})` : "") +
|
|
1271
999
|
". A crash is not evidence. Case remains investigating.",
|
|
1272
|
-
{ run: r, pocCrashed: true },
|
|
1273
1000
|
);
|
|
1274
1001
|
}
|
|
1275
1002
|
if (r.evidenceError) {
|
|
1276
1003
|
return fail(
|
|
1277
|
-
`EVIDENCE CONTRACT FAILED
|
|
1278
|
-
"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 — " +
|
|
1004
|
+
`EVIDENCE CONTRACT FAILED: ${r.evidenceError}. ` +
|
|
1005
|
+
"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, baseline }; a response-body assertion is mandatory — " +
|
|
1279
1006
|
"the file is bound to this run and validated by the harness. Case remains investigating.",
|
|
1280
|
-
{ run: r, evidenceError: r.evidenceError },
|
|
1281
1007
|
);
|
|
1282
1008
|
}
|
|
1283
1009
|
if (!r.evidence || !r.evidenceSha256 || !r.nonce) {
|
|
1284
|
-
return fail(
|
|
1285
|
-
run: r,
|
|
1286
|
-
});
|
|
1010
|
+
return fail("PoC run produced no evidence. Case remains investigating.");
|
|
1287
1011
|
}
|
|
1288
1012
|
return {
|
|
1289
|
-
mode,
|
|
1013
|
+
mode: "poc",
|
|
1290
1014
|
target,
|
|
1291
1015
|
nonce: r.nonce,
|
|
1292
1016
|
ranAt: r.ranAt,
|
|
@@ -1302,100 +1026,33 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1302
1026
|
};
|
|
1303
1027
|
|
|
1304
1028
|
const targetRuns: [PocEvidenceRun, PocEvidenceRun] = [
|
|
1305
|
-
evidenceRun(run1,
|
|
1306
|
-
evidenceRun(run2,
|
|
1029
|
+
evidenceRun(run1, caseTarget),
|
|
1030
|
+
evidenceRun(run2, caseTarget),
|
|
1307
1031
|
];
|
|
1308
1032
|
|
|
1309
|
-
// Reflection canary + OOB is rejected after run 1 (the canary is
|
|
1310
|
-
// declared inside evidence.json): the canary path requires a harness
|
|
1311
|
-
// response transcript, which OOB-only bundles never produce — the
|
|
1312
|
-
// per-run callback token IS the causality signal there.
|
|
1313
|
-
if (oobRequested && targetRuns.some((r) => r.evidence.verify.canary !== undefined)) {
|
|
1314
|
-
return fail(
|
|
1315
|
-
"verify.canary cannot be combined with oob:true — the per-run callback token already provides a harness-owned causality signal. Remove the {{PI_POC_CANARY}} placeholder and verify.canary from evidence.json, then re-promote.",
|
|
1316
|
-
{ canaryOobConflict: true },
|
|
1317
|
-
);
|
|
1318
|
-
}
|
|
1319
1033
|
const allowPrivateReplay = process.env.PI_POC_ALLOW_PRIVATE_REPLAY === "1";
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
return fail(
|
|
1329
|
-
"INTRA-TARGET FAILED: the PoC's evidence.json must set verify.mode='intra_target' when promoting in intra-target mode.",
|
|
1330
|
-
{ intraModeMismatch: true },
|
|
1331
|
-
);
|
|
1332
|
-
}
|
|
1333
|
-
if (!ev0.baseline) {
|
|
1334
|
-
return fail(
|
|
1335
|
-
"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
|
-
);
|
|
1338
|
-
}
|
|
1339
|
-
harnessVerified = await replayIntraTarget(ev0, caseTarget, {
|
|
1340
|
-
allowPrivate: allowPrivateReplay,
|
|
1341
|
-
});
|
|
1342
|
-
} else if (!oobOnly) {
|
|
1343
|
-
// Inter-host (Tier 2): the harness executes the SAME request template
|
|
1344
|
-
// against target and operator-approved control, applying the target's
|
|
1345
|
-
// predicates to both. DNS is pinned at connect time.
|
|
1346
|
-
controlRun = evidenceRun(
|
|
1347
|
-
runPoc(controlPath, runOptions("control", controlTarget)),
|
|
1348
|
-
"control",
|
|
1349
|
-
controlTarget,
|
|
1350
|
-
);
|
|
1351
|
-
harnessVerified = await replayDifferential(
|
|
1352
|
-
targetRuns[0].evidence,
|
|
1353
|
-
caseTarget,
|
|
1354
|
-
controlTarget,
|
|
1355
|
-
{ allowPrivate: allowPrivateReplay },
|
|
1356
|
-
);
|
|
1357
|
-
}
|
|
1358
|
-
// OOB differential: poll the oracle for both run tokens. The ledger's
|
|
1359
|
-
// assertMachineConfirmation consumes this BEFORE the response-diff
|
|
1360
|
-
// requirement — blind classes pass via this path when the oracle saw
|
|
1361
|
-
// the target token and NOT the control token under attested source
|
|
1362
|
-
// separation.
|
|
1363
|
-
let callbackVerified: OobVerification | undefined;
|
|
1364
|
-
if (oobConfig && targetCallback && controlCallback) {
|
|
1365
|
-
callbackVerified = (
|
|
1366
|
-
await verifyOobDifferential({
|
|
1367
|
-
targetToken: targetCallback.token,
|
|
1368
|
-
controlToken: controlCallback.token,
|
|
1369
|
-
})
|
|
1370
|
-
).verification;
|
|
1371
|
-
}
|
|
1034
|
+
// Intra-target differential: prove target-dependence with the evidence's
|
|
1035
|
+
// same-host baseline request. The harness sends attack + baseline to the
|
|
1036
|
+
// case target and requires the proof on attack only.
|
|
1037
|
+
const harnessVerified: HarnessVerifyResult = await replayIntraTarget(
|
|
1038
|
+
targetRuns[0].evidence,
|
|
1039
|
+
caseTarget,
|
|
1040
|
+
{ allowPrivate: allowPrivateReplay },
|
|
1041
|
+
);
|
|
1372
1042
|
const bundle: PendingConfirmation = {
|
|
1373
1043
|
caseId,
|
|
1374
1044
|
ranAt: new Date().toISOString(),
|
|
1375
1045
|
pocPath,
|
|
1376
1046
|
pocSha256: pocHash,
|
|
1377
|
-
mode,
|
|
1378
1047
|
targetRuns,
|
|
1379
1048
|
harnessVerified,
|
|
1380
|
-
...(callbackVerified && targetCallback && controlCallback
|
|
1381
|
-
? {
|
|
1382
|
-
callbackVerified,
|
|
1383
|
-
oobTokens: {
|
|
1384
|
-
targetToken: targetCallback.token,
|
|
1385
|
-
controlToken: controlCallback.token,
|
|
1386
|
-
},
|
|
1387
|
-
}
|
|
1388
|
-
: {}),
|
|
1389
|
-
...(!(isIntra || oobOnly) ? { controlPath, controlTarget, controlRun } : {}),
|
|
1390
1049
|
};
|
|
1391
1050
|
|
|
1392
1051
|
let record: CaseRecord;
|
|
1393
1052
|
try {
|
|
1394
1053
|
record = storePendingConfirmation(caseId, bundle);
|
|
1395
1054
|
} catch (e) {
|
|
1396
|
-
return fail(`Pending confirmation rejected: ${(e as Error).message}
|
|
1397
|
-
storeRejected: true,
|
|
1398
|
-
});
|
|
1055
|
+
return fail(`Pending confirmation rejected: ${(e as Error).message}`);
|
|
1399
1056
|
}
|
|
1400
1057
|
|
|
1401
1058
|
return {
|
|
@@ -1404,15 +1061,12 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1404
1061
|
type: "text",
|
|
1405
1062
|
text:
|
|
1406
1063
|
`Phase 1 complete — evidence bundle recorded on ${caseId} (expires in 1h).\n` +
|
|
1407
|
-
`
|
|
1064
|
+
`Target runs: 2, same-host baseline differential — all with validated nonce-bound evidence.json.\n` +
|
|
1408
1065
|
`Evidence sha256: ${targetRuns[0].evidenceSha256}\n` +
|
|
1409
1066
|
`PoC script sha256 (at run time): ${pocHash}\n` +
|
|
1410
1067
|
`Harness verify replay: ${harnessVerified?.attempted ? (harnessVerified.pass ? `PASS (status ${harnessVerified.status})` : `FAILED — ${harnessVerified.note}`) : (harnessVerified?.note ?? "not run")}
|
|
1411
1068
|
` +
|
|
1412
|
-
(
|
|
1413
|
-
? `OOB oracle: target-token hits ${callbackVerified.targetHits}, control-token hits ${callbackVerified.controlHits}, source-separated: ${String(callbackVerified.sourceSeparated)} — ${callbackVerified.note}\n`
|
|
1414
|
-
: "") +
|
|
1415
|
-
`\nMAIN-AGENT REVIEW REQUIRED (do not delegate): inspect case ${caseId}, PoC ${pocPath}, ${isIntra ? "same-host baseline" : `control ${controlTarget}`}, evidence ${targetRuns[0].evidenceSha256}, and PoC hash ${pocHash}. Hunt for a trivial predicate or fabricated differential and perform a concrete disconfirmation attempt, then call ConfirmFinding yourself. A CONFIRMED call performs and stores a fresh harness-owned ${isIntra ? "attack/baseline" : "target/control"} replay; NOT_CONFIRMED keeps the case investigating.`,
|
|
1069
|
+
`\nMAIN-AGENT REVIEW REQUIRED (do not delegate): inspect case ${caseId}, PoC ${pocPath}, the same-host baseline, evidence ${targetRuns[0].evidenceSha256}, and PoC hash ${pocHash}. Hunt for a trivial predicate or fabricated differential and perform a concrete disconfirmation attempt, then call ConfirmFinding yourself. NOT_CONFIRMED keeps the case investigating.`,
|
|
1416
1070
|
},
|
|
1417
1071
|
],
|
|
1418
1072
|
details: {
|
|
@@ -1420,10 +1074,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1420
1074
|
bundle: {
|
|
1421
1075
|
caseId,
|
|
1422
1076
|
ranAt: bundle.ranAt,
|
|
1423
|
-
mode,
|
|
1424
1077
|
pocPath,
|
|
1425
|
-
controlPath: isIntra ? undefined : controlPath,
|
|
1426
|
-
controlTarget: isIntra ? undefined : controlTarget,
|
|
1427
1078
|
pocSha256: pocHash,
|
|
1428
1079
|
evidenceSha256: targetRuns[0].evidenceSha256,
|
|
1429
1080
|
harnessVerified,
|
|
@@ -1461,15 +1112,15 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1461
1112
|
name: "ConfirmFinding",
|
|
1462
1113
|
label: "Main-Agent Confirmation",
|
|
1463
1114
|
description:
|
|
1464
|
-
"Phase 2 of confirmation, reserved for the main/coordinator agent: commit or refuse promotion after independently
|
|
1115
|
+
"Phase 2 of confirmation, reserved for the main/coordinator agent: commit or refuse promotion after independently reviewing the recorded evidence. The verdict requires a target-only differential, a concrete re_execution_note and disconfirmation_attempt, and the still-valid PromoteFinding bundle. The machine floor is the promote-time harness replay plus confirm-time bundle re-validation — there is no fresh network replay at verdict time, so weigh bundle age (1h TTL) in your review. The machine transcript is evidence, not the semantic vulnerability verdict. Worker/subagent processes are rejected. Three verdicts: CONFIRMED (you reproduced real impact), NOT_CONFIRMED (you POSITIVELY disproved it), INCONCLUSIVE (you could neither reproduce nor disprove — the case is preserved for manual review, never dropped).",
|
|
1465
1116
|
promptSnippet: "Main agent: independently re-test, then commit or refuse PoC confirmation",
|
|
1466
1117
|
promptGuidelines: [
|
|
1467
1118
|
"Run only in the main/coordinator agent after PromoteFinding returns. Do not dispatch a worker to decide or author this verdict.",
|
|
1468
|
-
"Verify with DISBELIEF: assume the finding is a false positive until
|
|
1119
|
+
"Verify with DISBELIEF: assume the finding is a false positive until the recorded evidence proves otherwise. Read the PoC script, both run transcripts, the attack/baseline replay, and the evidence artifacts (not the hunter's narrative) — a difference you cannot tie to the baseline is not proof.",
|
|
1469
1120
|
"Provenance: the proof must exercise THIS finding's own mechanism. Evidence obtained through a DIFFERENT bug (e.g. 'SQLi' proven by dumping the DB via an RCE) does not confirm it — that is INCONCLUSIVE at best.",
|
|
1470
1121
|
"Kill the cheapest benign explanation: is this the technology's intended behavior? Did the attacker supply the 'secret' themselves (circular)? Is the claimed C/I/A impact actually demonstrated?",
|
|
1471
1122
|
"Want a second pair of eyes? Dispatch a read-only skeptic subagent to re-test — it CANNOT confirm (only the main agent commits). You review its verdict and commit it here.",
|
|
1472
|
-
"CONFIRMED requires differential: 'target_only', re_execution_note, and disconfirmation_attempt (your failed disproof). A verdict missing any of these is rejected.
|
|
1123
|
+
"CONFIRMED requires differential: 'target_only', re_execution_note, and disconfirmation_attempt (your failed disproof). A verdict missing any of these is rejected.",
|
|
1473
1124
|
"NOT_CONFIRMED means you POSITIVELY disproved it (by-design, circular, mislabeled, no impact). Never mark NOT_CONFIRMED merely because you could not reproduce it.",
|
|
1474
1125
|
"INCONCLUSIVE when you could neither reproduce nor disprove (needs auth, a second account, specific state, timing, or a blind/stored trigger you cannot observe). The case stays investigating and is preserved for manual review — dropping a real finding is worse than keeping an unproven one.",
|
|
1475
1126
|
"Every verdict consumes the attempt: a fresh PromoteFinding run is required to try again. Never CaseUpdate status='confirmed' directly — always PromoteFinding + ConfirmFinding.",
|
|
@@ -1487,83 +1138,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1487
1138
|
if (!parsedVerdict.ok) {
|
|
1488
1139
|
throw new Error(`Invalid main-agent confirmation verdict: ${parsedVerdict.error}`);
|
|
1489
1140
|
}
|
|
1490
|
-
|
|
1491
|
-
if (parsedVerdict.verdict.verdict === "CONFIRMED") {
|
|
1492
|
-
const current = getCaseById(caseId);
|
|
1493
|
-
if (!current) throw new Error(`Case not found: ${caseId}`);
|
|
1494
|
-
const bundle = current.pendingConfirmation;
|
|
1495
|
-
if (!bundle) {
|
|
1496
|
-
throw new Error("No pending confirmation on this case — run PromoteFinding first");
|
|
1497
|
-
}
|
|
1498
|
-
const allowPrivate = process.env.PI_POC_ALLOW_PRIVATE_REPLAY === "1";
|
|
1499
|
-
const caseTargetForReplay = current.target ?? bundle.targetRuns[0].target;
|
|
1500
|
-
let replay: HarnessVerifyResult;
|
|
1501
|
-
if (bundle.mode === "intra_target") {
|
|
1502
|
-
// Same-host attack-vs-baseline replay; no control target to authorize.
|
|
1503
|
-
replay = await replayIntraTarget(bundle.targetRuns[0].evidence, caseTargetForReplay, {
|
|
1504
|
-
allowPrivate,
|
|
1505
|
-
});
|
|
1506
|
-
} else if (bundle.callbackVerified?.attempted && bundle.oobTokens) {
|
|
1507
|
-
// OOB differential: fresh harness-owned re-poll of BOTH run tokens.
|
|
1508
|
-
// Re-polling at confirm time catches interactions that landed after
|
|
1509
|
-
// phase 1 (e.g. a delayed control-token hit) — the verdict is bound
|
|
1510
|
-
// to this fresh observation, not the stored one.
|
|
1511
|
-
const { verification } = await verifyOobDifferential({
|
|
1512
|
-
targetToken: bundle.oobTokens.targetToken,
|
|
1513
|
-
controlToken: bundle.oobTokens.controlToken,
|
|
1514
|
-
});
|
|
1515
|
-
const oobPass =
|
|
1516
|
-
verification.targetHits > 0 &&
|
|
1517
|
-
verification.controlHits === 0 &&
|
|
1518
|
-
verification.sourceSeparated === true;
|
|
1519
|
-
replay = {
|
|
1520
|
-
attempted: true,
|
|
1521
|
-
pass: oobPass,
|
|
1522
|
-
target: {
|
|
1523
|
-
attempted: true,
|
|
1524
|
-
matched: verification.targetHits > 0,
|
|
1525
|
-
url: bundle.targetRuns[0].evidence.verify.url,
|
|
1526
|
-
note: verification.note,
|
|
1527
|
-
},
|
|
1528
|
-
control: {
|
|
1529
|
-
attempted: true,
|
|
1530
|
-
matched: verification.controlHits > 0,
|
|
1531
|
-
url: bundle.targetRuns[0].evidence.verify.url,
|
|
1532
|
-
note: `${verification.controlHits} control-token interaction(s)`,
|
|
1533
|
-
},
|
|
1534
|
-
differential:
|
|
1535
|
-
verification.targetHits > 0
|
|
1536
|
-
? verification.controlHits === 0
|
|
1537
|
-
? "target_only"
|
|
1538
|
-
: "both"
|
|
1539
|
-
: "neither",
|
|
1540
|
-
note: `harness OOB re-poll: ${verification.note}`,
|
|
1541
|
-
};
|
|
1542
|
-
} else if (bundle.callbackVerified?.attempted) {
|
|
1543
|
-
throw new Error(
|
|
1544
|
-
"OOB bundle lacks its provisioned tokens (pre-token-storage ledger) — re-run PromoteFinding for a fresh bundle",
|
|
1545
|
-
);
|
|
1546
|
-
} else {
|
|
1547
|
-
if (!bundle.controlTarget) {
|
|
1548
|
-
throw new Error("inter-host confirmation requires a control target");
|
|
1549
|
-
}
|
|
1550
|
-
const controlAuthorizationError = controlTargetAuthorizationError(bundle.controlTarget);
|
|
1551
|
-
if (controlAuthorizationError) {
|
|
1552
|
-
throw new Error(`CONTROL AUTHORIZATION FAILED: ${controlAuthorizationError}`);
|
|
1553
|
-
}
|
|
1554
|
-
replay = await replayDifferential(
|
|
1555
|
-
bundle.targetRuns[0].evidence,
|
|
1556
|
-
caseTargetForReplay,
|
|
1557
|
-
bundle.controlTarget,
|
|
1558
|
-
{ allowPrivate },
|
|
1559
|
-
);
|
|
1560
|
-
}
|
|
1561
|
-
phase2Verification = {
|
|
1562
|
-
at: new Date().toISOString(),
|
|
1563
|
-
result: replay,
|
|
1564
|
-
};
|
|
1565
|
-
}
|
|
1566
|
-
const result = applyConfirmationResult(caseId, parsedVerdict.verdict, phase2Verification, {
|
|
1141
|
+
const result = applyConfirmationResult(caseId, parsedVerdict.verdict, {
|
|
1567
1142
|
startedAsSubagent: isSubagentProcess(),
|
|
1568
1143
|
});
|
|
1569
1144
|
const record = result.record;
|
|
@@ -1602,6 +1177,65 @@ ${formatCaseDetail(record)}`,
|
|
|
1602
1177
|
},
|
|
1603
1178
|
});
|
|
1604
1179
|
|
|
1180
|
+
// ── Tool: CoverageAdd ──
|
|
1181
|
+
|
|
1182
|
+
registerCaseTool({
|
|
1183
|
+
name: "CoverageAdd",
|
|
1184
|
+
label: "Record Coverage Cell",
|
|
1185
|
+
description:
|
|
1186
|
+
"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.",
|
|
1187
|
+
promptSnippet: "Record a tested coverage cell (found or clean)",
|
|
1188
|
+
promptGuidelines: [
|
|
1189
|
+
"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.",
|
|
1190
|
+
"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.",
|
|
1191
|
+
"Reference an artifact-backed EvidenceAdd item via evidence_item_id so the tested verdict is machine-checkable, not prose-only.",
|
|
1192
|
+
],
|
|
1193
|
+
parameters: CoverageAddSchema,
|
|
1194
|
+
|
|
1195
|
+
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1196
|
+
const item = recordCoverageResult(params.case_id as string, {
|
|
1197
|
+
asset: params.asset as string,
|
|
1198
|
+
class: params.class as string,
|
|
1199
|
+
scope: params.scope as CoverageScope,
|
|
1200
|
+
note: params.note as string,
|
|
1201
|
+
evidenceItemId: params.evidence_item_id as string | undefined,
|
|
1202
|
+
});
|
|
1203
|
+
const record = getCaseById(params.case_id as string);
|
|
1204
|
+
if (!record) throw new Error(`Case not found after coverage insert: ${params.case_id}`);
|
|
1205
|
+
return {
|
|
1206
|
+
content: [
|
|
1207
|
+
{
|
|
1208
|
+
type: "text",
|
|
1209
|
+
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)}`,
|
|
1210
|
+
},
|
|
1211
|
+
],
|
|
1212
|
+
details: { item, record },
|
|
1213
|
+
};
|
|
1214
|
+
},
|
|
1215
|
+
|
|
1216
|
+
renderCall(args, theme) {
|
|
1217
|
+
return callLine(
|
|
1218
|
+
theme,
|
|
1219
|
+
"CoverageAdd",
|
|
1220
|
+
`${(args.case_id as string) ?? ""} ${(args.asset as string) ?? ""}×${(args.class as string) ?? ""}`,
|
|
1221
|
+
);
|
|
1222
|
+
},
|
|
1223
|
+
|
|
1224
|
+
renderResult(result, _opts, theme) {
|
|
1225
|
+
const details = result.details as { item?: CoverageItem } | undefined;
|
|
1226
|
+
if (!details?.item) {
|
|
1227
|
+
return new Text(theme.fg("error", "✗ CoverageAdd failed"), 0, 0);
|
|
1228
|
+
}
|
|
1229
|
+
return new Text(
|
|
1230
|
+
theme.fg("success", "✓ ") +
|
|
1231
|
+
theme.fg("dim", `[${details.item.scope}] `) +
|
|
1232
|
+
truncateToWidth(`${details.item.asset} × ${details.item.class}`, 60),
|
|
1233
|
+
0,
|
|
1234
|
+
0,
|
|
1235
|
+
);
|
|
1236
|
+
},
|
|
1237
|
+
});
|
|
1238
|
+
|
|
1605
1239
|
// ── Tool: CaseGet ──
|
|
1606
1240
|
|
|
1607
1241
|
registerCaseTool({
|
|
@@ -1831,15 +1465,15 @@ ${formatCaseDetail(record)}`,
|
|
|
1831
1465
|
parameters: IdSchema,
|
|
1832
1466
|
|
|
1833
1467
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1834
|
-
const { path, contextPath, record } = writeCaseContext(params.id as string);
|
|
1468
|
+
const { path, contextPath, contractPath, record } = writeCaseContext(params.id as string);
|
|
1835
1469
|
return {
|
|
1836
1470
|
content: [
|
|
1837
1471
|
{
|
|
1838
1472
|
type: "text",
|
|
1839
|
-
text: `Case context written: ${contextPath}\nReport path: ${path}\n${formatCase(record)}`,
|
|
1473
|
+
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
1474
|
},
|
|
1841
1475
|
],
|
|
1842
|
-
details: { path, contextPath, record },
|
|
1476
|
+
details: { path, contextPath, contractPath, record },
|
|
1843
1477
|
};
|
|
1844
1478
|
},
|
|
1845
1479
|
|
|
@@ -1857,153 +1491,6 @@ ${formatCaseDetail(record)}`,
|
|
|
1857
1491
|
},
|
|
1858
1492
|
});
|
|
1859
1493
|
|
|
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
1494
|
// ── Tool: ScratchpadWrite ──
|
|
2008
1495
|
|
|
2009
1496
|
registerCaseTool({
|
|
@@ -2011,7 +1498,7 @@ ${formatCaseDetail(record)}`,
|
|
|
2011
1498
|
label: "Write Artifact",
|
|
2012
1499
|
description:
|
|
2013
1500
|
"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
|
|
1501
|
+
promptSnippet: "Save a run artifact to the scratchpad",
|
|
2015
1502
|
promptGuidelines: [
|
|
2016
1503
|
"Agents write artifacts to the scratchpad, not to each other's output files (prevents an echo chamber).",
|
|
2017
1504
|
"The casefile owns state transitions; the scratchpad owns artifacts. Use ScratchpadWrite for bulky intermediate outputs, not CaseUpdate.",
|
|
@@ -2057,7 +1544,7 @@ ${formatCaseDetail(record)}`,
|
|
|
2057
1544
|
label: "Read Artifact",
|
|
2058
1545
|
description:
|
|
2059
1546
|
"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
|
|
1547
|
+
promptSnippet: "Read a run artifact from the scratchpad",
|
|
2061
1548
|
promptGuidelines: [
|
|
2062
1549
|
"On resume, ScratchpadRead retrieves a prior phase's intermediate output so the next phase can proceed without re-running it.",
|
|
2063
1550
|
"Returns null for missing artifacts — treat as 'not yet produced' rather than an error.",
|
|
@@ -2107,60 +1594,16 @@ ${formatCaseDetail(record)}`,
|
|
|
2107
1594
|
},
|
|
2108
1595
|
});
|
|
2109
1596
|
|
|
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
1597
|
// ── Tool: ScratchpadClear ──
|
|
2154
1598
|
|
|
2155
1599
|
registerCaseTool({
|
|
2156
1600
|
name: "ScratchpadClear",
|
|
2157
1601
|
label: "Clear Run",
|
|
2158
1602
|
description:
|
|
2159
|
-
"Clear a single
|
|
2160
|
-
promptSnippet: "Clear one
|
|
1603
|
+
"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.",
|
|
1604
|
+
promptSnippet: "Clear one run's artifacts",
|
|
2161
1605
|
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.",
|
|
1606
|
+
"Use ScratchpadClear to force a fresh start for a single run. It deletes that run's directory only.",
|
|
2164
1607
|
],
|
|
2165
1608
|
parameters: RunIdSchema,
|
|
2166
1609
|
|
|
@@ -2170,7 +1613,7 @@ ${formatCaseDetail(record)}`,
|
|
|
2170
1613
|
content: [
|
|
2171
1614
|
{
|
|
2172
1615
|
type: "text",
|
|
2173
|
-
text: `Scratchpad cleared for run ${params.run_id}
|
|
1616
|
+
text: `Scratchpad cleared for run ${params.run_id}.`,
|
|
2174
1617
|
},
|
|
2175
1618
|
],
|
|
2176
1619
|
details: { run_id: params.run_id, cleared: true },
|