@xaccefy/pi-casefile 0.2.0 → 0.2.2

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.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Offensive security case tracker for Pi Agent — bug bounties, CTFs, security audits",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -13,11 +13,23 @@ Use Casefile to maintain durable security investigation state across agent turns
13
13
  1. Check existing cases before opening a new one with CaseList or CaseSearch.
14
14
  2. Open new leads with CaseAdd as `hypothesis` or `investigating`.
15
15
  3. Promote cases with CaseUpdate only after materially new evidence, proof, impact, blockers, remediation, or status changes.
16
- 4. Mark `confirmed` only when evidence and a PoC or repro are recorded.
16
+ 4. Mark `confirmed` only via PromoteFinding after a real PoC exit 0 (evidence, impact, severity, poc required).
17
17
  5. Use CaseLink and CaseUnlink for exploit chains. Do not edit linked case IDs directly.
18
- 6. Use CaseReport only for confirmed or already reported cases.
18
+ 6. Use CaseReport only for confirmed or already reported cases, then CaseUpdate status=`reported`.
19
19
  7. Use `killed` for disproven, duplicate, or dead-end leads, and include evidence, blockers, next step, or assumptions explaining why.
20
20
 
21
+ ## State machine
22
+
23
+ ```
24
+ hypothesis → investigating → confirmed → reported
25
+ ↓ ↓
26
+ blocked killed (terminal)
27
+ ```
28
+
29
+ - investigating requires evidence + confidence
30
+ - confirmed requires PromoteFinding (not CaseUpdate)
31
+ - killed/reported are terminal (no field edits or re-links)
32
+
21
33
  ## Tool Map
22
34
 
23
35
  - `CaseAdd`: create a new case.
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Tools: CaseAdd, CaseUpdate, PromoteFinding, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseReport
5
5
  * Command: /casefile — interactive dashboard
6
- * Event: before_agent_start — injects case summary context into the system prompt
6
+ * Event: before_agent_start — injects cyber workflow (+ active case list) once per user prompt
7
7
  */
8
8
 
9
9
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -122,8 +122,15 @@ const ListSchema = Type.Object(
122
122
  status: Type.Optional(CaseStatusSchema),
123
123
  confidence: Type.Optional(CaseConfidenceSchema),
124
124
  severity: Type.Optional(CaseSeveritySchema),
125
+ minSeverity: Type.Optional(CaseSeveritySchema),
125
126
  priority: Type.Optional(CasePrioritySchema),
126
127
  tag: Type.Optional(Type.String({ description: "Filter by tag" })),
128
+ since: Type.Optional(
129
+ Type.String({ description: "ISO timestamp; only cases created at/after this time" }),
130
+ ),
131
+ until: Type.Optional(
132
+ Type.String({ description: "ISO timestamp; only cases created at/before this time" }),
133
+ ),
127
134
  limit: Type.Optional(Type.Number({ description: "Max results (default 50)" })),
128
135
  offset: Type.Optional(Type.Number({ description: "Skip N results for pagination" })),
129
136
  },
@@ -146,8 +153,15 @@ const SearchSchema = Type.Object(
146
153
  status: Type.Optional(CaseStatusSchema),
147
154
  confidence: Type.Optional(CaseConfidenceSchema),
148
155
  severity: Type.Optional(CaseSeveritySchema),
156
+ minSeverity: Type.Optional(CaseSeveritySchema),
149
157
  priority: Type.Optional(CasePrioritySchema),
150
- tag: Type.Optional(Type.String()),
158
+ tag: Type.Optional(Type.String({ description: "Filter by tag" })),
159
+ since: Type.Optional(
160
+ Type.String({ description: "ISO timestamp; only cases created at/after this time" }),
161
+ ),
162
+ until: Type.Optional(
163
+ Type.String({ description: "ISO timestamp; only cases created at/before this time" }),
164
+ ),
151
165
  limit: Type.Optional(Type.Number()),
152
166
  offset: Type.Optional(Type.Number()),
153
167
  },
@@ -310,11 +324,20 @@ class CasefileDashboard {
310
324
  }
311
325
 
312
326
  // ── Context injection ─────────────────────────────────────────────────
327
+ // Injected once per user prompt via before_agent_start (not every tool turn).
328
+ // Skills are opt-in; this keeps bounty discipline always present even with an empty ledger.
313
329
 
