@akasecurity/ai-tc-claude-code 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -494,9 +494,13 @@ var require_ignore = __commonJS({
494
494
  // src/backfill.ts
495
495
  import { fileURLToPath } from "url";
496
496
 
497
+ // ../../packages/plugin-sdk/src/config.ts
498
+ import { existsSync as existsSync3 } from "fs";
499
+ import { join as join6 } from "path";
500
+
497
501
  // ../../packages/persistence/src/database.ts
498
502
  import { randomUUID as randomUUID8 } from "crypto";
499
- import { existsSync, renameSync, rmSync } from "fs";
503
+ import { existsSync, renameSync as renameSync2, rmSync as rmSync2 } from "fs";
500
504
  import { join, sep } from "path";
501
505
  import { DatabaseSync } from "node:sqlite";
502
506
 
@@ -549,6 +553,18 @@ var SQLITE_MIGRATIONS = [
549
553
  {
550
554
  tag: "0011_egress_writer",
551
555
  sql: '-- Stable per-project reconcile key for egress call sites, plus a host-keyed\n-- egress decision override that survives destination pruning.\n--\n-- DROP INDEX IF EXISTS, not a bare DROP: the applier replays a pending\n-- migration\'s non-index statements verbatim, so a store that lost\n-- uq_share_call_site out of band would throw here \u2014 and on the plugin hook path\n-- that throw is swallowed fail-open, silently stopping capture.\n--\n-- Existing rows carry the old key\'s discriminator into project_key\n-- (\'legacy:\' || project), so the new unique index is a re-encoding of the old\n-- one and cannot collide on rows the old one allowed. Leaving them on the\n-- column default would collapse two projects\' identical file/line hits onto\n-- one key and abort the whole migration. Writer keys are \'git:\'/\'path:\'-\n-- prefixed, so a backfilled row never collides with a captured one either.\n--\n-- egress_decision_override.destination_id becomes nullable with ON DELETE SET\n-- NULL, which SQLite can only do by rebuilding the table. `host` is ADDed\n-- before the rebuild so the copy has a column to read and so the migration\n-- still presents two probeable columns to the applier\'s evidence check.\nALTER TABLE `share_call_site` ADD `project_key` text DEFAULT \'\' NOT NULL;--> statement-breakpoint\nUPDATE `share_call_site` SET `project_key` = \'legacy:\' || `project`;--> statement-breakpoint\nDROP INDEX IF EXISTS `uq_share_call_site`;--> statement-breakpoint\nCREATE UNIQUE INDEX `uq_share_call_site` ON `share_call_site` (`endpoint_id`,`project_key`,`file`,`line`);--> statement-breakpoint\nCREATE INDEX `idx_share_call_site_project` ON `share_call_site` (`project_key`,`endpoint_id`);--> statement-breakpoint\nALTER TABLE `egress_decision_override` ADD `host` text;--> statement-breakpoint\nPRAGMA foreign_keys=OFF;--> statement-breakpoint\nCREATE TABLE `__new_egress_decision_override` (\n `id` text PRIMARY KEY NOT NULL,\n `destination_id` text,\n `host` text,\n `decision` text NOT NULL,\n `created_at` integer NOT NULL,\n `updated_at` integer NOT NULL,\n FOREIGN KEY (`destination_id`) REFERENCES `share_destination`(`id`) ON UPDATE no action ON DELETE set null\n);\n--> statement-breakpoint\nINSERT INTO `__new_egress_decision_override`("id", "destination_id", "host", "decision", "created_at", "updated_at") SELECT "id", "destination_id", "host", "decision", "created_at", "updated_at" FROM `egress_decision_override`;--> statement-breakpoint\nDROP TABLE `egress_decision_override`;--> statement-breakpoint\nALTER TABLE `__new_egress_decision_override` RENAME TO `egress_decision_override`;--> statement-breakpoint\nPRAGMA foreign_keys=ON;--> statement-breakpoint\nCREATE UNIQUE INDEX `uq_egress_decision_override` ON `egress_decision_override` (`destination_id`);--> statement-breakpoint\nCREATE UNIQUE INDEX `uq_egress_decision_override_host` ON `egress_decision_override` (`host`) WHERE `host` IS NOT NULL;\n'
556
+ },
557
+ {
558
+ tag: "0012_handy_the_captain",
559
+ sql: "ALTER TABLE `inspection_findings` ADD `finding_key` text;--> statement-breakpoint\nALTER TABLE `inspection_findings` ADD `first_detected_at` integer;--> statement-breakpoint\nCREATE UNIQUE INDEX `uq_inspection_findings_key` ON `inspection_findings` (`finding_key`);"
560
+ },
561
+ {
562
+ tag: "0013_legacy_history_backfill_support",
563
+ sql: "-- Legacy-to-generalized backfill support: the schema the resumable\n-- events/findings -> audit_events/inspection_findings copy needs (the row\n-- copy itself runs as a batched post-migration installer, not here \u2014 see\n-- @akasecurity/persistence's migrations.ts), plus the audit_events read-path\n-- indexes replacing the ones the legacy events table carried (0009/0010).\n\n-- Tracks how far the batched, resumable copy has advanced through each legacy\n-- table, by rowid, so a copy interrupted mid-run resumes instead of\n-- restarting, and a completed copy is a cheap no-op on every later open.\nCREATE TABLE `legacy_copy_watermark` (\n `source` text PRIMARY KEY NOT NULL,\n `last_rowid` integer DEFAULT 0 NOT NULL\n);\n--> statement-breakpoint\n-- Serves the time-range read family that scans a single event_type across a\n-- start/end window (e.g. the Activity timeline) without a full-table scan.\nCREATE INDEX `idx_audit_type_t` ON `audit_events` (`event_type`,`started_at`);\n--> statement-breakpoint\n-- Partial expression index mirroring `idx_events_code_change_path` (0009) for\n-- the generalized audit_events/attributes pair: file-path reads filter\n-- `event_type = 'code_change' AND json_extract(attributes, '$.file_path') = :path`.\nCREATE INDEX `idx_audit_code_change_path` ON `audit_events` (json_extract(`attributes`, '$.file_path')) WHERE `event_type` = 'code_change';\n--> statement-breakpoint\n-- Synthesizes a stub session root (event_type = 'session', no attributes) for\n-- every legacy `events.metadata.sessionId` that has no audit_events row yet.\n-- audit_events.root_session_id is a self-FK, enforced with foreign-key\n-- checking on, and INSERT OR IGNORE does not suppress a foreign-key violation\n-- (only UNIQUE/PK/NOT NULL/CHECK) \u2014 so this must run before the events copy\n-- resolves root_session_id, or every session-scoped row's insert fails.\n-- started_at takes the earliest legacy occurred_at recorded under that\n-- session, so the stub root's own timeline position is never later than\n-- anything it will end up parenting.\nINSERT INTO audit_events (id, event_type, root_session_id, started_at)\nSELECT\n json_extract(metadata, '$.sessionId'),\n 'session',\n NULL,\n min(occurred_at)\nFROM events\nWHERE json_valid(metadata)\n AND json_extract(metadata, '$.sessionId') IS NOT NULL\n AND json_extract(metadata, '$.sessionId') NOT IN (SELECT id FROM audit_events)\nGROUP BY json_extract(metadata, '$.sessionId');\n"
564
+ },
565
+ {
566
+ tag: "0014_drop_legacy_events_findings",
567
+ sql: "-- Custom migration: drops the frozen legacy `events`/`findings` tables\n-- (superseded by audit_events/inspection_definitions/inspection_findings) and\n-- replaces them with read-only views of the same name.\n--\n-- persistence's migrations.ts applies this migration ONLY once the batched\n-- history backfill (see runLegacyHistoryBackfill) has fully drained both\n-- tables, and only after copying the live file aside \u2014 see\n-- backupBeforeLegacyDrop in packages/persistence/src/migrations.ts. A store\n-- still mid-copy keeps its real tables and this migration stays pending.\n--\n-- The views exist for skew: the `aka` CLI and the Claude Code plugin update\n-- independently against one shared store, so an older, already-installed\n-- binary can open a store a newer binary already dropped these tables on.\n-- Every already-shipped repository constructor prepares its SQL eagerly at\n-- open time, so a bare \"table not found\" would fail the WHOLE open, not just\n-- a findings-specific read \u2014 these views keep `prepare()` succeeding so an\n-- old binary's unrelated features keep working; its own reads stay truthful,\n-- and its rare (already fail-open) writes fail at run time instead.\n--\n-- The 0009/0010 expression indexes existed only over the legacy `events`\n-- table; DROP TABLE below would remove them implicitly, but they are\n-- dropped explicitly first so the intent reads clearly. IF EXISTS so a store\n-- that reached here with an index missing out of band (an adopted-tag store\n-- whose physical index was never built) does not throw a deterministic \"no\n-- such index\" out of the fail-open drop path.\nDROP INDEX IF EXISTS `idx_events_code_change_path`;\n--> statement-breakpoint\nDROP INDEX IF EXISTS `idx_events_session_id`;\n--> statement-breakpoint\n-- `findings.event_id` references `events.id` \u2014 drop the child first.\nDROP TABLE `findings`;\n--> statement-breakpoint\nDROP TABLE `events`;\n--> statement-breakpoint\n-- Legacy `events` shape, projected from audit_events: `kind`/`occurred_at`/\n-- `source_tool` become real columns again (source_tool round-trips through\n-- the attributes bag, the only place a capture-typed audit row keeps it);\n-- `metadata` is reconstructed as the old camelCase JSON object from the new\n-- snake_case attributes bag, with `root_session_id` folded back in as\n-- `sessionId` (the one legacy metadata key that became a column, never an\n-- attribute, on the new table). Constrained to the four capture kinds so\n-- structural rows (session/run/tool_call/llm_call/source_lookup/config_scan)\n-- never leak into a legacy reader's result set \u2014 the old `events` table\n-- never held them either.\nCREATE VIEW `events` AS\nSELECT\n id,\n json_extract(attributes, '$.source_tool') AS source_tool,\n event_type AS kind,\n started_at AS occurred_at,\n content_hash,\n content,\n -- Plugin-local bookkeeping column that `ensureSyncedAtColumn` adds to the\n -- real `events` table at open time. A pre-cutover binary runs that probe on\n -- EVERY open; without this projection its `columnNames('events')` check\n -- misses `synced_at` and issues `ALTER TABLE events ADD COLUMN` against this\n -- view, which SQLite rejects (\"Cannot add a column to a view\") \u2014 a hard,\n -- non-fail-open crash of the whole open, the exact skew failure these views\n -- exist to prevent. Projecting it (always NULL; no reader consumes it)\n -- short-circuits that ALTER.\n NULL AS synced_at,\n json_object(\n 'sessionId', root_session_id,\n 'repo', json_extract(attributes, '$.repo'),\n 'filePath', json_extract(attributes, '$.file_path'),\n 'toolName', json_extract(attributes, '$.tool_name'),\n 'gitignored', json_extract(attributes, '$.gitignored'),\n 'wholeFile', json_extract(attributes, '$.whole_file'),\n 'model', json_extract(attributes, '$.model'),\n 'turnIndex', json_extract(attributes, '$.turn_index'),\n 'correlationId', json_extract(attributes, '$.correlation_id'),\n 'traceId', json_extract(attributes, '$.trace_id'),\n 'exceptionIds', json_extract(attributes, '$.exception_ids')\n ) AS metadata\nFROM audit_events\nWHERE event_type IN ('prompt', 'response', 'code_change', 'tool_use');\n--> statement-breakpoint\n-- A plain single-row INSERT (the old writer's shape \u2014 no ON CONFLICT) is\n-- accepted by prepare() against a view once an INSTEAD OF INSERT trigger\n-- exists, so it fails at run time instead of at open \u2014 landing in the same\n-- fail-open path the caller already wraps its write in.\nCREATE TRIGGER `trg_events_ro`\nINSTEAD OF INSERT ON `events`\nBEGIN SELECT RAISE(ABORT, 'events is read-only; write to audit_events instead'); END;\n--> statement-breakpoint\n-- Legacy `findings` shape: rule_id/category/severity come from the joined\n-- inspection_definitions row (the legacy table inlined them per-row; the\n-- generalized schema normalizes them out into the shared definition). A view\n-- has no rowid of its own, so the underlying table's rowid is re-exposed\n-- explicitly under that name \u2014 a legacy reader ordering by `f.rowid` would\n-- otherwise fail to resolve the column at all. Joined to audit_events for the\n-- same four-capture-kind constraint as the events view above (a finding\n-- attached to a tool_call/config_scan row never existed in the legacy table\n-- either \u2014 see the persistence findings repository's identical predicate).\nCREATE VIEW `findings` AS\nSELECT\n f.id AS id,\n f.rowid AS rowid,\n f.audit_event_id AS event_id,\n d.rule_id AS rule_id,\n d.category AS category,\n d.severity AS severity,\n f.span_start AS span_start,\n f.span_end AS span_end,\n f.masked_match AS masked_match,\n f.action_taken AS action_taken,\n f.confidence AS confidence,\n f.finding_key AS finding_key,\n f.first_detected_at AS first_detected_at\nFROM inspection_findings f\nJOIN audit_events e ON e.id = f.audit_event_id\nJOIN inspection_definitions d ON d.id = f.inspection_definition_id\nWHERE e.event_type IN ('prompt', 'response', 'code_change', 'tool_use');\n--> statement-breakpoint\n-- Defense in depth only: SQLite refuses to plan ANY upsert against a view\n-- (\"cannot UPSERT a view\") no matter what trigger exists, so this can never\n-- rescue the real legacy writer, which always used\n-- `ON CONFLICT (finding_key) DO UPDATE` \u2014 that statement still fails at\n-- prepare() on an old binary, same as it would with no trigger at all. This\n-- only covers a plain-INSERT shape, should one ever exist.\nCREATE TRIGGER `trg_findings_ro`\nINSTEAD OF INSERT ON `findings`\nBEGIN SELECT RAISE(ABORT, 'findings is read-only; write to inspection_findings instead'); END;\n"
552
568
  }
553
569
  ];
554
570
 
@@ -15379,7 +15395,12 @@ var FindingFacets = external_exports.object({
15379
15395
  severity: external_exports.array(FindingFacetItem),
15380
15396
  subtype: external_exports.array(FindingFacetItem),
15381
15397
  provider: external_exports.array(FindingFacetItem),
15382
- action: external_exports.array(FindingFacetItem)
15398
+ action: external_exports.array(FindingFacetItem),
15399
+ // Counts by the group's derived status. The SQLite store derives a status
15400
+ // for every instance, so every group lands in a bucket; a status-less
15401
+ // group (possible only for callers whose rows carry no statuses) is
15402
+ // counted under no value.
15403
+ status: external_exports.array(FindingFacetItem)
15383
15404
  }).meta({ id: "FindingFacets" });
15384
15405
  var DEFAULT_GROUPED_FINDINGS_LIMIT = 50;
15385
15406
  var ListGroupedFindingsQuery = external_exports.object({
@@ -15389,6 +15410,10 @@ var ListGroupedFindingsQuery = external_exports.object({
15389
15410
  subtype: external_exports.array(external_exports.string()).optional(),
15390
15411
  provider: external_exports.array(FindingProvider).optional(),
15391
15412
  action: external_exports.array(FindingAction).optional(),
15413
+ // Matches a group's DERIVED status (see FindingGroup.status), not its
15414
+ // individual instances' — so a filtered group's Status column always reads
15415
+ // one of the requested values.
15416
+ status: external_exports.array(FindingStatus).optional(),
15392
15417
  q: external_exports.string().optional(),
15393
15418
  // Scope to findings whose event carries this session id (the Activity page's
15394
15419
  // session → findings drilldown). Findings without a session never match.
@@ -15579,6 +15604,33 @@ var ToolCallAttributes = external_exports.object({
15579
15604
  parent_uuid: external_exports.string().optional(),
15580
15605
  run_key: external_exports.string().optional()
15581
15606
  }).catchall(external_exports.unknown());
15607
+ var CaptureAttributes = external_exports.object({
15608
+ // The harness/tool that produced the capture (`claude-code`, `cli`, …). A
15609
+ // column on the legacy `events` table; here it rides the bag because a
15610
+ // capture-typed audit row has no equivalent column of its own.
15611
+ source_tool: external_exports.string().optional(),
15612
+ file_path: external_exports.string().optional(),
15613
+ repo: external_exports.string().optional(),
15614
+ // The host tool whose input/output was scanned (e.g. 'Bash', 'WebFetch') —
15615
+ // gives a non-file capture a display location ("via Bash") when file_path
15616
+ // is absent. The tool NAME only, never its arguments/output.
15617
+ tool_name: external_exports.string().optional(),
15618
+ // Presence-only provenance flag: set when the file is excluded by the
15619
+ // repo's .gitignore. Omitted (not false) for tracked files.
15620
+ gitignored: external_exports.boolean().optional(),
15621
+ // Set ONLY when the capture is a COMPLETE file snapshot (a worktree scan
15622
+ // reading from disk), never a partial fragment (a hook-captured edit).
15623
+ whole_file: external_exports.boolean().optional(),
15624
+ // Distributed-tracing correlation: `correlation_id` ties the capture back to
15625
+ // the request that produced it; `trace_id` is the originating span's W3C
15626
+ // trace id when telemetry is enabled.
15627
+ correlation_id: external_exports.uuid().optional(),
15628
+ trace_id: external_exports.string().regex(/^[0-9a-f]{32}$/).optional(),
15629
+ // Ids of the detection exceptions that downgraded findings in this capture
15630
+ // to 'allow' — the enforcement audit trail's link back to the grant that
15631
+ // authorized the bypass.
15632
+ exception_ids: external_exports.array(external_exports.guid()).optional()
15633
+ }).catchall(external_exports.unknown());
15582
15634
  var ToolCallInspection = external_exports.object({
15583
15635
  ruleId: external_exports.string().min(1),
15584
15636
  ruleName: external_exports.string(),
@@ -15665,7 +15717,18 @@ var InspectionFindingInput = external_exports.object({
15665
15717
  span: Span,
15666
15718
  maskedMatch: external_exports.string(),
15667
15719
  actionTaken: ActionTaken,
15668
- confidence: external_exports.number().min(0).max(1)
15720
+ confidence: external_exports.number().min(0).max(1),
15721
+ // Stable, content-addressed key correlating this finding across re-detections
15722
+ // — mirrors the legacy `findings.finding_key` (uq_inspection_findings_key is
15723
+ // its unique index). Optional: only an at-rest/re-scannable finding carries
15724
+ // one; an in-flight capture (prompt/response) has nothing to re-detect
15725
+ // against and leaves it unset, so every insert is a fresh row.
15726
+ findingKey: external_exports.string().optional(),
15727
+ // The ORIGINAL detection time, preserved across a later re-detection of the
15728
+ // same findingKey — mirrors the legacy `findings.first_detected_at`.
15729
+ // Optional: when omitted, the writer derives it from the referenced audit
15730
+ // event's startedAt on first insert (see SqliteInspectionFindingsRepository).
15731
+ firstDetectedAt: external_exports.iso.datetime().optional()
15669
15732
  });
15670
15733
  var InventoryContext = external_exports.object({
15671
15734
  host: InventoryInput.optional(),
@@ -15867,6 +15930,7 @@ var ActivityOverviewResponse = external_exports.object({
15867
15930
 
15868
15931
  // ../../packages/schema/src/zod/event.ts
15869
15932
  var EventKind = external_exports.enum(["prompt", "response", "code_change", "tool_use"]).meta({ id: "EventKind" });
15933
+ var CAPTURE_EVENT_TYPES_SQL = EventKind.options.map((k) => `'${k}'`).join(",");
15870
15934
  var SourceTool = external_exports.enum(["claude-code", "claude-desktop", "cursor", "chatgpt", "github-copilot", "cli", "unknown"]).meta({ id: "SourceTool" });
15871
15935
  var EventMetadata = external_exports.object({
15872
15936
  sessionId: external_exports.string().optional(),
@@ -16356,6 +16420,12 @@ var PolicyBundle = external_exports.object({
16356
16420
  // on-disk caches — that omit the field still parse; consumers read
16357
16421
  // `bundle.exceptions ?? []`.
16358
16422
  exceptions: external_exports.array(ExceptionBundleEntry).optional(),
16423
+ // Installed pack version, keyed by ruleId, for rules in `rules` that came
16424
+ // from a versioned installed pack. Optional so older backends — and older
16425
+ // on-disk caches — that omit the field still parse; consumers fall back to
16426
+ // the rule's own spec version. NOT the bundle version above — see
16427
+ // installedRuleset's ruleVersions for the source of truth.
16428
+ ruleVersions: external_exports.record(external_exports.string(), external_exports.string()).optional(),
16359
16429
  customKeywords: external_exports.array(external_exports.string()),
16360
16430
  fetchedAt: external_exports.iso.datetime()
16361
16431
  }).meta({ id: "PolicyBundle" });
@@ -17155,6 +17225,15 @@ function groupActions(g) {
17155
17225
  actionsCache.set(g, actions);
17156
17226
  return actions;
17157
17227
  }
17228
+ function countInstancesByStatus(statusInputs, statuses) {
17229
+ const statusSet = new Set(statuses);
17230
+ let sum = 0;
17231
+ for (const input of statusInputs) {
17232
+ if (input.count === void 0) return null;
17233
+ if (statusSet.has(deriveFindingStatus(input))) sum += input.count;
17234
+ }
17235
+ return sum;
17236
+ }
17158
17237
  function applyFindingFilters(groups, opts) {
17159
17238
  let filtered = groups;
17160
17239
  if (opts.severity && opts.severity.length > 0) {
@@ -17173,6 +17252,10 @@ function applyFindingFilters(groups, opts) {
17173
17252
  const subtypeSet = new Set(opts.subtype);
17174
17253
  filtered = filtered.filter((g) => subtypeSet.has(g.subtype));
17175
17254
  }
17255
+ if (opts.statuses && opts.statuses.length > 0) {
17256
+ const statusSet = new Set(opts.statuses);
17257
+ filtered = filtered.filter((g) => g.status !== void 0 && statusSet.has(g.status));
17258
+ }
17176
17259
  if (opts.q) {
17177
17260
  const q = opts.q.toLowerCase();
17178
17261
  filtered = filtered.filter((g) => groupHaystack(g).includes(q));
@@ -17194,6 +17277,7 @@ function computeFindingFacets(allGroups, opts) {
17194
17277
  const forSeverity = applyFindingFilters(allGroups, {
17195
17278
  providers: opts.providers,
17196
17279
  actions: opts.actions,
17280
+ statuses: opts.statuses,
17197
17281
  q: opts.q,
17198
17282
  subtype: opts.subtype
17199
17283
  });
@@ -17203,6 +17287,7 @@ function computeFindingFacets(allGroups, opts) {
17203
17287
  }
17204
17288
  const forProvider = applyFindingFilters(allGroups, {
17205
17289
  actions: opts.actions,
17290
+ statuses: opts.statuses,
17206
17291
  q: opts.q,
17207
17292
  subtype: opts.subtype,
17208
17293
  severity: opts.severity
@@ -17213,6 +17298,7 @@ function computeFindingFacets(allGroups, opts) {
17213
17298
  }
17214
17299
  const forAction = applyFindingFilters(allGroups, {
17215
17300
  providers: opts.providers,
17301
+ statuses: opts.statuses,
17216
17302
  q: opts.q,
17217
17303
  subtype: opts.subtype,
17218
17304
  severity: opts.severity
@@ -17224,17 +17310,30 @@ function computeFindingFacets(allGroups, opts) {
17224
17310
  const forSubtype = applyFindingFilters(allGroups, {
17225
17311
  providers: opts.providers,
17226
17312
  actions: opts.actions,
17313
+ statuses: opts.statuses,
17227
17314
  q: opts.q,
17228
17315
  severity: opts.severity
17229
17316
  });
17230
17317
  const subtypeMap = /* @__PURE__ */ new Map();
17231
17318
  for (const g of forSubtype) subtypeMap.set(g.subtype, (subtypeMap.get(g.subtype) ?? 0) + 1);
17319
+ const forStatus = applyFindingFilters(allGroups, {
17320
+ providers: opts.providers,
17321
+ actions: opts.actions,
17322
+ q: opts.q,
17323
+ subtype: opts.subtype,
17324
+ severity: opts.severity
17325
+ });
17326
+ const statusMap = /* @__PURE__ */ new Map();
17327
+ for (const g of forStatus) {
17328
+ if (g.status !== void 0) statusMap.set(g.status, (statusMap.get(g.status) ?? 0) + 1);
17329
+ }
17232
17330
  const toItems = (m) => [...m.entries()].map(([value, count]) => ({ value, count }));
17233
17331
  return {
17234
17332
  severity: toItems(severityMap),
17235
17333
  provider: toItems(providerMap),
17236
17334
  action: toItems(actionMap),
17237
- subtype: toItems(subtypeMap)
17335
+ subtype: toItems(subtypeMap),
17336
+ status: toItems(statusMap)
17238
17337
  };
17239
17338
  }
17240
17339
 
@@ -17269,10 +17368,14 @@ var PatchInstalledPackRequest = external_exports.object({
17269
17368
  }).meta({ id: "PatchInstalledPackRequest" });
17270
17369
 
17271
17370
  // ../../packages/schema/src/zod/local.ts
17272
- var WORKSPACE_SETTINGS_SPEC_VERSION = 3;
17371
+ var WORKSPACE_SETTINGS_SPEC_VERSION = 4;
17273
17372
  var RunMode = external_exports.enum(["standalone"]);
17274
17373
  var SimpleDetectionPolicy = external_exports.enum(["redact", "warn"]);
17275
17374
  var HistoricalAccess = external_exports.enum(["full", "session-only"]);
17375
+ var ModelJudgeConsent = external_exports.object({
17376
+ acknowledgedAt: external_exports.iso.datetime(),
17377
+ payloadVersion: external_exports.number().int().positive()
17378
+ });
17276
17379
  var WorkspaceSettings = external_exports.object({
17277
17380
  specVersion: external_exports.number().int().positive().default(WORKSPACE_SETTINGS_SPEC_VERSION),
17278
17381
  // Settings files written by earlier releases may carry the retired 'attached'
@@ -17288,37 +17391,16 @@ var WorkspaceSettings = external_exports.object({
17288
17391
  // Shares writes.
17289
17392
  dataSharesInPlace: external_exports.boolean().default(true),
17290
17393
  // Absent until /aka:setup completes; its presence is what "onboarded" means.
17291
- onboardedAt: external_exports.iso.datetime().optional()
17394
+ onboardedAt: external_exports.iso.datetime().optional(),
17395
+ // Records that the user consented to sending findings to the model API for
17396
+ // the /aka:setup judge, along with the payload-shape version they agreed to.
17397
+ // Absent until granted; a stale payloadVersion means the consent no longer
17398
+ // covers the current payload and must be re-granted.
17399
+ modelJudgeConsent: ModelJudgeConsent.optional()
17292
17400
  });
17293
17401
  function defaultWorkspaceSettings() {
17294
17402
  return WorkspaceSettings.parse({});
17295
17403
  }
17296
- function toEventRow(event) {
17297
- return {
17298
- id: event.id,
17299
- sourceTool: event.sourceTool,
17300
- kind: event.kind,
17301
- occurredAt: isoToEpochMillis(event.occurredAt),
17302
- contentHash: event.contentHash,
17303
- content: event.content,
17304
- metadata: event.metadata ? JSON.stringify(event.metadata) : null
17305
- };
17306
- }
17307
- function toFindingRow(finding) {
17308
- return {
17309
- id: finding.id,
17310
- eventId: finding.eventId,
17311
- ruleId: finding.ruleId,
17312
- category: finding.category,
17313
- severity: finding.severity,
17314
- spanStart: finding.span.start,
17315
- spanEnd: finding.span.end,
17316
- maskedMatch: finding.maskedMatch,
17317
- actionTaken: finding.actionTaken,
17318
- confidence: finding.confidence,
17319
- findingKey: finding.findingKey ?? null
17320
- };
17321
- }
17322
17404
  function toInventoryRow(input, id, now) {
17323
17405
  return {
17324
17406
  id,
@@ -17388,7 +17470,42 @@ function toInspectionFindingRow(input) {
17388
17470
  spanEnd: input.span.end,
17389
17471
  maskedMatch: input.maskedMatch,
17390
17472
  actionTaken: input.actionTaken,
17391
- confidence: input.confidence
17473
+ confidence: input.confidence,
17474
+ findingKey: input.findingKey ?? null,
17475
+ firstDetectedAt: input.firstDetectedAt ? isoToEpochMillis(input.firstDetectedAt) : null
17476
+ };
17477
+ }
17478
+ function toCaptureAttributes(event) {
17479
+ const metadata = event.metadata;
17480
+ return {
17481
+ source_tool: event.sourceTool,
17482
+ ...metadata?.repo !== void 0 ? { repo: metadata.repo } : {},
17483
+ ...metadata?.filePath !== void 0 ? { file_path: metadata.filePath } : {},
17484
+ ...metadata?.toolName !== void 0 ? { tool_name: metadata.toolName } : {},
17485
+ ...metadata?.gitignored !== void 0 ? { gitignored: metadata.gitignored } : {},
17486
+ ...metadata?.wholeFile !== void 0 ? { whole_file: metadata.wholeFile } : {},
17487
+ ...metadata?.correlationId !== void 0 ? { correlation_id: metadata.correlationId } : {},
17488
+ ...metadata?.traceId !== void 0 ? { trace_id: metadata.traceId } : {},
17489
+ ...metadata?.exceptionIds !== void 0 ? { exception_ids: metadata.exceptionIds } : {},
17490
+ // `model`/`turnIndex` have no dedicated CaptureAttributes field (no writer
17491
+ // has ever populated either), but every legacy metadata key still rides
17492
+ // the bag rather than being silently dropped — CaptureAttributes'
17493
+ // `.catchall(z.unknown())` carries the long tail.
17494
+ ...metadata?.model !== void 0 ? { model: metadata.model } : {},
17495
+ ...metadata?.turnIndex !== void 0 ? { turn_index: metadata.turnIndex } : {}
17496
+ };
17497
+ }
17498
+ function captureDefinitionVersion(finding) {
17499
+ return `capture/${finding.category}/${finding.severity}`;
17500
+ }
17501
+ function toCaptureDefinitionInput(finding) {
17502
+ return {
17503
+ ruleId: finding.ruleId,
17504
+ version: captureDefinitionVersion(finding),
17505
+ name: finding.ruleId,
17506
+ category: finding.category,
17507
+ severity: finding.severity,
17508
+ definition: JSON.stringify({ ruleId: finding.ruleId })
17392
17509
  };
17393
17510
  }
17394
17511
 
@@ -17816,6 +17933,48 @@ function reviewSeverityRank(reasons) {
17816
17933
  return Math.min(...reasons.map((r) => REVIEW_SEVERITY_RANK[r]));
17817
17934
  }
17818
17935
 
17936
+ // ../../packages/persistence/src/ids.ts
17937
+ import { createHash } from "crypto";
17938
+ function sha256Hex(input) {
17939
+ return createHash("sha256").update(input).digest("hex");
17940
+ }
17941
+ function inventoryId(objectType, identityKey) {
17942
+ return sha256Hex(canonicalIdentity(["inventory", objectType, identityKey]));
17943
+ }
17944
+ function sourceProjectId(url2) {
17945
+ return sha256Hex(canonicalIdentity(["source_project", url2]));
17946
+ }
17947
+ function classifiedDataId(cls) {
17948
+ return sha256Hex(canonicalIdentity(["classified_data", cls]));
17949
+ }
17950
+ function inspectionDefinitionId(ruleId, version2) {
17951
+ return sha256Hex(canonicalIdentity(["inspection_definition", ruleId, version2]));
17952
+ }
17953
+ function llmCallId(sessionId, messageId) {
17954
+ return sha256Hex(canonicalIdentity(["audit_event_llm_call", sessionId, messageId]));
17955
+ }
17956
+ function toolCallId(sessionId, toolUseId) {
17957
+ return sha256Hex(canonicalIdentity(["audit_event_tool_call", sessionId, toolUseId]));
17958
+ }
17959
+ function inspectionFindingId(auditEventId, ruleId, spanStart, spanEnd) {
17960
+ return sha256Hex(
17961
+ canonicalIdentity([
17962
+ "inspection_finding",
17963
+ auditEventId,
17964
+ ruleId,
17965
+ String(spanStart),
17966
+ String(spanEnd)
17967
+ ])
17968
+ );
17969
+ }
17970
+ var NO_SESSION = "no_session";
17971
+ var NO_PATH = "no_path";
17972
+ function captureId(sessionId, contentHash, filePath = null) {
17973
+ return sha256Hex(
17974
+ canonicalIdentity(["capture", sessionId ?? NO_SESSION, contentHash, filePath ?? NO_PATH])
17975
+ );
17976
+ }
17977
+
17819
17978
  // ../../packages/persistence/src/internal/sql-text.ts
17820
17979
  function escapeLikePattern(s) {
17821
17980
  return s.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
@@ -17912,39 +18071,98 @@ function evidenceExists(db, object2) {
17912
18071
  return schemaObjectExists(db, "table", object2.name);
17913
18072
  }
17914
18073
 
17915
- // ../../packages/persistence/src/ids.ts
17916
- import { createHash } from "crypto";
17917
- function sha256Hex(input) {
17918
- return createHash("sha256").update(input).digest("hex");
18074
+ // ../../packages/persistence/src/internal/rows.ts
18075
+ function allRows(stmt, params) {
18076
+ if (params === void 0) return stmt.all();
18077
+ if (Array.isArray(params)) return stmt.all(...params);
18078
+ return stmt.all(params);
17919
18079
  }
17920
- function inventoryId(objectType, identityKey) {
17921
- return sha256Hex(canonicalIdentity(["inventory", objectType, identityKey]));
18080
+ function getRow(stmt, params) {
18081
+ if (params === void 0) return stmt.get();
18082
+ if (Array.isArray(params)) return stmt.get(...params);
18083
+ return stmt.get(params);
17922
18084
  }
17923
- function sourceProjectId(url2) {
17924
- return sha256Hex(canonicalIdentity(["source_project", url2]));
18085
+ function intToBool(raw) {
18086
+ return raw === 1 || raw === true;
17925
18087
  }
17926
- function classifiedDataId(cls) {
17927
- return sha256Hex(canonicalIdentity(["classified_data", cls]));
18088
+ function boolToInt(b) {
18089
+ return b ? 1 : 0;
17928
18090
  }
17929
- function inspectionDefinitionId(ruleId, version2) {
17930
- return sha256Hex(canonicalIdentity(["inspection_definition", ruleId, version2]));
18091
+ function bindParams(row) {
18092
+ const out = {};
18093
+ for (const [key, value] of Object.entries(row)) {
18094
+ out[key] = value === void 0 ? null : value;
18095
+ }
18096
+ return out;
17931
18097
  }
17932
- function llmCallId(sessionId, messageId) {
17933
- return sha256Hex(canonicalIdentity(["audit_event_llm_call", sessionId, messageId]));
18098
+ function countScalar(db, sql, params) {
18099
+ return getRow(db.prepare(sql), params)?.n ?? 0;
17934
18100
  }
17935
- function toolCallId(sessionId, toolUseId) {
17936
- return sha256Hex(canonicalIdentity(["audit_event_tool_call", sessionId, toolUseId]));
18101
+ function countBy(db, sql, params) {
18102
+ const map2 = /* @__PURE__ */ new Map();
18103
+ for (const row of allRows(db.prepare(sql), params)) {
18104
+ map2.set(row.k, row.n);
18105
+ }
18106
+ return map2;
17937
18107
  }
17938
- function inspectionFindingId(auditEventId, definitionId, spanStart, spanEnd) {
17939
- return sha256Hex(
17940
- canonicalIdentity([
17941
- "inspection_finding",
17942
- auditEventId,
17943
- definitionId,
17944
- String(spanStart),
17945
- String(spanEnd)
17946
- ])
17947
- );
18108
+ function mapRowsTolerant(rows, map2) {
18109
+ const out = [];
18110
+ for (const row of rows) {
18111
+ try {
18112
+ out.push(map2(row));
18113
+ } catch {
18114
+ }
18115
+ }
18116
+ return out;
18117
+ }
18118
+
18119
+ // ../../packages/persistence/src/paths.ts
18120
+ import { chmodSync, lstatSync, mkdirSync, renameSync, rmSync, writeFileSync } from "fs";
18121
+ var DATA_DIR_MODE = 448;
18122
+ var DATA_FILE_MODE = 384;
18123
+ var DB_FILENAME = "aka.db";
18124
+ function chmodBestEffort(path, mode) {
18125
+ try {
18126
+ chmodSync(path, mode);
18127
+ } catch {
18128
+ }
18129
+ }
18130
+ function tightenDir(dir) {
18131
+ chmodBestEffort(dir, DATA_DIR_MODE);
18132
+ }
18133
+ function ensureDataDirSync(dir) {
18134
+ mkdirSync(dir, { recursive: true, mode: DATA_DIR_MODE });
18135
+ tightenDir(dir);
18136
+ }
18137
+ function dbSidecars(file2) {
18138
+ return [`${file2}-wal`, `${file2}-shm`, `${file2}-journal`];
18139
+ }
18140
+ function tightenFile(file2) {
18141
+ try {
18142
+ if (lstatSync(file2).isSymbolicLink()) return;
18143
+ } catch {
18144
+ }
18145
+ chmodBestEffort(file2, DATA_FILE_MODE);
18146
+ }
18147
+ function tightenPerms(file2) {
18148
+ for (const path of [file2, ...dbSidecars(file2)]) chmodBestEffort(path, DATA_FILE_MODE);
18149
+ }
18150
+ function writeOwnerOnlyFileSync(file2, data) {
18151
+ const tmp = `${file2}.${String(process.pid)}.tmp`;
18152
+ try {
18153
+ rmSync(tmp, { force: true });
18154
+ } catch {
18155
+ }
18156
+ try {
18157
+ writeFileSync(tmp, data, { mode: DATA_FILE_MODE, flag: "wx" });
18158
+ renameSync(tmp, file2);
18159
+ } finally {
18160
+ try {
18161
+ rmSync(tmp, { force: true });
18162
+ } catch {
18163
+ }
18164
+ }
18165
+ tightenFile(file2);
17948
18166
  }
17949
18167
 
17950
18168
  // ../../packages/persistence/src/migrations.ts
@@ -17958,7 +18176,8 @@ function createdIndexName(statement) {
17958
18176
  const body = statement.replace(/^(?:\s*--[^\n]*\n?)+/, "").trimStart();
17959
18177
  return /^CREATE (?:UNIQUE )?INDEX (?:IF NOT EXISTS )?`([^`]+)`/.exec(body)?.[1];
17960
18178
  }
17961
- function applyMigrations(db) {
18179
+ var LEGACY_DROP_MIGRATION_TAG = "0014_drop_legacy_events_findings";
18180
+ function applyMigrations(db, file2) {
17962
18181
  const legacyCount = db.prepare("PRAGMA user_version").get().user_version;
17963
18182
  db.exec(
17964
18183
  "CREATE TABLE IF NOT EXISTS migration_ledger (tag TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)"
@@ -17972,6 +18191,7 @@ function applyMigrations(db) {
17972
18191
  );
17973
18192
  for (const [index, migration] of SQLITE_MIGRATIONS.entries()) {
17974
18193
  if (applied.has(migration.tag)) continue;
18194
+ if (migration.tag === LEGACY_DROP_MIGRATION_TAG) continue;
17975
18195
  const evidence = evidenceObjects(migration.sql);
17976
18196
  const present = evidence.filter((o) => evidenceExists(db, o));
17977
18197
  if (present.length > 0 && present.length < evidence.length) {
@@ -18016,7 +18236,6 @@ function applyMigrations(db) {
18016
18236
  if (legacyCount < SQLITE_MIGRATIONS.length) {
18017
18237
  db.exec(`PRAGMA user_version = ${String(SQLITE_MIGRATIONS.length)}`);
18018
18238
  }
18019
- ensureSyncedAtColumn(db, "events");
18020
18239
  ensureSyncedAtColumn(db, "audit_events");
18021
18240
  ensureScanLedgerTable(db);
18022
18241
  ensureBlockedDetectionsTable(db);
@@ -18024,6 +18243,47 @@ function applyMigrations(db) {
18024
18243
  ensureWriteGateTrigger(db);
18025
18244
  ensureTokenUsageColumns(db);
18026
18245
  reconcileSourceProjectIds(db);
18246
+ if (!applied.has(LEGACY_DROP_MIGRATION_TAG)) {
18247
+ const drained = runLegacyHistoryBackfill(db);
18248
+ if (drained) applyLegacyDropMigration(db, file2);
18249
+ }
18250
+ }
18251
+ function applyLegacyDropMigration(db, file2) {
18252
+ const migration = SQLITE_MIGRATIONS.find((m) => m.tag === LEGACY_DROP_MIGRATION_TAG);
18253
+ if (!migration) return;
18254
+ if (file2) {
18255
+ try {
18256
+ backupBeforeLegacyDrop(db, file2);
18257
+ } catch (error51) {
18258
+ akaWarn(`legacy events/findings backup failed; deferring the drop: ${String(error51)}`);
18259
+ return;
18260
+ }
18261
+ }
18262
+ try {
18263
+ withTransaction(
18264
+ db,
18265
+ () => {
18266
+ const alreadyDropped = db.prepare("SELECT 1 FROM migration_ledger WHERE tag = ?").get(migration.tag);
18267
+ if (alreadyDropped) return;
18268
+ for (const statement of splitStatements(migration.sql)) {
18269
+ db.exec(statement);
18270
+ }
18271
+ db.prepare("INSERT OR IGNORE INTO migration_ledger (tag, applied_at) VALUES (?, ?)").run(
18272
+ migration.tag,
18273
+ Date.now()
18274
+ );
18275
+ },
18276
+ "IMMEDIATE"
18277
+ );
18278
+ } catch (error51) {
18279
+ akaWarn(`legacy events/findings drop failed; deferring: ${String(error51)}`);
18280
+ }
18281
+ }
18282
+ function backupBeforeLegacyDrop(db, file2) {
18283
+ const backup = `${file2}.pre-drop.${String(Date.now())}.bak`;
18284
+ db.prepare("VACUUM INTO ?").run(backup);
18285
+ tightenFile(backup);
18286
+ return backup;
18027
18287
  }
18028
18288
  var TOKEN_USAGE_COLUMNS = [
18029
18289
  {
@@ -18052,6 +18312,7 @@ var TOKEN_USAGE_COLUMNS = [
18052
18312
  }
18053
18313
  ];
18054
18314
  function ensureTokenUsageColumns(db) {
18315
+ if (!schemaObjectExists(db, "table", "audit_events")) return;
18055
18316
  const existing = new Set(columnNames(db, "audit_events", { includeGenerated: true }));
18056
18317
  for (const column of TOKEN_USAGE_COLUMNS) {
18057
18318
  if (!existing.has(column.name)) {
@@ -18117,11 +18378,187 @@ function reconcileSourceProjectIds(db) {
18117
18378
  akaWarn(`source_project id reconcile failed: ${String(error51)}`);
18118
18379
  }
18119
18380
  }
18381
+ var LEGACY_BACKFILL_BATCH_SIZE = 200;
18382
+ var LEGACY_BACKFILL_MAX_ROWS_PER_CALL = 1e3;
18383
+ function getLegacyCopyWatermark(db, source) {
18384
+ const row = db.prepare("SELECT last_rowid AS lastRowid FROM legacy_copy_watermark WHERE source = ?").get(source);
18385
+ return row?.lastRowid ?? 0;
18386
+ }
18387
+ function setLegacyCopyWatermark(db, source, lastRowid) {
18388
+ db.prepare(
18389
+ `INSERT INTO legacy_copy_watermark (source, last_rowid) VALUES (?, ?)
18390
+ ON CONFLICT(source) DO UPDATE SET last_rowid = excluded.last_rowid`
18391
+ ).run(source, lastRowid);
18392
+ }
18393
+ function drainLegacyTable(db, source, selectStmt, handleRows) {
18394
+ let watermark = getLegacyCopyWatermark(db, source);
18395
+ let processed = 0;
18396
+ while (processed < LEGACY_BACKFILL_MAX_ROWS_PER_CALL) {
18397
+ const rows = selectStmt.all(watermark, LEGACY_BACKFILL_BATCH_SIZE);
18398
+ if (rows.length === 0) return true;
18399
+ withTransaction(
18400
+ db,
18401
+ () => {
18402
+ handleRows(rows);
18403
+ watermark = rows[rows.length - 1]?.rowid ?? watermark;
18404
+ setLegacyCopyWatermark(db, source, watermark);
18405
+ },
18406
+ "IMMEDIATE"
18407
+ );
18408
+ processed += rows.length;
18409
+ if (rows.length < LEGACY_BACKFILL_BATCH_SIZE) return true;
18410
+ }
18411
+ return false;
18412
+ }
18413
+ function parseLegacyEventMetadata(raw) {
18414
+ if (raw === null) return void 0;
18415
+ try {
18416
+ return JSON.parse(raw);
18417
+ } catch {
18418
+ return void 0;
18419
+ }
18420
+ }
18421
+ function toLegacyAuditAttributesJson(row) {
18422
+ return JSON.stringify(
18423
+ toCaptureAttributes({
18424
+ id: row.id,
18425
+ sourceTool: row.sourceTool,
18426
+ kind: row.kind,
18427
+ occurredAt: new Date(row.occurredAt).toISOString(),
18428
+ contentHash: row.contentHash,
18429
+ content: row.content,
18430
+ metadata: row.metadata
18431
+ })
18432
+ );
18433
+ }
18434
+ function copyLegacyEvents(db) {
18435
+ const selectStmt = db.prepare(
18436
+ `SELECT rowid AS rowid, id, source_tool AS sourceTool, kind, occurred_at AS occurredAt,
18437
+ content_hash AS contentHash, content, metadata
18438
+ FROM events WHERE rowid > ? ORDER BY rowid LIMIT ?`
18439
+ );
18440
+ const insertStmt = db.prepare(
18441
+ `INSERT OR IGNORE INTO audit_events
18442
+ (id, parent_id, root_session_id, event_type, started_at, content, content_hash, attributes)
18443
+ VALUES (:id, :parentId, :rootSessionId, :eventType, :startedAt, :content, :contentHash, :attributes)`
18444
+ );
18445
+ const stubRootStmt = db.prepare(
18446
+ `INSERT OR IGNORE INTO audit_events (id, event_type, started_at) VALUES (?, 'session', ?)`
18447
+ );
18448
+ return drainLegacyTable(
18449
+ db,
18450
+ "events",
18451
+ selectStmt,
18452
+ (rows) => {
18453
+ for (const row of rows) {
18454
+ const metadata = parseLegacyEventMetadata(row.metadata);
18455
+ const sessionId = metadata?.sessionId ?? null;
18456
+ if (sessionId !== null) stubRootStmt.run(sessionId, row.occurredAt);
18457
+ insertStmt.run(
18458
+ bindParams({
18459
+ id: row.id,
18460
+ parentId: sessionId,
18461
+ rootSessionId: sessionId,
18462
+ eventType: row.kind,
18463
+ startedAt: row.occurredAt,
18464
+ content: row.content,
18465
+ contentHash: row.contentHash,
18466
+ attributes: toLegacyAuditAttributesJson({ ...row, metadata })
18467
+ })
18468
+ );
18469
+ }
18470
+ }
18471
+ );
18472
+ }
18473
+ function copyLegacyFindings(db) {
18474
+ const selectStmt = db.prepare(
18475
+ `SELECT rowid AS rowid, id, event_id AS eventId, rule_id AS ruleId, category, severity,
18476
+ span_start AS spanStart, span_end AS spanEnd, masked_match AS maskedMatch,
18477
+ action_taken AS actionTaken, confidence, finding_key AS findingKey,
18478
+ first_detected_at AS firstDetectedAt
18479
+ FROM findings WHERE rowid > ? ORDER BY rowid LIMIT ?`
18480
+ );
18481
+ const definitionStmt = db.prepare(
18482
+ `INSERT OR IGNORE INTO inspection_definitions
18483
+ (id, rule_id, name, category, severity, definition, version)
18484
+ VALUES (:id, :ruleId, :name, :category, :severity, :definition, :version)`
18485
+ );
18486
+ const findingStmt = db.prepare(
18487
+ `INSERT INTO inspection_findings
18488
+ (id, audit_event_id, inspection_definition_id, classified_data_id,
18489
+ span_start, span_end, masked_match, action_taken, confidence,
18490
+ finding_key, first_detected_at)
18491
+ VALUES
18492
+ (:id, :auditEventId, :inspectionDefinitionId, NULL,
18493
+ :spanStart, :spanEnd, :maskedMatch, :actionTaken, :confidence,
18494
+ :findingKey, :firstDetectedAt)
18495
+ ON CONFLICT(id) DO NOTHING
18496
+ ON CONFLICT (finding_key) DO UPDATE SET
18497
+ first_detected_at = CASE
18498
+ WHEN first_detected_at IS NULL THEN excluded.first_detected_at
18499
+ WHEN excluded.first_detected_at IS NULL THEN first_detected_at
18500
+ ELSE min(first_detected_at, excluded.first_detected_at)
18501
+ END`
18502
+ );
18503
+ return drainLegacyTable(
18504
+ db,
18505
+ "findings",
18506
+ selectStmt,
18507
+ (rows) => {
18508
+ const definitionIds = /* @__PURE__ */ new Map();
18509
+ for (const row of rows) {
18510
+ const tupleKey = JSON.stringify([row.ruleId, row.category, row.severity]);
18511
+ let definitionId = definitionIds.get(tupleKey);
18512
+ if (definitionId === void 0) {
18513
+ const version2 = `unmigrated/${row.category}/${row.severity}`;
18514
+ definitionId = inspectionDefinitionId(row.ruleId, version2);
18515
+ definitionStmt.run(
18516
+ bindParams({
18517
+ id: definitionId,
18518
+ ruleId: row.ruleId,
18519
+ name: row.ruleId,
18520
+ category: row.category,
18521
+ severity: row.severity,
18522
+ definition: "",
18523
+ version: version2
18524
+ })
18525
+ );
18526
+ definitionIds.set(tupleKey, definitionId);
18527
+ }
18528
+ findingStmt.run(
18529
+ bindParams({
18530
+ id: row.id,
18531
+ auditEventId: row.eventId,
18532
+ inspectionDefinitionId: definitionId,
18533
+ spanStart: row.spanStart,
18534
+ spanEnd: row.spanEnd,
18535
+ maskedMatch: row.maskedMatch,
18536
+ actionTaken: row.actionTaken,
18537
+ confidence: row.confidence,
18538
+ findingKey: row.findingKey,
18539
+ firstDetectedAt: row.firstDetectedAt
18540
+ })
18541
+ );
18542
+ }
18543
+ }
18544
+ );
18545
+ }
18546
+ function runLegacyHistoryBackfill(db) {
18547
+ try {
18548
+ const eventsCaughtUp = copyLegacyEvents(db);
18549
+ if (!eventsCaughtUp) return false;
18550
+ return copyLegacyFindings(db);
18551
+ } catch (error51) {
18552
+ akaWarn(`legacy history backfill failed: ${String(error51)}`);
18553
+ return false;
18554
+ }
18555
+ }
18120
18556
  function isForeignSqliteLineage(db) {
18121
18557
  if (schemaObjectExists(db, "table", "tenants")) return true;
18122
18558
  return columnNames(db, "events").includes("tenant_id");
18123
18559
  }
18124
18560
  function ensureSyncedAtColumn(db, table) {
18561
+ if (!schemaObjectExists(db, "table", table)) return;
18125
18562
  if (!columnNames(db, table).includes("synced_at")) {
18126
18563
  db.exec(`ALTER TABLE ${table} ADD COLUMN synced_at integer`);
18127
18564
  }
@@ -18142,6 +18579,7 @@ function ensureWriteGateTrigger(db) {
18142
18579
  CONSTRAINT "ck_pack_write_gate_single_row" CHECK("_pack_write_gate"."id" = 1)
18143
18580
  )`);
18144
18581
  db.exec("INSERT OR IGNORE INTO _pack_write_gate (id, open) VALUES (1, 0)");
18582
+ if (!schemaObjectExists(db, "table", "installed_packs")) return;
18145
18583
  db.exec(`CREATE TRIGGER IF NOT EXISTS trg_installed_packs_write_gate
18146
18584
  BEFORE UPDATE OF version, name, rules_json ON installed_packs
18147
18585
  WHEN (SELECT open FROM _pack_write_gate WHERE id = 1) IS NOT 1
@@ -18169,30 +18607,6 @@ function ensureRuleProbeCacheTable(db) {
18169
18607
  )`);
18170
18608
  }
18171
18609
 
18172
- // ../../packages/persistence/src/paths.ts
18173
- import { chmodSync, mkdirSync } from "fs";
18174
- var DATA_DIR_MODE = 448;
18175
- var DATA_FILE_MODE = 384;
18176
- var DB_FILENAME = "aka.db";
18177
- function ensureDataDirSync(dir) {
18178
- mkdirSync(dir, { recursive: true, mode: DATA_DIR_MODE });
18179
- try {
18180
- chmodSync(dir, DATA_DIR_MODE);
18181
- } catch {
18182
- }
18183
- }
18184
- function walSidecars(file2) {
18185
- return [`${file2}-wal`, `${file2}-shm`];
18186
- }
18187
- function tightenPerms(file2) {
18188
- for (const path of [file2, ...walSidecars(file2)]) {
18189
- try {
18190
- chmodSync(path, DATA_FILE_MODE);
18191
- } catch {
18192
- }
18193
- }
18194
- }
18195
-
18196
18610
  // ../../packages/persistence/src/internal/json.ts
18197
18611
  function safeJson(s, fallback) {
18198
18612
  if (s == null) return fallback;
@@ -18212,51 +18626,6 @@ function parseJsonObject(s) {
18212
18626
  return void 0;
18213
18627
  }
18214
18628
 
18215
- // ../../packages/persistence/src/internal/rows.ts
18216
- function allRows(stmt, params) {
18217
- if (params === void 0) return stmt.all();
18218
- if (Array.isArray(params)) return stmt.all(...params);
18219
- return stmt.all(params);
18220
- }
18221
- function getRow(stmt, params) {
18222
- if (params === void 0) return stmt.get();
18223
- if (Array.isArray(params)) return stmt.get(...params);
18224
- return stmt.get(params);
18225
- }
18226
- function intToBool(raw) {
18227
- return raw === 1 || raw === true;
18228
- }
18229
- function boolToInt(b) {
18230
- return b ? 1 : 0;
18231
- }
18232
- function bindParams(row) {
18233
- const out = {};
18234
- for (const [key, value] of Object.entries(row)) {
18235
- out[key] = value === void 0 ? null : value;
18236
- }
18237
- return out;
18238
- }
18239
- function countScalar(db, sql, params) {
18240
- return getRow(db.prepare(sql), params)?.n ?? 0;
18241
- }
18242
- function countBy(db, sql, params) {
18243
- const map2 = /* @__PURE__ */ new Map();
18244
- for (const row of allRows(db.prepare(sql), params)) {
18245
- map2.set(row.k, row.n);
18246
- }
18247
- return map2;
18248
- }
18249
- function mapRowsTolerant(rows, map2) {
18250
- const out = [];
18251
- for (const row of rows) {
18252
- try {
18253
- out.push(map2(row));
18254
- } catch {
18255
- }
18256
- }
18257
- return out;
18258
- }
18259
-
18260
18629
  // ../../packages/persistence/src/repositories/activity.ts
18261
18630
  var DAY_MS = 864e5;
18262
18631
  var LIVE_ACTIVITY_WINDOW_MS = 30 * 6e4;
@@ -18873,6 +19242,21 @@ var SqliteAuditEventsRepository = class {
18873
19242
  })
18874
19243
  );
18875
19244
  }
19245
+ // Idempotent stub of a session's structural root. Session-scoped leaves
19246
+ // (captures, llm_call, tool_call) FK parent_id/root_session_id onto this row;
19247
+ // INSERT OR IGNORE does NOT suppress a foreign-key violation (only
19248
+ // UNIQUE/PK/NOT NULL/CHECK), so a session-scoped insert with no root row
19249
+ // raises SQLITE_CONSTRAINT and rolls its whole transaction back — silently
19250
+ // dropping the write under failOpenTransaction. SessionStart's own root write
19251
+ // is itself fail-open and marks "attempted", not "succeeded", so a session
19252
+ // with no root row yet is a real, permanent condition, not a transient race.
19253
+ // The stub carries no dimensions/attributes; an authoritative root
19254
+ // (SessionStart / the reconciler's buildSessionRoot) wins by first-write-wins
19255
+ // on the id PK, so the stub never shadows real data. This is the single named
19256
+ // home for that FK invariant — call it before writing any session-scoped row.
19257
+ ensureSessionRoot(sessionId, startedAt) {
19258
+ this.insertAuditEvent({ id: sessionId, eventType: "session", startedAt });
19259
+ }
18876
19260
  // Insert one transcript-derived `llm_call` leaf. Unlike `insertAuditEvent`
18877
19261
  // (which takes a caller-supplied random id), the id here is MINTED internally
18878
19262
  // from the natural key — `llmCallId(sessionId, messageId)` — tenant-free like the
@@ -19334,8 +19718,14 @@ var SqliteDetectionsRepository = class {
19334
19718
  )
19335
19719
  );
19336
19720
  }
19337
- // Findings whose parent event occurred in the last 30 days and whose rule_id is
19338
- // in the given set. Mirrors the security repo's findings⋈events window join.
19721
+ // Findings whose parent audit event occurred in the last 30 days, is one of
19722
+ // the four capture kinds, and whose definition's rule_id is in the given set.
19723
+ // Mirrors the security repo's inspection_findings⋈audit_events window join.
19724
+ // rule_id lives on inspection_definitions, not the finding row, so the join
19725
+ // chains through it. audit_events also holds structural rows (session, run,
19726
+ // tool_call, llm_call, source_lookup, config_scan) that never had a legacy
19727
+ // events counterpart, so the event_type predicate keeps this count identical
19728
+ // to the old findings⋈events one.
19339
19729
  countFindingsLast30d(ruleIds) {
19340
19730
  if (ruleIds.length === 0) return 0;
19341
19731
  const since = this.now() - 30 * DAY_MS2;
@@ -19343,8 +19733,12 @@ var SqliteDetectionsRepository = class {
19343
19733
  return countScalar(
19344
19734
  this.db,
19345
19735
  `SELECT count(*) AS n
19346
- FROM findings f JOIN events e ON e.id = f.event_id
19347
- WHERE e.occurred_at >= ? AND f.rule_id IN (${inClause})`,
19736
+ FROM inspection_findings f
19737
+ JOIN audit_events e ON e.id = f.audit_event_id
19738
+ JOIN inspection_definitions d ON d.id = f.inspection_definition_id
19739
+ WHERE e.started_at >= ?
19740
+ AND e.event_type IN (${CAPTURE_EVENT_TYPES_SQL})
19741
+ AND d.rule_id IN (${inClause})`,
19348
19742
  [since, ...ruleIds]
19349
19743
  );
19350
19744
  }
@@ -19354,35 +19748,24 @@ var SqliteDetectionsRepository = class {
19354
19748
  var SqliteEventsRepository = class {
19355
19749
  constructor(db) {
19356
19750
  this.db = db;
19357
- this.insertStmt = db.prepare(
19358
- `INSERT INTO events (id, source_tool, kind, occurred_at, content_hash, content, metadata)
19359
- VALUES (:id, :sourceTool, :kind, :occurredAt, :contentHash, :content, :metadata)`
19360
- );
19361
19751
  }
19362
19752
  db;
19363
- insertStmt;
19364
- insertEvent(event) {
19365
- const row = toEventRow(event);
19366
- this.insertStmt.run(
19367
- bindParams({
19368
- id: row.id,
19369
- sourceTool: row.sourceTool,
19370
- kind: row.kind,
19371
- occurredAt: row.occurredAt,
19372
- contentHash: row.contentHash,
19373
- content: row.content,
19374
- metadata: row.metadata
19375
- })
19376
- );
19377
- }
19378
- // Every recorded event's content hash — the historical backfill loads this once
19379
- // to skip transcript messages it has already stored, so re-running the scan
19380
- // never duplicates findings.
19753
+ // Every recorded capture's content hash — the historical backfill loads this
19754
+ // once to skip transcript messages it has already stored, so re-running the
19755
+ // scan never duplicates findings.
19381
19756
  // Async (Promise.resolve over synchronous node:sqlite) so it satisfies the
19382
19757
  // async EventsReadPort contract.
19758
+ //
19759
+ // audit_events also holds structural rows (session, run, tool_call, llm_call,
19760
+ // source_lookup, config_scan) with a NULL content_hash, so the capture-kind
19761
+ // predicate isn't load-bearing here — it documents intent and keeps the scan
19762
+ // index-friendly rather than walking rows that can never match.
19383
19763
  contentHashes() {
19384
19764
  const rows = allRows(
19385
- this.db.prepare("SELECT content_hash FROM events")
19765
+ this.db.prepare(
19766
+ `SELECT content_hash FROM audit_events
19767
+ WHERE event_type IN (${CAPTURE_EVENT_TYPES_SQL})`
19768
+ )
19386
19769
  );
19387
19770
  return Promise.resolve(new Set(rows.map((r) => r.content_hash)));
19388
19771
  }
@@ -19718,17 +20101,20 @@ function parseExceptionRow(row) {
19718
20101
  }
19719
20102
 
19720
20103
  // ../../packages/persistence/src/repositories/resolution-sql.ts
19721
- function latestResolutionStatusSql(findingsAlias) {
20104
+ function latestResolutionColumnSql(column, findingsAlias) {
19722
20105
  return `(
19723
- SELECT fr.status FROM finding_resolution fr
20106
+ SELECT fr.${column} FROM finding_resolution fr
19724
20107
  WHERE fr.finding_key = ${findingsAlias}.finding_key
19725
20108
  ORDER BY fr.created_at DESC, fr.rowid DESC
19726
20109
  LIMIT 1
19727
20110
  )`;
19728
20111
  }
20112
+ function latestResolutionStatusSql(findingsAlias) {
20113
+ return latestResolutionColumnSql("status", findingsAlias);
20114
+ }
19729
20115
  var LATEST_RESOLUTION_BY_KEY_SQL = `(
19730
- SELECT finding_key, status FROM (
19731
- SELECT fr.finding_key, fr.status,
20116
+ SELECT finding_key, status, method, resolved_at FROM (
20117
+ SELECT fr.finding_key, fr.status, fr.method, fr.resolved_at,
19732
20118
  ROW_NUMBER() OVER (
19733
20119
  PARTITION BY fr.finding_key
19734
20120
  ORDER BY fr.created_at DESC, fr.rowid DESC
@@ -19755,68 +20141,21 @@ var DAY_MS3 = 864e5;
19755
20141
  var SqliteFindingsRepository = class {
19756
20142
  constructor(db) {
19757
20143
  this.db = db;
19758
- this.insertStmt = db.prepare(
19759
- `INSERT INTO findings (id, event_id, rule_id, category, severity, span_start, span_end, masked_match, action_taken, confidence, finding_key, first_detected_at)
19760
- VALUES (:id, :eventId, :ruleId, :category, :severity, :spanStart, :spanEnd, :maskedMatch, :actionTaken, :confidence, :findingKey,
19761
- (SELECT occurred_at FROM events WHERE id = :eventId))
19762
- ON CONFLICT (finding_key) DO UPDATE SET
19763
- event_id = excluded.event_id,
19764
- category = excluded.category,
19765
- severity = excluded.severity,
19766
- span_start = excluded.span_start,
19767
- span_end = excluded.span_end,
19768
- masked_match = excluded.masked_match,
19769
- action_taken = excluded.action_taken,
19770
- confidence = excluded.confidence`
19771
- );
19772
- this.sessionDupStmt = db.prepare(
19773
- `SELECT 1 FROM findings f JOIN events e ON e.id = f.event_id
19774
- WHERE f.rule_id = :ruleId AND f.masked_match = :maskedMatch
19775
- AND json_extract(e.metadata, '$.sessionId') = :sessionId
19776
- LIMIT 1`
19777
- );
19778
20144
  }
19779
20145
  db;
19780
- insertStmt;
19781
- sessionDupStmt;
19782
- insertFindings(findings, scope = {}) {
19783
- for (const finding of findings) {
19784
- if (scope.sessionId && this.isSessionDuplicate(finding, scope.sessionId)) continue;
19785
- const row = toFindingRow(finding);
19786
- this.insertStmt.run({
19787
- id: row.id,
19788
- eventId: row.eventId,
19789
- ruleId: row.ruleId,
19790
- category: row.category,
19791
- severity: row.severity,
19792
- spanStart: row.spanStart,
19793
- spanEnd: row.spanEnd,
19794
- maskedMatch: row.maskedMatch,
19795
- actionTaken: row.actionTaken,
19796
- confidence: row.confidence,
19797
- findingKey: row.findingKey ?? null
19798
- });
19799
- }
19800
- }
19801
- // True when an earlier event in the same session already recorded a finding
19802
- // with the same rule and masked value. The current event is inserted before
19803
- // its findings, but carries no findings yet, so this never self-matches.
19804
- isSessionDuplicate(finding, sessionId) {
19805
- const hit = this.sessionDupStmt.get({
19806
- ruleId: finding.ruleId,
19807
- maskedMatch: finding.maskedMatch,
19808
- sessionId
19809
- });
19810
- return hit !== void 0;
19811
- }
19812
20146
  recentFindings(opts) {
19813
20147
  const limit = opts?.limit ?? 50;
19814
20148
  const rows = allRows(
19815
20149
  this.db.prepare(
19816
- `SELECT f.id, f.event_id, f.rule_id, f.category, f.severity, f.masked_match,
19817
- f.action_taken, f.confidence, e.occurred_at, e.source_tool, e.kind
19818
- FROM findings f JOIN events e ON e.id = f.event_id
19819
- ORDER BY e.occurred_at DESC, f.rowid DESC
20150
+ `SELECT f.id, f.audit_event_id AS event_id, d.rule_id, d.category, d.severity,
20151
+ f.masked_match, f.action_taken, f.confidence, e.started_at AS occurred_at,
20152
+ json_extract(e.attributes, '$.source_tool') AS source_tool,
20153
+ e.event_type AS kind
20154
+ FROM inspection_findings f
20155
+ JOIN audit_events e ON e.id = f.audit_event_id
20156
+ JOIN inspection_definitions d ON d.id = f.inspection_definition_id
20157
+ WHERE e.event_type IN (${CAPTURE_EVENT_TYPES_SQL})
20158
+ ORDER BY e.started_at DESC, f.rowid DESC
19820
20159
  LIMIT :limit`
19821
20160
  ),
19822
20161
  { limit }
@@ -19838,25 +20177,34 @@ var SqliteFindingsRepository = class {
19838
20177
  );
19839
20178
  }
19840
20179
  /** Live-enforced findings recorded for one session — a bare COUNT over the
19841
- * session-stamped events (served by idx_events_session_id), so the Activity
20180
+ * session-stamped audit_events (served by idx_audit_session), so the Activity
19842
20181
  * page can label its findings link without the grouped pipeline. */
19843
20182
  sessionFindingsCount(sessionId) {
19844
20183
  if (!sessionId) return Promise.resolve(0);
19845
20184
  return Promise.resolve(
19846
20185
  countScalar(
19847
20186
  this.db,
19848
- `SELECT count(*) AS n FROM findings f
19849
- JOIN events e ON e.id = f.event_id
19850
- WHERE json_extract(e.metadata, '$.sessionId') = :sessionId`,
20187
+ `SELECT count(*) AS n FROM inspection_findings f
20188
+ JOIN audit_events e ON e.id = f.audit_event_id
20189
+ WHERE e.root_session_id = :sessionId
20190
+ AND e.event_type IN (${CAPTURE_EVENT_TYPES_SQL})`,
19851
20191
  { sessionId }
19852
20192
  )
19853
20193
  );
19854
20194
  }
19855
- /** Per-rule transcript firing tally for one session — reads the OTHER finding
19856
- * store (inspection_findings, keyed to audit_events): every detection the
19857
- * transcript pass recorded, counted per firing rather than per unique value.
19858
- * Rides on session-scoped grouped responses so the findings view can
19859
- * reconcile the Activity page's tally with the deduped groups it lists. */
20195
+ /** Per-rule transcript firing tally for one session — every detection the
20196
+ * transcript-reconciler pass recorded against the session's `tool_call` rows,
20197
+ * counted per firing rather than per unique value. Rides on session-scoped
20198
+ * grouped responses so the findings view can reconcile the Activity page's
20199
+ * tally with the deduped groups it lists.
20200
+ *
20201
+ * `inspection_findings`/`audit_events` are now the SAME physical tables the
20202
+ * rest of this class reads for the live-capture list above (they used to be
20203
+ * a separate store), so this excludes the four capture kinds those rows
20204
+ * already carry — without that exclusion, every live-capture finding in the
20205
+ * session would be tallied here too, double-counting against the grouped
20206
+ * list this response rides alongside. The reconciler attaches its findings
20207
+ * only to `tool_call` rows, which the exclusion leaves untouched. */
19860
20208
  sessionFirings(sessionId) {
19861
20209
  return Object.fromEntries(
19862
20210
  countBy(
@@ -19866,18 +20214,25 @@ var SqliteFindingsRepository = class {
19866
20214
  JOIN audit_events e ON e.id = f.audit_event_id
19867
20215
  JOIN inspection_definitions d ON d.id = f.inspection_definition_id
19868
20216
  WHERE e.root_session_id = :sessionId
20217
+ AND e.event_type NOT IN (${CAPTURE_EVENT_TYPES_SQL})
19869
20218
  GROUP BY d.rule_id`,
19870
20219
  { sessionId }
19871
20220
  )
19872
20221
  );
19873
20222
  }
19874
20223
  /**
19875
- * Grouped findings for the dashboard — joins findingsevents (repo/file/
19876
- * toolName from event metadata), groups by ruleId, computes per-filter-excluded facets,
19877
- * applies the requested filters, and sorts by severity then recency. Filtering
20224
+ * Grouped findings for the dashboard — joins inspection_findingsaudit_events
20225
+ * ⋈inspection_definitions (repo/file/toolName from the audit event's
20226
+ * attributes bag, rule_id/category/severity from the definition), scoped to
20227
+ * the four capture kinds (audit_events also holds structural/reconciler/scan
20228
+ * rows this list must never surface), groups by ruleId, computes
20229
+ * per-filter-excluded facets, applies the requested filters, and sorts by
20230
+ * severity then recency. Filtering
19878
20231
  * and faceting run in JS via the shared @akasecurity/schema helpers. `totals`
19879
20232
  * reflect the full filtered set; `items` is the requested
19880
- * page (default 50); no cursor (nextCursor is always null).
20233
+ * page (default 50); no cursor (nextCursor is always null). Under a `status`
20234
+ * filter, `totals.findings` counts only instances whose derived status was
20235
+ * requested, and each item's instance preview is narrowed the same way.
19881
20236
  *
19882
20237
  * Two reads, neither of which materializes a row per finding:
19883
20238
  * 1. one aggregate row per rule_id, folding EVERY instance into the numbers
@@ -19890,10 +20245,11 @@ var SqliteFindingsRepository = class {
19890
20245
  * rule is ever restated in SQL.
19891
20246
  */
19892
20247
  listGroupedFindings(query) {
19893
- const sessionPredicate = query.sessionId ? `WHERE json_extract(e.metadata, '$.sessionId') = :sessionId` : "";
20248
+ const sessionPredicate = query.sessionId ? ` AND e.root_session_id = :sessionId` : "";
20249
+ const predicate = `WHERE e.event_type IN (${CAPTURE_EVENT_TYPES_SQL})${sessionPredicate}`;
19894
20250
  const sessionParams = query.sessionId ? { sessionId: query.sessionId } : {};
19895
20251
  const aggregates = this.groupAggregates(query.q !== void 0 && query.q !== "", {
19896
- predicate: sessionPredicate,
20252
+ predicate,
19897
20253
  params: sessionParams
19898
20254
  });
19899
20255
  const rows = allRows(
@@ -19901,24 +20257,26 @@ var SqliteFindingsRepository = class {
19901
20257
  `SELECT id, rule_id, category, severity, masked_match, action_taken, confidence,
19902
20258
  occurred_at, source_tool, repo, file, tool_name, kind, finding_key, latest_status
19903
20259
  FROM (
19904
- SELECT f.id AS id, f.rule_id AS rule_id, f.category AS category,
19905
- f.severity AS severity, f.masked_match AS masked_match,
20260
+ SELECT f.id AS id, d.rule_id AS rule_id, d.category AS category,
20261
+ d.severity AS severity, f.masked_match AS masked_match,
19906
20262
  f.action_taken AS action_taken, f.confidence AS confidence,
19907
- e.occurred_at AS occurred_at, e.source_tool AS source_tool,
19908
- json_extract(e.metadata, '$.repo') AS repo,
19909
- json_extract(e.metadata, '$.filePath') AS file,
19910
- json_extract(e.metadata, '$.toolName') AS tool_name,
19911
- e.kind AS kind, f.finding_key AS finding_key,
20263
+ e.started_at AS occurred_at,
20264
+ json_extract(e.attributes, '$.source_tool') AS source_tool,
20265
+ json_extract(e.attributes, '$.repo') AS repo,
20266
+ json_extract(e.attributes, '$.file_path') AS file,
20267
+ json_extract(e.attributes, '$.tool_name') AS tool_name,
20268
+ e.event_type AS kind, f.finding_key AS finding_key,
19912
20269
  latest.status AS latest_status,
19913
20270
  ROW_NUMBER() OVER (
19914
- PARTITION BY f.rule_id
19915
- ORDER BY e.occurred_at DESC, f.id DESC
20271
+ PARTITION BY d.rule_id
20272
+ ORDER BY e.started_at DESC, f.id DESC
19916
20273
  ) AS rn
19917
- FROM findings f
19918
- JOIN events e ON e.id = f.event_id
20274
+ FROM inspection_findings f
20275
+ JOIN audit_events e ON e.id = f.audit_event_id
20276
+ JOIN inspection_definitions d ON d.id = f.inspection_definition_id
19919
20277
  LEFT JOIN ${LATEST_RESOLUTION_BY_KEY_SQL} latest
19920
20278
  ON latest.finding_key = f.finding_key
19921
- ${sessionPredicate}
20279
+ ${predicate}
19922
20280
  )
19923
20281
  WHERE rn <= :cap
19924
20282
  ORDER BY occurred_at DESC, id DESC`
@@ -19945,17 +20303,29 @@ var SqliteFindingsRepository = class {
19945
20303
  severity: query.severity,
19946
20304
  providers: query.provider,
19947
20305
  actions: query.action,
20306
+ statuses: query.status,
19948
20307
  subtype: query.subtype,
19949
20308
  q: query.q
19950
20309
  };
19951
20310
  const facets = computeFindingFacets(allGroups, filterOpts);
19952
20311
  const sorted = sortFindingGroups(applyFindingFilters(allGroups, filterOpts));
20312
+ const statusFilter = query.status ?? [];
19953
20313
  const totals = {
19954
- findings: sorted.reduce((acc, g) => acc + g.instanceCount, 0),
20314
+ findings: sorted.reduce((acc, g) => {
20315
+ if (statusFilter.length === 0) return acc + g.instanceCount;
20316
+ const agg = aggregates.get(g.id);
20317
+ return acc + (agg ? countInstancesByStatus(agg.statusInputs, statusFilter) ?? g.instanceCount : g.instanceCount);
20318
+ }, 0),
19955
20319
  groups: sorted.length
19956
20320
  };
19957
20321
  const limit = query.limit ?? DEFAULT_GROUPED_FINDINGS_LIMIT;
19958
- const items = sorted.slice(0, limit);
20322
+ const statusSet = statusFilter.length > 0 ? new Set(statusFilter) : null;
20323
+ const items = sorted.slice(0, limit).map(
20324
+ (g) => statusSet ? {
20325
+ ...g,
20326
+ instances: g.instances.filter((i) => i.status !== void 0 && statusSet.has(i.status))
20327
+ } : g
20328
+ );
19959
20329
  return Promise.resolve({
19960
20330
  totals,
19961
20331
  facets,
@@ -19969,45 +20339,62 @@ var SqliteFindingsRepository = class {
19969
20339
  * buildFindingGroups cannot recover from a preview. Bounded by the number of
19970
20340
  * distinct rule_ids (the installed packs' rules), not by the store's size.
19971
20341
  *
19972
- * The per-instance sets ride back as group_concat lists of RAW DB values —
19973
- * source_tool, action_taken, and the (kind, has-key, latest-status) triples
19974
- * deriveFindingStatus consumes. Aggregating the status INPUTS rather than a
19975
- * status keeps the classifier itself in @akasecurity/schema, where
19976
- * severitySummary's SQL and this query can't drift apart on what 'resolved'
19977
- * means (see resolution-sql.ts). Each of those sets is bounded by an enum, so
20342
+ * A single scan, folded in two levels: the inner SELECT groups by
20343
+ * (rule_id, status tuple) so each (kind, has-key, latest-status) combination
20344
+ * carries its instance count countInstancesByStatus needs those counts for
20345
+ * status-scoped totals — and the outer SELECT folds the tuples back to one
20346
+ * row per rule. The per-instance sets ride back as group_concat lists of RAW
20347
+ * DB values source_tool, action_taken, and the tuples deriveFindingStatus
20348
+ * consumes. Aggregating the status INPUTS rather than a status keeps the
20349
+ * classifier itself in @akasecurity/schema, where severitySummary's SQL and
20350
+ * this query can't drift apart on what 'resolved' means (see
20351
+ * resolution-sql.ts). The concat-of-concats can repeat a value across
20352
+ * tuples; the schema mappers dedupe, and each set is bounded by an enum, so
19978
20353
  * a group's row stays small however many findings it holds.
19979
20354
  *
19980
20355
  * `withSearchText` is the exception, and the one column here that does NOT
19981
- * stay small: the group's distinct repos/filePaths, whose size tracks how many
19982
- * distinct paths a rule fired across — for a rule hitting mostly-unique paths
19983
- * that is a string proportional to the store (~8MB over 200k distinct paths,
19984
- * and buildHaystack lowercases a second copy). It buys `q` the ability to
19985
- * match an instance outside the preview, which searching the preview alone
19986
- * would silently lose, so it is fetched only when the request actually
19987
- * carries a `q`.
20356
+ * stay small: the group's per-tuple-distinct repos/filePaths, whose size
20357
+ * tracks how many distinct paths a rule fired across — for a rule hitting
20358
+ * mostly-unique paths that is a string proportional to the store (~8MB over
20359
+ * 200k distinct paths, and buildHaystack lowercases a second copy). It buys
20360
+ * `q` the ability to match an instance outside the preview, which searching
20361
+ * the preview alone would silently lose, so it is fetched only when the
20362
+ * request actually carries a `q`. (Substring matching is unaffected by a
20363
+ * path repeating across tuples.)
19988
20364
  */
19989
20365
  groupAggregates(withSearchText, scope) {
19990
- const searchTextColumns = withSearchText ? `, group_concat(DISTINCT json_extract(e.metadata, '$.repo')) AS repos,
19991
- group_concat(DISTINCT json_extract(e.metadata, '$.filePath')) AS files,
19992
- group_concat(DISTINCT 'via ' || json_extract(e.metadata, '$.toolName')) AS tool_names` : `, NULL AS repos, NULL AS files, NULL AS tool_names`;
20366
+ const innerSearchColumns = withSearchText ? `, group_concat(DISTINCT json_extract(e.attributes, '$.repo')) AS repos,
20367
+ group_concat(DISTINCT json_extract(e.attributes, '$.file_path')) AS files,
20368
+ group_concat(DISTINCT 'via ' || json_extract(e.attributes, '$.tool_name')) AS tool_names` : `, NULL AS repos, NULL AS files, NULL AS tool_names`;
19993
20369
  const rows = this.db.prepare(
19994
- `SELECT f.rule_id AS rule_id,
19995
- count(*) AS instance_count,
19996
- max(e.occurred_at) AS latest_at,
19997
- group_concat(DISTINCT e.source_tool) AS source_tools,
19998
- group_concat(DISTINCT f.action_taken) AS actions_taken,
19999
- group_concat(DISTINCT (
20000
- e.kind || '${TUPLE_SEP}' ||
20001
- (CASE WHEN f.finding_key IS NULL THEN '' ELSE 'k' END) || '${TUPLE_SEP}' ||
20002
- coalesce(latest.status, '')
20003
- )) AS status_inputs
20004
- ${searchTextColumns}
20005
- FROM findings f
20006
- JOIN events e ON e.id = f.event_id
20007
- LEFT JOIN ${LATEST_RESOLUTION_BY_KEY_SQL} latest
20008
- ON latest.finding_key = f.finding_key
20009
- ${scope.predicate}
20010
- GROUP BY f.rule_id`
20370
+ `SELECT rule_id,
20371
+ sum(tuple_count) AS instance_count,
20372
+ max(latest_at) AS latest_at,
20373
+ group_concat(source_tools) AS source_tools,
20374
+ group_concat(actions_taken) AS actions_taken,
20375
+ group_concat(status_tuple || '${TUPLE_SEP}' || tuple_count) AS status_inputs,
20376
+ group_concat(repos) AS repos,
20377
+ group_concat(files) AS files,
20378
+ group_concat(tool_names) AS tool_names
20379
+ FROM (
20380
+ SELECT d.rule_id AS rule_id,
20381
+ e.event_type || '${TUPLE_SEP}' ||
20382
+ (CASE WHEN f.finding_key IS NULL THEN '' ELSE 'k' END) || '${TUPLE_SEP}' ||
20383
+ coalesce(latest.status, '') AS status_tuple,
20384
+ count(*) AS tuple_count,
20385
+ max(e.started_at) AS latest_at,
20386
+ group_concat(DISTINCT json_extract(e.attributes, '$.source_tool')) AS source_tools,
20387
+ group_concat(DISTINCT f.action_taken) AS actions_taken
20388
+ ${innerSearchColumns}
20389
+ FROM inspection_findings f
20390
+ JOIN audit_events e ON e.id = f.audit_event_id
20391
+ JOIN inspection_definitions d ON d.id = f.inspection_definition_id
20392
+ LEFT JOIN ${LATEST_RESOLUTION_BY_KEY_SQL} latest
20393
+ ON latest.finding_key = f.finding_key
20394
+ ${scope.predicate}
20395
+ GROUP BY d.rule_id, status_tuple
20396
+ )
20397
+ GROUP BY rule_id`
20011
20398
  ).all(scope.params);
20012
20399
  return new Map(
20013
20400
  rows.map((r) => [
@@ -20017,13 +20404,14 @@ var SqliteFindingsRepository = class {
20017
20404
  sourceTools: splitConcat(r.source_tools),
20018
20405
  actionsTaken: splitConcat(r.actions_taken),
20019
20406
  statusInputs: splitConcat(r.status_inputs).map((tuple2) => {
20020
- const [kind = "", keyMarker = "", latestStatus = ""] = tuple2.split(TUPLE_SEP);
20407
+ const [kind = "", keyMarker = "", latestStatus = "", count = ""] = tuple2.split(TUPLE_SEP);
20021
20408
  return {
20022
20409
  // deriveFindingStatus only distinguishes null from non-null here,
20023
20410
  // so the marker stands in for the key itself (never rendered).
20024
20411
  kind,
20025
20412
  findingKey: keyMarker === "" ? null : keyMarker,
20026
- latestResolutionStatus: latestStatus === "" ? null : latestStatus
20413
+ latestResolutionStatus: latestStatus === "" ? null : latestStatus,
20414
+ count: Number(count)
20027
20415
  };
20028
20416
  }),
20029
20417
  latestDetectedAt: epochMillisToIso(r.latest_at),
@@ -20040,10 +20428,21 @@ var SqliteFindingsRepository = class {
20040
20428
  );
20041
20429
  }
20042
20430
  healthSummary() {
20043
- const total = countScalar(this.db, "SELECT count(*) AS n FROM findings");
20431
+ const total = countScalar(
20432
+ this.db,
20433
+ `SELECT count(*) AS n FROM inspection_findings f
20434
+ JOIN audit_events e ON e.id = f.audit_event_id
20435
+ WHERE e.event_type IN (${CAPTURE_EVENT_TYPES_SQL})`
20436
+ );
20044
20437
  const byAction = Object.fromEntries(ACTION_TAKEN_KEYS.map((a) => [a, 0]));
20045
20438
  const grouped = allRows(
20046
- this.db.prepare("SELECT action_taken, count(*) AS c FROM findings GROUP BY action_taken")
20439
+ this.db.prepare(
20440
+ `SELECT f.action_taken AS action_taken, count(*) AS c
20441
+ FROM inspection_findings f
20442
+ JOIN audit_events e ON e.id = f.audit_event_id
20443
+ WHERE e.event_type IN (${CAPTURE_EVENT_TYPES_SQL})
20444
+ GROUP BY f.action_taken`
20445
+ )
20047
20446
  );
20048
20447
  for (const row of grouped) {
20049
20448
  if (row.action_taken in byAction) byAction[row.action_taken] = row.c;
@@ -20051,12 +20450,15 @@ var SqliteFindingsRepository = class {
20051
20450
  const bySeverity = { critical: 0, high: 0, medium: 0, low: 0 };
20052
20451
  const sevRows = allRows(
20053
20452
  this.db.prepare(
20054
- `SELECT f.severity AS severity, count(*) AS c
20055
- FROM findings f
20453
+ `SELECT d.severity AS severity, count(*) AS c
20454
+ FROM inspection_findings f
20455
+ JOIN audit_events e ON e.id = f.audit_event_id
20456
+ JOIN inspection_definitions d ON d.id = f.inspection_definition_id
20056
20457
  LEFT JOIN ${LATEST_RESOLUTION_BY_KEY_SQL} latest
20057
20458
  ON latest.finding_key = f.finding_key
20058
- WHERE latest.status IS NULL OR latest.status != 'resolved'
20059
- GROUP BY f.severity`
20459
+ WHERE e.event_type IN (${CAPTURE_EVENT_TYPES_SQL})
20460
+ AND (latest.status IS NULL OR latest.status != 'resolved')
20461
+ GROUP BY d.severity`
20060
20462
  )
20061
20463
  );
20062
20464
  for (const row of sevRows) {
@@ -20077,9 +20479,11 @@ var SqliteFindingsRepository = class {
20077
20479
  const since = startOfUtcDay(Date.now()) - (days - 1) * DAY_MS3;
20078
20480
  const rows = allRows(
20079
20481
  this.db.prepare(
20080
- `SELECT date(e.occurred_at / 1000, 'unixepoch') AS day, f.action_taken AS action, count(*) AS c
20081
- FROM findings f JOIN events e ON e.id = f.event_id
20082
- WHERE e.occurred_at >= :since
20482
+ `SELECT date(e.started_at / 1000, 'unixepoch') AS day, f.action_taken AS action, count(*) AS c
20483
+ FROM inspection_findings f
20484
+ JOIN audit_events e ON e.id = f.audit_event_id
20485
+ WHERE e.started_at >= :since
20486
+ AND e.event_type IN (${CAPTURE_EVENT_TYPES_SQL})
20083
20487
  GROUP BY day, f.action_taken`
20084
20488
  ),
20085
20489
  { since }
@@ -20144,15 +20548,59 @@ var SqliteInspectionFindingsRepository = class {
20144
20548
  this.insertStmt = db.prepare(
20145
20549
  `INSERT INTO inspection_findings
20146
20550
  (id, audit_event_id, inspection_definition_id, classified_data_id,
20147
- span_start, span_end, masked_match, action_taken, confidence)
20551
+ span_start, span_end, masked_match, action_taken, confidence,
20552
+ finding_key, first_detected_at)
20148
20553
  VALUES
20149
20554
  (:id, :auditEventId, :inspectionDefinitionId, :classifiedDataId,
20150
- :spanStart, :spanEnd, :maskedMatch, :actionTaken, :confidence)
20151
- ON CONFLICT(id) DO NOTHING`
20555
+ :spanStart, :spanEnd, :maskedMatch, :actionTaken, :confidence,
20556
+ :findingKey,
20557
+ COALESCE(:firstDetectedAt, (SELECT started_at FROM audit_events WHERE id = :auditEventId)))
20558
+ ON CONFLICT(id) DO UPDATE SET
20559
+ inspection_definition_id = excluded.inspection_definition_id
20560
+ ON CONFLICT (finding_key) DO UPDATE SET
20561
+ audit_event_id = excluded.audit_event_id,
20562
+ inspection_definition_id = excluded.inspection_definition_id,
20563
+ classified_data_id = excluded.classified_data_id,
20564
+ span_start = excluded.span_start,
20565
+ span_end = excluded.span_end,
20566
+ masked_match = excluded.masked_match,
20567
+ action_taken = excluded.action_taken,
20568
+ confidence = excluded.confidence`
20569
+ );
20570
+ this.sessionDupStmt = db.prepare(
20571
+ `SELECT 1 FROM inspection_findings f
20572
+ JOIN audit_events e ON e.id = f.audit_event_id
20573
+ JOIN inspection_definitions d ON d.id = f.inspection_definition_id
20574
+ WHERE d.rule_id = :ruleId AND f.masked_match = :maskedMatch
20575
+ AND e.root_session_id = :sessionId
20576
+ AND e.event_type IN (${CAPTURE_EVENT_TYPES_SQL})
20577
+ LIMIT 1`
20578
+ );
20579
+ this.eventDupStmt = db.prepare(
20580
+ `SELECT 1 FROM inspection_findings f
20581
+ JOIN inspection_definitions d ON d.id = f.inspection_definition_id
20582
+ WHERE f.audit_event_id = :auditEventId AND d.rule_id = :ruleId
20583
+ AND f.masked_match = :maskedMatch
20584
+ AND f.span_start = :spanStart AND f.span_end = :spanEnd
20585
+ LIMIT 1`
20152
20586
  );
20153
20587
  }
20154
20588
  db;
20155
20589
  insertStmt;
20590
+ sessionDupStmt;
20591
+ eventDupStmt;
20592
+ // True when an earlier event in the same session already recorded a finding
20593
+ // with the same rule and masked value. The current event's own findings are
20594
+ // inserted one at a time in caller order, so an earlier finding in the SAME
20595
+ // recordCapture call is visible to a later duplicate check within it too.
20596
+ isSessionDuplicate(ruleId, maskedMatch, sessionId) {
20597
+ return this.sessionDupStmt.get({ ruleId, maskedMatch, sessionId }) !== void 0;
20598
+ }
20599
+ // True when this exact detection (rule + masked value + span) is already
20600
+ // recorded against the given audit event.
20601
+ isEventDuplicate(auditEventId, ruleId, maskedMatch, spanStart, spanEnd) {
20602
+ return this.eventDupStmt.get({ auditEventId, ruleId, maskedMatch, spanStart, spanEnd }) !== void 0;
20603
+ }
20156
20604
  insertFinding(input) {
20157
20605
  const row = toInspectionFindingRow(input);
20158
20606
  this.insertStmt.run(
@@ -20165,7 +20613,9 @@ var SqliteInspectionFindingsRepository = class {
20165
20613
  spanEnd: row.spanEnd,
20166
20614
  maskedMatch: row.maskedMatch,
20167
20615
  actionTaken: row.actionTaken,
20168
- confidence: row.confidence
20616
+ confidence: row.confidence,
20617
+ findingKey: row.findingKey,
20618
+ firstDetectedAt: row.firstDetectedAt
20169
20619
  })
20170
20620
  );
20171
20621
  }
@@ -20437,7 +20887,7 @@ var SqliteInstalledPacksRepository = class {
20437
20887
  installedRuleset() {
20438
20888
  const rows = allRows(
20439
20889
  this.db.prepare(
20440
- `SELECT enabled, policy_id AS policyId, rules_json AS rulesJson FROM installed_packs`
20890
+ `SELECT enabled, policy_id AS policyId, rules_json AS rulesJson, version FROM installed_packs`
20441
20891
  )
20442
20892
  );
20443
20893
  const out = {
@@ -20445,7 +20895,8 @@ var SqliteInstalledPacksRepository = class {
20445
20895
  enabledPacks: 0,
20446
20896
  rules: [],
20447
20897
  invalidRules: 0,
20448
- ruleActions: /* @__PURE__ */ new Map()
20898
+ ruleActions: /* @__PURE__ */ new Map(),
20899
+ ruleVersions: /* @__PURE__ */ new Map()
20449
20900
  };
20450
20901
  for (const row of rows) {
20451
20902
  if (!intToBool(row.enabled)) continue;
@@ -20467,6 +20918,7 @@ var SqliteInstalledPacksRepository = class {
20467
20918
  if (parsed.success) {
20468
20919
  out.rules.push(parsed.data);
20469
20920
  out.ruleActions.set(parsed.data.id, action);
20921
+ out.ruleVersions.set(parsed.data.id, row.version);
20470
20922
  } else out.invalidRules += 1;
20471
20923
  }
20472
20924
  }
@@ -21641,19 +22093,19 @@ var SqliteResolutionsRepository = class {
21641
22093
  );
21642
22094
  this.openAtRestStmt = db.prepare(
21643
22095
  `SELECT DISTINCT f.finding_key AS finding_key
21644
- FROM findings f
21645
- JOIN events e ON e.id = f.event_id
21646
- WHERE e.kind = 'code_change'
21647
- AND json_extract(e.metadata, '$.filePath') = :path
22096
+ FROM inspection_findings f
22097
+ JOIN audit_events e ON e.id = f.audit_event_id
22098
+ WHERE e.event_type = 'code_change'
22099
+ AND json_extract(e.attributes, '$.file_path') = :path
21648
22100
  AND f.finding_key IS NOT NULL
21649
22101
  AND ${latestResolutionStatusSql("f")} IS NOT 'resolved'`
21650
22102
  );
21651
22103
  this.resolvedAtRestStmt = db.prepare(
21652
22104
  `SELECT DISTINCT f.finding_key AS finding_key
21653
- FROM findings f
21654
- JOIN events e ON e.id = f.event_id
21655
- WHERE e.kind = 'code_change'
21656
- AND json_extract(e.metadata, '$.filePath') = :path
22105
+ FROM inspection_findings f
22106
+ JOIN audit_events e ON e.id = f.audit_event_id
22107
+ WHERE e.event_type = 'code_change'
22108
+ AND json_extract(e.attributes, '$.file_path') = :path
21657
22109
  AND f.finding_key IS NOT NULL
21658
22110
  AND ${latestResolutionStatusSql("f")} = 'resolved'`
21659
22111
  );
@@ -21876,25 +22328,27 @@ var SqliteSecurityRepository = class {
21876
22328
  severitySummary() {
21877
22329
  const rows = allRows(
21878
22330
  this.db.prepare(
21879
- `SELECT f.severity AS severity,
22331
+ `SELECT d.severity AS severity,
21880
22332
  COUNT(*) AS count,
21881
22333
  SUM(CASE
21882
- WHEN e.kind != 'code_change' THEN 1
22334
+ WHEN e.event_type != 'code_change' THEN 1
21883
22335
  WHEN f.finding_key IS NULL THEN 0
21884
22336
  WHEN latest.status = 'resolved' THEN 1
21885
22337
  ELSE 0
21886
22338
  END) AS caught,
21887
22339
  SUM(CASE
21888
- WHEN e.kind = 'code_change'
22340
+ WHEN e.event_type = 'code_change'
21889
22341
  AND f.finding_key IS NOT NULL
21890
22342
  AND (latest.status IS NULL OR latest.status != 'resolved') THEN 1
21891
22343
  ELSE 0
21892
22344
  END) AS open_at_rest
21893
- FROM findings f
21894
- JOIN events e ON e.id = f.event_id
22345
+ FROM inspection_findings f
22346
+ JOIN audit_events e ON e.id = f.audit_event_id
22347
+ JOIN inspection_definitions d ON d.id = f.inspection_definition_id
21895
22348
  LEFT JOIN ${LATEST_RESOLUTION_BY_KEY_SQL} latest
21896
22349
  ON latest.finding_key = f.finding_key
21897
- GROUP BY f.severity`
22350
+ WHERE e.event_type IN (${CAPTURE_EVENT_TYPES_SQL})
22351
+ GROUP BY d.severity`
21898
22352
  )
21899
22353
  );
21900
22354
  const byRow = new Map(rows.map((r) => [r.severity, r]));
@@ -21960,7 +22414,7 @@ var SqliteSecurityRepository = class {
21960
22414
  // Mean time-to-remediate per bucket, split by severity — a sibling of
21961
22415
  // findingsTimeseries that reuses the same window/bucket/UTC math, but buckets
21962
22416
  // on a different timestamp: findingsTimeseries buckets by first-detection
21963
- // (events.occurred_at), this buckets by resolution time (the latest
22417
+ // (audit_events.started_at), this buckets by resolution time (the latest
21964
22418
  // finding_resolution row's resolved_at) — it's a "resolved in this bucket"
21965
22419
  // trend, not a "detected in this bucket" one. Only findings whose LATEST
21966
22420
  // resolution row (latest-resolution-wins, same correlated subquery as
@@ -21985,30 +22439,20 @@ var SqliteSecurityRepository = class {
21985
22439
  // first_detected_at is the PRESERVED first-detection time (set once on a
21986
22440
  // finding's INSERT, never overwritten on the re-detection upsert), so MTTR
21987
22441
  // measures from first sighting — not the latest re-scan's event, whose
21988
- // occurred_at the upsert overwrites onto findings.event_id. COALESCE onto
21989
- // the parent event's occurred_at defends against any legacy/edge row the
21990
- // backfill left null.
21991
- `SELECT COALESCE(f.first_detected_at, e.occurred_at) AS first_detected_at, f.severity AS severity,
21992
- (
21993
- SELECT fr.status FROM finding_resolution fr
21994
- WHERE fr.finding_key = f.finding_key
21995
- ORDER BY fr.created_at DESC, fr.rowid DESC
21996
- LIMIT 1
21997
- ) AS latest_status,
21998
- (
21999
- SELECT fr.method FROM finding_resolution fr
22000
- WHERE fr.finding_key = f.finding_key
22001
- ORDER BY fr.created_at DESC, fr.rowid DESC
22002
- LIMIT 1
22003
- ) AS latest_method,
22004
- (
22005
- SELECT fr.resolved_at FROM finding_resolution fr
22006
- WHERE fr.finding_key = f.finding_key
22007
- ORDER BY fr.created_at DESC, fr.rowid DESC
22008
- LIMIT 1
22009
- ) AS latest_resolved_at
22010
- FROM findings f JOIN events e ON e.id = f.event_id
22442
+ // started_at the upsert overwrites onto inspection_findings.audit_event_id.
22443
+ // COALESCE onto the parent event's started_at defends against any
22444
+ // legacy/edge row the backfill left null.
22445
+ `SELECT COALESCE(f.first_detected_at, e.started_at) AS first_detected_at, d.severity AS severity,
22446
+ latest.status AS latest_status,
22447
+ latest.method AS latest_method,
22448
+ latest.resolved_at AS latest_resolved_at
22449
+ FROM inspection_findings f
22450
+ JOIN audit_events e ON e.id = f.audit_event_id
22451
+ JOIN inspection_definitions d ON d.id = f.inspection_definition_id
22452
+ LEFT JOIN ${LATEST_RESOLUTION_BY_KEY_SQL} latest
22453
+ ON latest.finding_key = f.finding_key
22011
22454
  WHERE f.finding_key IS NOT NULL
22455
+ AND e.event_type IN (${CAPTURE_EVENT_TYPES_SQL})
22012
22456
  AND EXISTS (
22013
22457
  SELECT 1 FROM finding_resolution fr
22014
22458
  WHERE fr.finding_key = f.finding_key
@@ -22055,11 +22499,13 @@ var SqliteSecurityRepository = class {
22055
22499
  const from = now - RANGE_DAYS[range] * DAY_MS4;
22056
22500
  const rows = allRows(
22057
22501
  this.db.prepare(
22058
- `SELECT json_extract(e.metadata, '$.repo') AS repo, count(*) AS c
22059
- FROM findings f JOIN events e ON e.id = f.event_id
22060
- WHERE e.occurred_at >= :from AND e.occurred_at < :to
22061
- AND json_extract(e.metadata, '$.repo') IS NOT NULL
22062
- AND json_extract(e.metadata, '$.repo') != ''
22502
+ `SELECT json_extract(e.attributes, '$.repo') AS repo, count(*) AS c
22503
+ FROM inspection_findings f
22504
+ JOIN audit_events e ON e.id = f.audit_event_id
22505
+ WHERE e.started_at >= :from AND e.started_at < :to
22506
+ AND e.event_type IN (${CAPTURE_EVENT_TYPES_SQL})
22507
+ AND json_extract(e.attributes, '$.repo') IS NOT NULL
22508
+ AND json_extract(e.attributes, '$.repo') != ''
22063
22509
  GROUP BY repo
22064
22510
  ORDER BY c DESC, repo
22065
22511
  LIMIT :limit`
@@ -22083,44 +22529,28 @@ var SqliteSecurityRepository = class {
22083
22529
  // secret came back) is excluded — it is not currently resolved. Legacy
22084
22530
  // at-rest findings with finding_key IS NULL are excluded outright (the
22085
22531
  // resolution lifecycle can never attach to them). Path comes from the
22086
- // finding's parent event (kind 'code_change', metadata.filePath) — mirrors
22087
- // resolutions.ts's openAtRestStmt accessor. Ordered by resolved_at DESC,
22088
- // capped at `limit`.
22532
+ // finding's parent event (event_type 'code_change', attributes.file_path) —
22533
+ // mirrors resolutions.ts's openAtRestStmt accessor. Ordered by resolved_at
22534
+ // DESC, capped at `limit`.
22089
22535
  recentlyResolved(limit = 20) {
22090
22536
  const rows = allRows(
22091
22537
  this.db.prepare(
22092
22538
  `SELECT f.finding_key AS finding_key,
22093
- f.rule_id AS rule_id,
22094
- f.severity AS severity,
22095
- json_extract(e.metadata, '$.filePath') AS path,
22096
- COALESCE(f.first_detected_at, e.occurred_at) AS first_detected_at,
22097
- (
22098
- SELECT fr.resolved_at FROM finding_resolution fr
22099
- WHERE fr.finding_key = f.finding_key
22100
- ORDER BY fr.created_at DESC, fr.rowid DESC
22101
- LIMIT 1
22102
- ) AS latest_resolved_at
22103
- FROM findings f JOIN events e ON e.id = f.event_id
22104
- WHERE e.kind = 'code_change'
22539
+ d.rule_id AS rule_id,
22540
+ d.severity AS severity,
22541
+ json_extract(e.attributes, '$.file_path') AS path,
22542
+ COALESCE(f.first_detected_at, e.started_at) AS first_detected_at,
22543
+ latest.resolved_at AS latest_resolved_at
22544
+ FROM inspection_findings f
22545
+ JOIN audit_events e ON e.id = f.audit_event_id
22546
+ JOIN inspection_definitions d ON d.id = f.inspection_definition_id
22547
+ LEFT JOIN ${LATEST_RESOLUTION_BY_KEY_SQL} latest
22548
+ ON latest.finding_key = f.finding_key
22549
+ WHERE e.event_type = 'code_change'
22105
22550
  AND f.finding_key IS NOT NULL
22106
- AND (
22107
- SELECT fr.status FROM finding_resolution fr
22108
- WHERE fr.finding_key = f.finding_key
22109
- ORDER BY fr.created_at DESC, fr.rowid DESC
22110
- LIMIT 1
22111
- ) = 'resolved'
22112
- AND (
22113
- SELECT fr.method FROM finding_resolution fr
22114
- WHERE fr.finding_key = f.finding_key
22115
- ORDER BY fr.created_at DESC, fr.rowid DESC
22116
- LIMIT 1
22117
- ) = 'fixed-at-source'
22118
- AND (
22119
- SELECT fr.resolved_at FROM finding_resolution fr
22120
- WHERE fr.finding_key = f.finding_key
22121
- ORDER BY fr.created_at DESC, fr.rowid DESC
22122
- LIMIT 1
22123
- ) IS NOT NULL
22551
+ AND latest.status = 'resolved'
22552
+ AND latest.method = 'fixed-at-source'
22553
+ AND latest.resolved_at IS NOT NULL
22124
22554
  ORDER BY latest_resolved_at DESC
22125
22555
  LIMIT :limit`
22126
22556
  ),
@@ -22139,15 +22569,18 @@ var SqliteSecurityRepository = class {
22139
22569
  return Promise.resolve({ items });
22140
22570
  }
22141
22571
  // Findings whose parent event occurred in [fromMs, toMs), with the parent's
22142
- // epoch-millis timestamp. occurred_at is an INTEGER column, so the bounds stay
22572
+ // epoch-millis timestamp. started_at is an INTEGER column, so the bounds stay
22143
22573
  // numeric and the JS aggregations bucket/split on ms directly.
22144
22574
  findingsInRange(fromMs, toMs) {
22145
22575
  const rows = allRows(
22146
22576
  this.db.prepare(
22147
- `SELECT e.occurred_at AS occurred_at, f.severity AS severity, f.action_taken AS action_taken
22148
- FROM findings f JOIN events e ON e.id = f.event_id
22149
- WHERE e.occurred_at >= :from AND e.occurred_at < :to
22150
- ORDER BY e.occurred_at`
22577
+ `SELECT e.started_at AS occurred_at, d.severity AS severity, f.action_taken AS action_taken
22578
+ FROM inspection_findings f
22579
+ JOIN audit_events e ON e.id = f.audit_event_id
22580
+ JOIN inspection_definitions d ON d.id = f.inspection_definition_id
22581
+ WHERE e.started_at >= :from AND e.started_at < :to
22582
+ AND e.event_type IN (${CAPTURE_EVENT_TYPES_SQL})
22583
+ ORDER BY e.started_at`
22151
22584
  ),
22152
22585
  { from: fromMs, to: toMs }
22153
22586
  );
@@ -22971,9 +23404,10 @@ function openWithPragmas(file2) {
22971
23404
  }
22972
23405
  function backupLegacyStore(file2) {
22973
23406
  const backup = `${file2}.legacy.${String(Date.now())}.bak`;
22974
- renameSync(file2, backup);
22975
- for (const sidecar of walSidecars(file2)) {
22976
- if (existsSync(sidecar)) rmSync(sidecar);
23407
+ renameSync2(file2, backup);
23408
+ tightenFile(backup);
23409
+ for (const sidecar of dbSidecars(file2)) {
23410
+ if (existsSync(sidecar)) rmSync2(sidecar);
22977
23411
  }
22978
23412
  return backup;
22979
23413
  }
@@ -22989,7 +23423,7 @@ function openLocalDatabase(dir) {
22989
23423
  `Detected an older, incompatible (tenant-bearing) ${DB_FILENAME}; backed it up to ${backup} and created a fresh store.`
22990
23424
  );
22991
23425
  }
22992
- applyMigrations(db);
23426
+ applyMigrations(db, file2);
22993
23427
  tightenPerms(file2);
22994
23428
  const events = new SqliteEventsRepository(db);
22995
23429
  const findings = new SqliteFindingsRepository(db);
@@ -23016,9 +23450,56 @@ function openLocalDatabase(dir) {
23016
23450
  policies.seedDefaults();
23017
23451
  function recordCapture(event, detected) {
23018
23452
  failOpenTransaction(db, () => {
23019
- events.insertEvent(event);
23020
23453
  const sessionId = event.metadata?.sessionId;
23021
- findings.insertFindings(detected, sessionId ? { sessionId } : {});
23454
+ if (sessionId) {
23455
+ auditEvents.ensureSessionRoot(sessionId, event.occurredAt);
23456
+ }
23457
+ const auditEventId = captureId(
23458
+ sessionId ?? null,
23459
+ event.contentHash,
23460
+ event.metadata?.filePath ?? null
23461
+ );
23462
+ auditEvents.insertAuditEvent({
23463
+ id: auditEventId,
23464
+ eventType: event.kind,
23465
+ startedAt: event.occurredAt,
23466
+ parentId: sessionId,
23467
+ rootSessionId: sessionId,
23468
+ content: event.content,
23469
+ contentHash: event.contentHash,
23470
+ attributes: toCaptureAttributes(event)
23471
+ });
23472
+ const definitionIds = /* @__PURE__ */ new Map();
23473
+ for (const finding of detected) {
23474
+ if (sessionId && inspectionFindings.isSessionDuplicate(finding.ruleId, finding.maskedMatch, sessionId)) {
23475
+ continue;
23476
+ }
23477
+ if (inspectionFindings.isEventDuplicate(
23478
+ auditEventId,
23479
+ finding.ruleId,
23480
+ finding.maskedMatch,
23481
+ finding.span.start,
23482
+ finding.span.end
23483
+ )) {
23484
+ continue;
23485
+ }
23486
+ const key = `${finding.ruleId}@${captureDefinitionVersion(finding)}`;
23487
+ let definitionId = definitionIds.get(key);
23488
+ if (!definitionId) {
23489
+ definitionId = inspectionDefinitions.upsert(toCaptureDefinitionInput(finding));
23490
+ definitionIds.set(key, definitionId);
23491
+ }
23492
+ inspectionFindings.insertFinding({
23493
+ id: finding.id,
23494
+ auditEventId,
23495
+ inspectionDefinitionId: definitionId,
23496
+ span: finding.span,
23497
+ maskedMatch: finding.maskedMatch,
23498
+ actionTaken: finding.actionTaken,
23499
+ confidence: finding.confidence,
23500
+ findingKey: finding.findingKey ?? void 0
23501
+ });
23502
+ }
23022
23503
  });
23023
23504
  }
23024
23505
  function ensureInventory(ctx) {
@@ -23166,9 +23647,19 @@ function openLocalDatabase(dir) {
23166
23647
  };
23167
23648
  }
23168
23649
 
23650
+ // ../../packages/persistence/src/finding-key.ts
23651
+ import { createHash as createHash3 } from "crypto";
23652
+ function normalizeFilePath(filePath) {
23653
+ return filePath.replaceAll("\\", "/");
23654
+ }
23655
+ function computeFindingKey(input) {
23656
+ const normalizedPath = normalizeFilePath(input.filePath);
23657
+ return createHash3("sha256").update(`${input.ruleId}\0${normalizedPath}\0${input.valueFingerprint}`).digest("hex");
23658
+ }
23659
+
23169
23660
  // ../../packages/persistence/src/fingerprint.ts
23170
23661
  import { createHmac, randomBytes } from "crypto";
23171
- import { chmodSync as chmodSync2, readFileSync, renameSync as renameSync2, writeFileSync } from "fs";
23662
+ import { readFileSync } from "fs";
23172
23663
  import { join as join2 } from "path";
23173
23664
  var KEY_FILENAME = "exception.key";
23174
23665
  var KEY_MATERIAL_BYTES = 32;
@@ -23196,15 +23687,9 @@ function parseKeyFile(raw) {
23196
23687
  function writeKeyFile(dataDir2, key) {
23197
23688
  ensureDataDirSync(dataDir2);
23198
23689
  const file2 = keyFilePath(dataDir2);
23199
- const tmp = `${file2}.tmp`;
23200
23690
  const body = JSON.stringify({ version: key.version, material: key.material.toString("base64") });
23201
- writeFileSync(tmp, `${body}
23202
- `, { mode: DATA_FILE_MODE });
23203
- renameSync2(tmp, file2);
23204
- try {
23205
- chmodSync2(file2, DATA_FILE_MODE);
23206
- } catch {
23207
- }
23691
+ writeOwnerOnlyFileSync(file2, `${body}
23692
+ `);
23208
23693
  return key;
23209
23694
  }
23210
23695
  function readFingerprintKey(dataDir2) {
@@ -23220,10 +23705,7 @@ function readFingerprintKey(dataDir2) {
23220
23705
  function loadOrCreateFingerprintKey(dataDir2) {
23221
23706
  const existing = readFingerprintKey(dataDir2);
23222
23707
  if (existing) {
23223
- try {
23224
- chmodSync2(keyFilePath(dataDir2), DATA_FILE_MODE);
23225
- } catch {
23226
- }
23708
+ tightenFile(keyFilePath(dataDir2));
23227
23709
  return existing;
23228
23710
  }
23229
23711
  return writeKeyFile(dataDir2, { version: 1, material: randomBytes(KEY_MATERIAL_BYTES) });
@@ -23233,8 +23715,8 @@ function fingerprintValue(key, raw) {
23233
23715
  }
23234
23716
 
23235
23717
  // ../../packages/persistence/src/local-layout.ts
23236
- import { chmodSync as chmodSync3, mkdirSync as mkdirSync2, renameSync as renameSync3 } from "fs";
23237
- import { chmod, mkdir } from "fs/promises";
23718
+ import { renameSync as renameSync3 } from "fs";
23719
+ import { mkdir } from "fs/promises";
23238
23720
  import { homedir } from "os";
23239
23721
  import { join as join3 } from "path";
23240
23722
  function defaultDataDir() {
@@ -23249,6 +23731,9 @@ function dataDir(base = defaultDataDir()) {
23249
23731
  function dbPath(base = defaultDataDir()) {
23250
23732
  return join3(dataDir(base), "aka.db");
23251
23733
  }
23734
+ function ensureLayoutDirSync(dir = defaultDataDir()) {
23735
+ ensureDataDirSync(dir);
23736
+ }
23252
23737
  function migrateLegacyLayout(base = defaultDataDir()) {
23253
23738
  const moves = [
23254
23739
  { name: "config.json", dest: settingsDir(base) },
@@ -23256,19 +23741,17 @@ function migrateLegacyLayout(base = defaultDataDir()) {
23256
23741
  ];
23257
23742
  for (const { name, dest } of moves) {
23258
23743
  try {
23259
- mkdirSync2(dest, { recursive: true, mode: DATA_DIR_MODE });
23260
- try {
23261
- chmodSync3(dest, DATA_DIR_MODE);
23262
- } catch {
23263
- }
23264
- renameSync3(join3(base, name), join3(dest, name));
23744
+ ensureDataDirSync(dest);
23745
+ const moved = join3(dest, name);
23746
+ renameSync3(join3(base, name), moved);
23747
+ tightenFile(moved);
23265
23748
  } catch {
23266
23749
  }
23267
23750
  }
23268
23751
  }
23269
23752
 
23270
23753
  // ../../packages/persistence/src/settings.ts
23271
- import { readFileSync as readFileSync2, renameSync as renameSync4, writeFileSync as writeFileSync2 } from "fs";
23754
+ import { readFileSync as readFileSync2 } from "fs";
23272
23755
  import { join as join4 } from "path";
23273
23756
  function readWorkspaceSettings(base = defaultDataDir()) {
23274
23757
  const record2 = readJson(join4(settingsDir(base), "settings.json"));
@@ -23290,7 +23773,7 @@ function readJson(file2) {
23290
23773
  }
23291
23774
 
23292
23775
  // ../../packages/persistence/src/warn-era-cap.ts
23293
- import { existsSync as existsSync2, writeFileSync as writeFileSync3 } from "fs";
23776
+ import { existsSync as existsSync2, writeFileSync as writeFileSync2 } from "fs";
23294
23777
  import { join as join5 } from "path";
23295
23778
  var MARKER = "warn-era-capped";
23296
23779
  function capWarnEraEnforcementOnce(db, policyMode, dataDir2) {
@@ -23298,7 +23781,7 @@ function capWarnEraEnforcementOnce(db, policyMode, dataDir2) {
23298
23781
  const marker = join5(dataDir2, MARKER);
23299
23782
  if (existsSync2(marker)) return { capped: 0, skipped: "already-run" };
23300
23783
  const capped = db.policies.capCategoryActions();
23301
- writeFileSync3(marker, `${new Date(Date.now()).toISOString()}
23784
+ writeFileSync2(marker, `${new Date(Date.now()).toISOString()}
23302
23785
  `, { mode: DATA_FILE_MODE });
23303
23786
  return { capped };
23304
23787
  }
@@ -23363,6 +23846,12 @@ function providerFromModelId(modelId) {
23363
23846
 
23364
23847
  // ../../packages/plugin-sdk/src/config.ts
23365
23848
  function loadConfig(base = defaultDataDir()) {
23849
+ try {
23850
+ ensureLayoutDirSync(base);
23851
+ const settingsFile = join6(settingsDir(base), "settings.json");
23852
+ if (existsSync3(settingsFile)) tightenFile(settingsFile);
23853
+ } catch {
23854
+ }
23366
23855
  migrateLegacyLayout(base);
23367
23856
  const settings = readWorkspaceSettings(base);
23368
23857
  return {
@@ -23385,7 +23874,7 @@ function resolveProviderSafe() {
23385
23874
  // ../../packages/plugin-sdk/src/config-inventory.ts
23386
23875
  import { readdirSync, readFileSync as readFileSync4, realpathSync, statSync as statSync2 } from "fs";
23387
23876
  import { homedir as homedir2 } from "os";
23388
- import { basename as basename2, join as join7 } from "path";
23877
+ import { basename as basename2, join as join8 } from "path";
23389
23878
 
23390
23879
  // ../../packages/detections/src/egress/registry.ts
23391
23880
  var EXTRACTOR_VERSION = "1";
@@ -26417,7 +26906,7 @@ function ensureBundledPacks() {
26417
26906
  return false;
26418
26907
  }
26419
26908
  }
26420
- function scanText(text) {
26909
+ function scanText(text, ruleVersions) {
26421
26910
  if (!ensureBundledPacks()) return { masked: "[REDACTED]", findings: [] };
26422
26911
  try {
26423
26912
  const rules = getLoadedRules();
@@ -26429,7 +26918,7 @@ function scanText(text) {
26429
26918
  return {
26430
26919
  ruleId: m.ruleId,
26431
26920
  ruleName: rule?.name ?? m.ruleId,
26432
- ruleVersion: String(rule?.specVersion ?? 1),
26921
+ ruleVersion: ruleVersions?.[m.ruleId] ?? String(rule?.specVersion ?? 1),
26433
26922
  category: m.category,
26434
26923
  severity: m.severity,
26435
26924
  span: m.span,
@@ -26444,8 +26933,8 @@ function scanText(text) {
26444
26933
  }
26445
26934
 
26446
26935
  // ../../packages/plugin-sdk/src/repo.ts
26447
- import { existsSync as existsSync3, readFileSync as readFileSync3, statSync } from "fs";
26448
- import { basename, dirname, isAbsolute, join as join6, sep as sep2 } from "path";
26936
+ import { existsSync as existsSync4, readFileSync as readFileSync3, statSync } from "fs";
26937
+ import { basename, dirname, isAbsolute, join as join7, sep as sep2 } from "path";
26449
26938
  function resolveRepoIdentity(cwd) {
26450
26939
  try {
26451
26940
  const root = findGitRoot(cwd);
@@ -26478,32 +26967,32 @@ function resolveRepoNwo(cwd) {
26478
26967
  function findGitRoot(start) {
26479
26968
  let dir = start;
26480
26969
  for (; ; ) {
26481
- if (existsSync3(join6(dir, ".git"))) return dir;
26970
+ if (existsSync4(join7(dir, ".git"))) return dir;
26482
26971
  const parent = dirname(dir);
26483
26972
  if (parent === dir) return void 0;
26484
26973
  dir = parent;
26485
26974
  }
26486
26975
  }
26487
26976
  function resolveGitContext(root) {
26488
- const dotGit = join6(root, ".git");
26977
+ const dotGit = join7(root, ".git");
26489
26978
  try {
26490
26979
  if (statSync(dotGit).isDirectory()) {
26491
- return { configPath: join6(dotGit, "config"), headRoot: root };
26980
+ return { configPath: join7(dotGit, "config"), headRoot: root };
26492
26981
  }
26493
26982
  } catch {
26494
26983
  return void 0;
26495
26984
  }
26496
26985
  const target = /^gitdir:\s*(.+?)\s*$/m.exec(safeRead(dotGit) ?? "")?.[1];
26497
26986
  if (!target) return void 0;
26498
- const gitdir = isAbsolute(target) ? target : join6(root, target);
26499
- if (existsSync3(join6(gitdir, "config"))) {
26500
- return { configPath: join6(gitdir, "config"), headRoot: root };
26987
+ const gitdir = isAbsolute(target) ? target : join7(root, target);
26988
+ if (existsSync4(join7(gitdir, "config"))) {
26989
+ return { configPath: join7(gitdir, "config"), headRoot: root };
26501
26990
  }
26502
- const commonRaw = safeRead(join6(gitdir, "commondir"))?.trim();
26991
+ const commonRaw = safeRead(join7(gitdir, "commondir"))?.trim();
26503
26992
  if (!commonRaw) return void 0;
26504
- const commonGitDir = isAbsolute(commonRaw) ? commonRaw : join6(gitdir, commonRaw);
26993
+ const commonGitDir = isAbsolute(commonRaw) ? commonRaw : join7(gitdir, commonRaw);
26505
26994
  const headRoot = basename(commonGitDir) === ".git" ? dirname(commonGitDir) : root;
26506
- return { configPath: join6(commonGitDir, "config"), headRoot };
26995
+ return { configPath: join7(commonGitDir, "config"), headRoot };
26507
26996
  }
26508
26997
  function safeRead(path) {
26509
26998
  try {
@@ -26558,9 +27047,9 @@ function nwoFromUrl(url2) {
26558
27047
  }
26559
27048
 
26560
27049
  // ../../packages/plugin-sdk/src/events.ts
26561
- import { createHash as createHash3, randomUUID as randomUUID9 } from "crypto";
27050
+ import { createHash as createHash4, randomUUID as randomUUID9 } from "crypto";
26562
27051
  function contentHashOf(text) {
26563
- return createHash3("sha256").update(text).digest("hex");
27052
+ return createHash4("sha256").update(text).digest("hex");
26564
27053
  }
26565
27054
  function buildIngestEvent(input) {
26566
27055
  return {
@@ -26580,16 +27069,6 @@ function buildIngestEvent(input) {
26580
27069
  };
26581
27070
  }
26582
27071
 
26583
- // ../../packages/plugin-sdk/src/finding-key.ts
26584
- import { createHash as createHash4 } from "crypto";
26585
- function normalizeFilePath(filePath) {
26586
- return filePath.replaceAll("\\", "/");
26587
- }
26588
- function computeFindingKey(input) {
26589
- const normalizedPath = normalizeFilePath(input.filePath);
26590
- return createHash4("sha256").update(`${input.ruleId}\0${normalizedPath}\0${input.valueFingerprint}`).digest("hex");
26591
- }
26592
-
26593
27072
  // ../../packages/plugin-sdk/src/inventory-resolver.ts
26594
27073
  import { arch, hostname as hostname3, platform, release } from "os";
26595
27074
  function resolveInventoryContext(input) {
@@ -26620,8 +27099,8 @@ function resolveInventoryContext(input) {
26620
27099
  }
26621
27100
 
26622
27101
  // ../../packages/plugin-sdk/src/nudge.ts
26623
- import { mkdirSync as mkdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
26624
- import { join as join8 } from "path";
27102
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
27103
+ import { join as join9 } from "path";
26625
27104
 
26626
27105
  // ../../packages/plugin-sdk/src/paths.ts
26627
27106
  import { readdirSync as readdirSync2, realpathSync as realpathSync2 } from "fs";
@@ -26629,8 +27108,8 @@ import { basename as basename3, dirname as dirname2, sep as sep3 } from "path";
26629
27108
 
26630
27109
  // ../../packages/plugin-sdk/src/project-files.ts
26631
27110
  var import_ignore = __toESM(require_ignore(), 1);
26632
- import { existsSync as existsSync4, readdirSync as readdirSync3, readFileSync as readFileSync6 } from "fs";
26633
- import { basename as basename4, join as join9, relative, sep as sep4 } from "path";
27111
+ import { existsSync as existsSync5, readdirSync as readdirSync3, readFileSync as readFileSync6 } from "fs";
27112
+ import { basename as basename4, join as join10, relative, sep as sep4 } from "path";
26634
27113
 
26635
27114
  // ../../packages/plugin-sdk/src/raw-egress.ts
26636
27115
  var RawEgressError = class extends Error {
@@ -27020,8 +27499,8 @@ function createPluginRuntime(gateway, settings, opts) {
27020
27499
  var THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1e3;
27021
27500
 
27022
27501
  // ../../packages/plugin-sdk/src/throttle.ts
27023
- import { mkdirSync as mkdirSync4, statSync as statSync3, writeFileSync as writeFileSync5 } from "fs";
27024
- import { join as join10 } from "path";
27502
+ import { mkdirSync as mkdirSync3, statSync as statSync3, writeFileSync as writeFileSync4 } from "fs";
27503
+ import { join as join11 } from "path";
27025
27504
 
27026
27505
  // ../../packages/plugin-runtime/src/standalone-gateway.ts
27027
27506
  import { randomUUID as randomUUID11 } from "crypto";
@@ -27052,7 +27531,8 @@ var StandaloneDataGateway = class {
27052
27531
  }
27053
27532
  // The id is minted inside the repository from the natural key — the plugin can't
27054
27533
  // import @akasecurity/persistence to compute it, so the gateway is the boundary that
27055
- // hands the natural key across. INSERT OR IGNORE → idempotent re-reads.
27534
+ // hands the natural key across. UPSERT-take-MAX → idempotent re-reads that also
27535
+ // converge a streaming partial/final split (see insertLlmCall).
27056
27536
  recordLlmCall(input) {
27057
27537
  this.db.auditEvents.insertLlmCall(input);
27058
27538
  return Promise.resolve();
@@ -27094,7 +27574,9 @@ var StandaloneDataGateway = class {
27094
27574
  // caller's transaction (Layer 2b). The audit-event id the findings FK into is the
27095
27575
  // SAME content-addressed `toolCallId` the leaf insert mints, so both re-read
27096
27576
  // idempotently. Definitions/classified-data are idempotent upserts; findings are
27097
- // content-addressed INSERT OR IGNORE.
27577
+ // content-addressed upserts (ON CONFLICT(id) DO UPDATE SET inspection_definition_id),
27578
+ // so a re-detection under a bumped rule version repoints the definition FK rather
27579
+ // than no-opping.
27098
27580
  writeToolCall(input) {
27099
27581
  this.db.auditEvents.insertToolCall(input);
27100
27582
  if (input.inspections.length === 0) return;
@@ -27114,7 +27596,7 @@ var StandaloneDataGateway = class {
27114
27596
  });
27115
27597
  const classifiedDataId2 = this.db.classifiedData.upsert({ class: insp.category });
27116
27598
  this.db.inspectionFindings.insertFinding({
27117
- id: inspectionFindingId(auditEventId, definitionId, insp.span.start, insp.span.end),
27599
+ id: inspectionFindingId(auditEventId, insp.ruleId, insp.span.start, insp.span.end),
27118
27600
  auditEventId,
27119
27601
  inspectionDefinitionId: definitionId,
27120
27602
  classifiedDataId: classifiedDataId2,
@@ -27163,10 +27645,17 @@ var StandaloneDataGateway = class {
27163
27645
  try {
27164
27646
  const snapshot = this.db.installedPacks.installedRuleset();
27165
27647
  if (snapshot.installedPacks === 0) return void 0;
27166
- if (snapshot.enabledPacks === 0) return { rules: [], ruleActions: /* @__PURE__ */ new Map(), complete: true };
27648
+ if (snapshot.enabledPacks === 0) {
27649
+ return { rules: [], ruleActions: /* @__PURE__ */ new Map(), ruleVersions: /* @__PURE__ */ new Map(), complete: true };
27650
+ }
27167
27651
  if (snapshot.invalidRules > 0) return void 0;
27168
27652
  if (snapshot.rules.length === 0) return void 0;
27169
- return { rules: snapshot.rules, ruleActions: snapshot.ruleActions, complete: true };
27653
+ return {
27654
+ rules: snapshot.rules,
27655
+ ruleActions: snapshot.ruleActions,
27656
+ ruleVersions: snapshot.ruleVersions,
27657
+ complete: true
27658
+ };
27170
27659
  } catch {
27171
27660
  return void 0;
27172
27661
  }
@@ -27194,6 +27683,7 @@ var StandaloneDataGateway = class {
27194
27683
  policies: [...policies, ...rulePolicies],
27195
27684
  rules: installed ? installed.rules : [],
27196
27685
  ...installed ? { rulesComplete: true } : {},
27686
+ ...installed ? { ruleVersions: Object.fromEntries(installed.ruleVersions) } : {},
27197
27687
  ...exceptions !== void 0 ? { exceptions } : {},
27198
27688
  customKeywords,
27199
27689
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -27334,9 +27824,9 @@ var EXCEPTION_RETENTION_MS = 90 * 24 * 60 * 60 * 1e3;
27334
27824
  // src/history/transcripts.ts
27335
27825
  import { readdirSync as readdirSync4, readFileSync as readFileSync7 } from "fs";
27336
27826
  import { homedir as homedir3 } from "os";
27337
- import { join as join11 } from "path";
27827
+ import { join as join12 } from "path";
27338
27828
  function transcriptsDir(home) {
27339
- return join11(home ?? homedir3(), ".claude", "projects");
27829
+ return join12(home ?? homedir3(), ".claude", "projects");
27340
27830
  }
27341
27831
  function isRecord(value) {
27342
27832
  return typeof value === "object" && value !== null;
@@ -27404,9 +27894,9 @@ function parseTranscriptUsage(jsonl, sinceMs = 0) {
27404
27894
  if (sinceMs > 0 && Date.parse(optString(rec.timestamp) ?? "") < sinceMs) continue;
27405
27895
  if (rec.type === "user") {
27406
27896
  const uuid5 = optString(rec.uuid);
27407
- const promptId = optString(rec.promptId);
27408
- if (uuid5 === void 0 || promptId === void 0) continue;
27409
- out.push({ kind: "user", uuid: uuid5, promptId });
27897
+ const promptId2 = optString(rec.promptId);
27898
+ if (uuid5 === void 0 || promptId2 === void 0) continue;
27899
+ out.push({ kind: "user", uuid: uuid5, promptId: promptId2 });
27410
27900
  continue;
27411
27901
  }
27412
27902
  if (rec.type !== "assistant") continue;
@@ -27584,7 +28074,7 @@ function* iterateFileContents(dir, excludeSessionId) {
27584
28074
  return;
27585
28075
  }
27586
28076
  for (const project of projects) {
27587
- const projectDir = join11(dir, project);
28077
+ const projectDir = join12(dir, project);
27588
28078
  let files;
27589
28079
  try {
27590
28080
  files = readdirSync4(projectDir).filter((name) => name.endsWith(".jsonl"));
@@ -27594,7 +28084,7 @@ function* iterateFileContents(dir, excludeSessionId) {
27594
28084
  for (const file2 of files) {
27595
28085
  if (excludeSessionId !== void 0 && file2.slice(0, -".jsonl".length) === excludeSessionId)
27596
28086
  continue;
27597
- const filePath = join11(projectDir, file2);
28087
+ const filePath = join12(projectDir, file2);
27598
28088
  let content;
27599
28089
  try {
27600
28090
  content = readFileSync7(filePath, "utf8");
@@ -27715,13 +28205,13 @@ import { createHash as createHash5 } from "crypto";
27715
28205
  import {
27716
28206
  closeSync,
27717
28207
  fstatSync,
27718
- mkdirSync as mkdirSync5,
28208
+ mkdirSync as mkdirSync4,
27719
28209
  openSync,
27720
28210
  readFileSync as readFileSync8,
27721
28211
  readSync,
27722
- writeFileSync as writeFileSync6
28212
+ writeFileSync as writeFileSync5
27723
28213
  } from "fs";
27724
- import { join as join12 } from "path";
28214
+ import { join as join13 } from "path";
27725
28215
 
27726
28216
  // src/history/usage.ts
27727
28217
  var NO_PROJECT_CWD = "/nonexistent/aka-reconciler/no-project";
@@ -27772,12 +28262,18 @@ async function reconcileSessionToolCalls(gateway, sessionId, toolCalls, usageRec
27772
28262
  if (toolCalls.length === 0) return 0;
27773
28263
  const promptIdByUuid = /* @__PURE__ */ new Map();
27774
28264
  for (const r of usageRecords) if (r.kind === "user") promptIdByUuid.set(r.uuid, r.promptId);
28265
+ let ruleVersions;
28266
+ try {
28267
+ ruleVersions = (await gateway.getPolicyBundle()).ruleVersions;
28268
+ } catch {
28269
+ ruleVersions = void 0;
28270
+ }
27775
28271
  const inputs = toolCalls.map((tc) => {
27776
28272
  const runKey = (tc.parentUuid !== void 0 ? promptIdByUuid.get(tc.parentUuid) : void 0) ?? opts.seedPromptId;
27777
28273
  const attributes = { tool_name: tc.toolName, tool_use_id: tc.toolUseId };
27778
28274
  let inspections = [];
27779
28275
  if (tc.target !== void 0) {
27780
- const { masked, findings } = scanText(tc.target);
28276
+ const { masked, findings } = scanText(tc.target, ruleVersions);
27781
28277
  if (masked !== "") attributes.target = truncateTarget(masked);
27782
28278
  inspections = findings.map((f) => ({
27783
28279
  ruleId: f.ruleId,