@xaccefy/pi-casefile 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/casefile/SKILL.md +14 -2
- package/src/index.ts +156 -74
- package/src/ledger.ts +134 -69
- package/src/poc-runner.ts +13 -14
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";
|
|
@@ -324,11 +324,20 @@ class CasefileDashboard {
|
|
|
324
324
|
}
|
|
325
325
|
|
|
326
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.
|
|
327
329
|
|
|
328
330
|
const STATIC_CYBER_WORKFLOW = `
|
|
329
|
-
# Cyber Workflow
|
|
331
|
+
# Cyber Workflow (Attacker-Oriented)
|
|
330
332
|
|
|
331
|
-
|
|
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.
|
|
334
|
+
|
|
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.
|
|
332
341
|
|
|
333
342
|
## State Machine (CaseAdd → CaseUpdate → CaseReport)
|
|
334
343
|
|
|
@@ -343,96 +352,147 @@ HYPOTHESIS ──→ INVESTIGATING ──→ CONFIRMED ──→ REPORTED
|
|
|
343
352
|
|
|
344
353
|
| Advance To | Required Case Fields | Must Exist on Disk |
|
|
345
354
|
|-----------|---------------------|--------------------|
|
|
346
|
-
| INVESTIGATING | \`evidence\` (source→sink
|
|
347
|
-
| **CONFIRMED** | \`evidence\`, **\`poc\`**, \`impact
|
|
348
|
-
| 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) | — |
|
|
349
358
|
| REPORTED | Only after \`CaseReport(id)\` succeeds | Report file |
|
|
350
359
|
|
|
351
360
|
**Rule: If a required field is empty, you cannot advance.** \`CaseUpdate({status:"confirmed", poc:""})\` is invalid. The fields are the gates.
|
|
352
361
|
|
|
353
362
|
---
|
|
354
363
|
|
|
355
|
-
##
|
|
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
|
|
356
389
|
Evidence overrides intuition. Never present speculation as fact. Every security claim must be traceable to:
|
|
357
390
|
- Observed behavior (logs, responses, error traces)
|
|
358
391
|
- Reproduced behavior (exact steps, scripts)
|
|
359
392
|
- Source code / protocol analysis
|
|
360
393
|
- Documented platform behavior
|
|
361
|
-
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.
|
|
362
413
|
|
|
363
414
|
---
|
|
364
415
|
|
|
365
|
-
##
|
|
366
|
-
|
|
367
|
-
1.
|
|
368
|
-
2.
|
|
369
|
-
3.
|
|
370
|
-
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?
|
|
371
423
|
|
|
372
424
|
---
|
|
373
425
|
|
|
374
|
-
##
|
|
375
|
-
|
|
376
|
-
-
|
|
377
|
-
-
|
|
378
|
-
- Framework/middleware
|
|
379
|
-
-
|
|
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\`)
|
|
380
435
|
|
|
381
436
|
---
|
|
382
437
|
|
|
383
|
-
##
|
|
384
|
-
|
|
438
|
+
## 5. Root Cause → Boundary → Impact (Not Behavior → Hype)
|
|
439
|
+
Trace:
|
|
385
440
|
\`\`\`
|
|
386
|
-
|
|
441
|
+
Entry (attacker-controlled) ──→ Reachable code path ──→ Trust boundary crossed ──→ Victim impact
|
|
387
442
|
\`\`\`
|
|
388
|
-
- Minimum
|
|
389
|
-
-
|
|
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.
|
|
390
446
|
|
|
391
447
|
---
|
|
392
448
|
|
|
393
|
-
##
|
|
394
|
-
Before
|
|
395
|
-
- Is this
|
|
396
|
-
-
|
|
397
|
-
-
|
|
398
|
-
|
|
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.
|
|
399
455
|
|
|
400
456
|
---
|
|
401
457
|
|
|
402
|
-
##
|
|
403
|
-
Before
|
|
404
|
-
-
|
|
405
|
-
-
|
|
406
|
-
-
|
|
407
|
-
-
|
|
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
|
|
408
465
|
|
|
409
466
|
---
|
|
410
467
|
|
|
411
|
-
##
|
|
412
|
-
Keep killed
|
|
468
|
+
## 8. Permanent KILLED Cataloging
|
|
469
|
+
Keep killed reasons explicit in assumptions/blockers:
|
|
413
470
|
- \`intended_behavior\`
|
|
414
471
|
- \`duplicate\`
|
|
415
472
|
- \`framework_protection\`
|
|
416
473
|
- \`exploit_unreliable\`
|
|
417
474
|
- \`insufficient_impact\`
|
|
418
475
|
- \`environmental_issue\`
|
|
419
|
-
|
|
420
|
-
|
|
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
|
+
}
|
|
421
491
|
|
|
422
|
-
|
|
492
|
+
/** Active-case ledger summary only (no workflow). Empty when nothing is open. */
|
|
493
|
+
function buildCaseListContext(records: CaseRecord[]): string {
|
|
423
494
|
if (records.length === 0) return "";
|
|
424
495
|
|
|
425
|
-
const safe = (v?: string, max = 160) => {
|
|
426
|
-
const controlChars = /[\r\n\t\u0000-\u001F\u007F\u2028\u2029]+/g;
|
|
427
|
-
const s = v
|
|
428
|
-
?.replace(controlChars, " ")
|
|
429
|
-
.replace(/[<>]/g, (c) => (c === "<" ? "‹" : "›"))
|
|
430
|
-
.replace(/([\\`*_{}[\]()#+\-.!])/g, "\\$1")
|
|
431
|
-
.replace(/\s+/g, " ")
|
|
432
|
-
.trim();
|
|
433
|
-
return s ? (s.length > max ? `${s.slice(0, max - 1)}…` : s) : undefined;
|
|
434
|
-
};
|
|
435
|
-
|
|
436
496
|
const count = (s: string) => records.filter((r) => r.status === s).length;
|
|
437
497
|
const lines: string[] = [
|
|
438
498
|
"<casefile_context>",
|
|
@@ -454,9 +514,11 @@ function buildCaseContext(records: CaseRecord[]): string {
|
|
|
454
514
|
if (!subset.length) continue;
|
|
455
515
|
lines.push(` ${label}:`);
|
|
456
516
|
for (const c of subset) {
|
|
457
|
-
const n =
|
|
517
|
+
const n = sanitizeContextText(c.nextStep, 180);
|
|
458
518
|
const extra = status === "confirmed" ? ` [${c.severity ?? "?"}]` : "";
|
|
459
|
-
lines.push(
|
|
519
|
+
lines.push(
|
|
520
|
+
` - ${c.id}: ${sanitizeContextText(c.title, 140) ?? "(untitled)"}${extra}${n ? ` → ${n}` : ""}`,
|
|
521
|
+
);
|
|
460
522
|
}
|
|
461
523
|
}
|
|
462
524
|
|
|
@@ -464,16 +526,22 @@ function buildCaseContext(records: CaseRecord[]): string {
|
|
|
464
526
|
if (highPrio.length > 0) {
|
|
465
527
|
lines.push(" High priority:");
|
|
466
528
|
for (const c of highPrio) {
|
|
467
|
-
lines.push(
|
|
529
|
+
lines.push(
|
|
530
|
+
` - ${c.id}: ${sanitizeContextText(c.title, 140) ?? "(untitled)"} [${c.priority}]`,
|
|
531
|
+
);
|
|
468
532
|
}
|
|
469
533
|
}
|
|
470
534
|
|
|
471
535
|
lines.push("</casefile_context>");
|
|
472
|
-
lines.push(STATIC_CYBER_WORKFLOW);
|
|
473
|
-
|
|
474
536
|
return lines.join("\n");
|
|
475
537
|
}
|
|
476
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
|
+
|
|
477
545
|
// ── Main extension ────────────────────────────────────────────────────
|
|
478
546
|
|
|
479
547
|
export default function casefileExtension(pi: ExtensionAPI) {
|
|
@@ -650,6 +718,22 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
650
718
|
|
|
651
719
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
652
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
|
+
|
|
653
737
|
const result = promoteFindingResult(params.id as string, {
|
|
654
738
|
path: run.path,
|
|
655
739
|
exitCode: run.exitCode,
|
|
@@ -662,10 +746,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
662
746
|
content: [
|
|
663
747
|
{
|
|
664
748
|
type: "text",
|
|
665
|
-
text:
|
|
666
|
-
run.exitCode === 0
|
|
667
|
-
? `PoC verified (exit ${run.exitCode}). Case promoted to confirmed:\n${formatCaseDetail(record)}`
|
|
668
|
-
: `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)}`,
|
|
669
750
|
},
|
|
670
751
|
],
|
|
671
752
|
details: { record, run },
|
|
@@ -1024,22 +1105,23 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1024
1105
|
// ── Event: Inject context into system prompt ──
|
|
1025
1106
|
|
|
1026
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[] = [];
|
|
1027
1111
|
try {
|
|
1028
1112
|
const records = readCasefile();
|
|
1029
|
-
|
|
1030
|
-
if (active.length === 0) return;
|
|
1031
|
-
|
|
1032
|
-
const caseContext = buildCaseContext(active);
|
|
1033
|
-
return {
|
|
1034
|
-
message: {
|
|
1035
|
-
customType: "casefile_summary",
|
|
1036
|
-
content: caseContext,
|
|
1037
|
-
display: false,
|
|
1038
|
-
},
|
|
1039
|
-
};
|
|
1113
|
+
active = records.filter((r) => r.status !== "killed" && r.status !== "reported");
|
|
1040
1114
|
} catch {
|
|
1041
|
-
// No database yet
|
|
1115
|
+
// No database yet — still inject workflow.
|
|
1042
1116
|
}
|
|
1117
|
+
|
|
1118
|
+
return {
|
|
1119
|
+
message: {
|
|
1120
|
+
customType: "casefile_summary",
|
|
1121
|
+
content: buildAgentInjection(active),
|
|
1122
|
+
display: false,
|
|
1123
|
+
},
|
|
1124
|
+
};
|
|
1043
1125
|
});
|
|
1044
1126
|
|
|
1045
1127
|
// ── Event: Update status bar ──
|
package/src/ledger.ts
CHANGED
|
@@ -419,14 +419,21 @@ function validateTransition(
|
|
|
419
419
|
if (to === "blocked") return;
|
|
420
420
|
|
|
421
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
|
+
|
|
422
434
|
const transitions: Partial<Record<CaseStatus, Partial<Record<CaseStatus, Rule>>>> = {
|
|
423
435
|
hypothesis: {
|
|
424
|
-
investigating:
|
|
425
|
-
!u.evidence
|
|
426
|
-
? "INVESTIGATING requires evidence (source→sink trace)"
|
|
427
|
-
: !u.confidence
|
|
428
|
-
? "INVESTIGATING requires confidence level"
|
|
429
|
-
: null,
|
|
436
|
+
investigating: requireInvestigatingFields,
|
|
430
437
|
confirmed: () => "Cannot jump hypothesis → confirmed; promote to investigating first",
|
|
431
438
|
reported: () => "Cannot jump hypothesis → reported; confirm first",
|
|
432
439
|
},
|
|
@@ -443,12 +450,7 @@ function validateTransition(
|
|
|
443
450
|
investigating: () => null,
|
|
444
451
|
},
|
|
445
452
|
blocked: {
|
|
446
|
-
investigating:
|
|
447
|
-
!u.evidence
|
|
448
|
-
? "INVESTIGATING requires evidence (source→sink trace)"
|
|
449
|
-
: !u.confidence
|
|
450
|
-
? "INVESTIGATING requires confidence level"
|
|
451
|
-
: null,
|
|
453
|
+
investigating: requireInvestigatingFields,
|
|
452
454
|
hypothesis: () => null,
|
|
453
455
|
},
|
|
454
456
|
};
|
|
@@ -514,7 +516,11 @@ function buildRecord(input: NormalizedCaseInput, existing?: CaseRecord): CaseRec
|
|
|
514
516
|
};
|
|
515
517
|
}
|
|
516
518
|
|
|
517
|
-
function findDuplicateCaseInDb(
|
|
519
|
+
function findDuplicateCaseInDb(
|
|
520
|
+
db: DatabaseSync,
|
|
521
|
+
candidate: Pick<CaseRecord, "title" | "target" | "endpoint" | "bugClass">,
|
|
522
|
+
excludeId?: string,
|
|
523
|
+
): CaseRecord | undefined {
|
|
518
524
|
const title = normalizeMatchText(candidate.title);
|
|
519
525
|
if (!title) return undefined;
|
|
520
526
|
|
|
@@ -522,9 +528,12 @@ function findDuplicateCaseInDb(db: DatabaseSync, candidate: CaseRecord): CaseRec
|
|
|
522
528
|
const endpoint = normalizeMatchText(candidate.endpoint);
|
|
523
529
|
const bugClass = normalizeMatchText(candidate.bugClass);
|
|
524
530
|
|
|
525
|
-
//
|
|
526
|
-
const
|
|
527
|
-
|
|
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[]);
|
|
528
537
|
|
|
529
538
|
for (const row of rows) {
|
|
530
539
|
if (
|
|
@@ -533,9 +542,9 @@ function findDuplicateCaseInDb(db: DatabaseSync, candidate: CaseRecord): CaseRec
|
|
|
533
542
|
normalizeMatchText(row.endpoint as string) === endpoint &&
|
|
534
543
|
normalizeMatchText(row.bugClass as string) === bugClass
|
|
535
544
|
) {
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
545
|
+
const links = db
|
|
546
|
+
.prepare("SELECT target_id FROM case_links WHERE source_id = ?")
|
|
547
|
+
.all(row.id) as { target_id: string }[];
|
|
539
548
|
return mapRow(
|
|
540
549
|
row,
|
|
541
550
|
links.map((l) => l.target_id),
|
|
@@ -547,9 +556,11 @@ function findDuplicateCaseInDb(db: DatabaseSync, candidate: CaseRecord): CaseRec
|
|
|
547
556
|
|
|
548
557
|
// ── SQLite Mutation Actions ───────────────────────────────────────────
|
|
549
558
|
|
|
550
|
-
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.
|
|
551
562
|
const stmt = db.prepare(`
|
|
552
|
-
INSERT
|
|
563
|
+
INSERT INTO cases (
|
|
553
564
|
id, title, status, confidence, severity, priority, target, endpoint, bugClass,
|
|
554
565
|
summary, evidence, impact, nextStep, poc, remediation,
|
|
555
566
|
references_json, blockers_json, tags_json, assumptions_json, poc_verified_json,
|
|
@@ -560,6 +571,30 @@ function insertOrReplaceCase(db: DatabaseSync, record: CaseRecord) {
|
|
|
560
571
|
?, ?, ?, ?, ?,
|
|
561
572
|
?, ?, ?, ?
|
|
562
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
|
|
563
598
|
`);
|
|
564
599
|
|
|
565
600
|
stmt.run(
|
|
@@ -606,7 +641,7 @@ export function addCaseResult(input: CaseInput): CaseAddResult {
|
|
|
606
641
|
};
|
|
607
642
|
}
|
|
608
643
|
|
|
609
|
-
|
|
644
|
+
upsertCase(db, record);
|
|
610
645
|
return { record, created: true };
|
|
611
646
|
}
|
|
612
647
|
|
|
@@ -617,6 +652,16 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
|
|
|
617
652
|
throw new Error(`Case not found: ${id}`);
|
|
618
653
|
}
|
|
619
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
|
+
|
|
620
665
|
const optionalFields = [
|
|
621
666
|
"title",
|
|
622
667
|
"target",
|
|
@@ -636,7 +681,7 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
|
|
|
636
681
|
}
|
|
637
682
|
}
|
|
638
683
|
|
|
639
|
-
|
|
684
|
+
let next = buildRecord(
|
|
640
685
|
{
|
|
641
686
|
...optionalPatch,
|
|
642
687
|
status: update.status ?? current.status,
|
|
@@ -654,6 +699,12 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
|
|
|
654
699
|
if (update.status && update.status !== current.status) {
|
|
655
700
|
validateTransition(current.status, next.status, update, current);
|
|
656
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
|
+
|
|
657
708
|
validateCase(next);
|
|
658
709
|
|
|
659
710
|
// Check material equality (we ignore links since links are mutated via CaseLink)
|
|
@@ -667,32 +718,7 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
|
|
|
667
718
|
return { record: current, changed: false, reason };
|
|
668
719
|
}
|
|
669
720
|
|
|
670
|
-
|
|
671
|
-
const title = normalizeMatchText(next.title);
|
|
672
|
-
const target = normalizeMatchText(next.target);
|
|
673
|
-
const endpoint = normalizeMatchText(next.endpoint);
|
|
674
|
-
const bugClass = normalizeMatchText(next.bugClass);
|
|
675
|
-
|
|
676
|
-
const stmt = db.prepare("SELECT * FROM cases WHERE status != 'killed' AND id != ?");
|
|
677
|
-
const rows = stmt.all(id);
|
|
678
|
-
let duplicate: CaseRecord | undefined;
|
|
679
|
-
for (const row of rows) {
|
|
680
|
-
if (
|
|
681
|
-
normalizeMatchText(row.title as string) === title &&
|
|
682
|
-
normalizeMatchText(row.target as string) === target &&
|
|
683
|
-
normalizeMatchText(row.endpoint as string) === endpoint &&
|
|
684
|
-
normalizeMatchText(row.bugClass as string) === bugClass
|
|
685
|
-
) {
|
|
686
|
-
const linkStmt = db.prepare("SELECT target_id FROM case_links WHERE source_id = ?");
|
|
687
|
-
const links = linkStmt.all(row.id) as { target_id: string }[];
|
|
688
|
-
duplicate = mapRow(
|
|
689
|
-
row,
|
|
690
|
-
links.map((l) => l.target_id),
|
|
691
|
-
);
|
|
692
|
-
break;
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
|
|
721
|
+
const duplicate = findDuplicateCaseInDb(db, next, id);
|
|
696
722
|
if (duplicate) {
|
|
697
723
|
return {
|
|
698
724
|
record: current,
|
|
@@ -701,7 +727,7 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
|
|
|
701
727
|
};
|
|
702
728
|
}
|
|
703
729
|
|
|
704
|
-
|
|
730
|
+
upsertCase(db, next);
|
|
705
731
|
return { record: next, changed: true };
|
|
706
732
|
}
|
|
707
733
|
|
|
@@ -757,7 +783,7 @@ export function promoteFindingResult(id: string, verification: PocVerification):
|
|
|
757
783
|
);
|
|
758
784
|
validateCase(next);
|
|
759
785
|
|
|
760
|
-
|
|
786
|
+
upsertCase(db, next);
|
|
761
787
|
return { record: next, changed: true };
|
|
762
788
|
}
|
|
763
789
|
|
|
@@ -772,6 +798,12 @@ export function linkCasesResult(sourceId: string, targetId: string): CaseLinkRes
|
|
|
772
798
|
const target = getCaseById(targetId);
|
|
773
799
|
if (!source) throw new Error(`Case not found: ${sourceId}`);
|
|
774
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
|
+
}
|
|
775
807
|
|
|
776
808
|
const checkStmt = db.prepare("SELECT 1 FROM case_links WHERE source_id = ? AND target_id = ?");
|
|
777
809
|
const exists = checkStmt.get(sourceId, targetId);
|
|
@@ -781,14 +813,25 @@ export function linkCasesResult(sourceId: string, targetId: string): CaseLinkRes
|
|
|
781
813
|
}
|
|
782
814
|
|
|
783
815
|
// Atomic insert both directions into junction table
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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
|
+
}
|
|
792
835
|
|
|
793
836
|
const finalSource = getCaseById(sourceId)!;
|
|
794
837
|
const finalTarget = getCaseById(targetId)!;
|
|
@@ -801,6 +844,12 @@ export function unlinkCasesResult(sourceId: string, targetId: string): CaseLinkR
|
|
|
801
844
|
const target = getCaseById(targetId);
|
|
802
845
|
if (!source) throw new Error(`Case not found: ${sourceId}`);
|
|
803
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
|
+
}
|
|
804
853
|
|
|
805
854
|
const checkStmt = db.prepare("SELECT 1 FROM case_links WHERE source_id = ? AND target_id = ?");
|
|
806
855
|
const exists = checkStmt.get(sourceId, targetId);
|
|
@@ -809,15 +858,26 @@ export function unlinkCasesResult(sourceId: string, targetId: string): CaseLinkR
|
|
|
809
858
|
return { source, target, changed: false, reason: "Cases are not linked" };
|
|
810
859
|
}
|
|
811
860
|
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
+
}
|
|
821
881
|
|
|
822
882
|
const finalSource = getCaseById(sourceId)!;
|
|
823
883
|
const finalTarget = getCaseById(targetId)!;
|
|
@@ -1039,6 +1099,11 @@ export function writeCaseReport(id: string): { path: string; record: CaseRecord
|
|
|
1039
1099
|
throw new Error("Case reports require a confirmed or reported case");
|
|
1040
1100
|
}
|
|
1041
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
|
+
|
|
1042
1107
|
const db = getDb();
|
|
1043
1108
|
const dbPath = getCasefilePath();
|
|
1044
1109
|
|
|
@@ -1094,6 +1159,6 @@ export function writeCaseReport(id: string): { path: string; record: CaseRecord
|
|
|
1094
1159
|
updatedAt: new Date().toISOString(),
|
|
1095
1160
|
};
|
|
1096
1161
|
|
|
1097
|
-
|
|
1162
|
+
upsertCase(db, next);
|
|
1098
1163
|
return { path: reportPath, record: next };
|
|
1099
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(
|