@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xaccefy/pi-casefile",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Offensive security case tracker for Pi Agent — bug bounties, CTFs, security audits",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -13,11 +13,23 @@ Use Casefile to maintain durable security investigation state across agent turns
13
13
  1. Check existing cases before opening a new one with CaseList or CaseSearch.
14
14
  2. Open new leads with CaseAdd as `hypothesis` or `investigating`.
15
15
  3. Promote cases with CaseUpdate only after materially new evidence, proof, impact, blockers, remediation, or status changes.
16
- 4. Mark `confirmed` only when evidence and a PoC or repro are recorded.
16
+ 4. Mark `confirmed` only via PromoteFinding after a real PoC exit 0 (evidence, impact, severity, poc required).
17
17
  5. Use CaseLink and CaseUnlink for exploit chains. Do not edit linked case IDs directly.
18
- 6. Use CaseReport only for confirmed or already reported cases.
18
+ 6. Use CaseReport only for confirmed or already reported cases, then CaseUpdate status=`reported`.
19
19
  7. Use `killed` for disproven, duplicate, or dead-end leads, and include evidence, blockers, next step, or assumptions explaining why.
20
20
 
21
+ ## State machine
22
+
23
+ ```
24
+ hypothesis → investigating → confirmed → reported
25
+ ↓ ↓
26
+ blocked killed (terminal)
27
+ ```
28
+
29
+ - investigating requires evidence + confidence
30
+ - confirmed requires PromoteFinding (not CaseUpdate)
31
+ - killed/reported are terminal (no field edits or re-links)
32
+
21
33
  ## Tool Map
22
34
 
23
35
  - `CaseAdd`: create a new case.
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Tools: CaseAdd, CaseUpdate, PromoteFinding, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseReport
5
5
  * Command: /casefile — interactive dashboard
6
- * Event: before_agent_start — injects case summary context into the system prompt
6
+ * Event: before_agent_start — injects cyber workflow (+ active case list) once per user prompt
7
7
  */
8
8
 
