@xaccefy/pi-casefile 0.7.1 → 0.7.3

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/src/ledger.ts CHANGED
@@ -11,8 +11,14 @@
11
11
  */
12
12
 
13
13
  import { createHash, randomUUID } from "node:crypto";
14
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
14
+ import { type Dirent, existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
15
15
  import { basename, dirname, join, resolve } from "node:path";
16
+ import {
17
+ getScratchpadRoot,
18
+ type ScratchpadPhase,
19
+ scratchpad_read,
20
+ scratchpad_resume,
21
+ } from "./scratchpad.ts";
16
22
  import { DatabaseSync } from "./sqlite-compat/index.ts";
17
23
 
18
24
  // ── Types ────────────────────────────────────────────────────────────
@@ -119,9 +125,9 @@ export type CaseRecord = {
119
125
  output?: string;
120
126
  sandbox: boolean;
121
127
  };
122
- /** ISO timestamp when CaseReport first wrote the markdown report. */
128
+ /** ISO timestamp when CaseContext first wrote the context bundle. */
123
129
  reportedAt?: string;
124
- /** Path to the generated markdown report (set only by writeCaseReport). */
130
+ /** Path to the final report file (set by writeCaseContext; the reporter agent writes the file). */
125
131
  reportPath?: string;
126
132
  /** Flat list of linked case IDs (back-compat; derived from linkedCases). */
127
133
  linkedCaseIds: string[];
@@ -486,10 +492,13 @@ function validateCase(record: CaseRecord): void {
486
492
  "Killed cases require evidence, next step, blockers, or assumptions explaining why",
487
493
  );
488
494
  }
489
- // A case becomes REPORTED only via CaseReport, which records reportPath. Require it
490
- // here so validation stays consistent with the confirmed→reported transition gate.
491
- if (record.status === "reported" && !record.reportPath) {
492
- throw new Error("Reported cases require a generated report (run CaseReport first)");
495
+ // A case becomes REPORTED only after the report FILE exists on disk (the
496
+ // report writer writes it at the path CaseContext recorded). Require both
497
+ // here so validation stays consistent with the confirmed→reported gate.
498
+ if (record.status === "reported" && (!record.reportPath || !existsSync(record.reportPath))) {
499
+ throw new Error(
500
+ "Reported cases require the report file on disk; run CaseContext then have the report writer create it",
501
+ );
493
502
  }
494
503
  }
495
504
 
@@ -541,8 +550,8 @@ function validateTransition(
541
550
  },
542
551
  confirmed: {
543
552
  reported: (_, current) =>
544
- !current?.reportPath
545
- ? "confirmed → reported requires a report; run CaseReport first"
553
+ !current?.reportPath || !existsSync(current.reportPath)
554
+ ? "confirmed → reported requires the report file on disk; run CaseContext, then have the report writer create it"
546
555
  : null,
547
556
  investigating: () => null,
548
557
  },
