@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 +1 -1
- package/skills/casefile/SKILL.md +14 -2
- package/src/index.ts +177 -75
- package/src/ledger.ts +296 -126
- package/src/poc-runner.ts +54 -22
- package/src/sqlite-compat/index.ts +9 -2
package/package.json
CHANGED
package/skills/casefile/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
333
|
-
| **CONFIRMED** | \`evidence\`, **\`poc\`**, \`impact
|
|
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
|
-
##
|
|
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:
|
|
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
|
-
##
|
|
352
|
-
|
|
353
|
-
1.
|
|
354
|
-
2.
|
|
355
|
-
3.
|
|
356
|
-
4.
|
|
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
|
-
##
|
|
361
|
-
|
|
362
|
-
-
|
|
363
|
-
-
|
|
364
|
-
- Framework/middleware
|
|
365
|
-
-
|
|
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
|
-
##
|
|
370
|
-
|
|
438
|
+
## 5. Root Cause → Boundary → Impact (Not Behavior → Hype)
|
|
439
|
+
Trace:
|
|
371
440
|
\`\`\`
|
|
372
|
-
|
|
441
|
+
Entry (attacker-controlled) ──→ Reachable code path ──→ Trust boundary crossed ──→ Victim impact
|
|
373
442
|
\`\`\`
|
|
374
|
-
- Minimum
|
|
375
|
-
-
|
|
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
|
-
##
|
|
380
|
-
Before
|
|
381
|
-
- Is this
|
|
382
|
-
-
|
|
383
|
-
-
|
|
384
|
-
|
|
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
|
-
##
|
|
389
|
-
Before
|
|
390
|
-
-
|
|
391
|
-
-
|
|
392
|
-
-
|
|
393
|
-
-
|
|
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
|
-
##
|
|
398
|
-
Keep killed
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
517
|
+
const n = sanitizeContextText(c.nextStep, 180);
|
|
444
518
|
const extra = status === "confirmed" ? ` [${c.severity ?? "?"}]` : "";
|
|
445
|
-
lines.push(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
357
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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:
|
|
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:
|
|
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(
|
|
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
|
-
//
|
|
510
|
-
const
|
|
511
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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
|
-
|
|
820
|
-
.
|
|
821
|
-
.
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
.
|
|
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
|
|
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
|
|
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
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
|
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
|
|
875
|
-
|
|
876
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
//
|
|
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
|
|
320
|
-
|
|
321
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
12
|
-
|
|
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
|