@xaccefy/pi-casefile 0.1.9 → 0.2.1

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.1.9",
3
+ "version": "0.2.1",
4
4
  "description": "Offensive security case tracker for Pi Agent — bug bounties, CTFs, security audits",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/index.ts CHANGED
@@ -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
  },
@@ -241,15 +255,15 @@ function renderCaseResult(
241
255
  theme: Theme,
242
256
  successPrefix = "✓ ",
243
257
  failPrefix = "✗ ",
244
- ): Text {
258
+ ): string {
245
259
  const details = result.details as { record?: CaseRecord; changed?: boolean } | undefined;
246
260
  if (!details?.record) {
247
- return new Text(theme.fg("error", "✗ Failed"), 0, 0);
261
+ return theme.fg("error", "✗ Failed");
248
262
  }
249
263
  const success = details.changed !== false;
250
264
  const prefix = success ? successPrefix : failPrefix;
251
265
  const color = success ? "success" : "warning";
252
- return new Text(theme.fg(color, prefix) + renderOneLine(details.record, theme), 0, 0);
266
+ return theme.fg(color, prefix) + renderOneLine(details.record, theme);
253
267
  }
254
268
 
255
269
  // ── Dashboard component ──────────────────────────────────────────────
@@ -409,7 +423,7 @@ function buildCaseContext(records: CaseRecord[]): string {
409
423
  if (records.length === 0) return "";
410
424
 
411
425
  const safe = (v?: string, max = 160) => {
412
- const controlChars = new RegExp("[\r\n\t\\u0000-\\u001F\\u007F\\u2028\\u2029]+", "g");
426
+ const controlChars = /[\r\n\t\u0000-\u001F\u007F\u2028\u2029]+/g;
413
427
  const s = v
414
428
  ?.replace(controlChars, " ")
415
429
  .replace(/[<>]/g, (c) => (c === "<" ? "‹" : "›"))
@@ -547,8 +561,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
547
561
  renderResult(result, { expanded }, theme) {
548
562
  const details = result.details as { created?: boolean; record?: CaseRecord };
549
563
  const created = details?.created;
550
- const baseText = renderCaseResult(result, theme, created === false ? "↻ " : "✓ ");
551
- let line = baseText.toString();
564
+ let line = renderCaseResult(result, theme, created === false ? "↻ " : "✓ ");
552
565
  if (expanded && details?.record) {
553
566
  line += `\n${theme.fg("dim", ` ${details.record.id} → ${details.record.nextStep ?? "no next step"}`)}`;
554
567
  }
@@ -603,8 +616,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
603
616
  renderResult(result, { expanded }, theme) {
604
617
  const details = result.details as { changed?: boolean; record?: CaseRecord; reason?: string };
605
618
  const unchanged = details?.changed === false;
606
- const baseText = renderCaseResult(result, theme, unchanged ? "↷ " : "✓ ");
607
- let line = baseText.toString();
619
+ let line = renderCaseResult(result, theme, unchanged ? "↷ " : "✓ ");
608
620
  if (expanded && details?.record) {
609
621
  line +=
610
622
  "\n" +
@@ -672,7 +684,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
672
684
  renderResult(result, _options, theme) {
673
685
  const details = result.details as { run?: { exitCode: number } } | undefined;
674
686
  const success = details?.run?.exitCode === 0;
675
- return renderCaseResult(result, theme, success ? "✓ " : "✗ ", "✗ ");
687
+ return new Text(renderCaseResult(result, theme, success ? "✓ " : "✗ ", "✗ "), 0, 0);
676
688
  },
677
689
  });
678
690
 
@@ -705,7 +717,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
705
717
  },
706
718
 
707
719
  renderResult(result, _options, theme) {
708
- return renderCaseResult(result, theme, "", "");
720
+ return new Text(renderCaseResult(result, theme, "", ""), 0, 0);
709
721
  },
710
722
  });
711
723
 
@@ -727,8 +739,11 @@ export default function casefileExtension(pi: ExtensionAPI) {
727
739
  status: params.status as CaseStatus | undefined,
728
740
  confidence: params.confidence as CaseConfidence | undefined,
729
741
  severity: params.severity as CaseSeverity | undefined,
742
+ minSeverity: params.minSeverity as CaseSeverity | undefined,
730
743
  priority: params.priority as CasePriority | undefined,
731
744
  tag: params.tag,
745
+ since: params.since as string | undefined,
746
+ until: params.until as string | undefined,
732
747
  limit: params.limit,
733
748
  offset: params.offset,
734
749
  });
