@xaccefy/pi-casefile 0.7.0 → 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 +1 -1
- package/src/index.ts +81 -37
- package/src/ledger.ts +26 -19
- package/src/scratchpad.ts +19 -39
- package/src/workflow.ts +37 -29
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -7,7 +7,6 @@
|
|
|
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";
|
|
@@ -122,6 +121,11 @@ const PromoteSchema = Type.Object(
|
|
|
122
121
|
poc_path: Type.String({
|
|
123
122
|
description: "Absolute path to the PoC script on disk",
|
|
124
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
|
+
}),
|
|
125
129
|
disconfirmation_path: Type.Optional(
|
|
126
130
|
Type.String({
|
|
127
131
|
description:
|
|
@@ -248,7 +252,8 @@ const SCRATCHPAD_PHASES = [
|
|
|
248
252
|
|
|
249
253
|
const ScratchpadPhaseSchema = Type.String({
|
|
250
254
|
enum: [...SCRATCHPAD_PHASES],
|
|
251
|
-
description:
|
|
255
|
+
description:
|
|
256
|
+
"Pipeline phase: recon | hunt | gapfil | trace | skeptic | validate | chain | patch | report",
|
|
252
257
|
});
|
|
253
258
|
|
|
254
259
|
const ScratchpadInitSchema = Type.Object(
|
|
@@ -269,7 +274,11 @@ const ScratchpadCheckpointSchema = Type.Object(
|
|
|
269
274
|
{
|
|
270
275
|
run_id: Type.String({ description: "Run identifier" }),
|
|
271
276
|
phase: ScratchpadPhaseSchema,
|
|
272
|
-
ids: Type.Optional(
|
|
277
|
+
ids: Type.Optional(
|
|
278
|
+
Type.Array(Type.String(), {
|
|
279
|
+
description: "Key IDs produced by this phase (case IDs, finding IDs)",
|
|
280
|
+
}),
|
|
281
|
+
),
|
|
273
282
|
summary: Type.Optional(Type.String({ description: "One-line summary of phase completion" })),
|
|
274
283
|
},
|
|
275
284
|
{ additionalProperties: false },
|
|
@@ -279,7 +288,9 @@ const ScratchpadWriteSchema = Type.Object(
|
|
|
279
288
|
{
|
|
280
289
|
run_id: Type.String({ description: "Run identifier" }),
|
|
281
290
|
phase: ScratchpadPhaseSchema,
|
|
282
|
-
artifact_name: Type.String({
|
|
291
|
+
artifact_name: Type.String({
|
|
292
|
+
description: "Artifact filename (sanitized; path traversal is blocked)",
|
|
293
|
+
}),
|
|
283
294
|
content: Type.String({ description: "Artifact content to write" }),
|
|
284
295
|
},
|
|
285
296
|
{ additionalProperties: false },
|
|
@@ -415,9 +426,7 @@ class CasefileDashboard {
|
|
|
415
426
|
|
|
416
427
|
if (this.records.length === 0) {
|
|
417
428
|
lines.push("");
|
|
418
|
-
lines.push(
|
|
419
|
-
` ${th.fg("dim", "No active security cases. Ask the agent to CaseAdd findings!")}`,
|
|
420
|
-
);
|
|
429
|
+
lines.push(` ${th.fg("dim", "No security cases yet. Ask the agent to CaseAdd findings!")}`);
|
|
421
430
|
} else {
|
|
422
431
|
lines.push("");
|
|
423
432
|
for (const r of this.records) {
|
|
@@ -520,11 +529,7 @@ export const XP_MODE_ENV = "PI_XP_MODE";
|
|
|
520
529
|
export type XpMode = "on" | "off";
|
|
521
530
|
|
|
522
531
|
export function getXpModeStatePath(): string {
|
|
523
|
-
|
|
524
|
-
return join(dirname(getCasefilePath()), "xp-mode");
|
|
525
|
-
} catch {
|
|
526
|
-
return join(homedir(), ".pi", "xp-mode");
|
|
527
|
-
}
|
|
532
|
+
return join(dirname(getCasefilePath()), "xp-mode");
|
|
528
533
|
}
|
|
529
534
|
|
|
530
535
|
export function readXpMode(
|
|
@@ -724,13 +729,14 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
724
729
|
name: "PromoteFinding",
|
|
725
730
|
label: "Promote Finding",
|
|
726
731
|
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).",
|
|
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).",
|
|
728
733
|
promptSnippet: "Run a PoC and promote an investigating case to confirmed",
|
|
729
734
|
promptGuidelines: [
|
|
730
735
|
"Use PromoteFinding when an investigating case has a concrete PoC script on disk and you are ready to prove it.",
|
|
731
736
|
"The case must already have status='investigating' and non-empty poc, evidence, impact, severity, target, and disconfirmation fields.",
|
|
732
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).",
|
|
733
|
-
"
|
|
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.",
|
|
734
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.",
|
|
735
741
|
"Do not use CaseUpdate to set status='confirmed' directly — it is rejected. Always use PromoteFinding.",
|
|
736
742
|
],
|
|
@@ -741,6 +747,26 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
741
747
|
// 30s (plus first-time image pull), so fail cheap when the case can't
|
|
742
748
|
// advance anyway (missing, wrong status, missing required fields).
|
|
743
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
|
+
|
|
744
770
|
const run = runPoc(params.poc_path as string, params.local !== true);
|
|
745
771
|
|
|
746
772
|
// Fail closed without throwing: non-zero PoC must leave the case investigating.
|
|
@@ -758,6 +784,28 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
758
784
|
};
|
|
759
785
|
}
|
|
760
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
|
+
|
|
761
809
|
// Run disconfirmation script if provided — must exit NON-0 (finding survived the attempt to disprove).
|
|
762
810
|
let disconfirmationRun: PocRun | undefined;
|
|
763
811
|
if (params.disconfirmation_path) {
|
|
@@ -1105,7 +1153,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1105
1153
|
name: "CaseReport",
|
|
1106
1154
|
label: "Write Case Report",
|
|
1107
1155
|
description:
|
|
1108
|
-
"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.",
|
|
1109
1157
|
promptSnippet: "Generate a bounty-style markdown report from a case",
|
|
1110
1158
|
promptGuidelines: [
|
|
1111
1159
|
"Use CaseReport only for confirmed or already reported cases. Keep hypotheses and investigating cases in the ledger until proof is captured.",
|
|
@@ -1180,7 +1228,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1180
1228
|
content: [
|
|
1181
1229
|
{
|
|
1182
1230
|
type: "text",
|
|
1183
|
-
text: `Scratchpad initialized for run ${cp.run_id}.\nCompleted phases: ${cp.completed_phases.length ? cp.completed_phases.join(", ") : "none"}
|
|
1231
|
+
text: `Scratchpad initialized for run ${cp.run_id}.\nCompleted phases: ${cp.completed_phases.length ? cp.completed_phases.join(", ") : "none"}`,
|
|
1184
1232
|
},
|
|
1185
1233
|
],
|
|
1186
1234
|
details: { checkpoint: cp },
|
|
@@ -1197,13 +1245,8 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1197
1245
|
},
|
|
1198
1246
|
|
|
1199
1247
|
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
|
-
);
|
|
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);
|
|
1207
1250
|
},
|
|
1208
1251
|
});
|
|
1209
1252
|
|
|
@@ -1243,8 +1286,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1243
1286
|
text:
|
|
1244
1287
|
`Resume run ${cp.run_id}:\n` +
|
|
1245
1288
|
`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}`,
|
|
1289
|
+
`Next phase: ${resume.next_phase ?? "none (run is done)"}`,
|
|
1248
1290
|
},
|
|
1249
1291
|
],
|
|
1250
1292
|
details: { resume },
|
|
@@ -1288,11 +1330,10 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1288
1330
|
parameters: ScratchpadCheckpointSchema,
|
|
1289
1331
|
|
|
1290
1332
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1291
|
-
const cp = scratchpad_checkpoint(
|
|
1292
|
-
params.
|
|
1293
|
-
params.
|
|
1294
|
-
|
|
1295
|
-
);
|
|
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
|
+
});
|
|
1296
1337
|
return {
|
|
1297
1338
|
content: [
|
|
1298
1339
|
{
|
|
@@ -1316,7 +1357,9 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1316
1357
|
},
|
|
1317
1358
|
|
|
1318
1359
|
renderResult(result, _opts, theme) {
|
|
1319
|
-
const cp = (
|
|
1360
|
+
const cp = (
|
|
1361
|
+
result.details as { checkpoint: { run_id: string; completed_phases: string[] } } | undefined
|
|
1362
|
+
)?.checkpoint;
|
|
1320
1363
|
return new Text(
|
|
1321
1364
|
theme.fg("success", "✓ ") +
|
|
1322
1365
|
`ScratchpadCheckpoint ${cp?.run_id ?? ""} — ${cp?.completed_phases.length ?? 0} phases done`,
|
|
@@ -1422,7 +1465,9 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1422
1465
|
renderResult(result, _opts, theme) {
|
|
1423
1466
|
const found = (result.details as { found?: boolean } | undefined)?.found;
|
|
1424
1467
|
return new Text(
|
|
1425
|
-
found
|
|
1468
|
+
found
|
|
1469
|
+
? theme.fg("success", "✓ ScratchpadRead")
|
|
1470
|
+
: theme.fg("warning", "↷ ScratchpadRead — not found"),
|
|
1426
1471
|
0,
|
|
1427
1472
|
0,
|
|
1428
1473
|
);
|
|
@@ -1444,10 +1489,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1444
1489
|
parameters: ScratchpadPhaseDoneSchema,
|
|
1445
1490
|
|
|
1446
1491
|
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
|
-
);
|
|
1492
|
+
const done = scratchpad_phase_done(params.run_id as string, params.phase as ScratchpadPhase);
|
|
1451
1493
|
return {
|
|
1452
1494
|
content: [
|
|
1453
1495
|
{
|
|
@@ -1471,7 +1513,9 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1471
1513
|
renderResult(result, _opts, theme) {
|
|
1472
1514
|
const done = (result.details as { done?: boolean } | undefined)?.done;
|
|
1473
1515
|
return new Text(
|
|
1474
|
-
done
|
|
1516
|
+
done
|
|
1517
|
+
? theme.fg("success", "✓ ScratchpadPhaseDone — done")
|
|
1518
|
+
: theme.fg("warning", "↷ ScratchpadPhaseDone — not done"),
|
|
1475
1519
|
0,
|
|
1476
1520
|
0,
|
|
1477
1521
|
);
|
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),
|
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
|
@@ -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,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
|