314
330
  const STATIC_CYBER_WORKFLOW = `
315
- # Cyber Workflow
331
+ # Cyber Workflow (Attacker-Oriented)
332
+
333
+ Think like a real external attacker, not a code reviewer. Technical bugs are cheap; **reachable attacker impact** is what matters for bounty-valid findings.
316
334
 
317
- Every finding starts HYPOTHESIS. Nothing reaches CONFIRMED without a working PoC on disk. Optimize for correctness over novelty. Prefer rejecting a real bug temporarily rather than reporting a false positive. Every confirmed finding must survive skeptical review by another experienced security researcher.
335
+ Every lead starts HYPOTHESIS. Nothing reaches CONFIRMED without:
336
+ 1. a working PoC on disk,
337
+ 2. a proven attacker path (who can trigger it, from where),
338
+ 3. demonstrated C/I/A impact (not theoretical).
339
+
340
+ 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.
318
341
 
319
342
  ## State Machine (CaseAdd → CaseUpdate → CaseReport)
320
343
 
@@ -329,96 +352,147 @@ HYPOTHESIS ──→ INVESTIGATING ──→ CONFIRMED ──→ REPORTED
329
352
 
330
353
  | Advance To | Required Case Fields | Must Exist on Disk |
331
354
  |-----------|---------------------|--------------------|
332
- | INVESTIGATING | \`evidence\` (source→sink trace), \`confidence\` | Path trace in notes |
333
- | **CONFIRMED** | \`evidence\`, **\`poc\`**, \`impact\`, \`severity\`, **\`impact_proof\`** | **PoC script + run.log with exit code 0, impact evidence on disk** |
334
- | KILLED | \`assumptions\` (why it died) | — |
355
+ | INVESTIGATING | \`evidence\` (source→sink + **attacker reachability**), \`confidence\` | Path trace in notes |
356
+ | **CONFIRMED** | \`evidence\`, **\`poc\`**, \`impact\` (attacker-real), \`severity\`, **\`impact_proof\`** | **PoC script + run.log exit 0 + proof of impact** |
357
+ | KILLED | \`assumptions\` (why it died: no path / no impact / not applicable) | — |
335
358
  | REPORTED | Only after \`CaseReport(id)\` succeeds | Report file |
336
359
 
337
360
  **Rule: If a required field is empty, you cannot advance.** \`CaseUpdate({status:"confirmed", poc:""})\` is invalid. The fields are the gates.
338
361
 
339
362
  ---
340
363
 
341
- ## 1. Evidence-First Doctrine (Highest Priority)
364
+ ## 0. Attacker Model (Read First)
365
+
366
+ Before escalating any finding, answer in evidence:
367
+
368
+ 1. **Who is the attacker?** (unauth internet, low-priv user, tenant peer, SSRF pivot, etc.)
369
+ 2. **What can they already do without the bug?** (baseline privileges)
370
+ 3. **What extra power does the bug grant beyond that baseline?**
371
+ 4. **Is the path realistic in production?** (auth, CSRF, WAF, network, feature flags, admin-only)
372
+
373
+ 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\`.
374
+
375
+ **Non-applicable / weak-impact defaults (KILL or do not promote):**
376
+ - Self-XSS / self-DoS only (attacker harms only their own session/account)
377
+ - Requires admin/root/already-trusted role that already has the same power
378
+ - Local-only, offline, or impossible deployment assumptions
379
+ - Spec-compliant / documented intentional behavior
380
+ - Needs physical access, victim to paste payload into their own console, or other social-engineering-only steps with no trust-boundary break
381
+ - "Interesting" logic quirks with **no confidentiality, integrity, availability, or financial effect**
382
+ - PoC proves a code path exists but **not** that a real victim asset is affected
383
+
384
+ Technical validity ≠ bounty validity. A true bug with no attacker-usable impact is still a kill for confirmed/report.
385
+
386
+ ---
387
+
388
+ ## 1. Evidence-First Doctrine
342
389
  Evidence overrides intuition. Never present speculation as fact. Every security claim must be traceable to:
343
390
  - Observed behavior (logs, responses, error traces)
344
391
  - Reproduced behavior (exact steps, scripts)
345
392
  - Source code / protocol analysis
346
393
  - Documented platform behavior
347
- If evidence is insufficient: explicitly state uncertainty, propose the next experiment, and do not escalate the finding. Produce the strongest conclusion supported by available evidence; never assume success where verification is incomplete.
394
+ If evidence is insufficient: state uncertainty, propose the next experiment, do not escalate. Never assume success where verification is incomplete.
395
+
396
+ ---
397
+
398
+ ## 2. Impact Gate (Mandatory Before CONFIRMED)
399
+
400
+ Prove at least **one** real attacker-facing violation:
401
+
402
+ | Category | Required proof |
403
+ |----------|----------------|
404
+ | **Confidentiality** | Attacker reads data they must not see (other users/tenants/secrets) |
405
+ | **Integrity** | Attacker changes data/state they must not control |
406
+ | **Availability** | Attacker degrades service for **others** (not only self) |
407
+ | **Financial / authz** | Direct money, privilege, or account takeover path |
408
+
409
+ Impact text must answer: *who is hurt, what is lost, how the attacker reaches it.*
410
+ Vague impact like "could be dangerous" or "may lead to RCE" without a path is not impact_proof.
411
+
412
+ 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.
348
413
 
349
414
  ---
350
415
 
351
- ## 2. Adversarial Self-Review (Mandatory Before CONFIRMED)
352
- Before confirming any vulnerability, argue against yourself:
353
- 1. Explain why this might NOT be a vulnerability (e.g. intended behavior, sandbox limit, misconfiguration).
354
- 2. List alternative explanations for the observed behavior.
355
- 3. Explain why each alternative was rejected.
356
- 4. Describe what specific evidence disproves those alternatives.
416
+ ## 3. Adversarial Self-Review (Mandatory Before CONFIRMED)
417
+ Argue against yourself:
418
+ 1. Why this might NOT be a vulnerability (intended, sandbox, misconfig, already authorized).
419
+ 2. Alternative explanations for the observation.
420
+ 3. Why each alternative was rejected **with evidence**.
421
+ 4. What blocks a real attacker today (auth, CSRF, network, role checks) and whether each is bypassed.
422
+ 5. Would a program triage say "informative / N/A" because impact is self-only or privileged-only?
357
423
 
358
424
  ---
359
425
 
360
- ## 3. False Positive Audit Checklist
361
- Attempt to falsify the finding. Immediately KILL the case if any of the following apply:
362
- - The behavior matches intended or documented specs.
363
- - The issue is caused by browser quirks, testing mistakes, or cache artifacts.
364
- - Framework/middleware protections render it unexploitable in production.
365
- - Environmental limitations prevent crossing a security boundary.
426
+ ## 4. False Positive / Non-Applicable Kill Checklist
427
+ KILL immediately when any apply:
428
+ - Matches documented/spec behavior (\`intended_behavior\`)
429
+ - Browser quirk, test artifact, or cache noise
430
+ - Framework/middleware/WAF blocks the path and is not bypassed (\`framework_protection\`)
431
+ - Requires privileges the attacker already has or cannot obtain (\`environmental_issue\`)
432
+ - No C/I/A/financial effect for anyone but the attacker themselves (\`insufficient_impact\`)
433
+ - Exploit unreliable / not reproducible twice (\`exploit_unreliable\`)
434
+ - Duplicate of an existing case (\`duplicate\`)
366
435
 
367
436
  ---
368
437
 
369
- ## 4. Root Cause Before Impact
370
- Do not map "Behavior → Impact". You must trace:
438
+ ## 5. Root Cause Boundary → Impact (Not Behavior → Hype)
439
+ Trace:
371
440
  \`\`\`
372
- Observed Behavior ──→ Root Cause ──→ Security Boundary Broken ──→ Actual Impact
441
+ Entry (attacker-controlled) ──→ Reachable code path ──→ Trust boundary crossed ──→ Victim impact
373
442
  \`\`\`
374
- - Minimum confirmation: Must reproduce successfully at least twice or via two independent methods.
375
- - Document case details structured as: **Observed Facts**, **Assumptions**, **Unknowns**, **Experiments Remaining**.
443
+ - Minimum: reproduce successfully at least twice or via two independent methods.
444
+ - Record: **Observed Facts**, **Assumptions**, **Unknowns**, **Experiments Remaining**.
445
+ - 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.
376
446
 
377
447
  ---
378
448
 
379
- ## 5. Duplicate Check
380
- Before creating any new case, ask:
381
- - Is this actually new?
382
- - Could it be another manifestation of an existing case?
383
- - Do multiple endpoints share the same underlying root cause?
384
- Keep the database clean; consolidate related endpoints into single root-cause cases.
449
+ ## 6. Duplicate Check
450
+ Before CaseAdd:
451
+ - Is this new?
452
+ - Same root cause as an open case?
453
+ - Multiple endpoints, one bug?
454
+ Continue the existing case ID when scope matches.
385
455
 
386
456
  ---
387
457
 
388
- ## 6. Report-Readiness Gate
389
- Before marking Ready for Report:
390
- - Can another researcher reproduce this deterministically?
391
- - Are the steps completely reproducible?
392
- - Is the impact justified without inflating severity? (Would the vendor agree with this impact? Is a real trust boundary crossed?)
393
- - Are exact root causes and remedial code changes detailed?
458
+ ## 7. Report-Readiness Gate
459
+ Before REPORTED:
460
+ - Another researcher can reproduce deterministically
461
+ - Steps are complete and production-realistic
462
+ - Impact is justified without inflation (would the vendor agree?)
463
+ - Root cause and fix guidance are concrete
464
+ - Attacker model + victim impact are explicit in the write-up
394
465
 
395
466
  ---
396
467
 
397
- ## 7. Permanent KILLED Case Cataloging
398
- Keep killed cases documented with a clear classification in the ledger:
468
+ ## 8. Permanent KILLED Cataloging
469
+ Keep killed reasons explicit in assumptions/blockers:
399
470
  - \`intended_behavior\`
400
471
  - \`duplicate\`
401
472
  - \`framework_protection\`
402
473
  - \`exploit_unreliable\`
403
474
  - \`insufficient_impact\`
404
475
  - \`environmental_issue\`
405
- Documenting why ideas were rejected prevents revisiting the same dead ends.
406
- `;
476
+ - \`not_applicable\` (true bug / interesting behavior, no realistic attacker value)
477
+ Documenting kills prevents re-opening dead ends.
478
+ `.trim();
479
+
480
+ function sanitizeContextText(v?: string, max = 160): string | undefined {
481
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: strip C0 controls from untrusted case text
482
+ const controlChars = /[\r\n\t\u0000-\u001F\u007F\u2028\u2029]+/g;
483
+ const s = v
484
+ ?.replace(controlChars, " ")
485
+ .replace(/[<>]/g, (c) => (c === "<" ? "‹" : "›"))
486
+ .replace(/([\\`*_{}[\]()#+\-.!])/g, "\\$1")
487
+ .replace(/\s+/g, " ")
488
+ .trim();
489
+ return s ? (s.length > max ? `${s.slice(0, max - 1)}…` : s) : undefined;
490
+ }
407
491
 
408
- function buildCaseContext(records: CaseRecord[]): string {
492
+ /** Active-case ledger summary only (no workflow). Empty when nothing is open. */
493
+ function buildCaseListContext(records: CaseRecord[]): string {
409
494
  if (records.length === 0) return "";
410
495
 
411
- const safe = (v?: string, max = 160) => {
412
- const controlChars = /[\r\n\t\u0000-\u001F\u007F\u2028\u2029]+/g;
413
- const s = v
414
- ?.replace(controlChars, " ")
415
- .replace(/[<>]/g, (c) => (c === "<" ? "‹" : "›"))
416
- .replace(/([\\`*_{}[\]()#+\-.!])/g, "\\$1")
417
- .replace(/\s+/g, " ")
418
- .trim();
419
- return s ? (s.length > max ? `${s.slice(0, max - 1)}…` : s) : undefined;
420
- };
421
-
422
496
  const count = (s: string) => records.filter((r) => r.status === s).length;
423
497
  const lines: string[] = [
424
498
  "<casefile_context>",
@@ -440,9 +514,11 @@ function buildCaseContext(records: CaseRecord[]): string {
440
514
  if (!subset.length) continue;
441
515
  lines.push(` ${label}:`);
442
516
  for (const c of subset) {
443
- const n = safe(c.nextStep, 180);
517
+ const n = sanitizeContextText(c.nextStep, 180);
444
518
  const extra = status === "confirmed" ? ` [${c.severity ?? "?"}]` : "";
445
- lines.push(` - ${c.id}: ${safe(c.title, 140) ?? "(untitled)"}${extra}${n ? ` → ${n}` : ""}`);
519
+ lines.push(
520
+ ` - ${c.id}: ${sanitizeContextText(c.title, 140) ?? "(untitled)"}${extra}${n ? ` → ${n}` : ""}`,
521
+ );
446
522
  }
447
523
  }
448
524
 
@@ -450,16 +526,22 @@ function buildCaseContext(records: CaseRecord[]): string {
450
526
  if (highPrio.length > 0) {
451
527
  lines.push(" High priority:");
452
528
  for (const c of highPrio) {
453
- lines.push(` - ${c.id}: ${safe(c.title, 140) ?? "(untitled)"} [${c.priority}]`);
529
+ lines.push(
530
+ ` - ${c.id}: ${sanitizeContextText(c.title, 140) ?? "(untitled)"} [${c.priority}]`,
531
+ );
454
532
  }
455
533
  }
456
534
 
457
535
  lines.push("</casefile_context>");
458
- lines.push(STATIC_CYBER_WORKFLOW);
459
-
460
536
  return lines.join("\n");
461
537
  }
462
538
 
539
+ /** Always includes cyber workflow; attaches case list when active cases exist. */
540
+ function buildAgentInjection(active: CaseRecord[]): string {
541
+ const caseList = buildCaseListContext(active);
542
+ return caseList ? `${caseList}\n\n${STATIC_CYBER_WORKFLOW}` : STATIC_CYBER_WORKFLOW;
543
+ }
544
+
463
545
  // ── Main extension ────────────────────────────────────────────────────
464
546
 
465
547
  export default function casefileExtension(pi: ExtensionAPI) {
@@ -636,6 +718,22 @@ export default function casefileExtension(pi: ExtensionAPI) {
636
718
 
637
719
  async execute(_id, params, _signal, _onUpdate, _ctx) {
638
720
  const run = runPoc(params.poc_path as string, params.local !== true);
721
+
722
+ // Fail closed without throwing: non-zero PoC must leave the case investigating.
723
+ if (run.exitCode !== 0) {
724
+ const record = getCaseById(params.id as string);
725
+ return {
726
+ content: [
727
+ {
728
+ type: "text",
729
+ text: `PoC failed (exit ${run.exitCode}). Case remains investigating.\nOutput:\n${run.output}`,
730
+ },
731
+ ],
732
+ isError: true,
733
+ details: { record, run },
734
+ };
735
+ }
736
+
639
737
  const result = promoteFindingResult(params.id as string, {
640
738
  path: run.path,
641
739
  exitCode: run.exitCode,
@@ -648,10 +746,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
648
746
  content: [
649
747
  {
650
748
  type: "text",
651
- text:
652
- run.exitCode === 0
653
- ? `PoC verified (exit ${run.exitCode}). Case promoted to confirmed:\n${formatCaseDetail(record)}`
654
- : `PoC failed (exit ${run.exitCode}). Case remains investigating.\nOutput:\n${run.output}`,
749
+ text: `PoC verified (exit ${run.exitCode}). Case promoted to confirmed:\n${formatCaseDetail(record)}`,
655
750
  },
656
751
  ],
657
752
  details: { record, run },
@@ -725,8 +820,11 @@ export default function casefileExtension(pi: ExtensionAPI) {
725
820
  status: params.status as CaseStatus | undefined,
726
821
  confidence: params.confidence as CaseConfidence | undefined,
727
822
  severity: params.severity as CaseSeverity | undefined,
823
+ minSeverity: params.minSeverity as CaseSeverity | undefined,
728
824
  priority: params.priority as CasePriority | undefined,
729
825
  tag: params.tag,
826
+ since: params.since as string | undefined,
827
+ until: params.until as string | undefined,
730
828
  limit: params.limit,
731
829
  offset: params.offset,
732
830
  });
@@ -772,8 +870,11 @@ export default function casefileExtension(pi: ExtensionAPI) {
772
870
  status: params.status as CaseStatus | undefined,
773
871
  confidence: params.confidence as CaseConfidence | undefined,
774
872
  severity: params.severity as CaseSeverity | undefined,
873
+ minSeverity: params.minSeverity as CaseSeverity | undefined,
775
874
  priority: params.priority as CasePriority | undefined,
776
875
  tag: params.tag,
876
+ since: params.since as string | undefined,
877
+ until: params.until as string | undefined,
777
878
  limit: params.limit,
778
879
  offset: params.offset,
779
880
  });
@@ -1004,22 +1105,23 @@ export default function casefileExtension(pi: ExtensionAPI) {
1004
1105
  // ── Event: Inject context into system prompt ──
1005
1106
 
1006
1107
  pi.on("before_agent_start", async () => {
1108
+ // Once per user prompt (not every tool turn). Always inject workflow so empty
1109
+ // ledgers still get attacker discipline; attach case list only when useful.
1110
+ let active: CaseRecord[] = [];
1007
1111
  try {
1008
1112
  const records = readCasefile();
1009
- const active = records.filter((r) => r.status !== "killed" && r.status !== "reported");
1010
- if (active.length === 0) return;
1011
-
1012
- const caseContext = buildCaseContext(active);
1013
- return {
1014
- message: {
1015
- customType: "casefile_summary",
1016
- content: caseContext,
1017
- display: false,
1018
- },
1019
- };
1113
+ active = records.filter((r) => r.status !== "killed" && r.status !== "reported");
1020
1114
  } catch {
1021
- // No database yet
1115
+ // No database yet — still inject workflow.
1022
1116
  }
1117
+
1118
+ return {
1119
+ message: {
1120
+ customType: "casefile_summary",
1121
+ content: buildAgentInjection(active),
1122
+ display: false,
1123
+ },
1124
+ };
1023
1125
  });
1024
1126
 
1025
1127
  // ── Event: Update status bar ──
package/src/ledger.ts CHANGED
@@ -141,8 +141,14 @@ export type CaseSearchOptions = {
141
141
  status?: CaseStatus;
142
142
  confidence?: CaseConfidence;
143
143
  severity?: CaseSeverity;
144
+ /** Return only cases at or above this severity (info < low < medium < high < critical). */
145
+ minSeverity?: CaseSeverity;
144
146
  priority?: CasePriority;
145
147
  tag?: string;
148
+ /** ISO timestamp; only cases created at/after this time. */
149
+ since?: string;
150
+ /** ISO timestamp; only cases created at/before this time. */
151
+ until?: string;
146
152
  limit?: number;
147
153
  offset?: number;
148
154
  };
@@ -190,10 +196,15 @@ export function getCasefilePath(): string {
190
196
  }
191
197
 
192
198
  export function setCasefilePath(path: string | undefined): void {
193
- ledgerPathOverride = path;
194
199
  if (dbInstance) {
195
- dbInstance = undefined; // Force reconnection on next getDb
200
+ try {
201
+ dbInstance.close();
202
+ } catch {
203
+ // Best-effort close.
204
+ }
196
205
  }
206
+ ledgerPathOverride = path;
207
+ dbInstance = undefined; // Force reconnection on next getDb
197
208
  }
198
209
 
199
210
  // ── SQLite Schema Init ────────────────────────────────────────────────
@@ -210,6 +221,9 @@ function getDb(): DatabaseSync {
210
221
  }
211
222
 
212
223
  const db = new DatabaseSync(dbPath);
224
+ // Enable foreign-key enforcement so ON DELETE CASCADE actually fires
225
+ // (SQLite keeps FK off by default; bun:sqlite in particular defaults it off).
226
+ db.exec("PRAGMA foreign_keys = ON");
213
227
 
214
228
  // Create tables
215
229
  db.exec(`
@@ -353,8 +367,13 @@ export function getCaseById(id: string): CaseRecord | undefined {
353
367
 
354
368
  function validateCase(record: CaseRecord): void {
355
369
  if (!record.title.trim()) throw new Error("Case title cannot be empty");
356
- if (record.status === "confirmed" && (!record.evidence || !record.poc)) {
357
- throw new Error("Confirmed cases require both evidence and poc");
370
+ // Keep this gate in lockstep with promoteFindingResult: a case may only be
371
+ // CONFIRMED when it has evidence, a PoC, demonstrated impact, and a severity.
372
+ if (
373
+ record.status === "confirmed" &&
374
+ (!record.evidence || !record.poc || !record.impact || !record.severity)
375
+ ) {
376
+ throw new Error("Confirmed cases require evidence, poc, impact, and severity");
358
377
  }
359
378
  if (record.status === "blocked" && (record.blockers ?? []).length === 0) {
360
379
  throw new Error("Blocked cases require at least one blocker");
@@ -370,13 +389,10 @@ function validateCase(record: CaseRecord): void {
370
389
  "Killed cases require evidence, next step, blockers, or assumptions explaining why",
371
390
  );
372
391
  }
373
- if (
374
- record.status === "reported" &&
375
- !record.poc &&
376
- !record.remediation &&
377
- (record.references ?? []).length === 0
378
- ) {
379
- throw new Error("Reported cases require poc, remediation, or references");
392
+ // A case becomes REPORTED only via CaseReport, which records reportPath. Require it
393
+ // here so validation stays consistent with the confirmed→reported transition gate.
394
+ if (record.status === "reported" && !record.reportPath) {
395
+ throw new Error("Reported cases require a generated report (run CaseReport first)");
380
396
  }
381
397
  }
382
398
 
@@ -403,14 +419,21 @@ function validateTransition(
403
419
  if (to === "blocked") return;
404
420
 
405
421
  type Rule = (u: CaseUpdate, current?: CaseRecord) => string | null;
422
+
423
+ // Transition rules must consult both the update payload AND the current record.
424
+ // Agents often promote status alone after evidence was already written in a prior update.
425
+ const requireInvestigatingFields: Rule = (u, cur) => {
426
+ // Normalize so whitespace-only evidence cannot satisfy the gate.
427
+ const evidence = normalizeText(u.evidence ?? cur?.evidence);
428
+ const confidence = u.confidence ?? cur?.confidence;
429
+ if (!evidence) return "INVESTIGATING requires evidence (source→sink trace)";
430
+ if (!confidence) return "INVESTIGATING requires confidence level";
431
+ return null;
432
+ };
433
+
406
434
  const transitions: Partial<Record<CaseStatus, Partial<Record<CaseStatus, Rule>>>> = {
407
435
  hypothesis: {
408
- investigating: (u) =>
409
- !u.evidence
410
- ? "INVESTIGATING requires evidence (source→sink trace)"
411
- : !u.confidence
412
- ? "INVESTIGATING requires confidence level"
413
- : null,
436
+ investigating: requireInvestigatingFields,
414
437
  confirmed: () => "Cannot jump hypothesis → confirmed; promote to investigating first",
415
438
  reported: () => "Cannot jump hypothesis → reported; confirm first",
416
439
  },
@@ -427,12 +450,7 @@ function validateTransition(
427
450
  investigating: () => null,
428
451
  },
429
452
  blocked: {
430
- investigating: (u) =>
431
- !u.evidence
432
- ? "INVESTIGATING requires evidence (source→sink trace)"
433
- : !u.confidence
434
- ? "INVESTIGATING requires confidence level"
435
- : null,
453
+ investigating: requireInvestigatingFields,
436
454
  hypothesis: () => null,
437
455
  },
438
456
  };
@@ -498,7 +516,11 @@ function buildRecord(input: NormalizedCaseInput, existing?: CaseRecord): CaseRec
498
516
  };
499
517
  }
500
518
 
501
- function findDuplicateCaseInDb(db: DatabaseSync, candidate: CaseRecord): CaseRecord | undefined {
519
+ function findDuplicateCaseInDb(
520
+ db: DatabaseSync,
521
+ candidate: Pick<CaseRecord, "title" | "target" | "endpoint" | "bugClass">,
522
+ excludeId?: string,
523
+ ): CaseRecord | undefined {
502
524
  const title = normalizeMatchText(candidate.title);
503
525
  if (!title) return undefined;
504
526
 
@@ -506,9 +528,12 @@ function findDuplicateCaseInDb(db: DatabaseSync, candidate: CaseRecord): CaseRec
506
528
  const endpoint = normalizeMatchText(candidate.endpoint);
507
529
  const bugClass = normalizeMatchText(candidate.bugClass);
508
530
 
509
- // We query all cases where status is not killed, then match in JS for normalized forms
510
- const stmt = db.prepare("SELECT * FROM cases WHERE status != 'killed'");
511
- const rows = stmt.all();
531
+ // Query non-killed cases, then match normalized title/scope in JS.
532
+ const rows = excludeId
533
+ ? (db
534
+ .prepare("SELECT * FROM cases WHERE status != 'killed' AND id != ?")
535
+ .all(excludeId) as any[])
536
+ : (db.prepare("SELECT * FROM cases WHERE status != 'killed'").all() as any[]);
512
537
 
513
538
  for (const row of rows) {
514
539
  if (
@@ -517,9 +542,9 @@ function findDuplicateCaseInDb(db: DatabaseSync, candidate: CaseRecord): CaseRec
517
542
  normalizeMatchText(row.endpoint as string) === endpoint &&
518
543
  normalizeMatchText(row.bugClass as string) === bugClass
519
544
  ) {
520
- // Find links
521
- const linkStmt = db.prepare("SELECT target_id FROM case_links WHERE source_id = ?");
522
- const links = linkStmt.all(row.id) as { target_id: string }[];
545
+ const links = db
546
+ .prepare("SELECT target_id FROM case_links WHERE source_id = ?")
547
+ .all(row.id) as { target_id: string }[];
523
548
  return mapRow(
524
549
  row,
525
550
  links.map((l) => l.target_id),
@@ -531,9 +556,11 @@ function findDuplicateCaseInDb(db: DatabaseSync, candidate: CaseRecord): CaseRec
531
556
 
532
557
  // ── SQLite Mutation Actions ───────────────────────────────────────────
533
558
 
534
- function insertOrReplaceCase(db: DatabaseSync, record: CaseRecord) {
559
+ function upsertCase(db: DatabaseSync, record: CaseRecord) {
560
+ // Use ON CONFLICT DO UPDATE (not INSERT OR REPLACE) so FK CASCADE does not
561
+ // wipe case_links when updating an existing primary key.
535
562
  const stmt = db.prepare(`
536
- INSERT OR REPLACE INTO cases (
563
+ INSERT INTO cases (
537
564
  id, title, status, confidence, severity, priority, target, endpoint, bugClass,
538
565
  summary, evidence, impact, nextStep, poc, remediation,
539
566
  references_json, blockers_json, tags_json, assumptions_json, poc_verified_json,
@@ -544,6 +571,30 @@ function insertOrReplaceCase(db: DatabaseSync, record: CaseRecord) {
544
571
  ?, ?, ?, ?, ?,
545
572
  ?, ?, ?, ?
546
573
  )
574
+ ON CONFLICT(id) DO UPDATE SET
575
+ title = excluded.title,
576
+ status = excluded.status,
577
+ confidence = excluded.confidence,
578
+ severity = excluded.severity,
579
+ priority = excluded.priority,
580
+ target = excluded.target,
581
+ endpoint = excluded.endpoint,
582
+ bugClass = excluded.bugClass,
583
+ summary = excluded.summary,
584
+ evidence = excluded.evidence,
585
+ impact = excluded.impact,
586
+ nextStep = excluded.nextStep,
587
+ poc = excluded.poc,
588
+ remediation = excluded.remediation,
589
+ references_json = excluded.references_json,
590
+ blockers_json = excluded.blockers_json,
591
+ tags_json = excluded.tags_json,
592
+ assumptions_json = excluded.assumptions_json,
593
+ poc_verified_json = excluded.poc_verified_json,
594
+ reported_at = excluded.reported_at,
595
+ report_path = excluded.report_path,
596
+ created_at = excluded.created_at,
597
+ updated_at = excluded.updated_at
547
598
  `);
548
599
 
549
600
  stmt.run(
@@ -590,7 +641,7 @@ export function addCaseResult(input: CaseInput): CaseAddResult {
590
641
  };
591
642
  }
592
643
 
593
- insertOrReplaceCase(db, record);
644
+ upsertCase(db, record);
594
645
  return { record, created: true };
595
646
  }
596
647
 
@@ -601,6 +652,16 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
601
652
  throw new Error(`Case not found: ${id}`);
602
653
  }
603
654
 
655
+ // Terminal states: block all mutations (status and field edits). The transition
656
+ // gate only runs on status changes, so without this reported/killed cases could
657
+ // still be rewritten via field-only updates.
658
+ if (current.status === "killed") {
659
+ throw new Error("Cannot mutate a killed case; open a new case if the lead is revived");
660
+ }
661
+ if (current.status === "reported") {
662
+ throw new Error("Cannot mutate a reported case; file a follow-up case instead");
663
+ }
664
+
604
665
  const optionalFields = [
605
666
  "title",
606
667
  "target",
@@ -620,7 +681,7 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
620
681
  }
621
682
  }
622
683
 
623
- const next = buildRecord(
684
+ let next = buildRecord(
624
685
  {
625
686
  ...optionalPatch,
626
687
  status: update.status ?? current.status,
@@ -638,6 +699,12 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
638
699
  if (update.status && update.status !== current.status) {
639
700
  validateTransition(current.status, next.status, update, current);
640
701
  }
702
+
703
+ // Demoting off confirmed invalidates prior PoC verification — re-promote required.
704
+ if (current.status === "confirmed" && next.status === "investigating") {
705
+ next = { ...next, pocVerified: undefined };
706
+ }
707
+
641
708
  validateCase(next);
642
709
 
643
710
  // Check material equality (we ignore links since links are mutated via CaseLink)
@@ -651,32 +718,7 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
651
718
  return { record: current, changed: false, reason };
652
719
  }
653
720
 
654
- // Duplicate checks excluding current
655
- const title = normalizeMatchText(next.title);
656
- const target = normalizeMatchText(next.target);
657
- const endpoint = normalizeMatchText(next.endpoint);
658
- const bugClass = normalizeMatchText(next.bugClass);
659
-
660
- const stmt = db.prepare("SELECT * FROM cases WHERE status != 'killed' AND id != ?");
661
- const rows = stmt.all(id);
662
- let duplicate: CaseRecord | undefined;
663
- for (const row of rows) {
664
- if (
665
- normalizeMatchText(row.title as string) === title &&
666
- normalizeMatchText(row.target as string) === target &&
667
- normalizeMatchText(row.endpoint as string) === endpoint &&
668
- normalizeMatchText(row.bugClass as string) === bugClass
669
- ) {
670
- const linkStmt = db.prepare("SELECT target_id FROM case_links WHERE source_id = ?");
671
- const links = linkStmt.all(row.id) as { target_id: string }[];
672
- duplicate = mapRow(
673
- row,
674
- links.map((l) => l.target_id),
675
- );
676
- break;
677
- }
678
- }
679
-
721
+ const duplicate = findDuplicateCaseInDb(db, next, id);
680
722
  if (duplicate) {
681
723
  return {
682
724
  record: current,
@@ -685,7 +727,7 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
685
727
  };
686
728
  }
687
729
 
688
- insertOrReplaceCase(db, next);
730
+ upsertCase(db, next);
689
731
  return { record: next, changed: true };
690
732
  }
691
733
 
@@ -741,7 +783,7 @@ export function promoteFindingResult(id: string, verification: PocVerification):
741
783
  );
742
784
  validateCase(next);
743
785
 
744
- insertOrReplaceCase(db, next);
786
+ upsertCase(db, next);
745
787
  return { record: next, changed: true };
746
788
  }
747
789
 
@@ -756,6 +798,12 @@ export function linkCasesResult(sourceId: string, targetId: string): CaseLinkRes
756
798
  const target = getCaseById(targetId);
757
799
  if (!source) throw new Error(`Case not found: ${sourceId}`);
758
800
  if (!target) throw new Error(`Case not found: ${targetId}`);
801
+ if (source.status === "killed" || source.status === "reported") {
802
+ throw new Error(`Cannot link terminal case ${sourceId} (${source.status})`);
803
+ }
804
+ if (target.status === "killed" || target.status === "reported") {
805
+ throw new Error(`Cannot link terminal case ${targetId} (${target.status})`);
806
+ }
759
807
 
760
808
  const checkStmt = db.prepare("SELECT 1 FROM case_links WHERE source_id = ? AND target_id = ?");
761
809
  const exists = checkStmt.get(sourceId, targetId);
@@ -765,14 +813,25 @@ export function linkCasesResult(sourceId: string, targetId: string): CaseLinkRes
765
813
  }
766
814
 
767
815
  // Atomic insert both directions into junction table
768
- const linkStmt = db.prepare("INSERT INTO case_links (source_id, target_id) VALUES (?, ?)");
769
- linkStmt.run(sourceId, targetId);
770
- linkStmt.run(targetId, sourceId);
771
-
772
- const now = new Date().toISOString();
773
- const updateTimeStmt = db.prepare("UPDATE cases SET updated_at = ? WHERE id = ?");
774
- updateTimeStmt.run(now, sourceId);
775
- updateTimeStmt.run(now, targetId);
816
+ db.exec("BEGIN");
817
+ try {
818
+ const linkStmt = db.prepare("INSERT INTO case_links (source_id, target_id) VALUES (?, ?)");
819
+ linkStmt.run(sourceId, targetId);
820
+ linkStmt.run(targetId, sourceId);
821
+
822
+ const now = new Date().toISOString();
823
+ const updateTimeStmt = db.prepare("UPDATE cases SET updated_at = ? WHERE id = ?");
824
+ updateTimeStmt.run(now, sourceId);
825
+ updateTimeStmt.run(now, targetId);
826
+ db.exec("COMMIT");
827
+ } catch (err) {
828
+ try {
829
+ db.exec("ROLLBACK");
830
+ } catch {
831
+ // ignore
832
+ }
833
+ throw err;
834
+ }
776
835
 
777
836
  const finalSource = getCaseById(sourceId)!;
778
837
  const finalTarget = getCaseById(targetId)!;
@@ -785,6 +844,12 @@ export function unlinkCasesResult(sourceId: string, targetId: string): CaseLinkR
785
844
  const target = getCaseById(targetId);
786
845
  if (!source) throw new Error(`Case not found: ${sourceId}`);
787
846
  if (!target) throw new Error(`Case not found: ${targetId}`);
847
+ if (source.status === "killed" || source.status === "reported") {
848
+ throw new Error(`Cannot unlink terminal case ${sourceId} (${source.status})`);
849
+ }
850
+ if (target.status === "killed" || target.status === "reported") {
851
+ throw new Error(`Cannot unlink terminal case ${targetId} (${target.status})`);
852
+ }
788
853
 
789
854
  const checkStmt = db.prepare("SELECT 1 FROM case_links WHERE source_id = ? AND target_id = ?");
790
855
  const exists = checkStmt.get(sourceId, targetId);
@@ -793,15 +858,26 @@ export function unlinkCasesResult(sourceId: string, targetId: string): CaseLinkR
793
858
  return { source, target, changed: false, reason: "Cases are not linked" };
794
859
  }
795
860
 
796
- const unlinkStmt = db.prepare(
797
- "DELETE FROM case_links WHERE (source_id = ? AND target_id = ?) OR (source_id = ? AND target_id = ?)",
798
- );
799
- unlinkStmt.run(sourceId, targetId, targetId, sourceId);
800
-
801
- const now = new Date().toISOString();
802
- const updateTimeStmt = db.prepare("UPDATE cases SET updated_at = ? WHERE id = ?");
803
- updateTimeStmt.run(now, sourceId);
804
- updateTimeStmt.run(now, targetId);
861
+ db.exec("BEGIN");
862
+ try {
863
+ const unlinkStmt = db.prepare(
864
+ "DELETE FROM case_links WHERE (source_id = ? AND target_id = ?) OR (source_id = ? AND target_id = ?)",
865
+ );
866
+ unlinkStmt.run(sourceId, targetId, targetId, sourceId);
867
+
868
+ const now = new Date().toISOString();
869
+ const updateTimeStmt = db.prepare("UPDATE cases SET updated_at = ? WHERE id = ?");
870
+ updateTimeStmt.run(now, sourceId);
871
+ updateTimeStmt.run(now, targetId);
872
+ db.exec("COMMIT");
873
+ } catch (err) {
874
+ try {
875
+ db.exec("ROLLBACK");
876
+ } catch {
877
+ // ignore
878
+ }
879
+ throw err;
880
+ }
805
881
 
806
882
  const finalSource = getCaseById(sourceId)!;
807
883
  const finalTarget = getCaseById(targetId)!;
@@ -810,57 +886,138 @@ export function unlinkCasesResult(sourceId: string, targetId: string): CaseLinkR
810
886
 
811
887
  // ── Search & Queries ─────────────────────────────────────────────────
812
888
 
813
- function caseHaystack(record: CaseRecord, field?: CaseSearchField): string {
814
- if (field) {
815
- const val = record[field];
816
- if (Array.isArray(val)) return val.join(" ").toLowerCase();
817
- return (typeof val === "string" ? val : String(val ?? "")).toLowerCase();
889
+ // Searchable text columns (excludes ids/timestamps/JSON arrays for performance + signal).
890
+ const SEARCH_COLUMNS = [
891
+ "title",
892
+ "summary",
893
+ "evidence",
894
+ "impact",
895
+ "target",
896
+ "endpoint",
897
+ "bugClass",
898
+ "poc",
899
+ ] as const;
900
+
901
+ const FIELD_COLUMN: Record<CaseSearchField, string> = {
902
+ title: "title",
903
+ summary: "summary",
904
+ evidence: "evidence",
905
+ impact: "impact",
906
+ target: "target",
907
+ endpoint: "endpoint",
908
+ bugClass: "bugClass",
909
+ poc: "poc",
910
+ };
911
+
912
+ function severityRank(s: CaseSeverity): number {
913
+ return SEVERITY_VALUES.indexOf(s);
914
+ }
915
+
916
+ /**
917
+ * Build a parameterized WHERE clause + params for case queries. Pushes all
918
+ * structured filters (and free-text) into SQL so we never load the whole ledger
919
+ * into memory just to filter it in JS. Also returns a stable ORDER BY that keeps
920
+ * the original status precedence (hypothesis first) with updated_at as tiebreak.
921
+ */
922
+ function buildCaseWhere(options: CaseSearchOptions): {
923
+ whereSql: string;
924
+ orderSql: string;
925
+ params: unknown[];
926
+ } {
927
+ const where: string[] = [];
928
+ const params: unknown[] = [];
929
+
930
+ if (options.status) {
931
+ where.push("status = ?");
932
+ params.push(options.status);
818
933
  }
819
- return Object.entries(record)
820
- .filter(([k]) => !["id", "createdAt", "updatedAt", "reportedAt", "reportPath"].includes(k))
821
- .map(([, v]) => (Array.isArray(v) ? v.join(" ") : String(v ?? "")))
822
- .filter(Boolean)
823
- .join("\n")
824
- .toLowerCase();
934
+ if (options.confidence) {
935
+ where.push("confidence = ?");
936
+ params.push(options.confidence);
937
+ }
938
+ if (options.severity) {
939
+ where.push("severity = ?");
940
+ params.push(options.severity);
941
+ }
942
+ if (options.minSeverity) {
943
+ where.push(
944
+ "severity IS NOT NULL AND (CASE severity WHEN 'info' THEN 0 WHEN 'low' THEN 1 WHEN 'medium' THEN 2 WHEN 'high' THEN 3 WHEN 'critical' THEN 4 ELSE -1 END) >= ?",
945
+ );
946
+ params.push(severityRank(options.minSeverity));
947
+ }
948
+ if (options.priority) {
949
+ where.push("priority = ?");
950
+ params.push(options.priority);
951
+ }
952
+ if (options.tag) {
953
+ where.push("EXISTS (SELECT 1 FROM json_each(tags_json) WHERE lower(value) = ?)");
954
+ params.push(options.tag.trim().toLowerCase());
955
+ }
956
+ if (options.since) {
957
+ where.push("created_at >= ?");
958
+ params.push(options.since);
959
+ }
960
+ if (options.until) {
961
+ where.push("created_at <= ?");
962
+ params.push(options.until);
963
+ }
964
+
965
+ const query = options.query?.trim().toLowerCase();
966
+ if (query) {
967
+ const likeParam = `%${query}%`;
968
+ if (options.field) {
969
+ where.push(`lower(${FIELD_COLUMN[options.field]}) LIKE ?`);
970
+ params.push(likeParam);
971
+ } else {
972
+ const ors = SEARCH_COLUMNS.map((c) => `lower(${c}) LIKE ?`).join(" OR ");
973
+ where.push(`(${ors})`);
974
+ for (let i = 0; i < SEARCH_COLUMNS.length; i++) params.push(likeParam);
975
+ }
976
+ }
977
+
978
+ const orderSql =
979
+ "CASE status WHEN 'hypothesis' THEN 0 WHEN 'investigating' THEN 1 WHEN 'confirmed' THEN 2 " +
980
+ "WHEN 'blocked' THEN 3 WHEN 'killed' THEN 4 WHEN 'reported' THEN 5 ELSE 6 END, updated_at DESC";
981
+
982
+ return {
983
+ whereSql: where.length ? `WHERE ${where.join(" AND ")}` : "",
984
+ orderSql,
985
+ params,
986
+ };
987
+ }
988
+
989
+ /** Map DB rows to CaseRecords, attaching linkedCaseIds fetched in a single batch. */
990
+ function mapRowsWithLinks(db: DatabaseSync, rows: any[]): CaseRecord[] {
991
+ if (rows.length === 0) return [];
992
+ const ids = rows.map((r) => r.id);
993
+ const placeholders = ids.map(() => "?").join(",");
994
+ const links = db
995
+ .prepare(`SELECT source_id, target_id FROM case_links WHERE source_id IN (${placeholders})`)
996
+ .all(...ids) as { source_id: string; target_id: string }[];
997
+ const linkMap = new Map<string, string[]>();
998
+ for (const l of links) {
999
+ if (!linkMap.has(l.source_id)) linkMap.set(l.source_id, []);
1000
+ linkMap.get(l.source_id)!.push(l.target_id);
1001
+ }
1002
+ return rows.map((row) => mapRow(row, linkMap.get(row.id) ?? []));
825
1003
  }
826
1004
 
827
1005
  export function searchCases(options: CaseSearchOptions = {}): {
828
1006
  cases: CaseRecord[];
829
1007
  total: number;
830
1008
  } {
831
- const query = options.query?.trim().toLowerCase();
832
- const field = options.field;
833
- const tag = options.tag?.trim().toLowerCase();
1009
+ const db = getDb();
834
1010
  const limit = Math.max(1, Math.min(options.limit ?? 50, 200));
835
1011
  const offset = Math.max(0, options.offset ?? 0);
836
1012
 
837
- const STATUS_ORDER: CaseStatus[] = [
838
- "hypothesis",
839
- "investigating",
840
- "confirmed",
841
- "blocked",
842
- "killed",
843
- "reported",
844
- ];
845
-
846
- const filtered = readCasefile()
847
- .filter((r) => !options.status || r.status === options.status)
848
- .filter((r) => !options.confidence || r.confidence === options.confidence)
849
- .filter((r) => !options.severity || r.severity === options.severity)
850
- .filter((r) => !options.priority || r.priority === options.priority)
851
- .filter((r) => !tag || r.tags?.some((t) => t.toLowerCase() === tag))
852
- .filter((r) => !query || caseHaystack(r, field).includes(query))
853
- .sort((a, b) => {
854
- const aStatus = STATUS_ORDER.indexOf(a.status);
855
- const bStatus = STATUS_ORDER.indexOf(b.status);
856
- if (aStatus !== bStatus) return aStatus - bStatus;
857
- return b.updatedAt.localeCompare(a.updatedAt);
858
- });
1013
+ const { whereSql, orderSql, params } = buildCaseWhere(options);
859
1014
 
860
- return {
861
- total: filtered.length,
862
- cases: filtered.slice(offset, offset + limit),
863
- };
1015
+ const total = (db.prepare(`SELECT COUNT(*) as c FROM cases ${whereSql}`).get(...params) as any).c;
1016
+ const rows = db
1017
+ .prepare(`SELECT * FROM cases ${whereSql} ORDER BY ${orderSql} LIMIT ? OFFSET ?`)
1018
+ .all(...params, limit, offset) as any[];
1019
+
1020
+ return { total, cases: mapRowsWithLinks(db, rows) };
864
1021
  }
865
1022
 
866
1023
  export function countCases(): {
@@ -868,14 +1025,22 @@ export function countCases(): {
868
1025
  byStatus: Record<string, number>;
869
1026
  bySeverity: Record<string, number>;
870
1027
  } {
871
- const records = readCasefile();
1028
+ const db = getDb();
1029
+ const total = (db.prepare("SELECT COUNT(*) as c FROM cases").get() as any).c;
1030
+ const statusRows = db
1031
+ .prepare("SELECT status, COUNT(*) as n FROM cases GROUP BY status")
1032
+ .all() as { status: string; n: number }[];
1033
+ const severityRows = db
1034
+ .prepare(
1035
+ "SELECT severity, COUNT(*) as n FROM cases WHERE severity IS NOT NULL GROUP BY severity",
1036
+ )
1037
+ .all() as { severity: string; n: number }[];
1038
+
872
1039
  const byStatus: Record<string, number> = {};
873
1040
  const bySeverity: Record<string, number> = {};
874
- for (const r of records) {
875
- byStatus[r.status] = (byStatus[r.status] ?? 0) + 1;
876
- if (r.severity) bySeverity[r.severity] = (bySeverity[r.severity] ?? 0) + 1;
877
- }
878
- return { total: records.length, byStatus, bySeverity };
1041
+ for (const r of statusRows) byStatus[r.status] = r.n;
1042
+ for (const r of severityRows) bySeverity[r.severity] = r.n;
1043
+ return { total, byStatus, bySeverity };
879
1044
  }
880
1045
 
881
1046
  // ── Format helpers ───────────────────────────────────────────────────
@@ -934,6 +1099,11 @@ export function writeCaseReport(id: string): { path: string; record: CaseRecord
934
1099
  throw new Error("Case reports require a confirmed or reported case");
935
1100
  }
936
1101
 
1102
+ // Reported cases are terminal artifacts — return the existing report path if present.
1103
+ if (current.status === "reported" && current.reportPath && existsSync(current.reportPath)) {
1104
+ return { path: current.reportPath, record: current };
1105
+ }
1106
+
937
1107
  const db = getDb();
938
1108
  const dbPath = getCasefilePath();
939
1109
 
@@ -989,6 +1159,6 @@ export function writeCaseReport(id: string): { path: string; record: CaseRecord
989
1159
  updatedAt: new Date().toISOString(),
990
1160
  };
991
1161
 
992
- insertOrReplaceCase(db, next);
1162
+ upsertCase(db, next);
993
1163
  return { path: reportPath, record: next };
994
1164
  }
package/src/poc-runner.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
- import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
4
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
5
5
 
6
6
  export type PocRun = {
7
7
  path: string;
@@ -145,18 +145,18 @@ function resolveLanguage(pocPath: string): { key: string; language: PocLanguage
145
145
  if (shebangKey) return { key: shebangKey, language: languages[shebangKey] };
146
146
  }
147
147
 
148
- // 2. Project type detection.
148
+ // 2. Extension-based language (prefer the PoC file itself over ambient project markers).
149
+ // A .py PoC in a Node monorepo must still run under python, not node.
150
+ if (extKey && languages[extKey]) {
151
+ return { key: extKey, language: languages[extKey] };
152
+ }
153
+
154
+ // 3. Project type detection only when the extension is unknown/unmapped.
149
155
  const projectType = detectProjectType(languages);
150
156
  if (projectType && languages[projectType]) {
151
- // If the file extension matches the project type or type has no extension restrictions, use it.
152
157
  return { key: projectType, language: languages[projectType] };
153
158
  }
154
159
 
155
- // 3. Extension-based fallback.
156
- if (extKey && languages[extKey]) {
157
- return { key: extKey, language: languages[extKey] };
158
- }
159
-
160
160
  // 4. Unknown extension: allow env override specifying a single language key.
161
161
  const envDefault = process.env.PI_POC_DEFAULT_LANGUAGE?.trim();
162
162
  if (envDefault && languages[envDefault]) {
@@ -186,13 +186,12 @@ function validatePocPath(pocPath: string): string {
186
186
  throw new Error("PoC path contains null bytes");
187
187
  }
188
188
 
189
- const parts = normalized.split(/[\\/]/);
190
- if (parts.includes("..")) {
191
- throw new Error(`PoC path contains traversal segments: ${pocPath}`);
192
- }
193
-
194
189
  const root = getProjectRoot();
195
- if (!normalized.startsWith(`${root}/`) && normalized !== root) {
190
+ // Use path.relative so prefix-sibling escapes like /tmp/proj vs /tmp/proj-evil are rejected.
191
+ // startsWith(`${root}/`) would accept /tmp/proj-evil when root is /tmp/proj.
192
+ const rel = relative(root, normalized);
193
+ const outsideWorkspace = rel === "" ? false : rel.startsWith("..") || isAbsolute(rel);
194
+ if (outsideWorkspace) {
196
195
  const allowAbsolute = process.env.PI_POC_ALLOW_ABSOLUTE === "1";
197
196
  if (!allowAbsolute) {
198
197
  throw new Error(
@@ -239,6 +238,12 @@ function buildDockerArgs(image: string, command: string, workspaceDir: string):
239
238
  "no-new-privileges",
240
239
  "--user",
241
240
  "1000:1000",
241
+ "--memory",
242
+ "256m",
243
+ "--pids-limit",
244
+ "128",
245
+ "--cpus",
246
+ "1.0",
242
247
  "-v",
243
248
  `${workspaceDir}:/workspace:rw`,
244
249
  image,
@@ -260,6 +265,27 @@ function renderCommand(template: string, pocPath: string, inSandbox: boolean): s
260
265
  .replace(/{{class}}/g, className);
261
266
  }
262
267
 
268
+ /**
269
+ * Translate a spawnSync result into a robust exit code.
270
+ *
271
+ * `spawnSync` returns `status: null` AND `signal: null` when it cannot even start
272
+ * the child (e.g. the binary is missing → ENOENT, or the docker daemon is
273
+ * unavailable). The previous `result.status ?? (result.signal ? 1 : 0)` then
274
+ * collapsed to `0`, making a never-executed PoC look successful — which let
275
+ * PromoteFinding promote an investigating case to CONFIRMED without the PoC
276
+ * ever running. We fail closed: a spawn error or a missing status/signal is
277
+ * always a non-zero exit.
278
+ */
279
+ function spawnExitCode(result: {
280
+ status: number | null;
281
+ signal: string | null;
282
+ error?: Error;
283
+ }): number {
284
+ if (result.error) return 127;
285
+ if (result.status !== null) return result.status;
286
+ return 1;
287
+ }
288
+
263
289
  function runSandboxed(pocPath: string, language: PocLanguage): PocRun {
264
290
  const ranAt = new Date().toISOString();
265
291
  const sourceName = basename(pocPath);
@@ -283,10 +309,11 @@ function runSandboxed(pocPath: string, language: PocLanguage): PocRun {
283
309
  maxBuffer: MAX_BUFFER,
284
310
  });
285
311
 
286
- const output = sanitizeOutput((result.stdout ?? "") + (result.stderr ?? ""));
312
+ const spawnErr = result.error ? `\n[spawn error] ${result.error.message}` : "";
313
+ const output = sanitizeOutput((result.stdout ?? "") + (result.stderr ?? "") + spawnErr);
287
314
  return {
288
315
  path: pocPath,
289
- exitCode: result.status ?? (result.signal ? 1 : 0),
316
+ exitCode: spawnExitCode(result),
290
317
  output,
291
318
  ranAt,
292
319
  sandbox: true,
@@ -314,11 +341,15 @@ function runLocal(pocPath: string, language: PocLanguage): PocRun {
314
341
  throw new Error("Language config has no run command");
315
342
  }
316
343
 
317
- // For local execution, split the run command into binary and args.
344
+ // The run template is `<interpreter> <file>`. Preserve multi-arg run commands for
345
+ // normal paths, but keep a space-containing PoC path as a single argument (no shell
346
+ // is used, so args are passed verbatim).
318
347
  const command = renderCommand(language.run, pocPath, false);
319
- const [interpreter, ...args] = command.split(" ");
320
- // Note: the file path is already substituted in the args if {{file}} was used.
321
- // We just need to spawn.
348
+ const trimmed = command.trim();
349
+ const firstSpace = trimmed.indexOf(" ");
350
+ const interpreter = firstSpace === -1 ? trimmed : trimmed.slice(0, firstSpace);
351
+ const rest = firstSpace === -1 ? "" : trimmed.slice(firstSpace + 1);
352
+ const args = pocPath.includes(" ") ? (rest ? [rest] : []) : rest ? rest.split(" ") : [];
322
353
 
323
354
  const result = spawnSync(interpreter, args, {
324
355
  encoding: "utf8",
@@ -326,10 +357,11 @@ function runLocal(pocPath: string, language: PocLanguage): PocRun {
326
357
  maxBuffer: MAX_BUFFER,
327
358
  });
328
359
 
329
- const output = sanitizeOutput((result.stdout ?? "") + (result.stderr ?? ""));
360
+ const spawnErr = result.error ? `\n[spawn error] ${result.error.message}` : "";
361
+ const output = sanitizeOutput((result.stdout ?? "") + (result.stderr ?? "") + spawnErr);
330
362
  return {
331
363
  path: pocPath,
332
- exitCode: result.status ?? (result.signal ? 1 : 0),
364
+ exitCode: spawnExitCode(result),
333
365
  output,
334
366
  ranAt,
335
367
  sandbox: false,
@@ -8,8 +8,15 @@ try {
8
8
  // biome-ignore lint/suspicious/noExplicitAny: Runtime module swappability
9
9
  DatabaseSyncConstructor = _require("bun:sqlite").Database as any;
10
10
  } catch {
11
- // biome-ignore lint/suspicious/noExplicitAny: Runtime module swappability
12
- DatabaseSyncConstructor = (_require("node:sqlite") as any).DatabaseSync;
11
+ try {
12
+ // biome-ignore lint/suspicious/noExplicitAny: Runtime module swappability
13
+ DatabaseSyncConstructor = (_require("node:sqlite") as any).DatabaseSync;
14
+ } catch (e) {
15
+ throw new Error(
16
+ "XPI requires bun:sqlite (Bun runtime) or node:sqlite (Node >= 22.5). " +
17
+ `Neither SQLite backend is available: ${(e as Error).message}`,
18
+ );
19
+ }
13
20
  }
14
21
 
15
22
  // biome-ignore lint/suspicious/noExplicitAny: Standard SQLite API returns any