@xaccefy/pi-casefile 0.6.2 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/index.ts +510 -13
- package/src/ledger.ts +26 -19
- package/src/scratchpad.ts +339 -0
- package/src/workflow.ts +37 -27
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xaccefy/pi-casefile",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
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/scratchpad.ts",
|
|
38
39
|
"src/sqlite-compat/index.ts",
|
|
39
40
|
"skills",
|
|
40
41
|
"README.md",
|
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
|
|
4
|
+
* Tools: CaseAdd, CaseUpdate, PromoteFinding, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseReport, ScratchpadInit, ScratchpadResume, ScratchpadCheckpoint, ScratchpadWrite, ScratchpadRead, ScratchpadPhaseDone, ScratchpadClear
|
|
5
5
|
* Command: /casefile — interactive dashboard
|
|
6
6
|
* Event: before_agent_start — injects cyber workflow (+ active case list) once per user 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";
|
|
@@ -46,6 +45,17 @@ import {
|
|
|
46
45
|
writeCaseReport,
|
|
47
46
|
} from "./ledger.ts";
|
|
48
47
|
import { type PocRun, runPoc } from "./poc-runner.ts";
|
|
48
|
+
import {
|
|
49
|
+
type ScratchpadPhase,
|
|
50
|
+
type ScratchpadResume,
|
|
51
|
+
scratchpad_checkpoint,
|
|
52
|
+
scratchpad_clear,
|
|
53
|
+
scratchpad_init,
|
|
54
|
+
scratchpad_phase_done,
|
|
55
|
+
scratchpad_read,
|
|
56
|
+
scratchpad_resume,
|
|
57
|
+
scratchpad_write,
|
|
58
|
+
} from "./scratchpad.ts";
|
|
49
59
|
import { STATIC_CYBER_WORKFLOW } from "./workflow.ts";
|
|
50
60
|
|
|
51
61
|
// ── Schemas ───────────────────────────────────────────────────────────
|
|
@@ -111,6 +121,11 @@ const PromoteSchema = Type.Object(
|
|
|
111
121
|
poc_path: Type.String({
|
|
112
122
|
description: "Absolute path to the PoC script on disk",
|
|
113
123
|
}),
|
|
124
|
+
verification_marker: Type.String({
|
|
125
|
+
minLength: 1,
|
|
126
|
+
description:
|
|
127
|
+
"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.",
|
|
128
|
+
}),
|
|
114
129
|
disconfirmation_path: Type.Optional(
|
|
115
130
|
Type.String({
|
|
116
131
|
description:
|
|
@@ -216,6 +231,95 @@ const ReportSchema = Type.Object(
|
|
|
216
231
|
{ additionalProperties: false },
|
|
217
232
|
);
|
|
218
233
|
|
|
234
|
+
// ── Tool: Scratchpad ─────────────────────────────────────────────────
|
|
235
|
+
//
|
|
236
|
+
// The scratchpad is the pipeline's crash-recoverable artifact store.
|
|
237
|
+
// The casefile owns state transitions; the scratchpad owns artifacts
|
|
238
|
+
// (recon maps, trace outputs, verification logs). Resume re-reads
|
|
239
|
+
// artifacts; it does not re-run completed phases (idempotent).
|
|
240
|
+
|
|
241
|
+
const SCRATCHPAD_PHASES = [
|
|
242
|
+
"recon",
|
|
243
|
+
"hunt",
|
|
244
|
+
"gapfil",
|
|
245
|
+
"trace",
|
|
246
|
+
"skeptic",
|
|
247
|
+
"validate",
|
|
248
|
+
"chain",
|
|
249
|
+
"patch",
|
|
250
|
+
"report",
|
|
251
|
+
] as const;
|
|
252
|
+
|
|
253
|
+
const ScratchpadPhaseSchema = Type.String({
|
|
254
|
+
enum: [...SCRATCHPAD_PHASES],
|
|
255
|
+
description:
|
|
256
|
+
"Pipeline phase: recon | hunt | gapfil | trace | skeptic | validate | chain | patch | report",
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const ScratchpadInitSchema = Type.Object(
|
|
260
|
+
{
|
|
261
|
+
run_id: Type.String({ description: "Unique run identifier for this pipeline run" }),
|
|
262
|
+
},
|
|
263
|
+
{ additionalProperties: false },
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
const ScratchpadResumeSchema = Type.Object(
|
|
267
|
+
{
|
|
268
|
+
run_id: Type.String({ description: "Run identifier to resume" }),
|
|
269
|
+
},
|
|
270
|
+
{ additionalProperties: false },
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const ScratchpadCheckpointSchema = Type.Object(
|
|
274
|
+
{
|
|
275
|
+
run_id: Type.String({ description: "Run identifier" }),
|
|
276
|
+
phase: ScratchpadPhaseSchema,
|
|
277
|
+
ids: Type.Optional(
|
|
278
|
+
Type.Array(Type.String(), {
|
|
279
|
+
description: "Key IDs produced by this phase (case IDs, finding IDs)",
|
|
280
|
+
}),
|
|
281
|
+
),
|
|
282
|
+
summary: Type.Optional(Type.String({ description: "One-line summary of phase completion" })),
|
|
283
|
+
},
|
|
284
|
+
{ additionalProperties: false },
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
const ScratchpadWriteSchema = Type.Object(
|
|
288
|
+
{
|
|
289
|
+
run_id: Type.String({ description: "Run identifier" }),
|
|
290
|
+
phase: ScratchpadPhaseSchema,
|
|
291
|
+
artifact_name: Type.String({
|
|
292
|
+
description: "Artifact filename (sanitized; path traversal is blocked)",
|
|
293
|
+
}),
|
|
294
|
+
content: Type.String({ description: "Artifact content to write" }),
|
|
295
|
+
},
|
|
296
|
+
{ additionalProperties: false },
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
const ScratchpadReadSchema = Type.Object(
|
|
300
|
+
{
|
|
301
|
+
run_id: Type.String({ description: "Run identifier" }),
|
|
302
|
+
phase: ScratchpadPhaseSchema,
|
|
303
|
+
artifact_name: Type.String({ description: "Artifact filename to read" }),
|
|
304
|
+
},
|
|
305
|
+
{ additionalProperties: false },
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const ScratchpadPhaseDoneSchema = Type.Object(
|
|
309
|
+
{
|
|
310
|
+
run_id: Type.String({ description: "Run identifier" }),
|
|
311
|
+
phase: ScratchpadPhaseSchema,
|
|
312
|
+
},
|
|
313
|
+
{ additionalProperties: false },
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const ScratchpadClearSchema = Type.Object(
|
|
317
|
+
{
|
|
318
|
+
run_id: Type.String({ description: "Run identifier to clear (deletes that run only)" }),
|
|
319
|
+
},
|
|
320
|
+
{ additionalProperties: false },
|
|
321
|
+
);
|
|
322
|
+
|
|
219
323
|
interface Theme {
|
|
220
324
|
fg(color: string, text: string): string;
|
|
221
325
|
bold(text: string): string;
|
|
@@ -322,9 +426,7 @@ class CasefileDashboard {
|
|
|
322
426
|
|
|
323
427
|
if (this.records.length === 0) {
|
|
324
428
|
lines.push("");
|
|
325
|
-
lines.push(
|
|
326
|
-
` ${th.fg("dim", "No active security cases. Ask the agent to CaseAdd findings!")}`,
|
|
327
|
-
);
|
|
429
|
+
lines.push(` ${th.fg("dim", "No security cases yet. Ask the agent to CaseAdd findings!")}`);
|
|
328
430
|
} else {
|
|
329
431
|
lines.push("");
|
|
330
432
|
for (const r of this.records) {
|
|
@@ -427,11 +529,7 @@ export const XP_MODE_ENV = "PI_XP_MODE";
|
|
|
427
529
|
export type XpMode = "on" | "off";
|
|
428
530
|
|
|
429
531
|
export function getXpModeStatePath(): string {
|
|
430
|
-
|
|
431
|
-
return join(dirname(getCasefilePath()), "xp-mode");
|
|
432
|
-
} catch {
|
|
433
|
-
return join(homedir(), ".pi", "xp-mode");
|
|
434
|
-
}
|
|
532
|
+
return join(dirname(getCasefilePath()), "xp-mode");
|
|
435
533
|
}
|
|
436
534
|
|
|
437
535
|
export function readXpMode(
|
|
@@ -631,13 +729,14 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
631
729
|
name: "PromoteFinding",
|
|
632
730
|
label: "Promote Finding",
|
|
633
731
|
description:
|
|
634
|
-
"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).",
|
|
732
|
+
"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).",
|
|
635
733
|
promptSnippet: "Run a PoC and promote an investigating case to confirmed",
|
|
636
734
|
promptGuidelines: [
|
|
637
735
|
"Use PromoteFinding when an investigating case has a concrete PoC script on disk and you are ready to prove it.",
|
|
638
736
|
"The case must already have status='investigating' and non-empty poc, evidence, impact, severity, target, and disconfirmation fields.",
|
|
639
737
|
"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).",
|
|
640
|
-
"
|
|
738
|
+
"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.",
|
|
739
|
+
"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.",
|
|
641
740
|
"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.",
|
|
642
741
|
"Do not use CaseUpdate to set status='confirmed' directly — it is rejected. Always use PromoteFinding.",
|
|
643
742
|
],
|
|
@@ -648,6 +747,26 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
648
747
|
// 30s (plus first-time image pull), so fail cheap when the case can't
|
|
649
748
|
// advance anyway (missing, wrong status, missing required fields).
|
|
650
749
|
assertPromotable(params.id as string);
|
|
750
|
+
|
|
751
|
+
// Reject empty/whitespace markers BEFORE any PoC run — it's a param
|
|
752
|
+
// error, so fail cheap instead of burning a (up to 30s) sandboxed run.
|
|
753
|
+
const marker = (params.verification_marker as string | undefined)?.trim();
|
|
754
|
+
if (!marker) {
|
|
755
|
+
return {
|
|
756
|
+
content: [
|
|
757
|
+
{
|
|
758
|
+
type: "text",
|
|
759
|
+
text:
|
|
760
|
+
"verification_marker is empty or whitespace. " +
|
|
761
|
+
"A non-empty marker printed only AFTER the PoC confirms exploitation is required — " +
|
|
762
|
+
"exit code 0 alone is not sufficient. Case remains investigating.",
|
|
763
|
+
},
|
|
764
|
+
],
|
|
765
|
+
isError: true,
|
|
766
|
+
details: { record: getCaseById(params.id as string) },
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
651
770
|
const run = runPoc(params.poc_path as string, params.local !== true);
|
|
652
771
|
|
|
653
772
|
// Fail closed without throwing: non-zero PoC must leave the case investigating.
|
|
@@ -665,6 +784,28 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
665
784
|
};
|
|
666
785
|
}
|
|
667
786
|
|
|
787
|
+
// Verification marker check: exit code 0 alone is NOT sufficient.
|
|
788
|
+
// The PoC must print the verification_marker to stdout, proving the
|
|
789
|
+
// exploit actually worked — not just that the script ran. This blocks
|
|
790
|
+
// fluke exit 0 (crash before real logic) and mocked PoCs that don't
|
|
791
|
+
// actually exploit the target.
|
|
792
|
+
if (!(run.output ?? "").includes(marker)) {
|
|
793
|
+
const record = getCaseById(params.id as string);
|
|
794
|
+
return {
|
|
795
|
+
content: [
|
|
796
|
+
{
|
|
797
|
+
type: "text",
|
|
798
|
+
text:
|
|
799
|
+
`PoC exited 0 but the verification marker "${marker}" was NOT found in the output.\n` +
|
|
800
|
+
`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` +
|
|
801
|
+
`Do not print the marker unconditionally — print it only when the exploit is confirmed.\n\nOutput:\n${run.output}`,
|
|
802
|
+
},
|
|
803
|
+
],
|
|
804
|
+
isError: true,
|
|
805
|
+
details: { record, run, markerMissing: true },
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
|
|
668
809
|
// Run disconfirmation script if provided — must exit NON-0 (finding survived the attempt to disprove).
|
|
669
810
|
let disconfirmationRun: PocRun | undefined;
|
|
670
811
|
if (params.disconfirmation_path) {
|
|
@@ -1012,7 +1153,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1012
1153
|
name: "CaseReport",
|
|
1013
1154
|
label: "Write Case Report",
|
|
1014
1155
|
description:
|
|
1015
|
-
"Generate a markdown report from a confirmed or reported case under the
|
|
1156
|
+
"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.",
|
|
1016
1157
|
promptSnippet: "Generate a bounty-style markdown report from a case",
|
|
1017
1158
|
promptGuidelines: [
|
|
1018
1159
|
"Use CaseReport only for confirmed or already reported cases. Keep hypotheses and investigating cases in the ledger until proof is captured.",
|
|
@@ -1066,6 +1207,362 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1066
1207
|
},
|
|
1067
1208
|
});
|
|
1068
1209
|
|
|
1210
|
+
// ── Tool: ScratchpadInit ──
|
|
1211
|
+
|
|
1212
|
+
pi.registerTool({
|
|
1213
|
+
name: "ScratchpadInit",
|
|
1214
|
+
label: "Init Scratchpad",
|
|
1215
|
+
description:
|
|
1216
|
+
"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.",
|
|
1217
|
+
promptSnippet: "Initialize the pipeline artifact store for a run",
|
|
1218
|
+
promptGuidelines: [
|
|
1219
|
+
"Call ScratchpadInit once at the start of a pipeline run (or on resume before ScratchpadResume).",
|
|
1220
|
+
"The run_id is arbitrary but should be unique per pipeline run — typically <target>-<timestamp>.",
|
|
1221
|
+
"On resume, ScratchpadInit returns the existing checkpoint without wiping it; pair with ScratchpadResume to skip completed phases.",
|
|
1222
|
+
],
|
|
1223
|
+
parameters: ScratchpadInitSchema,
|
|
1224
|
+
|
|
1225
|
+
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1226
|
+
const cp = scratchpad_init(params.run_id as string);
|
|
1227
|
+
return {
|
|
1228
|
+
content: [
|
|
1229
|
+
{
|
|
1230
|
+
type: "text",
|
|
1231
|
+
text: `Scratchpad initialized for run ${cp.run_id}.\nCompleted phases: ${cp.completed_phases.length ? cp.completed_phases.join(", ") : "none"}`,
|
|
1232
|
+
},
|
|
1233
|
+
],
|
|
1234
|
+
details: { checkpoint: cp },
|
|
1235
|
+
};
|
|
1236
|
+
},
|
|
1237
|
+
|
|
1238
|
+
renderCall(args, theme) {
|
|
1239
|
+
return new Text(
|
|
1240
|
+
theme.fg("toolTitle", theme.bold("ScratchpadInit ")) +
|
|
1241
|
+
theme.fg("dim", (args.run_id as string) ?? ""),
|
|
1242
|
+
0,
|
|
1243
|
+
0,
|
|
1244
|
+
);
|
|
1245
|
+
},
|
|
1246
|
+
|
|
1247
|
+
renderResult(result, _opts, theme) {
|
|
1248
|
+
const cp = (result.details as { checkpoint: { run_id: string } } | undefined)?.checkpoint;
|
|
1249
|
+
return new Text(`${theme.fg("success", "✓ ")}ScratchpadInit ${cp?.run_id ?? ""}`, 0, 0);
|
|
1250
|
+
},
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
// ── Tool: ScratchpadResume ──
|
|
1254
|
+
|
|
1255
|
+
pi.registerTool({
|
|
1256
|
+
name: "ScratchpadResume",
|
|
1257
|
+
label: "Resume Scratchpad",
|
|
1258
|
+
description:
|
|
1259
|
+
"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.",
|
|
1260
|
+
promptSnippet: "Check pipeline resume state — which phases are done",
|
|
1261
|
+
promptGuidelines: [
|
|
1262
|
+
"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.",
|
|
1263
|
+
"If ScratchpadResume returns null, the run has no checkpoint — call ScratchpadInit to start fresh.",
|
|
1264
|
+
"Use ScratchpadPhaseDone before dispatching each stage to avoid re-running completed phases (idempotent resume).",
|
|
1265
|
+
],
|
|
1266
|
+
parameters: ScratchpadResumeSchema,
|
|
1267
|
+
|
|
1268
|
+
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1269
|
+
const resume = scratchpad_resume(params.run_id as string);
|
|
1270
|
+
if (!resume) {
|
|
1271
|
+
return {
|
|
1272
|
+
content: [
|
|
1273
|
+
{
|
|
1274
|
+
type: "text",
|
|
1275
|
+
text: `No scratchpad found for run ${params.run_id}. Call ScratchpadInit to start a new run.`,
|
|
1276
|
+
},
|
|
1277
|
+
],
|
|
1278
|
+
details: { resume: null },
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
const cp = resume.checkpoint;
|
|
1282
|
+
return {
|
|
1283
|
+
content: [
|
|
1284
|
+
{
|
|
1285
|
+
type: "text",
|
|
1286
|
+
text:
|
|
1287
|
+
`Resume run ${cp.run_id}:\n` +
|
|
1288
|
+
`Completed phases: ${cp.completed_phases.length ? cp.completed_phases.join(", ") : "none"}\n` +
|
|
1289
|
+
`Next phase: ${resume.next_phase ?? "none (run is done)"}`,
|
|
1290
|
+
},
|
|
1291
|
+
],
|
|
1292
|
+
details: { resume },
|
|
1293
|
+
};
|
|
1294
|
+
},
|
|
1295
|
+
|
|
1296
|
+
renderCall(args, theme) {
|
|
1297
|
+
return new Text(
|
|
1298
|
+
theme.fg("toolTitle", theme.bold("ScratchpadResume ")) +
|
|
1299
|
+
theme.fg("dim", (args.run_id as string) ?? ""),
|
|
1300
|
+
0,
|
|
1301
|
+
0,
|
|
1302
|
+
);
|
|
1303
|
+
},
|
|
1304
|
+
|
|
1305
|
+
renderResult(result, _opts, theme) {
|
|
1306
|
+
const resume = (result.details as { resume: ScratchpadResume | null } | undefined)?.resume;
|
|
1307
|
+
if (!resume) return new Text(theme.fg("warning", "↷ ScratchpadResume — no run found"), 0, 0);
|
|
1308
|
+
return new Text(
|
|
1309
|
+
theme.fg("success", "✓ ") +
|
|
1310
|
+
`ScratchpadResume ${resume.checkpoint.run_id} → next: ${resume.next_phase ?? "done"}`,
|
|
1311
|
+
0,
|
|
1312
|
+
0,
|
|
1313
|
+
);
|
|
1314
|
+
},
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
// ── Tool: ScratchpadCheckpoint ──
|
|
1318
|
+
|
|
1319
|
+
pi.registerTool({
|
|
1320
|
+
name: "ScratchpadCheckpoint",
|
|
1321
|
+
label: "Checkpoint Phase",
|
|
1322
|
+
description:
|
|
1323
|
+
"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.",
|
|
1324
|
+
promptSnippet: "Record a pipeline phase as complete",
|
|
1325
|
+
promptGuidelines: [
|
|
1326
|
+
"Call ScratchpadCheckpoint after every phase completes: ScratchpadCheckpoint(run_id, phase, { ids, summary }).",
|
|
1327
|
+
"ids are the key case/finding IDs the phase produced — used by resume to reconstruct state.",
|
|
1328
|
+
"Keep completed_phases in pipeline order; the checkpoint sorts automatically.",
|
|
1329
|
+
],
|
|
1330
|
+
parameters: ScratchpadCheckpointSchema,
|
|
1331
|
+
|
|
1332
|
+
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1333
|
+
const cp = scratchpad_checkpoint(params.run_id as string, params.phase as ScratchpadPhase, {
|
|
1334
|
+
ids: params.ids as string[] | undefined,
|
|
1335
|
+
summary: params.summary as string | undefined,
|
|
1336
|
+
});
|
|
1337
|
+
return {
|
|
1338
|
+
content: [
|
|
1339
|
+
{
|
|
1340
|
+
type: "text",
|
|
1341
|
+
text:
|
|
1342
|
+
`Phase ${params.phase} checkpointed for run ${cp.run_id}.\n` +
|
|
1343
|
+
`Completed phases: ${cp.completed_phases.join(", ")}`,
|
|
1344
|
+
},
|
|
1345
|
+
],
|
|
1346
|
+
details: { checkpoint: cp },
|
|
1347
|
+
};
|
|
1348
|
+
},
|
|
1349
|
+
|
|
1350
|
+
renderCall(args, theme) {
|
|
1351
|
+
return new Text(
|
|
1352
|
+
theme.fg("toolTitle", theme.bold("ScratchpadCheckpoint ")) +
|
|
1353
|
+
theme.fg("dim", `${args.run_id ?? ""} ${args.phase ?? ""}`),
|
|
1354
|
+
0,
|
|
1355
|
+
0,
|
|
1356
|
+
);
|
|
1357
|
+
},
|
|
1358
|
+
|
|
1359
|
+
renderResult(result, _opts, theme) {
|
|
1360
|
+
const cp = (
|
|
1361
|
+
result.details as { checkpoint: { run_id: string; completed_phases: string[] } } | undefined
|
|
1362
|
+
)?.checkpoint;
|
|
1363
|
+
return new Text(
|
|
1364
|
+
theme.fg("success", "✓ ") +
|
|
1365
|
+
`ScratchpadCheckpoint ${cp?.run_id ?? ""} — ${cp?.completed_phases.length ?? 0} phases done`,
|
|
1366
|
+
0,
|
|
1367
|
+
0,
|
|
1368
|
+
);
|
|
1369
|
+
},
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
// ── Tool: ScratchpadWrite ──
|
|
1373
|
+
|
|
1374
|
+
pi.registerTool({
|
|
1375
|
+
name: "ScratchpadWrite",
|
|
1376
|
+
label: "Write Artifact",
|
|
1377
|
+
description:
|
|
1378
|
+
"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.",
|
|
1379
|
+
promptSnippet: "Save a pipeline artifact to the scratchpad",
|
|
1380
|
+
promptGuidelines: [
|
|
1381
|
+
"Agents write artifacts to the scratchpad, not to each other's output files (prevents an echo chamber).",
|
|
1382
|
+
"The casefile owns state transitions; the scratchpad owns artifacts. Use ScratchpadWrite for bulky intermediate outputs, not CaseUpdate.",
|
|
1383
|
+
],
|
|
1384
|
+
parameters: ScratchpadWriteSchema,
|
|
1385
|
+
|
|
1386
|
+
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1387
|
+
const path = scratchpad_write(
|
|
1388
|
+
params.run_id as string,
|
|
1389
|
+
params.phase as ScratchpadPhase,
|
|
1390
|
+
params.artifact_name as string,
|
|
1391
|
+
params.content as string,
|
|
1392
|
+
);
|
|
1393
|
+
return {
|
|
1394
|
+
content: [
|
|
1395
|
+
{
|
|
1396
|
+
type: "text",
|
|
1397
|
+
text: `Artifact written: ${params.artifact_name} → ${path}`,
|
|
1398
|
+
},
|
|
1399
|
+
],
|
|
1400
|
+
details: { path, artifact_name: params.artifact_name },
|
|
1401
|
+
};
|
|
1402
|
+
},
|
|
1403
|
+
|
|
1404
|
+
renderCall(args, theme) {
|
|
1405
|
+
return new Text(
|
|
1406
|
+
theme.fg("toolTitle", theme.bold("ScratchpadWrite ")) +
|
|
1407
|
+
theme.fg("dim", `${args.run_id ?? ""}/${args.phase ?? ""}/${args.artifact_name ?? ""}`),
|
|
1408
|
+
0,
|
|
1409
|
+
0,
|
|
1410
|
+
);
|
|
1411
|
+
},
|
|
1412
|
+
|
|
1413
|
+
renderResult(result, _opts, theme) {
|
|
1414
|
+
const name = (result.details as { artifact_name?: string } | undefined)?.artifact_name;
|
|
1415
|
+
return new Text(theme.fg("success", `✓ ScratchpadWrite ${name ?? ""}`), 0, 0);
|
|
1416
|
+
},
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
// ── Tool: ScratchpadRead ──
|
|
1420
|
+
|
|
1421
|
+
pi.registerTool({
|
|
1422
|
+
name: "ScratchpadRead",
|
|
1423
|
+
label: "Read Artifact",
|
|
1424
|
+
description:
|
|
1425
|
+
"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.",
|
|
1426
|
+
promptSnippet: "Read a pipeline artifact from the scratchpad",
|
|
1427
|
+
promptGuidelines: [
|
|
1428
|
+
"On resume, ScratchpadRead retrieves a prior phase's intermediate output so the next phase can proceed without re-running it.",
|
|
1429
|
+
"Returns null for missing artifacts — treat as 'not yet produced' rather than an error.",
|
|
1430
|
+
],
|
|
1431
|
+
parameters: ScratchpadReadSchema,
|
|
1432
|
+
|
|
1433
|
+
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1434
|
+
const content = scratchpad_read(
|
|
1435
|
+
params.run_id as string,
|
|
1436
|
+
params.phase as ScratchpadPhase,
|
|
1437
|
+
params.artifact_name as string,
|
|
1438
|
+
);
|
|
1439
|
+
if (content === null) {
|
|
1440
|
+
return {
|
|
1441
|
+
content: [
|
|
1442
|
+
{
|
|
1443
|
+
type: "text",
|
|
1444
|
+
text: `Artifact not found: ${params.artifact_name} in ${params.phase}/`,
|
|
1445
|
+
},
|
|
1446
|
+
],
|
|
1447
|
+
details: { artifact_name: params.artifact_name, found: false },
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1450
|
+
return {
|
|
1451
|
+
content: [{ type: "text", text: content }],
|
|
1452
|
+
details: { artifact_name: params.artifact_name, found: true, length: content.length },
|
|
1453
|
+
};
|
|
1454
|
+
},
|
|
1455
|
+
|
|
1456
|
+
renderCall(args, theme) {
|
|
1457
|
+
return new Text(
|
|
1458
|
+
theme.fg("toolTitle", theme.bold("ScratchpadRead ")) +
|
|
1459
|
+
theme.fg("dim", `${args.run_id ?? ""}/${args.phase ?? ""}/${args.artifact_name ?? ""}`),
|
|
1460
|
+
0,
|
|
1461
|
+
0,
|
|
1462
|
+
);
|
|
1463
|
+
},
|
|
1464
|
+
|
|
1465
|
+
renderResult(result, _opts, theme) {
|
|
1466
|
+
const found = (result.details as { found?: boolean } | undefined)?.found;
|
|
1467
|
+
return new Text(
|
|
1468
|
+
found
|
|
1469
|
+
? theme.fg("success", "✓ ScratchpadRead")
|
|
1470
|
+
: theme.fg("warning", "↷ ScratchpadRead — not found"),
|
|
1471
|
+
0,
|
|
1472
|
+
0,
|
|
1473
|
+
);
|
|
1474
|
+
},
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1477
|
+
// ── Tool: ScratchpadPhaseDone ──
|
|
1478
|
+
|
|
1479
|
+
pi.registerTool({
|
|
1480
|
+
name: "ScratchpadPhaseDone",
|
|
1481
|
+
label: "Phase Done?",
|
|
1482
|
+
description:
|
|
1483
|
+
"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.",
|
|
1484
|
+
promptSnippet: "Check if a pipeline phase is already complete",
|
|
1485
|
+
promptGuidelines: [
|
|
1486
|
+
"Call ScratchpadPhaseDone before dispatching each stage to avoid re-running completed phases on resume.",
|
|
1487
|
+
"A completed phase with a checkpoint is a no-op on re-run — skip it and continue to the next incomplete phase.",
|
|
1488
|
+
],
|
|
1489
|
+
parameters: ScratchpadPhaseDoneSchema,
|
|
1490
|
+
|
|
1491
|
+
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1492
|
+
const done = scratchpad_phase_done(params.run_id as string, params.phase as ScratchpadPhase);
|
|
1493
|
+
return {
|
|
1494
|
+
content: [
|
|
1495
|
+
{
|
|
1496
|
+
type: "text",
|
|
1497
|
+
text: `Phase ${params.phase} for run ${params.run_id}: ${done ? "DONE (skip on resume)" : "not done"}`,
|
|
1498
|
+
},
|
|
1499
|
+
],
|
|
1500
|
+
details: { phase: params.phase, done },
|
|
1501
|
+
};
|
|
1502
|
+
},
|
|
1503
|
+
|
|
1504
|
+
renderCall(args, theme) {
|
|
1505
|
+
return new Text(
|
|
1506
|
+
theme.fg("toolTitle", theme.bold("ScratchpadPhaseDone ")) +
|
|
1507
|
+
theme.fg("dim", `${args.run_id ?? ""} ${args.phase ?? ""}`),
|
|
1508
|
+
0,
|
|
1509
|
+
0,
|
|
1510
|
+
);
|
|
1511
|
+
},
|
|
1512
|
+
|
|
1513
|
+
renderResult(result, _opts, theme) {
|
|
1514
|
+
const done = (result.details as { done?: boolean } | undefined)?.done;
|
|
1515
|
+
return new Text(
|
|
1516
|
+
done
|
|
1517
|
+
? theme.fg("success", "✓ ScratchpadPhaseDone — done")
|
|
1518
|
+
: theme.fg("warning", "↷ ScratchpadPhaseDone — not done"),
|
|
1519
|
+
0,
|
|
1520
|
+
0,
|
|
1521
|
+
);
|
|
1522
|
+
},
|
|
1523
|
+
});
|
|
1524
|
+
|
|
1525
|
+
// ── Tool: ScratchpadClear ──
|
|
1526
|
+
|
|
1527
|
+
pi.registerTool({
|
|
1528
|
+
name: "ScratchpadClear",
|
|
1529
|
+
label: "Clear Run",
|
|
1530
|
+
description:
|
|
1531
|
+
"Clear a single pipeline run's scratchpad directory. Used by --fresh for one run. Does not touch other runs. The run must be re-initialized with ScratchpadInit afterward.",
|
|
1532
|
+
promptSnippet: "Clear one pipeline run's artifacts",
|
|
1533
|
+
promptGuidelines: [
|
|
1534
|
+
"Use ScratchpadClear to force a fresh start for a single run (--fresh). It deletes that run's directory only.",
|
|
1535
|
+
"After clearing, call ScratchpadInit to recreate the directory structure before writing artifacts.",
|
|
1536
|
+
],
|
|
1537
|
+
parameters: ScratchpadClearSchema,
|
|
1538
|
+
|
|
1539
|
+
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1540
|
+
scratchpad_clear(params.run_id as string);
|
|
1541
|
+
return {
|
|
1542
|
+
content: [
|
|
1543
|
+
{
|
|
1544
|
+
type: "text",
|
|
1545
|
+
text: `Scratchpad cleared for run ${params.run_id}. Call ScratchpadInit to start a new run.`,
|
|
1546
|
+
},
|
|
1547
|
+
],
|
|
1548
|
+
details: { run_id: params.run_id, cleared: true },
|
|
1549
|
+
};
|
|
1550
|
+
},
|
|
1551
|
+
|
|
1552
|
+
renderCall(args, theme) {
|
|
1553
|
+
return new Text(
|
|
1554
|
+
theme.fg("toolTitle", theme.bold("ScratchpadClear ")) +
|
|
1555
|
+
theme.fg("dim", (args.run_id as string) ?? ""),
|
|
1556
|
+
0,
|
|
1557
|
+
0,
|
|
1558
|
+
);
|
|
1559
|
+
},
|
|
1560
|
+
|
|
1561
|
+
renderResult(_result, _opts, theme) {
|
|
1562
|
+
return new Text(theme.fg("success", "✓ ScratchpadClear"), 0, 0);
|
|
1563
|
+
},
|
|
1564
|
+
});
|
|
1565
|
+
|
|
1069
1566
|
// ── Command: /casefile ──
|
|
1070
1567
|
|
|
1071
1568
|
pi.registerCommand("casefile", {
|
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,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scratchpad — intermediate artifact store for pipeline runs.
|
|
3
|
+
*
|
|
4
|
+
* The casefile owns state transitions; the scratchpad owns artifacts.
|
|
5
|
+
* Agents write their outputs here (recon maps, trace outputs, verification
|
|
6
|
+
* logs) instead of stuffing everything into casefile text fields or relying
|
|
7
|
+
* on each other's output streams (which creates an echo chamber).
|
|
8
|
+
*
|
|
9
|
+
* Directory layout per pipeline run:
|
|
10
|
+
* {project_root}/.scratchpad/{run_id}/
|
|
11
|
+
* recon/ — fingerprints, tech detection, surface maps
|
|
12
|
+
* trace/ — per-finding reachability traces
|
|
13
|
+
* verify/ — PoC logs, run outputs
|
|
14
|
+
* state.json — checkpoint file with phase completion + key IDs
|
|
15
|
+
*
|
|
16
|
+
* Resume re-reads scratchpad artifacts; it does not re-run completed phases
|
|
17
|
+
* (idempotent). The `.scratchpad/` directory is preserved between runs;
|
|
18
|
+
* `--fresh` clears it via scratchpad_clear().
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { dirname, join, resolve } from "node:path";
|
|
23
|
+
|
|
24
|
+
// ── Types ────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export type ScratchpadPhase =
|
|
27
|
+
| "recon"
|
|
28
|
+
| "hunt"
|
|
29
|
+
| "gapfil"
|
|
30
|
+
| "trace"
|
|
31
|
+
| "skeptic"
|
|
32
|
+
| "validate"
|
|
33
|
+
| "chain"
|
|
34
|
+
| "patch"
|
|
35
|
+
| "report";
|
|
36
|
+
|
|
37
|
+
export interface ScratchpadCheckpoint {
|
|
38
|
+
run_id: string;
|
|
39
|
+
project_root: string;
|
|
40
|
+
created_at: string;
|
|
41
|
+
last_updated: string;
|
|
42
|
+
/** Ordered list of phases that have completed (in pipeline order). */
|
|
43
|
+
completed_phases: ScratchpadPhase[];
|
|
44
|
+
/** ISO timestamp of the last phase completion. */
|
|
45
|
+
last_phase_at: string | null;
|
|
46
|
+
/** Key IDs produced by each phase — case IDs, finding IDs, etc. */
|
|
47
|
+
phase_ids: Record<ScratchpadPhase, string[]>;
|
|
48
|
+
/** Free-form summary per phase, set by checkpoint(). */
|
|
49
|
+
phase_summaries: Record<ScratchpadPhase, string>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ScratchpadResume {
|
|
53
|
+
checkpoint: ScratchpadCheckpoint;
|
|
54
|
+
/** The next phase to run (or null if the run is done). */
|
|
55
|
+
next_phase: ScratchpadPhase | null;
|
|
56
|
+
/** Artifact references per phase: { trace: ["finding-abc.json", ...], ... } */
|
|
57
|
+
artifacts: Record<string, string[]>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Constants ────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
const PHASE_ORDER: ScratchpadPhase[] = [
|
|
63
|
+
"recon",
|
|
64
|
+
"hunt",
|
|
65
|
+
"gapfil",
|
|
66
|
+
"trace",
|
|
67
|
+
"skeptic",
|
|
68
|
+
"validate",
|
|
69
|
+
"chain",
|
|
70
|
+
"patch",
|
|
71
|
+
"report",
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const PHASE_DIRS: Record<ScratchpadPhase, string> = {
|
|
75
|
+
recon: "recon",
|
|
76
|
+
hunt: "hunt",
|
|
77
|
+
gapfil: "gapfil",
|
|
78
|
+
trace: "trace",
|
|
79
|
+
skeptic: "skeptic",
|
|
80
|
+
validate: "verify",
|
|
81
|
+
chain: "chain",
|
|
82
|
+
patch: "patch",
|
|
83
|
+
report: "report",
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const SCRATCHPAD_DIR = ".scratchpad";
|
|
87
|
+
|
|
88
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
let scratchpadRootOverride: string | undefined;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Detect the workspace root by walking up for a .git dir or package.json,
|
|
94
|
+
* matching the ledger's detectWorkspaceRoot() heuristic.
|
|
95
|
+
*/
|
|
96
|
+
function detectWorkspaceRoot(): string {
|
|
97
|
+
if (scratchpadRootOverride) return scratchpadRootOverride;
|
|
98
|
+
|
|
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
|
+
for (const e of envs) {
|
|
103
|
+
const v = process.env[e];
|
|
104
|
+
if (v) return resolve(v);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let curr = resolve(process.cwd());
|
|
108
|
+
for (let i = 0; i < 20; i++) {
|
|
109
|
+
if (existsSync(join(curr, ".git")) || existsSync(join(curr, "package.json"))) return curr;
|
|
110
|
+
const parent = dirname(curr);
|
|
111
|
+
if (parent === curr) break;
|
|
112
|
+
curr = parent;
|
|
113
|
+
}
|
|
114
|
+
return resolve(process.cwd());
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Override the scratchpad root (for testing). Pass undefined to reset. */
|
|
118
|
+
export function setScratchpadRoot(path: string | undefined): void {
|
|
119
|
+
scratchpadRootOverride = path ? resolve(path) : undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The top-level scratchpad directory for a given project root. */
|
|
123
|
+
export function getScratchpadRoot(projectRoot?: string): string {
|
|
124
|
+
const root = projectRoot ?? detectWorkspaceRoot();
|
|
125
|
+
return join(root, SCRATCHPAD_DIR);
|
|
126
|
+
}
|
|
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
|
+
|
|
143
|
+
/** The directory for a specific run. */
|
|
144
|
+
export function getRunDir(runId: string, projectRoot?: string): string {
|
|
145
|
+
return join(getScratchpadRoot(projectRoot), sanitizeRunId(runId));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The state.json path for a run. */
|
|
149
|
+
export function getStatePath(runId: string, projectRoot?: string): string {
|
|
150
|
+
return join(getRunDir(runId, projectRoot), "state.json");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function emptyCheckpoint(runId: string, projectRoot: string): ScratchpadCheckpoint {
|
|
154
|
+
const now = new Date().toISOString();
|
|
155
|
+
return {
|
|
156
|
+
run_id: runId,
|
|
157
|
+
project_root: projectRoot,
|
|
158
|
+
created_at: now,
|
|
159
|
+
last_updated: now,
|
|
160
|
+
last_phase_at: null,
|
|
161
|
+
completed_phases: [],
|
|
162
|
+
phase_ids: {} as Record<ScratchpadPhase, string[]>,
|
|
163
|
+
phase_summaries: {} as Record<ScratchpadPhase, string>,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function ensureRunDirs(runDir: string): void {
|
|
168
|
+
if (!existsSync(runDir)) mkdirSync(runDir, { recursive: true });
|
|
169
|
+
for (const phase of PHASE_ORDER) {
|
|
170
|
+
const dir = join(runDir, PHASE_DIRS[phase]);
|
|
171
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function readCheckpointRaw(runId: string, projectRoot?: string): ScratchpadCheckpoint | null {
|
|
176
|
+
const statePath = getStatePath(runId, projectRoot);
|
|
177
|
+
if (!existsSync(statePath)) return null;
|
|
178
|
+
try {
|
|
179
|
+
const raw = readFileSync(statePath, "utf8");
|
|
180
|
+
const cp = JSON.parse(raw) as ScratchpadCheckpoint;
|
|
181
|
+
// Backfill maps for phases not yet checkpointed (defensive).
|
|
182
|
+
if (!cp.phase_ids) cp.phase_ids = {} as Record<ScratchpadPhase, string[]>;
|
|
183
|
+
if (!cp.phase_summaries) cp.phase_summaries = {} as Record<ScratchpadPhase, string>;
|
|
184
|
+
return cp;
|
|
185
|
+
} catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function writeCheckpointRaw(cp: ScratchpadCheckpoint, projectRoot?: string): void {
|
|
191
|
+
cp.last_updated = new Date().toISOString();
|
|
192
|
+
const statePath = getStatePath(cp.run_id, projectRoot);
|
|
193
|
+
ensureRunDirs(getRunDir(cp.run_id, projectRoot));
|
|
194
|
+
writeFileSync(statePath, JSON.stringify(cp, null, 2), "utf8");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── Public API ───────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Initialize a new scratchpad run. Creates the directory structure and writes
|
|
201
|
+
* an initial state.json. If the run already exists, returns the existing
|
|
202
|
+
* checkpoint (idempotent — safe to call on resume without --fresh).
|
|
203
|
+
*/
|
|
204
|
+
export function scratchpad_init(runId: string, projectRoot?: string): ScratchpadCheckpoint {
|
|
205
|
+
const root = projectRoot ?? detectWorkspaceRoot();
|
|
206
|
+
const runDir = getRunDir(runId, root);
|
|
207
|
+
ensureRunDirs(runDir);
|
|
208
|
+
|
|
209
|
+
const existing = readCheckpointRaw(runId, root);
|
|
210
|
+
if (existing) return existing;
|
|
211
|
+
|
|
212
|
+
const cp = emptyCheckpoint(runId, root);
|
|
213
|
+
writeCheckpointRaw(cp, root);
|
|
214
|
+
return cp;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Write an artifact to a phase's subdirectory. Overwrites if the name exists.
|
|
219
|
+
* Returns the full path to the written artifact.
|
|
220
|
+
*/
|
|
221
|
+
export function scratchpad_write(
|
|
222
|
+
runId: string,
|
|
223
|
+
phase: ScratchpadPhase,
|
|
224
|
+
artifactName: string,
|
|
225
|
+
content: string,
|
|
226
|
+
projectRoot?: string,
|
|
227
|
+
): string {
|
|
228
|
+
const root = projectRoot ?? detectWorkspaceRoot();
|
|
229
|
+
const runDir = getRunDir(runId, root);
|
|
230
|
+
ensureRunDirs(runDir);
|
|
231
|
+
|
|
232
|
+
// Sanitize artifact name: no path traversal.
|
|
233
|
+
const safeName = artifactName.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
234
|
+
const dir = join(runDir, PHASE_DIRS[phase]);
|
|
235
|
+
const filePath = join(dir, safeName);
|
|
236
|
+
writeFileSync(filePath, content, "utf8");
|
|
237
|
+
return filePath;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Read an artifact. Returns null if missing.
|
|
242
|
+
*/
|
|
243
|
+
export function scratchpad_read(
|
|
244
|
+
runId: string,
|
|
245
|
+
phase: ScratchpadPhase,
|
|
246
|
+
artifactName: string,
|
|
247
|
+
projectRoot?: string,
|
|
248
|
+
): string | null {
|
|
249
|
+
const root = projectRoot ?? detectWorkspaceRoot();
|
|
250
|
+
const safeName = artifactName.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
251
|
+
const filePath = join(getRunDir(runId, root), PHASE_DIRS[phase], safeName);
|
|
252
|
+
if (!existsSync(filePath)) return null;
|
|
253
|
+
return readFileSync(filePath, "utf8");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* List all artifacts written for a phase.
|
|
258
|
+
*/
|
|
259
|
+
export function scratchpad_list(
|
|
260
|
+
runId: string,
|
|
261
|
+
phase: ScratchpadPhase,
|
|
262
|
+
projectRoot?: string,
|
|
263
|
+
): string[] {
|
|
264
|
+
const root = projectRoot ?? detectWorkspaceRoot();
|
|
265
|
+
const dir = join(getRunDir(runId, root), PHASE_DIRS[phase]);
|
|
266
|
+
if (!existsSync(dir)) return [];
|
|
267
|
+
return readdirSync(dir).filter((f) => f !== "state.json");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Mark a phase as complete. Records the completion timestamp, key IDs, and an
|
|
272
|
+
* optional summary in state.json. Idempotent: re-checkpointing a phase
|
|
273
|
+
* overwrites its previous summary/IDs but does not duplicate the entry in
|
|
274
|
+
* completed_phases.
|
|
275
|
+
*/
|
|
276
|
+
export function scratchpad_checkpoint(
|
|
277
|
+
runId: string,
|
|
278
|
+
phase: ScratchpadPhase,
|
|
279
|
+
data: { ids?: string[]; summary?: string },
|
|
280
|
+
projectRoot?: string,
|
|
281
|
+
): ScratchpadCheckpoint {
|
|
282
|
+
const root = projectRoot ?? detectWorkspaceRoot();
|
|
283
|
+
const cp = readCheckpointRaw(runId, root) ?? scratchpad_init(runId, root);
|
|
284
|
+
|
|
285
|
+
if (!cp.completed_phases.includes(phase)) {
|
|
286
|
+
cp.completed_phases.push(phase);
|
|
287
|
+
// Keep completed_phases in pipeline order for predictable resume.
|
|
288
|
+
cp.completed_phases.sort((a, b) => PHASE_ORDER.indexOf(a) - PHASE_ORDER.indexOf(b));
|
|
289
|
+
}
|
|
290
|
+
cp.last_phase_at = new Date().toISOString();
|
|
291
|
+
if (data.ids) cp.phase_ids[phase] = data.ids;
|
|
292
|
+
if (data.summary) cp.phase_summaries[phase] = data.summary;
|
|
293
|
+
|
|
294
|
+
writeCheckpointRaw(cp, root);
|
|
295
|
+
return cp;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Read the checkpoint + all artifact references for resume.
|
|
300
|
+
* Returns null if the run doesn't exist.
|
|
301
|
+
*/
|
|
302
|
+
export function scratchpad_resume(runId: string, projectRoot?: string): ScratchpadResume | null {
|
|
303
|
+
const root = projectRoot ?? detectWorkspaceRoot();
|
|
304
|
+
const cp = readCheckpointRaw(runId, root);
|
|
305
|
+
if (!cp) return null;
|
|
306
|
+
|
|
307
|
+
// Find the next phase: the first phase in order not in completed_phases.
|
|
308
|
+
const next = PHASE_ORDER.find((p) => !cp.completed_phases.includes(p)) ?? null;
|
|
309
|
+
|
|
310
|
+
// Gather artifact listing per completed phase.
|
|
311
|
+
const artifacts: Record<string, string[]> = {};
|
|
312
|
+
for (const phase of cp.completed_phases) {
|
|
313
|
+
artifacts[phase] = scratchpad_list(runId, phase, root);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return { checkpoint: cp, next_phase: next, artifacts };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Check whether a phase has already been checkpointed (for idempotent re-run).
|
|
321
|
+
*/
|
|
322
|
+
export function scratchpad_phase_done(
|
|
323
|
+
runId: string,
|
|
324
|
+
phase: ScratchpadPhase,
|
|
325
|
+
projectRoot?: string,
|
|
326
|
+
): boolean {
|
|
327
|
+
const cp = readCheckpointRaw(runId, projectRoot);
|
|
328
|
+
return cp?.completed_phases.includes(phase) ?? false;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Clear a specific run's scratchpad directory. Used by `--fresh` for a single
|
|
333
|
+
* run. Does not touch other runs.
|
|
334
|
+
*/
|
|
335
|
+
export function scratchpad_clear(runId: string, projectRoot?: string): void {
|
|
336
|
+
const root = projectRoot ?? detectWorkspaceRoot();
|
|
337
|
+
const runDir = getRunDir(runId, root);
|
|
338
|
+
if (existsSync(runDir)) rmSync(runDir, { recursive: true, force: true });
|
|
339
|
+
}
|
package/src/workflow.ts
CHANGED
|
@@ -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
|
|
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,19 +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
|
+
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.
|
|
149
150
|
|
|
150
151
|
### 2. Production Path Verification (must be in impact field)
|
|
151
152
|
|
|
@@ -164,7 +165,7 @@ The **impact** field for CONFIRMED must explicitly answer:
|
|
|
164
165
|
|
|
165
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.
|
|
166
167
|
|
|
167
|
-
###
|
|
168
|
+
### 3. KILL at Validate stage
|
|
168
169
|
|
|
169
170
|
Documented intended behavior
|
|
170
171
|
- Self-XSS / self-DoS only (attacker harms only their own session)
|
|
@@ -175,11 +176,11 @@ Documented intended behavior
|
|
|
175
176
|
- PoC proves a code path exists but not that any victim asset is affected
|
|
176
177
|
- Protections in production block the path and are not bypassed
|
|
177
178
|
|
|
178
|
-
###
|
|
179
|
+
### 4. Evidence-First Doctrine
|
|
179
180
|
|
|
180
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.
|
|
181
182
|
|
|
182
|
-
###
|
|
183
|
+
### 5. Impact Gate
|
|
183
184
|
|
|
184
185
|
Prove at least **one** real attacker-facing violation against a production-viable target:
|
|
185
186
|
|
|
@@ -194,7 +195,16 @@ Impact text must answer: *who is hurt, what is lost, how the attacker reaches it
|
|
|
194
195
|
|
|
195
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.
|
|
196
197
|
|
|
197
|
-
|
|
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
|
|
198
208
|
|
|
199
209
|
1. Why this might NOT be a vulnerability.
|
|
200
210
|
2. Alternative explanations for the observation.
|
|
@@ -202,7 +212,7 @@ If impact is theoretical, needs a second unproven bug, or is not yet reachable f
|
|
|
202
212
|
4. What blocks a real attacker in production today and whether each is bypassed.
|
|
203
213
|
5. Would a program triage reject this as informative/N/A?
|
|
204
214
|
|
|
205
|
-
###
|
|
215
|
+
### 7. Root Cause -> Boundary -> Impact
|
|
206
216
|
|
|
207
217
|
\`\`\`
|
|
208
218
|
Entry (attacker-controlled) -> Code path -> Trust boundary crossed -> Victim impact
|