@xaccefy/pi-casefile 0.7.0 → 0.7.2
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/package.json +2 -1
- package/src/index.ts +182 -46
- package/src/ledger.ts +26 -19
- package/src/pipeline-submit.ts +509 -0
- package/src/scratchpad.ts +19 -39
- package/src/workflow.ts +38 -30
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xaccefy/pi-casefile",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "Offensive security case tracker for Pi Agent — bug bounties, CTFs, security audits",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"src/ledger.ts",
|
|
36
36
|
"src/workflow.ts",
|
|
37
37
|
"src/poc-runner.ts",
|
|
38
|
+
"src/pipeline-submit.ts",
|
|
38
39
|
"src/scratchpad.ts",
|
|
39
40
|
"src/sqlite-compat/index.ts",
|
|
40
41
|
"skills",
|
package/src/index.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Casefile — offensive security case tracker for Pi.
|
|
3
3
|
*
|
|
4
|
-
* Tools: CaseAdd, CaseUpdate, PromoteFinding, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseReport, ScratchpadInit, ScratchpadResume, ScratchpadCheckpoint, ScratchpadWrite, ScratchpadRead, ScratchpadPhaseDone, ScratchpadClear
|
|
4
|
+
* Tools: CaseAdd, CaseUpdate, PromoteFinding, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseReport, PipelineSubmit, ScratchpadInit, ScratchpadResume, ScratchpadCheckpoint, ScratchpadWrite, ScratchpadRead, ScratchpadPhaseDone, ScratchpadClear
|
|
5
5
|
* Command: /casefile — interactive dashboard
|
|
6
|
-
* Event: before_agent_start — injects cyber workflow
|
|
6
|
+
* Event: before_agent_start — injects cyber workflow once per session, refreshes the active case list per prompt
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
-
import { homedir } from "node:os";
|
|
11
10
|
import { dirname, join } from "node:path";
|
|
12
11
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
12
|
import { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
@@ -45,6 +44,7 @@ import {
|
|
|
45
44
|
updateCaseResult,
|
|
46
45
|
writeCaseReport,
|
|
47
46
|
} from "./ledger.ts";
|
|
47
|
+
import { pipeline_submit, SUBMIT_STAGES, type SubmitStage } from "./pipeline-submit.ts";
|
|
48
48
|
import { type PocRun, runPoc } from "./poc-runner.ts";
|
|
49
49
|
import {
|
|
50
50
|
type ScratchpadPhase,
|
|
@@ -122,6 +122,11 @@ const PromoteSchema = Type.Object(
|
|
|
122
122
|
poc_path: Type.String({
|
|
123
123
|
description: "Absolute path to the PoC script on disk",
|
|
124
124
|
}),
|
|
125
|
+
verification_marker: Type.String({
|
|
126
|
+
minLength: 1,
|
|
127
|
+
description:
|
|
128
|
+
"A unique string the PoC must print to stdout to prove exploitation actually occurred. The gate checks the PoC output contains this marker — exit code 0 alone is NOT sufficient. The marker should be specific to the finding (e.g. 'VULN_CONFIRMED_<case-id>') and only printed after the PoC has verified the exploit worked (e.g. after extracting data, receiving a callback, seeing the payload reflected). This prevents fluke exit 0 and mocked PoCs from passing the gate.",
|
|
129
|
+
}),
|
|
125
130
|
disconfirmation_path: Type.Optional(
|
|
126
131
|
Type.String({
|
|
127
132
|
description:
|
|
@@ -248,7 +253,8 @@ const SCRATCHPAD_PHASES = [
|
|
|
248
253
|
|
|
249
254
|
const ScratchpadPhaseSchema = Type.String({
|
|
250
255
|
enum: [...SCRATCHPAD_PHASES],
|
|
251
|
-
description:
|
|
256
|
+
description:
|
|
257
|
+
"Pipeline phase: recon | hunt | gapfil | trace | skeptic | validate | chain | patch | report",
|
|
252
258
|
});
|
|
253
259
|
|
|
254
260
|
const ScratchpadInitSchema = Type.Object(
|
|
@@ -269,7 +275,11 @@ const ScratchpadCheckpointSchema = Type.Object(
|
|
|
269
275
|
{
|
|
270
276
|
run_id: Type.String({ description: "Run identifier" }),
|
|
271
277
|
phase: ScratchpadPhaseSchema,
|
|
272
|
-
ids: Type.Optional(
|
|
278
|
+
ids: Type.Optional(
|
|
279
|
+
Type.Array(Type.String(), {
|
|
280
|
+
description: "Key IDs produced by this phase (case IDs, finding IDs)",
|
|
281
|
+
}),
|
|
282
|
+
),
|
|
273
283
|
summary: Type.Optional(Type.String({ description: "One-line summary of phase completion" })),
|
|
274
284
|
},
|
|
275
285
|
{ additionalProperties: false },
|
|
@@ -279,7 +289,9 @@ const ScratchpadWriteSchema = Type.Object(
|
|
|
279
289
|
{
|
|
280
290
|
run_id: Type.String({ description: "Run identifier" }),
|
|
281
291
|
phase: ScratchpadPhaseSchema,
|
|
282
|
-
artifact_name: Type.String({
|
|
292
|
+
artifact_name: Type.String({
|
|
293
|
+
description: "Artifact filename (sanitized; path traversal is blocked)",
|
|
294
|
+
}),
|
|
283
295
|
content: Type.String({ description: "Artifact content to write" }),
|
|
284
296
|
},
|
|
285
297
|
{ additionalProperties: false },
|
|
@@ -415,9 +427,7 @@ class CasefileDashboard {
|
|
|
415
427
|
|
|
416
428
|
if (this.records.length === 0) {
|
|
417
429
|
lines.push("");
|
|
418
|
-
lines.push(
|
|
419
|
-
` ${th.fg("dim", "No active security cases. Ask the agent to CaseAdd findings!")}`,
|
|
420
|
-
);
|
|
430
|
+
lines.push(` ${th.fg("dim", "No security cases yet. Ask the agent to CaseAdd findings!")}`);
|
|
421
431
|
} else {
|
|
422
432
|
lines.push("");
|
|
423
433
|
for (const r of this.records) {
|
|
@@ -502,9 +512,15 @@ function buildCaseListContext(records: CaseRecord[]): string {
|
|
|
502
512
|
return lines.join("\n");
|
|
503
513
|
}
|
|
504
514
|
|
|
505
|
-
/**
|
|
506
|
-
|
|
515
|
+
/**
|
|
516
|
+
* Builds the per-prompt injection. The cyber workflow is session-scope data —
|
|
517
|
+
* it never changes — so the caller passes includeWorkflow=true exactly once
|
|
518
|
+
* per session; re-injecting it on every prompt is pure token cost. The active
|
|
519
|
+
* case list DOES change as cases are added, so it is refreshed every prompt.
|
|
520
|
+
*/
|
|
521
|
+
function buildAgentInjection(active: CaseRecord[], includeWorkflow: boolean): string {
|
|
507
522
|
const caseList = buildCaseListContext(active);
|
|
523
|
+
if (!includeWorkflow) return caseList;
|
|
508
524
|
// Workflow FIRST for prominence, then case list as reference data.
|
|
509
525
|
return caseList ? `${STATIC_CYBER_WORKFLOW}\n\n${caseList}` : STATIC_CYBER_WORKFLOW;
|
|
510
526
|
}
|
|
@@ -520,11 +536,7 @@ export const XP_MODE_ENV = "PI_XP_MODE";
|
|
|
520
536
|
export type XpMode = "on" | "off";
|
|
521
537
|
|
|
522
538
|
export function getXpModeStatePath(): string {
|
|
523
|
-
|
|
524
|
-
return join(dirname(getCasefilePath()), "xp-mode");
|
|
525
|
-
} catch {
|
|
526
|
-
return join(homedir(), ".pi", "xp-mode");
|
|
527
|
-
}
|
|
539
|
+
return join(dirname(getCasefilePath()), "xp-mode");
|
|
528
540
|
}
|
|
529
541
|
|
|
530
542
|
export function readXpMode(
|
|
@@ -724,13 +736,14 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
724
736
|
name: "PromoteFinding",
|
|
725
737
|
label: "Promote Finding",
|
|
726
738
|
description:
|
|
727
|
-
"Run an on-disk PoC script (Docker sandbox or local) and, on exit 0, promote an investigating case to confirmed. Optionally run a disconfirmation script that must exit non-0 (finding survived the attempt to disprove).",
|
|
739
|
+
"Run an on-disk PoC script (Docker sandbox or local) and, on exit 0 + verification marker present in output, promote an investigating case to confirmed. The verification_marker proves the exploit actually worked — exit code 0 alone is NOT sufficient. Optionally run a disconfirmation script that must exit non-0 (finding survived the attempt to disprove).",
|
|
728
740
|
promptSnippet: "Run a PoC and promote an investigating case to confirmed",
|
|
729
741
|
promptGuidelines: [
|
|
730
742
|
"Use PromoteFinding when an investigating case has a concrete PoC script on disk and you are ready to prove it.",
|
|
731
743
|
"The case must already have status='investigating' and non-empty poc, evidence, impact, severity, target, and disconfirmation fields.",
|
|
732
744
|
"By default, the PoC runs in `docker run --rm --network none`. Use local:true to run on the host (e.g. for network-dependent bugs).",
|
|
733
|
-
"
|
|
745
|
+
"Promotion requires BOTH exit code 0 AND the verification_marker appearing in the PoC output. The marker is a string you choose (e.g. 'VULN_CONFIRMED_<case-id>') that the PoC prints ONLY after it has verified the exploit worked — after extracting data, receiving a callback, seeing the payload reflected, etc. Do NOT print the marker unconditionally or before the exploit check.",
|
|
746
|
+
"The marker check prevents fluke exit 0 (script crashed before real logic) and mocked PoCs (script ran but didn't actually exploit the target) from passing the gate.",
|
|
734
747
|
"Optionally provide disconfirmation_path to a script that tries to disprove the finding. If the disconfirmation script exits 0, the finding is considered disproven and promotion is blocked.",
|
|
735
748
|
"Do not use CaseUpdate to set status='confirmed' directly — it is rejected. Always use PromoteFinding.",
|
|
736
749
|
],
|
|
@@ -741,6 +754,26 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
741
754
|
// 30s (plus first-time image pull), so fail cheap when the case can't
|
|
742
755
|
// advance anyway (missing, wrong status, missing required fields).
|
|
743
756
|
assertPromotable(params.id as string);
|
|
757
|
+
|
|
758
|
+
// Reject empty/whitespace markers BEFORE any PoC run — it's a param
|
|
759
|
+
// error, so fail cheap instead of burning a (up to 30s) sandboxed run.
|
|
760
|
+
const marker = (params.verification_marker as string | undefined)?.trim();
|
|
761
|
+
if (!marker) {
|
|
762
|
+
return {
|
|
763
|
+
content: [
|
|
764
|
+
{
|
|
765
|
+
type: "text",
|
|
766
|
+
text:
|
|
767
|
+
"verification_marker is empty or whitespace. " +
|
|
768
|
+
"A non-empty marker printed only AFTER the PoC confirms exploitation is required — " +
|
|
769
|
+
"exit code 0 alone is not sufficient. Case remains investigating.",
|
|
770
|
+
},
|
|
771
|
+
],
|
|
772
|
+
isError: true,
|
|
773
|
+
details: { record: getCaseById(params.id as string) },
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
|
|
744
777
|
const run = runPoc(params.poc_path as string, params.local !== true);
|
|
745
778
|
|
|
746
779
|
// Fail closed without throwing: non-zero PoC must leave the case investigating.
|
|
@@ -758,6 +791,28 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
758
791
|
};
|
|
759
792
|
}
|
|
760
793
|
|
|
794
|
+
// Verification marker check: exit code 0 alone is NOT sufficient.
|
|
795
|
+
// The PoC must print the verification_marker to stdout, proving the
|
|
796
|
+
// exploit actually worked — not just that the script ran. This blocks
|
|
797
|
+
// fluke exit 0 (crash before real logic) and mocked PoCs that don't
|
|
798
|
+
// actually exploit the target.
|
|
799
|
+
if (!(run.output ?? "").includes(marker)) {
|
|
800
|
+
const record = getCaseById(params.id as string);
|
|
801
|
+
return {
|
|
802
|
+
content: [
|
|
803
|
+
{
|
|
804
|
+
type: "text",
|
|
805
|
+
text:
|
|
806
|
+
`PoC exited 0 but the verification marker "${marker}" was NOT found in the output.\n` +
|
|
807
|
+
`This means the script ran but did not prove exploitation. The marker must be printed only AFTER the PoC verifies the exploit worked (data extracted, callback received, payload reflected, etc.).\n` +
|
|
808
|
+
`Do not print the marker unconditionally — print it only when the exploit is confirmed.\n\nOutput:\n${run.output}`,
|
|
809
|
+
},
|
|
810
|
+
],
|
|
811
|
+
isError: true,
|
|
812
|
+
details: { record, run, markerMissing: true },
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
|
|
761
816
|
// Run disconfirmation script if provided — must exit NON-0 (finding survived the attempt to disprove).
|
|
762
817
|
let disconfirmationRun: PocRun | undefined;
|
|
763
818
|
if (params.disconfirmation_path) {
|
|
@@ -1105,7 +1160,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1105
1160
|
name: "CaseReport",
|
|
1106
1161
|
label: "Write Case Report",
|
|
1107
1162
|
description:
|
|
1108
|
-
"Generate a markdown report from a confirmed or reported case under the
|
|
1163
|
+
"Generate a markdown report from a confirmed or reported case under the casefile report directory (next to the casefile DB). Hypothesis/investigating/blocked/killed cases are rejected — promote to confirmed first.",
|
|
1109
1164
|
promptSnippet: "Generate a bounty-style markdown report from a case",
|
|
1110
1165
|
promptGuidelines: [
|
|
1111
1166
|
"Use CaseReport only for confirmed or already reported cases. Keep hypotheses and investigating cases in the ledger until proof is captured.",
|
|
@@ -1159,6 +1214,80 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1159
1214
|
},
|
|
1160
1215
|
});
|
|
1161
1216
|
|
|
1217
|
+
// ── Tool: PipelineSubmit ──
|
|
1218
|
+
|
|
1219
|
+
pi.registerTool({
|
|
1220
|
+
name: "PipelineSubmit",
|
|
1221
|
+
label: "Submit Stage Output",
|
|
1222
|
+
description:
|
|
1223
|
+
"Submit a pipeline stage's output (hunt, trace, skeptic, validate, chain, report) through the validation gate. Validates required fields against the stage spec (mirrors schemas/*.json), applies the deterministic pre-filter (test-path and file-existence filters on hunt findings, trivial dedup by file+class+line), and counts repair attempts (max 2, then rejected). A stage cannot advance on an invalid output — submit fixed output until accepted.",
|
|
1224
|
+
promptSnippet: "Validate and submit a pipeline stage's output",
|
|
1225
|
+
promptGuidelines: [
|
|
1226
|
+
"Every stage output a subagent returns must go through PipelineSubmit before the next stage is dispatched. Do not eyeball schemas.",
|
|
1227
|
+
"If the verdict is repair, fix the fields listed in errors and re-submit the same output. The repair budget is 2 attempts per finding — after that the submission is rejected and the stage is failed.",
|
|
1228
|
+
"Unhandled skeptic output: an unparseable or schema-invalid skeptic response is UNDETERMINED, never DISPROVEN. A tracer error is UNREACHABLE. PipelineSubmit returns repair for these instead of accepting them.",
|
|
1229
|
+
"Test-path findings and hallucinated files are rejected by the pre-filter, not repairable — the finding itself is noise.",
|
|
1230
|
+
],
|
|
1231
|
+
parameters: Type.Object(
|
|
1232
|
+
{
|
|
1233
|
+
run_id: Type.String({
|
|
1234
|
+
description: "Pipeline run identifier (same as the scratchpad run_id)",
|
|
1235
|
+
}),
|
|
1236
|
+
stage: Type.String({
|
|
1237
|
+
enum: [...SUBMIT_STAGES],
|
|
1238
|
+
description: "Pipeline stage: hunt | trace | skeptic | validate | chain | report",
|
|
1239
|
+
}),
|
|
1240
|
+
output: Type.Union([Type.String(), Type.Object({}, { additionalProperties: true })], {
|
|
1241
|
+
description: "The stage output as a JSON object or JSON string (code fences tolerated)",
|
|
1242
|
+
}),
|
|
1243
|
+
},
|
|
1244
|
+
{ additionalProperties: false },
|
|
1245
|
+
),
|
|
1246
|
+
|
|
1247
|
+
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1248
|
+
const result = pipeline_submit(
|
|
1249
|
+
params.run_id as string,
|
|
1250
|
+
params.stage as SubmitStage,
|
|
1251
|
+
params.output,
|
|
1252
|
+
);
|
|
1253
|
+
const statusLine =
|
|
1254
|
+
result.verdict === "accepted"
|
|
1255
|
+
? `ACCEPTED (${params.stage}) — artifact: ${result.artifact}`
|
|
1256
|
+
: result.verdict === "repair"
|
|
1257
|
+
? `REPAIR (attempt ${result.repair_attempt}/2) — fix these and re-submit:\n - ${result.errors.join("\n - ")}`
|
|
1258
|
+
: `REJECTED — ${result.errors.join("\n")}`;
|
|
1259
|
+
return {
|
|
1260
|
+
content: [{ type: "text", text: statusLine }],
|
|
1261
|
+
isError: result.verdict !== "accepted",
|
|
1262
|
+
details: result as unknown as Record<string, unknown>,
|
|
1263
|
+
};
|
|
1264
|
+
},
|
|
1265
|
+
|
|
1266
|
+
renderCall(args, theme) {
|
|
1267
|
+
return new Text(
|
|
1268
|
+
theme.fg("toolTitle", theme.bold("PipelineSubmit ")) +
|
|
1269
|
+
theme.fg("dim", `${args.stage ?? ""}`),
|
|
1270
|
+
0,
|
|
1271
|
+
0,
|
|
1272
|
+
);
|
|
1273
|
+
},
|
|
1274
|
+
|
|
1275
|
+
renderResult(result, _opts, theme) {
|
|
1276
|
+
const details = result.details as { verdict?: string; repair_attempt?: number } | undefined;
|
|
1277
|
+
if (details?.verdict === "accepted") {
|
|
1278
|
+
return new Text(theme.fg("success", "✓ PipelineSubmit accepted"), 0, 0);
|
|
1279
|
+
}
|
|
1280
|
+
if (details?.verdict === "repair") {
|
|
1281
|
+
return new Text(
|
|
1282
|
+
theme.fg("warning", `↷ PipelineSubmit repair ${details.repair_attempt}/2`),
|
|
1283
|
+
0,
|
|
1284
|
+
0,
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
return new Text(theme.fg("error", "✗ PipelineSubmit rejected"), 0, 0);
|
|
1288
|
+
},
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1162
1291
|
// ── Tool: ScratchpadInit ──
|
|
1163
1292
|
|
|
1164
1293
|
pi.registerTool({
|
|
@@ -1180,7 +1309,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1180
1309
|
content: [
|
|
1181
1310
|
{
|
|
1182
1311
|
type: "text",
|
|
1183
|
-
text: `Scratchpad initialized for run ${cp.run_id}.\nCompleted phases: ${cp.completed_phases.length ? cp.completed_phases.join(", ") : "none"}
|
|
1312
|
+
text: `Scratchpad initialized for run ${cp.run_id}.\nCompleted phases: ${cp.completed_phases.length ? cp.completed_phases.join(", ") : "none"}`,
|
|
1184
1313
|
},
|
|
1185
1314
|
],
|
|
1186
1315
|
details: { checkpoint: cp },
|
|
@@ -1197,13 +1326,8 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1197
1326
|
},
|
|
1198
1327
|
|
|
1199
1328
|
renderResult(result, _opts, theme) {
|
|
1200
|
-
const cp = (result.details as { checkpoint: { run_id: string
|
|
1201
|
-
return new Text(
|
|
1202
|
-
theme.fg("success", "✓ ") +
|
|
1203
|
-
`ScratchpadInit ${cp?.run_id ?? ""}${cp?.done ? " (done)" : ""}`,
|
|
1204
|
-
0,
|
|
1205
|
-
0,
|
|
1206
|
-
);
|
|
1329
|
+
const cp = (result.details as { checkpoint: { run_id: string } } | undefined)?.checkpoint;
|
|
1330
|
+
return new Text(`${theme.fg("success", "✓ ")}ScratchpadInit ${cp?.run_id ?? ""}`, 0, 0);
|
|
1207
1331
|
},
|
|
1208
1332
|
});
|
|
1209
1333
|
|
|
@@ -1243,8 +1367,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1243
1367
|
text:
|
|
1244
1368
|
`Resume run ${cp.run_id}:\n` +
|
|
1245
1369
|
`Completed phases: ${cp.completed_phases.length ? cp.completed_phases.join(", ") : "none"}\n` +
|
|
1246
|
-
`Next phase: ${resume.next_phase ?? "none (run is done)"}
|
|
1247
|
-
`Done: ${cp.done}`,
|
|
1370
|
+
`Next phase: ${resume.next_phase ?? "none (run is done)"}`,
|
|
1248
1371
|
},
|
|
1249
1372
|
],
|
|
1250
1373
|
details: { resume },
|
|
@@ -1288,11 +1411,10 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1288
1411
|
parameters: ScratchpadCheckpointSchema,
|
|
1289
1412
|
|
|
1290
1413
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1291
|
-
const cp = scratchpad_checkpoint(
|
|
1292
|
-
params.
|
|
1293
|
-
params.
|
|
1294
|
-
|
|
1295
|
-
);
|
|
1414
|
+
const cp = scratchpad_checkpoint(params.run_id as string, params.phase as ScratchpadPhase, {
|
|
1415
|
+
ids: params.ids as string[] | undefined,
|
|
1416
|
+
summary: params.summary as string | undefined,
|
|
1417
|
+
});
|
|
1296
1418
|
return {
|
|
1297
1419
|
content: [
|
|
1298
1420
|
{
|
|
@@ -1316,7 +1438,9 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1316
1438
|
},
|
|
1317
1439
|
|
|
1318
1440
|
renderResult(result, _opts, theme) {
|
|
1319
|
-
const cp = (
|
|
1441
|
+
const cp = (
|
|
1442
|
+
result.details as { checkpoint: { run_id: string; completed_phases: string[] } } | undefined
|
|
1443
|
+
)?.checkpoint;
|
|
1320
1444
|
return new Text(
|
|
1321
1445
|
theme.fg("success", "✓ ") +
|
|
1322
1446
|
`ScratchpadCheckpoint ${cp?.run_id ?? ""} — ${cp?.completed_phases.length ?? 0} phases done`,
|
|
@@ -1422,7 +1546,9 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1422
1546
|
renderResult(result, _opts, theme) {
|
|
1423
1547
|
const found = (result.details as { found?: boolean } | undefined)?.found;
|
|
1424
1548
|
return new Text(
|
|
1425
|
-
found
|
|
1549
|
+
found
|
|
1550
|
+
? theme.fg("success", "✓ ScratchpadRead")
|
|
1551
|
+
: theme.fg("warning", "↷ ScratchpadRead — not found"),
|
|
1426
1552
|
0,
|
|
1427
1553
|
0,
|
|
1428
1554
|
);
|
|
@@ -1444,10 +1570,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1444
1570
|
parameters: ScratchpadPhaseDoneSchema,
|
|
1445
1571
|
|
|
1446
1572
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1447
|
-
const done = scratchpad_phase_done(
|
|
1448
|
-
params.run_id as string,
|
|
1449
|
-
params.phase as ScratchpadPhase,
|
|
1450
|
-
);
|
|
1573
|
+
const done = scratchpad_phase_done(params.run_id as string, params.phase as ScratchpadPhase);
|
|
1451
1574
|
return {
|
|
1452
1575
|
content: [
|
|
1453
1576
|
{
|
|
@@ -1471,7 +1594,9 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1471
1594
|
renderResult(result, _opts, theme) {
|
|
1472
1595
|
const done = (result.details as { done?: boolean } | undefined)?.done;
|
|
1473
1596
|
return new Text(
|
|
1474
|
-
done
|
|
1597
|
+
done
|
|
1598
|
+
? theme.fg("success", "✓ ScratchpadPhaseDone — done")
|
|
1599
|
+
: theme.fg("warning", "↷ ScratchpadPhaseDone — not done"),
|
|
1475
1600
|
0,
|
|
1476
1601
|
0,
|
|
1477
1602
|
);
|
|
@@ -1555,13 +1680,22 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1555
1680
|
|
|
1556
1681
|
// ── Event: Inject cyber workflow into system prompt ──
|
|
1557
1682
|
// XP (offensive) mode is OFF by default so normal dev work stays quiet.
|
|
1558
|
-
//
|
|
1559
|
-
// the
|
|
1560
|
-
//
|
|
1561
|
-
// session bloat from repeated message entries.
|
|
1683
|
+
// When enabled, the cyber workflow is injected ONCE per session (first
|
|
1684
|
+
// prompt); the active case list refreshes every prompt because it changes
|
|
1685
|
+
// as cases are added. Injecting into event.systemPrompt (not as a
|
|
1686
|
+
// conversation message) avoids session bloat from repeated message entries.
|
|
1687
|
+
let workflowInjected = false;
|
|
1562
1688
|
|
|
1563
1689
|
pi.on("before_agent_start", async (event) => {
|
|
1564
1690
|
if (readXpMode() === "off") return;
|
|
1691
|
+
// Skip subagent child processes: pi-subagents runs each child in its own
|
|
1692
|
+
// pi process (PI_SUBAGENT_CHILD=1) with this extension loaded. Injecting
|
|
1693
|
+
// the workflow + entire active-case ledger into every child dispatch is a
|
|
1694
|
+
// token multiplier (N subagents × workflow + growing case list per turn) —
|
|
1695
|
+
// workers get what they need via their task and tool guidelines.
|
|
1696
|
+
if (process.env.PI_SUBAGENT_CHILD === "1") return;
|
|
1697
|
+
|
|
1698
|
+
const includeWorkflow = !workflowInjected;
|
|
1565
1699
|
|
|
1566
1700
|
let active: CaseRecord[] = [];
|
|
1567
1701
|
try {
|
|
@@ -1570,7 +1704,9 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1570
1704
|
// No database yet — still inject workflow.
|
|
1571
1705
|
}
|
|
1572
1706
|
|
|
1573
|
-
const injection = buildAgentInjection(active);
|
|
1707
|
+
const injection = buildAgentInjection(active, includeWorkflow);
|
|
1708
|
+
if (!injection) return; // workflow already injected, no active cases
|
|
1709
|
+
workflowInjected = true;
|
|
1574
1710
|
|
|
1575
1711
|
// Inject workflow FIRST (before skills) so the attacker mindset is
|
|
1576
1712
|
// prominent, not buried at the end of a long system prompt.
|
package/src/ledger.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { createHash, randomUUID } from "node:crypto";
|
|
14
14
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
15
|
-
import { dirname, join, resolve } from "node:path";
|
|
15
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
16
16
|
import { DatabaseSync } from "./sqlite-compat/index.ts";
|
|
17
17
|
|
|
18
18
|
// ── Types ────────────────────────────────────────────────────────────
|
|
@@ -226,8 +226,14 @@ function stableShortId(input: string): string {
|
|
|
226
226
|
}
|
|
227
227
|
|
|
228
228
|
function detectWorkspaceRoot(): string {
|
|
229
|
-
|
|
230
|
-
|
|
229
|
+
// PWD is deliberately excluded: it is shell-set, can be stale or forged in
|
|
230
|
+
// spawned processes, and disagree with the real cwd. Explicit overrides only,
|
|
231
|
+
// then walk up from the actual cwd.
|
|
232
|
+
const envs = ["CASEFILE_WORKSPACE_ROOT", "PI_WORKSPACE_ROOT", "GITHUB_WORKSPACE"];
|
|
233
|
+
for (const e of envs) {
|
|
234
|
+
const v = process.env[e]?.trim();
|
|
235
|
+
if (v) return resolve(v);
|
|
236
|
+
}
|
|
231
237
|
|
|
232
238
|
let curr = resolve(process.cwd());
|
|
233
239
|
for (let i = 0; i < 20; i++) {
|
|
@@ -241,7 +247,10 @@ function detectWorkspaceRoot(): string {
|
|
|
241
247
|
|
|
242
248
|
export function getCasefilePath(): string {
|
|
243
249
|
if (ledgerPathOverride) return ledgerPathOverride;
|
|
244
|
-
|
|
250
|
+
// Trim BEFORE the truthiness check: a whitespace-only value must not
|
|
251
|
+
// "pass" and resolve to the process cwd ("" resolves to cwd).
|
|
252
|
+
const envPath = process.env.PI_CASEFILE_PATH?.trim();
|
|
253
|
+
if (envPath) return resolve(envPath);
|
|
245
254
|
return join(detectWorkspaceRoot(), ".pi", "casefile.db");
|
|
246
255
|
}
|
|
247
256
|
|
|
@@ -622,19 +631,17 @@ function findDuplicateCaseInDb(
|
|
|
622
631
|
const endpoint = normalizeMatchText(candidate.endpoint);
|
|
623
632
|
const bugClass = normalizeMatchText(candidate.bugClass);
|
|
624
633
|
|
|
625
|
-
//
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
|
|
634
|
+
// No SQL pre-filter: candidate rows are compared in JS against
|
|
635
|
+
// normalizeMatchText (lowercase + whitespace-collapse). SQLite's lower() is
|
|
636
|
+
// ASCII-only and LIKE can't collapse whitespace, so any SQL pre-filter would
|
|
637
|
+
// silently drop rows the JS comparator would call duplicates (e.g. stored
|
|
638
|
+
// "SQL Injection" vs candidate "SQL Injection", or non-ASCII case variants).
|
|
639
|
+
// Case ledgers are small (hundreds of rows); a full non-killed scan is cheap.
|
|
631
640
|
const rows = excludeId
|
|
632
641
|
? (db
|
|
633
|
-
.prepare("SELECT * FROM cases WHERE status != 'killed' AND id != ?
|
|
634
|
-
.all(excludeId
|
|
635
|
-
: (db
|
|
636
|
-
.prepare("SELECT * FROM cases WHERE status != 'killed' AND lower(title) LIKE ?")
|
|
637
|
-
.all(sqlTitle) as any[]);
|
|
642
|
+
.prepare("SELECT * FROM cases WHERE status != 'killed' AND id != ?")
|
|
643
|
+
.all(excludeId) as any[])
|
|
644
|
+
: (db.prepare("SELECT * FROM cases WHERE status != 'killed'").all() as any[]);
|
|
638
645
|
|
|
639
646
|
for (const row of rows) {
|
|
640
647
|
if (
|
|
@@ -922,7 +929,7 @@ export function promoteFindingResult(
|
|
|
922
929
|
}
|
|
923
930
|
|
|
924
931
|
const newEvidence =
|
|
925
|
-
(current.evidence ? current.evidence
|
|
932
|
+
(current.evidence ? `${current.evidence}\n\n` : "") +
|
|
926
933
|
`### PoC Execution Capture (${verification.ranAt})\n` +
|
|
927
934
|
`- **Exit Code:** ${verification.exitCode}\n` +
|
|
928
935
|
`- **Sandbox:** ${verification.sandbox ? "yes" : "no"}\n` +
|
|
@@ -1171,7 +1178,7 @@ function mapRowsWithLinks(db: DatabaseSync, rows: any[]): CaseRecord[] {
|
|
|
1171
1178
|
const linkMap = new Map<string, { id: string; kind: string }[]>();
|
|
1172
1179
|
for (const l of links) {
|
|
1173
1180
|
if (!linkMap.has(l.source_id)) linkMap.set(l.source_id, []);
|
|
1174
|
-
linkMap.get(l.source_id)
|
|
1181
|
+
linkMap.get(l.source_id)?.push({ id: l.target_id, kind: l.kind });
|
|
1175
1182
|
}
|
|
1176
1183
|
return rows.map((row) => mapRow(row, linkMap.get(row.id) ?? []));
|
|
1177
1184
|
}
|
|
@@ -1323,14 +1330,14 @@ export function writeCaseReport(id: string): { path: string; record: CaseRecord
|
|
|
1323
1330
|
current.pocVerified
|
|
1324
1331
|
? mdSection(
|
|
1325
1332
|
"PoC Verification Log",
|
|
1326
|
-
`### PoC Run Verification\n- **Timestamp:** ${current.pocVerified.ranAt}\n- **
|
|
1333
|
+
`### PoC Run Verification\n- **Timestamp:** ${current.pocVerified.ranAt}\n- **Script:** \`${basename(current.pocVerified.path)}\`\n- **Sandbox:** ${current.pocVerified.sandbox ? "yes" : "no"}\n- **Exit Code:** ${current.pocVerified.exitCode}\n\n#### Output\n\`\`\`\n${current.pocVerified.output ?? ""}\n\`\`\``,
|
|
1327
1334
|
)
|
|
1328
1335
|
: undefined,
|
|
1329
1336
|
mdSection("Disconfirmation Attempt", current.disconfirmation),
|
|
1330
1337
|
current.disconfirmationVerified
|
|
1331
1338
|
? mdSection(
|
|
1332
1339
|
"Disconfirmation Verification Log",
|
|
1333
|
-
`### Disconfirmation Run Verification\n- **Timestamp:** ${current.disconfirmationVerified.ranAt}\n- **
|
|
1340
|
+
`### Disconfirmation Run Verification\n- **Timestamp:** ${current.disconfirmationVerified.ranAt}\n- **Script:** \`${basename(current.disconfirmationVerified.path)}\`\n- **Sandbox:** ${current.disconfirmationVerified.sandbox ? "yes" : "no"}\n- **Exit Code:** ${current.disconfirmationVerified.exitCode} (non-zero = finding survived the attempt to disprove)\n\n#### Output\n\`\`\`\n${current.disconfirmationVerified.output ?? ""}\n\`\`\``,
|
|
1334
1341
|
)
|
|
1335
1342
|
: undefined,
|
|
1336
1343
|
mdSection("Impact", current.impact),
|
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PipelineSubmit — the stage-output gate.
|
|
3
|
+
*
|
|
4
|
+
* The coordinator (model) dispatches stage subagents and submits their output
|
|
5
|
+
* here. This module is where the pipeline stops trusting prose: it validates
|
|
6
|
+
* stage output against the field specs mirrored from schemas/*.json, applies
|
|
7
|
+
* the deterministic pre-filter (test paths, hallucinated files, trivial dedup),
|
|
8
|
+
* and counts repair attempts. A stage cannot advance on an invalid output —
|
|
9
|
+
* the answer is REPAIR (with field-level errors) or REJECTED, in code.
|
|
10
|
+
*
|
|
11
|
+
* KEEP IN SYNC with schemas/*.json at the repo root. The JSON schemas are the
|
|
12
|
+
* canonical data contract for documentation; the SPECS table here is the
|
|
13
|
+
* executable gate (a focused validator for exactly these six shapes — no
|
|
14
|
+
* general JSON Schema engine).
|
|
15
|
+
*
|
|
16
|
+
* Persistence: .scratchpad/{run_id}/pipeline-submit.json
|
|
17
|
+
* { repairs: { "<stage>:<key>": n }, accepted_findings: FindingRef[] }
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
23
|
+
import {
|
|
24
|
+
getRunDir,
|
|
25
|
+
getScratchpadRoot,
|
|
26
|
+
type ScratchpadPhase,
|
|
27
|
+
scratchpad_write,
|
|
28
|
+
} from "./scratchpad.ts";
|
|
29
|
+
|
|
30
|
+
// ── Types ────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
export const SUBMIT_STAGES = ["hunt", "trace", "skeptic", "validate", "chain", "report"] as const;
|
|
33
|
+
export type SubmitStage = (typeof SUBMIT_STAGES)[number];
|
|
34
|
+
|
|
35
|
+
export type SubmitVerdict = "accepted" | "repair" | "rejected";
|
|
36
|
+
|
|
37
|
+
export type SubmitResult = {
|
|
38
|
+
verdict: SubmitVerdict;
|
|
39
|
+
stage: SubmitStage;
|
|
40
|
+
/** Field-level validation errors (repair) or rejection reason (rejected). */
|
|
41
|
+
errors: string[];
|
|
42
|
+
/** Repair attempt number (1-based) when verdict is repair. */
|
|
43
|
+
repair_attempt?: number;
|
|
44
|
+
/** Stable key identifying this finding's repair bucket. */
|
|
45
|
+
key?: string;
|
|
46
|
+
/** Set when hunt-stage dedup matched an accepted finding. */
|
|
47
|
+
duplicate_of?: string;
|
|
48
|
+
/** Scratchpad path the accepted output was written to. */
|
|
49
|
+
artifact?: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type StageSpec = {
|
|
53
|
+
/** Fields that must be present and non-empty. */
|
|
54
|
+
required: {
|
|
55
|
+
name: string;
|
|
56
|
+
type: "string" | "integer" | "array" | "object";
|
|
57
|
+
enum?: readonly string[];
|
|
58
|
+
minItems?: number;
|
|
59
|
+
}[];
|
|
60
|
+
/** Exactly one of these locator field-sets must be fully present. */
|
|
61
|
+
locatorXor?: [string[], string[]];
|
|
62
|
+
/** Conditional requirements: when field equals value, these must be non-empty. */
|
|
63
|
+
conditional?: { when: { field: string; equals: string }; require: string[] }[];
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// ── Stage specs (mirror of schemas/*.json semantics) ─────────────────
|
|
67
|
+
|
|
68
|
+
const VULN_CLASSES = [
|
|
69
|
+
"injection",
|
|
70
|
+
"xss",
|
|
71
|
+
"idor",
|
|
72
|
+
"bola",
|
|
73
|
+
"path-traversal",
|
|
74
|
+
"ssrf",
|
|
75
|
+
"command-injection",
|
|
76
|
+
"deserialization",
|
|
77
|
+
"auth-bypass",
|
|
78
|
+
"privilege-escalation",
|
|
79
|
+
"business-logic",
|
|
80
|
+
"race-condition",
|
|
81
|
+
"xxe",
|
|
82
|
+
"ssti",
|
|
83
|
+
"open-redirect",
|
|
84
|
+
"information-disclosure",
|
|
85
|
+
"crypto-weakness",
|
|
86
|
+
"other",
|
|
87
|
+
] as const;
|
|
88
|
+
|
|
89
|
+
const KILL_REASONS = [
|
|
90
|
+
"unreachable",
|
|
91
|
+
"framework_protection",
|
|
92
|
+
"input_validation_blocks",
|
|
93
|
+
"requires_privilege_attacker_lacks",
|
|
94
|
+
"poc_failed_3x",
|
|
95
|
+
"no_real_impact",
|
|
96
|
+
"intended_behavior",
|
|
97
|
+
"duplicate",
|
|
98
|
+
] as const;
|
|
99
|
+
|
|
100
|
+
const SPECS: Record<SubmitStage, StageSpec> = {
|
|
101
|
+
// schemas/stage-finding.json
|
|
102
|
+
hunt: {
|
|
103
|
+
required: [
|
|
104
|
+
{ name: "vuln_class", type: "string", enum: VULN_CLASSES },
|
|
105
|
+
{ name: "sink", type: "string" },
|
|
106
|
+
{ name: "entry_point", type: "string" },
|
|
107
|
+
{ name: "confidence", type: "string", enum: ["low", "medium", "high"] },
|
|
108
|
+
{ name: "evidence", type: "string" },
|
|
109
|
+
],
|
|
110
|
+
// Source targets: file + line. Live targets: endpoint.
|
|
111
|
+
locatorXor: [["file", "line"], ["endpoint"]],
|
|
112
|
+
},
|
|
113
|
+
// schemas/stage-trace.json
|
|
114
|
+
trace: {
|
|
115
|
+
required: [
|
|
116
|
+
{ name: "trace_result", type: "string", enum: ["REACHABLE", "UNREACHABLE"] },
|
|
117
|
+
{ name: "entry_point", type: "string" },
|
|
118
|
+
{ name: "call_chain", type: "array", minItems: 1 },
|
|
119
|
+
{ name: "defenses_checked", type: "array" },
|
|
120
|
+
{ name: "attacker_model", type: "string" },
|
|
121
|
+
],
|
|
122
|
+
conditional: [
|
|
123
|
+
{ when: { field: "trace_result", equals: "REACHABLE" }, require: ["impact_if_reachable"] },
|
|
124
|
+
{ when: { field: "trace_result", equals: "UNREACHABLE" }, require: ["unreachable_reason"] },
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
// schemas/stage-skeptic.json
|
|
128
|
+
skeptic: {
|
|
129
|
+
required: [
|
|
130
|
+
{ name: "finding_id", type: "string" },
|
|
131
|
+
{ name: "verdict", type: "string", enum: ["CONFIRMED", "DISPROVEN"] },
|
|
132
|
+
{ name: "reasoning", type: "string" },
|
|
133
|
+
{ name: "evidence_reviewed", type: "array", minItems: 1 },
|
|
134
|
+
],
|
|
135
|
+
conditional: [
|
|
136
|
+
{ when: { field: "verdict", equals: "DISPROVEN" }, require: ["disproval_reason"] },
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
// schemas/stage-validation.json
|
|
140
|
+
validate: {
|
|
141
|
+
required: [
|
|
142
|
+
{ name: "finding_id", type: "string" },
|
|
143
|
+
{ name: "status", type: "string", enum: ["confirmed", "killed", "reported"] },
|
|
144
|
+
{ name: "technique_used", type: "string" },
|
|
145
|
+
{ name: "detection_method", type: "string" },
|
|
146
|
+
],
|
|
147
|
+
conditional: [
|
|
148
|
+
{
|
|
149
|
+
when: { field: "status", equals: "confirmed" },
|
|
150
|
+
require: ["poc_path", "run_log", "evidence_extracted"],
|
|
151
|
+
},
|
|
152
|
+
{ when: { field: "status", equals: "killed" }, require: ["kill_reason"] },
|
|
153
|
+
],
|
|
154
|
+
},
|
|
155
|
+
// schemas/stage-chain.json
|
|
156
|
+
chain: {
|
|
157
|
+
required: [
|
|
158
|
+
{ name: "chains", type: "array" },
|
|
159
|
+
{ name: "summary", type: "string" },
|
|
160
|
+
],
|
|
161
|
+
},
|
|
162
|
+
// schemas/stage-report.json
|
|
163
|
+
report: {
|
|
164
|
+
required: [
|
|
165
|
+
{ name: "target", type: "string" },
|
|
166
|
+
{ name: "pipeline_status", type: "string", enum: ["complete", "partial", "aborted"] },
|
|
167
|
+
{ name: "findings", type: "array" },
|
|
168
|
+
{ name: "coverage", type: "object" }, // patternProperties object, not array
|
|
169
|
+
{ name: "summary", type: "string" },
|
|
170
|
+
],
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const MAX_REPAIR_ATTEMPTS = 2;
|
|
175
|
+
|
|
176
|
+
// Segment-based test-path detection: matches "test", "__tests__", "specs",
|
|
177
|
+
// "e2e", "test-utils", "fixtures", ... anchored per path segment so
|
|
178
|
+
// "latest"/"contest"/"attest" do NOT match. A regex-only version missed
|
|
179
|
+
// leading underscores ("__tests__").
|
|
180
|
+
const TEST_SEGMENT_RE =
|
|
181
|
+
/^[._-]*(tests?|specs?|e2e|fixtures?|mocks?|stubs?|examples?|samples?|test[-_]?data|test[-_]?utils)[._-]*$/i;
|
|
182
|
+
const TEST_FILE_RE =
|
|
183
|
+
/([._-](test|spec|mock|fixture|stub|example|sample)\.[a-z0-9]+$|^test[-_]utils\.[a-z0-9]+$)/i;
|
|
184
|
+
|
|
185
|
+
/** Chain items: each must have title, severity, steps (≥2), narrative. */
|
|
186
|
+
const CHAIN_SEVERITIES = ["low", "medium", "high", "critical"] as const;
|
|
187
|
+
|
|
188
|
+
// ── Pre-filter constants (hunt stage only) ───────────────────────────
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Test/mock/example paths carry no real findings (mirrors VVAH S5). Exception
|
|
192
|
+
* from VVAH deliberately not copied: hardcoded-creds-in-test-files — the
|
|
193
|
+
* auditor can submit those under vuln_class "other"+bugClass documentation;
|
|
194
|
+
* the gate errs on filtering noise.
|
|
195
|
+
*/
|
|
196
|
+
|
|
197
|
+
/** Trivial dedup: same file + vuln_class + line within this tolerance. */
|
|
198
|
+
const DEDUP_LINE_TOLERANCE = 10;
|
|
199
|
+
|
|
200
|
+
// ── Persistence ─────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
type FindingRef = {
|
|
203
|
+
key: string;
|
|
204
|
+
file: string;
|
|
205
|
+
line?: number;
|
|
206
|
+
vuln_class: string;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
type SubmitState = {
|
|
210
|
+
repairs: Record<string, number>;
|
|
211
|
+
accepted_findings: FindingRef[];
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
function statePath(runId: string): string {
|
|
215
|
+
return join(getRunDir(runId), "pipeline-submit.json");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function readState(runId: string): SubmitState {
|
|
219
|
+
const p = statePath(runId);
|
|
220
|
+
if (!existsSync(p)) return { repairs: {}, accepted_findings: [] };
|
|
221
|
+
try {
|
|
222
|
+
const raw = JSON.parse(readFileSync(p, "utf8")) as Partial<SubmitState>;
|
|
223
|
+
return {
|
|
224
|
+
repairs: raw.repairs ?? {},
|
|
225
|
+
accepted_findings: raw.accepted_findings ?? [],
|
|
226
|
+
};
|
|
227
|
+
} catch {
|
|
228
|
+
return { repairs: {}, accepted_findings: [] };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function writeState(runId: string, state: SubmitState): void {
|
|
233
|
+
writeFileSync(statePath(runId), JSON.stringify(state, null, 2), "utf8");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Project root containing the scratchpad (file-existence checks resolve here). */
|
|
237
|
+
function projectRoot(): string {
|
|
238
|
+
return dirname(getScratchpadRoot());
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── Parsing ──────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
function parseOutput(output: unknown): { obj?: Record<string, unknown>; error?: string } {
|
|
244
|
+
if (typeof output === "object" && output !== null && !Array.isArray(output)) {
|
|
245
|
+
return { obj: output as Record<string, unknown> };
|
|
246
|
+
}
|
|
247
|
+
if (typeof output !== "string") {
|
|
248
|
+
return { error: "output must be a JSON object or a JSON string" };
|
|
249
|
+
}
|
|
250
|
+
let text = output.trim();
|
|
251
|
+
// Tolerate markdown code fences around the payload.
|
|
252
|
+
text = text.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "");
|
|
253
|
+
try {
|
|
254
|
+
const parsed = JSON.parse(text);
|
|
255
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
256
|
+
return { error: "output must parse to a JSON object" };
|
|
257
|
+
}
|
|
258
|
+
return { obj: parsed as Record<string, unknown> };
|
|
259
|
+
} catch (e) {
|
|
260
|
+
return { error: `output is not valid JSON: ${(e as Error).message.slice(0, 120)}` };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Stable repair-bucket key for a submission. */
|
|
265
|
+
function submissionKey(stage: SubmitStage, obj: Record<string, unknown>): string {
|
|
266
|
+
const id =
|
|
267
|
+
(typeof obj.finding_id === "string" && obj.finding_id) ||
|
|
268
|
+
(typeof obj.title === "string" && obj.title) ||
|
|
269
|
+
(typeof obj.id === "string" && obj.id);
|
|
270
|
+
const tail = id ?? createHash("sha1").update(JSON.stringify(obj)).digest("hex").slice(0, 8);
|
|
271
|
+
return `${stage}:${tail}`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── Validation ──────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
function isNonEmptyString(v: unknown): v is string {
|
|
277
|
+
return typeof v === "string" && v.trim().length > 0;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function validateStage(stage: SubmitStage, obj: Record<string, unknown>): string[] {
|
|
281
|
+
const spec = SPECS[stage];
|
|
282
|
+
const errors: string[] = [];
|
|
283
|
+
|
|
284
|
+
for (const field of spec.required) {
|
|
285
|
+
const v = obj[field.name];
|
|
286
|
+
if (field.type === "string") {
|
|
287
|
+
if (!isNonEmptyString(v)) {
|
|
288
|
+
errors.push(`${field.name}: missing or empty string`);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
} else if (field.type === "object") {
|
|
292
|
+
if (typeof v !== "object" || v === null || Array.isArray(v)) {
|
|
293
|
+
errors.push(`${field.name}: missing or not an object`);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
} else if (field.type === "integer") {
|
|
297
|
+
if (typeof v !== "number" || !Number.isInteger(v)) {
|
|
298
|
+
errors.push(`${field.name}: missing or not an integer`);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
} else {
|
|
302
|
+
if (!Array.isArray(v)) {
|
|
303
|
+
errors.push(`${field.name}: missing or not an array`);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (field.minItems !== undefined && v.length < field.minItems) {
|
|
307
|
+
errors.push(`${field.name}: needs at least ${field.minItems} item(s), got ${v.length}`);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (field.enum && !field.enum.includes(v as never)) {
|
|
312
|
+
errors.push(`${field.name}: "${String(v)}" not in { ${field.enum.join(" | ")} }`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (spec.locatorXor) {
|
|
317
|
+
const [a, b] = spec.locatorXor;
|
|
318
|
+
const hasSet = (set: string[]) =>
|
|
319
|
+
set.every((f) => (f === "line" ? Number.isInteger(obj[f]) : isNonEmptyString(obj[f])));
|
|
320
|
+
const hasA = hasSet(a);
|
|
321
|
+
const hasB = hasSet(b);
|
|
322
|
+
if (hasA === hasB) {
|
|
323
|
+
errors.push(
|
|
324
|
+
`locator: provide exactly one of { ${a.join("+")} } (source) or { ${b.join("+")} } (live)`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
if (hasA && typeof obj.line === "number" && obj.line < 1) {
|
|
328
|
+
errors.push("line: must be >= 1");
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
for (const cond of spec.conditional ?? []) {
|
|
333
|
+
if (obj[cond.when.field] === cond.when.equals) {
|
|
334
|
+
for (const name of cond.require) {
|
|
335
|
+
if (!isNonEmptyString(obj[name])) {
|
|
336
|
+
errors.push(`${name}: required when ${cond.when.field} = ${cond.when.equals}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Chain items have their own inner contract (≥2 steps, severity enum).
|
|
343
|
+
if (stage === "chain" && Array.isArray(obj.chains)) {
|
|
344
|
+
obj.chains.forEach((c, i) => {
|
|
345
|
+
const chain = c as Record<string, unknown>;
|
|
346
|
+
if (!isNonEmptyString(chain.title)) errors.push(`chains[${i}].title: missing or empty`);
|
|
347
|
+
if (
|
|
348
|
+
!isNonEmptyString(chain.severity) ||
|
|
349
|
+
!(CHAIN_SEVERITIES as readonly string[]).includes(chain.severity)
|
|
350
|
+
) {
|
|
351
|
+
errors.push(`chains[${i}].severity: must be one of { ${CHAIN_SEVERITIES.join(" | ")} }`);
|
|
352
|
+
}
|
|
353
|
+
if (!Array.isArray(chain.steps) || chain.steps.length < 2) {
|
|
354
|
+
errors.push(`chains[${i}].steps: needs at least 2 case IDs`);
|
|
355
|
+
}
|
|
356
|
+
if (!isNonEmptyString(chain.narrative))
|
|
357
|
+
errors.push(`chains[${i}].narrative: missing or empty`);
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return errors;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ── Pre-filter + dedup (hunt only) ──────────────────────────────────
|
|
365
|
+
|
|
366
|
+
function prefilterHunt(obj: Record<string, unknown>): string | null {
|
|
367
|
+
const file = typeof obj.file === "string" ? obj.file : undefined;
|
|
368
|
+
if (!file) return null; // live target: endpoint locator, nothing to filter
|
|
369
|
+
const normalized = file.replace(/^\.?\//, "");
|
|
370
|
+
const segments = normalized.split("/");
|
|
371
|
+
if (segments.some((s) => TEST_SEGMENT_RE.test(s)) || TEST_FILE_RE.test(normalized)) {
|
|
372
|
+
return (
|
|
373
|
+
`test-path filter: "${file}" matches test/fixture/mock paths — findings in ` +
|
|
374
|
+
`test code are noise. If this is a deliberately-shipped test credential, ` +
|
|
375
|
+
`re-submit documenting why it ships to production.`
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
const root = projectRoot();
|
|
379
|
+
const abs = isAbsolute(normalized) ? resolve(normalized) : resolve(root, normalized);
|
|
380
|
+
// Containment: resolved path must stay inside the project, otherwise a
|
|
381
|
+
// "finding" can point at ../ or absolute files outside the target repo.
|
|
382
|
+
const rel = relative(root, abs);
|
|
383
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
384
|
+
return (
|
|
385
|
+
`containment filter: "${file}" resolves outside the project root (${root}). ` +
|
|
386
|
+
`Findings must reference files inside the target repository.`
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
if (!existsSync(abs)) {
|
|
390
|
+
return (
|
|
391
|
+
`file-existence filter: "${file}" does not exist under the project root ` +
|
|
392
|
+
`(${root}). Hallucinated paths are rejected outright.`
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function dedupHunt(state: SubmitState, obj: Record<string, unknown>): { duplicateOf?: string } {
|
|
399
|
+
const file = typeof obj.file === "string" ? obj.file.replace(/^\.?\//, "") : undefined;
|
|
400
|
+
const vulnClass = typeof obj.vuln_class === "string" ? obj.vuln_class : undefined;
|
|
401
|
+
const line = typeof obj.line === "number" ? obj.line : undefined;
|
|
402
|
+
if (!file || !vulnClass) return {};
|
|
403
|
+
for (const accepted of state.accepted_findings) {
|
|
404
|
+
if (accepted.vuln_class !== vulnClass) continue;
|
|
405
|
+
if (accepted.file !== file) continue;
|
|
406
|
+
if (
|
|
407
|
+
line !== undefined &&
|
|
408
|
+
accepted.line !== undefined &&
|
|
409
|
+
Math.abs(line - accepted.line) > DEDUP_LINE_TOLERANCE
|
|
410
|
+
) {
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
return { duplicateOf: accepted.key };
|
|
414
|
+
}
|
|
415
|
+
return {};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ── Public API ───────────────────────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
const STAGE_TO_PHASE: Record<SubmitStage, ScratchpadPhase> = {
|
|
421
|
+
hunt: "hunt",
|
|
422
|
+
trace: "trace",
|
|
423
|
+
skeptic: "skeptic",
|
|
424
|
+
validate: "validate",
|
|
425
|
+
chain: "chain",
|
|
426
|
+
report: "report",
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
export function pipeline_submit(runId: string, stage: SubmitStage, output: unknown): SubmitResult {
|
|
430
|
+
const parsed = parseOutput(output);
|
|
431
|
+
if (parsed.error || !parsed.obj) {
|
|
432
|
+
const state = readState(runId);
|
|
433
|
+
const key = `${stage}:unparseable`;
|
|
434
|
+
state.repairs[key] = (state.repairs[key] ?? 0) + 1;
|
|
435
|
+
const attempt = state.repairs[key];
|
|
436
|
+
// Persist before BOTH returns — otherwise unparseable output bypasses the
|
|
437
|
+
// repair budget forever (counter never hits disk on the rejected path).
|
|
438
|
+
writeState(runId, state);
|
|
439
|
+
if (attempt > MAX_REPAIR_ATTEMPTS) {
|
|
440
|
+
return { verdict: "rejected", stage, errors: [parsed.error ?? "unparseable"], key };
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
verdict: "repair",
|
|
444
|
+
stage,
|
|
445
|
+
errors: [parsed.error ?? "unparseable"],
|
|
446
|
+
repair_attempt: attempt,
|
|
447
|
+
key,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const obj = parsed.obj;
|
|
452
|
+
const key = submissionKey(stage, obj);
|
|
453
|
+
|
|
454
|
+
const errors = validateStage(stage, obj);
|
|
455
|
+
if (errors.length > 0) {
|
|
456
|
+
const state = readState(runId);
|
|
457
|
+
state.repairs[key] = (state.repairs[key] ?? 0) + 1;
|
|
458
|
+
const attempt = state.repairs[key];
|
|
459
|
+
if (attempt > MAX_REPAIR_ATTEMPTS) {
|
|
460
|
+
writeState(runId, state);
|
|
461
|
+
return {
|
|
462
|
+
verdict: "rejected",
|
|
463
|
+
stage,
|
|
464
|
+
errors: [...errors, `repair budget exhausted (${MAX_REPAIR_ATTEMPTS} attempts)`],
|
|
465
|
+
key,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
writeState(runId, state);
|
|
469
|
+
return { verdict: "repair", stage, errors, repair_attempt: attempt, key };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Hunt stage: deterministic noise gates before acceptance.
|
|
473
|
+
if (stage === "hunt") {
|
|
474
|
+
const filtered = prefilterHunt(obj);
|
|
475
|
+
if (filtered) {
|
|
476
|
+
return { verdict: "rejected", stage, errors: [filtered], key };
|
|
477
|
+
}
|
|
478
|
+
const state = readState(runId);
|
|
479
|
+
const { duplicateOf } = dedupHunt(state, obj);
|
|
480
|
+
if (duplicateOf) {
|
|
481
|
+
return {
|
|
482
|
+
verdict: "rejected",
|
|
483
|
+
stage,
|
|
484
|
+
errors: [
|
|
485
|
+
`trivial dedup: same file + vuln_class within ${DEDUP_LINE_TOLERANCE} lines of accepted finding ${duplicateOf}`,
|
|
486
|
+
],
|
|
487
|
+
key,
|
|
488
|
+
duplicate_of: duplicateOf,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
if (typeof obj.file === "string" && typeof obj.vuln_class === "string") {
|
|
492
|
+
state.accepted_findings.push({
|
|
493
|
+
key,
|
|
494
|
+
file: obj.file.replace(/^\.?\//, ""),
|
|
495
|
+
line: typeof obj.line === "number" ? obj.line : undefined,
|
|
496
|
+
vuln_class: obj.vuln_class,
|
|
497
|
+
});
|
|
498
|
+
writeState(runId, state);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const artifact = scratchpad_write(
|
|
503
|
+
runId,
|
|
504
|
+
STAGE_TO_PHASE[stage],
|
|
505
|
+
`${key.replace(/[^a-zA-Z0-9._:-]/g, "_")}.json`,
|
|
506
|
+
JSON.stringify(obj, null, 2),
|
|
507
|
+
);
|
|
508
|
+
return { verdict: "accepted", stage, errors: [], key, artifact };
|
|
509
|
+
}
|
package/src/scratchpad.ts
CHANGED
|
@@ -47,8 +47,6 @@ export interface ScratchpadCheckpoint {
|
|
|
47
47
|
phase_ids: Record<ScratchpadPhase, string[]>;
|
|
48
48
|
/** Free-form summary per phase, set by checkpoint(). */
|
|
49
49
|
phase_summaries: Record<ScratchpadPhase, string>;
|
|
50
|
-
/** Whether the run is fully complete. */
|
|
51
|
-
done: boolean;
|
|
52
50
|
}
|
|
53
51
|
|
|
54
52
|
export interface ScratchpadResume {
|
|
@@ -98,7 +96,9 @@ let scratchpadRootOverride: string | undefined;
|
|
|
98
96
|
function detectWorkspaceRoot(): string {
|
|
99
97
|
if (scratchpadRootOverride) return scratchpadRootOverride;
|
|
100
98
|
|
|
101
|
-
|
|
99
|
+
// PWD is deliberately excluded (shell-set, can be stale/forged); explicit
|
|
100
|
+
// overrides only, then walk up from the real cwd.
|
|
101
|
+
const envs = ["XPI_SCRATCHPAD_ROOT", "PI_WORKSPACE_ROOT", "GITHUB_WORKSPACE"];
|
|
102
102
|
for (const e of envs) {
|
|
103
103
|
const v = process.env[e];
|
|
104
104
|
if (v) return resolve(v);
|
|
@@ -125,9 +125,24 @@ export function getScratchpadRoot(projectRoot?: string): string {
|
|
|
125
125
|
return join(root, SCRATCHPAD_DIR);
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Sanitize a run_id into a single safe directory name. Unlike artifact names,
|
|
130
|
+
* run_ids arrive from the agent and were never sanitized — `..`/`/` would let
|
|
131
|
+
* join() escape .scratchpad, turning ScratchpadClear("..") into a recursive
|
|
132
|
+
* delete of the project root. Same allowlist as artifact names, plus rejection
|
|
133
|
+
* of dot-only results (.", "..", or a sanitized empty string).
|
|
134
|
+
*/
|
|
135
|
+
function sanitizeRunId(runId: string): string {
|
|
136
|
+
const safe = runId.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
137
|
+
if (!safe || /^\.*$/.test(safe)) {
|
|
138
|
+
throw new Error(`Invalid run_id: "${runId}" — nothing left after sanitization`);
|
|
139
|
+
}
|
|
140
|
+
return safe;
|
|
141
|
+
}
|
|
142
|
+
|
|
128
143
|
/** The directory for a specific run. */
|
|
129
144
|
export function getRunDir(runId: string, projectRoot?: string): string {
|
|
130
|
-
return join(getScratchpadRoot(projectRoot), runId);
|
|
145
|
+
return join(getScratchpadRoot(projectRoot), sanitizeRunId(runId));
|
|
131
146
|
}
|
|
132
147
|
|
|
133
148
|
/** The state.json path for a run. */
|
|
@@ -146,7 +161,6 @@ function emptyCheckpoint(runId: string, projectRoot: string): ScratchpadCheckpoi
|
|
|
146
161
|
completed_phases: [],
|
|
147
162
|
phase_ids: {} as Record<ScratchpadPhase, string[]>,
|
|
148
163
|
phase_summaries: {} as Record<ScratchpadPhase, string>,
|
|
149
|
-
done: false,
|
|
150
164
|
};
|
|
151
165
|
}
|
|
152
166
|
|
|
@@ -323,37 +337,3 @@ export function scratchpad_clear(runId: string, projectRoot?: string): void {
|
|
|
323
337
|
const runDir = getRunDir(runId, root);
|
|
324
338
|
if (existsSync(runDir)) rmSync(runDir, { recursive: true, force: true });
|
|
325
339
|
}
|
|
326
|
-
|
|
327
|
-
/**
|
|
328
|
-
* Clear the entire scratchpad directory (all runs). Used by `--fresh` with no
|
|
329
|
-
* run ID. Use with care.
|
|
330
|
-
*/
|
|
331
|
-
export function scratchpad_clear_all(projectRoot?: string): void {
|
|
332
|
-
const root = projectRoot ?? detectWorkspaceRoot();
|
|
333
|
-
const dir = getScratchpadRoot(root);
|
|
334
|
-
if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/**
|
|
338
|
-
* List all run IDs in the scratchpad (for resume selection).
|
|
339
|
-
*/
|
|
340
|
-
export function scratchpad_list_runs(projectRoot?: string): string[] {
|
|
341
|
-
const root = projectRoot ?? detectWorkspaceRoot();
|
|
342
|
-
const dir = getScratchpadRoot(root);
|
|
343
|
-
if (!existsSync(dir)) return [];
|
|
344
|
-
return readdirSync(dir, { withFileTypes: true })
|
|
345
|
-
.filter((e) => e.isDirectory())
|
|
346
|
-
.map((e) => e.name)
|
|
347
|
-
.sort();
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/**
|
|
351
|
-
* Mark the run as fully done. Prevents resume from re-entering.
|
|
352
|
-
*/
|
|
353
|
-
export function scratchpad_finish(runId: string, projectRoot?: string): ScratchpadCheckpoint {
|
|
354
|
-
const root = projectRoot ?? detectWorkspaceRoot();
|
|
355
|
-
const cp = readCheckpointRaw(runId, root) ?? scratchpad_init(runId, root);
|
|
356
|
-
cp.done = true;
|
|
357
|
-
writeCheckpointRaw(cp, root);
|
|
358
|
-
return cp;
|
|
359
|
-
}
|
package/src/workflow.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cyber workflow injected into agent context when XP mode is ON.
|
|
3
3
|
*
|
|
4
|
-
* Skills (
|
|
4
|
+
* Skills (cyberwf, web-pentest) already cover tool usage and methodology.
|
|
5
5
|
* This file adds the unique attacker discipline: state machine with
|
|
6
6
|
* preconditions, attacker model, impact validation, adversarial review,
|
|
7
7
|
* kill checklist, and report-readiness criteria.
|
|
@@ -17,6 +17,16 @@ Think like a real external attacker, not a code reviewer. Technical bugs are che
|
|
|
17
17
|
|
|
18
18
|
Every lead starts HYPOTHESIS. Nothing reaches CONFIRMED without a proven attacker path and demonstrated impact against a real production target or faithful replica.
|
|
19
19
|
|
|
20
|
+
## Tool Reference
|
|
21
|
+
|
|
22
|
+
**Casefile (state tracking):** CaseAdd, CaseUpdate, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseReport, PromoteFinding, PipelineSubmit
|
|
23
|
+
|
|
24
|
+
**Scratchpad (pipeline artifacts):** ScratchpadInit, ScratchpadResume, ScratchpadCheckpoint, ScratchpadWrite, ScratchpadRead, ScratchpadPhaseDone, ScratchpadClear
|
|
25
|
+
|
|
26
|
+
**Web lookup (research):** web_search, web_fetch, exploit_search, context7, deepwiki, http_request
|
|
27
|
+
|
|
28
|
+
**Subagent dispatch:** \`subagent({agent: "auditor"|"tracer"|"skeptic"|"exploit"|"chain", task: "..."})\` — use this to dispatch specialist agents. Do NOT do the specialist work yourself.
|
|
29
|
+
|
|
20
30
|
## Case Lifecycle (State Machine)
|
|
21
31
|
|
|
22
32
|
\`\`\`
|
|
@@ -47,7 +57,7 @@ RECON -> HYPOTHESIS --+
|
|
|
47
57
|
| Advance To | Required Case Fields | Must Exist on Disk |
|
|
48
58
|
|-----------|---------------------|--------------------|
|
|
49
59
|
| HYPOTHESIS -> INVESTIGATING | evidence (observations or initial findings), confidence | Notes on what was observed |
|
|
50
|
-
| INVESTIGATING -> **CONFIRMED** | evidence, poc (steps/script), **impact (see below for content requirements)**, severity, **target (host/repo/scope this affects)**, **disconfirmation (your documented attempt to disprove the finding)** | PoC script +
|
|
60
|
+
| INVESTIGATING -> **CONFIRMED** | evidence, poc (steps/script), **impact (see below for content requirements)**, severity, **target (host/repo/scope this affects)**, **disconfirmation (your documented attempt to disprove the finding)** | PoC script + exit 0 + **verification_marker present in output** (proves the exploit actually worked, not just that the script ran). Optionally, disconfirmation script run.log exit non-0 (finding survived the attempt to disprove). |
|
|
51
61
|
| Any -> KILLED | assumptions (why it died) | --- |
|
|
52
62
|
| CONFIRMED -> REPORTED | Only after CaseReport(id) succeeds | Report file |
|
|
53
63
|
|
|
@@ -109,22 +119,20 @@ Before promoting to CONFIRMED, the following must be fully answered and document
|
|
|
109
119
|
|
|
110
120
|
If you cannot name a concrete attacker who gains something they should not have -> do **not** confirm. Stay INVESTIGATING or KILL with documented reason.
|
|
111
121
|
|
|
112
|
-
### 1. Disconfirmation
|
|
122
|
+
### 1. Disconfirmation (mandatory before CONFIRMED)
|
|
113
123
|
|
|
114
|
-
Before promoting,
|
|
115
|
-
|
|
116
|
-
|
|
124
|
+
Before promoting to CONFIRMED, the finding must survive an attempt to disprove it. There are two tiers, gated on the auditor's \`confidence\` (severity doesn't exist yet — the exploit agent assigns it only after the PoC runs):
|
|
125
|
+
|
|
126
|
+
**\`confidence: high\` → skeptic subagent (MANDATORY):** You MUST dispatch a skeptic subagent via \`subagent({agent: "skeptic", task: "..."})\` BEFORE the exploit agent runs. The skeptic independently re-reads the source (or re-probes live), verifies the finding is in scope per the program's scope instruction, and tries to disprove it. The skeptic's \`disconfirmation_attempt\` is written into the case's \`disconfirmation\` field — it satisfies this gate and is stronger than self-disconfirmation because a separate agent produced it. If the skeptic says DISPROVEN, the finding is killed directly — no tie-breaker. Do NOT skip this step. Do NOT self-disconfirm high-confidence findings.
|
|
127
|
+
|
|
128
|
+
**Below confidence high → self-disconfirmation:** You must actively attempt to disprove your own finding. Document the attempt in the \`disconfirmation\` field. This is not a formality.
|
|
117
129
|
|
|
118
130
|
**What a disconfirmation attempt looks like:**
|
|
119
131
|
|
|
120
|
-
- Reproduce the finding under different conditions (different auth, different
|
|
121
|
-
|
|
122
|
-
-
|
|
123
|
-
|
|
124
|
-
- Attempt to trigger protections (WAF, CSP, CSRF, rate limits) that would
|
|
125
|
-
block the path in production.
|
|
126
|
-
- Try to prove the root cause is wrong: can the same behavior be triggered
|
|
127
|
-
without the attacker-controlled input you identified?
|
|
132
|
+
- Reproduce the finding under different conditions (different auth, different config, different network position). If it fails, you disproved the scope.
|
|
133
|
+
- Check if the behavior is intentional by testing against documentation or by trying to get the same result on a known-baseline endpoint.
|
|
134
|
+
- Attempt to trigger protections (WAF, CSP, CSRF, rate limits) that would block the path in production.
|
|
135
|
+
- Try to prove the root cause is wrong: can the same behavior be triggered without the attacker-controlled input you identified?
|
|
128
136
|
|
|
129
137
|
**Document the attempt in \`disconfirmation\` field.** Must include:
|
|
130
138
|
1. What you tried to do to disprove the finding
|
|
@@ -133,21 +141,12 @@ field and verified by the optional \`disconfirmation_path\` in PromoteFinding.
|
|
|
133
141
|
4. Why you believe the disconfirmation attempt was valid
|
|
134
142
|
|
|
135
143
|
**Strong disconfirmation that passes the gate:**
|
|
136
|
-
"Attempted to read /api/users/123 as user B after confirming user A owns
|
|
137
|
-
record 123. The endpoint returned 403 for user B, confirming the IDOR
|
|
138
|
-
protection works as expected. However, when we modified the request to
|
|
139
|
-
include the X-Override-User header seen in admin traffic, the endpoint
|
|
140
|
-
returned user A's data. The protection is bypassed via the admin header."
|
|
144
|
+
"Attempted to read /api/users/123 as user B after confirming user A owns record 123. The endpoint returned 403 for user B, confirming the IDOR protection works as expected. However, when we modified the request to include the X-Override-User header seen in admin traffic, the endpoint returned user A's data. The protection is bypassed via the admin header."
|
|
141
145
|
|
|
142
146
|
**Weak disconfirmation:**
|
|
143
147
|
"Tried to disprove. Could not."
|
|
144
148
|
|
|
145
|
-
If the disconfirmation script (disconfirmation_path) exits 0, the finding
|
|
146
|
-
is considered disproven and promotion is blocked. If you cannot write a
|
|
147
|
-
meaningful disconfirmation script, you may not understand the finding well
|
|
148
|
-
enough to promote it.
|
|
149
|
-
|
|
150
|
-
**Adversarial disconfirmation (skeptic subagent):** For findings at severity >= high, a dedicated skeptic subagent independently re-reads the source and tries to disprove the finding BEFORE the exploit agent runs. The skeptic's \`disconfirmation_attempt\` is written into this \`disconfirmation\` field by the harness — it satisfies this gate and is stronger than self-disconfirmation because a separate agent produced it. If the skeptic says DISPROVEN, the finding is killed directly. Self-disconfirmation still applies for findings below high severity.
|
|
149
|
+
If the disconfirmation script (\`disconfirmation_path\` in PromoteFinding) exits 0, the finding is considered disproven and promotion is blocked. If you cannot write a meaningful disconfirmation script, you may not understand the finding well enough to promote it.
|
|
151
150
|
|
|
152
151
|
### 2. Production Path Verification (must be in impact field)
|
|
153
152
|
|
|
@@ -166,7 +165,7 @@ The **impact** field for CONFIRMED must explicitly answer:
|
|
|
166
165
|
|
|
167
166
|
You must name the **specific target host/repo** in the target field. If the finding only works on a dev instance with non-default config, document that honestly and consider whether it's KILL-worthy.
|
|
168
167
|
|
|
169
|
-
###
|
|
168
|
+
### 3. KILL at Validate stage
|
|
170
169
|
|
|
171
170
|
Documented intended behavior
|
|
172
171
|
- Self-XSS / self-DoS only (attacker harms only their own session)
|
|
@@ -177,11 +176,11 @@ Documented intended behavior
|
|
|
177
176
|
- PoC proves a code path exists but not that any victim asset is affected
|
|
178
177
|
- Protections in production block the path and are not bypassed
|
|
179
178
|
|
|
180
|
-
###
|
|
179
|
+
### 4. Evidence-First Doctrine
|
|
181
180
|
|
|
182
181
|
Every claim must be traceable to observed/reproduced behavior, source code, or documented platform behavior. If evidence is insufficient: state uncertainty and propose the next experiment. Never assume success where verification is incomplete.
|
|
183
182
|
|
|
184
|
-
###
|
|
183
|
+
### 5. Impact Gate
|
|
185
184
|
|
|
186
185
|
Prove at least **one** real attacker-facing violation against a production-viable target:
|
|
187
186
|
|
|
@@ -196,7 +195,16 @@ Impact text must answer: *who is hurt, what is lost, how the attacker reaches it
|
|
|
196
195
|
|
|
197
196
|
If impact is theoretical, needs a second unproven bug, or is not yet reachable from the attacker's position -> stay INVESTIGATING (chain it) or KILL.
|
|
198
197
|
|
|
199
|
-
|
|
198
|
+
**Severity is derived from PROVEN impact, not guessed.** Do not set severity until the PoC has exited 0 and the output demonstrates the impact. Map severity to what the PoC output actually shows:
|
|
199
|
+
- **critical** = RCE, account takeover, or direct fund theft — proven in PoC output
|
|
200
|
+
- **high** = sensitive data read/write, privilege escalation, SSRF to internal services — proven in PoC output
|
|
201
|
+
- **medium** = limited data exposure, XSS on sensitive page, IDOR on non-critical resources — proven in PoC output
|
|
202
|
+
- **low** = info leak, open redirect, self-only impact with a victim path — proven but minimal harm
|
|
203
|
+
- **info** = best-practice gap, no demonstrated impact
|
|
204
|
+
|
|
205
|
+
"Could lead to" / "may allow" / "theoretically" = NOT proven. Drop to the level the PoC output actually demonstrates. Under-claiming is safe; over-claiming gets the finding rejected at triage.
|
|
206
|
+
|
|
207
|
+
### 6. Adversarial Self-Review
|
|
200
208
|
|
|
201
209
|
1. Why this might NOT be a vulnerability.
|
|
202
210
|
2. Alternative explanations for the observation.
|
|
@@ -204,7 +212,7 @@ If impact is theoretical, needs a second unproven bug, or is not yet reachable f
|
|
|
204
212
|
4. What blocks a real attacker in production today and whether each is bypassed.
|
|
205
213
|
5. Would a program triage reject this as informative/N/A?
|
|
206
214
|
|
|
207
|
-
###
|
|
215
|
+
### 7. Root Cause -> Boundary -> Impact
|
|
208
216
|
|
|
209
217
|
\`\`\`
|
|
210
218
|
Entry (attacker-controlled) -> Code path -> Trust boundary crossed -> Victim impact
|