9
9
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -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
- Every finding starts HYPOTHESIS. Nothing reaches CONFIRMED without a working PoC on disk. Optimize for correctness over novelty. Prefer rejecting a real bug temporarily rather than reporting a false positive. Every confirmed finding must survive skeptical review by another experienced security researcher.
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 trace), \`confidence\` | Path trace in notes |
347
- | **CONFIRMED** | \`evidence\`, **\`poc\`**, \`impact\`, \`severity\`, **\`impact_proof\`** | **PoC script + run.log with exit code 0, impact evidence on disk** |
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
- ## 1. Evidence-First Doctrine (Highest Priority)
364
+ ## 0. Attacker Model (Read First)
365
+
366
+ Before escalating any finding, answer in evidence:
367
+
368
+ 1. **Who is the attacker?** (unauth internet, low-priv user, tenant peer, SSRF pivot, etc.)
369
+ 2. **What can they already do without the bug?** (baseline privileges)
370
+ 3. **What extra power does the bug grant beyond that baseline?**
371
+ 4. **Is the path realistic in production?** (auth, CSRF, WAF, network, feature flags, admin-only)
372
+
373
+ If you cannot name a concrete attacker who gains something they should not have → do **not** confirm. Keep as hypothesis/investigating, or kill as \`insufficient_impact\` / \`environmental_issue\`.
374
+
375
+ **Non-applicable / weak-impact defaults (KILL or do not promote):**
376
+ - Self-XSS / self-DoS only (attacker harms only their own session/account)
377
+ - Requires admin/root/already-trusted role that already has the same power
378
+ - Local-only, offline, or impossible deployment assumptions
379
+ - Spec-compliant / documented intentional behavior
380
+ - Needs physical access, victim to paste payload into their own console, or other social-engineering-only steps with no trust-boundary break
381
+ - "Interesting" logic quirks with **no confidentiality, integrity, availability, or financial effect**
382
+ - PoC proves a code path exists but **not** that a real victim asset is affected
383
+
384
+ Technical validity ≠ bounty validity. A true bug with no attacker-usable impact is still a kill for confirmed/report.
385
+
386
+ ---
387
+
388
+ ## 1. Evidence-First Doctrine
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: explicitly state uncertainty, propose the next experiment, and do not escalate the finding. Produce the strongest conclusion supported by available evidence; never assume success where verification is incomplete.
394
+ If evidence is insufficient: state uncertainty, propose the next experiment, do not escalate. Never assume success where verification is incomplete.
395
+
396
+ ---
397
+
398
+ ## 2. Impact Gate (Mandatory Before CONFIRMED)
399
+
400
+ Prove at least **one** real attacker-facing violation:
401
+
402
+ | Category | Required proof |
403
+ |----------|----------------|
404
+ | **Confidentiality** | Attacker reads data they must not see (other users/tenants/secrets) |
405
+ | **Integrity** | Attacker changes data/state they must not control |
406
+ | **Availability** | Attacker degrades service for **others** (not only self) |
407
+ | **Financial / authz** | Direct money, privilege, or account takeover path |
408
+
409
+ Impact text must answer: *who is hurt, what is lost, how the attacker reaches it.*
410
+ Vague impact like "could be dangerous" or "may lead to RCE" without a path is not impact_proof.
411
+
412
+ If impact is only theoretical, needs a second unproven bug, or is not yet capable from the attacker's seat → stay INVESTIGATING (chain it) or KILL \`insufficient_impact\`. Do **not** confirm "valid but non-applicable" findings.
362
413
 
363
414
  ---
364
415
 
365
- ## 2. Adversarial Self-Review (Mandatory Before CONFIRMED)
366
- Before confirming any vulnerability, argue against yourself:
367
- 1. Explain why this might NOT be a vulnerability (e.g. intended behavior, sandbox limit, misconfiguration).
368
- 2. List alternative explanations for the observed behavior.
369
- 3. Explain why each alternative was rejected.
370
- 4. Describe what specific evidence disproves those alternatives.
416
+ ## 3. Adversarial Self-Review (Mandatory Before CONFIRMED)
417
+ Argue against yourself:
418
+ 1. Why this might NOT be a vulnerability (intended, sandbox, misconfig, already authorized).
419
+ 2. Alternative explanations for the observation.
420
+ 3. Why each alternative was rejected **with evidence**.
421
+ 4. What blocks a real attacker today (auth, CSRF, network, role checks) and whether each is bypassed.
422
+ 5. Would a program triage say "informative / N/A" because impact is self-only or privileged-only?
371
423
 
372
424
  ---
373
425
 
374
- ## 3. False Positive Audit Checklist
375
- Attempt to falsify the finding. Immediately KILL the case if any of the following apply:
376
- - The behavior matches intended or documented specs.
377
- - The issue is caused by browser quirks, testing mistakes, or cache artifacts.
378
- - Framework/middleware protections render it unexploitable in production.
379
- - Environmental limitations prevent crossing a security boundary.
426
+ ## 4. False Positive / Non-Applicable Kill Checklist
427
+ KILL immediately when any apply:
428
+ - Matches documented/spec behavior (\`intended_behavior\`)
429
+ - Browser quirk, test artifact, or cache noise
430
+ - Framework/middleware/WAF blocks the path and is not bypassed (\`framework_protection\`)
431
+ - Requires privileges the attacker already has or cannot obtain (\`environmental_issue\`)
432
+ - No C/I/A/financial effect for anyone but the attacker themselves (\`insufficient_impact\`)
433
+ - Exploit unreliable / not reproducible twice (\`exploit_unreliable\`)
434
+ - Duplicate of an existing case (\`duplicate\`)
380
435
 
381
436
  ---
382
437
 
383
- ## 4. Root Cause Before Impact
384
- Do not map "Behavior → Impact". You must trace:
438
+ ## 5. Root Cause Boundary → Impact (Not Behavior → Hype)
439
+ Trace:
385
440
  \`\`\`
386
- Observed Behavior ──→ Root Cause ──→ Security Boundary Broken ──→ Actual Impact
441
+ Entry (attacker-controlled) ──→ Reachable code path ──→ Trust boundary crossed ──→ Victim impact
387
442
  \`\`\`
388
- - Minimum confirmation: Must reproduce successfully at least twice or via two independent methods.
389
- - Document case details structured as: **Observed Facts**, **Assumptions**, **Unknowns**, **Experiments Remaining**.
443
+ - Minimum: reproduce successfully at least twice or via two independent methods.
444
+ - Record: **Observed Facts**, **Assumptions**, **Unknowns**, **Experiments Remaining**.
445
+ - If the bug is only a **primitive** (e.g. open redirect, limited SSRF, info leak of non-sensitive data), either chain to high impact or keep severity honest — do not inflate.
390
446
 
391
447
  ---
392
448
 
393
- ## 5. Duplicate Check
394
- Before creating any new case, ask:
395
- - Is this actually new?
396
- - Could it be another manifestation of an existing case?
397
- - Do multiple endpoints share the same underlying root cause?
398
- Keep the database clean; consolidate related endpoints into single root-cause cases.
449
+ ## 6. Duplicate Check
450
+ Before CaseAdd:
451
+ - Is this new?
452
+ - Same root cause as an open case?
453
+ - Multiple endpoints, one bug?
454
+ Continue the existing case ID when scope matches.
399
455
 
400
456
  ---
401
457
 
402
- ## 6. Report-Readiness Gate
403
- Before marking Ready for Report:
404
- - Can another researcher reproduce this deterministically?
405
- - Are the steps completely reproducible?
406
- - Is the impact justified without inflating severity? (Would the vendor agree with this impact? Is a real trust boundary crossed?)
407
- - Are exact root causes and remedial code changes detailed?
458
+ ## 7. Report-Readiness Gate
459
+ Before REPORTED:
460
+ - Another researcher can reproduce deterministically
461
+ - Steps are complete and production-realistic
462
+ - Impact is justified without inflation (would the vendor agree?)
463
+ - Root cause and fix guidance are concrete
464
+ - Attacker model + victim impact are explicit in the write-up
408
465
 
409
466
  ---
410
467
 
411
- ## 7. Permanent KILLED Case Cataloging
412
- Keep killed cases documented with a clear classification in the ledger:
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
- Documenting why ideas were rejected prevents revisiting the same dead ends.
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
- function buildCaseContext(records: CaseRecord[]): string {
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 = safe(c.nextStep, 180);
517
+ const n = sanitizeContextText(c.nextStep, 180);
458
518
  const extra = status === "confirmed" ? ` [${c.severity ?? "?"}]` : "";
459
- lines.push(` - ${c.id}: ${safe(c.title, 140) ?? "(untitled)"}${extra}${n ? ` → ${n}` : ""}`);
519
+ lines.push(
520
+ ` - ${c.id}: ${sanitizeContextText(c.title, 140) ?? "(untitled)"}${extra}${n ? ` → ${n}` : ""}`,
521
+ );
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(` - ${c.id}: ${safe(c.title, 140) ?? "(untitled)"} [${c.priority}]`);
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
- const active = records.filter((r) => r.status !== "killed" && r.status !== "reported");
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: (u) =>
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: (u) =>
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(db: DatabaseSync, candidate: CaseRecord): CaseRecord | undefined {
519
+ function findDuplicateCaseInDb(
520
+ db: DatabaseSync,
521
+ candidate: Pick<CaseRecord, "title" | "target" | "endpoint" | "bugClass">,
522
+ excludeId?: string,
523
+ ): CaseRecord | undefined {
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
- // We query all cases where status is not killed, then match in JS for normalized forms
526
- const stmt = db.prepare("SELECT * FROM cases WHERE status != 'killed'");
527
- const rows = stmt.all();
531
+ // Query non-killed cases, then match normalized title/scope in JS.
532
+ const rows = excludeId
533
+ ? (db
534
+ .prepare("SELECT * FROM cases WHERE status != 'killed' AND id != ?")
535
+ .all(excludeId) as any[])
536
+ : (db.prepare("SELECT * FROM cases WHERE status != 'killed'").all() as any[]);
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
- // Find links
537
- const linkStmt = db.prepare("SELECT target_id FROM case_links WHERE source_id = ?");
538
- const links = linkStmt.all(row.id) as { target_id: string }[];
545
+ const links = db
546
+ .prepare("SELECT target_id FROM case_links WHERE source_id = ?")
547
+ .all(row.id) as { target_id: string }[];
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 insertOrReplaceCase(db: DatabaseSync, record: CaseRecord) {
559
+ function upsertCase(db: DatabaseSync, record: CaseRecord) {
560
+ // Use ON CONFLICT DO UPDATE (not INSERT OR REPLACE) so FK CASCADE does not
561
+ // wipe case_links when updating an existing primary key.
551
562
  const stmt = db.prepare(`
