@xaccefy/pi-casefile 0.5.7 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xaccefy/pi-casefile",
3
- "version": "0.5.7",
3
+ "version": "0.6.0",
4
4
  "description": "Offensive security case tracker for Pi Agent — bug bounties, CTFs, security audits",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/index.ts CHANGED
@@ -35,6 +35,7 @@ import {
35
35
  linkCasesResult,
36
36
  PRIORITY_VALUES,
37
37
  promoteFindingResult,
38
+ readActiveCases,
38
39
  readCasefile,
39
40
  SEARCH_FIELD_VALUES,
40
41
  SEVERITY_VALUES,
@@ -44,7 +45,7 @@ import {
44
45
  updateCaseResult,
45
46
  writeCaseReport,
46
47
  } from "./ledger.ts";
47
- import { runPoc } from "./poc-runner.ts";
48
+ import { type PocRun, runPoc } from "./poc-runner.ts";
48
49
  import { STATIC_CYBER_WORKFLOW } from "./workflow.ts";
49
50
 
50
51
  // ── Schemas ───────────────────────────────────────────────────────────
@@ -110,6 +111,12 @@ const PromoteSchema = Type.Object(
110
111
  poc_path: Type.String({
111
112
  description: "Absolute path to the PoC script on disk",
112
113
  }),
114
+ disconfirmation_path: Type.Optional(
115
+ Type.String({
116
+ description:
117
+ "Absolute path to a disconfirmation script that tries to disprove the finding; must exit non-zero (failure to disprove)",
118
+ }),
119
+ ),
113
120
  local: Type.Optional(Type.Boolean({ description: "Run locally instead of in Docker sandbox" })),
114
121
  },
115
122
  { additionalProperties: false },
@@ -405,7 +412,8 @@ function buildCaseListContext(records: CaseRecord[]): string {
405
412
  /** Always includes cyber workflow; attaches case list when active cases exist. */
406
413
  function buildAgentInjection(active: CaseRecord[]): string {
407
414
  const caseList = buildCaseListContext(active);
408
- return caseList ? `${caseList}\n\n${STATIC_CYBER_WORKFLOW}` : STATIC_CYBER_WORKFLOW;
415
+ // Workflow FIRST for prominence, then case list as reference data.
416
+ return caseList ? `${STATIC_CYBER_WORKFLOW}\n\n${caseList}` : STATIC_CYBER_WORKFLOW;
409
417
  }
410
418
 
411
419
  // ── XP (offensive / exploit) mode toggle ─────────────────────────────
@@ -623,13 +631,14 @@ export default function casefileExtension(pi: ExtensionAPI) {
623
631
  name: "PromoteFinding",
624
632
  label: "Promote Finding",
625
633
  description:
626
- "Run an on-disk PoC script (Docker sandbox or local) and, on exit 0, promote an investigating case to confirmed.",
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).",
627
635
  promptSnippet: "Run a PoC and promote an investigating case to confirmed",
628
636
  promptGuidelines: [
629
637
  "Use PromoteFinding when an investigating case has a concrete PoC script on disk and you are ready to prove it.",
630
- "The case must already have status='investigating' and non-empty poc, evidence, impact, and severity fields.",
638
+ "The case must already have status='investigating' and non-empty poc, evidence, impact, severity, target, and disconfirmation fields.",
631
639
  "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).",
632
640
  "Only exit code 0 promotes the case to confirmed.",
641
+ "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.",
633
642
  "Do not use CaseUpdate to set status='confirmed' directly — it is rejected. Always use PromoteFinding.",
634
643
  ],
635
644
  parameters: PromoteSchema,
@@ -656,13 +665,46 @@ export default function casefileExtension(pi: ExtensionAPI) {
656
665
  };
657
666
  }
658
667
 
659
- const result = promoteFindingResult(params.id as string, {
660
- path: run.path,
661
- exitCode: run.exitCode,
662
- ranAt: run.ranAt,
663
- output: run.output,
664
- sandbox: run.sandbox,
665
- });
668
+ // Run disconfirmation script if provided — must exit NON-0 (finding survived the attempt to disprove).
669
+ let disconfirmationRun: PocRun | undefined;
670
+ if (params.disconfirmation_path) {
671
+ disconfirmationRun = runPoc(params.disconfirmation_path as string, params.local !== true);
672
+ if (disconfirmationRun.exitCode === 0) {
673
+ const record = getCaseById(params.id as string);
674
+ return {
675
+ content: [
676
+ {
677
+ type: "text",
678
+ text:
679
+ `Disconfirmation script exited 0 (finding was disproven). ` +
680
+ `Case remains investigating.\nOutput:\n${disconfirmationRun.output}`,
681
+ },
682
+ ],
683
+ isError: true,
684
+ details: { record, run, disconfirmationRun },
685
+ };
686
+ }
687
+ }
688
+
689
+ const result = promoteFindingResult(
690
+ params.id as string,
691
+ {
692
+ path: run.path,
693
+ exitCode: run.exitCode,
694
+ ranAt: run.ranAt,
695
+ output: run.output,
696
+ sandbox: run.sandbox,
697
+ },
698
+ disconfirmationRun
699
+ ? {
700
+ path: disconfirmationRun.path,
701
+ exitCode: disconfirmationRun.exitCode,
702
+ ranAt: disconfirmationRun.ranAt,
703
+ output: disconfirmationRun.output,
704
+ sandbox: disconfirmationRun.sandbox,
705
+ }
706
+ : undefined,
707
+ );
666
708
  const record = result.record;
667
709
  return {
668
710
  content: [
@@ -969,7 +1011,8 @@ export default function casefileExtension(pi: ExtensionAPI) {
969
1011
  pi.registerTool({
970
1012
  name: "CaseReport",
971
1013
  label: "Write Case Report",
972
- description: "Generate a markdown report from a case under the project report directory.",
1014
+ description:
1015
+ "Generate a markdown report from a confirmed or reported case under the project report directory. Hypothesis/investigating/blocked/killed cases are rejected — promote to confirmed first.",
973
1016
  promptSnippet: "Generate a bounty-style markdown report from a case",
974
1017
  promptGuidelines: [
975
1018
  "Use CaseReport only for confirmed or already reported cases. Keep hypotheses and investigating cases in the ledger until proof is captured.",
@@ -1057,30 +1100,29 @@ export default function casefileExtension(pi: ExtensionAPI) {
1057
1100
  }
1058
1101
  });
1059
1102
 
1060
- // ── Event: Inject context into system prompt ──
1103
+ // ── Event: Inject cyber workflow into system prompt ──
1061
1104
  // XP (offensive) mode is OFF by default so normal dev work stays quiet.
1062
- // Only when enabled do we inject the cyber workflow (and case list) each
1063
- // prompt. This keeps the agent focused during everyday development while
1064
- // still allowing the full attacker discipline to be switched on for
1065
- // offensive/audit/bounty sessions.
1105
+ // Only when enabled do we inject the cyber workflow (and case list) into
1106
+ // the system prompt each turn. Injecting into event.systemPrompt (not as a
1107
+ // conversation message) makes the attacker mindset immediate and avoids
1108
+ // session bloat from repeated message entries.
1066
1109
 
1067
- pi.on("before_agent_start", async () => {
1110
+ pi.on("before_agent_start", async (event) => {
1068
1111
  if (readXpMode() === "off") return;
1069
1112
 
1070
1113
  let active: CaseRecord[] = [];
1071
1114
  try {
1072
- const records = readCasefile();
1073
- active = records.filter((r) => r.status !== "killed" && r.status !== "reported");
1115
+ active = readActiveCases();
1074
1116
  } catch {
1075
1117
  // No database yet — still inject workflow.
1076
1118
  }
1077
1119
 
1120
+ const injection = buildAgentInjection(active);
1121
+
1122
+ // Inject workflow FIRST (before skills) so the attacker mindset is
1123
+ // prominent, not buried at the end of a long system prompt.
1078
1124
  return {
1079
- message: {
1080
- customType: "casefile_summary",
1081
- content: buildAgentInjection(active),
1082
- display: false,
1083
- },
1125
+ systemPrompt: `${injection}\n\n${event.systemPrompt ?? ""}`,
1084
1126
  };
1085
1127
  });
1086
1128
 
package/src/ledger.ts CHANGED
@@ -101,6 +101,8 @@ export type CaseRecord = {
101
101
  tags?: string[];
102
102
  /** Explicit assumptions or unknowns to avoid overstating exploitability. */
103
103
  assumptions?: string[];
104
+ /** Agent's documented attempt to disprove the finding (required before CONFIRMED). */
105
+ disconfirmation?: string;
104
106
  /** Verification of an on-disk PoC run (set only by promoteFindingResult). */
105
107
  pocVerified?: {
106
108
  path: string;
@@ -109,6 +111,14 @@ export type CaseRecord = {
109
111
  output?: string;
110
112
  sandbox: boolean;
111
113
  };
114
+ /** Verification of a disconfirmation run (set only by promoteFindingResult). */
115
+ disconfirmationVerified?: {
116
+ path: string;
117
+ exitCode: number;
118
+ ranAt: string;
119
+ output?: string;
120
+ sandbox: boolean;
121
+ };
112
122
  /** ISO timestamp when CaseReport first wrote the markdown report. */
113
123
  reportedAt?: string;
114
124
  /** Path to the generated markdown report (set only by writeCaseReport). */
@@ -140,11 +150,14 @@ export type CaseInput = {
140
150
  blockers?: string[];
141
151
  tags?: string[];
142
152
  assumptions?: string[];
153
+ /** Agent's documented attempt to disprove the finding (required before CONFIRMED). */
154
+ disconfirmation?: string;
143
155
  };
144
156
 
145
157
  type NormalizedCaseInput = Partial<CaseInput> & {
146
158
  linkedCaseIds?: string[];
147
159
  pocVerified?: CaseRecord["pocVerified"];
160
+ disconfirmationVerified?: CaseRecord["disconfirmationVerified"];
148
161
  reportedAt?: string;
149
162
  reportPath?: string;
150
163
  };
@@ -285,6 +298,8 @@ function getDb(): DatabaseSync {
285
298
  tags_json TEXT, -- JSON string array
286
299
  assumptions_json TEXT, -- JSON string array
287
300
  poc_verified_json TEXT, -- JSON object
301
+ disconfirmation TEXT,
302
+ disconfirmation_verified_json TEXT, -- JSON object
288
303
  reported_at TEXT,
289
304
  report_path TEXT,
290
305
  created_at TEXT NOT NULL,
@@ -309,6 +324,15 @@ function getDb(): DatabaseSync {
309
324
  db.exec("ALTER TABLE case_links ADD COLUMN kind TEXT NOT NULL DEFAULT 'related'");
310
325
  }
311
326
 
327
+ // Idempotent migration for new columns on existing databases
328
+ const caseCols = db.prepare("PRAGMA table_info(cases)").all() as { name: string }[];
329
+ if (!caseCols.some((c) => c.name === "disconfirmation")) {
330
+ db.exec("ALTER TABLE cases ADD COLUMN disconfirmation TEXT");
331
+ }
332
+ if (!caseCols.some((c) => c.name === "disconfirmation_verified_json")) {
333
+ db.exec("ALTER TABLE cases ADD COLUMN disconfirmation_verified_json TEXT");
334
+ }
335
+
312
336
  // Indexes
313
337
  db.exec(`CREATE INDEX IF NOT EXISTS idx_cases_status ON cases(status)`);
314
338
  db.exec(`CREATE INDEX IF NOT EXISTS idx_cases_target ON cases(target)`);
@@ -361,7 +385,9 @@ function mapRow(row: any, linkedCases: { id: string; kind: string }[] = []): Cas
361
385
  blockers: safeParseArray(row.blockers_json),
362
386
  tags: safeParseArray(row.tags_json),
363
387
  assumptions: safeParseArray(row.assumptions_json),
388
+ disconfirmation: row.disconfirmation || undefined,
364
389
  pocVerified: safeParseObject(row.poc_verified_json),
390
+ disconfirmationVerified: safeParseObject(row.disconfirmation_verified_json),
365
391
  reportedAt: row.reported_at || undefined,
366
392
  reportPath: row.report_path || undefined,
367
393
  linkedCases,
@@ -393,6 +419,19 @@ export function readCasefile(): CaseRecord[] {
393
419
  return rows.map((row: any) => mapRow(row, linkMap.get(row.id) ?? []));
394
420
  }
395
421
 
422
+ /**
423
+ * Read only non-terminal cases (hypothesis, investigating, confirmed, blocked).
424
+ * Used for per-prompt context injection so we never load killed/reported rows
425
+ * (which grow without bound over a long engagement) into memory each turn.
426
+ */
427
+ export function readActiveCases(): CaseRecord[] {
428
+ const db = getDb();
429
+ const rows = db
430
+ .prepare("SELECT * FROM cases WHERE status NOT IN ('killed', 'reported')")
431
+ .all() as any[];
432
+ return mapRowsWithLinks(db, rows);
433
+ }
434
+
396
435
  export function getCaseById(id: string): CaseRecord | undefined {
397
436
  const db = getDb();
398
437
  const stmt = db.prepare("SELECT * FROM cases WHERE id = ?");
@@ -416,9 +455,13 @@ function validateCase(record: CaseRecord): void {
416
455
  // CONFIRMED when it has evidence, a PoC, demonstrated impact, and a severity.
417
456
  if (
418
457
  record.status === "confirmed" &&
419
- (!record.evidence || !record.poc || !record.impact || !record.severity)
458
+ (!record.evidence ||
459
+ !record.poc ||
460
+ !record.impact ||
461
+ !record.severity ||
462
+ !record.disconfirmation)
420
463
  ) {
421
- throw new Error("Confirmed cases require evidence, poc, impact, and severity");
464
+ throw new Error("Confirmed cases require evidence, poc, impact, severity, and disconfirmation");
422
465
  }
423
466
  if (record.status === "blocked" && (record.blockers ?? []).length === 0) {
424
467
  throw new Error("Blocked cases require at least one blocker");
@@ -553,6 +596,11 @@ function buildRecord(input: NormalizedCaseInput, existing?: CaseRecord): CaseRec
553
596
  tags: normalizeList(input.tags ?? existing?.tags),
554
597
  assumptions: normalizeList(input.assumptions ?? existing?.assumptions),
555
598
  pocVerified: input.pocVerified ?? existing?.pocVerified,
599
+ disconfirmation:
600
+ input.disconfirmation !== undefined
601
+ ? normalizeText(input.disconfirmation)
602
+ : existing?.disconfirmation,
603
+ disconfirmationVerified: input.disconfirmationVerified ?? existing?.disconfirmationVerified,
556
604
  reportedAt: input.reportedAt ?? existing?.reportedAt,
557
605
  reportPath: input.reportPath ?? existing?.reportPath,
558
606
  linkedCases: existing?.linkedCases ?? [],
@@ -574,12 +622,19 @@ function findDuplicateCaseInDb(
574
622
  const endpoint = normalizeMatchText(candidate.endpoint);
575
623
  const bugClass = normalizeMatchText(candidate.bugClass);
576
624
 
577
- // Query non-killed cases, then match normalized title/scope in JS.
625
+ // Pre-filter by normalized title in SQL so we don't load the whole ledger into
626
+ // JS just to find a duplicate. normalizeMatchText lowercases + collapses
627
+ // whitespace, so we match against lower(title) with the same normalization.
628
+ // The full title/target/endpoint/bugClass match still runs in JS below to catch
629
+ // whitespace/case differences the SQL LIKE can't express exactly.
630
+ const sqlTitle = `%${title}%`;
578
631
  const rows = excludeId
579
632
  ? (db
580
- .prepare("SELECT * FROM cases WHERE status != 'killed' AND id != ?")
581
- .all(excludeId) as any[])
582
- : (db.prepare("SELECT * FROM cases WHERE status != 'killed'").all() as any[]);
633
+ .prepare("SELECT * FROM cases WHERE status != 'killed' AND id != ? AND lower(title) LIKE ?")
634
+ .all(excludeId, sqlTitle) as any[])
635
+ : (db
636
+ .prepare("SELECT * FROM cases WHERE status != 'killed' AND lower(title) LIKE ?")
637
+ .all(sqlTitle) as any[]);
583
638
 
584
639
  for (const row of rows) {
585
640
  if (
@@ -610,11 +665,13 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
610
665
  id, title, status, confidence, severity, priority, target, endpoint, bugClass,
611
666
  summary, evidence, impact, nextStep, poc, remediation,
612
667
  references_json, blockers_json, tags_json, assumptions_json, poc_verified_json,
668
+ disconfirmation, disconfirmation_verified_json,
613
669
  reported_at, report_path, created_at, updated_at
614
670
  ) VALUES (
615
671
  ?, ?, ?, ?, ?, ?, ?, ?, ?,
616
672
  ?, ?, ?, ?, ?, ?,
617
673
  ?, ?, ?, ?, ?,
674
+ ?, ?,
618
675
  ?, ?, ?, ?
619
676
  )
620
677
  ON CONFLICT(id) DO UPDATE SET
@@ -637,6 +694,8 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
637
694
  tags_json = excluded.tags_json,
638
695
  assumptions_json = excluded.assumptions_json,
639
696
  poc_verified_json = excluded.poc_verified_json,
697
+ disconfirmation = excluded.disconfirmation,
698
+ disconfirmation_verified_json = excluded.disconfirmation_verified_json,
640
699
  reported_at = excluded.reported_at,
641
700
  report_path = excluded.report_path,
642
701
  created_at = excluded.created_at,
@@ -664,6 +723,8 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
664
723
  JSON.stringify(record.tags),
665
724
  JSON.stringify(record.assumptions),
666
725
  record.pocVerified ? JSON.stringify(record.pocVerified) : null,
726
+ record.disconfirmation || null,
727
+ record.disconfirmationVerified ? JSON.stringify(record.disconfirmationVerified) : null,
667
728
  record.reportedAt || null,
668
729
  record.reportPath || null,
669
730
  record.createdAt,
@@ -719,6 +780,7 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
719
780
  "nextStep",
720
781
  "poc",
721
782
  "remediation",
783
+ "disconfirmation",
722
784
  ] as const;
723
785
  const optionalPatch: Record<string, unknown> = {};
724
786
  for (const field of optionalFields) {
@@ -746,16 +808,38 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
746
808
  validateTransition(current.status, next.status, update, current);
747
809
  }
748
810
 
749
- // Demoting off confirmed invalidates prior PoC verification — re-promote required.
811
+ // Demoting off confirmed invalidates prior PoC + disconfirmation verification —
812
+ // re-promote required. Both verification artifacts must be re-earned together.
750
813
  if (current.status === "confirmed" && next.status === "investigating") {
751
- next = { ...next, pocVerified: undefined };
814
+ next = { ...next, pocVerified: undefined, disconfirmationVerified: undefined };
752
815
  }
753
816
 
754
817
  validateCase(next);
755
818
 
756
- // Check material equality (we ignore links since links are mutated via CaseLink)
819
+ // Check material equality (we ignore links since links are mutated via CaseLink).
820
+ // Keys are sorted before stringify because mapRow (DB read) and buildRecord
821
+ // (write) emit CaseRecord keys in different orders — a plain JSON.stringify({...r})
822
+ // would report false "changed" on no-op updates whenever a field is undefined on
823
+ // one side and absent on the other. Sorting makes the comparison order-independent.
824
+ // Do NOT simplify back to JSON.stringify({...r}) — it reintroduces the bug.
757
825
  const norm = (r: CaseRecord) =>
758
- JSON.stringify({ ...r, updatedAt: "", createdAt: "", linkedCaseIds: [], linkedCases: [] });
826
+ JSON.stringify(
827
+ Object.keys(r)
828
+ .sort()
829
+ .reduce<Record<string, unknown>>((acc, k) => {
830
+ if (
831
+ k === "updatedAt" ||
832
+ k === "createdAt" ||
833
+ k === "linkedCaseIds" ||
834
+ k === "linkedCases"
835
+ ) {
836
+ acc[k] = "";
837
+ } else {
838
+ acc[k] = (r as Record<string, unknown>)[k];
839
+ }
840
+ return acc;
841
+ }, {}),
842
+ );
759
843
  if (norm(current) === norm(next)) {
760
844
  const reason =
761
845
  update.status && update.status === current.status
@@ -777,7 +861,7 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
777
861
  return { record: next, changed: true };
778
862
  }
779
863
 
780
- type PocVerification = {
864
+ export type PocVerification = {
781
865
  path: string;
782
866
  exitCode: number;
783
867
  ranAt: string;
@@ -811,10 +895,24 @@ export function assertPromotable(id: string): CaseRecord {
811
895
  if (!current.severity) {
812
896
  throw new Error("CONFIRMED requires severity; set severity on the case first");
813
897
  }
898
+ if (!current.target) {
899
+ throw new Error(
900
+ "CONFIRMED requires target (what host/repo/scope this affects); set target on the case first",
901
+ );
902
+ }
903
+ if (!current.disconfirmation) {
904
+ throw new Error(
905
+ "CONFIRMED requires disconfirmation (your attempt to disprove the finding); set disconfirmation on the case first",
906
+ );
907
+ }
814
908
  return current;
815
909
  }
816
910
 
817
- export function promoteFindingResult(id: string, verification: PocVerification): CaseUpdateResult {
911
+ export function promoteFindingResult(
912
+ id: string,
913
+ verification: PocVerification,
914
+ disconfirmationVerification?: PocVerification,
915
+ ): CaseUpdateResult {
818
916
  const db = getDb();
819
917
  const current = assertPromotable(id);
820
918
  if (verification.exitCode !== 0) {
@@ -830,14 +928,16 @@ export function promoteFindingResult(id: string, verification: PocVerification):
830
928
  `- **Sandbox:** ${verification.sandbox ? "yes" : "no"}\n` +
831
929
  `#### Execution Output\n\`\`\`\n${verification.output ?? ""}\n\`\`\``;
832
930
 
833
- const next = buildRecord(
834
- {
835
- status: "confirmed",
836
- pocVerified: verification,
837
- evidence: newEvidence,
838
- },
839
- current,
840
- );
931
+ const update: NormalizedCaseInput = {
932
+ status: "confirmed",
933
+ pocVerified: verification,
934
+ evidence: newEvidence,
935
+ };
936
+ if (disconfirmationVerification) {
937
+ update.disconfirmationVerified = disconfirmationVerification;
938
+ }
939
+
940
+ const next = buildRecord(update, current);
841
941
  validateCase(next);
842
942
 
843
943
  upsertCase(db, next);
@@ -1226,6 +1326,13 @@ export function writeCaseReport(id: string): { path: string; record: CaseRecord
1226
1326
  `### PoC Run Verification\n- **Timestamp:** ${current.pocVerified.ranAt}\n- **Path:** \`${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\`\`\``,
1227
1327
  )
1228
1328
  : undefined,
1329
+ mdSection("Disconfirmation Attempt", current.disconfirmation),
1330
+ current.disconfirmationVerified
1331
+ ? mdSection(
1332
+ "Disconfirmation Verification Log",
1333
+ `### Disconfirmation Run Verification\n- **Timestamp:** ${current.disconfirmationVerified.ranAt}\n- **Path:** \`${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
+ )
1335
+ : undefined,
1229
1336
  mdSection("Impact", current.impact),
1230
1337
  mdSection("Remediation", current.remediation),
1231
1338
  mdSection("Assumptions and Uncertainty", assumptions),
@@ -1243,6 +1350,11 @@ export function writeCaseReport(id: string): { path: string; record: CaseRecord
1243
1350
  updatedAt: new Date().toISOString(),
1244
1351
  };
1245
1352
 
1353
+ // Enforce the same field invariants as every other write path. writeCaseReport
1354
+ // never changes status (confirmed stays confirmed; the caller flips to reported
1355
+ // via CaseUpdate, which runs validateTransition), but it does set reportPath —
1356
+ // validateCase ensures the resulting record is internally consistent.
1357
+ validateCase(next);
1246
1358
  upsertCase(db, next);
1247
1359
  return { path: reportPath, record: next };
1248
1360
  }
package/src/poc-runner.ts CHANGED
@@ -421,15 +421,20 @@ function runLocal(pocPath: string, language: PocLanguage): PocRun {
421
421
  throw new Error("Language config has no run command");
422
422
  }
423
423
 
424
- // The run template is `<interpreter> <file>`. Preserve multi-arg run commands for
425
- // normal paths, but keep a space-containing PoC path as a single argument (no shell
426
- // is used, so args are passed verbatim).
427
- const command = renderCommand(language.run, pocPath, false);
428
- const trimmed = command.trim();
429
- const firstSpace = trimmed.indexOf(" ");
430
- const interpreter = firstSpace === -1 ? trimmed : trimmed.slice(0, firstSpace);
431
- const rest = firstSpace === -1 ? "" : trimmed.slice(firstSpace + 1);
432
- const args = pocPath.includes(" ") ? (rest ? [rest] : []) : rest ? rest.split(" ") : [];
424
+ // The run template is `<interpreter> [flags...] {{file}}`. Split the static
425
+ // template on whitespace FIRST (the template is trusted config, not user input),
426
+ // then render placeholders within each token. Passing the tokens to spawnSync
427
+ // with NO shell keeps a space-containing PoC path as one arg and keeps extra
428
+ // flags (e.g. `node --experimental-vm-modules {{file}}`) as separate args.
429
+ // Splitting after rendering would re-split a space-containing path.
430
+ const tokens = language.run.trim().split(/\s+/).filter(Boolean);
431
+ const interpreter = tokens.shift() ?? language.run.trim();
432
+ const args = tokens.map((tok) =>
433
+ tok
434
+ .replace(/{{file}}/g, pocPath)
435
+ .replace(/{{bin}}/g, join(dirname(pocPath), "poc"))
436
+ .replace(/{{class}}/g, basename(pocPath).replace(/\.[^.]+$/i, "")),
437
+ );
433
438
 
434
439
  const result = spawnSync(interpreter, args, {
435
440
  encoding: "utf8",
package/src/workflow.ts CHANGED
@@ -1,187 +1,233 @@
1
1
  /**
2
2
  * Cyber workflow injected into agent context when XP mode is ON.
3
- * Extracted into its own file to keep index.ts readable.
3
+ *
4
+ * Skills (pipeline, web-pentest) already cover tool usage and methodology.
5
+ * This file adds the unique attacker discipline: state machine with
6
+ * preconditions, attacker model, impact validation, adversarial review,
7
+ * kill checklist, and report-readiness criteria.
8
+ *
9
+ * The case lifecycle (HYPOTHESIS -> INVESTIGATING -> CONFIRMED -> REPORTED)
10
+ * maps to the pipeline's discovery stages. This file explains the gate
11
+ * discipline applied at each transition.
4
12
  */
5
13
  export const STATIC_CYBER_WORKFLOW = `
6
14
  # Cyber Workflow (Attacker-Oriented)
7
15
 
8
16
  Think like a real external attacker, not a code reviewer. Technical bugs are cheap; **reachable attacker impact** is what matters for bounty-valid findings.
9
17
 
10
- Every lead starts HYPOTHESIS. Nothing reaches CONFIRMED without:
11
- 1. a working PoC on disk,
12
- 2. a proven attacker path (who can trigger it, from where),
13
- 3. demonstrated C/I/A impact (not theoretical).
18
+ Every lead starts HYPOTHESIS. Nothing reaches CONFIRMED without a proven attacker path and demonstrated impact against a real production target or faithful replica.
14
19
 
15
- Prefer killing a cute-but-unusable bug over reporting noise. Every confirmed finding must survive skeptical review by another experienced security researcher **and** a program triage engineer.
16
-
17
- ## State Machine (CaseAdd → CaseUpdate → CaseReport)
20
+ ## Case Lifecycle (State Machine)
18
21
 
19
22
  \`\`\`
20
- HYPOTHESIS ──→ INVESTIGATING ──→ CONFIRMED ──→ REPORTED
21
- │ │ │
22
- └──→ KILLED ←───┘ │
23
- CONFIRMED ←─ KILL if any gate fails
23
+ +--- KILLED (dead end, documented why)
24
+ |
25
+ RECON -> HYPOTHESIS --+
26
+ |
27
+ +--> INVESTIGATING --> CONFIRMED --> REPORTED
28
+ | ^ |
29
+ | | chain/primitive |
30
+ | +-----------------+
31
+ |
32
+ +--> KILLED (insufficient impact, duplicate, etc.)
24
33
  \`\`\`
25
34
 
26
- ### Preconditions Per State (MANDATORY)
35
+ ### Phase -> State map
36
+
37
+ | Phase | Case State | What happens |
38
+ |-------|-----------|-------------|
39
+ | RECON | (none yet) | Map attack surface, fingerprint, search CVEs. When something interesting appears -> HYPOTHESIS. |
40
+ | HUNT | HYPOTHESIS | Document the lead. Impact not required yet. If it's a clear intended-behavior or artifact -> KILLED. Otherwise -> INVESTIGATING. |
41
+ | CHAIN | INVESTIGATING | Test the hypothesis, chain primitives, build PoC. Explore combinations (open redirect + SSRF, leak + other endpoint, etc.). |
42
+ | VALIDATE | CONFIRMED | Prove impact against production target, adversarial review, root-cause trace. Survive the gates below, or fall back to INVESTIGATING / KILLED. |
43
+ | REPORT | REPORTED | Write up, report-readiness gate, submit. |
44
+
45
+ ### Preconditions Per State Transition (MANDATORY)
27
46
 
28
47
  | Advance To | Required Case Fields | Must Exist on Disk |
29
48
  |-----------|---------------------|--------------------|
30
- | INVESTIGATING | \`evidence\` (source→sink + **attacker reachability**), \`confidence\` | Path trace in notes |
31
- | **CONFIRMED** | \`evidence\`, **\`poc\`**, \`impact\` (attacker-real), \`severity\`, **\`impact_proof\`** | **PoC script + run.log exit 0 + proof of impact** |
32
- | KILLED | \`assumptions\` (why it died: no path / no impact / not applicable) | |
33
- | REPORTED | Only after \`CaseReport(id)\` succeeds | Report file |
49
+ | 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 + run.log exit 0. Optionally, disconfirmation script run.log exit non-0 (finding survived the attempt to disprove). |
51
+ | Any -> KILLED | assumptions (why it died) | --- |
52
+ | CONFIRMED -> REPORTED | Only after CaseReport(id) succeeds | Report file |
53
+
54
+ **Rule: If a required field is empty, you cannot advance.** The fields are the gates.
55
+
56
+ ### When to advance vs kill vs stay
57
+
58
+ Staying in HYPOTHESIS or INVESTIGATING is **fine** --- it means you're still working. Do not force a transition.
34
59
 
35
- **Rule: If a required field is empty, you cannot advance.** \`CaseUpdate({status:"confirmed", poc:""})\` is invalid. The fields are the gates.
60
+ - **HYPOTHESIS -> KILLED only when**: it's documented intended behavior, duplicate, artifact/noise, or you proved no attack path exists after testing.
61
+ - **HYPOTHESIS -> INVESTIGATING**: you have something real and are actively testing. Source-sink not required yet.
62
+ - **INVESTIGATING -> KILLED**: you proved insufficient impact, environmental issue, unreliable exploit, or duplicate after investigation.
63
+ - **INVESTIGATING -> CONFIRMED**: strict gates below must pass.
36
64
 
37
65
  ---
38
66
 
39
- ## 0. Attacker Model (Read First)
67
+ ## At HYPOTHESIS (just found something)
68
+
69
+ Document what you know without worrying about impact proof:
70
+
71
+ 1. **What happened?** (behavior, error, timing, leak)
72
+ 2. **Where?** (endpoint, parameter, component, line)
73
+ 3. **Who can reach it?** (unauth, any user, admin only)
74
+ 4. **What you don't know yet** -> next experiments
75
+
76
+ **Do not kill a hypothesis just because impact is unclear.** Impact may come from chaining.
77
+
78
+ **Kill a hypothesis only when:**
79
+ - It's clearly documented/intended behavior (after checking docs)
80
+ - It's a duplicate
81
+ - It's a test artifact, cache noise, browser quirk
82
+ - You tested and proved no attack path exists (not "I can't see one")
83
+
84
+ ---
40
85
 
41
- Before escalating any finding, answer in evidence:
86
+ ## At INVESTIGATING (chaining primitives)
87
+
88
+ Many findings start as primitives: open redirect, limited SSRF, info leak of non-sensitive data, reflected XSS on non-sensitive page, CSRF on public-only action.
89
+
90
+ For each primitive, ask:
91
+ 1. **What can this combine with?** (SSRF + internal service, open redirect + OAuth callback, leak + other endpoint)
92
+ 2. **Does the primitive cross a trust boundary?** Can an unauth user trigger it? Can a low-priv user reach an admin endpoint?
93
+ 3. **What's the worst-case chain expressed in C/I/A?**
94
+
95
+ Record chains via CaseLink. Keep the primitive as INVESTIGATING while you explore. Only KILL if you prove no chain exists after testing.
96
+
97
+ ---
98
+
99
+ ## At VALIDATE (before advancing to CONFIRMED)
100
+
101
+ Before promoting to CONFIRMED, the following must be fully answered and documented in the case fields (evidence + impact). Incomplete answers = stay INVESTIGATING.
102
+
103
+ ### 0. Attacker Model (must be in evidence or impact field)
42
104
 
43
105
  1. **Who is the attacker?** (unauth internet, low-priv user, tenant peer, SSRF pivot, etc.)
44
106
  2. **What can they already do without the bug?** (baseline privileges)
45
107
  3. **What extra power does the bug grant beyond that baseline?**
46
- 4. **Is the path realistic in production?** (auth, CSRF, WAF, network, feature flags, admin-only)
108
+ 4. **Is the path realistic in production?** (auth, CSRF, WAF, network, feature flags, admin-only required?)
47
109
 
48
- If you cannot name a concrete attacker who gains something they should not have do **not** confirm. Keep as hypothesis/investigating, or kill as \`insufficient_impact\` / \`environmental_issue\`.
110
+ If you cannot name a concrete attacker who gains something they should not have -> do **not** confirm. Stay INVESTIGATING or KILL with documented reason.
49
111
 
50
- **Non-applicable / weak-impact defaults (KILL or do not promote):**
51
- - Self-XSS / self-DoS only (attacker harms only their own session/account)
52
- - Requires admin/root/already-trusted role that already has the same power
53
- - Local-only, offline, or impossible deployment assumptions
54
- - Spec-compliant / documented intentional behavior
55
- - Needs physical access, victim to paste payload into their own console, or other social-engineering-only steps with no trust-boundary break
56
- - "Interesting" logic quirks with **no confidentiality, integrity, availability, or financial effect**
57
- - PoC proves a code path exists but **not** that a real victim asset is affected
112
+ ### 1. Disconfirmation Attempt (mandatory field before CONFIRMED)
58
113
 
59
- Technical validity bounty validity. A true bug with no attacker-usable impact is still a kill for confirmed/report.
114
+ Before promoting, you must actively attempt to disprove your own finding.
115
+ This is not a formality --- the attempt is documented in the \`disconfirmation\`
116
+ field and verified by the optional \`disconfirmation_path\` in PromoteFinding.
60
117
 
61
- ---
118
+ **What a disconfirmation attempt looks like:**
62
119
 
63
- ## 1. Evidence-First Doctrine
64
- Evidence overrides intuition. Never present speculation as fact. Every security claim must be traceable to:
65
- - Observed behavior (logs, responses, error traces)
66
- - Reproduced behavior (exact steps, scripts)
67
- - Source code / protocol analysis
68
- - Documented platform behavior
69
- If evidence is insufficient: state uncertainty, propose the next experiment, do not escalate. Never assume success where verification is incomplete.
120
+ - Reproduce the finding under different conditions (different auth, different
121
+ config, different network position). If it fails, you disproved the scope.
122
+ - Check if the behavior is intentional by testing against documentation or
123
+ by trying to get the same result on a known-baseline endpoint.
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?
70
128
 
71
- ---
129
+ **Document the attempt in \`disconfirmation\` field.** Must include:
130
+ 1. What you tried to do to disprove the finding
131
+ 2. How you did it (conditions, inputs, target)
132
+ 3. What result you got (if it failed to disprove, that's the expected outcome)
133
+ 4. Why you believe the disconfirmation attempt was valid
72
134
 
73
- ## 2. Impact Gate (Mandatory Before CONFIRMED)
135
+ **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."
74
141
 
75
- Prove at least **one** real attacker-facing violation:
142
+ **Weak disconfirmation:**
143
+ "Tried to disprove. Could not."
144
+
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
+ ### 2. Production Path Verification (must be in impact field)
151
+
152
+ The **impact** field for CONFIRMED must explicitly answer:
153
+
154
+ 1. **Target environment:** Which host/repo/instance was this tested against? (prod, staging, dev, local?)
155
+ 2. **Production protections:** What protections exist in production that could block this path? (WAF, CSRF tokens, CORS, CSP, rate limiting, network segmentation, auth, feature flags, admin-only access)
156
+ 3. **Bypass verification:** For each protection, have you confirmed it is bypassed or absent?
157
+ 4. **Target comparison:** If tested against dev/staging/local, what differs in production that could affect exploitability? Have you verified the path still works in the production configuration?
158
+
159
+ **Weak impact that fails this gate:**
160
+ - "Attacker can read files" without specifying which target and whether protections block it
161
+ - "This works on localhost" without verifying production differences
162
+ - "The code path exists" without proving a real victim asset is reachable
163
+ - "Could be dangerous" or "may lead to RCE" without a concrete production path
164
+
165
+ 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
+ ### 2. KILL at Validate stage
168
+
169
+ Documented intended behavior
170
+ - Self-XSS / self-DoS only (attacker harms only their own session)
171
+ - Requires admin/root role that already has the same power
172
+ - Local-only, offline, or impossible deployment assumptions
173
+ - Needs physical access, social engineering with no trust-boundary break
174
+ - No C/I/A/financial effect for anyone but the attacker
175
+ - PoC proves a code path exists but not that any victim asset is affected
176
+ - Protections in production block the path and are not bypassed
177
+
178
+ ### 3. Evidence-First Doctrine
179
+
180
+ 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
+ ### 4. Impact Gate
183
+
184
+ Prove at least **one** real attacker-facing violation against a production-viable target:
76
185
 
77
186
  | Category | Required proof |
78
187
  |----------|----------------|
79
- | **Confidentiality** | Attacker reads data they must not see (other users/tenants/secrets) |
188
+ | **Confidentiality** | Attacker reads data they must not see |
80
189
  | **Integrity** | Attacker changes data/state they must not control |
81
- | **Availability** | Attacker degrades service for **others** (not only self) |
190
+ | **Availability** | Attacker degrades service for **others** |
82
191
  | **Financial / authz** | Direct money, privilege, or account takeover path |
83
192
 
84
- Impact text must answer: *who is hurt, what is lost, how the attacker reaches it.*
85
- Vague impact like "could be dangerous" or "may lead to RCE" without a path is not impact_proof.
193
+ Impact text must answer: *who is hurt, what is lost, how the attacker reaches it from production.*
86
194
 
87
- If impact is only theoretical, needs a second unproven bug, or is not yet capable from the attacker's seat stay INVESTIGATING (chain it) or KILL \`insufficient_impact\`. Do **not** confirm "valid but non-applicable" findings.
195
+ 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.
88
196
 
89
- ---
197
+ ### 5. Adversarial Self-Review
90
198
 
91
- ## 3. Adversarial Self-Review (Mandatory Before CONFIRMED)
92
- Argue against yourself:
93
- 1. Why this might NOT be a vulnerability (intended, sandbox, misconfig, already authorized).
199
+ 1. Why this might NOT be a vulnerability.
94
200
  2. Alternative explanations for the observation.
95
201
  3. Why each alternative was rejected **with evidence**.
96
- 4. What blocks a real attacker today (auth, CSRF, network, role checks) and whether each is bypassed.
97
- 5. Would a program triage say "informative / N/A" because impact is self-only or privileged-only?
202
+ 4. What blocks a real attacker in production today and whether each is bypassed.
203
+ 5. Would a program triage reject this as informative/N/A?
98
204
 
99
- ---
100
-
101
- ## 4. False Positive / Non-Applicable Kill Checklist
102
- KILL immediately when any apply:
103
- - Matches documented/spec behavior (\`intended_behavior\`)
104
- - Browser quirk, test artifact, or cache noise
105
- - Framework/middleware/WAF blocks the path and is not bypassed (\`framework_protection\`)
106
- - Requires privileges the attacker already has or cannot obtain (\`environmental_issue\`)
107
- - No C/I/A/financial effect for anyone but the attacker themselves (\`insufficient_impact\`)
108
- - Exploit unreliable / not reproducible twice (\`exploit_unreliable\`)
109
- - Duplicate of an existing case (\`duplicate\`)
205
+ ### 6. Root Cause -> Boundary -> Impact
110
206
 
111
- ---
112
-
113
- ## 5. Root Cause → Boundary → Impact (Not Behavior → Hype)
114
- Trace:
115
207
  \`\`\`
116
- Entry (attacker-controlled) ──→ Reachable code path ──→ Trust boundary crossed ──→ Victim impact
208
+ Entry (attacker-controlled) -> Code path -> Trust boundary crossed -> Victim impact
117
209
  \`\`\`
118
- - Minimum: reproduce successfully at least twice or via two independent methods.
119
- - Record: **Observed Facts**, **Assumptions**, **Unknowns**, **Experiments Remaining**.
120
- - If the bug is only a **primitive** (e.g. open redirect, limited SSRF, info leak of non-sensitive data), either chain to high impact or keep severity honest — do not inflate.
121
-
122
- ---
123
210
 
124
- ## 6. Duplicate Check
125
- Before CaseAdd:
126
- - Is this new?
127
- - Same root cause as an open case?
128
- - Multiple endpoints, one bug?
129
- Continue the existing case ID when scope matches.
211
+ Reproduce at least twice or via two methods.
130
212
 
131
213
  ---
132
214
 
133
- ## 7. Report-Readiness Gate
134
- Before REPORTED:
215
+ ## At REPORT (before advancing to REPORTED)
216
+
135
217
  - Another researcher can reproduce deterministically
136
- - Steps are complete and production-realistic
137
- - Impact is justified without inflation (would the vendor agree?)
138
- - Root cause and fix guidance are concrete
139
- - Attacker model + victim impact are explicit in the write-up
218
+ - Steps realistic in production
219
+ - Impact justified without inflation (would the vendor agree?)
220
+ - Root cause + fix guidance are concrete
221
+ - Attacker model + victim impact + target explicit
140
222
 
141
223
  ---
142
224
 
143
- ## 8. Permanent KILLED Cataloging
144
- Keep killed reasons explicit in assumptions/blockers:
145
- - \`intended_behavior\`
146
- - \`duplicate\`
147
- - \`framework_protection\`
148
- - \`exploit_unreliable\`
149
- - \`insufficient_impact\`
150
- - \`environmental_issue\`
151
- - \`not_applicable\` (true bug / interesting behavior, no realistic attacker value)
152
- Documenting kills prevents re-opening dead ends.
225
+ ## KILLED cataloging
153
226
 
154
- ---
227
+ When a case is definitively dead (not "I don't know yet"), record the reason:
228
+ - intended_behavior / duplicate / framework_protection
229
+ - exploit_unreliable / insufficient_impact / environmental_issue
230
+ - not_applicable (true bug / interesting behavior, no realistic attacker value)
155
231
 
156
- ## 9. Tool Ecosystem (USE PROACTIVELY)
157
-
158
- You have offensive tools beyond casefile. Use them — do not rely on memory or guesswork.
159
-
160
- | Tool | When to use | Do NOT skip it when... |
161
- |------|------------|------------------------|
162
- | **ExploitSearch** | Before writing any PoC. Search for known techniques, bypasses, and attack primitives relevant to the target stack/vuln class. | ...you are investigating a hypothesis or building a PoC. Ground your approach in real write-ups, not memory. |
163
- | **web_search** | To find CVEs, advisories, prior bug reports, documentation, or any live information about the target. | ...you need to check if a vulnerability is known, find version-specific issues, or research a technology. |
164
- | **web_fetch** | To read full page content from a URL you already have (advisory, write-up, target page). | ...you have a specific URL to inspect. |
165
- | **context7** | To look up current library/framework API docs and behavior. | ...you need to understand how a framework feature works (auth, parsing, routing). |
166
- | **deepwiki** | To ask questions about a public GitHub repository's architecture and internals. | ...the target is an open-source project and you need to understand its design. |
167
- | **codebase-memory-mcp** | To index a codebase and trace source-to-sink paths. index_repository, get_architecture, search_graph, trace_path. | ...you have access to the target source code and need structural reachability analysis. |
168
-
169
- **pdtm CLI tools** (run via bash; each takes auth/flags differently — read the flags, do not guess):
170
- - subfinder -d host (-silent, -t threads) — passive subdomain enum from API sources; no target auth. Pipe to httpx, not straight to nuclei.
171
- - httpx -u <url> / -l hosts.txt (-t threads, -td tech-detect, -mc match-status, -H "Name: Value") — fast probe of authed endpoints; supports Header/Cookie/Bearer.
172
- - ffuf -u <url> -w wordlist (-t threads, -rate, -H "..." -b "c=v", -mc/-fs filters) — authed fuzzing / content discovery.
173
- - whatweb <url> (-a aggression 1-4, -t threads, --cookie) — tech fingerprint; positional URL, no -u.
174
- - naabu -host <ip> / -l (-p ports, -rate, -c top-ports) — port scan; hosts, not web-auth.
175
- - katana -u <url> / -list (-d depth, -jc js-crawl, -H "...") — crawl.
176
- - nuclei -l hosts.txt / -u <url> (-tags, -severity, -type http, -silent; -c threads -bs host-batch -rl rate-limit -timeout 5 -retries 0): FAST when filtered, slow only if naive.
177
- - Do not run all 9000+ templates. Filter: -tags cve,exposure,rce -severity critical,high -type http -t http/misconfiguration/.
178
- - Pre-filter targets: subfinder -> httpx -mc 200,403 -> nuclei (cuts ~80% of work).
179
- - Tune: -c 100-200 -bs 50-100 -rl 300 (avoid Cloudflare tarpit) -timeout 5 -retries 0 -mhe 10.
180
- - Many hosts: -scan-strategy host-spray (v3). Few hosts many templates: template-spray.
181
-
182
- **Default behavior when XP mode is ON:**
183
- 1. Start recon with ExploitSearch + web_search before diving into code.
184
- 2. Use context7/deepwiki to understand framework internals before claiming a vuln.
185
- 3. Use codebase-memory-mcp (if available) to prove reachability structurally.
186
- 4. Log everything to casefile (CaseAdd/CaseUpdate). Do not skip the ledger.
232
+ Documenting kills prevents re-opening dead ends. Cases with unresolved unknowns should stay INVESTIGATING, not killed.
187
233
  `.trim();