@xaccefy/pi-casefile 0.2.0 → 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.2.0",
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
  },
@@ -725,8 +739,11 @@ export default function casefileExtension(pi: ExtensionAPI) {
725
739
  status: params.status as CaseStatus | undefined,
726
740
  confidence: params.confidence as CaseConfidence | undefined,
727
741
  severity: params.severity as CaseSeverity | undefined,
742
+ minSeverity: params.minSeverity as CaseSeverity | undefined,
728
743
  priority: params.priority as CasePriority | undefined,
729
744
  tag: params.tag,
745
+ since: params.since as string | undefined,
746
+ until: params.until as string | undefined,
730
747
  limit: params.limit,
731
748
  offset: params.offset,
732
749
  });
@@ -772,8 +789,11 @@ export default function casefileExtension(pi: ExtensionAPI) {
772
789
  status: params.status as CaseStatus | undefined,
773
790
  confidence: params.confidence as CaseConfidence | undefined,
774
791
  severity: params.severity as CaseSeverity | undefined,
792
+ minSeverity: params.minSeverity as CaseSeverity | undefined,
775
793
  priority: params.priority as CasePriority | undefined,
776
794
  tag: params.tag,
795
+ since: params.since as string | undefined,
796
+ until: params.until as string | undefined,
777
797
  limit: params.limit,
778
798
  offset: params.offset,
779
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
 
@@ -810,57 +826,138 @@ export function unlinkCasesResult(sourceId: string, targetId: string): CaseLinkR
810
826
 
811
827
  // ── Search & Queries ─────────────────────────────────────────────────
812
828
 
813
- function caseHaystack(record: CaseRecord, field?: CaseSearchField): string {
814
- if (field) {
815
- const val = record[field];
816
- if (Array.isArray(val)) return val.join(" ").toLowerCase();
817
- return (typeof val === "string" ? val : String(val ?? "")).toLowerCase();
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);
818
873
  }
819
- return Object.entries(record)
820
- .filter(([k]) => !["id", "createdAt", "updatedAt", "reportedAt", "reportPath"].includes(k))
821
- .map(([, v]) => (Array.isArray(v) ? v.join(" ") : String(v ?? "")))
822
- .filter(Boolean)
823
- .join("\n")
824
- .toLowerCase();
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) ?? []));
825
943
  }
826
944
 
827
945
  export function searchCases(options: CaseSearchOptions = {}): {
828
946
  cases: CaseRecord[];
829
947
  total: number;
830
948
  } {
831
- const query = options.query?.trim().toLowerCase();
832
- const field = options.field;
833
- const tag = options.tag?.trim().toLowerCase();
949
+ const db = getDb();
834
950
  const limit = Math.max(1, Math.min(options.limit ?? 50, 200));
835
951
  const offset = Math.max(0, options.offset ?? 0);
836
952
 
837
- const STATUS_ORDER: CaseStatus[] = [
838
- "hypothesis",
839
- "investigating",
840
- "confirmed",
841
- "blocked",
842
- "killed",
843
- "reported",
844
- ];
845
-
846
- const filtered = readCasefile()
847
- .filter((r) => !options.status || r.status === options.status)
848
- .filter((r) => !options.confidence || r.confidence === options.confidence)
849
- .filter((r) => !options.severity || r.severity === options.severity)
850
- .filter((r) => !options.priority || r.priority === options.priority)
851
- .filter((r) => !tag || r.tags?.some((t) => t.toLowerCase() === tag))
852
- .filter((r) => !query || caseHaystack(r, field).includes(query))
853
- .sort((a, b) => {
854
- const aStatus = STATUS_ORDER.indexOf(a.status);
855
- const bStatus = STATUS_ORDER.indexOf(b.status);
856
- if (aStatus !== bStatus) return aStatus - bStatus;
857
- return b.updatedAt.localeCompare(a.updatedAt);
858
- });
953
+ const { whereSql, orderSql, params } = buildCaseWhere(options);
859
954
 
860
- return {
861
- total: filtered.length,
862
- cases: filtered.slice(offset, offset + limit),
863
- };
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) };
864
961
  }
865
962
 
866
963
  export function countCases(): {
@@ -868,14 +965,22 @@ export function countCases(): {
868
965
  byStatus: Record<string, number>;
869
966
  bySeverity: Record<string, number>;
870
967
  } {
871
- 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
+
872
979
  const byStatus: Record<string, number> = {};
873
980
  const bySeverity: Record<string, number> = {};
874
- for (const r of records) {
875
- byStatus[r.status] = (byStatus[r.status] ?? 0) + 1;
876
- if (r.severity) bySeverity[r.severity] = (bySeverity[r.severity] ?? 0) + 1;
877
- }
878
- return { total: records.length, byStatus, bySeverity };
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 };
879
984
  }
880
985
 
881
986
  // ── Format helpers ───────────────────────────────────────────────────
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,
@@ -8,8 +8,15 @@ try {
8
8
  // biome-ignore lint/suspicious/noExplicitAny: Runtime module swappability
9
9
  DatabaseSyncConstructor = _require("bun:sqlite").Database as any;
10
10
  } catch {
11
- // biome-ignore lint/suspicious/noExplicitAny: Runtime module swappability
12
- DatabaseSyncConstructor = (_require("node:sqlite") as any).DatabaseSync;
11
+ try {
12
+ // biome-ignore lint/suspicious/noExplicitAny: Runtime module swappability
13
+ DatabaseSyncConstructor = (_require("node:sqlite") as any).DatabaseSync;
14
+ } catch (e) {
15
+ throw new Error(
16
+ "XPI requires bun:sqlite (Bun runtime) or node:sqlite (Node >= 22.5). " +
17
+ `Neither SQLite backend is available: ${(e as Error).message}`,
18
+ );
19
+ }
13
20
  }
14
21
 
15
22
  // biome-ignore lint/suspicious/noExplicitAny: Standard SQLite API returns any