552
- INSERT OR REPLACE INTO cases (
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
- insertOrReplaceCase(db, record);
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
- const next = buildRecord(
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
- // Duplicate checks excluding current
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
- insertOrReplaceCase(db, next);
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
- insertOrReplaceCase(db, next);
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
- const linkStmt = db.prepare("INSERT INTO case_links (source_id, target_id) VALUES (?, ?)");
785
- linkStmt.run(sourceId, targetId);
786
- linkStmt.run(targetId, sourceId);
787
-
788
- const now = new Date().toISOString();
789
- const updateTimeStmt = db.prepare("UPDATE cases SET updated_at = ? WHERE id = ?");
790
- updateTimeStmt.run(now, sourceId);
791
- updateTimeStmt.run(now, targetId);
816
+ db.exec("BEGIN");
817
+ try {
818
+ const linkStmt = db.prepare("INSERT INTO case_links (source_id, target_id) VALUES (?, ?)");
819
+ linkStmt.run(sourceId, targetId);
820
+ linkStmt.run(targetId, sourceId);
821
+
822
+ const now = new Date().toISOString();
823
+ const updateTimeStmt = db.prepare("UPDATE cases SET updated_at = ? WHERE id = ?");
824
+ updateTimeStmt.run(now, sourceId);
825
+ updateTimeStmt.run(now, targetId);
826
+ db.exec("COMMIT");
827
+ } catch (err) {
828
+ try {
829
+ db.exec("ROLLBACK");
830
+ } catch {
831
+ // ignore
832
+ }
833
+ throw err;
834
+ }
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
- const unlinkStmt = db.prepare(
813
- "DELETE FROM case_links WHERE (source_id = ? AND target_id = ?) OR (source_id = ? AND target_id = ?)",
814
- );
815
- unlinkStmt.run(sourceId, targetId, targetId, sourceId);
816
-
817
- const now = new Date().toISOString();
818
- const updateTimeStmt = db.prepare("UPDATE cases SET updated_at = ? WHERE id = ?");
819
- updateTimeStmt.run(now, sourceId);
820
- updateTimeStmt.run(now, targetId);
861
+ db.exec("BEGIN");
862
+ try {
863
+ const unlinkStmt = db.prepare(
864
+ "DELETE FROM case_links WHERE (source_id = ? AND target_id = ?) OR (source_id = ? AND target_id = ?)",
865
+ );
866
+ unlinkStmt.run(sourceId, targetId, targetId, sourceId);
867
+
868
+ const now = new Date().toISOString();
869
+ const updateTimeStmt = db.prepare("UPDATE cases SET updated_at = ? WHERE id = ?");
870
+ updateTimeStmt.run(now, sourceId);
871
+ updateTimeStmt.run(now, targetId);
872
+ db.exec("COMMIT");
873
+ } catch (err) {
874
+ try {
875
+ db.exec("ROLLBACK");
876
+ } catch {
877
+ // ignore
878
+ }
879
+ throw err;
880
+ }
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
- insertOrReplaceCase(db, next);
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. Project type detection.
148
+ // 2. Extension-based language (prefer the PoC file itself over ambient project markers).
149
+ // A .py PoC in a Node monorepo must still run under python, not node.
150
+ if (extKey && languages[extKey]) {
151
+ return { key: extKey, language: languages[extKey] };
152
+ }
153
+
154
+ // 3. Project type detection only when the extension is unknown/unmapped.
149
155
  const projectType = detectProjectType(languages);
150
156
  if (projectType && languages[projectType]) {
151
- // If the file extension matches the project type or type has no extension restrictions, use it.
152
157
  return { key: projectType, language: languages[projectType] };
153
158
  }
154
159
 
155
- // 3. Extension-based fallback.
156
- if (extKey && languages[extKey]) {
157
- return { key: extKey, language: languages[extKey] };
158
- }
159
-
160
160
  // 4. Unknown extension: allow env override specifying a single language key.
161
161
  const envDefault = process.env.PI_POC_DEFAULT_LANGUAGE?.trim();
162
162
  if (envDefault && languages[envDefault]) {
@@ -186,13 +186,12 @@ function validatePocPath(pocPath: string): string {
186
186
  throw new Error("PoC path contains null bytes");
187
187
  }
188
188
 
189
- const parts = normalized.split(/[\\/]/);
190
- if (parts.includes("..")) {
191
- throw new Error(`PoC path contains traversal segments: ${pocPath}`);
192
- }
193
-
194
189
  const root = getProjectRoot();
195
- if (!normalized.startsWith(`${root}/`) && normalized !== root) {
190
+ // Use path.relative so prefix-sibling escapes like /tmp/proj vs /tmp/proj-evil are rejected.
191
+ // startsWith(`${root}/`) would accept /tmp/proj-evil when root is /tmp/proj.
192
+ const rel = relative(root, normalized);
193
+ const outsideWorkspace = rel === "" ? false : rel.startsWith("..") || isAbsolute(rel);
194
+ if (outsideWorkspace) {
196
195
  const allowAbsolute = process.env.PI_POC_ALLOW_ABSOLUTE === "1";
197
196
  if (!allowAbsolute) {
198
197
  throw new Error(