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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -491,9 +491,13 @@ var require_ignore = __commonJS({
491
491
  }
492
492
  });
493
493
 
494
+ // ../../packages/plugin-sdk/src/config.ts
495
+ import { existsSync as existsSync4 } from "fs";
496
+ import { join as join6 } from "path";
497
+
494
498
  // ../../packages/persistence/src/database.ts
495
499
  import { randomUUID as randomUUID8 } from "crypto";
496
- import { existsSync, renameSync, rmSync } from "fs";
500
+ import { existsSync, renameSync as renameSync2, rmSync as rmSync2 } from "fs";
497
501
  import { join, sep } from "path";
498
502
  import { DatabaseSync } from "node:sqlite";
499
503
 
@@ -546,6 +550,18 @@ var SQLITE_MIGRATIONS = [
546
550
  {
547
551
  tag: "0011_egress_writer",
548
552
  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'
553
+ },
554
+ {
555
+ tag: "0012_handy_the_captain",
556
+ 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`);"
557
+ },
558
+ {
559
+ tag: "0013_legacy_history_backfill_support",
560
+ 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"
561
+ },
562
+ {
563
+ tag: "0014_drop_legacy_events_findings",
564
+ 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"
549
565
  }
550
566
  ];
551
567
 
@@ -15376,7 +15392,12 @@ var FindingFacets = external_exports.object({
15376
15392
  severity: external_exports.array(FindingFacetItem),
15377
15393
  subtype: external_exports.array(FindingFacetItem),
15378
15394
  provider: external_exports.array(FindingFacetItem),
15379
- action: external_exports.array(FindingFacetItem)
15395
+ action: external_exports.array(FindingFacetItem),
15396
+ // Counts by the group's derived status. The SQLite store derives a status
15397
+ // for every instance, so every group lands in a bucket; a status-less
15398
+ // group (possible only for callers whose rows carry no statuses) is
15399
+ // counted under no value.
15400
+ status: external_exports.array(FindingFacetItem)
15380
15401
  }).meta({ id: "FindingFacets" });
15381
15402
  var DEFAULT_GROUPED_FINDINGS_LIMIT = 50;
15382
15403
  var ListGroupedFindingsQuery = external_exports.object({
@@ -15386,6 +15407,10 @@ var ListGroupedFindingsQuery = external_exports.object({
15386
15407
  subtype: external_exports.array(external_exports.string()).optional(),
15387
15408
  provider: external_exports.array(FindingProvider).optional(),
15388
15409
  action: external_exports.array(FindingAction).optional(),
15410
+ // Matches a group's DERIVED status (see FindingGroup.status), not its
15411
+ // individual instances' — so a filtered group's Status column always reads
15412
+ // one of the requested values.
15413
+ status: external_exports.array(FindingStatus).optional(),
15389
15414
  q: external_exports.string().optional(),
15390
15415
  // Scope to findings whose event carries this session id (the Activity page's
15391
15416
  // session → findings drilldown). Findings without a session never match.
@@ -15573,6 +15598,33 @@ var ToolCallAttributes = external_exports.object({
15573
15598
  parent_uuid: external_exports.string().optional(),
15574
15599
  run_key: external_exports.string().optional()
15575
15600
  }).catchall(external_exports.unknown());
15601
+ var CaptureAttributes = external_exports.object({
15602
+ // The harness/tool that produced the capture (`claude-code`, `cli`, …). A
15603
+ // column on the legacy `events` table; here it rides the bag because a
15604
+ // capture-typed audit row has no equivalent column of its own.
15605
+ source_tool: external_exports.string().optional(),
15606
+ file_path: external_exports.string().optional(),
15607
+ repo: external_exports.string().optional(),
15608
+ // The host tool whose input/output was scanned (e.g. 'Bash', 'WebFetch') —
15609
+ // gives a non-file capture a display location ("via Bash") when file_path
15610
+ // is absent. The tool NAME only, never its arguments/output.
15611
+ tool_name: external_exports.string().optional(),
15612
+ // Presence-only provenance flag: set when the file is excluded by the
15613
+ // repo's .gitignore. Omitted (not false) for tracked files.
15614
+ gitignored: external_exports.boolean().optional(),
15615
+ // Set ONLY when the capture is a COMPLETE file snapshot (a worktree scan
15616
+ // reading from disk), never a partial fragment (a hook-captured edit).
15617
+ whole_file: external_exports.boolean().optional(),
15618
+ // Distributed-tracing correlation: `correlation_id` ties the capture back to
15619
+ // the request that produced it; `trace_id` is the originating span's W3C
15620
+ // trace id when telemetry is enabled.
15621
+ correlation_id: external_exports.uuid().optional(),
15622
+ trace_id: external_exports.string().regex(/^[0-9a-f]{32}$/).optional(),
15623
+ // Ids of the detection exceptions that downgraded findings in this capture
15624
+ // to 'allow' — the enforcement audit trail's link back to the grant that
15625
+ // authorized the bypass.
15626
+ exception_ids: external_exports.array(external_exports.guid()).optional()
15627
+ }).catchall(external_exports.unknown());
15576
15628
  var ToolCallInspection = external_exports.object({
15577
15629
  ruleId: external_exports.string().min(1),
15578
15630
  ruleName: external_exports.string(),
@@ -15659,7 +15711,18 @@ var InspectionFindingInput = external_exports.object({
15659
15711
  span: Span,
15660
15712
  maskedMatch: external_exports.string(),
15661
15713
  actionTaken: ActionTaken,
15662
- confidence: external_exports.number().min(0).max(1)
15714
+ confidence: external_exports.number().min(0).max(1),
15715
+ // Stable, content-addressed key correlating this finding across re-detections
15716
+ // — mirrors the legacy `findings.finding_key` (uq_inspection_findings_key is
15717
+ // its unique index). Optional: only an at-rest/re-scannable finding carries
15718
+ // one; an in-flight capture (prompt/response) has nothing to re-detect
15719
+ // against and leaves it unset, so every insert is a fresh row.
15720
+ findingKey: external_exports.string().optional(),
15721
+ // The ORIGINAL detection time, preserved across a later re-detection of the
15722
+ // same findingKey — mirrors the legacy `findings.first_detected_at`.
15723
+ // Optional: when omitted, the writer derives it from the referenced audit
15724
+ // event's startedAt on first insert (see SqliteInspectionFindingsRepository).
15725
+ firstDetectedAt: external_exports.iso.datetime().optional()
15663
15726
  });
15664
15727
  var InventoryContext = external_exports.object({
15665
15728
  host: InventoryInput.optional(),
@@ -15861,6 +15924,7 @@ var ActivityOverviewResponse = external_exports.object({
15861
15924
 
15862
15925
  // ../../packages/schema/src/zod/event.ts
15863
15926
  var EventKind = external_exports.enum(["prompt", "response", "code_change", "tool_use"]).meta({ id: "EventKind" });
15927
+ var CAPTURE_EVENT_TYPES_SQL = EventKind.options.map((k) => `'${k}'`).join(",");
15864
15928
  var SourceTool = external_exports.enum(["claude-code", "claude-desktop", "cursor", "chatgpt", "github-copilot", "cli", "unknown"]).meta({ id: "SourceTool" });
15865
15929
  var EventMetadata = external_exports.object({
15866
15930
  sessionId: external_exports.string().optional(),
@@ -16350,6 +16414,12 @@ var PolicyBundle = external_exports.object({
16350
16414
  // on-disk caches — that omit the field still parse; consumers read
16351
16415
  // `bundle.exceptions ?? []`.
16352
16416
  exceptions: external_exports.array(ExceptionBundleEntry).optional(),
16417
+ // Installed pack version, keyed by ruleId, for rules in `rules` that came
16418
+ // from a versioned installed pack. Optional so older backends — and older
16419
+ // on-disk caches — that omit the field still parse; consumers fall back to
16420
+ // the rule's own spec version. NOT the bundle version above — see
16421
+ // installedRuleset's ruleVersions for the source of truth.
16422
+ ruleVersions: external_exports.record(external_exports.string(), external_exports.string()).optional(),
16353
16423
  customKeywords: external_exports.array(external_exports.string()),
16354
16424
  fetchedAt: external_exports.iso.datetime()
16355
16425
  }).meta({ id: "PolicyBundle" });
@@ -17149,6 +17219,15 @@ function groupActions(g) {
17149
17219
  actionsCache.set(g, actions);
17150
17220
  return actions;
17151
17221
  }
17222
+ function countInstancesByStatus(statusInputs, statuses) {
17223
+ const statusSet = new Set(statuses);
17224
+ let sum = 0;
17225
+ for (const input of statusInputs) {
17226
+ if (input.count === void 0) return null;
17227
+ if (statusSet.has(deriveFindingStatus(input))) sum += input.count;
17228
+ }
17229
+ return sum;
17230
+ }
17152
17231
  function applyFindingFilters(groups, opts) {
17153
17232
  let filtered = groups;
17154
17233
  if (opts.severity && opts.severity.length > 0) {
@@ -17167,6 +17246,10 @@ function applyFindingFilters(groups, opts) {
17167
17246
  const subtypeSet = new Set(opts.subtype);
17168
17247
  filtered = filtered.filter((g) => subtypeSet.has(g.subtype));
17169
17248
  }
17249
+ if (opts.statuses && opts.statuses.length > 0) {
17250
+ const statusSet = new Set(opts.statuses);
17251
+ filtered = filtered.filter((g) => g.status !== void 0 && statusSet.has(g.status));
17252
+ }
17170
17253
  if (opts.q) {
17171
17254
  const q = opts.q.toLowerCase();
17172
17255
  filtered = filtered.filter((g) => groupHaystack(g).includes(q));
@@ -17188,6 +17271,7 @@ function computeFindingFacets(allGroups, opts) {
17188
17271
  const forSeverity = applyFindingFilters(allGroups, {
17189
17272
  providers: opts.providers,
17190
17273
  actions: opts.actions,
17274
+ statuses: opts.statuses,
17191
17275
  q: opts.q,
17192
17276
  subtype: opts.subtype
17193
17277
  });
@@ -17197,6 +17281,7 @@ function computeFindingFacets(allGroups, opts) {
17197
17281
  }
17198
17282
  const forProvider = applyFindingFilters(allGroups, {
17199
17283
  actions: opts.actions,
17284
+ statuses: opts.statuses,
17200
17285
  q: opts.q,
17201
17286
  subtype: opts.subtype,
17202
17287
  severity: opts.severity
@@ -17207,6 +17292,7 @@ function computeFindingFacets(allGroups, opts) {
17207
17292
  }
17208
17293
  const forAction = applyFindingFilters(allGroups, {
17209
17294
  providers: opts.providers,
17295
+ statuses: opts.statuses,
17210
17296
  q: opts.q,
17211
17297
  subtype: opts.subtype,
17212
17298
  severity: opts.severity
@@ -17218,17 +17304,30 @@ function computeFindingFacets(allGroups, opts) {
17218
17304
  const forSubtype = applyFindingFilters(allGroups, {
17219
17305
  providers: opts.providers,
17220
17306
  actions: opts.actions,
17307
+ statuses: opts.statuses,
17221
17308
  q: opts.q,
17222
17309
  severity: opts.severity
17223
17310
  });
17224
17311
  const subtypeMap = /* @__PURE__ */ new Map();
17225
17312
  for (const g of forSubtype) subtypeMap.set(g.subtype, (subtypeMap.get(g.subtype) ?? 0) + 1);
17313
+ const forStatus = applyFindingFilters(allGroups, {
17314
+ providers: opts.providers,
17315
+ actions: opts.actions,
17316
+ q: opts.q,
17317
+ subtype: opts.subtype,
17318
+ severity: opts.severity
17319
+ });
17320
+ const statusMap = /* @__PURE__ */ new Map();
17321
+ for (const g of forStatus) {
17322
+ if (g.status !== void 0) statusMap.set(g.status, (statusMap.get(g.status) ?? 0) + 1);
17323
+ }
17226
17324
  const toItems = (m) => [...m.entries()].map(([value, count]) => ({ value, count }));
17227
17325
  return {
17228
17326
  severity: toItems(severityMap),
17229
17327
  provider: toItems(providerMap),
17230
17328
  action: toItems(actionMap),
17231
- subtype: toItems(subtypeMap)
17329
+ subtype: toItems(subtypeMap),
17330
+ status: toItems(statusMap)
17232
17331
  };
17233
17332
  }
17234
17333
 
@@ -17263,10 +17362,14 @@ var PatchInstalledPackRequest = external_exports.object({
17263
17362
  }).meta({ id: "PatchInstalledPackRequest" });
17264
17363
 
17265
17364
  // ../../packages/schema/src/zod/local.ts
17266
- var WORKSPACE_SETTINGS_SPEC_VERSION = 3;
17365
+ var WORKSPACE_SETTINGS_SPEC_VERSION = 4;
17267
17366
  var RunMode = external_exports.enum(["standalone"]);
17268
17367
  var SimpleDetectionPolicy = external_exports.enum(["redact", "warn"]);
17269
17368
  var HistoricalAccess = external_exports.enum(["full", "session-only"]);
17369
+ var ModelJudgeConsent = external_exports.object({
17370
+ acknowledgedAt: external_exports.iso.datetime(),
17371
+ payloadVersion: external_exports.number().int().positive()
17372
+ });
17270
17373
  var WorkspaceSettings = external_exports.object({
17271
17374
  specVersion: external_exports.number().int().positive().default(WORKSPACE_SETTINGS_SPEC_VERSION),
17272
17375
  // Settings files written by earlier releases may carry the retired 'attached'
@@ -17282,37 +17385,16 @@ var WorkspaceSettings = external_exports.object({
17282
17385
  // Shares writes.
17283
17386
  dataSharesInPlace: external_exports.boolean().default(true),
17284
17387
  // Absent until /aka:setup completes; its presence is what "onboarded" means.
17285
- onboardedAt: external_exports.iso.datetime().optional()
17388
+ onboardedAt: external_exports.iso.datetime().optional(),
17389
+ // Records that the user consented to sending findings to the model API for
17390
+ // the /aka:setup judge, along with the payload-shape version they agreed to.
17391
+ // Absent until granted; a stale payloadVersion means the consent no longer
17392
+ // covers the current payload and must be re-granted.
17393
+ modelJudgeConsent: ModelJudgeConsent.optional()
17286
17394
  });
17287
17395
  function defaultWorkspaceSettings() {
17288
17396
  return WorkspaceSettings.parse({});
17289
17397
  }
17290
- function toEventRow(event) {
17291
- return {
17292
- id: event.id,
17293
- sourceTool: event.sourceTool,
17294
- kind: event.kind,
17295
- occurredAt: isoToEpochMillis(event.occurredAt),
17296
- contentHash: event.contentHash,
17297
- content: event.content,
17298
- metadata: event.metadata ? JSON.stringify(event.metadata) : null
17299
- };
17300
- }
17301
- function toFindingRow(finding) {
17302
- return {
17303
- id: finding.id,
17304
- eventId: finding.eventId,
17305
- ruleId: finding.ruleId,
17306
- category: finding.category,
17307
- severity: finding.severity,
17308
- spanStart: finding.span.start,
17309
- spanEnd: finding.span.end,
17310
- maskedMatch: finding.maskedMatch,
17311
- actionTaken: finding.actionTaken,
17312
- confidence: finding.confidence,
17313
- findingKey: finding.findingKey ?? null
17314
- };
17315
- }
17316
17398
  function toInventoryRow(input, id, now) {
17317
17399
  return {
17318
17400
  id,
@@ -17382,7 +17464,42 @@ function toInspectionFindingRow(input) {
17382
17464
  spanEnd: input.span.end,
17383
17465
  maskedMatch: input.maskedMatch,
17384
17466
  actionTaken: input.actionTaken,
17385
- confidence: input.confidence
17467
+ confidence: input.confidence,
17468
+ findingKey: input.findingKey ?? null,
17469
+ firstDetectedAt: input.firstDetectedAt ? isoToEpochMillis(input.firstDetectedAt) : null
17470
+ };
17471
+ }
17472
+ function toCaptureAttributes(event) {
17473
+ const metadata = event.metadata;
17474
+ return {
17475
+ source_tool: event.sourceTool,
17476
+ ...metadata?.repo !== void 0 ? { repo: metadata.repo } : {},
17477
+ ...metadata?.filePath !== void 0 ? { file_path: metadata.filePath } : {},
17478
+ ...metadata?.toolName !== void 0 ? { tool_name: metadata.toolName } : {},
17479
+ ...metadata?.gitignored !== void 0 ? { gitignored: metadata.gitignored } : {},
17480
+ ...metadata?.wholeFile !== void 0 ? { whole_file: metadata.wholeFile } : {},
17481
+ ...metadata?.correlationId !== void 0 ? { correlation_id: metadata.correlationId } : {},
17482
+ ...metadata?.traceId !== void 0 ? { trace_id: metadata.traceId } : {},
17483
+ ...metadata?.exceptionIds !== void 0 ? { exception_ids: metadata.exceptionIds } : {},
17484
+ // `model`/`turnIndex` have no dedicated CaptureAttributes field (no writer
17485
+ // has ever populated either), but every legacy metadata key still rides
17486
+ // the bag rather than being silently dropped — CaptureAttributes'
17487
+ // `.catchall(z.unknown())` carries the long tail.
17488
+ ...metadata?.model !== void 0 ? { model: metadata.model } : {},
17489
+ ...metadata?.turnIndex !== void 0 ? { turn_index: metadata.turnIndex } : {}
17490
+ };
17491
+ }
17492
+ function captureDefinitionVersion(finding) {
17493
+ return `capture/${finding.category}/${finding.severity}`;
17494
+ }
17495
+ function toCaptureDefinitionInput(finding) {
17496
+ return {
17497
+ ruleId: finding.ruleId,
17498
+ version: captureDefinitionVersion(finding),
17499
+ name: finding.ruleId,
17500
+ category: finding.category,
17501
+ severity: finding.severity,
17502
+ definition: JSON.stringify({ ruleId: finding.ruleId })
17386
17503
  };
17387
17504
  }
17388
17505
 
@@ -17810,6 +17927,48 @@ function reviewSeverityRank(reasons) {
17810
17927
  return Math.min(...reasons.map((r) => REVIEW_SEVERITY_RANK[r]));
17811
17928
  }
17812
17929
 
17930
+ // ../../packages/persistence/src/ids.ts
17931
+ import { createHash } from "crypto";
17932
+ function sha256Hex(input) {
17933
+ return createHash("sha256").update(input).digest("hex");
17934
+ }
17935
+ function inventoryId(objectType, identityKey) {
17936
+ return sha256Hex(canonicalIdentity(["inventory", objectType, identityKey]));
17937
+ }
17938
+ function sourceProjectId(url2) {
17939
+ return sha256Hex(canonicalIdentity(["source_project", url2]));
17940
+ }
17941
+ function classifiedDataId(cls) {
17942
+ return sha256Hex(canonicalIdentity(["classified_data", cls]));
17943
+ }
17944
+ function inspectionDefinitionId(ruleId, version2) {
17945
+ return sha256Hex(canonicalIdentity(["inspection_definition", ruleId, version2]));
17946
+ }
17947
+ function llmCallId(sessionId, messageId) {
17948
+ return sha256Hex(canonicalIdentity(["audit_event_llm_call", sessionId, messageId]));
17949
+ }
17950
+ function toolCallId(sessionId, toolUseId) {
17951
+ return sha256Hex(canonicalIdentity(["audit_event_tool_call", sessionId, toolUseId]));
17952
+ }
17953
+ function inspectionFindingId(auditEventId, ruleId, spanStart, spanEnd) {
17954
+ return sha256Hex(
17955
+ canonicalIdentity([
17956
+ "inspection_finding",
17957
+ auditEventId,
17958
+ ruleId,
17959
+ String(spanStart),
17960
+ String(spanEnd)
17961
+ ])
17962
+ );
17963
+ }
17964
+ var NO_SESSION = "no_session";
17965
+ var NO_PATH = "no_path";
17966
+ function captureId(sessionId, contentHash, filePath = null) {
17967
+ return sha256Hex(
17968
+ canonicalIdentity(["capture", sessionId ?? NO_SESSION, contentHash, filePath ?? NO_PATH])
17969
+ );
17970
+ }
17971
+
17813
17972
  // ../../packages/persistence/src/internal/sql-text.ts
17814
17973
  function escapeLikePattern(s) {
17815
17974
  return s.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
@@ -17906,39 +18065,98 @@ function evidenceExists(db, object2) {
17906
18065
  return schemaObjectExists(db, "table", object2.name);
17907
18066
  }
17908
18067
 
17909
- // ../../packages/persistence/src/ids.ts
17910
- import { createHash } from "crypto";
17911
- function sha256Hex(input) {
17912
- return createHash("sha256").update(input).digest("hex");
18068
+ // ../../packages/persistence/src/internal/rows.ts
18069
+ function allRows(stmt, params) {
18070
+ if (params === void 0) return stmt.all();
18071
+ if (Array.isArray(params)) return stmt.all(...params);
18072
+ return stmt.all(params);
17913
18073
  }
17914
- function inventoryId(objectType, identityKey) {
17915
- return sha256Hex(canonicalIdentity(["inventory", objectType, identityKey]));
18074
+ function getRow(stmt, params) {
18075
+ if (params === void 0) return stmt.get();
18076
+ if (Array.isArray(params)) return stmt.get(...params);
18077
+ return stmt.get(params);
17916
18078
  }
17917
- function sourceProjectId(url2) {
17918
- return sha256Hex(canonicalIdentity(["source_project", url2]));
18079
+ function intToBool(raw) {
18080
+ return raw === 1 || raw === true;
17919
18081
  }
17920
- function classifiedDataId(cls) {
17921
- return sha256Hex(canonicalIdentity(["classified_data", cls]));
18082
+ function boolToInt(b) {
18083
+ return b ? 1 : 0;
17922
18084
  }
17923
- function inspectionDefinitionId(ruleId, version2) {
17924
- return sha256Hex(canonicalIdentity(["inspection_definition", ruleId, version2]));
18085
+ function bindParams(row) {
18086
+ const out = {};
18087
+ for (const [key, value] of Object.entries(row)) {
18088
+ out[key] = value === void 0 ? null : value;
18089
+ }
18090
+ return out;
17925
18091
  }
17926
- function llmCallId(sessionId, messageId) {
17927
- return sha256Hex(canonicalIdentity(["audit_event_llm_call", sessionId, messageId]));
18092
+ function countScalar(db, sql, params) {
18093
+ return getRow(db.prepare(sql), params)?.n ?? 0;
17928
18094
  }
17929
- function toolCallId(sessionId, toolUseId) {
17930
- return sha256Hex(canonicalIdentity(["audit_event_tool_call", sessionId, toolUseId]));
18095
+ function countBy(db, sql, params) {
18096
+ const map2 = /* @__PURE__ */ new Map();
18097
+ for (const row of allRows(db.prepare(sql), params)) {
18098
+ map2.set(row.k, row.n);
18099
+ }
18100
+ return map2;
17931
18101
  }
17932
- function inspectionFindingId(auditEventId, definitionId, spanStart, spanEnd) {
17933
- return sha256Hex(
17934
- canonicalIdentity([
17935
- "inspection_finding",
17936
- auditEventId,
17937
- definitionId,
17938
- String(spanStart),
17939
- String(spanEnd)
17940
- ])
17941
- );
18102
+ function mapRowsTolerant(rows, map2) {
18103
+ const out = [];
18104
+ for (const row of rows) {
18105
+ try {
18106
+ out.push(map2(row));
18107
+ } catch {
18108
+ }
18109
+ }
18110
+ return out;
18111
+ }
18112
+
18113
+ // ../../packages/persistence/src/paths.ts
18114
+ import { chmodSync, lstatSync, mkdirSync, renameSync, rmSync, writeFileSync } from "fs";
18115
+ var DATA_DIR_MODE = 448;
18116
+ var DATA_FILE_MODE = 384;
18117
+ var DB_FILENAME = "aka.db";
18118
+ function chmodBestEffort(path, mode) {
18119
+ try {
18120
+ chmodSync(path, mode);
18121
+ } catch {
18122
+ }
18123
+ }
18124
+ function tightenDir(dir) {
18125
+ chmodBestEffort(dir, DATA_DIR_MODE);
18126
+ }
18127
+ function ensureDataDirSync(dir) {
18128
+ mkdirSync(dir, { recursive: true, mode: DATA_DIR_MODE });
18129
+ tightenDir(dir);
18130
+ }
18131
+ function dbSidecars(file2) {
18132
+ return [`${file2}-wal`, `${file2}-shm`, `${file2}-journal`];
18133
+ }
18134
+ function tightenFile(file2) {
18135
+ try {
18136
+ if (lstatSync(file2).isSymbolicLink()) return;
18137
+ } catch {
18138
+ }
18139
+ chmodBestEffort(file2, DATA_FILE_MODE);
18140
+ }
18141
+ function tightenPerms(file2) {
18142
+ for (const path of [file2, ...dbSidecars(file2)]) chmodBestEffort(path, DATA_FILE_MODE);
18143
+ }
18144
+ function writeOwnerOnlyFileSync(file2, data) {
18145
+ const tmp = `${file2}.${String(process.pid)}.tmp`;
18146
+ try {
18147
+ rmSync(tmp, { force: true });
18148
+ } catch {
18149
+ }
18150
+ try {
18151
+ writeFileSync(tmp, data, { mode: DATA_FILE_MODE, flag: "wx" });
18152
+ renameSync(tmp, file2);
18153
+ } finally {
18154
+ try {
18155
+ rmSync(tmp, { force: true });
18156
+ } catch {
18157
+ }
18158
+ }
18159
+ tightenFile(file2);
17942
18160
  }
17943
18161
 
17944
18162
  // ../../packages/persistence/src/migrations.ts
@@ -17952,7 +18170,8 @@ function createdIndexName(statement) {
17952
18170
  const body = statement.replace(/^(?:\s*--[^\n]*\n?)+/, "").trimStart();
17953
18171
  return /^CREATE (?:UNIQUE )?INDEX (?:IF NOT EXISTS )?`([^`]+)`/.exec(body)?.[1];
17954
18172
  }
17955
- function applyMigrations(db) {
18173
+ var LEGACY_DROP_MIGRATION_TAG = "0014_drop_legacy_events_findings";
18174
+ function applyMigrations(db, file2) {
17956
18175
  const legacyCount = db.prepare("PRAGMA user_version").get().user_version;
17957
18176
  db.exec(
17958
18177
  "CREATE TABLE IF NOT EXISTS migration_ledger (tag TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)"
@@ -17966,6 +18185,7 @@ function applyMigrations(db) {
17966
18185
  );
17967
18186
  for (const [index, migration] of SQLITE_MIGRATIONS.entries()) {
17968
18187
  if (applied.has(migration.tag)) continue;
18188
+ if (migration.tag === LEGACY_DROP_MIGRATION_TAG) continue;
17969
18189
  const evidence = evidenceObjects(migration.sql);
17970
18190
  const present = evidence.filter((o) => evidenceExists(db, o));
17971
18191
  if (present.length > 0 && present.length < evidence.length) {
@@ -18010,7 +18230,6 @@ function applyMigrations(db) {
18010
18230
  if (legacyCount < SQLITE_MIGRATIONS.length) {
18011
18231
  db.exec(`PRAGMA user_version = ${String(SQLITE_MIGRATIONS.length)}`);
18012
18232
  }
18013
- ensureSyncedAtColumn(db, "events");
18014
18233
  ensureSyncedAtColumn(db, "audit_events");
18015
18234
  ensureScanLedgerTable(db);
18016
18235
  ensureBlockedDetectionsTable(db);
@@ -18018,6 +18237,47 @@ function applyMigrations(db) {
18018
18237
  ensureWriteGateTrigger(db);
18019
18238
  ensureTokenUsageColumns(db);
18020
18239
  reconcileSourceProjectIds(db);
18240
+ if (!applied.has(LEGACY_DROP_MIGRATION_TAG)) {
18241
+ const drained = runLegacyHistoryBackfill(db);
18242
+ if (drained) applyLegacyDropMigration(db, file2);
18243
+ }
18244
+ }
18245
+ function applyLegacyDropMigration(db, file2) {
18246
+ const migration = SQLITE_MIGRATIONS.find((m) => m.tag === LEGACY_DROP_MIGRATION_TAG);
18247
+ if (!migration) return;
18248
+ if (file2) {
18249
+ try {
18250
+ backupBeforeLegacyDrop(db, file2);
18251
+ } catch (error51) {
18252
+ akaWarn(`legacy events/findings backup failed; deferring the drop: ${String(error51)}`);
18253
+ return;
18254
+ }
18255
+ }
18256
+ try {
18257
+ withTransaction(
18258
+ db,
18259
+ () => {
18260
+ const alreadyDropped = db.prepare("SELECT 1 FROM migration_ledger WHERE tag = ?").get(migration.tag);
18261
+ if (alreadyDropped) return;
18262
+ for (const statement of splitStatements(migration.sql)) {
18263
+ db.exec(statement);
18264
+ }
18265
+ db.prepare("INSERT OR IGNORE INTO migration_ledger (tag, applied_at) VALUES (?, ?)").run(
18266
+ migration.tag,
18267
+ Date.now()
18268
+ );
18269
+ },
18270
+ "IMMEDIATE"
18271
+ );
18272
+ } catch (error51) {
18273
+ akaWarn(`legacy events/findings drop failed; deferring: ${String(error51)}`);
18274
+ }
18275
+ }
18276
+ function backupBeforeLegacyDrop(db, file2) {
18277
+ const backup = `${file2}.pre-drop.${String(Date.now())}.bak`;
18278
+ db.prepare("VACUUM INTO ?").run(backup);
18279
+ tightenFile(backup);
18280
+ return backup;
18021
18281
  }
18022
18282
  var TOKEN_USAGE_COLUMNS = [
18023
18283
  {
@@ -18046,6 +18306,7 @@ var TOKEN_USAGE_COLUMNS = [
18046
18306
  }
18047
18307
  ];
18048
18308
  function ensureTokenUsageColumns(db) {
18309
+ if (!schemaObjectExists(db, "table", "audit_events")) return;
18049
18310
  const existing = new Set(columnNames(db, "audit_events", { includeGenerated: true }));
18050
18311
  for (const column of TOKEN_USAGE_COLUMNS) {
18051
18312
  if (!existing.has(column.name)) {
@@ -18111,11 +18372,187 @@ function reconcileSourceProjectIds(db) {
18111
18372
  akaWarn(`source_project id reconcile failed: ${String(error51)}`);
18112
18373
  }
18113
18374
  }
18375
+ var LEGACY_BACKFILL_BATCH_SIZE = 200;
18376
+ var LEGACY_BACKFILL_MAX_ROWS_PER_CALL = 1e3;
18377
+ function getLegacyCopyWatermark(db, source) {
18378
+ const row = db.prepare("SELECT last_rowid AS lastRowid FROM legacy_copy_watermark WHERE source = ?").get(source);
18379
+ return row?.lastRowid ?? 0;
18380
+ }
18381
+ function setLegacyCopyWatermark(db, source, lastRowid) {
18382
+ db.prepare(
18383
+ `INSERT INTO legacy_copy_watermark (source, last_rowid) VALUES (?, ?)
18384
+ ON CONFLICT(source) DO UPDATE SET last_rowid = excluded.last_rowid`
18385
+ ).run(source, lastRowid);
18386
+ }
18387
+ function drainLegacyTable(db, source, selectStmt, handleRows) {
18388
+ let watermark = getLegacyCopyWatermark(db, source);
18389
+ let processed = 0;
18390
+ while (processed < LEGACY_BACKFILL_MAX_ROWS_PER_CALL) {
18391
+ const rows = selectStmt.all(watermark, LEGACY_BACKFILL_BATCH_SIZE);
18392
+ if (rows.length === 0) return true;
18393
+ withTransaction(
18394
+ db,
18395
+ () => {
18396
+ handleRows(rows);
18397
+ watermark = rows[rows.length - 1]?.rowid ?? watermark;
18398
+ setLegacyCopyWatermark(db, source, watermark);
18399
+ },
18400
+ "IMMEDIATE"
18401
+ );
18402
+ processed += rows.length;
18403
+ if (rows.length < LEGACY_BACKFILL_BATCH_SIZE) return true;
18404
+ }
18405
+ return false;
18406
+ }
18407
+ function parseLegacyEventMetadata(raw) {
18408
+ if (raw === null) return void 0;
18409
+ try {
18410
+ return JSON.parse(raw);
18411
+ } catch {
18412
+ return void 0;
18413
+ }
18414
+ }
18415
+ function toLegacyAuditAttributesJson(row) {
18416
+ return JSON.stringify(
18417
+ toCaptureAttributes({
18418
+ id: row.id,
18419
+ sourceTool: row.sourceTool,
18420
+ kind: row.kind,
18421
+ occurredAt: new Date(row.occurredAt).toISOString(),
18422
+ contentHash: row.contentHash,
18423
+ content: row.content,
18424
+ metadata: row.metadata
18425
+ })
18426
+ );
18427
+ }
18428
+ function copyLegacyEvents(db) {
18429
+ const selectStmt = db.prepare(
18430
+ `SELECT rowid AS rowid, id, source_tool AS sourceTool, kind, occurred_at AS occurredAt,
18431
+ content_hash AS contentHash, content, metadata
18432
+ FROM events WHERE rowid > ? ORDER BY rowid LIMIT ?`
18433
+ );
18434
+ const insertStmt = db.prepare(
18435
+ `INSERT OR IGNORE INTO audit_events
18436
+ (id, parent_id, root_session_id, event_type, started_at, content, content_hash, attributes)
18437
+ VALUES (:id, :parentId, :rootSessionId, :eventType, :startedAt, :content, :contentHash, :attributes)`
18438
+ );
18439
+ const stubRootStmt = db.prepare(
18440
+ `INSERT OR IGNORE INTO audit_events (id, event_type, started_at) VALUES (?, 'session', ?)`
18441
+ );
18442
+ return drainLegacyTable(
18443
+ db,
18444
+ "events",
18445
+ selectStmt,
18446
+ (rows) => {
18447
+ for (const row of rows) {
18448
+ const metadata = parseLegacyEventMetadata(row.metadata);
18449
+ const sessionId = metadata?.sessionId ?? null;
18450
+ if (sessionId !== null) stubRootStmt.run(sessionId, row.occurredAt);
18451
+ insertStmt.run(
18452
+ bindParams({
18453
+ id: row.id,
18454
+ parentId: sessionId,
18455
+ rootSessionId: sessionId,
18456
+ eventType: row.kind,
18457
+ startedAt: row.occurredAt,
18458
+ content: row.content,
18459
+ contentHash: row.contentHash,
18460
+ attributes: toLegacyAuditAttributesJson({ ...row, metadata })
18461
+ })
18462
+ );
18463
+ }
18464
+ }
18465
+ );
18466
+ }
18467
+ function copyLegacyFindings(db) {
18468
+ const selectStmt = db.prepare(
18469
+ `SELECT rowid AS rowid, id, event_id AS eventId, rule_id AS ruleId, category, severity,
18470
+ span_start AS spanStart, span_end AS spanEnd, masked_match AS maskedMatch,
18471
+ action_taken AS actionTaken, confidence, finding_key AS findingKey,
18472
+ first_detected_at AS firstDetectedAt
18473
+ FROM findings WHERE rowid > ? ORDER BY rowid LIMIT ?`
18474
+ );
18475
+ const definitionStmt = db.prepare(
18476
+ `INSERT OR IGNORE INTO inspection_definitions
18477
+ (id, rule_id, name, category, severity, definition, version)
18478
+ VALUES (:id, :ruleId, :name, :category, :severity, :definition, :version)`
18479
+ );
18480
+ const findingStmt = db.prepare(
18481
+ `INSERT INTO inspection_findings
18482
+ (id, audit_event_id, inspection_definition_id, classified_data_id,
18483
+ span_start, span_end, masked_match, action_taken, confidence,
18484
+ finding_key, first_detected_at)
18485
+ VALUES
18486
+ (:id, :auditEventId, :inspectionDefinitionId, NULL,
18487
+ :spanStart, :spanEnd, :maskedMatch, :actionTaken, :confidence,
18488
+ :findingKey, :firstDetectedAt)
18489
+ ON CONFLICT(id) DO NOTHING
18490
+ ON CONFLICT (finding_key) DO UPDATE SET
18491
+ first_detected_at = CASE
18492
+ WHEN first_detected_at IS NULL THEN excluded.first_detected_at
18493
+ WHEN excluded.first_detected_at IS NULL THEN first_detected_at
18494
+ ELSE min(first_detected_at, excluded.first_detected_at)
18495
+ END`
18496
+ );
18497
+ return drainLegacyTable(
18498
+ db,
18499
+ "findings",
18500
+ selectStmt,
18501
+ (rows) => {
18502
+ const definitionIds = /* @__PURE__ */ new Map();
18503
+ for (const row of rows) {
18504
+ const tupleKey = JSON.stringify([row.ruleId, row.category, row.severity]);
18505
+ let definitionId = definitionIds.get(tupleKey);
18506
+ if (definitionId === void 0) {
18507
+ const version2 = `unmigrated/${row.category}/${row.severity}`;
18508
+ definitionId = inspectionDefinitionId(row.ruleId, version2);
18509
+ definitionStmt.run(
18510
+ bindParams({
18511
+ id: definitionId,
18512
+ ruleId: row.ruleId,
18513
+ name: row.ruleId,
18514
+ category: row.category,
18515
+ severity: row.severity,
18516
+ definition: "",
18517
+ version: version2
18518
+ })
18519
+ );
18520
+ definitionIds.set(tupleKey, definitionId);
18521
+ }
18522
+ findingStmt.run(
18523
+ bindParams({
18524
+ id: row.id,
18525
+ auditEventId: row.eventId,
18526
+ inspectionDefinitionId: definitionId,
18527
+ spanStart: row.spanStart,
18528
+ spanEnd: row.spanEnd,
18529
+ maskedMatch: row.maskedMatch,
18530
+ actionTaken: row.actionTaken,
18531
+ confidence: row.confidence,
18532
+ findingKey: row.findingKey,
18533
+ firstDetectedAt: row.firstDetectedAt
18534
+ })
18535
+ );
18536
+ }
18537
+ }
18538
+ );
18539
+ }
18540
+ function runLegacyHistoryBackfill(db) {
18541
+ try {
18542
+ const eventsCaughtUp = copyLegacyEvents(db);
18543
+ if (!eventsCaughtUp) return false;
18544
+ return copyLegacyFindings(db);
18545
+ } catch (error51) {
18546
+ akaWarn(`legacy history backfill failed: ${String(error51)}`);
18547
+ return false;
18548
+ }
18549
+ }
18114
18550
  function isForeignSqliteLineage(db) {
18115
18551
  if (schemaObjectExists(db, "table", "tenants")) return true;
18116
18552
  return columnNames(db, "events").includes("tenant_id");
18117
18553
  }
18118
18554
  function ensureSyncedAtColumn(db, table) {
18555
+ if (!schemaObjectExists(db, "table", table)) return;
18119
18556
  if (!columnNames(db, table).includes("synced_at")) {
18120
18557
  db.exec(`ALTER TABLE ${table} ADD COLUMN synced_at integer`);
18121
18558
  }
@@ -18136,6 +18573,7 @@ function ensureWriteGateTrigger(db) {
18136
18573
  CONSTRAINT "ck_pack_write_gate_single_row" CHECK("_pack_write_gate"."id" = 1)
18137
18574
  )`);
18138
18575
  db.exec("INSERT OR IGNORE INTO _pack_write_gate (id, open) VALUES (1, 0)");
18576
+ if (!schemaObjectExists(db, "table", "installed_packs")) return;
18139
18577
  db.exec(`CREATE TRIGGER IF NOT EXISTS trg_installed_packs_write_gate
18140
18578
  BEFORE UPDATE OF version, name, rules_json ON installed_packs
18141
18579
  WHEN (SELECT open FROM _pack_write_gate WHERE id = 1) IS NOT 1
@@ -18163,30 +18601,6 @@ function ensureRuleProbeCacheTable(db) {
18163
18601
  )`);
18164
18602
  }
18165
18603
 
18166
- // ../../packages/persistence/src/paths.ts
18167
- import { chmodSync, mkdirSync } from "fs";
18168
- var DATA_DIR_MODE = 448;
18169
- var DATA_FILE_MODE = 384;
18170
- var DB_FILENAME = "aka.db";
18171
- function ensureDataDirSync(dir) {
18172
- mkdirSync(dir, { recursive: true, mode: DATA_DIR_MODE });
18173
- try {
18174
- chmodSync(dir, DATA_DIR_MODE);
18175
- } catch {
18176
- }
18177
- }
18178
- function walSidecars(file2) {
18179
- return [`${file2}-wal`, `${file2}-shm`];
18180
- }
18181
- function tightenPerms(file2) {
18182
- for (const path of [file2, ...walSidecars(file2)]) {
18183
- try {
18184
- chmodSync(path, DATA_FILE_MODE);
18185
- } catch {
18186
- }
18187
- }
18188
- }
18189
-
18190
18604
  // ../../packages/persistence/src/internal/json.ts
18191
18605
  function safeJson(s, fallback) {
18192
18606
  if (s == null) return fallback;
@@ -18206,51 +18620,6 @@ function parseJsonObject(s) {
18206
18620
  return void 0;
18207
18621
  }
18208
18622
 
18209
- // ../../packages/persistence/src/internal/rows.ts
18210
- function allRows(stmt, params) {
18211
- if (params === void 0) return stmt.all();
18212
- if (Array.isArray(params)) return stmt.all(...params);
18213
- return stmt.all(params);
18214
- }
18215
- function getRow(stmt, params) {
18216
- if (params === void 0) return stmt.get();
18217
- if (Array.isArray(params)) return stmt.get(...params);
18218
- return stmt.get(params);
18219
- }
18220
- function intToBool(raw) {
18221
- return raw === 1 || raw === true;
18222
- }
18223
- function boolToInt(b) {
18224
- return b ? 1 : 0;
18225
- }
18226
- function bindParams(row) {
18227
- const out = {};
18228
- for (const [key, value] of Object.entries(row)) {
18229
- out[key] = value === void 0 ? null : value;
18230
- }
18231
- return out;
18232
- }
18233
- function countScalar(db, sql, params) {
18234
- return getRow(db.prepare(sql), params)?.n ?? 0;
18235
- }
18236
- function countBy(db, sql, params) {
18237
- const map2 = /* @__PURE__ */ new Map();
18238
- for (const row of allRows(db.prepare(sql), params)) {
18239
- map2.set(row.k, row.n);
18240
- }
18241
- return map2;
18242
- }
18243
- function mapRowsTolerant(rows, map2) {
18244
- const out = [];
18245
- for (const row of rows) {
18246
- try {
18247
- out.push(map2(row));
18248
- } catch {
18249
- }
18250
- }
18251
- return out;
18252
- }
18253
-
18254
18623
  // ../../packages/persistence/src/repositories/activity.ts
18255
18624
  var DAY_MS = 864e5;
18256
18625
  var LIVE_ACTIVITY_WINDOW_MS = 30 * 6e4;
@@ -18867,6 +19236,21 @@ var SqliteAuditEventsRepository = class {
18867
19236
  })
18868
19237
  );
18869
19238
  }
19239
+ // Idempotent stub of a session's structural root. Session-scoped leaves
19240
+ // (captures, llm_call, tool_call) FK parent_id/root_session_id onto this row;
19241
+ // INSERT OR IGNORE does NOT suppress a foreign-key violation (only
19242
+ // UNIQUE/PK/NOT NULL/CHECK), so a session-scoped insert with no root row
19243
+ // raises SQLITE_CONSTRAINT and rolls its whole transaction back — silently
19244
+ // dropping the write under failOpenTransaction. SessionStart's own root write
19245
+ // is itself fail-open and marks "attempted", not "succeeded", so a session
19246
+ // with no root row yet is a real, permanent condition, not a transient race.
19247
+ // The stub carries no dimensions/attributes; an authoritative root
19248
+ // (SessionStart / the reconciler's buildSessionRoot) wins by first-write-wins
19249
+ // on the id PK, so the stub never shadows real data. This is the single named
19250
+ // home for that FK invariant — call it before writing any session-scoped row.
19251
+ ensureSessionRoot(sessionId, startedAt) {
19252
+ this.insertAuditEvent({ id: sessionId, eventType: "session", startedAt });
19253
+ }
18870
19254
  // Insert one transcript-derived `llm_call` leaf. Unlike `insertAuditEvent`
18871
19255
  // (which takes a caller-supplied random id), the id here is MINTED internally
18872
19256
  // from the natural key — `llmCallId(sessionId, messageId)` — tenant-free like the
@@ -19328,8 +19712,14 @@ var SqliteDetectionsRepository = class {
19328
19712
  )
19329
19713
  );
19330
19714
  }
19331
- // Findings whose parent event occurred in the last 30 days and whose rule_id is
19332
- // in the given set. Mirrors the security repo's findings⋈events window join.
19715
+ // Findings whose parent audit event occurred in the last 30 days, is one of
19716
+ // the four capture kinds, and whose definition's rule_id is in the given set.
19717
+ // Mirrors the security repo's inspection_findings⋈audit_events window join.
19718
+ // rule_id lives on inspection_definitions, not the finding row, so the join
19719
+ // chains through it. audit_events also holds structural rows (session, run,
19720
+ // tool_call, llm_call, source_lookup, config_scan) that never had a legacy
19721
+ // events counterpart, so the event_type predicate keeps this count identical
19722
+ // to the old findings⋈events one.
19333
19723
  countFindingsLast30d(ruleIds) {
19334
19724
  if (ruleIds.length === 0) return 0;
19335
19725
  const since = this.now() - 30 * DAY_MS2;
@@ -19337,8 +19727,12 @@ var SqliteDetectionsRepository = class {
19337
19727
  return countScalar(
19338
19728
  this.db,
19339
19729
  `SELECT count(*) AS n
19340
- FROM findings f JOIN events e ON e.id = f.event_id
19341
- WHERE e.occurred_at >= ? AND f.rule_id IN (${inClause})`,
19730
+ FROM inspection_findings f
19731
+ JOIN audit_events e ON e.id = f.audit_event_id
19732
+ JOIN inspection_definitions d ON d.id = f.inspection_definition_id
19733
+ WHERE e.started_at >= ?
19734
+ AND e.event_type IN (${CAPTURE_EVENT_TYPES_SQL})
19735
+ AND d.rule_id IN (${inClause})`,
19342
19736
  [since, ...ruleIds]
19343
19737
  );
19344
19738
  }
@@ -19348,35 +19742,24 @@ var SqliteDetectionsRepository = class {
19348
19742
  var SqliteEventsRepository = class {
19349
19743
  constructor(db) {
19350
19744
  this.db = db;
19351
- this.insertStmt = db.prepare(
19352
- `INSERT INTO events (id, source_tool, kind, occurred_at, content_hash, content, metadata)
19353
- VALUES (:id, :sourceTool, :kind, :occurredAt, :contentHash, :content, :metadata)`
19354
- );
19355
19745
  }
19356
19746
  db;
19357
- insertStmt;
19358
- insertEvent(event) {
19359
- const row = toEventRow(event);
19360
- this.insertStmt.run(
19361
- bindParams({
19362
- id: row.id,
19363
- sourceTool: row.sourceTool,
19364
- kind: row.kind,
19365
- occurredAt: row.occurredAt,
19366
- contentHash: row.contentHash,
19367
- content: row.content,
19368
- metadata: row.metadata
19369
- })
19370
- );
19371
- }
19372
- // Every recorded event's content hash — the historical backfill loads this once
19373
- // to skip transcript messages it has already stored, so re-running the scan
19374
- // never duplicates findings.
19747
+ // Every recorded capture's content hash — the historical backfill loads this
19748
+ // once to skip transcript messages it has already stored, so re-running the
19749
+ // scan never duplicates findings.
19375
19750
  // Async (Promise.resolve over synchronous node:sqlite) so it satisfies the
19376
19751
  // async EventsReadPort contract.
19752
+ //
19753
+ // audit_events also holds structural rows (session, run, tool_call, llm_call,
19754
+ // source_lookup, config_scan) with a NULL content_hash, so the capture-kind
19755
+ // predicate isn't load-bearing here — it documents intent and keeps the scan
19756
+ // index-friendly rather than walking rows that can never match.
19377
19757
  contentHashes() {
19378
19758
  const rows = allRows(
19379
- this.db.prepare("SELECT content_hash FROM events")
19759
+ this.db.prepare(
19760
+ `SELECT content_hash FROM audit_events
19761
+ WHERE event_type IN (${CAPTURE_EVENT_TYPES_SQL})`
19762
+ )
19380
19763
  );
19381
19764
  return Promise.resolve(new Set(rows.map((r) => r.content_hash)));
19382
19765
  }
@@ -19712,17 +20095,20 @@ function parseExceptionRow(row) {
19712
20095
  }
19713
20096
 
19714
20097
  // ../../packages/persistence/src/repositories/resolution-sql.ts
19715
- function latestResolutionStatusSql(findingsAlias) {
20098
+ function latestResolutionColumnSql(column, findingsAlias) {
19716
20099
  return `(
19717
- SELECT fr.status FROM finding_resolution fr
20100
+ SELECT fr.${column} FROM finding_resolution fr
19718
20101
  WHERE fr.finding_key = ${findingsAlias}.finding_key
19719
20102
  ORDER BY fr.created_at DESC, fr.rowid DESC
19720
20103
  LIMIT 1
19721
20104
  )`;
19722
20105
  }
20106
+ function latestResolutionStatusSql(findingsAlias) {
20107
+ return latestResolutionColumnSql("status", findingsAlias);
20108
+ }
19723
20109
  var LATEST_RESOLUTION_BY_KEY_SQL = `(
19724
- SELECT finding_key, status FROM (
19725
- SELECT fr.finding_key, fr.status,
20110
+ SELECT finding_key, status, method, resolved_at FROM (
20111
+ SELECT fr.finding_key, fr.status, fr.method, fr.resolved_at,
19726
20112
  ROW_NUMBER() OVER (
19727
20113
  PARTITION BY fr.finding_key
19728
20114
  ORDER BY fr.created_at DESC, fr.rowid DESC
@@ -19749,68 +20135,21 @@ var DAY_MS3 = 864e5;
19749
20135
  var SqliteFindingsRepository = class {
19750
20136
  constructor(db) {
19751
20137
  this.db = db;
19752
- this.insertStmt = db.prepare(
19753
- `INSERT INTO findings (id, event_id, rule_id, category, severity, span_start, span_end, masked_match, action_taken, confidence, finding_key, first_detected_at)
19754
- VALUES (:id, :eventId, :ruleId, :category, :severity, :spanStart, :spanEnd, :maskedMatch, :actionTaken, :confidence, :findingKey,
19755
- (SELECT occurred_at FROM events WHERE id = :eventId))
19756
- ON CONFLICT (finding_key) DO UPDATE SET
19757
- event_id = excluded.event_id,
19758
- category = excluded.category,
19759
- severity = excluded.severity,
19760
- span_start = excluded.span_start,
19761
- span_end = excluded.span_end,
19762
- masked_match = excluded.masked_match,
19763
- action_taken = excluded.action_taken,
19764
- confidence = excluded.confidence`
19765
- );
19766
- this.sessionDupStmt = db.prepare(
19767
- `SELECT 1 FROM findings f JOIN events e ON e.id = f.event_id
19768
- WHERE f.rule_id = :ruleId AND f.masked_match = :maskedMatch
19769
- AND json_extract(e.metadata, '$.sessionId') = :sessionId
19770
- LIMIT 1`
19771
- );
19772
20138
  }
19773
20139
  db;
19774
- insertStmt;
19775
- sessionDupStmt;
19776
- insertFindings(findings, scope = {}) {
19777
- for (const finding of findings) {
19778
- if (scope.sessionId && this.isSessionDuplicate(finding, scope.sessionId)) continue;
19779
- const row = toFindingRow(finding);
19780
- this.insertStmt.run({
19781
- id: row.id,
19782
- eventId: row.eventId,
19783
- ruleId: row.ruleId,
19784
- category: row.category,
19785
- severity: row.severity,
19786
- spanStart: row.spanStart,
19787
- spanEnd: row.spanEnd,
19788
- maskedMatch: row.maskedMatch,
19789
- actionTaken: row.actionTaken,
19790
- confidence: row.confidence,
19791
- findingKey: row.findingKey ?? null
19792
- });
19793
- }
19794
- }
19795
- // True when an earlier event in the same session already recorded a finding
19796
- // with the same rule and masked value. The current event is inserted before
19797
- // its findings, but carries no findings yet, so this never self-matches.
19798
- isSessionDuplicate(finding, sessionId) {
19799
- const hit = this.sessionDupStmt.get({
19800
- ruleId: finding.ruleId,
19801
- maskedMatch: finding.maskedMatch,
19802
- sessionId
19803
- });
19804
- return hit !== void 0;
19805
- }
19806
20140
  recentFindings(opts) {
19807
20141
  const limit = opts?.limit ?? 50;
19808
20142
  const rows = allRows(
19809
20143
  this.db.prepare(
19810
- `SELECT f.id, f.event_id, f.rule_id, f.category, f.severity, f.masked_match,
19811
- f.action_taken, f.confidence, e.occurred_at, e.source_tool, e.kind
19812
- FROM findings f JOIN events e ON e.id = f.event_id
19813
- ORDER BY e.occurred_at DESC, f.rowid DESC
20144
+ `SELECT f.id, f.audit_event_id AS event_id, d.rule_id, d.category, d.severity,
20145
+ f.masked_match, f.action_taken, f.confidence, e.started_at AS occurred_at,
20146
+ json_extract(e.attributes, '$.source_tool') AS source_tool,
20147
+ e.event_type AS kind
20148
+ FROM inspection_findings f
20149
+ JOIN audit_events e ON e.id = f.audit_event_id
20150
+ JOIN inspection_definitions d ON d.id = f.inspection_definition_id
20151
+ WHERE e.event_type IN (${CAPTURE_EVENT_TYPES_SQL})
20152
+ ORDER BY e.started_at DESC, f.rowid DESC
19814
20153
  LIMIT :limit`
19815
20154
  ),
19816
20155
  { limit }
@@ -19832,25 +20171,34 @@ var SqliteFindingsRepository = class {
19832
20171
  );
19833
20172
  }
19834
20173
  /** Live-enforced findings recorded for one session — a bare COUNT over the
19835
- * session-stamped events (served by idx_events_session_id), so the Activity
20174
+ * session-stamped audit_events (served by idx_audit_session), so the Activity
19836
20175
  * page can label its findings link without the grouped pipeline. */
19837
20176
  sessionFindingsCount(sessionId) {
19838
20177
  if (!sessionId) return Promise.resolve(0);
19839
20178
  return Promise.resolve(
19840
20179
  countScalar(
19841
20180
  this.db,
19842
- `SELECT count(*) AS n FROM findings f
19843
- JOIN events e ON e.id = f.event_id
19844
- WHERE json_extract(e.metadata, '$.sessionId') = :sessionId`,
20181
+ `SELECT count(*) AS n FROM inspection_findings f
20182
+ JOIN audit_events e ON e.id = f.audit_event_id
20183
+ WHERE e.root_session_id = :sessionId
20184
+ AND e.event_type IN (${CAPTURE_EVENT_TYPES_SQL})`,
19845
20185
  { sessionId }
19846
20186
  )
19847
20187
  );
19848
20188
  }
19849
- /** Per-rule transcript firing tally for one session — reads the OTHER finding
19850
- * store (inspection_findings, keyed to audit_events): every detection the
19851
- * transcript pass recorded, counted per firing rather than per unique value.
19852
- * Rides on session-scoped grouped responses so the findings view can
19853
- * reconcile the Activity page's tally with the deduped groups it lists. */
20189
+ /** Per-rule transcript firing tally for one session — every detection the
20190
+ * transcript-reconciler pass recorded against the session's `tool_call` rows,
20191
+ * counted per firing rather than per unique value. Rides on session-scoped
20192
+ * grouped responses so the findings view can reconcile the Activity page's
20193
+ * tally with the deduped groups it lists.
20194
+ *
20195
+ * `inspection_findings`/`audit_events` are now the SAME physical tables the
20196
+ * rest of this class reads for the live-capture list above (they used to be
20197
+ * a separate store), so this excludes the four capture kinds those rows
20198
+ * already carry — without that exclusion, every live-capture finding in the
20199
+ * session would be tallied here too, double-counting against the grouped
20200
+ * list this response rides alongside. The reconciler attaches its findings
20201
+ * only to `tool_call` rows, which the exclusion leaves untouched. */
19854
20202
  sessionFirings(sessionId) {
19855
20203
  return Object.fromEntries(
19856
20204
  countBy(
@@ -19860,18 +20208,25 @@ var SqliteFindingsRepository = class {
19860
20208
  JOIN audit_events e ON e.id = f.audit_event_id
19861
20209
  JOIN inspection_definitions d ON d.id = f.inspection_definition_id
19862
20210
  WHERE e.root_session_id = :sessionId
20211
+ AND e.event_type NOT IN (${CAPTURE_EVENT_TYPES_SQL})
19863
20212
  GROUP BY d.rule_id`,
19864
20213
  { sessionId }
19865
20214
  )
19866
20215
  );
19867
20216
  }
19868
20217
  /**
19869
- * Grouped findings for the dashboard — joins findingsevents (repo/file/
19870
- * toolName from event metadata), groups by ruleId, computes per-filter-excluded facets,
19871
- * applies the requested filters, and sorts by severity then recency. Filtering
20218
+ * Grouped findings for the dashboard — joins inspection_findingsaudit_events
20219
+ * ⋈inspection_definitions (repo/file/toolName from the audit event's
20220
+ * attributes bag, rule_id/category/severity from the definition), scoped to
20221
+ * the four capture kinds (audit_events also holds structural/reconciler/scan
20222
+ * rows this list must never surface), groups by ruleId, computes
20223
+ * per-filter-excluded facets, applies the requested filters, and sorts by
20224
+ * severity then recency. Filtering
19872
20225
  * and faceting run in JS via the shared @akasecurity/schema helpers. `totals`
19873
20226
  * reflect the full filtered set; `items` is the requested
19874
- * page (default 50); no cursor (nextCursor is always null).
20227
+ * page (default 50); no cursor (nextCursor is always null). Under a `status`
20228
+ * filter, `totals.findings` counts only instances whose derived status was
20229
+ * requested, and each item's instance preview is narrowed the same way.
19875
20230
  *
19876
20231
  * Two reads, neither of which materializes a row per finding:
19877
20232
  * 1. one aggregate row per rule_id, folding EVERY instance into the numbers
@@ -19884,10 +20239,11 @@ var SqliteFindingsRepository = class {
19884
20239
  * rule is ever restated in SQL.
19885
20240
  */
19886
20241
  listGroupedFindings(query) {
19887
- const sessionPredicate = query.sessionId ? `WHERE json_extract(e.metadata, '$.sessionId') = :sessionId` : "";
20242
+ const sessionPredicate = query.sessionId ? ` AND e.root_session_id = :sessionId` : "";
20243
+ const predicate = `WHERE e.event_type IN (${CAPTURE_EVENT_TYPES_SQL})${sessionPredicate}`;
19888
20244
  const sessionParams = query.sessionId ? { sessionId: query.sessionId } : {};
19889
20245
  const aggregates = this.groupAggregates(query.q !== void 0 && query.q !== "", {
19890
- predicate: sessionPredicate,
20246
+ predicate,
19891
20247
  params: sessionParams
19892
20248
  });
19893
20249
  const rows = allRows(
@@ -19895,24 +20251,26 @@ var SqliteFindingsRepository = class {
19895
20251
  `SELECT id, rule_id, category, severity, masked_match, action_taken, confidence,
19896
20252
  occurred_at, source_tool, repo, file, tool_name, kind, finding_key, latest_status
19897
20253
  FROM (
19898
- SELECT f.id AS id, f.rule_id AS rule_id, f.category AS category,
19899
- f.severity AS severity, f.masked_match AS masked_match,
20254
+ SELECT f.id AS id, d.rule_id AS rule_id, d.category AS category,
20255
+ d.severity AS severity, f.masked_match AS masked_match,
19900
20256
  f.action_taken AS action_taken, f.confidence AS confidence,
19901
- e.occurred_at AS occurred_at, e.source_tool AS source_tool,
19902
- json_extract(e.metadata, '$.repo') AS repo,
19903
- json_extract(e.metadata, '$.filePath') AS file,
19904
- json_extract(e.metadata, '$.toolName') AS tool_name,
19905
- e.kind AS kind, f.finding_key AS finding_key,
20257
+ e.started_at AS occurred_at,
20258
+ json_extract(e.attributes, '$.source_tool') AS source_tool,
20259
+ json_extract(e.attributes, '$.repo') AS repo,
20260
+ json_extract(e.attributes, '$.file_path') AS file,
20261
+ json_extract(e.attributes, '$.tool_name') AS tool_name,
20262
+ e.event_type AS kind, f.finding_key AS finding_key,
19906
20263
  latest.status AS latest_status,
19907
20264
  ROW_NUMBER() OVER (
19908
- PARTITION BY f.rule_id
19909
- ORDER BY e.occurred_at DESC, f.id DESC
20265
+ PARTITION BY d.rule_id
20266
+ ORDER BY e.started_at DESC, f.id DESC
19910
20267
  ) AS rn
19911
- FROM findings f
19912
- JOIN events e ON e.id = f.event_id
20268
+ FROM inspection_findings f
20269
+ JOIN audit_events e ON e.id = f.audit_event_id
20270
+ JOIN inspection_definitions d ON d.id = f.inspection_definition_id
19913
20271
  LEFT JOIN ${LATEST_RESOLUTION_BY_KEY_SQL} latest
19914
20272
  ON latest.finding_key = f.finding_key
19915
- ${sessionPredicate}
20273
+ ${predicate}
19916
20274
  )
19917
20275
  WHERE rn <= :cap
19918
20276
  ORDER BY occurred_at DESC, id DESC`
@@ -19939,17 +20297,29 @@ var SqliteFindingsRepository = class {
19939
20297
  severity: query.severity,
19940
20298
  providers: query.provider,
19941
20299
  actions: query.action,
20300
+ statuses: query.status,
19942
20301
  subtype: query.subtype,
19943
20302
  q: query.q
19944
20303
  };
19945
20304
  const facets = computeFindingFacets(allGroups, filterOpts);
19946
20305
  const sorted = sortFindingGroups(applyFindingFilters(allGroups, filterOpts));
20306
+ const statusFilter = query.status ?? [];
19947
20307
  const totals = {
19948
- findings: sorted.reduce((acc, g) => acc + g.instanceCount, 0),
20308
+ findings: sorted.reduce((acc, g) => {
20309
+ if (statusFilter.length === 0) return acc + g.instanceCount;
20310
+ const agg = aggregates.get(g.id);
20311
+ return acc + (agg ? countInstancesByStatus(agg.statusInputs, statusFilter) ?? g.instanceCount : g.instanceCount);
20312
+ }, 0),
19949
20313
  groups: sorted.length
19950
20314
  };
19951
20315
  const limit = query.limit ?? DEFAULT_GROUPED_FINDINGS_LIMIT;
19952
- const items = sorted.slice(0, limit);
20316
+ const statusSet = statusFilter.length > 0 ? new Set(statusFilter) : null;
20317
+ const items = sorted.slice(0, limit).map(
20318
+ (g) => statusSet ? {
20319
+ ...g,
20320
+ instances: g.instances.filter((i) => i.status !== void 0 && statusSet.has(i.status))
20321
+ } : g
20322
+ );
19953
20323
  return Promise.resolve({
19954
20324
  totals,
19955
20325
  facets,
@@ -19963,45 +20333,62 @@ var SqliteFindingsRepository = class {
19963
20333
  * buildFindingGroups cannot recover from a preview. Bounded by the number of
19964
20334
  * distinct rule_ids (the installed packs' rules), not by the store's size.
19965
20335
  *
19966
- * The per-instance sets ride back as group_concat lists of RAW DB values —
19967
- * source_tool, action_taken, and the (kind, has-key, latest-status) triples
19968
- * deriveFindingStatus consumes. Aggregating the status INPUTS rather than a
19969
- * status keeps the classifier itself in @akasecurity/schema, where
19970
- * severitySummary's SQL and this query can't drift apart on what 'resolved'
19971
- * means (see resolution-sql.ts). Each of those sets is bounded by an enum, so
20336
+ * A single scan, folded in two levels: the inner SELECT groups by
20337
+ * (rule_id, status tuple) so each (kind, has-key, latest-status) combination
20338
+ * carries its instance count countInstancesByStatus needs those counts for
20339
+ * status-scoped totals — and the outer SELECT folds the tuples back to one
20340
+ * row per rule. The per-instance sets ride back as group_concat lists of RAW
20341
+ * DB values source_tool, action_taken, and the tuples deriveFindingStatus
20342
+ * consumes. Aggregating the status INPUTS rather than a status keeps the
20343
+ * classifier itself in @akasecurity/schema, where severitySummary's SQL and
20344
+ * this query can't drift apart on what 'resolved' means (see
20345
+ * resolution-sql.ts). The concat-of-concats can repeat a value across
20346
+ * tuples; the schema mappers dedupe, and each set is bounded by an enum, so
19972
20347
  * a group's row stays small however many findings it holds.
19973
20348
  *
19974
20349
  * `withSearchText` is the exception, and the one column here that does NOT
19975
- * stay small: the group's distinct repos/filePaths, whose size tracks how many
19976
- * distinct paths a rule fired across — for a rule hitting mostly-unique paths
19977
- * that is a string proportional to the store (~8MB over 200k distinct paths,
19978
- * and buildHaystack lowercases a second copy). It buys `q` the ability to
19979
- * match an instance outside the preview, which searching the preview alone
19980
- * would silently lose, so it is fetched only when the request actually
19981
- * carries a `q`.
20350
+ * stay small: the group's per-tuple-distinct repos/filePaths, whose size
20351
+ * tracks how many distinct paths a rule fired across — for a rule hitting
20352
+ * mostly-unique paths that is a string proportional to the store (~8MB over
20353
+ * 200k distinct paths, and buildHaystack lowercases a second copy). It buys
20354
+ * `q` the ability to match an instance outside the preview, which searching
20355
+ * the preview alone would silently lose, so it is fetched only when the
20356
+ * request actually carries a `q`. (Substring matching is unaffected by a
20357
+ * path repeating across tuples.)
19982
20358
  */
19983
20359
  groupAggregates(withSearchText, scope) {
19984
- const searchTextColumns = withSearchText ? `, group_concat(DISTINCT json_extract(e.metadata, '$.repo')) AS repos,
19985
- group_concat(DISTINCT json_extract(e.metadata, '$.filePath')) AS files,
19986
- group_concat(DISTINCT 'via ' || json_extract(e.metadata, '$.toolName')) AS tool_names` : `, NULL AS repos, NULL AS files, NULL AS tool_names`;
20360
+ const innerSearchColumns = withSearchText ? `, group_concat(DISTINCT json_extract(e.attributes, '$.repo')) AS repos,
20361
+ group_concat(DISTINCT json_extract(e.attributes, '$.file_path')) AS files,
20362
+ group_concat(DISTINCT 'via ' || json_extract(e.attributes, '$.tool_name')) AS tool_names` : `, NULL AS repos, NULL AS files, NULL AS tool_names`;
19987
20363
  const rows = this.db.prepare(
19988
- `SELECT f.rule_id AS rule_id,
19989
- count(*) AS instance_count,
19990
- max(e.occurred_at) AS latest_at,
19991
- group_concat(DISTINCT e.source_tool) AS source_tools,
19992
- group_concat(DISTINCT f.action_taken) AS actions_taken,
19993
- group_concat(DISTINCT (
19994
- e.kind || '${TUPLE_SEP}' ||
19995
- (CASE WHEN f.finding_key IS NULL THEN '' ELSE 'k' END) || '${TUPLE_SEP}' ||
19996
- coalesce(latest.status, '')
19997
- )) AS status_inputs
19998
- ${searchTextColumns}
19999
- FROM findings f
20000
- JOIN events e ON e.id = f.event_id
20001
- LEFT JOIN ${LATEST_RESOLUTION_BY_KEY_SQL} latest
20002
- ON latest.finding_key = f.finding_key
20003
- ${scope.predicate}
20004
- GROUP BY f.rule_id`
20364
+ `SELECT rule_id,
20365
+ sum(tuple_count) AS instance_count,
20366
+ max(latest_at) AS latest_at,
20367
+ group_concat(source_tools) AS source_tools,
20368
+ group_concat(actions_taken) AS actions_taken,
20369
+ group_concat(status_tuple || '${TUPLE_SEP}' || tuple_count) AS status_inputs,
20370
+ group_concat(repos) AS repos,
20371
+ group_concat(files) AS files,
20372
+ group_concat(tool_names) AS tool_names
20373
+ FROM (
20374
+ SELECT d.rule_id AS rule_id,
20375
+ e.event_type || '${TUPLE_SEP}' ||
20376
+ (CASE WHEN f.finding_key IS NULL THEN '' ELSE 'k' END) || '${TUPLE_SEP}' ||
20377
+ coalesce(latest.status, '') AS status_tuple,
20378
+ count(*) AS tuple_count,
20379
+ max(e.started_at) AS latest_at,
20380
+ group_concat(DISTINCT json_extract(e.attributes, '$.source_tool')) AS source_tools,
20381
+ group_concat(DISTINCT f.action_taken) AS actions_taken
20382
+ ${innerSearchColumns}
20383
+ FROM inspection_findings f
20384
+ JOIN audit_events e ON e.id = f.audit_event_id
20385
+ JOIN inspection_definitions d ON d.id = f.inspection_definition_id
20386
+ LEFT JOIN ${LATEST_RESOLUTION_BY_KEY_SQL} latest
20387
+ ON latest.finding_key = f.finding_key
20388
+ ${scope.predicate}
20389
+ GROUP BY d.rule_id, status_tuple
20390
+ )
20391
+ GROUP BY rule_id`
20005
20392
  ).all(scope.params);
20006
20393
  return new Map(
20007
20394
  rows.map((r) => [
@@ -20011,13 +20398,14 @@ var SqliteFindingsRepository = class {
20011
20398
  sourceTools: splitConcat(r.source_tools),
20012
20399
  actionsTaken: splitConcat(r.actions_taken),
20013
20400
  statusInputs: splitConcat(r.status_inputs).map((tuple2) => {
20014
- const [kind = "", keyMarker = "", latestStatus = ""] = tuple2.split(TUPLE_SEP);
20401
+ const [kind = "", keyMarker = "", latestStatus = "", count = ""] = tuple2.split(TUPLE_SEP);
20015
20402
  return {
20016
20403
  // deriveFindingStatus only distinguishes null from non-null here,
20017
20404
  // so the marker stands in for the key itself (never rendered).
20018
20405
  kind,
20019
20406
  findingKey: keyMarker === "" ? null : keyMarker,
20020
- latestResolutionStatus: latestStatus === "" ? null : latestStatus
20407
+ latestResolutionStatus: latestStatus === "" ? null : latestStatus,
20408
+ count: Number(count)
20021
20409
  };
20022
20410
  }),
20023
20411
  latestDetectedAt: epochMillisToIso(r.latest_at),
@@ -20034,10 +20422,21 @@ var SqliteFindingsRepository = class {
20034
20422
  );
20035
20423
  }
20036
20424
  healthSummary() {
20037
- const total = countScalar(this.db, "SELECT count(*) AS n FROM findings");
20425
+ const total = countScalar(
20426
+ this.db,
20427
+ `SELECT count(*) AS n FROM inspection_findings f
20428
+ JOIN audit_events e ON e.id = f.audit_event_id
20429
+ WHERE e.event_type IN (${CAPTURE_EVENT_TYPES_SQL})`
20430
+ );
20038
20431
  const byAction = Object.fromEntries(ACTION_TAKEN_KEYS.map((a) => [a, 0]));
20039
20432
  const grouped = allRows(
20040
- this.db.prepare("SELECT action_taken, count(*) AS c FROM findings GROUP BY action_taken")
20433
+ this.db.prepare(
20434
+ `SELECT f.action_taken AS action_taken, count(*) AS c
20435
+ FROM inspection_findings f
20436
+ JOIN audit_events e ON e.id = f.audit_event_id
20437
+ WHERE e.event_type IN (${CAPTURE_EVENT_TYPES_SQL})
20438
+ GROUP BY f.action_taken`
20439
+ )
20041
20440
  );
20042
20441
  for (const row of grouped) {
20043
20442
  if (row.action_taken in byAction) byAction[row.action_taken] = row.c;
@@ -20045,12 +20444,15 @@ var SqliteFindingsRepository = class {
20045
20444
  const bySeverity = { critical: 0, high: 0, medium: 0, low: 0 };
20046
20445
  const sevRows = allRows(
20047
20446
  this.db.prepare(
20048
- `SELECT f.severity AS severity, count(*) AS c
20049
- FROM findings f
20447
+ `SELECT d.severity AS severity, count(*) AS c
20448
+ FROM inspection_findings f
20449
+ JOIN audit_events e ON e.id = f.audit_event_id
20450
+ JOIN inspection_definitions d ON d.id = f.inspection_definition_id
20050
20451
  LEFT JOIN ${LATEST_RESOLUTION_BY_KEY_SQL} latest
20051
20452
  ON latest.finding_key = f.finding_key
20052
- WHERE latest.status IS NULL OR latest.status != 'resolved'
20053
- GROUP BY f.severity`
20453
+ WHERE e.event_type IN (${CAPTURE_EVENT_TYPES_SQL})
20454
+ AND (latest.status IS NULL OR latest.status != 'resolved')
20455
+ GROUP BY d.severity`
20054
20456
  )
20055
20457
  );
20056
20458
  for (const row of sevRows) {
@@ -20071,9 +20473,11 @@ var SqliteFindingsRepository = class {
20071
20473
  const since = startOfUtcDay(Date.now()) - (days - 1) * DAY_MS3;
20072
20474
  const rows = allRows(
20073
20475
  this.db.prepare(
20074
- `SELECT date(e.occurred_at / 1000, 'unixepoch') AS day, f.action_taken AS action, count(*) AS c
20075
- FROM findings f JOIN events e ON e.id = f.event_id
20076
- WHERE e.occurred_at >= :since
20476
+ `SELECT date(e.started_at / 1000, 'unixepoch') AS day, f.action_taken AS action, count(*) AS c
20477
+ FROM inspection_findings f
20478
+ JOIN audit_events e ON e.id = f.audit_event_id
20479
+ WHERE e.started_at >= :since
20480
+ AND e.event_type IN (${CAPTURE_EVENT_TYPES_SQL})
20077
20481
  GROUP BY day, f.action_taken`
20078
20482
  ),
20079
20483
  { since }
@@ -20138,15 +20542,59 @@ var SqliteInspectionFindingsRepository = class {
20138
20542
  this.insertStmt = db.prepare(
20139
20543
  `INSERT INTO inspection_findings
20140
20544
  (id, audit_event_id, inspection_definition_id, classified_data_id,
20141
- span_start, span_end, masked_match, action_taken, confidence)
20545
+ span_start, span_end, masked_match, action_taken, confidence,
20546
+ finding_key, first_detected_at)
20142
20547
  VALUES
20143
20548
  (:id, :auditEventId, :inspectionDefinitionId, :classifiedDataId,
20144
- :spanStart, :spanEnd, :maskedMatch, :actionTaken, :confidence)
20145
- ON CONFLICT(id) DO NOTHING`
20549
+ :spanStart, :spanEnd, :maskedMatch, :actionTaken, :confidence,
20550
+ :findingKey,
20551
+ COALESCE(:firstDetectedAt, (SELECT started_at FROM audit_events WHERE id = :auditEventId)))
20552
+ ON CONFLICT(id) DO UPDATE SET
20553
+ inspection_definition_id = excluded.inspection_definition_id
20554
+ ON CONFLICT (finding_key) DO UPDATE SET
20555
+ audit_event_id = excluded.audit_event_id,
20556
+ inspection_definition_id = excluded.inspection_definition_id,
20557
+ classified_data_id = excluded.classified_data_id,
20558
+ span_start = excluded.span_start,
20559
+ span_end = excluded.span_end,
20560
+ masked_match = excluded.masked_match,
20561
+ action_taken = excluded.action_taken,
20562
+ confidence = excluded.confidence`
20563
+ );
20564
+ this.sessionDupStmt = db.prepare(
20565
+ `SELECT 1 FROM inspection_findings f
20566
+ JOIN audit_events e ON e.id = f.audit_event_id
20567
+ JOIN inspection_definitions d ON d.id = f.inspection_definition_id
20568
+ WHERE d.rule_id = :ruleId AND f.masked_match = :maskedMatch
20569
+ AND e.root_session_id = :sessionId
20570
+ AND e.event_type IN (${CAPTURE_EVENT_TYPES_SQL})
20571
+ LIMIT 1`
20572
+ );
20573
+ this.eventDupStmt = db.prepare(
20574
+ `SELECT 1 FROM inspection_findings f
20575
+ JOIN inspection_definitions d ON d.id = f.inspection_definition_id
20576
+ WHERE f.audit_event_id = :auditEventId AND d.rule_id = :ruleId
20577
+ AND f.masked_match = :maskedMatch
20578
+ AND f.span_start = :spanStart AND f.span_end = :spanEnd
20579
+ LIMIT 1`
20146
20580
  );
20147
20581
  }
20148
20582
  db;
20149
20583
  insertStmt;
20584
+ sessionDupStmt;
20585
+ eventDupStmt;
20586
+ // True when an earlier event in the same session already recorded a finding
20587
+ // with the same rule and masked value. The current event's own findings are
20588
+ // inserted one at a time in caller order, so an earlier finding in the SAME
20589
+ // recordCapture call is visible to a later duplicate check within it too.
20590
+ isSessionDuplicate(ruleId, maskedMatch, sessionId) {
20591
+ return this.sessionDupStmt.get({ ruleId, maskedMatch, sessionId }) !== void 0;
20592
+ }
20593
+ // True when this exact detection (rule + masked value + span) is already
20594
+ // recorded against the given audit event.
20595
+ isEventDuplicate(auditEventId, ruleId, maskedMatch, spanStart, spanEnd) {
20596
+ return this.eventDupStmt.get({ auditEventId, ruleId, maskedMatch, spanStart, spanEnd }) !== void 0;
20597
+ }
20150
20598
  insertFinding(input) {
20151
20599
  const row = toInspectionFindingRow(input);
20152
20600
  this.insertStmt.run(
@@ -20159,7 +20607,9 @@ var SqliteInspectionFindingsRepository = class {
20159
20607
  spanEnd: row.spanEnd,
20160
20608
  maskedMatch: row.maskedMatch,
20161
20609
  actionTaken: row.actionTaken,
20162
- confidence: row.confidence
20610
+ confidence: row.confidence,
20611
+ findingKey: row.findingKey,
20612
+ firstDetectedAt: row.firstDetectedAt
20163
20613
  })
20164
20614
  );
20165
20615
  }
@@ -20431,7 +20881,7 @@ var SqliteInstalledPacksRepository = class {
20431
20881
  installedRuleset() {
20432
20882
  const rows = allRows(
20433
20883
  this.db.prepare(
20434
- `SELECT enabled, policy_id AS policyId, rules_json AS rulesJson FROM installed_packs`
20884
+ `SELECT enabled, policy_id AS policyId, rules_json AS rulesJson, version FROM installed_packs`
20435
20885
  )
20436
20886
  );
20437
20887
  const out = {
@@ -20439,7 +20889,8 @@ var SqliteInstalledPacksRepository = class {
20439
20889
  enabledPacks: 0,
20440
20890
  rules: [],
20441
20891
  invalidRules: 0,
20442
- ruleActions: /* @__PURE__ */ new Map()
20892
+ ruleActions: /* @__PURE__ */ new Map(),
20893
+ ruleVersions: /* @__PURE__ */ new Map()
20443
20894
  };
20444
20895
  for (const row of rows) {
20445
20896
  if (!intToBool(row.enabled)) continue;
@@ -20461,6 +20912,7 @@ var SqliteInstalledPacksRepository = class {
20461
20912
  if (parsed.success) {
20462
20913
  out.rules.push(parsed.data);
20463
20914
  out.ruleActions.set(parsed.data.id, action);
20915
+ out.ruleVersions.set(parsed.data.id, row.version);
20464
20916
  } else out.invalidRules += 1;
20465
20917
  }
20466
20918
  }
@@ -21635,19 +22087,19 @@ var SqliteResolutionsRepository = class {
21635
22087
  );
21636
22088
  this.openAtRestStmt = db.prepare(
21637
22089
  `SELECT DISTINCT f.finding_key AS finding_key
21638
- FROM findings f
21639
- JOIN events e ON e.id = f.event_id
21640
- WHERE e.kind = 'code_change'
21641
- AND json_extract(e.metadata, '$.filePath') = :path
22090
+ FROM inspection_findings f
22091
+ JOIN audit_events e ON e.id = f.audit_event_id
22092
+ WHERE e.event_type = 'code_change'
22093
+ AND json_extract(e.attributes, '$.file_path') = :path
21642
22094
  AND f.finding_key IS NOT NULL
21643
22095
  AND ${latestResolutionStatusSql("f")} IS NOT 'resolved'`
21644
22096
  );
21645
22097
  this.resolvedAtRestStmt = db.prepare(
21646
22098
  `SELECT DISTINCT f.finding_key AS finding_key
21647
- FROM findings f
21648
- JOIN events e ON e.id = f.event_id
21649
- WHERE e.kind = 'code_change'
21650
- AND json_extract(e.metadata, '$.filePath') = :path
22099
+ FROM inspection_findings f
22100
+ JOIN audit_events e ON e.id = f.audit_event_id
22101
+ WHERE e.event_type = 'code_change'
22102
+ AND json_extract(e.attributes, '$.file_path') = :path
21651
22103
  AND f.finding_key IS NOT NULL
21652
22104
  AND ${latestResolutionStatusSql("f")} = 'resolved'`
21653
22105
  );
@@ -21870,25 +22322,27 @@ var SqliteSecurityRepository = class {
21870
22322
  severitySummary() {
21871
22323
  const rows = allRows(
21872
22324
  this.db.prepare(
21873
- `SELECT f.severity AS severity,
22325
+ `SELECT d.severity AS severity,
21874
22326
  COUNT(*) AS count,
21875
22327
  SUM(CASE
21876
- WHEN e.kind != 'code_change' THEN 1
22328
+ WHEN e.event_type != 'code_change' THEN 1
21877
22329
  WHEN f.finding_key IS NULL THEN 0
21878
22330
  WHEN latest.status = 'resolved' THEN 1
21879
22331
  ELSE 0
21880
22332
  END) AS caught,
21881
22333
  SUM(CASE
21882
- WHEN e.kind = 'code_change'
22334
+ WHEN e.event_type = 'code_change'
21883
22335
  AND f.finding_key IS NOT NULL
21884
22336
  AND (latest.status IS NULL OR latest.status != 'resolved') THEN 1
21885
22337
  ELSE 0
21886
22338
  END) AS open_at_rest
21887
- FROM findings f
21888
- JOIN events e ON e.id = f.event_id
22339
+ FROM inspection_findings f
22340
+ JOIN audit_events e ON e.id = f.audit_event_id
22341
+ JOIN inspection_definitions d ON d.id = f.inspection_definition_id
21889
22342
  LEFT JOIN ${LATEST_RESOLUTION_BY_KEY_SQL} latest
21890
22343
  ON latest.finding_key = f.finding_key
21891
- GROUP BY f.severity`
22344
+ WHERE e.event_type IN (${CAPTURE_EVENT_TYPES_SQL})
22345
+ GROUP BY d.severity`
21892
22346
  )
21893
22347
  );
21894
22348
  const byRow = new Map(rows.map((r) => [r.severity, r]));
@@ -21954,7 +22408,7 @@ var SqliteSecurityRepository = class {
21954
22408
  // Mean time-to-remediate per bucket, split by severity — a sibling of
21955
22409
  // findingsTimeseries that reuses the same window/bucket/UTC math, but buckets
21956
22410
  // on a different timestamp: findingsTimeseries buckets by first-detection
21957
- // (events.occurred_at), this buckets by resolution time (the latest
22411
+ // (audit_events.started_at), this buckets by resolution time (the latest
21958
22412
  // finding_resolution row's resolved_at) — it's a "resolved in this bucket"
21959
22413
  // trend, not a "detected in this bucket" one. Only findings whose LATEST
21960
22414
  // resolution row (latest-resolution-wins, same correlated subquery as
@@ -21979,30 +22433,20 @@ var SqliteSecurityRepository = class {
21979
22433
  // first_detected_at is the PRESERVED first-detection time (set once on a
21980
22434
  // finding's INSERT, never overwritten on the re-detection upsert), so MTTR
21981
22435
  // measures from first sighting — not the latest re-scan's event, whose
21982
- // occurred_at the upsert overwrites onto findings.event_id. COALESCE onto
21983
- // the parent event's occurred_at defends against any legacy/edge row the
21984
- // backfill left null.
21985
- `SELECT COALESCE(f.first_detected_at, e.occurred_at) AS first_detected_at, f.severity AS severity,
21986
- (
21987
- SELECT fr.status FROM finding_resolution fr
21988
- WHERE fr.finding_key = f.finding_key
21989
- ORDER BY fr.created_at DESC, fr.rowid DESC
21990
- LIMIT 1
21991
- ) AS latest_status,
21992
- (
21993
- SELECT fr.method 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_method,
21998
- (
21999
- SELECT fr.resolved_at 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_resolved_at
22004
- FROM findings f JOIN events e ON e.id = f.event_id
22436
+ // started_at the upsert overwrites onto inspection_findings.audit_event_id.
22437
+ // COALESCE onto the parent event's started_at defends against any
22438
+ // legacy/edge row the backfill left null.
22439
+ `SELECT COALESCE(f.first_detected_at, e.started_at) AS first_detected_at, d.severity AS severity,
22440
+ latest.status AS latest_status,
22441
+ latest.method AS latest_method,
22442
+ latest.resolved_at AS latest_resolved_at
22443
+ FROM inspection_findings f
22444
+ JOIN audit_events e ON e.id = f.audit_event_id
22445
+ JOIN inspection_definitions d ON d.id = f.inspection_definition_id
22446
+ LEFT JOIN ${LATEST_RESOLUTION_BY_KEY_SQL} latest
22447
+ ON latest.finding_key = f.finding_key
22005
22448
  WHERE f.finding_key IS NOT NULL
22449
+ AND e.event_type IN (${CAPTURE_EVENT_TYPES_SQL})
22006
22450
  AND EXISTS (
22007
22451
  SELECT 1 FROM finding_resolution fr
22008
22452
  WHERE fr.finding_key = f.finding_key
@@ -22049,11 +22493,13 @@ var SqliteSecurityRepository = class {
22049
22493
  const from = now - RANGE_DAYS[range] * DAY_MS4;
22050
22494
  const rows = allRows(
22051
22495
  this.db.prepare(
22052
- `SELECT json_extract(e.metadata, '$.repo') AS repo, count(*) AS c
22053
- FROM findings f JOIN events e ON e.id = f.event_id
22054
- WHERE e.occurred_at >= :from AND e.occurred_at < :to
22055
- AND json_extract(e.metadata, '$.repo') IS NOT NULL
22056
- AND json_extract(e.metadata, '$.repo') != ''
22496
+ `SELECT json_extract(e.attributes, '$.repo') AS repo, count(*) AS c
22497
+ FROM inspection_findings f
22498
+ JOIN audit_events e ON e.id = f.audit_event_id
22499
+ WHERE e.started_at >= :from AND e.started_at < :to
22500
+ AND e.event_type IN (${CAPTURE_EVENT_TYPES_SQL})
22501
+ AND json_extract(e.attributes, '$.repo') IS NOT NULL
22502
+ AND json_extract(e.attributes, '$.repo') != ''
22057
22503
  GROUP BY repo
22058
22504
  ORDER BY c DESC, repo
22059
22505
  LIMIT :limit`
@@ -22077,44 +22523,28 @@ var SqliteSecurityRepository = class {
22077
22523
  // secret came back) is excluded — it is not currently resolved. Legacy
22078
22524
  // at-rest findings with finding_key IS NULL are excluded outright (the
22079
22525
  // resolution lifecycle can never attach to them). Path comes from the
22080
- // finding's parent event (kind 'code_change', metadata.filePath) — mirrors
22081
- // resolutions.ts's openAtRestStmt accessor. Ordered by resolved_at DESC,
22082
- // capped at `limit`.
22526
+ // finding's parent event (event_type 'code_change', attributes.file_path) —
22527
+ // mirrors resolutions.ts's openAtRestStmt accessor. Ordered by resolved_at
22528
+ // DESC, capped at `limit`.
22083
22529
  recentlyResolved(limit = 20) {
22084
22530
  const rows = allRows(
22085
22531
  this.db.prepare(
22086
22532
  `SELECT f.finding_key AS finding_key,
22087
- f.rule_id AS rule_id,
22088
- f.severity AS severity,
22089
- json_extract(e.metadata, '$.filePath') AS path,
22090
- COALESCE(f.first_detected_at, e.occurred_at) AS first_detected_at,
22091
- (
22092
- SELECT fr.resolved_at FROM finding_resolution fr
22093
- WHERE fr.finding_key = f.finding_key
22094
- ORDER BY fr.created_at DESC, fr.rowid DESC
22095
- LIMIT 1
22096
- ) AS latest_resolved_at
22097
- FROM findings f JOIN events e ON e.id = f.event_id
22098
- WHERE e.kind = 'code_change'
22533
+ d.rule_id AS rule_id,
22534
+ d.severity AS severity,
22535
+ json_extract(e.attributes, '$.file_path') AS path,
22536
+ COALESCE(f.first_detected_at, e.started_at) AS first_detected_at,
22537
+ latest.resolved_at AS latest_resolved_at
22538
+ FROM inspection_findings f
22539
+ JOIN audit_events e ON e.id = f.audit_event_id
22540
+ JOIN inspection_definitions d ON d.id = f.inspection_definition_id
22541
+ LEFT JOIN ${LATEST_RESOLUTION_BY_KEY_SQL} latest
22542
+ ON latest.finding_key = f.finding_key
22543
+ WHERE e.event_type = 'code_change'
22099
22544
  AND f.finding_key IS NOT NULL
22100
- AND (
22101
- SELECT fr.status FROM finding_resolution fr
22102
- WHERE fr.finding_key = f.finding_key
22103
- ORDER BY fr.created_at DESC, fr.rowid DESC
22104
- LIMIT 1
22105
- ) = 'resolved'
22106
- AND (
22107
- SELECT fr.method 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
- ) = 'fixed-at-source'
22112
- AND (
22113
- SELECT fr.resolved_at 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
- ) IS NOT NULL
22545
+ AND latest.status = 'resolved'
22546
+ AND latest.method = 'fixed-at-source'
22547
+ AND latest.resolved_at IS NOT NULL
22118
22548
  ORDER BY latest_resolved_at DESC
22119
22549
  LIMIT :limit`
22120
22550
  ),
@@ -22133,15 +22563,18 @@ var SqliteSecurityRepository = class {
22133
22563
  return Promise.resolve({ items });
22134
22564
  }
22135
22565
  // Findings whose parent event occurred in [fromMs, toMs), with the parent's
22136
- // epoch-millis timestamp. occurred_at is an INTEGER column, so the bounds stay
22566
+ // epoch-millis timestamp. started_at is an INTEGER column, so the bounds stay
22137
22567
  // numeric and the JS aggregations bucket/split on ms directly.
22138
22568
  findingsInRange(fromMs, toMs) {
22139
22569
  const rows = allRows(
22140
22570
  this.db.prepare(
22141
- `SELECT e.occurred_at AS occurred_at, f.severity AS severity, f.action_taken AS action_taken
22142
- FROM findings f JOIN events e ON e.id = f.event_id
22143
- WHERE e.occurred_at >= :from AND e.occurred_at < :to
22144
- ORDER BY e.occurred_at`
22571
+ `SELECT e.started_at AS occurred_at, d.severity AS severity, f.action_taken AS action_taken
22572
+ FROM inspection_findings f
22573
+ JOIN audit_events e ON e.id = f.audit_event_id
22574
+ JOIN inspection_definitions d ON d.id = f.inspection_definition_id
22575
+ WHERE e.started_at >= :from AND e.started_at < :to
22576
+ AND e.event_type IN (${CAPTURE_EVENT_TYPES_SQL})
22577
+ ORDER BY e.started_at`
22145
22578
  ),
22146
22579
  { from: fromMs, to: toMs }
22147
22580
  );
@@ -22956,63 +23389,159 @@ function purgeSampleData(db) {
22956
23389
  function linkHost(input, hostId) {
22957
23390
  return hostId ? { ...input, hostId } : input;
22958
23391
  }
23392
+ function closeQuietly(db) {
23393
+ try {
23394
+ db.close();
23395
+ } catch {
23396
+ }
23397
+ }
22959
23398
  function openWithPragmas(file2) {
22960
23399
  const db = new DatabaseSync(file2);
22961
- db.exec("PRAGMA journal_mode = WAL");
22962
- db.exec("PRAGMA busy_timeout = 2000");
22963
- db.exec("PRAGMA foreign_keys = ON");
23400
+ try {
23401
+ db.exec("PRAGMA journal_mode = WAL");
23402
+ db.exec("PRAGMA busy_timeout = 2000");
23403
+ db.exec("PRAGMA foreign_keys = ON");
23404
+ } catch (err) {
23405
+ closeQuietly(db);
23406
+ throw err;
23407
+ }
22964
23408
  return db;
22965
23409
  }
22966
23410
  function backupLegacyStore(file2) {
22967
23411
  const backup = `${file2}.legacy.${String(Date.now())}.bak`;
22968
- renameSync(file2, backup);
22969
- for (const sidecar of walSidecars(file2)) {
22970
- if (existsSync(sidecar)) rmSync(sidecar);
23412
+ renameSync2(file2, backup);
23413
+ tightenFile(backup);
23414
+ for (const sidecar of dbSidecars(file2)) {
23415
+ if (existsSync(sidecar)) rmSync2(sidecar);
22971
23416
  }
22972
23417
  return backup;
22973
23418
  }
23419
+ function openAndInitialize(file2) {
23420
+ let db = openWithPragmas(file2);
23421
+ try {
23422
+ if (isForeignSqliteLineage(db)) {
23423
+ db.close();
23424
+ const backup = backupLegacyStore(file2);
23425
+ db = openWithPragmas(file2);
23426
+ akaWarn(
23427
+ `Detected an older, incompatible (tenant-bearing) ${DB_FILENAME}; backed it up to ${backup} and created a fresh store.`
23428
+ );
23429
+ }
23430
+ applyMigrations(db, file2);
23431
+ tightenPerms(file2);
23432
+ const policies = new SqlitePoliciesRepository(db);
23433
+ const installedPacks = new SqliteInstalledPacksRepository(db);
23434
+ const repositories = {
23435
+ events: new SqliteEventsRepository(db),
23436
+ findings: new SqliteFindingsRepository(db),
23437
+ policies,
23438
+ installedPacks,
23439
+ scanLedger: new SqliteScanLedgerRepository(db),
23440
+ exceptions: new SqliteExceptionsRepository(db),
23441
+ resolutions: new SqliteResolutionsRepository(db),
23442
+ ruleProbeCache: new SqliteRuleProbeCacheRepository(db),
23443
+ security: new SqliteSecurityRepository(db),
23444
+ detections: new SqliteDetectionsRepository(db),
23445
+ shares: new SqliteSharesRepository(db),
23446
+ policyCatalog: new SqlitePolicyCatalogRepository(installedPacks),
23447
+ inventory: new SqliteInventoryRepository(db),
23448
+ inventoryAssets: new SqliteInventoryAssetsRepository(db),
23449
+ projectFiles: new SqliteProjectFilesRepository(db),
23450
+ activity: new SqliteActivityRepository(db),
23451
+ sourceProject: new SqliteSourceProjectRepository(db),
23452
+ auditEvents: new SqliteAuditEventsRepository(db),
23453
+ classifiedData: new SqliteClassifiedDataRepository(db),
23454
+ inspectionDefinitions: new SqliteInspectionDefinitionsRepository(db),
23455
+ inspectionFindings: new SqliteInspectionFindingsRepository(db),
23456
+ configInventory: new SqliteConfigInventoryRepository(db)
23457
+ };
23458
+ policies.seedDefaults();
23459
+ return { db, ...repositories };
23460
+ } catch (err) {
23461
+ closeQuietly(db);
23462
+ throw err;
23463
+ }
23464
+ }
22974
23465
  function openLocalDatabase(dir) {
22975
23466
  ensureDataDirSync(dir);
22976
23467
  const file2 = join(dir, DB_FILENAME);
22977
- let db = openWithPragmas(file2);
22978
- if (isForeignSqliteLineage(db)) {
22979
- db.close();
22980
- const backup = backupLegacyStore(file2);
22981
- db = openWithPragmas(file2);
22982
- akaWarn(
22983
- `Detected an older, incompatible (tenant-bearing) ${DB_FILENAME}; backed it up to ${backup} and created a fresh store.`
22984
- );
22985
- }
22986
- applyMigrations(db);
22987
- tightenPerms(file2);
22988
- const events = new SqliteEventsRepository(db);
22989
- const findings = new SqliteFindingsRepository(db);
22990
- const policies = new SqlitePoliciesRepository(db);
22991
- const installedPacks = new SqliteInstalledPacksRepository(db);
22992
- const scanLedger = new SqliteScanLedgerRepository(db);
22993
- const exceptions = new SqliteExceptionsRepository(db);
22994
- const resolutions = new SqliteResolutionsRepository(db);
22995
- const ruleProbeCache = new SqliteRuleProbeCacheRepository(db);
22996
- const security = new SqliteSecurityRepository(db);
22997
- const detections = new SqliteDetectionsRepository(db);
22998
- const shares = new SqliteSharesRepository(db);
22999
- const policyCatalog = new SqlitePolicyCatalogRepository(installedPacks);
23000
- const inventory = new SqliteInventoryRepository(db);
23001
- const inventoryAssets = new SqliteInventoryAssetsRepository(db);
23002
- const projectFiles = new SqliteProjectFilesRepository(db);
23003
- const activity = new SqliteActivityRepository(db);
23004
- const sourceProject = new SqliteSourceProjectRepository(db);
23005
- const auditEvents = new SqliteAuditEventsRepository(db);
23006
- const classifiedData = new SqliteClassifiedDataRepository(db);
23007
- const inspectionDefinitions = new SqliteInspectionDefinitionsRepository(db);
23008
- const inspectionFindings = new SqliteInspectionFindingsRepository(db);
23009
- const configInventory = new SqliteConfigInventoryRepository(db);
23010
- policies.seedDefaults();
23468
+ const {
23469
+ db,
23470
+ events,
23471
+ findings,
23472
+ policies,
23473
+ installedPacks,
23474
+ scanLedger,
23475
+ exceptions,
23476
+ resolutions,
23477
+ ruleProbeCache,
23478
+ security,
23479
+ detections,
23480
+ shares,
23481
+ policyCatalog,
23482
+ inventory,
23483
+ inventoryAssets,
23484
+ projectFiles,
23485
+ activity,
23486
+ sourceProject,
23487
+ auditEvents,
23488
+ classifiedData,
23489
+ inspectionDefinitions,
23490
+ inspectionFindings,
23491
+ configInventory
23492
+ } = openAndInitialize(file2);
23011
23493
  function recordCapture(event, detected) {
23012
23494
  failOpenTransaction(db, () => {
23013
- events.insertEvent(event);
23014
23495
  const sessionId = event.metadata?.sessionId;
23015
- findings.insertFindings(detected, sessionId ? { sessionId } : {});
23496
+ if (sessionId) {
23497
+ auditEvents.ensureSessionRoot(sessionId, event.occurredAt);
23498
+ }
23499
+ const auditEventId = captureId(
23500
+ sessionId ?? null,
23501
+ event.contentHash,
23502
+ event.metadata?.filePath ?? null
23503
+ );
23504
+ auditEvents.insertAuditEvent({
23505
+ id: auditEventId,
23506
+ eventType: event.kind,
23507
+ startedAt: event.occurredAt,
23508
+ parentId: sessionId,
23509
+ rootSessionId: sessionId,
23510
+ content: event.content,
23511
+ contentHash: event.contentHash,
23512
+ attributes: toCaptureAttributes(event)
23513
+ });
23514
+ const definitionIds = /* @__PURE__ */ new Map();
23515
+ for (const finding of detected) {
23516
+ if (sessionId && inspectionFindings.isSessionDuplicate(finding.ruleId, finding.maskedMatch, sessionId)) {
23517
+ continue;
23518
+ }
23519
+ if (inspectionFindings.isEventDuplicate(
23520
+ auditEventId,
23521
+ finding.ruleId,
23522
+ finding.maskedMatch,
23523
+ finding.span.start,
23524
+ finding.span.end
23525
+ )) {
23526
+ continue;
23527
+ }
23528
+ const key = `${finding.ruleId}@${captureDefinitionVersion(finding)}`;
23529
+ let definitionId = definitionIds.get(key);
23530
+ if (!definitionId) {
23531
+ definitionId = inspectionDefinitions.upsert(toCaptureDefinitionInput(finding));
23532
+ definitionIds.set(key, definitionId);
23533
+ }
23534
+ inspectionFindings.insertFinding({
23535
+ id: finding.id,
23536
+ auditEventId,
23537
+ inspectionDefinitionId: definitionId,
23538
+ span: finding.span,
23539
+ maskedMatch: finding.maskedMatch,
23540
+ actionTaken: finding.actionTaken,
23541
+ confidence: finding.confidence,
23542
+ findingKey: finding.findingKey ?? void 0
23543
+ });
23544
+ }
23016
23545
  });
23017
23546
  }
23018
23547
  function ensureInventory(ctx) {
@@ -23160,10 +23689,21 @@ function openLocalDatabase(dir) {
23160
23689
  };
23161
23690
  }
23162
23691
 
23692
+ // ../../packages/persistence/src/finding-key.ts
23693
+ import { createHash as createHash3 } from "crypto";
23694
+ function normalizeFilePath(filePath) {
23695
+ return filePath.replaceAll("\\", "/");
23696
+ }
23697
+ function computeFindingKey(input) {
23698
+ const normalizedPath = normalizeFilePath(input.filePath);
23699
+ return createHash3("sha256").update(`${input.ruleId}\0${normalizedPath}\0${input.valueFingerprint}`).digest("hex");
23700
+ }
23701
+
23163
23702
  // ../../packages/persistence/src/fingerprint.ts
23164
23703
  import { createHmac, randomBytes } from "crypto";
23165
- import { chmodSync as chmodSync2, readFileSync, renameSync as renameSync2, writeFileSync } from "fs";
23704
+ import { existsSync as existsSync2, readFileSync } from "fs";
23166
23705
  import { join as join2 } from "path";
23706
+ import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
23167
23707
  var KEY_FILENAME = "exception.key";
23168
23708
  var KEY_MATERIAL_BYTES = 32;
23169
23709
  function keyFilePath(dataDir2) {
@@ -23187,18 +23727,52 @@ function parseKeyFile(raw) {
23187
23727
  }
23188
23728
  return { version: version2, material: bytes };
23189
23729
  }
23730
+ var KEY_VERSION_TABLES = ["exceptions", "blocked_detections"];
23731
+ var SQLITE_ERROR = 1;
23732
+ var FLOOR_BUSY_TIMEOUT_MS = 250;
23733
+ var FloorUnreadableError = class extends Error {
23734
+ code = "floor-unreadable";
23735
+ constructor(cause) {
23736
+ super(
23737
+ `cannot read the stored fingerprint key versions: ${cause instanceof Error ? cause.message : String(cause)}`,
23738
+ { cause }
23739
+ );
23740
+ this.name = "FloorUnreadableError";
23741
+ }
23742
+ };
23743
+ function storedKeyVersionFloor(dataDir2) {
23744
+ const file2 = join2(dataDir2, DB_FILENAME);
23745
+ if (!existsSync2(file2)) return 0;
23746
+ let db;
23747
+ try {
23748
+ db = new DatabaseSync2(file2, { readOnly: true });
23749
+ db.exec(`PRAGMA busy_timeout = ${String(FLOOR_BUSY_TIMEOUT_MS)}`);
23750
+ let floor = 0;
23751
+ for (const table of KEY_VERSION_TABLES) {
23752
+ try {
23753
+ const row = getRow(
23754
+ db.prepare(`SELECT MAX(key_version) AS v FROM ${table}`)
23755
+ );
23756
+ floor = Math.max(floor, row?.v ?? 0);
23757
+ } catch (err) {
23758
+ if (err.errcode !== SQLITE_ERROR) {
23759
+ throw new FloorUnreadableError(err);
23760
+ }
23761
+ }
23762
+ }
23763
+ return floor;
23764
+ } catch (err) {
23765
+ throw err instanceof FloorUnreadableError ? err : new FloorUnreadableError(err);
23766
+ } finally {
23767
+ db?.close();
23768
+ }
23769
+ }
23190
23770
  function writeKeyFile(dataDir2, key) {
23191
23771
  ensureDataDirSync(dataDir2);
23192
23772
  const file2 = keyFilePath(dataDir2);
23193
- const tmp = `${file2}.tmp`;
23194
23773
  const body = JSON.stringify({ version: key.version, material: key.material.toString("base64") });
23195
- writeFileSync(tmp, `${body}
23196
- `, { mode: DATA_FILE_MODE });
23197
- renameSync2(tmp, file2);
23198
- try {
23199
- chmodSync2(file2, DATA_FILE_MODE);
23200
- } catch {
23201
- }
23774
+ writeOwnerOnlyFileSync(file2, `${body}
23775
+ `);
23202
23776
  return key;
23203
23777
  }
23204
23778
  function readFingerprintKey(dataDir2) {
@@ -23214,21 +23788,21 @@ function readFingerprintKey(dataDir2) {
23214
23788
  function loadOrCreateFingerprintKey(dataDir2) {
23215
23789
  const existing = readFingerprintKey(dataDir2);
23216
23790
  if (existing) {
23217
- try {
23218
- chmodSync2(keyFilePath(dataDir2), DATA_FILE_MODE);
23219
- } catch {
23220
- }
23791
+ tightenFile(keyFilePath(dataDir2));
23221
23792
  return existing;
23222
23793
  }
23223
- return writeKeyFile(dataDir2, { version: 1, material: randomBytes(KEY_MATERIAL_BYTES) });
23794
+ return writeKeyFile(dataDir2, {
23795
+ version: storedKeyVersionFloor(dataDir2) + 1,
23796
+ material: randomBytes(KEY_MATERIAL_BYTES)
23797
+ });
23224
23798
  }
23225
23799
  function fingerprintValue(key, raw) {
23226
23800
  return createHmac("sha256", key.material).update(raw, "utf8").digest("hex");
23227
23801
  }
23228
23802
 
23229
23803
  // ../../packages/persistence/src/local-layout.ts
23230
- import { chmodSync as chmodSync3, mkdirSync as mkdirSync2, renameSync as renameSync3 } from "fs";
23231
- import { chmod, mkdir } from "fs/promises";
23804
+ import { renameSync as renameSync3 } from "fs";
23805
+ import { mkdir } from "fs/promises";
23232
23806
  import { homedir } from "os";
23233
23807
  import { join as join3 } from "path";
23234
23808
  function defaultDataDir() {
@@ -23243,6 +23817,9 @@ function dataDir(base = defaultDataDir()) {
23243
23817
  function dbPath(base = defaultDataDir()) {
23244
23818
  return join3(dataDir(base), "aka.db");
23245
23819
  }
23820
+ function ensureLayoutDirSync(dir = defaultDataDir()) {
23821
+ ensureDataDirSync(dir);
23822
+ }
23246
23823
  function migrateLegacyLayout(base = defaultDataDir()) {
23247
23824
  const moves = [
23248
23825
  { name: "config.json", dest: settingsDir(base) },
@@ -23250,19 +23827,17 @@ function migrateLegacyLayout(base = defaultDataDir()) {
23250
23827
  ];
23251
23828
  for (const { name, dest } of moves) {
23252
23829
  try {
23253
- mkdirSync2(dest, { recursive: true, mode: DATA_DIR_MODE });
23254
- try {
23255
- chmodSync3(dest, DATA_DIR_MODE);
23256
- } catch {
23257
- }
23258
- renameSync3(join3(base, name), join3(dest, name));
23830
+ ensureDataDirSync(dest);
23831
+ const moved = join3(dest, name);
23832
+ renameSync3(join3(base, name), moved);
23833
+ tightenFile(moved);
23259
23834
  } catch {
23260
23835
  }
23261
23836
  }
23262
23837
  }
23263
23838
 
23264
23839
  // ../../packages/persistence/src/settings.ts
23265
- import { readFileSync as readFileSync2, renameSync as renameSync4, writeFileSync as writeFileSync2 } from "fs";
23840
+ import { readFileSync as readFileSync2 } from "fs";
23266
23841
  import { join as join4 } from "path";
23267
23842
  function readWorkspaceSettings(base = defaultDataDir()) {
23268
23843
  const record2 = readJson(join4(settingsDir(base), "settings.json"));
@@ -23284,15 +23859,15 @@ function readJson(file2) {
23284
23859
  }
23285
23860
 
23286
23861
  // ../../packages/persistence/src/warn-era-cap.ts
23287
- import { existsSync as existsSync2, writeFileSync as writeFileSync3 } from "fs";
23862
+ import { existsSync as existsSync3, writeFileSync as writeFileSync2 } from "fs";
23288
23863
  import { join as join5 } from "path";
23289
23864
  var MARKER = "warn-era-capped";
23290
23865
  function capWarnEraEnforcementOnce(db, policyMode, dataDir2) {
23291
23866
  if (policyMode !== "warn") return { capped: 0, skipped: "not-warn" };
23292
23867
  const marker = join5(dataDir2, MARKER);
23293
- if (existsSync2(marker)) return { capped: 0, skipped: "already-run" };
23868
+ if (existsSync3(marker)) return { capped: 0, skipped: "already-run" };
23294
23869
  const capped = db.policies.capCategoryActions();
23295
- writeFileSync3(marker, `${new Date(Date.now()).toISOString()}
23870
+ writeFileSync2(marker, `${new Date(Date.now()).toISOString()}
23296
23871
  `, { mode: DATA_FILE_MODE });
23297
23872
  return { capped };
23298
23873
  }
@@ -23347,6 +23922,12 @@ function resolveProvider() {
23347
23922
 
23348
23923
  // ../../packages/plugin-sdk/src/config.ts
23349
23924
  function loadConfig(base = defaultDataDir()) {
23925
+ try {
23926
+ ensureLayoutDirSync(base);
23927
+ const settingsFile = join6(settingsDir(base), "settings.json");
23928
+ if (existsSync4(settingsFile)) tightenFile(settingsFile);
23929
+ } catch {
23930
+ }
23350
23931
  migrateLegacyLayout(base);
23351
23932
  const settings = readWorkspaceSettings(base);
23352
23933
  return {
@@ -23369,7 +23950,7 @@ function resolveProviderSafe() {
23369
23950
  // ../../packages/plugin-sdk/src/config-inventory.ts
23370
23951
  import { readdirSync, readFileSync as readFileSync4, realpathSync, statSync as statSync2 } from "fs";
23371
23952
  import { homedir as homedir2 } from "os";
23372
- import { basename as basename2, join as join7 } from "path";
23953
+ import { basename as basename2, join as join8 } from "path";
23373
23954
 
23374
23955
  // ../../packages/detections/src/egress/registry.ts
23375
23956
  var EXTRACTOR_VERSION = "1";
@@ -26387,8 +26968,8 @@ function bundledDetections() {
26387
26968
  }
26388
26969
 
26389
26970
  // ../../packages/plugin-sdk/src/repo.ts
26390
- import { existsSync as existsSync3, readFileSync as readFileSync3, statSync } from "fs";
26391
- import { basename, dirname, isAbsolute, join as join6, sep as sep2 } from "path";
26971
+ import { existsSync as existsSync5, readFileSync as readFileSync3, statSync } from "fs";
26972
+ import { basename, dirname, isAbsolute, join as join7, sep as sep2 } from "path";
26392
26973
  function resolveRepo(cwd) {
26393
26974
  try {
26394
26975
  const root = findGitRoot(cwd);
@@ -26403,32 +26984,32 @@ function resolveRepo(cwd) {
26403
26984
  function findGitRoot(start) {
26404
26985
  let dir = start;
26405
26986
  for (; ; ) {
26406
- if (existsSync3(join6(dir, ".git"))) return dir;
26987
+ if (existsSync5(join7(dir, ".git"))) return dir;
26407
26988
  const parent = dirname(dir);
26408
26989
  if (parent === dir) return void 0;
26409
26990
  dir = parent;
26410
26991
  }
26411
26992
  }
26412
26993
  function resolveGitContext(root) {
26413
- const dotGit = join6(root, ".git");
26994
+ const dotGit = join7(root, ".git");
26414
26995
  try {
26415
26996
  if (statSync(dotGit).isDirectory()) {
26416
- return { configPath: join6(dotGit, "config"), headRoot: root };
26997
+ return { configPath: join7(dotGit, "config"), headRoot: root };
26417
26998
  }
26418
26999
  } catch {
26419
27000
  return void 0;
26420
27001
  }
26421
27002
  const target = /^gitdir:\s*(.+?)\s*$/m.exec(safeRead(dotGit) ?? "")?.[1];
26422
27003
  if (!target) return void 0;
26423
- const gitdir = isAbsolute(target) ? target : join6(root, target);
26424
- if (existsSync3(join6(gitdir, "config"))) {
26425
- return { configPath: join6(gitdir, "config"), headRoot: root };
27004
+ const gitdir = isAbsolute(target) ? target : join7(root, target);
27005
+ if (existsSync5(join7(gitdir, "config"))) {
27006
+ return { configPath: join7(gitdir, "config"), headRoot: root };
26426
27007
  }
26427
- const commonRaw = safeRead(join6(gitdir, "commondir"))?.trim();
27008
+ const commonRaw = safeRead(join7(gitdir, "commondir"))?.trim();
26428
27009
  if (!commonRaw) return void 0;
26429
- const commonGitDir = isAbsolute(commonRaw) ? commonRaw : join6(gitdir, commonRaw);
27010
+ const commonGitDir = isAbsolute(commonRaw) ? commonRaw : join7(gitdir, commonRaw);
26430
27011
  const headRoot = basename(commonGitDir) === ".git" ? dirname(commonGitDir) : root;
26431
- return { configPath: join6(commonGitDir, "config"), headRoot };
27012
+ return { configPath: join7(commonGitDir, "config"), headRoot };
26432
27013
  }
26433
27014
  function safeRead(path) {
26434
27015
  try {
@@ -26470,9 +27051,9 @@ function slugFromUrl(url2) {
26470
27051
  }
26471
27052
 
26472
27053
  // ../../packages/plugin-sdk/src/events.ts
26473
- import { createHash as createHash3, randomUUID as randomUUID9 } from "crypto";
27054
+ import { createHash as createHash4, randomUUID as randomUUID9 } from "crypto";
26474
27055
  function contentHashOf(text) {
26475
- return createHash3("sha256").update(text).digest("hex");
27056
+ return createHash4("sha256").update(text).digest("hex");
26476
27057
  }
26477
27058
  function buildIngestEvent(input) {
26478
27059
  return {
@@ -26492,22 +27073,12 @@ function buildIngestEvent(input) {
26492
27073
  };
26493
27074
  }
26494
27075
 
26495
- // ../../packages/plugin-sdk/src/finding-key.ts
26496
- import { createHash as createHash4 } from "crypto";
26497
- function normalizeFilePath(filePath) {
26498
- return filePath.replaceAll("\\", "/");
26499
- }
26500
- function computeFindingKey(input) {
26501
- const normalizedPath = normalizeFilePath(input.filePath);
26502
- return createHash4("sha256").update(`${input.ruleId}\0${normalizedPath}\0${input.valueFingerprint}`).digest("hex");
26503
- }
26504
-
26505
27076
  // ../../packages/plugin-sdk/src/inventory-resolver.ts
26506
27077
  import { arch, hostname as hostname3, platform, release } from "os";
26507
27078
 
26508
27079
  // ../../packages/plugin-sdk/src/nudge.ts
26509
- import { mkdirSync as mkdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
26510
- import { join as join8 } from "path";
27080
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
27081
+ import { join as join9 } from "path";
26511
27082
 
26512
27083
  // ../../packages/plugin-sdk/src/paths.ts
26513
27084
  import { readdirSync as readdirSync2, realpathSync as realpathSync2 } from "fs";
@@ -26515,8 +27086,8 @@ import { basename as basename3, dirname as dirname2, sep as sep3 } from "path";
26515
27086
 
26516
27087
  // ../../packages/plugin-sdk/src/project-files.ts
26517
27088
  var import_ignore = __toESM(require_ignore(), 1);
26518
- import { existsSync as existsSync4, readdirSync as readdirSync3, readFileSync as readFileSync6 } from "fs";
26519
- import { basename as basename4, join as join9, relative, sep as sep4 } from "path";
27089
+ import { existsSync as existsSync6, readdirSync as readdirSync3, readFileSync as readFileSync6 } from "fs";
27090
+ import { basename as basename4, join as join10, relative, sep as sep4 } from "path";
26520
27091
 
26521
27092
  // ../../packages/plugin-sdk/src/rule-quarantine.ts
26522
27093
  var PASS_BUDGET_MS = 2e3;
@@ -26866,8 +27437,8 @@ function createPluginRuntime(gateway, settings, opts) {
26866
27437
  var THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1e3;
26867
27438
 
26868
27439
  // ../../packages/plugin-sdk/src/throttle.ts
26869
- import { mkdirSync as mkdirSync4, statSync as statSync3, writeFileSync as writeFileSync5 } from "fs";
26870
- import { join as join10 } from "path";
27440
+ import { mkdirSync as mkdirSync3, statSync as statSync3, writeFileSync as writeFileSync4 } from "fs";
27441
+ import { join as join11 } from "path";
26871
27442
 
26872
27443
  // src/hooks/paths.ts
26873
27444
  function stringAtPath(root, path) {
@@ -27142,8 +27713,8 @@ function baseMetadata(input) {
27142
27713
  }
27143
27714
 
27144
27715
  // src/hooks/store-health.ts
27145
- import { mkdirSync as mkdirSync5, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "fs";
27146
- import { join as join11 } from "path";
27716
+ import { mkdirSync as mkdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
27717
+ import { join as join12 } from "path";
27147
27718
 
27148
27719
  // ../../packages/plugin-runtime/src/standalone-gateway.ts
27149
27720
  import { randomUUID as randomUUID11 } from "crypto";
@@ -27174,7 +27745,8 @@ var StandaloneDataGateway = class {
27174
27745
  }
27175
27746
  // The id is minted inside the repository from the natural key — the plugin can't
27176
27747
  // import @akasecurity/persistence to compute it, so the gateway is the boundary that
27177
- // hands the natural key across. INSERT OR IGNORE → idempotent re-reads.
27748
+ // hands the natural key across. UPSERT-take-MAX → idempotent re-reads that also
27749
+ // converge a streaming partial/final split (see insertLlmCall).
27178
27750
  recordLlmCall(input) {
27179
27751
  this.db.auditEvents.insertLlmCall(input);
27180
27752
  return Promise.resolve();
@@ -27216,7 +27788,9 @@ var StandaloneDataGateway = class {
27216
27788
  // caller's transaction (Layer 2b). The audit-event id the findings FK into is the
27217
27789
  // SAME content-addressed `toolCallId` the leaf insert mints, so both re-read
27218
27790
  // idempotently. Definitions/classified-data are idempotent upserts; findings are
27219
- // content-addressed INSERT OR IGNORE.
27791
+ // content-addressed upserts (ON CONFLICT(id) DO UPDATE SET inspection_definition_id),
27792
+ // so a re-detection under a bumped rule version repoints the definition FK rather
27793
+ // than no-opping.
27220
27794
  writeToolCall(input) {
27221
27795
  this.db.auditEvents.insertToolCall(input);
27222
27796
  if (input.inspections.length === 0) return;
@@ -27236,7 +27810,7 @@ var StandaloneDataGateway = class {
27236
27810
  });
27237
27811
  const classifiedDataId2 = this.db.classifiedData.upsert({ class: insp.category });
27238
27812
  this.db.inspectionFindings.insertFinding({
27239
- id: inspectionFindingId(auditEventId, definitionId, insp.span.start, insp.span.end),
27813
+ id: inspectionFindingId(auditEventId, insp.ruleId, insp.span.start, insp.span.end),
27240
27814
  auditEventId,
27241
27815
  inspectionDefinitionId: definitionId,
27242
27816
  classifiedDataId: classifiedDataId2,
@@ -27285,10 +27859,17 @@ var StandaloneDataGateway = class {
27285
27859
  try {
27286
27860
  const snapshot = this.db.installedPacks.installedRuleset();
27287
27861
  if (snapshot.installedPacks === 0) return void 0;
27288
- if (snapshot.enabledPacks === 0) return { rules: [], ruleActions: /* @__PURE__ */ new Map(), complete: true };
27862
+ if (snapshot.enabledPacks === 0) {
27863
+ return { rules: [], ruleActions: /* @__PURE__ */ new Map(), ruleVersions: /* @__PURE__ */ new Map(), complete: true };
27864
+ }
27289
27865
  if (snapshot.invalidRules > 0) return void 0;
27290
27866
  if (snapshot.rules.length === 0) return void 0;
27291
- return { rules: snapshot.rules, ruleActions: snapshot.ruleActions, complete: true };
27867
+ return {
27868
+ rules: snapshot.rules,
27869
+ ruleActions: snapshot.ruleActions,
27870
+ ruleVersions: snapshot.ruleVersions,
27871
+ complete: true
27872
+ };
27292
27873
  } catch {
27293
27874
  return void 0;
27294
27875
  }
@@ -27316,6 +27897,7 @@ var StandaloneDataGateway = class {
27316
27897
  policies: [...policies, ...rulePolicies],
27317
27898
  rules: installed ? installed.rules : [],
27318
27899
  ...installed ? { rulesComplete: true } : {},
27900
+ ...installed ? { ruleVersions: Object.fromEntries(installed.ruleVersions) } : {},
27319
27901
  ...exceptions !== void 0 ? { exceptions } : {},
27320
27902
  customKeywords,
27321
27903
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -27467,14 +28049,14 @@ function storeUnavailableMessage(dbPath2) {
27467
28049
  }
27468
28050
  function claimStoreUnavailableWarning(dataDir2, sessionId) {
27469
28051
  if (!sessionId) return true;
27470
- const path = join11(dataDir2, STORE_WARNING_MARKER);
28052
+ const path = join12(dataDir2, STORE_WARNING_MARKER);
27471
28053
  try {
27472
28054
  if (readFileSync7(path, "utf8") === sessionId) return false;
27473
28055
  } catch {
27474
28056
  }
27475
28057
  try {
27476
- mkdirSync5(dataDir2, { recursive: true, mode: DATA_DIR_MODE });
27477
- writeFileSync6(path, sessionId, { mode: DATA_FILE_MODE });
28058
+ mkdirSync4(dataDir2, { recursive: true, mode: DATA_DIR_MODE });
28059
+ writeFileSync5(path, sessionId, { mode: DATA_FILE_MODE });
27478
28060
  } catch {
27479
28061
  }
27480
28062
  return true;