@@ -623,7 +632,7 @@ function findDuplicateCaseInDb(
623
632
  db: DatabaseSync,
624
633
  candidate: Pick<CaseRecord, "title" | "target" | "endpoint" | "bugClass">,
625
634
  excludeId?: string,
626
- ): CaseRecord | undefined {
635
+ ): { record: CaseRecord; near: boolean } | undefined {
627
636
  const title = normalizeMatchText(candidate.title);
628
637
  if (!title) return undefined;
629
638
 
@@ -636,12 +645,14 @@ function findDuplicateCaseInDb(
636
645
  // ASCII-only and LIKE can't collapse whitespace, so any SQL pre-filter would
637
646
  // silently drop rows the JS comparator would call duplicates (e.g. stored
638
647
  // "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.
648
+ // Case ledgers are small (hundreds of rows); a full scan of live rows is cheap.
649
+ // Reported rows are excluded: they are terminal — an exact/near duplicate of a
650
+ // reported case is a NEW follow-up case, not a merge target.
640
651
  const rows = excludeId
641
652
  ? (db
642
- .prepare("SELECT * FROM cases WHERE status != 'killed' AND id != ?")
653
+ .prepare("SELECT * FROM cases WHERE status NOT IN ('killed', 'reported') AND id != ?")
643
654
  .all(excludeId) as any[])
644
- : (db.prepare("SELECT * FROM cases WHERE status != 'killed'").all() as any[]);
655
+ : (db.prepare("SELECT * FROM cases WHERE status NOT IN ('killed', 'reported')").all() as any[]);
645
656
 
646
657
  for (const row of rows) {
647
658
  if (
@@ -650,18 +661,212 @@ function findDuplicateCaseInDb(
650
661
  normalizeMatchText(row.endpoint as string) === endpoint &&
651
662
  normalizeMatchText(row.bugClass as string) === bugClass
652
663
  ) {
653
- const links = db
654
- .prepare("SELECT target_id, kind FROM case_links WHERE source_id = ?")
655
- .all(row.id) as { target_id: string; kind: string }[];
656
- return mapRow(
657
- row,
658
- links.map((l) => ({ id: l.target_id, kind: l.kind })),
664
+ return { record: rowToRecord(db, row), near: false };
665
+ }
666
+ }
667
+
668
+ // Near-duplicate gate: parallel subagents re-phrase the same finding
669
+ // (different prefixes, order, or extra detail), so exact normalized titles
670
+ // miss most duplicates. When BOTH sides have the same non-empty target and
671
+ // the titles share enough significant tokens (≥5-char words, stopwords
672
+ // excluded), treat it as the same case — the agent should CaseUpdate the
673
+ // existing case instead of creating a 31st near-identical one. Calibrated
674
+ // against a real 30-case run: true near-dups shared 3–6 distinctive tokens.
675
+ // The non-empty-target requirement is deliberate: with no target, titles
676
+ // share only generic class vocabulary ("remote code execution in image vs
677
+ // PDF processing") and near-dup would false-merge distinct findings.
678
+ const candidateTokens = new Set(significantTitleTokens(title));
679
+ if (target && candidateTokens.size >= 3) {
680
+ for (const row of rows) {
681
+ const rowTarget = normalizeMatchText(row.target as string);
682
+ if (!rowTarget || rowTarget !== target) continue;
683
+ const shared = countSharedTokens(
684
+ candidateTokens,
685
+ significantTitleTokens(row.title as string),
659
686
  );
687
+ if (shared >= NEAR_DUP_MIN_SHARED_TOKENS) {
688
+ return { record: rowToRecord(db, row), near: true };
689
+ }
660
690
  }
661
691
  }
692
+
662
693
  return undefined;
663
694
  }
664
695
 
696
+ // ── Near-duplicate title comparison ───────────────────────────────────
697
+ // Subagent titles phrase the same finding differently, so dedup must survive
698
+ // re-wording. Tokens are ≥5-char words from the lowercased, punctuation-split
699
+ // title, minus a small stopword set ("middleware", "pipeline", …). Two cases
700
+ // with the same target that share ≥3 significant tokens are near-duplicates.
701
+
702
+ const NEAR_DUP_MIN_SHARED_TOKENS = 3;
703
+
704
+ const TITLE_STOPWORDS = new Set([
705
+ // structural / workflow words
706
+ "middleware",
707
+ "middlewares",
708
+ "pipeline",
709
+ "finding",
710
+ "findings",
711
+ "vulnerability",
712
+ "vulnerabilities",
713
+ "issue",
714
+ "issues",
715
+ "result",
716
+ "results",
717
+ "causes",
718
+ "cause",
719
+ "leads",
720
+ "lead",
721
+ // connective / generic
722
+ "allows",
723
+ "allow",
724
+ "using",
725
+ "without",
726
+ "because",
727
+ "through",
728
+ "within",
729
+ "across",
730
+ "via",
731
+ "with",
732
+ "after",
733
+ "before",
734
+ "from",
735
+ "into",
736
+ "that",
737
+ "this",
738
+ "there",
739
+ "their",
740
+ "when",
741
+ "where",
742
+ "which",
743
+ "what",
744
+ "does",
745
+ "doesn",
746
+ "has",
747
+ "have",
748
+ "been",
749
+ "being",
750
+ "not",
751
+ "only",
752
+ "other",
753
+ "another",
754
+ "more",
755
+ "most",
756
+ "some",
757
+ "any",
758
+ "and",
759
+ "the",
760
+ "for",
761
+ "are",
762
+ "was",
763
+ "were",
764
+ "but",
765
+ "can",
766
+ "could",
767
+ "would",
768
+ "should",
769
+ "might",
770
+ // generic security-report vocabulary — class and filler words that appear in
771
+ // nearly every finding title; suppressing them makes the gate count only the
772
+ // distinctive subject (the trigger/location), which is what separates
773
+ // re-phrasings of one bug from different bugs in the same class.
774
+ "endpoint",
775
+ "endpoints",
776
+ "arbitrary",
777
+ "file",
778
+ "files",
779
+ "folder",
780
+ "folders",
781
+ "execution",
782
+ "execute",
783
+ "processing",
784
+ "process",
785
+ "remote",
786
+ "stored",
787
+ "blind",
788
+ "boolean",
789
+ "based",
790
+ "account",
791
+ "accounts",
792
+ "takeover",
793
+ "admin",
794
+ "administrator",
795
+ "administrators",
796
+ "request",
797
+ "requests",
798
+ "response",
799
+ "responses",
800
+ "parameter",
801
+ "parameters",
802
+ "input",
803
+ "inputs",
804
+ "value",
805
+ "values",
806
+ "user",
807
+ "users",
808
+ "data",
809
+ "access",
810
+ "page",
811
+ "pages",
812
+ "report",
813
+ "reports",
814
+ "code",
815
+ "script",
816
+ "scripts",
817
+ "injection",
818
+ "injections",
819
+ "leak",
820
+ "leaks",
821
+ "leaking",
822
+ "expose",
823
+ "exposes",
824
+ "exposed",
825
+ "exposure",
826
+ "disclose",
827
+ "discloses",
828
+ "disclosed",
829
+ "disclosure",
830
+ "bypass",
831
+ "bypasses",
832
+ "bypassing",
833
+ "bypassed",
834
+ ]);
835
+
836
+ /** Significant (≥5-char, non-stopword) unique tokens of a title. */
837
+ function significantTitleTokens(title: string): string[] {
838
+ const words = (title ?? "")
839
+ .toLowerCase()
840
+ .replace(/[^a-z0-9]+/g, " ")
841
+ .split(" ")
842
+ .filter(Boolean);
843
+ const seen = new Set<string>();
844
+ const out: string[] = [];
845
+ for (const w of words) {
846
+ if (w.length >= 5 && !TITLE_STOPWORDS.has(w) && !seen.has(w)) {
847
+ seen.add(w);
848
+ out.push(w);
849
+ }
850
+ }
851
+ return out;
852
+ }
853
+
854
+ function countSharedTokens(a: Set<string>, b: string[]): number {
855
+ let n = 0;
856
+ for (const t of b) if (a.has(t)) n++;
857
+ return n;
858
+ }
859
+
860
+ function rowToRecord(db: DatabaseSync, row: any): CaseRecord {
861
+ const links = db
862
+ .prepare("SELECT target_id, kind FROM case_links WHERE source_id = ?")
863
+ .all(row.id) as { target_id: string; kind: string }[];
864
+ return mapRow(
865
+ row,
866
+ links.map((l) => ({ id: l.target_id, kind: l.kind })),
867
+ );
868
+ }
869
+
665
870
  // ── SQLite Mutation Actions ───────────────────────────────────────────
666
871
 
667
872
  function upsertCase(db: DatabaseSync, record: CaseRecord) {
@@ -749,9 +954,11 @@ export function addCaseResult(input: CaseInput): CaseAddResult {
749
954
  const duplicate = findDuplicateCaseInDb(db, record);
750
955
  if (duplicate) {
751
956
  return {
752
- record: duplicate,
957
+ record: duplicate.record,
753
958
  created: false,
754
- reason: `Duplicate case exists: ${duplicate.id}`,
959
+ reason: duplicate.near
960
+ ? `Near-duplicate of existing case ${duplicate.record.id} (same target, overlapping title) — continue with that case via CaseUpdate`
961
+ : `Duplicate case exists: ${duplicate.record.id}`,
755
962
  };
756
963
  }
757
964
 
@@ -860,7 +1067,9 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
860
1067
  return {
861
1068
  record: current,
862
1069
  changed: false,
863
- reason: `Update would create a duplicate of case ${duplicate.id}`,
1070
+ reason: duplicate.near
1071
+ ? `Update would near-duplicate case ${duplicate.record.id} (same target, overlapping title)`
1072
+ : `Update would create a duplicate of case ${duplicate.record.id}`,
864
1073
  };
865
1074
  }
866
1075
 
@@ -1283,16 +1492,123 @@ function mdSection(title: string, body?: string): string {
1283
1492
  return `## ${title}\n\n${body?.trim() || "Not recorded."}\n`;
1284
1493
  }
1285
1494
 
1286
- export function writeCaseReport(id: string): { path: string; record: CaseRecord } {
1495
+ // ── Context bundle completeness ──────────────────────────────────────
1496
+ // The case context is the reporter agent's ONLY window into the run. It must
1497
+ // carry the full audit trail: every case field (including the investigation
1498
+ // trail in evidence/assumptions and the failed disconfirmation attempts), the
1499
+ // linked cases in BOTH directions (chains AND killed dead-ends), and the
1500
+ // pipeline artifacts (recon entry points, traces, skeptic verdicts, PoC logs)
1501
+ // from any scratchpad run that produced this case.
1502
+
1503
+ const CONTEXT_PHASES: ScratchpadPhase[] = [
1504
+ "recon",
1505
+ "hunt",
1506
+ "gapfil",
1507
+ "trace",
1508
+ "skeptic",
1509
+ "validate",
1510
+ "chain",
1511
+ "patch",
1512
+ "report",
1513
+ ];
1514
+
1515
+ /** Per-artifact content cap for the context bundle (generous; artifacts are small). */
1516
+ const MAX_ARTIFACT_CHARS = 100_000;
1517
+
1518
+ function buildCompleteRecord(current: CaseRecord): string {
1519
+ const rows: string[] = [];
1520
+ for (const [k, v] of Object.entries(current)) {
1521
+ if (v === undefined || v === null || v === "") continue;
1522
+ let display = typeof v === "object" ? JSON.stringify(v, null, 2) : String(v);
1523
+ // Path-leak guard: the verification objects carry the researcher's local
1524
+ // PoC/disconfirmation script paths — show basenames only (the dedicated
1525
+ // log sections below already render them as basenames).
1526
+ if ((k === "pocVerified" || k === "disconfirmationVerified") && v && typeof v === "object") {
1527
+ const redacted = {
1528
+ ...(v as Record<string, unknown>),
1529
+ path: basename((v as { path?: string }).path ?? ""),
1530
+ };
1531
+ display = JSON.stringify(redacted, null, 2);
1532
+ }
1533
+ rows.push(`- **${k}:** ${display.replace(/\n/g, "\n ")}`);
1534
+ }
1535
+ return rows.join("\n");
1536
+ }
1537
+
1538
+ /** All links touching this case, both directions, with the neighbor's state. */
1539
+ function buildCaseLinks(db: DatabaseSync, id: string): string {
1540
+ const outgoing = db
1541
+ .prepare("SELECT target_id, kind FROM case_links WHERE source_id = ?")
1542
+ .all(id) as { target_id: string; kind: string }[];
1543
+ const incoming = db
1544
+ .prepare("SELECT source_id, kind FROM case_links WHERE target_id = ?")
1545
+ .all(id) as { source_id: string; kind: string }[];
1546
+ const lines: string[] = [];
1547
+ for (const l of outgoing) {
1548
+ const t = getCaseById(l.target_id);
1549
+ lines.push(`- → ${l.target_id} [${l.kind}] ${t?.title ?? "?"} (${t?.status ?? "?"})`);
1550
+ }
1551
+ for (const l of incoming) {
1552
+ const t = getCaseById(l.source_id);
1553
+ lines.push(`- ← ${l.source_id} [${l.kind}] ${t?.title ?? "?"} (${t?.status ?? "?"})`);
1554
+ }
1555
+ return lines.length ? lines.join("\n") : "None.";
1556
+ }
1557
+
1558
+ /**
1559
+ * Pipeline artifacts from every scratchpad run whose checkpoint lists this
1560
+ * case id — recon entry points, per-finding traces, skeptic verdicts, PoC
1561
+ * logs, chain analysis. Missing runs/artifacts are stated, not silently
1562
+ * dropped, so the reporter knows what was never recorded.
1563
+ */
1564
+ function buildScratchpadSection(caseId: string): string {
1565
+ const root = getScratchpadRoot();
1566
+ if (!existsSync(root)) return "No scratchpad found (no pipeline run artifacts recorded).";
1567
+ let entries: Dirent[] = [];
1568
+ try {
1569
+ entries = readdirSync(root, { withFileTypes: true });
1570
+ } catch {
1571
+ return "Scratchpad root unreadable.";
1572
+ }
1573
+
1574
+ const sections: string[] = [];
1575
+ for (const entry of entries) {
1576
+ if (!entry.isDirectory()) continue;
1577
+ const resume = scratchpad_resume(entry.name);
1578
+ if (!resume) continue;
1579
+ const allIds = Object.values(resume.checkpoint.phase_ids ?? {}).flat() as string[];
1580
+ if (!allIds.includes(caseId)) continue;
1581
+
1582
+ sections.push(`### Run: ${entry.name} (project root: ${resume.checkpoint.project_root})`);
1583
+ for (const phase of CONTEXT_PHASES) {
1584
+ const names = resume.artifacts[phase];
1585
+ if (!names?.length) continue;
1586
+ sections.push(`#### ${phase}/`);
1587
+ for (const name of names) {
1588
+ const content = scratchpad_read(entry.name, phase, name) ?? "(unreadable)";
1589
+ const clipped =
1590
+ content.length > MAX_ARTIFACT_CHARS
1591
+ ? `${content.slice(0, MAX_ARTIFACT_CHARS)}\n… [truncated ${content.length - MAX_ARTIFACT_CHARS} chars]`
1592
+ : content;
1593
+ sections.push(`\`${name}\`:\n\`\`\`\n${clipped}\n\`\`\``);
1594
+ }
1595
+ }
1596
+ }
1597
+
1598
+ return sections.length
1599
+ ? sections.join("\n")
1600
+ : "No scratchpad run found containing this case id (manual/CTF run without pipeline artifacts).";
1601
+ }
1602
+
1603
+ export function writeCaseContext(id: string): {
1604
+ path: string;
1605
+ contextPath: string;
1606
+ record: CaseRecord;
1607
+ } {
1287
1608
  const current = getCaseById(id);
1288
1609
  if (!current) throw new Error(`Case not found: ${id}`);
1289
1610
  if (current.status !== "confirmed" && current.status !== "reported") {
1290
- throw new Error("Case reports require a confirmed or reported case");
1291
- }
1292
-
1293
- // Reported cases are terminal artifacts — return the existing report path if present.
1294
- if (current.status === "reported" && current.reportPath && existsSync(current.reportPath)) {
1295
- return { path: current.reportPath, record: current };
1611
+ throw new Error("Case context requires a confirmed or reported case");
1296
1612
  }
1297
1613
 
1298
1614
  const db = getDb();
@@ -1307,7 +1623,16 @@ export function writeCaseReport(id: string): { path: string; record: CaseRecord
1307
1623
  .replace(/[^a-z0-9]+/g, "-")
1308
1624
  .replace(/^-+|-+$/g, "")
1309
1625
  .slice(0, 70) || "case";
1310
- const reportPath = join(reportDir, `${slug}-${current.id}.md`);
1626
+ // The final report path: the reporter agent writes the polished report here.
1627
+ // A previously recorded reportPath is kept stable across calls (the report
1628
+ // file may already exist at it); otherwise derive the default.
1629
+ const reportPath = current.reportPath ?? join(reportDir, `${slug}-${current.id}.md`);
1630
+ // The context bundle: raw material for the report writer (evidence, logs,
1631
+ // verification, timeline). Never cleaned up — it is the audit trail.
1632
+ // ALWAYS regenerated fresh — serving a stored/derived bundle would silently
1633
+ // return stale or fabricated content (e.g. legacy cases reported before the
1634
+ // context bundle existed).
1635
+ const contextPath = join(reportDir, `${slug}-${current.id}.context.md`);
1311
1636
  const references = current.references?.length
1312
1637
  ? current.references.map((r) => `- ${r}`).join("\n")
1313
1638
  : undefined;
@@ -1316,6 +1641,11 @@ export function writeCaseReport(id: string): { path: string; record: CaseRecord
1316
1641
  : undefined;
1317
1642
  const body = [
1318
1643
  `# ${current.title}`,
1644
+ "",
1645
+ "> CASE CONTEXT — raw material for the report writer (reporter agent). Do not ship this file.",
1646
+ `> Final report target: \`${basename(reportPath)}\` (write the polished report there).`,
1647
+ `> Case ID: ${current.id} — strip ALL case IDs and local paths from the final report.`,
1648
+ "",
1319
1649
  `**Severity:** ${current.severity ?? "Not assessed"}`,
1320
1650
  `**Status:** ${current.status}`,
1321
1651
  `**Confidence:** ${current.confidence}`,
@@ -1344,11 +1674,20 @@ export function writeCaseReport(id: string): { path: string; record: CaseRecord
1344
1674
  mdSection("Remediation", current.remediation),
1345
1675
  mdSection("Assumptions and Uncertainty", assumptions),
1346
1676
  mdSection("References", references),
1677
+ mdSection("Complete Case Record (all fields)", buildCompleteRecord(current)),
1678
+ mdSection(
1679
+ "Linked Cases (both directions, incl. killed dead-ends)",
1680
+ buildCaseLinks(db, current.id),
1681
+ ),
1682
+ mdSection(
1683
+ "Pipeline Artifacts (scratchpad: recon, traces, skeptic, logs)",
1684
+ buildScratchpadSection(current.id),
1685
+ ),
1347
1686
  ]
1348
1687
  .filter(Boolean)
1349
1688
  .join("\n");
1350
1689
 
1351
- writeFileSync(reportPath, body, "utf8");
1690
+ writeFileSync(contextPath, body, "utf8");
1352
1691
 
1353
1692
  const next: CaseRecord = {
1354
1693
  ...current,
@@ -1357,11 +1696,11 @@ export function writeCaseReport(id: string): { path: string; record: CaseRecord
1357
1696
  updatedAt: new Date().toISOString(),
1358
1697
  };
1359
1698
 
1360
- // Enforce the same field invariants as every other write path. writeCaseReport
1699
+ // Enforce the same field invariants as every other write path. writeCaseContext
1361
1700
  // never changes status (confirmed stays confirmed; the caller flips to reported
1362
1701
  // via CaseUpdate, which runs validateTransition), but it does set reportPath —
1363
1702
  // validateCase ensures the resulting record is internally consistent.
1364
1703
  validateCase(next);
1365
1704
  upsertCase(db, next);
1366
- return { path: reportPath, record: next };
1705
+ return { path: reportPath, contextPath, record: next };
1367
1706
  }