@@ -774,8 +789,11 @@ export default function casefileExtension(pi: ExtensionAPI) {
774
789
  status: params.status as CaseStatus | undefined,
775
790
  confidence: params.confidence as CaseConfidence | undefined,
776
791
  severity: params.severity as CaseSeverity | undefined,
792
+ minSeverity: params.minSeverity as CaseSeverity | undefined,
777
793
  priority: params.priority as CasePriority | undefined,
778
794
  tag: params.tag,
795
+ since: params.since as string | undefined,
796
+ until: params.until as string | undefined,
779
797
  limit: params.limit,
780
798
  offset: params.offset,
781
799
  });
package/src/ledger.ts CHANGED
@@ -141,8 +141,14 @@ export type CaseSearchOptions = {
141
141
  status?: CaseStatus;
142
142
  confidence?: CaseConfidence;
143
143
  severity?: CaseSeverity;
144
+ /** Return only cases at or above this severity (info < low < medium < high < critical). */
145
+ minSeverity?: CaseSeverity;
144
146
  priority?: CasePriority;
145
147
  tag?: string;
148
+ /** ISO timestamp; only cases created at/after this time. */
149
+ since?: string;
150
+ /** ISO timestamp; only cases created at/before this time. */
151
+ until?: string;
146
152
  limit?: number;
147
153
  offset?: number;
148
154
  };
@@ -190,10 +196,15 @@ export function getCasefilePath(): string {
190
196
  }
191
197
 
192
198
  export function setCasefilePath(path: string | undefined): void {
193
- ledgerPathOverride = path;
194
199
  if (dbInstance) {
195
- dbInstance = undefined; // Force reconnection on next getDb
200
+ try {
201
+ dbInstance.close();
202
+ } catch {
203
+ // Best-effort close.
204
+ }
196
205
  }
206
+ ledgerPathOverride = path;
207
+ dbInstance = undefined; // Force reconnection on next getDb
197
208
  }
198
209
 
199
210
  // ── SQLite Schema Init ────────────────────────────────────────────────
@@ -210,6 +221,9 @@ function getDb(): DatabaseSync {
210
221
  }
211
222
 
212
223
  const db = new DatabaseSync(dbPath);
224
+ // Enable foreign-key enforcement so ON DELETE CASCADE actually fires
225
+ // (SQLite keeps FK off by default; bun:sqlite in particular defaults it off).
226
+ db.exec("PRAGMA foreign_keys = ON");
213
227
 
214
228
  // Create tables
