@xaccefy/pi-casefile 0.7.2 → 0.7.4
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/README.md +5 -4
- package/package.json +1 -1
- package/skills/casefile/SKILL.md +2 -2
- package/src/index.ts +98 -63
- package/src/ledger.ts +378 -33
- package/src/pipeline-submit.ts +12 -14
- package/src/scratchpad.ts +16 -3
- package/src/sqlite-compat/index.ts +0 -1
- package/src/workflow.ts +158 -123
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
|
|
128
|
+
/** ISO timestamp when CaseContext first wrote the context bundle. */
|
|
123
129
|
reportedAt?: string;
|
|
124
|
-
/** Path to the
|
|
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
|
|
490
|
-
//
|
|
491
|
-
|
|
492
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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:
|
|
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:
|
|
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,129 @@ function mdSection(title: string, body?: string): string {
|
|
|
1283
1492
|
return `## ${title}\n\n${body?.trim() || "Not recorded."}\n`;
|
|
1284
1493
|
}
|
|
1285
1494
|
|
|
1286
|
-
|
|
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
|
+
// Gate on the case id appearing in phase_ids OR in any artifact filename —
|
|
1581
|
+
// checkpoint ids are often empty for recon/hunt, while artifact names like
|
|
1582
|
+
// skeptic_case_<id>.json / trace_case_<id>.json are equally valid evidence.
|
|
1583
|
+
const namedInArtifact = Object.values(resume.artifacts)
|
|
1584
|
+
.flat()
|
|
1585
|
+
.some((n) => n.includes(caseId));
|
|
1586
|
+
if (!allIds.includes(caseId) && !namedInArtifact) continue;
|
|
1587
|
+
|
|
1588
|
+
sections.push(`### Run: ${entry.name} (project root: ${resume.checkpoint.project_root})`);
|
|
1589
|
+
for (const phase of CONTEXT_PHASES) {
|
|
1590
|
+
const names = resume.artifacts[phase];
|
|
1591
|
+
if (!names?.length) continue;
|
|
1592
|
+
sections.push(`#### ${phase}/`);
|
|
1593
|
+
for (const name of names) {
|
|
1594
|
+
const content = scratchpad_read(entry.name, phase, name) ?? "(unreadable)";
|
|
1595
|
+
const clipped =
|
|
1596
|
+
content.length > MAX_ARTIFACT_CHARS
|
|
1597
|
+
? `${content.slice(0, MAX_ARTIFACT_CHARS)}\n… [truncated ${content.length - MAX_ARTIFACT_CHARS} chars]`
|
|
1598
|
+
: content;
|
|
1599
|
+
sections.push(`\`${name}\`:\n\`\`\`\n${clipped}\n\`\`\``);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
return sections.length
|
|
1605
|
+
? sections.join("\n")
|
|
1606
|
+
: "No scratchpad run found containing this case id (manual/CTF run without pipeline artifacts).";
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
export function writeCaseContext(id: string): {
|
|
1610
|
+
path: string;
|
|
1611
|
+
contextPath: string;
|
|
1612
|
+
record: CaseRecord;
|
|
1613
|
+
} {
|
|
1287
1614
|
const current = getCaseById(id);
|
|
1288
1615
|
if (!current) throw new Error(`Case not found: ${id}`);
|
|
1289
1616
|
if (current.status !== "confirmed" && current.status !== "reported") {
|
|
1290
|
-
throw new Error("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 };
|
|
1617
|
+
throw new Error("Case context requires a confirmed or reported case");
|
|
1296
1618
|
}
|
|
1297
1619
|
|
|
1298
1620
|
const db = getDb();
|
|
@@ -1307,7 +1629,16 @@ export function writeCaseReport(id: string): { path: string; record: CaseRecord
|
|
|
1307
1629
|
.replace(/[^a-z0-9]+/g, "-")
|
|
1308
1630
|
.replace(/^-+|-+$/g, "")
|
|
1309
1631
|
.slice(0, 70) || "case";
|
|
1310
|
-
|
|
1632
|
+
// The final report path: the reporter agent writes the polished report here.
|
|
1633
|
+
// A previously recorded reportPath is kept stable across calls (the report
|
|
1634
|
+
// file may already exist at it); otherwise derive the default.
|
|
1635
|
+
const reportPath = current.reportPath ?? join(reportDir, `${slug}-${current.id}.md`);
|
|
1636
|
+
// The context bundle: raw material for the report writer (evidence, logs,
|
|
1637
|
+
// verification, timeline). Never cleaned up — it is the audit trail.
|
|
1638
|
+
// ALWAYS regenerated fresh — serving a stored/derived bundle would silently
|
|
1639
|
+
// return stale or fabricated content (e.g. legacy cases reported before the
|
|
1640
|
+
// context bundle existed).
|
|
1641
|
+
const contextPath = join(reportDir, `${slug}-${current.id}.context.md`);
|
|
1311
1642
|
const references = current.references?.length
|
|
1312
1643
|
? current.references.map((r) => `- ${r}`).join("\n")
|
|
1313
1644
|
: undefined;
|
|
@@ -1316,6 +1647,11 @@ export function writeCaseReport(id: string): { path: string; record: CaseRecord
|
|
|
1316
1647
|
: undefined;
|
|
1317
1648
|
const body = [
|
|
1318
1649
|
`# ${current.title}`,
|
|
1650
|
+
"",
|
|
1651
|
+
"> CASE CONTEXT — raw material for the report writer (reporter agent). Do not ship this file.",
|
|
1652
|
+
`> Final report target: \`${basename(reportPath)}\` (write the polished report there).`,
|
|
1653
|
+
`> Case ID: ${current.id} — strip ALL case IDs and local paths from the final report.`,
|
|
1654
|
+
"",
|
|
1319
1655
|
`**Severity:** ${current.severity ?? "Not assessed"}`,
|
|
1320
1656
|
`**Status:** ${current.status}`,
|
|
1321
1657
|
`**Confidence:** ${current.confidence}`,
|
|
@@ -1344,11 +1680,20 @@ export function writeCaseReport(id: string): { path: string; record: CaseRecord
|
|
|
1344
1680
|
mdSection("Remediation", current.remediation),
|
|
1345
1681
|
mdSection("Assumptions and Uncertainty", assumptions),
|
|
1346
1682
|
mdSection("References", references),
|
|
1683
|
+
mdSection("Complete Case Record (all fields)", buildCompleteRecord(current)),
|
|
1684
|
+
mdSection(
|
|
1685
|
+
"Linked Cases (both directions, incl. killed dead-ends)",
|
|
1686
|
+
buildCaseLinks(db, current.id),
|
|
1687
|
+
),
|
|
1688
|
+
mdSection(
|
|
1689
|
+
"Pipeline Artifacts (scratchpad: recon, traces, skeptic, logs)",
|
|
1690
|
+
buildScratchpadSection(current.id),
|
|
1691
|
+
),
|
|
1347
1692
|
]
|
|
1348
1693
|
.filter(Boolean)
|
|
1349
1694
|
.join("\n");
|
|
1350
1695
|
|
|
1351
|
-
writeFileSync(
|
|
1696
|
+
writeFileSync(contextPath, body, "utf8");
|
|
1352
1697
|
|
|
1353
1698
|
const next: CaseRecord = {
|
|
1354
1699
|
...current,
|
|
@@ -1357,11 +1702,11 @@ export function writeCaseReport(id: string): { path: string; record: CaseRecord
|
|
|
1357
1702
|
updatedAt: new Date().toISOString(),
|
|
1358
1703
|
};
|
|
1359
1704
|
|
|
1360
|
-
// Enforce the same field invariants as every other write path.
|
|
1705
|
+
// Enforce the same field invariants as every other write path. writeCaseContext
|
|
1361
1706
|
// never changes status (confirmed stays confirmed; the caller flips to reported
|
|
1362
1707
|
// via CaseUpdate, which runs validateTransition), but it does set reportPath —
|
|
1363
1708
|
// validateCase ensures the resulting record is internally consistent.
|
|
1364
1709
|
validateCase(next);
|
|
1365
1710
|
upsertCase(db, next);
|
|
1366
|
-
return { path: reportPath, record: next };
|
|
1711
|
+
return { path: reportPath, contextPath, record: next };
|
|
1367
1712
|
}
|
package/src/pipeline-submit.ts
CHANGED
|
@@ -86,17 +86,6 @@ const VULN_CLASSES = [
|
|
|
86
86
|
"other",
|
|
87
87
|
] as const;
|
|
88
88
|
|
|
89
|
-
const KILL_REASONS = [
|
|
90
|
-
"unreachable",
|
|
91
|
-
"framework_protection",
|
|
92
|
-
"input_validation_blocks",
|
|
93
|
-
"requires_privilege_attacker_lacks",
|
|
94
|
-
"poc_failed_3x",
|
|
95
|
-
"no_real_impact",
|
|
96
|
-
"intended_behavior",
|
|
97
|
-
"duplicate",
|
|
98
|
-
] as const;
|
|
99
|
-
|
|
100
89
|
const SPECS: Record<SubmitStage, StageSpec> = {
|
|
101
90
|
// schemas/stage-finding.json
|
|
102
91
|
hunt: {
|
|
@@ -262,11 +251,20 @@ function parseOutput(output: unknown): { obj?: Record<string, unknown>; error?:
|
|
|
262
251
|
}
|
|
263
252
|
|
|
264
253
|
/** Stable repair-bucket key for a submission. */
|
|
254
|
+
/** Placeholder-y ids that carry no identity (observed in the wild: every
|
|
255
|
+
* submission in a run keyed "false"). Trusting them makes distinct findings
|
|
256
|
+
* share one key — one artifact name, one repair-budget bucket — so the last
|
|
257
|
+
* write clobbers the rest. Fall back to the content hash instead. */
|
|
258
|
+
const JUNK_ID_RE =
|
|
259
|
+
/^(false|true|null|none|undefined|n\/?a|na|unknown|missing|empty|todo|tbd|pending|not-set)$/i;
|
|
260
|
+
|
|
265
261
|
function submissionKey(stage: SubmitStage, obj: Record<string, unknown>): string {
|
|
262
|
+
const candidate =
|
|
263
|
+
(typeof obj.finding_id === "string" && obj.finding_id.trim()) ||
|
|
264
|
+
(typeof obj.title === "string" && obj.title.trim()) ||
|
|
265
|
+
(typeof obj.id === "string" && obj.id.trim());
|
|
266
266
|
const id =
|
|
267
|
-
|
|
268
|
-
(typeof obj.title === "string" && obj.title) ||
|
|
269
|
-
(typeof obj.id === "string" && obj.id);
|
|
267
|
+
candidate && candidate.length >= 3 && !JUNK_ID_RE.test(candidate) ? candidate : undefined;
|
|
270
268
|
const tail = id ?? createHash("sha1").update(JSON.stringify(obj)).digest("hex").slice(0, 8);
|
|
271
269
|
return `${stage}:${tail}`;
|
|
272
270
|
}
|
package/src/scratchpad.ts
CHANGED
|
@@ -150,6 +150,19 @@ export function getStatePath(runId: string, projectRoot?: string): string {
|
|
|
150
150
|
return join(getRunDir(runId, projectRoot), "state.json");
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Sanitize an artifact name into a safe filename. Same allowlist as run_ids,
|
|
155
|
+
* plus dot-only rejection: a name like `..` or `.` would otherwise let join()
|
|
156
|
+
* point at the phase/run directory itself (EISDIR crash on write/read).
|
|
157
|
+
*/
|
|
158
|
+
function sanitizeArtifactName(artifactName: string): string {
|
|
159
|
+
const safe = artifactName.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
160
|
+
if (!safe || /^\.+$/.test(safe)) {
|
|
161
|
+
throw new Error(`Invalid artifact name: "${artifactName}" — nothing left after sanitization`);
|
|
162
|
+
}
|
|
163
|
+
return safe;
|
|
164
|
+
}
|
|
165
|
+
|
|
153
166
|
function emptyCheckpoint(runId: string, projectRoot: string): ScratchpadCheckpoint {
|
|
154
167
|
const now = new Date().toISOString();
|
|
155
168
|
return {
|
|
@@ -229,8 +242,8 @@ export function scratchpad_write(
|
|
|
229
242
|
const runDir = getRunDir(runId, root);
|
|
230
243
|
ensureRunDirs(runDir);
|
|
231
244
|
|
|
232
|
-
// Sanitize artifact name: no path traversal.
|
|
233
|
-
const safeName = artifactName
|
|
245
|
+
// Sanitize artifact name: no path traversal, no dot-only escape.
|
|
246
|
+
const safeName = sanitizeArtifactName(artifactName);
|
|
234
247
|
const dir = join(runDir, PHASE_DIRS[phase]);
|
|
235
248
|
const filePath = join(dir, safeName);
|
|
236
249
|
writeFileSync(filePath, content, "utf8");
|
|
@@ -247,7 +260,7 @@ export function scratchpad_read(
|
|
|
247
260
|
projectRoot?: string,
|
|
248
261
|
): string | null {
|
|
249
262
|
const root = projectRoot ?? detectWorkspaceRoot();
|
|
250
|
-
const safeName = artifactName
|
|
263
|
+
const safeName = sanitizeArtifactName(artifactName);
|
|
251
264
|
const filePath = join(getRunDir(runId, root), PHASE_DIRS[phase], safeName);
|
|
252
265
|
if (!existsSync(filePath)) return null;
|
|
253
266
|
return readFileSync(filePath, "utf8");
|
|
@@ -19,7 +19,6 @@ try {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
// biome-ignore lint/suspicious/noExplicitAny: Standard SQLite API returns any
|
|
23
22
|
export interface StatementSync {
|
|
24
23
|
run(...args: unknown[]): { lastInsertRowid: number; changes: number };
|
|
25
24
|
// biome-ignore lint/suspicious/noExplicitAny: Standard SQLite API returns any
|