215
229
  db.exec(`
@@ -353,8 +367,13 @@ export function getCaseById(id: string): CaseRecord | undefined {
353
367
 
354
368
  function validateCase(record: CaseRecord): void {
355
369
  if (!record.title.trim()) throw new Error("Case title cannot be empty");
356
- if (record.status === "confirmed" && (!record.evidence || !record.poc)) {
357
- throw new Error("Confirmed cases require both evidence and poc");
370
+ // Keep this gate in lockstep with promoteFindingResult: a case may only be
371
+ // CONFIRMED when it has evidence, a PoC, demonstrated impact, and a severity.
372
+ if (
373
+ record.status === "confirmed" &&
374
+ (!record.evidence || !record.poc || !record.impact || !record.severity)
375
+ ) {
376
+ throw new Error("Confirmed cases require evidence, poc, impact, and severity");
358
377
  }
359
378
  if (record.status === "blocked" && (record.blockers ?? []).length === 0) {
360
379
  throw new Error("Blocked cases require at least one blocker");
@@ -370,13 +389,10 @@ function validateCase(record: CaseRecord): void {
370
389
  "Killed cases require evidence, next step, blockers, or assumptions explaining why",
371
390
  );
372
391
  }
373
- if (
374
- record.status === "reported" &&
375
- !record.poc &&
376
- !record.remediation &&
377
- (record.references ?? []).length === 0
378
- ) {
379
- throw new Error("Reported cases require poc, remediation, or references");
392
+ // A case becomes REPORTED only via CaseReport, which records reportPath. Require it
393
+ // here so validation stays consistent with the confirmed→reported transition gate.
394
+ if (record.status === "reported" && !record.reportPath) {
395
+ throw new Error("Reported cases require a generated report (run CaseReport first)");
380
396
  }
381
397
  }
382
398
 
@@ -724,10 +740,18 @@ export function promoteFindingResult(id: string, verification: PocVerification):
724
740
  );
725
741
  }
726
742
 
743
+ const newEvidence =
744
+ (current.evidence ? current.evidence + "\n\n" : "") +
745
+ `### PoC Execution Capture (${verification.ranAt})\n` +
746
+ `- **Exit Code:** ${verification.exitCode}\n` +
747
+ `- **Sandbox:** ${verification.sandbox ? "yes" : "no"}\n` +
748
+ `#### Execution Output\n\`\`\`\n${verification.output ?? ""}\n\`\`\``;
749
+
727
750
  const next = buildRecord(
728
751
  {
729
752
  status: "confirmed",
730
753
  pocVerified: verification,
754
+ evidence: newEvidence,
731
755
  },
732
756
  current,
733
757
  );
@@ -802,57 +826,138 @@ export function unlinkCasesResult(sourceId: string, targetId: string): CaseLinkR
802
826
 
803
827
  // ── Search & Queries ─────────────────────────────────────────────────
804
828
 
805
- function caseHaystack(record: CaseRecord, field?: CaseSearchField): string {
806
- if (field) {
807
- const val = record[field];
808
- if (Array.isArray(val)) return val.join(" ").toLowerCase();
809
- return (typeof val === "string" ? val : String(val ?? "")).toLowerCase();
829
+ // Searchable text columns (excludes ids/timestamps/JSON arrays for performance + signal).
830
+ const SEARCH_COLUMNS = [
831
+ "title",
832
+ "summary",
833
+ "evidence",
834
+ "impact",
835
+ "target",
836
+ "endpoint",
837
+ "bugClass",
838
+ "poc",
839
+ ] as const;
840
+
841
+ const FIELD_COLUMN: Record<CaseSearchField, string> = {
842
+ title: "title",
843
+ summary: "summary",
844
+ evidence: "evidence",
845
+ impact: "impact",
846
+ target: "target",
847
+ endpoint: "endpoint",
848
+ bugClass: "bugClass",
849
+ poc: "poc",
850
+ };
851
+
852
+ function severityRank(s: CaseSeverity): number {
853
+ return SEVERITY_VALUES.indexOf(s);
854
+ }
855
+
856
+ /**
857
+ * Build a parameterized WHERE clause + params for case queries. Pushes all
858
+ * structured filters (and free-text) into SQL so we never load the whole ledger
859
+ * into memory just to filter it in JS. Also returns a stable ORDER BY that keeps
860
+ * the original status precedence (hypothesis first) with updated_at as tiebreak.
861
+ */
862
+ function buildCaseWhere(options: CaseSearchOptions): {
863
+ whereSql: string;
864
+ orderSql: string;
865
+ params: unknown[];
866
+ } {
867
+ const where: string[] = [];
868
+ const params: unknown[] = [];
869
+
870
+ if (options.status) {
871
+ where.push("status = ?");
872
+ params.push(options.status);
810
873
  }
811
- return Object.entries(record)
812
- .filter(([k]) => !["id", "createdAt", "updatedAt", "reportedAt", "reportPath"].includes(k))
813
- .map(([, v]) => (Array.isArray(v) ? v.join(" ") : String(v ?? "")))
814
- .filter(Boolean)
815
- .join("\n")
816
- .toLowerCase();
874
+ if (options.confidence) {
875
+ where.push("confidence = ?");
876
+ params.push(options.confidence);
877
+ }
878
+ if (options.severity) {
879
+ where.push("severity = ?");
880
+ params.push(options.severity);
881
+ }
882
+ if (options.minSeverity) {
883
+ where.push(
884
+ "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) >= ?",
885
+ );
886
+ params.push(severityRank(options.minSeverity));
887
+ }
888
+ if (options.priority) {
889
+ where.push("priority = ?");
890
+ params.push(options.priority);
891
+ }
892
+ if (options.tag) {
893
+ where.push("EXISTS (SELECT 1 FROM json_each(tags_json) WHERE lower(value) = ?)");
894
+ params.push(options.tag.trim().toLowerCase());
895
+ }
896
+ if (options.since) {
897
+ where.push("created_at >= ?");
898
+ params.push(options.since);
899
+ }
900
+ if (options.until) {
901
+ where.push("created_at <= ?");
902
+ params.push(options.until);
903
+ }
904
+
905
+ const query = options.query?.trim().toLowerCase();
906
+ if (query) {
907
+ const likeParam = `%${query}%`;
908
+ if (options.field) {
909
+ where.push(`lower(${FIELD_COLUMN[options.field]}) LIKE ?`);
910
+ params.push(likeParam);
911
+ } else {
912
+ const ors = SEARCH_COLUMNS.map((c) => `lower(${c}) LIKE ?`).join(" OR ");
913
+ where.push(`(${ors})`);
914
+ for (let i = 0; i < SEARCH_COLUMNS.length; i++) params.push(likeParam);
915
+ }
916
+ }
917
+
918
+ const orderSql =
919
+ "CASE status WHEN 'hypothesis' THEN 0 WHEN 'investigating' THEN 1 WHEN 'confirmed' THEN 2 " +
920
+ "WHEN 'blocked' THEN 3 WHEN 'killed' THEN 4 WHEN 'reported' THEN 5 ELSE 6 END, updated_at DESC";
921
+
922
+ return {
923
+ whereSql: where.length ? `WHERE ${where.join(" AND ")}` : "",
924
+ orderSql,
925
+ params,
926
+ };
927
+ }
928
+
929
+ /** Map DB rows to CaseRecords, attaching linkedCaseIds fetched in a single batch. */
930
+ function mapRowsWithLinks(db: DatabaseSync, rows: any[]): CaseRecord[] {
931
+ if (rows.length === 0) return [];
932
+ const ids = rows.map((r) => r.id);
933
+ const placeholders = ids.map(() => "?").join(",");
934
+ const links = db
935
+ .prepare(`SELECT source_id, target_id FROM case_links WHERE source_id IN (${placeholders})`)
936
+ .all(...ids) as { source_id: string; target_id: string }[];
937
+ const linkMap = new Map<string, string[]>();
938
+ for (const l of links) {
939
+ if (!linkMap.has(l.source_id)) linkMap.set(l.source_id, []);
940
+ linkMap.get(l.source_id)!.push(l.target_id);
941
+ }
942
+ return rows.map((row) => mapRow(row, linkMap.get(row.id) ?? []));
817
943
  }
818
944
 
819
945
  export function searchCases(options: CaseSearchOptions = {}): {
820
946
  cases: CaseRecord[];
821
947
  total: number;
822
948
  } {
823
- const query = options.query?.trim().toLowerCase();
824
- const field = options.field;
825
- const tag = options.tag?.trim().toLowerCase();
949
+ const db = getDb();
826
950
  const limit = Math.max(1, Math.min(options.limit ?? 50, 200));
827
951
  const offset = Math.max(0, options.offset ?? 0);
828
952
 
829
- const STATUS_ORDER: CaseStatus[] = [
830
- "hypothesis",
831
- "investigating",
832
- "confirmed",
833
- "blocked",
834
- "killed",
835
- "reported",
836
- ];
837
-
838
- const filtered = readCasefile()
839
- .filter((r) => !options.status || r.status === options.status)
840
- .filter((r) => !options.confidence || r.confidence === options.confidence)
841
- .filter((r) => !options.severity || r.severity === options.severity)
842
- .filter((r) => !options.priority || r.priority === options.priority)
843
- .filter((r) => !tag || r.tags?.some((t) => t.toLowerCase() === tag))
844
- .filter((r) => !query || caseHaystack(r, field).includes(query))
845
- .sort((a, b) => {
846
- const aStatus = STATUS_ORDER.indexOf(a.status);
847
- const bStatus = STATUS_ORDER.indexOf(b.status);
848
- if (aStatus !== bStatus) return aStatus - bStatus;
849
- return b.updatedAt.localeCompare(a.updatedAt);
850
- });
953
+ const { whereSql, orderSql, params } = buildCaseWhere(options);
851
954
 
852
- return {
853
- total: filtered.length,
854
- cases: filtered.slice(offset, offset + limit),
855
- };
955
+ const total = (db.prepare(`SELECT COUNT(*) as c FROM cases ${whereSql}`).get(...params) as any).c;
956
+ const rows = db
957
+ .prepare(`SELECT * FROM cases ${whereSql} ORDER BY ${orderSql} LIMIT ? OFFSET ?`)
958
+ .all(...params, limit, offset) as any[];
959
+
960
+ return { total, cases: mapRowsWithLinks(db, rows) };
856
961
  }
857
962
 
858
963
  export function countCases(): {
@@ -860,14 +965,22 @@ export function countCases(): {
860
965
  byStatus: Record<string, number>;
861
966
  bySeverity: Record<string, number>;
862
967
  } {
863
- const records = readCasefile();
968
+ const db = getDb();
969
+ const total = (db.prepare("SELECT COUNT(*) as c FROM cases").get() as any).c;
970
+ const statusRows = db
971
+ .prepare("SELECT status, COUNT(*) as n FROM cases GROUP BY status")
972
+ .all() as { status: string; n: number }[];
973
+ const severityRows = db
974
+ .prepare(
975
+ "SELECT severity, COUNT(*) as n FROM cases WHERE severity IS NOT NULL GROUP BY severity",
976
+ )
977
+ .all() as { severity: string; n: number }[];
978
+
864
979
  const byStatus: Record<string, number> = {};
865
980
  const bySeverity: Record<string, number> = {};
866
- for (const r of records) {
867
- byStatus[r.status] = (byStatus[r.status] ?? 0) + 1;
868
- if (r.severity) bySeverity[r.severity] = (bySeverity[r.severity] ?? 0) + 1;
869
- }
870
- return { total: records.length, byStatus, bySeverity };
981
+ for (const r of statusRows) byStatus[r.status] = r.n;
982
+ for (const r of severityRows) bySeverity[r.severity] = r.n;
983
+ return { total, byStatus, bySeverity };
871
984
  }
872
985
 
873
986
  // ── Format helpers ───────────────────────────────────────────────────
@@ -958,6 +1071,12 @@ export function writeCaseReport(id: string): { path: string; record: CaseRecord
958
1071
  mdSection("Summary", current.summary),
959
1072
  mdSection("Steps to Reproduce / Evidence", current.evidence),
960
1073
  mdSection("Proof of Concept", current.poc),
1074
+ current.pocVerified
1075
+ ? mdSection(
1076
+ "PoC Verification Log",
1077
+ `### PoC Run Verification\n- **Timestamp:** ${current.pocVerified.ranAt}\n- **Path:** \`${current.pocVerified.path}\`\n- **Sandbox:** ${current.pocVerified.sandbox ? "yes" : "no"}\n- **Exit Code:** ${current.pocVerified.exitCode}\n\n#### Output\n\`\`\`\n${current.pocVerified.output ?? ""}\n\`\`\``,
1078
+ )
1079
+ : undefined,
961
1080
  mdSection("Impact", current.impact),
962
1081
  mdSection("Remediation", current.remediation),
963
1082
  mdSection("Assumptions and Uncertainty", assumptions),
package/src/poc-runner.ts CHANGED
@@ -239,6 +239,12 @@ function buildDockerArgs(image: string, command: string, workspaceDir: string):
239
239
  "no-new-privileges",
240
240
  "--user",
241
241
  "1000:1000",
242
+ "--memory",
243
+ "256m",
244
+ "--pids-limit",
245
+ "128",
246
+ "--cpus",
247
+ "1.0",
242
248
  "-v",
243
249
  `${workspaceDir}:/workspace:rw`,
244
250
  image,
@@ -260,6 +266,27 @@ function renderCommand(template: string, pocPath: string, inSandbox: boolean): s
260
266
  .replace(/{{class}}/g, className);
261
267
  }
262
268
 
269
+ /**
270
+ * Translate a spawnSync result into a robust exit code.
271
+ *
272
+ * `spawnSync` returns `status: null` AND `signal: null` when it cannot even start
273
+ * the child (e.g. the binary is missing → ENOENT, or the docker daemon is
274
+ * unavailable). The previous `result.status ?? (result.signal ? 1 : 0)` then
275
+ * collapsed to `0`, making a never-executed PoC look successful — which let
276
+ * PromoteFinding promote an investigating case to CONFIRMED without the PoC
277
+ * ever running. We fail closed: a spawn error or a missing status/signal is
278
+ * always a non-zero exit.
279
+ */
280
+ function spawnExitCode(result: {
281
+ status: number | null;
282
+ signal: string | null;
283
+ error?: Error;
284
+ }): number {
285
+ if (result.error) return 127;
286
+ if (result.status !== null) return result.status;
287
+ return 1;
288
+ }
289
+
263
290
  function runSandboxed(pocPath: string, language: PocLanguage): PocRun {
264
291
  const ranAt = new Date().toISOString();
265
292
  const sourceName = basename(pocPath);
@@ -283,10 +310,11 @@ function runSandboxed(pocPath: string, language: PocLanguage): PocRun {
283
310
  maxBuffer: MAX_BUFFER,
284
311
  });
285
312
 
286
- const output = sanitizeOutput((result.stdout ?? "") + (result.stderr ?? ""));
313
+ const spawnErr = result.error ? `\n[spawn error] ${result.error.message}` : "";
314
+ const output = sanitizeOutput((result.stdout ?? "") + (result.stderr ?? "") + spawnErr);
287
315
  return {
288
316
  path: pocPath,
289
- exitCode: result.status ?? (result.signal ? 1 : 0),
317
+ exitCode: spawnExitCode(result),
290
318
  output,
291
319
  ranAt,
292
320
  sandbox: true,
@@ -314,11 +342,15 @@ function runLocal(pocPath: string, language: PocLanguage): PocRun {
314
342
  throw new Error("Language config has no run command");
315
343
  }
316
344
 
317
- // For local execution, split the run command into binary and args.
345
+ // The run template is `<interpreter> <file>`. Preserve multi-arg run commands for
346
+ // normal paths, but keep a space-containing PoC path as a single argument (no shell
347
+ // is used, so args are passed verbatim).
318
348
  const command = renderCommand(language.run, pocPath, false);
319
- const [interpreter, ...args] = command.split(" ");
320
- // Note: the file path is already substituted in the args if {{file}} was used.
321
- // We just need to spawn.
349
+ const trimmed = command.trim();
350
+ const firstSpace = trimmed.indexOf(" ");
351
+ const interpreter = firstSpace === -1 ? trimmed : trimmed.slice(0, firstSpace);
352
+ const rest = firstSpace === -1 ? "" : trimmed.slice(firstSpace + 1);
353
+ const args = pocPath.includes(" ") ? (rest ? [rest] : []) : rest ? rest.split(" ") : [];
322
354
 
323
355
  const result = spawnSync(interpreter, args, {
324
356
  encoding: "utf8",
@@ -326,10 +358,11 @@ function runLocal(pocPath: string, language: PocLanguage): PocRun {
326
358
  maxBuffer: MAX_BUFFER,
327
359
  });
328
360
 
329
- const output = sanitizeOutput((result.stdout ?? "") + (result.stderr ?? ""));
361
+ const spawnErr = result.error ? `\n[spawn error] ${result.error.message}` : "";
362
+ const output = sanitizeOutput((result.stdout ?? "") + (result.stderr ?? "") + spawnErr);
330
363
  return {
331
364
  path: pocPath,
332
- exitCode: result.status ?? (result.signal ? 1 : 0),
365
+ exitCode: spawnExitCode(result),
333
366
  output,
334
367
  ranAt,
335
368
  sandbox: false,
@@ -1,12 +1,37 @@
1
1
  import { createRequire } from "node:module";
2
+
2
3
  const _require = createRequire(import.meta.url);
3
4
 
4
- let DatabaseSync: any;
5
+ // biome-ignore lint/suspicious/noExplicitAny: Runtime module swappability requires any casting
6
+ let DatabaseSyncConstructor: any;
5
7
  try {
6
- DatabaseSync = _require("bun:sqlite").Database as any;
8
+ // biome-ignore lint/suspicious/noExplicitAny: Runtime module swappability
9
+ DatabaseSyncConstructor = _require("bun:sqlite").Database as any;
7
10
  } catch {
8
- DatabaseSync = (_require("node:sqlite") as any).DatabaseSync;
11
+ try {
12
+ // biome-ignore lint/suspicious/noExplicitAny: Runtime module swappability
13
+ DatabaseSyncConstructor = (_require("node:sqlite") as any).DatabaseSync;
14
+ } catch (e) {
15
+ throw new Error(
16
+ "XPI requires bun:sqlite (Bun runtime) or node:sqlite (Node >= 22.5). " +
17
+ `Neither SQLite backend is available: ${(e as Error).message}`,
18
+ );
19
+ }
20
+ }
21
+
22
+ // biome-ignore lint/suspicious/noExplicitAny: Standard SQLite API returns any
23
+ export interface StatementSync {
24
+ run(...args: unknown[]): { lastInsertRowid: number; changes: number };
25
+ // biome-ignore lint/suspicious/noExplicitAny: Standard SQLite API returns any
26
+ get(...args: unknown[]): any;
27
+ // biome-ignore lint/suspicious/noExplicitAny: Standard SQLite API returns any
28
+ all(...args: unknown[]): any[];
29
+ }
30
+
31
+ export interface DatabaseSync {
32
+ prepare(sql: string): StatementSync;
33
+ exec(sql: string): void;
34
+ close(): void;
9
35
  }
10
36
 
11
- export { DatabaseSync };
12
- export type DatabaseSync = any;
37
+ export const DatabaseSync: new (path: string) => DatabaseSync = DatabaseSyncConstructor;