@akasecurity/ai-tc-claude-code 0.9.10 → 0.9.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/scripts/query.js CHANGED
@@ -20420,6 +20420,101 @@ function epochMillisToIso(ms) {
20420
20420
  return new Date(ms).toISOString();
20421
20421
  }
20422
20422
 
20423
+ // ../../packages/schema/src/security/recommendations.ts
20424
+ var SEVERITY_WEIGHT = {
20425
+ critical: 4,
20426
+ high: 3,
20427
+ medium: 2,
20428
+ low: 1
20429
+ };
20430
+ var SEVERITY_WEIGHT_BY_STRING = SEVERITY_WEIGHT;
20431
+ function severityWeight(severity) {
20432
+ return SEVERITY_WEIGHT_BY_STRING[severity] ?? 0;
20433
+ }
20434
+ var ADVICE = {
20435
+ secret: "Rotate the exposed credentials and move them out of prompts (secrets manager / env vars).",
20436
+ pii: "Remove or mask personal data before it reaches the model.",
20437
+ financial: "Strip card and account numbers; share only non-sensitive references.",
20438
+ phi: "Remove protected health information \u2014 it should never reach an external model.",
20439
+ code_context: "Confirm this proprietary code context is safe to share.",
20440
+ code_flaw: "Review the flagged pattern and apply the secure alternative (parameterized queries, safe deserializers, etc.).",
20441
+ config: "Review the setting \u2014 a hook conflict or an egress change applies to every session that follows.",
20442
+ custom: "Review against your organization\u2019s custom policy."
20443
+ };
20444
+ var REC_TEMPLATE = {
20445
+ secret: { title: "Exposed secret detected", action: "Rotate" },
20446
+ pii: { title: "Personal data in a prompt", action: "Remove" },
20447
+ financial: { title: "Financial data detected", action: "Strip" },
20448
+ phi: { title: "Health information detected", action: "Remove" },
20449
+ code_context: { title: "Proprietary code shared", action: "Review" },
20450
+ code_flaw: { title: "Insecure code pattern", action: "Fix" },
20451
+ config: { title: "Weakened configuration", action: "Review" },
20452
+ custom: { title: "Custom policy match", action: "Review" }
20453
+ };
20454
+ var MAX_RECOMMENDATIONS = 10;
20455
+ function bucketizeRecommendations(findings) {
20456
+ const byRule = /* @__PURE__ */ new Map();
20457
+ const buckets = /* @__PURE__ */ new Map();
20458
+ for (const f of findings) {
20459
+ const n = f.count ?? 1;
20460
+ byRule.set(f.ruleId, (byRule.get(f.ruleId) ?? 0) + n);
20461
+ const b = buckets.get(f.category) ?? {
20462
+ category: f.category,
20463
+ count: 0,
20464
+ categoryCount: 0,
20465
+ severity: f.severity,
20466
+ weight: 0,
20467
+ ruleId: f.ruleId
20468
+ };
20469
+ b.categoryCount += n;
20470
+ const w = severityWeight(f.severity);
20471
+ if (w > b.weight) {
20472
+ b.weight = w;
20473
+ b.severity = f.severity;
20474
+ b.ruleId = f.ruleId;
20475
+ }
20476
+ buckets.set(f.category, b);
20477
+ }
20478
+ for (const b of buckets.values()) b.count = byRule.get(b.ruleId) ?? 0;
20479
+ return [...buckets.values()].sort((a, b) => b.weight - a.weight || b.categoryCount - a.categoryCount).slice(0, MAX_RECOMMENDATIONS);
20480
+ }
20481
+ function buildRecommendations(findings) {
20482
+ return bucketizeRecommendations(findings).map((b) => {
20483
+ const copy = recommendationCopy(b.category);
20484
+ return {
20485
+ severity: b.severity,
20486
+ title: copy.title,
20487
+ description: copy.advice,
20488
+ context: `${b.ruleId} \xB7 ${String(b.count)} finding${b.count === 1 ? "" : "s"}`,
20489
+ action: copy.action
20490
+ };
20491
+ });
20492
+ }
20493
+ function recommendationCopy(category) {
20494
+ const template = REC_TEMPLATE_BY_STRING[category] ?? {
20495
+ title: `${category} finding`,
20496
+ action: "Review"
20497
+ };
20498
+ return {
20499
+ ...template,
20500
+ advice: ADVICE_BY_STRING[category] ?? "Review this finding against your policy."
20501
+ };
20502
+ }
20503
+ var ADVICE_BY_STRING = ADVICE;
20504
+ var REC_TEMPLATE_BY_STRING = REC_TEMPLATE;
20505
+ function healthScore(summary) {
20506
+ const handled = summary.byAction.block + summary.byAction.redact + summary.byAction.warn;
20507
+ const handledRatio = summary.findings === 0 ? 1 : handled / summary.findings;
20508
+ return Math.round(100 * (0.6 * summary.coverage + 0.4 * handledRatio));
20509
+ }
20510
+ function findingStatus(summary) {
20511
+ return {
20512
+ score: healthScore(summary),
20513
+ unreviewed: { ...summary.bySeverity },
20514
+ openFindings: summary.findings
20515
+ };
20516
+ }
20517
+
20423
20518
  // ../../packages/schema/src/token/cost-model.ts
20424
20519
  var PROVIDER_PLATFORM = /* @__PURE__ */ new Map([
20425
20520
  ["anthropic", "anthropic"],
@@ -20799,13 +20894,11 @@ var FindingGroup = external_exports.object({
20799
20894
  latestDetectedAt: external_exports.iso.datetime(),
20800
20895
  instances: external_exports.array(FindingInstance),
20801
20896
  // Derived from instances' statuses with open-dominates precedence (see
20802
- // buildFindingGroups). Undefined only when no instance carries a status.
20897
+ // foldGroupStatus). Undefined only when no instance carries a status.
20803
20898
  status: FindingStatus.optional(),
20804
- // The distinct people across the WHOLE group, not just the `instances`
20805
- // preview — from the store's whole-group aggregate when it supplies one,
20806
- // else folded from the rows (see buildFindingGroups). Undefined when no
20807
- // instance carries a user, or when the store supplied whole-group folds
20808
- // without one.
20899
+ // The distinct people across the WHOLE group, not just the instances
20900
+ // carried here. Undefined when no instance carries a user, or when the
20901
+ // store supplied whole-group folds without one.
20809
20902
  users: external_exports.array(FindingUser).optional()
20810
20903
  }).meta({ id: "FindingGroup" });
20811
20904
  var FindingStats = external_exports.object({
@@ -20834,21 +20927,31 @@ var FindingFacets = external_exports.object({
20834
20927
  // counted under no value.
20835
20928
  status: external_exports.array(FindingFacetItem),
20836
20929
  // Host tool (attributes.tool_name). Present only on the instance-level
20837
- // reads, which can filter by it; the grouped read omits the dimension
20930
+ // reads, which can filter by it; the type-level read omits the dimension
20838
20931
  // because a group spans tools.
20839
20932
  tool: external_exports.array(FindingFacetItem).optional()
20840
20933
  }).meta({ id: "FindingFacets" });
20841
- var DEFAULT_GROUPED_FINDINGS_LIMIT = 50;
20842
- var ListGroupedFindingsQuery = external_exports.object({
20934
+ var FindingTypeSummary = FindingGroup.omit({ instances: true, match: true }).meta({
20935
+ id: "FindingTypeSummary"
20936
+ });
20937
+ var DEFAULT_FINDING_TYPES_LIMIT = 50;
20938
+ var MAX_FINDING_TYPES_LIMIT = 100;
20939
+ var ListFindingTypesQuery = external_exports.object({
20843
20940
  // NOTE: severity filters by Severity (critical/high/medium/low), not by
20844
- // FindingAction.
20941
+ // FindingAction. It narrows TYPES: a type's severity is the one its newest
20942
+ // firing version carries, and this list pages types.
20943
+ //
20944
+ // That is NOT a claim the findings of a type share it. A rule can hold several
20945
+ // definition versions at different severities, so a type kept by this filter
20946
+ // can hold findings that individually do not match — see totals.findings on
20947
+ // ListFindingTypesResponse, which counts them all.
20845
20948
  severity: external_exports.array(Severity).optional(),
20846
20949
  subtype: external_exports.array(external_exports.string()).optional(),
20847
20950
  provider: external_exports.array(FindingProvider).optional(),
20848
20951
  action: external_exports.array(FindingAction).optional(),
20849
- // Matches a group's DERIVED status (see FindingGroup.status), not its
20850
- // individual instances' — so a filtered group's Status column always reads
20851
- // one of the requested values.
20952
+ // Matches a type's DERIVED status (see FindingGroup.status), not its
20953
+ // individual findings' — so a filtered row's status always reads one of the
20954
+ // requested values.
20852
20955
  status: external_exports.array(FindingStatus).optional(),
20853
20956
  q: external_exports.string().optional(),
20854
20957
  // Scope to findings whose event carries this session id (the Activity page's
@@ -20858,23 +20961,37 @@ var ListGroupedFindingsQuery = external_exports.object({
20858
20961
  // from a time-scoped page (Activity's range) can carry that scope. Absent
20859
20962
  // means all time — this list has no default window.
20860
20963
  from: external_exports.iso.datetime().optional(),
20861
- // A group or instance id that must appear in the page even when the cursor
20862
- // has already advanced past its sort position. This is what keeps the
20863
- // Findings page's one-shot ?finding= deep link resolving once the list
20864
- // paginates: the target group is appended out of sort order rather than
20865
- // scanning forward for it. Never affects totals, facets or the cursor.
20964
+ // A RULE id that must appear in the page even when the cursor has already
20965
+ // advanced past its sort position. This is what keeps the selected type
20966
+ // visible in the list once it paginates: the target is appended out of sort
20967
+ // order rather than scanned forward for. Never affects totals, facets or the
20968
+ // cursor. Unlike the grouped read this replaces, it names a rule only — an
20969
+ // instance id is resolved by `findingInstance`, which is a primary-key seek
20970
+ // and so is not bounded by what any page happens to hold.
20866
20971
  includeId: external_exports.string().optional(),
20867
- groupBy: external_exports.literal("type").optional(),
20868
- limit: external_exports.coerce.number().int().min(1).max(100).optional(),
20972
+ limit: external_exports.coerce.number().int().min(1).max(MAX_FINDING_TYPES_LIMIT).optional(),
20869
20973
  cursor: external_exports.string().optional()
20870
20974
  });
20871
- var ListGroupedFindingsResponse = external_exports.object({
20975
+ var ListFindingTypesResponse = external_exports.object({
20872
20976
  totals: external_exports.object({
20977
+ // Findings belonging to the matching TYPES — not findings that each match
20978
+ // the filters. The filters here select types, so a type that survives
20979
+ // contributes its whole instanceCount.
20980
+ //
20981
+ // `status` is the one exception, narrowed per finding via
20982
+ // countInstancesByStatus. `severity`, `provider` and `action` are not, so
20983
+ // this can exceed what the instance read reports for the same filters: a
20984
+ // rule whose severity moved between versions is kept on its newest and
20985
+ // still counts its older findings. Narrowing the other three needs
20986
+ // per-dimension counts the aggregate does not carry today.
20873
20987
  findings: external_exports.number().int().nonnegative(),
20874
- groups: external_exports.number().int().nonnegative()
20988
+ // Counts TYPES, which is the unit this read pages. The instance read's
20989
+ // own totals count findings; the two deliberately answer different
20990
+ // questions and are never summed.
20991
+ types: external_exports.number().int().nonnegative()
20875
20992
  }),
20876
20993
  facets: FindingFacets,
20877
- items: external_exports.array(FindingGroup),
20994
+ items: external_exports.array(FindingTypeSummary),
20878
20995
  nextCursor: external_exports.string().nullable(),
20879
20996
  // Present only on session-scoped queries (`sessionId` set): per ruleId, how
20880
20997
  // many times that rule fired in the session's persisted transcript. Findings
@@ -20882,7 +20999,7 @@ var ListGroupedFindingsResponse = external_exports.object({
20882
20999
  // every firing, so the two numbers legitimately differ — this map lets a
20883
21000
  // session-scoped view show both.
20884
21001
  sessionFirings: external_exports.record(external_exports.string(), external_exports.number().int().nonnegative()).optional()
20885
- }).meta({ id: "ListGroupedFindingsResponse" });
21002
+ }).meta({ id: "ListFindingTypesResponse" });
20886
21003
  var ApplyFindingActionRequest = external_exports.object({
20887
21004
  // 'quarantined' is system-assigned (see FindingAction) — clients may not set
20888
21005
  // it, so it is excluded from the request contract. The mapping helper
@@ -20912,12 +21029,13 @@ var DEFAULT_FLAT_FINDINGS_LIMIT = 50;
20912
21029
  var MAX_FLAT_FINDINGS_LIMIT = 200;
20913
21030
  var ListFindingInstancesQuery = external_exports.object({
20914
21031
  severity: external_exports.array(Severity).optional(),
20915
- // Rule ids, the same vocabulary the grouped list's `subtype` carries.
21032
+ // Rule ids, the same vocabulary the types list's `subtype` carries. Pinning
21033
+ // ONE of them is how the master/detail view scopes its right-hand panel.
20916
21034
  subtype: external_exports.array(external_exports.string()).optional(),
20917
21035
  provider: external_exports.array(FindingProvider).optional(),
20918
21036
  action: external_exports.array(FindingAction).optional(),
20919
21037
  // Matches each instance's OWN derived status (deriveFindingStatus), unlike
20920
- // the grouped query's group-level fold.
21038
+ // the types query's type-level fold.
20921
21039
  status: external_exports.array(FindingStatus).optional(),
20922
21040
  // Exact host-tool names (attributes.tool_name, e.g. 'Bash'). A real filter,
20923
21041
  // where the free-text `q` can only match the rendered "via Bash" label.
@@ -20934,37 +21052,47 @@ var ListFindingInstancesQuery = external_exports.object({
20934
21052
  });
20935
21053
  var ListFindingInstancesResponse = external_exports.object({
20936
21054
  // Instances matching the filters across the whole scope, not just this
20937
- // page — cursor-independent, like the grouped list's totals.
21055
+ // page — cursor-independent, like the types list's totals.
20938
21056
  totals: external_exports.object({ findings: external_exports.number().int().nonnegative() }),
20939
- // Counts in INSTANCES here, where the grouped response counts groups. Each
21057
+ // Counts in INSTANCES here, where the types response counts types. Each
20940
21058
  // dimension still excludes its own filter.
20941
21059
  facets: FindingFacets,
20942
21060
  items: external_exports.array(FindingInstanceDetail),
20943
21061
  nextCursor: external_exports.string().nullable()
20944
21062
  }).meta({ id: "ListFindingInstancesResponse" });
20945
- var FindingLocationFile = external_exports.object({
20946
- // Empty when the instances carried no file path (a prompt or a tool call
20947
- // with no file attribution).
20948
- file: external_exports.string(),
20949
- instanceCount: external_exports.number().int().nonnegative(),
20950
- maxSeverity: Severity,
20951
- latestDetectedAt: external_exports.iso.datetime(),
20952
- // Folded from the instances' derived statuses with the same
20953
- // open-dominates precedence a group uses.
20954
- status: FindingStatus.optional(),
20955
- // Distinct rules seen at this location, capped — the row shows them as
20956
- // chips, and the count is what conveys scale.
20957
- ruleIds: external_exports.array(external_exports.string())
20958
- }).meta({ id: "FindingLocationFile" });
20959
- var FindingLocationRepo = external_exports.object({
21063
+ var FindingLocationSummary = external_exports.object({
21064
+ // Opaque, stable, minted from the pair by encodeLocationId. It exists
21065
+ // because a location's identity is two values and a URL param carries one:
21066
+ // `?loc=` names a location the way `?rule=` names a type. Only ever compared
21067
+ // for EQUALITY — the page's selection check, this read's `includeId`, the
21068
+ // client's page dedupe — never decoded, and never a sort key.
21069
+ id: external_exports.string(),
20960
21070
  /** Empty when the instances carried no repo attribute. */
20961
21071
  repo: external_exports.string(),
21072
+ // Empty when the instances carried no file path (a prompt, or a tool call
21073
+ // with no file attribution). Both halves empty is a real location — usually
21074
+ // the largest one in a store — and is selectable like any other.
21075
+ file: external_exports.string(),
20962
21076
  instanceCount: external_exports.number().int().nonnegative(),
21077
+ // The WORST severity present, not the first row's. It is this list's primary
21078
+ // sort key, so it is also what explains why a row is where it is, and it is
21079
+ // how a reader decides what to open without opening everything.
20963
21080
  maxSeverity: Severity,
20964
21081
  latestDetectedAt: external_exports.iso.datetime(),
21082
+ // Folded from the instances' derived statuses with the same open-dominates
21083
+ // precedence a group uses, so it answers "is anything left to do here" and
21084
+ // not much more: a location holding 1 open among 40 resolved reads like one
21085
+ // holding 40 open. That loss is accepted — the panel beside this list
21086
+ // carries each finding's own status, and instanceCount sits next to the
21087
+ // badge.
20965
21088
  status: FindingStatus.optional(),
20966
- files: external_exports.array(FindingLocationFile)
20967
- }).meta({ id: "FindingLocationRepo" });
21089
+ // Every distinct rule seen at this location, UNCAPPED — so the length is a
21090
+ // tally rather than a sample and a row can say how many there are. Bounded
21091
+ // by the ruleset, not by the store. The view bounds what it DISPLAYS.
21092
+ ruleIds: external_exports.array(external_exports.string())
21093
+ }).meta({ id: "FindingLocationSummary" });
21094
+ var DEFAULT_FINDING_LOCATIONS_LIMIT = 50;
21095
+ var MAX_FINDING_LOCATIONS_LIMIT = 100;
20968
21096
  var ListFindingLocationsQuery = external_exports.object({
20969
21097
  severity: external_exports.array(Severity).optional(),
20970
21098
  subtype: external_exports.array(external_exports.string()).optional(),
@@ -20977,18 +21105,42 @@ var ListFindingLocationsQuery = external_exports.object({
20977
21105
  q: external_exports.string().optional(),
20978
21106
  sessionId: external_exports.string().optional(),
20979
21107
  from: external_exports.iso.datetime().optional(),
20980
- limit: external_exports.coerce.number().int().min(1).max(500).optional()
21108
+ // A LOCATION id (see FindingLocationSummary.id) that must appear in the page
21109
+ // even when the cursor has already advanced past its sort position — the
21110
+ // counterpart of ListFindingTypesQuery.includeId, and needed far more often
21111
+ // here. Selecting a row pushes the URL, which re-renders the server and resets
21112
+ // the client's page cache to page 0; with distinct (repo, file) pairs running
21113
+ // into the thousands, a selection sitting off page 0 is the ordinary case
21114
+ // rather than a deep-link corner. Never affects totals, facets or the cursor.
21115
+ includeId: external_exports.string().optional(),
21116
+ limit: external_exports.coerce.number().int().min(1).max(MAX_FINDING_LOCATIONS_LIMIT).optional(),
21117
+ cursor: external_exports.string().optional()
20981
21118
  });
20982
21119
  var ListFindingLocationsResponse = external_exports.object({
20983
21120
  totals: external_exports.object({
21121
+ // Findings matching the filters across the whole scope. Unlike the types
21122
+ // read's same-named field this needs no caveat: the filters here narrow
21123
+ // per finding, so this is the sum of every row's instanceCount.
20984
21124
  findings: external_exports.number().int().nonnegative(),
20985
- repos: external_exports.number().int().nonnegative(),
20986
- files: external_exports.number().int().nonnegative()
21125
+ // Counts LOCATIONS, the unit this read pages — the number the paginator
21126
+ // states. The facets beside it count FINDINGS (see below); a surface
21127
+ // showing both says which is which.
21128
+ locations: external_exports.number().int().nonnegative()
20987
21129
  }),
20988
- /** Sorted by max severity, then most recent. */
20989
- items: external_exports.array(FindingLocationRepo),
20990
- /** Whether `limit` truncated the repo list. */
20991
- hasMore: external_exports.boolean()
21130
+ // Counts in FINDINGS, where the types response counts types, each dimension
21131
+ // still excluding its own filter. Deliberately not locations: counting those
21132
+ // needs a set of location keys per dimension per value — memory tracking the
21133
+ // store times the vocabulary, in a read whose scan promises flat memory —
21134
+ // and the cheap per-location version is not an approximation but WRONG. A
21135
+ // location holding {claudecode, block} and {codex, warn} would survive
21136
+ // provider=claudecode AND action=warn, under which no single finding
21137
+ // matches, so the facet would contradict the instanceCount this whole view
21138
+ // rests on. Findings also keep the toolbar in the same unit as the page
21139
+ // tally and the panel it sits above.
21140
+ facets: FindingFacets,
21141
+ /** Sorted by max severity, then most recent, then (repo, file). */
21142
+ items: external_exports.array(FindingLocationSummary),
21143
+ nextCursor: external_exports.string().nullable()
20992
21144
  }).meta({ id: "ListFindingLocationsResponse" });
20993
21145
 
20994
21146
  // ../../packages/schema/src/zod/meta.ts
@@ -22157,6 +22309,14 @@ var ControlPlaneErrorBody = external_exports.object({
22157
22309
  message: external_exports.string().optional()
22158
22310
  }).optional()
22159
22311
  });
22312
+ var RemoteFailureKind = external_exports.enum([
22313
+ "unauthorized",
22314
+ "forbidden",
22315
+ "route-absent",
22316
+ "invalid-request",
22317
+ "rejected",
22318
+ "unreachable"
22319
+ ]);
22160
22320
  var AttachDeviceRequest = external_exports.object({
22161
22321
  // This machine's own continuity id, so re-attaching ROTATES the credential
22162
22322
  // on one machine record instead of producing a second one. Client-minted
@@ -22850,139 +23010,62 @@ function deriveFindingStatus(row) {
22850
23010
  if (row.latestResolutionStatus === "dismissed") return "dismissed";
22851
23011
  return "open";
22852
23012
  }
22853
- function distinctUsers(instances) {
22854
- const seen = /* @__PURE__ */ new Set();
22855
- const users = [];
22856
- for (const i of instances) {
22857
- if (i.user === void 0 || seen.has(i.user.id)) continue;
22858
- seen.add(i.user.id);
22859
- users.push(i.user);
22860
- }
22861
- return users;
22862
- }
22863
23013
  function sortUsers(users) {
22864
23014
  return [...users].sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
22865
23015
  }
22866
- function buildFindingGroups(rows, opts = {}) {
22867
- const overrides = opts.overrides;
23016
+ function buildFindingTypes(aggregates, opts = {}) {
22868
23017
  const packNames = opts.packNames;
22869
- const aggregates = opts.aggregates;
22870
- const byRuleId = /* @__PURE__ */ new Map();
22871
- for (const row of rows) {
22872
- const existing = byRuleId.get(row.ruleId);
22873
- if (existing) existing.push(row);
22874
- else byRuleId.set(row.ruleId, [row]);
22875
- }
22876
- const groups = [];
22877
- for (const [ruleId, ruleRows] of byRuleId) {
22878
- const instances = ruleRows.map((r) => {
22879
- const effectiveDbAction = overrides?.get(r.id) ?? r.actionTaken;
22880
- return {
22881
- id: r.id,
22882
- provider: toApiProvider(r.sourceTool),
22883
- repo: r.repo,
22884
- file: r.file,
22885
- ...r.toolName === void 0 ? {} : { toolName: r.toolName },
22886
- ...r.eventId === void 0 ? {} : { eventId: r.eventId },
22887
- ...r.sessionId === void 0 ? {} : { sessionId: r.sessionId },
22888
- ...r.user === void 0 ? {} : { user: r.user },
22889
- action: toApiAction(effectiveDbAction),
22890
- detectedAt: r.occurredAt,
22891
- confidence: r.confidence,
22892
- status: r.status
22893
- };
22894
- });
22895
- const agg = aggregates?.get(ruleId);
22896
- const users = agg ? sortUsers(agg.users ?? []) : distinctUsers(instances);
22897
- const latestDetectedAt = agg?.latestDetectedAt ?? ruleRows.reduce(
22898
- (max, r) => r.occurredAt > max ? r.occurredAt : max,
22899
- ruleRows[0]?.occurredAt ?? (/* @__PURE__ */ new Date(0)).toISOString()
22900
- );
22901
- const seenProviders = /* @__PURE__ */ new Set();
22902
- const providers = (agg ? [...new Set(agg.sourceTools.map(toApiProvider))].sort() : instances.map((i) => i.provider)).filter((p) => {
22903
- if (seenProviders.has(p)) return false;
22904
- seenProviders.add(p);
22905
- return true;
22906
- });
22907
- const actionSet = new Set(
22908
- agg ? agg.actionsTaken.map(toApiAction) : instances.map((i) => i.action)
22909
- );
23018
+ const types = [];
23019
+ for (const [ruleId, agg] of aggregates) {
23020
+ const users = sortUsers(agg.users ?? []);
23021
+ const providers = [...new Set(agg.sourceTools.map(toApiProvider))].sort();
23022
+ const actionSet = new Set(agg.actionsTaken.map(toApiAction));
22910
23023
  const aggregateAction = actionSet.size === 1 ? [...actionSet][0] ?? null : null;
22911
- const severity = ruleRows[0]?.severity ?? "low";
22912
- const detection = {
22913
- id: ruleId,
22914
- name: packNames?.get(ruleId) ?? null
22915
- };
22916
- const apiCategory = toApiCategory(ruleRows[0]?.category ?? "custom");
22917
- const policy = { id: `category:${apiCategory}`, name: apiCategory };
22918
- const match = {
22919
- maskedValue: ruleRows[0]?.maskedMatch ?? "",
22920
- contextPrefix: ""
22921
- // empty (pending privacy review)
22922
- };
22923
- const status = foldGroupStatus(
22924
- agg ? agg.statusInputs.map(deriveFindingStatus) : instances.map((i) => i.status)
22925
- );
22926
- const group = {
23024
+ const apiCategory = toApiCategory(agg.category ?? "custom");
23025
+ const type = {
22927
23026
  id: ruleId,
22928
23027
  category: apiCategory,
22929
23028
  subtype: ruleId,
22930
23029
  // human label comes with pack metadata later
22931
- severity,
22932
- match,
22933
- detection,
22934
- policy,
22935
- instanceCount: agg?.instanceCount ?? instances.length,
23030
+ severity: agg.severity ?? "low",
23031
+ detection: { id: ruleId, name: packNames?.get(ruleId) ?? null },
23032
+ policy: { id: `category:${apiCategory}`, name: apiCategory },
23033
+ instanceCount: agg.instanceCount,
22936
23034
  providers,
22937
23035
  aggregateAction,
22938
- latestDetectedAt,
22939
- instances,
22940
- status,
23036
+ latestDetectedAt: agg.latestDetectedAt,
23037
+ status: foldGroupStatus(agg.statusInputs.map(deriveFindingStatus)),
22941
23038
  ...users.length > 0 ? { users } : {}
22942
23039
  };
22943
- if (agg) {
22944
- actionsCache.set(group, [...actionSet]);
22945
- if (agg.searchText !== void 0) {
22946
- haystackCache.set(group, buildHaystack(group, agg.searchText));
22947
- }
23040
+ actionsCache.set(type, [...actionSet]);
23041
+ if (agg.searchText !== void 0) {
23042
+ haystackCache.set(type, buildHaystack(type, agg.searchText));
22948
23043
  }
22949
- groups.push(group);
23044
+ types.push(type);
22950
23045
  }
22951
- return groups;
23046
+ return types;
22952
23047
  }
22953
23048
  var haystackCache = /* @__PURE__ */ new WeakMap();
22954
- function buildHaystack(g, extra) {
23049
+ function buildHaystack(t, extra) {
22955
23050
  return [
22956
- g.subtype,
22957
- g.category,
22958
- g.match.maskedValue,
22959
- g.policy.name,
22960
- g.id,
22961
- ...g.instances.map((i) => i.repo),
22962
- ...g.instances.map((i) => i.file),
22963
- ...g.instances.map((i) => i.toolName ? `via ${i.toolName}` : ""),
22964
- ...g.instances.map((i) => i.id),
22965
- // The people: the whole group's list when the store folded one, plus the
22966
- // preview's own — the two overlap, and a haystack does not mind.
22967
- ...(g.users ?? []).map((u) => u.name),
22968
- ...g.instances.map((i) => i.user?.name ?? ""),
23051
+ t.subtype,
23052
+ t.category,
23053
+ t.policy.name,
23054
+ t.id,
23055
+ ...(t.users ?? []).map((u) => u.name),
22969
23056
  ...extra === void 0 ? [] : [extra]
22970
23057
  ].join(" ").toLowerCase();
22971
23058
  }
22972
- function groupHaystack(g) {
22973
- const cached2 = haystackCache.get(g);
23059
+ function typeHaystack(t) {
23060
+ const cached2 = haystackCache.get(t);
22974
23061
  if (cached2 !== void 0) return cached2;
22975
- const haystack = buildHaystack(g);
22976
- haystackCache.set(g, haystack);
23062
+ const haystack = buildHaystack(t);
23063
+ haystackCache.set(t, haystack);
22977
23064
  return haystack;
22978
23065
  }
22979
23066
  var actionsCache = /* @__PURE__ */ new WeakMap();
22980
- function groupActions(g) {
22981
- const cached2 = actionsCache.get(g);
22982
- if (cached2 !== void 0) return cached2;
22983
- const actions = [...new Set(g.instances.map((i) => i.action))];
22984
- actionsCache.set(g, actions);
22985
- return actions;
23067
+ function typeActions(t) {
23068
+ return actionsCache.get(t) ?? [];
22986
23069
  }
22987
23070
  function countInstancesByStatus(statusInputs, statuses) {
22988
23071
  const statusSet = new Set(statuses);
@@ -22993,8 +23076,8 @@ function countInstancesByStatus(statusInputs, statuses) {
22993
23076
  }
22994
23077
  return sum;
22995
23078
  }
22996
- function applyFindingFilters(groups, opts) {
22997
- let filtered = groups;
23079
+ function applyFindingFilters(types, opts) {
23080
+ let filtered = types;
22998
23081
  if (opts.severity && opts.severity.length > 0) {
22999
23082
  const sevSet = new Set(opts.severity);
23000
23083
  filtered = filtered.filter((g) => sevSet.has(g.severity));
@@ -23005,7 +23088,7 @@ function applyFindingFilters(groups, opts) {
23005
23088
  }
23006
23089
  if (opts.actions && opts.actions.length > 0) {
23007
23090
  const actionSet = new Set(opts.actions);
23008
- filtered = filtered.filter((g) => groupActions(g).some((a) => actionSet.has(a)));
23091
+ filtered = filtered.filter((t) => typeActions(t).some((a) => actionSet.has(a)));
23009
23092
  }
23010
23093
  if (opts.subtype && opts.subtype.length > 0) {
23011
23094
  const subtypeSet = new Set(opts.subtype);
@@ -23017,7 +23100,7 @@ function applyFindingFilters(groups, opts) {
23017
23100
  }
23018
23101
  if (opts.q) {
23019
23102
  const q = opts.q.toLowerCase();
23020
- filtered = filtered.filter((g) => groupHaystack(g).includes(q));
23103
+ filtered = filtered.filter((t) => typeHaystack(t).includes(q));
23021
23104
  }
23022
23105
  return filtered;
23023
23106
  }
@@ -23032,11 +23115,11 @@ function compareFindingGroupOrder(a, b) {
23032
23115
  if (recencyDiff !== 0) return recencyDiff;
23033
23116
  return a.id.localeCompare(b.id);
23034
23117
  }
23035
- function sortFindingGroups(groups) {
23036
- return [...groups].sort(compareFindingGroupOrder);
23118
+ function sortFindingTypes(types) {
23119
+ return [...types].sort(compareFindingGroupOrder);
23037
23120
  }
23038
- function computeFindingFacets(allGroups, opts) {
23039
- const forSeverity = applyFindingFilters(allGroups, {
23121
+ function computeFindingFacets(allTypes, opts) {
23122
+ const forSeverity = applyFindingFilters(allTypes, {
23040
23123
  providers: opts.providers,
23041
23124
  actions: opts.actions,
23042
23125
  statuses: opts.statuses,
@@ -23047,7 +23130,7 @@ function computeFindingFacets(allGroups, opts) {
23047
23130
  for (const g of forSeverity) {
23048
23131
  severityMap.set(g.severity, (severityMap.get(g.severity) ?? 0) + 1);
23049
23132
  }
23050
- const forProvider = applyFindingFilters(allGroups, {
23133
+ const forProvider = applyFindingFilters(allTypes, {
23051
23134
  actions: opts.actions,
23052
23135
  statuses: opts.statuses,
23053
23136
  q: opts.q,
@@ -23058,7 +23141,7 @@ function computeFindingFacets(allGroups, opts) {
23058
23141
  for (const g of forProvider) {
23059
23142
  for (const p of g.providers) providerMap.set(p, (providerMap.get(p) ?? 0) + 1);
23060
23143
  }
23061
- const forAction = applyFindingFilters(allGroups, {
23144
+ const forAction = applyFindingFilters(allTypes, {
23062
23145
  providers: opts.providers,
23063
23146
  statuses: opts.statuses,
23064
23147
  q: opts.q,
@@ -23067,9 +23150,9 @@ function computeFindingFacets(allGroups, opts) {
23067
23150
  });
23068
23151
  const actionMap = /* @__PURE__ */ new Map();
23069
23152
  for (const g of forAction) {
23070
- for (const a of groupActions(g)) actionMap.set(a, (actionMap.get(a) ?? 0) + 1);
23153
+ for (const a of typeActions(g)) actionMap.set(a, (actionMap.get(a) ?? 0) + 1);
23071
23154
  }
23072
- const forSubtype = applyFindingFilters(allGroups, {
23155
+ const forSubtype = applyFindingFilters(allTypes, {
23073
23156
  providers: opts.providers,
23074
23157
  actions: opts.actions,
23075
23158
  statuses: opts.statuses,
@@ -23078,7 +23161,7 @@ function computeFindingFacets(allGroups, opts) {
23078
23161
  });
23079
23162
  const subtypeMap = /* @__PURE__ */ new Map();
23080
23163
  for (const g of forSubtype) subtypeMap.set(g.subtype, (subtypeMap.get(g.subtype) ?? 0) + 1);
23081
- const forStatus = applyFindingFilters(allGroups, {
23164
+ const forStatus = applyFindingFilters(allTypes, {
23082
23165
  providers: opts.providers,
23083
23166
  actions: opts.actions,
23084
23167
  q: opts.q,
@@ -23126,10 +23209,20 @@ function matchesDimension(row, opts, dimension) {
23126
23209
  return !opts.statuses?.length || row.status !== void 0 && opts.statuses.includes(row.status);
23127
23210
  case "tools":
23128
23211
  return !opts.tools?.length || row.toolName !== void 0 && opts.tools.includes(row.toolName);
23212
+ // An EMPTY value is a real filter here, not an absent one. The location
23213
+ // list buckets a finding whose event recorded no repo — or no file — under
23214
+ // the empty string, and selecting that bucket has to narrow the panel to
23215
+ // exactly it. Only `undefined` means "no filter"; a caller that wants every
23216
+ // row omits the key, which every call site already does.
23217
+ //
23218
+ // Reading '' as unset is what this replaced, and it failed in the one place
23219
+ // it mattered: the no-repo/no-file bucket is often the largest in a real
23220
+ // store, and its panel dropped both predicates and returned the WHOLE scope
23221
+ // — a row reading 3 findings beside a panel listing every finding there is.
23129
23222
  case "repo":
23130
- return opts.repo === void 0 || opts.repo === "" || row.repo === opts.repo;
23223
+ return opts.repo === void 0 || row.repo === opts.repo;
23131
23224
  case "file":
23132
- return opts.file === void 0 || opts.file === "" || row.file === opts.file;
23225
+ return opts.file === void 0 || row.file === opts.file;
23133
23226
  case "q":
23134
23227
  return !opts.q || rowHaystack(row).includes(opts.q.toLowerCase());
23135
23228
  }
@@ -23243,6 +23336,23 @@ function addToLocation(acc, row) {
23243
23336
  acc.statuses.push(row.status);
23244
23337
  acc.ruleIds.add(row.ruleId);
23245
23338
  }
23339
+ function compareLocationOrder(a, b) {
23340
+ const rankA = SEVERITY_ORDER2[a.maxSeverity] ?? -1;
23341
+ const rankB = SEVERITY_ORDER2[b.maxSeverity] ?? -1;
23342
+ if (rankA !== rankB) return rankA - rankB;
23343
+ if (a.latestDetectedAt !== b.latestDetectedAt) {
23344
+ return a.latestDetectedAt < b.latestDetectedAt ? 1 : -1;
23345
+ }
23346
+ if (a.repo !== b.repo) return a.repo < b.repo ? -1 : 1;
23347
+ if (a.file !== b.file) return a.file < b.file ? -1 : 1;
23348
+ return 0;
23349
+ }
23350
+ function encodeLocationId(repo, file2) {
23351
+ return `${encodePart(repo)}/${encodePart(file2)}`;
23352
+ }
23353
+ function encodePart(value) {
23354
+ return encodeURIComponent(value.replace(/[\uD800-\uDFFF]/gu, "\uFFFD"));
23355
+ }
23246
23356
 
23247
23357
  // ../../packages/schema/src/zod/installed-pack.ts
23248
23358
  var InstalledPack = external_exports.object({
@@ -23751,11 +23861,11 @@ var WorkspaceSettings = external_exports.object({
23751
23861
  // covers the current payload and must be re-granted.
23752
23862
  modelJudgeConsent: ModelJudgeConsent.optional(),
23753
23863
  // Records that the user consented to the DEFERRED send — the outbox — along
23754
- // with the payload shape and the endpoint they agreed to. Since payload v2
23755
- // that covers both the pre-attach backlog and undelivered captures (which
23756
- // carry prompt/reply text in `content`); the key name predates the widening.
23757
- // Absent until granted, and a grant for a different endpoint or an older
23758
- // payload no longer counts.
23864
+ // with the payload shape and the endpoint they agreed to. Since payload v3
23865
+ // that covers the pre-attach backlog AND undelivered captures alike, and both
23866
+ // carry prompt/reply/tool-output text in `content`; the key name predates
23867
+ // both widenings. Absent until granted, and a grant for a different endpoint
23868
+ // or an older payload no longer counts.
23759
23869
  historySyncConsent: HistorySyncConsent.optional()
23760
23870
  });
23761
23871
  function defaultWorkspaceSettings() {
@@ -23886,6 +23996,9 @@ var ManagedSettingKey = external_exports.enum([
23886
23996
  "dataSharesInPlace",
23887
23997
  "redactFallback"
23888
23998
  ]).meta({ id: "ManagedSettingKey" });
23999
+ function isManagedSettingKey(value) {
24000
+ return ManagedSettingKey.safeParse(value).success;
24001
+ }
23889
24002
  var ManagedSettingsValues = external_exports.object({
23890
24003
  runMode: external_exports.enum(["standalone", "attached"]).optional(),
23891
24004
  controlPlane: external_exports.object({
@@ -23910,7 +24023,27 @@ var ManagedSettings = external_exports.object({
23910
24023
  // Which of those the user may not change. A key here with no matching value
23911
24024
  // freezes whatever the user last chose; a value with no lock is a DEFAULT
23912
24025
  // the user may still override. The two are separable on purpose.
23913
- lockedFields: external_exports.array(ManagedSettingKey).default([])
24026
+ //
24027
+ // Parsed as NAMES rather than as the enum, and split below: a name this
24028
+ // build does not know is dropped from the locked set and reported, never a
24029
+ // reason to refuse the file. The same shape reaches an older build whenever
24030
+ // an administrator locks a key a newer build added, and refusing it there
24031
+ // ran that build entirely unmanaged — every pin and lock gone — on exactly
24032
+ // the fleets most likely to carry a version skew. A name outside the enum
24033
+ // is still never HONOURED: the lockable set stays explicit above.
24034
+ lockedFields: external_exports.array(external_exports.string()).default([])
24035
+ }).transform(({ lockedFields, ...rest }) => {
24036
+ const known = [];
24037
+ const unknown2 = [];
24038
+ for (const name of lockedFields) {
24039
+ if (isManagedSettingKey(name)) known.push(name);
24040
+ else unknown2.push(name);
24041
+ }
24042
+ return {
24043
+ ...rest,
24044
+ lockedFields: known,
24045
+ ...unknown2.length > 0 ? { unknownLockedFields: unknown2 } : {}
24046
+ };
23914
24047
  }).meta({ id: "ManagedSettings" });
23915
24048
 
23916
24049
  // ../../packages/schema/src/zod/project-files.ts
@@ -24034,7 +24167,11 @@ var FindingsTimeseriesPoint = external_exports.object({
24034
24167
  timestamp: external_exports.iso.date(),
24035
24168
  critical: external_exports.number().int().nonnegative(),
24036
24169
  high: external_exports.number().int().nonnegative(),
24037
- medium: external_exports.number().int().nonnegative()
24170
+ medium: external_exports.number().int().nonnegative(),
24171
+ // Optional and additive, so a producer written against the earlier
24172
+ // three-series contract keeps validating. A consumer plotting it resolves the
24173
+ // absent case itself — the chart point requires a number.
24174
+ low: external_exports.number().int().nonnegative().optional()
24038
24175
  }).meta({ id: "FindingsTimeseriesPoint" });
24039
24176
  var FindingsTimeseriesResponse = external_exports.object({
24040
24177
  range: TimeRange,
@@ -24060,6 +24197,10 @@ var ResolvedFeedItem = external_exports.object({
24060
24197
  findingKey: external_exports.string(),
24061
24198
  ruleId: external_exports.string(),
24062
24199
  severity: Severity,
24200
+ // Repository slug, and the file path RELATIVE to it. The pair is what
24201
+ // identifies the file: a bare path matches the same name in every repo.
24202
+ // Optional and additive; empty when the event carried no repo.
24203
+ repo: external_exports.string().optional(),
24063
24204
  path: external_exports.string(),
24064
24205
  // ISO-8601 datetime (matches FindingInstance.detectedAt / the rest of the
24065
24206
  // findings domain). The reader `.toISOString()`s the DB epoch-ms values.
@@ -25603,7 +25744,8 @@ var SqliteActivityRepository = class {
25603
25744
  SELECT 1 FROM audit_events d
25604
25745
  WHERE d.root_session_id = audit_events.id
25605
25746
  AND (d.content LIKE ? ESCAPE '\\'
25606
- OR json_extract(d.attributes, '$.detail') LIKE ? ESCAPE '\\')))`
25747
+ OR coalesce(json_extract(d.attributes, '$.detail'),
25748
+ json_extract(d.attributes, '$.target')) LIKE ? ESCAPE '\\')))`
25607
25749
  );
25608
25750
  params.push(pattern, pattern, pattern, pattern, pattern, pattern);
25609
25751
  }
@@ -26941,23 +27083,6 @@ var LATEST_RESOLUTION_BY_KEY_SQL = `(
26941
27083
  )`;
26942
27084
 
26943
27085
  // ../../packages/persistence/src/repositories/findings.ts
26944
- var PREVIEW_INSTANCES_PER_GROUP = 200;
26945
- var DEFAULT_LOCATIONS_LIMIT = 100;
26946
- var LOCATION_RULE_IDS_CAP = 20;
26947
- function compareLocationOrder(a, b) {
26948
- return compareFindingGroupOrder(
26949
- {
26950
- severity: a.maxSeverity,
26951
- latestDetectedAt: a.latestDetectedAt,
26952
- id: ""
26953
- },
26954
- {
26955
- severity: b.maxSeverity,
26956
- latestDetectedAt: b.latestDetectedAt,
26957
- id: ""
26958
- }
26959
- );
26960
- }
26961
27086
  var CONCAT_SEP = ",";
26962
27087
  var TUPLE_SEP = "|";
26963
27088
  function splitConcat(value) {
@@ -27009,13 +27134,48 @@ function decodeGroupCursor(cursor) {
27009
27134
  return null;
27010
27135
  }
27011
27136
  function firstAfter(sorted, cursor) {
27012
- const index = sorted.findIndex((g) => compareFindingGroupOrder(g, cursor) > 0);
27137
+ const index = sorted.findIndex((t) => compareFindingGroupOrder(t, cursor) > 0);
27013
27138
  return index === -1 ? sorted.length : index;
27014
27139
  }
27015
27140
  function findDeepLinked(sorted, page, id) {
27016
- if (page.some((g) => g.id === id || g.instances.some((i) => i.id === id))) return void 0;
27017
- return sorted.find((g) => g.id === id || g.instances.some((i) => i.id === id));
27141
+ if (page.some((t) => t.id === id)) return void 0;
27142
+ return sorted.find((t) => t.id === id);
27018
27143
  }
27144
+ function encodeLocationCursor(location) {
27145
+ const payload = {
27146
+ sev: location.maxSeverity,
27147
+ t: location.latestDetectedAt,
27148
+ r: location.repo,
27149
+ f: location.file
27150
+ };
27151
+ return Buffer.from(JSON.stringify(payload)).toString("base64url");
27152
+ }
27153
+ function decodeLocationCursor(cursor) {
27154
+ const parsed2 = parseJsonObject(Buffer.from(cursor, "base64url").toString("utf8"));
27155
+ if (parsed2 !== void 0 && typeof parsed2.sev === "string" && typeof parsed2.t === "string" && typeof parsed2.r === "string" && typeof parsed2.f === "string") {
27156
+ return { maxSeverity: parsed2.sev, latestDetectedAt: parsed2.t, repo: parsed2.r, file: parsed2.f };
27157
+ }
27158
+ return null;
27159
+ }
27160
+ function firstLocationAfter(sorted, cursor) {
27161
+ const index = sorted.findIndex((l) => compareLocationOrder(l, cursor) > 0);
27162
+ return index === -1 ? sorted.length : index;
27163
+ }
27164
+ function findDeepLinkedLocation(sorted, page, id) {
27165
+ if (page.some((l) => l.id === id)) return void 0;
27166
+ return sorted.find((l) => l.id === id);
27167
+ }
27168
+ var FINDING_ROW_COLUMNS_SQL = `f.id AS id, d.rule_id AS rule_id, d.category AS category,
27169
+ d.severity AS severity, f.masked_match AS masked_match,
27170
+ f.action_taken AS action_taken, f.confidence AS confidence,
27171
+ e.started_at AS occurred_at,
27172
+ e.source_tool AS source_tool,
27173
+ e.repo AS repo,
27174
+ e.file_path AS file,
27175
+ e.tool_name AS tool_name,
27176
+ f.audit_event_id AS event_id, e.root_session_id AS session_id,
27177
+ e.event_type AS kind, f.finding_key AS finding_key,
27178
+ ${latestResolutionStatusSql("f")} AS latest_status`;
27019
27179
  var DAY_MS3 = 864e5;
27020
27180
  var SqliteFindingsRepository = class {
27021
27181
  constructor(db) {
@@ -27136,30 +27296,26 @@ var SqliteFindingsRepository = class {
27136
27296
  );
27137
27297
  }
27138
27298
  /**
27139
- * Grouped findings for the dashboard — joins inspection_findings⋈audit_events
27140
- * ⋈inspection_definitions (repo/file/toolName from the audit event's
27141
- * attributes bag, rule_id/category/severity from the definition), scoped to
27142
- * the four capture kinds (audit_events also holds structural/reconciler/scan
27143
- * rows this list must never surface), groups by ruleId, computes
27144
- * per-filter-excluded facets, applies the requested filters, and sorts by
27145
- * severity then recency. Filtering
27146
- * and faceting run in JS via the shared @akasecurity/schema helpers. `totals`
27147
- * reflect the full filtered set; `items` is the requested
27148
- * page (default 50); no cursor (nextCursor is always null). Under a `status`
27149
- * filter, `totals.findings` counts only instances whose derived status was
27150
- * requested, and each item's instance preview is narrowed the same way.
27299
+ * Finding TYPES for the dashboard — one row per rule, scoped to the four
27300
+ * capture kinds (audit_events also holds structural/reconciler/scan rows this
27301
+ * list must never surface), with per-filter-excluded facets, the requested
27302
+ * filters applied, and sorted by severity then recency. Filtering and faceting
27303
+ * run in JS via the shared @akasecurity/schema helpers. `totals` reflect the
27304
+ * full filtered set; `items` is the requested page (default 50), keyset-paged.
27305
+ * Under a `status` filter, `totals.findings` counts only findings whose
27306
+ * derived status was requested.
27307
+ *
27308
+ * ONE read, which materializes no findings: a single aggregate per rule_id,
27309
+ * folding EVERY finding into the numbers a type row and the filters need
27310
+ * (count, severity, category, providers, actions, statuses, latest, search
27311
+ * text). The findings OF a type come from listFindingInstances scoped to
27312
+ * `subtype`, so neither list bounds the other and no per-type cap exists.
27151
27313
  *
27152
- * Two reads, neither of which materializes a row per finding:
27153
- * 1. one aggregate row per rule_id, folding EVERY instance into the numbers
27154
- * the group and the filters need (count, providers, actions, statuses,
27155
- * latest, search text);
27156
- * 2. each group's newest PREVIEW_INSTANCES_PER_GROUP instances, which
27157
- * populate `instances` for the table's expanded rows.
27158
27314
  * The aggregates carry raw DB values and are translated by the same
27159
- * @akasecurity/schema mappers the row path uses, so no enum mapping or status
27160
- * rule is ever restated in SQL.
27315
+ * @akasecurity/schema mappers every other path uses, so no enum mapping or
27316
+ * status rule is ever restated in SQL.
27161
27317
  */
27162
- listGroupedFindings(query) {
27318
+ listFindingTypes(query) {
27163
27319
  const sessionPredicate = query.sessionId ? ` AND e.root_session_id = :sessionId` : "";
27164
27320
  const fromMs = query.from === void 0 ? void 0 : isoToEpochMillis(query.from);
27165
27321
  const fromPredicate = fromMs === void 0 ? "" : ` AND e.started_at >= :fromMs`;
@@ -27172,12 +27328,7 @@ var SqliteFindingsRepository = class {
27172
27328
  predicate,
27173
27329
  params: sessionParams
27174
27330
  });
27175
- const rows = this.previewRows(aggregates, {
27176
- sessionId: query.sessionId,
27177
- from: query.from
27178
- });
27179
- const groupable = rows.map(toFlatFindingRow);
27180
- const allGroups = buildFindingGroups(groupable, { aggregates });
27331
+ const allTypes = buildFindingTypes(aggregates);
27181
27332
  const filterOpts = {
27182
27333
  severity: query.severity,
27183
27334
  providers: query.provider,
@@ -27186,30 +27337,25 @@ var SqliteFindingsRepository = class {
27186
27337
  subtype: query.subtype,
27187
27338
  q: query.q
27188
27339
  };
27189
- const facets = computeFindingFacets(allGroups, filterOpts);
27190
- const sorted = sortFindingGroups(applyFindingFilters(allGroups, filterOpts));
27340
+ const facets = computeFindingFacets(allTypes, filterOpts);
27341
+ const sorted = sortFindingTypes(applyFindingFilters(allTypes, filterOpts));
27191
27342
  const statusFilter = query.status ?? [];
27192
27343
  const totals = {
27193
- findings: sorted.reduce((acc, g) => {
27194
- if (statusFilter.length === 0) return acc + g.instanceCount;
27195
- const agg = aggregates.get(g.id);
27196
- return acc + (agg ? countInstancesByStatus(agg.statusInputs, statusFilter) ?? g.instanceCount : g.instanceCount);
27344
+ findings: sorted.reduce((acc, t) => {
27345
+ if (statusFilter.length === 0) return acc + t.instanceCount;
27346
+ const agg = aggregates.get(t.id);
27347
+ return acc + (agg ? countInstancesByStatus(agg.statusInputs, statusFilter) ?? t.instanceCount : t.instanceCount);
27197
27348
  }, 0),
27198
- groups: sorted.length
27349
+ types: sorted.length
27199
27350
  };
27200
- const limit = query.limit ?? DEFAULT_GROUPED_FINDINGS_LIMIT;
27351
+ const limit = query.limit ?? DEFAULT_FINDING_TYPES_LIMIT;
27201
27352
  const cursor = query.cursor === void 0 ? null : decodeGroupCursor(query.cursor);
27202
27353
  const start = cursor === null ? 0 : firstAfter(sorted, cursor);
27203
27354
  const page = sorted.slice(start, start + limit);
27204
27355
  const lastOnPage = page.at(-1);
27205
27356
  const nextCursor = start + limit < sorted.length && lastOnPage ? encodeGroupCursor(lastOnPage) : null;
27206
27357
  const deepLinked = query.includeId === void 0 || query.includeId === "" ? void 0 : findDeepLinked(sorted, page, query.includeId);
27207
- const statusSet = statusFilter.length > 0 ? new Set(statusFilter) : null;
27208
- const narrow = (g) => statusSet ? {
27209
- ...g,
27210
- instances: g.instances.filter((i) => i.status !== void 0 && statusSet.has(i.status))
27211
- } : g;
27212
- const items = [...page, ...deepLinked ? [deepLinked] : []].map(narrow);
27358
+ const items = [...page, ...deepLinked ? [deepLinked] : []];
27213
27359
  return Promise.resolve({
27214
27360
  totals,
27215
27361
  facets,
@@ -27220,7 +27366,7 @@ var SqliteFindingsRepository = class {
27220
27366
  }
27221
27367
  /**
27222
27368
  * One row per rule_id, folding EVERY instance of the group into the values
27223
- * buildFindingGroups cannot recover from a preview. Bounded by the number of
27369
+ * buildFindingTypes cannot recover from an aggregate. Bounded by the number of
27224
27370
  * distinct rule_ids (the installed packs' rules), not by the store's size.
27225
27371
  *
27226
27372
  * A single scan, folded in two levels: the inner SELECT groups by
@@ -27314,13 +27460,25 @@ var SqliteFindingsRepository = class {
27314
27460
  });
27315
27461
  }
27316
27462
  /**
27317
- * The same findings folded by location: repository, then file within it.
27463
+ * The same findings folded by WHERE they live — one row per (repo, file) pair.
27318
27464
  *
27319
27465
  * The grouping keys come from the capturing event's attributes, which is what
27320
- * the local store relates a finding to — there is no finding↔asset row to
27321
- * group by instead. A repo or file the event did not record folds into the
27322
- * empty-string bucket, which the view renders but does not link, since no
27323
- * filter can name it.
27466
+ * the local store relates a finding to; there is no finding↔asset row to group
27467
+ * by instead. A repo or file the event did not record folds into the
27468
+ * empty-string bucket, which is a real location like any other: it is listed,
27469
+ * it is selectable, and its `?loc=` token is as good as any other row's.
27470
+ *
27471
+ * ONE flat list rather than repos nesting files. A rollup can only be paged by
27472
+ * repo, which leaves the file list inside it unbounded — the shape the by-type
27473
+ * list was rebuilt to remove — and two-level pagination inside an
27474
+ * expand/collapse table is what pushed that view to master/detail in the first
27475
+ * place.
27476
+ *
27477
+ * Every filter narrows the FINDINGS and the locations fall out of what
27478
+ * survives, so each row's `instanceCount` is exactly what listFindingInstances
27479
+ * reports for the same filters scoped to that pair. The view depends on it:
27480
+ * one toolbar sits over both panels precisely because a location owns none of
27481
+ * its fields.
27324
27482
  */
27325
27483
  listFindingLocations(query) {
27326
27484
  const opts = {
@@ -27332,13 +27490,16 @@ var SqliteFindingsRepository = class {
27332
27490
  tools: query.tool,
27333
27491
  q: query.q
27334
27492
  };
27335
- const limit = query.limit ?? DEFAULT_LOCATIONS_LIMIT;
27493
+ const limit = query.limit ?? DEFAULT_FINDING_LOCATIONS_LIMIT;
27494
+ const cursor = query.cursor === void 0 ? null : decodeLocationCursor(query.cursor);
27336
27495
  const byRepo = /* @__PURE__ */ new Map();
27496
+ const accumulator = createInstanceFacetAccumulator(opts);
27337
27497
  let total = 0;
27338
27498
  for (const row of this.scanFindingRows({
27339
27499
  sessionId: query.sessionId,
27340
27500
  from: query.from
27341
27501
  })) {
27502
+ accumulator.add(row);
27342
27503
  if (!matchesInstanceFilters(row, opts)) continue;
27343
27504
  total += 1;
27344
27505
  let files = byRepo.get(row.repo);
@@ -27353,103 +27514,35 @@ var SqliteFindingsRepository = class {
27353
27514
  }
27354
27515
  addToLocation(acc, row);
27355
27516
  }
27356
- let fileCount = 0;
27357
- const repos = [...byRepo.entries()].map(([repo, files]) => {
27358
- fileCount += files.size;
27359
- const fileRows = [...files.entries()].map(([file2, acc]) => ({
27360
- file: file2,
27361
- instanceCount: acc.instanceCount,
27362
- maxSeverity: acc.maxSeverity,
27363
- latestDetectedAt: acc.latestDetectedAt,
27364
- ...foldGroupStatus(acc.statuses) === void 0 ? {} : { status: foldGroupStatus(acc.statuses) },
27365
- ruleIds: [...acc.ruleIds].slice(0, LOCATION_RULE_IDS_CAP)
27366
- })).sort(compareLocationOrder);
27367
- const rollup = fileRows.reduce(
27368
- (a, f) => ({
27369
- instanceCount: a.instanceCount + f.instanceCount,
27370
- maxSeverity: compareLocationOrder(f, a) < 0 ? f.maxSeverity : a.maxSeverity,
27371
- latestDetectedAt: f.latestDetectedAt > a.latestDetectedAt ? f.latestDetectedAt : a.latestDetectedAt
27372
- }),
27373
- {
27374
- instanceCount: 0,
27375
- maxSeverity: fileRows[0]?.maxSeverity ?? "low",
27376
- latestDetectedAt: ""
27377
- }
27378
- );
27379
- const statuses = fileRows.map((f) => f.status);
27380
- const folded = foldGroupStatus(statuses);
27381
- return {
27382
- repo,
27383
- instanceCount: rollup.instanceCount,
27384
- maxSeverity: rollup.maxSeverity,
27385
- latestDetectedAt: rollup.latestDetectedAt,
27386
- ...folded === void 0 ? {} : { status: folded },
27387
- files: fileRows
27388
- };
27389
- });
27390
- repos.sort(compareLocationOrder);
27517
+ const sorted = [];
27518
+ for (const [repo, files] of byRepo) {
27519
+ for (const [file2, acc] of files) {
27520
+ const status = foldGroupStatus(acc.statuses);
27521
+ sorted.push({
27522
+ id: encodeLocationId(repo, file2),
27523
+ repo,
27524
+ file: file2,
27525
+ instanceCount: acc.instanceCount,
27526
+ maxSeverity: acc.maxSeverity,
27527
+ latestDetectedAt: acc.latestDetectedAt,
27528
+ ...status === void 0 ? {} : { status },
27529
+ ruleIds: [...acc.ruleIds]
27530
+ });
27531
+ }
27532
+ }
27533
+ sorted.sort(compareLocationOrder);
27534
+ const start = cursor === null ? 0 : firstLocationAfter(sorted, cursor);
27535
+ const page = sorted.slice(start, start + limit);
27536
+ const lastOnPage = page.at(-1);
27537
+ const nextCursor = start + limit < sorted.length && lastOnPage ? encodeLocationCursor(lastOnPage) : null;
27538
+ const deepLinked = query.includeId === void 0 || query.includeId === "" ? void 0 : findDeepLinkedLocation(sorted, page, query.includeId);
27391
27539
  return Promise.resolve({
27392
- totals: { findings: total, repos: repos.length, files: fileCount },
27393
- items: repos.slice(0, limit),
27394
- hasMore: repos.length > limit
27540
+ totals: { findings: total, locations: sorted.length },
27541
+ facets: accumulator.facets(),
27542
+ items: [...page, ...deepLinked ? [deepLinked] : []],
27543
+ nextCursor
27395
27544
  });
27396
27545
  }
27397
- /**
27398
- * Each group's newest instances, for the table's expanded rows.
27399
- *
27400
- * ONE index-ordered scan with early termination, and the shape is the point.
27401
- * The natural spelling — `ROW_NUMBER() OVER (PARTITION BY rule_id ORDER BY
27402
- * started_at DESC)` then `WHERE rn <= cap` — sorts EVERY finding in scope
27403
- * through a temp B-tree to keep a bounded preview of each group, and then
27404
- * sorts the survivors again for the page order. Both sorts grow with the
27405
- * store while the answer does not.
27406
- *
27407
- * Instead the scan walks `audit_events` newest-first off `idx_audit_started_at`
27408
- * (or the session or window index the scope names — see `findingScanSql`),
27409
- * which is already the order the page wants, and keeps rows per rule until
27410
- * each rule has as many as it can show. The aggregate the caller already holds
27411
- * says how many that is: `min(instanceCount, PREVIEW_INSTANCES_PER_GROUP)`
27412
- * per rule, summed, is the number of rows this scan has to find, and it stops
27413
- * on the last one. That sum is bounded by `rules * PREVIEW_INSTANCES_PER_GROUP`
27414
- * (8,000 at this repo's 40-rule bench corpus), not by a fixed row count — a
27415
- * store with many firing rules widens it. The bound that DOES hold
27416
- * unconditionally is the sorted form's floor: this scan visits at most as
27417
- * many rows as `ROW_NUMBER() OVER (PARTITION BY rule_id …)` would have
27418
- * sorted, and stops the moment every rule has its cap, where the sorted form
27419
- * sorts the whole scope regardless. The true worst case — the rarest rule's
27420
- * wanted instances sitting at the tail of the scope — is one pass over
27421
- * everything in scope with a block sort of the id tie-break only, never a
27422
- * sort of the scope, which is still that floor.
27423
- *
27424
- * A row whose rule the aggregate did not see is skipped: the two statements
27425
- * run without a shared snapshot, so a capture landing between them can add a
27426
- * rule here that has no counts there, and the counts are what the group is
27427
- * built from.
27428
- */
27429
- previewRows(aggregates, scope) {
27430
- const wanted = /* @__PURE__ */ new Map();
27431
- let remaining = 0;
27432
- for (const [ruleId, agg] of aggregates) {
27433
- const n = Math.min(agg.instanceCount, PREVIEW_INSTANCES_PER_GROUP);
27434
- wanted.set(ruleId, n);
27435
- remaining += n;
27436
- }
27437
- const rows = [];
27438
- if (remaining === 0) return rows;
27439
- const { sql, params } = this.findingScanSql(scope);
27440
- const taken = /* @__PURE__ */ new Map();
27441
- for (const r of iterateRows(this.db.prepare(sql), params)) {
27442
- const want = wanted.get(r.rule_id);
27443
- if (want === void 0) continue;
27444
- const have = taken.get(r.rule_id) ?? 0;
27445
- if (have >= want) continue;
27446
- taken.set(r.rule_id, have + 1);
27447
- rows.push(r);
27448
- remaining -= 1;
27449
- if (remaining === 0) break;
27450
- }
27451
- return rows;
27452
- }
27453
27546
  /**
27454
27547
  * Every finding in scope as a FlatFindingRow, newest first, streamed.
27455
27548
  *
@@ -27476,6 +27569,33 @@ var SqliteFindingsRepository = class {
27476
27569
  yield toFlatFindingRow(r);
27477
27570
  }
27478
27571
  }
27572
+ /**
27573
+ * One finding by its own id, or null when no such row exists.
27574
+ *
27575
+ * A primary-key seek on `inspection_findings`, so its cost does not grow with
27576
+ * the store — and, unlike anything derived from a list page, it resolves a
27577
+ * finding of ANY age. That is what the Findings page's one-shot `?finding=`
27578
+ * deep link needs: the id it carries may name a finding thousands of rows
27579
+ * older than anything a first page holds.
27580
+ *
27581
+ * Deliberately UNFILTERED — no capture-kind, session or time predicate. It
27582
+ * RESOLVES an id; whether that row would survive the list's current filters is
27583
+ * a different question, and hiding the target because a filter excludes it is
27584
+ * worse than showing it.
27585
+ *
27586
+ * `groupId` on the result IS the rule id, so this one read answers both "which
27587
+ * type should the list select?" and "what does the drawer show?".
27588
+ */
27589
+ findingInstance(id) {
27590
+ const row = this.db.prepare(
27591
+ `SELECT ${FINDING_ROW_COLUMNS_SQL}
27592
+ FROM inspection_findings f
27593
+ JOIN audit_events e ON e.id = f.audit_event_id
27594
+ JOIN inspection_definitions d ON d.id = f.inspection_definition_id
27595
+ WHERE f.id = ?`
27596
+ ).get(id);
27597
+ return Promise.resolve(row === void 0 ? null : toInstanceDetail(toFlatFindingRow(row)));
27598
+ }
27479
27599
  /**
27480
27600
  * The one statement both instance-level scans run: every finding in scope,
27481
27601
  * joined to its event and definition, newest first.
@@ -27509,17 +27629,7 @@ var SqliteFindingsRepository = class {
27509
27629
  conditions.push("e.started_at >= ?");
27510
27630
  params.push(isoToEpochMillis(scope.from));
27511
27631
  }
27512
- const sql = `SELECT f.id AS id, d.rule_id AS rule_id, d.category AS category,
27513
- d.severity AS severity, f.masked_match AS masked_match,
27514
- f.action_taken AS action_taken, f.confidence AS confidence,
27515
- e.started_at AS occurred_at,
27516
- e.source_tool AS source_tool,
27517
- e.repo AS repo,
27518
- e.file_path AS file,
27519
- e.tool_name AS tool_name,
27520
- f.audit_event_id AS event_id, e.root_session_id AS session_id,
27521
- e.event_type AS kind, f.finding_key AS finding_key,
27522
- ${latestResolutionStatusSql("f")} AS latest_status
27632
+ const sql = `SELECT ${FINDING_ROW_COLUMNS_SQL}
27523
27633
  FROM audit_events e
27524
27634
  CROSS JOIN inspection_findings f ON f.audit_event_id = e.id
27525
27635
  CROSS JOIN inspection_definitions d ON d.id = f.inspection_definition_id
@@ -27533,6 +27643,26 @@ var SqliteFindingsRepository = class {
27533
27643
  group_concat(DISTINCT 'via ' || e.tool_name) AS tool_names` : `, NULL AS repos, NULL AS files, NULL AS tool_names`;
27534
27644
  const rows = this.db.prepare(
27535
27645
  `SELECT rule_id,
27646
+ -- BARE columns beside max(latest_at), which is deliberate and
27647
+ -- is SQLite's documented behaviour: with a single min()/max()
27648
+ -- in an aggregate query, every bare column takes its value from
27649
+ -- the row that produced the extremum. So these are the severity
27650
+ -- and category of the definition whose finding is NEWEST, which
27651
+ -- is what the row-based build they replaced read off its first
27652
+ -- (newest-first) row.
27653
+ --
27654
+ -- min() is WRONG here and was the defect: inspection_definitions
27655
+ -- holds one row per rule VERSION (see its writer \u2014 a version bump
27656
+ -- mints a new row), so a rule whose severity moved between
27657
+ -- versions has several, and min() picks the ALPHABETICALLY
27658
+ -- smallest \u2014 'low' over 'medium', but 'critical' over 'high'.
27659
+ -- That is arbitrary in direction, and it feeds the badge, the
27660
+ -- filter, the facet counts and the primary sort key.
27661
+ --
27662
+ -- Adding a second min()/max() aggregate here would make these
27663
+ -- bare columns ambiguous again; keep max(latest_at) the only one.
27664
+ severity,
27665
+ category,
27536
27666
  sum(tuple_count) AS instance_count,
27537
27667
  max(latest_at) AS latest_at,
27538
27668
  group_concat(source_tools) AS source_tools,
@@ -27543,6 +27673,14 @@ var SqliteFindingsRepository = class {
27543
27673
  group_concat(tool_names) AS tool_names
27544
27674
  FROM (
27545
27675
  SELECT d.rule_id AS rule_id,
27676
+ -- Severity and category are columns of the DEFINITION, and
27677
+ -- a rule can have SEVERAL definitions (one per version), so
27678
+ -- these are grouped on below and resolved to the newest
27679
+ -- firing version by the outer query's bare-column select.
27680
+ -- They ride the aggregate because the type build has no rows
27681
+ -- to read them off \u2014 see buildFindingTypes.
27682
+ d.severity AS severity,
27683
+ d.category AS category,
27546
27684
  e.event_type || '${TUPLE_SEP}' ||
27547
27685
  (CASE WHEN f.finding_key IS NULL THEN '' ELSE 'k' END) || '${TUPLE_SEP}' ||
27548
27686
  coalesce(latest.status, '') AS status_tuple,
@@ -27557,7 +27695,7 @@ var SqliteFindingsRepository = class {
27557
27695
  LEFT JOIN ${LATEST_RESOLUTION_BY_KEY_SQL} latest
27558
27696
  ON latest.finding_key = f.finding_key
27559
27697
  ${scope.predicate}
27560
- GROUP BY d.rule_id, status_tuple
27698
+ GROUP BY d.rule_id, d.severity, d.category, status_tuple
27561
27699
  )
27562
27700
  GROUP BY rule_id`
27563
27701
  ).all(scope.params);
@@ -27566,6 +27704,8 @@ var SqliteFindingsRepository = class {
27566
27704
  r.rule_id,
27567
27705
  {
27568
27706
  instanceCount: r.instance_count,
27707
+ severity: r.severity,
27708
+ category: r.category,
27569
27709
  sourceTools: splitConcat(r.source_tools),
27570
27710
  actionsTaken: splitConcat(r.actions_taken),
27571
27711
  statusInputs: splitConcat(r.status_inputs).map((tuple2) => {
@@ -27582,7 +27722,7 @@ var SqliteFindingsRepository = class {
27582
27722
  latestDetectedAt: epochMillisToIso(r.latest_at),
27583
27723
  // Free text only — joined and substring-matched, so group_concat's
27584
27724
  // commas need no unpicking (a repo/path containing one still matches).
27585
- // Left undefined (not '') when unfetched, so buildFindingGroups can
27725
+ // Left undefined (not '') when unfetched, so buildFindingTypes can
27586
27726
  // tell "no q this request" from "a group with no repo/file at all"
27587
27727
  // and skip priming a haystack nothing will read.
27588
27728
  ...withSearchText ? {
@@ -27734,6 +27874,12 @@ var SqliteHistorySyncRepository = class {
27734
27874
  this.markOwedStmt = db.prepare(
27735
27875
  `UPDATE audit_events SET outbox_owed = 1 WHERE id = :id AND synced_at IS NULL`
27736
27876
  );
27877
+ this.markCaptureBacklogOwedStmt = db.prepare(
27878
+ `UPDATE audit_events SET outbox_owed = 1
27879
+ WHERE synced_at IS NULL
27880
+ AND event_type IN (${CAPTURE_TYPE_LIST})
27881
+ AND started_at < :before`
27882
+ );
27737
27883
  this.stampStmt = db.prepare(
27738
27884
  `UPDATE audit_events SET synced_at = :at, sync_claimed_at = NULL WHERE id = :id`
27739
27885
  );
@@ -27782,7 +27928,9 @@ var SqliteHistorySyncRepository = class {
27782
27928
  );
27783
27929
  this.disownCapturesStmt = db.prepare(
27784
27930
  `UPDATE audit_events SET outbox_owed = NULL
27785
- WHERE outbox_owed IS NOT NULL AND event_type IN (${CAPTURE_TYPE_LIST})`
27931
+ WHERE outbox_owed IS NOT NULL
27932
+ AND event_type IN (${CAPTURE_TYPE_LIST})
27933
+ AND started_at < :attachedAt`
27786
27934
  );
27787
27935
  this.rearmStmt = db.prepare(
27788
27936
  `UPDATE audit_events SET synced_at = NULL
@@ -27858,6 +28006,7 @@ var SqliteHistorySyncRepository = class {
27858
28006
  freezeBoundaryStmt;
27859
28007
  captureRowsStmt;
27860
28008
  markOwedStmt;
28009
+ markCaptureBacklogOwedStmt;
27861
28010
  captureSkipCountStmt;
27862
28011
  disownCapturesStmt;
27863
28012
  partitionStmt;
@@ -27921,6 +28070,23 @@ var SqliteHistorySyncRepository = class {
27921
28070
  markCaptureOwed(id) {
27922
28071
  this.markOwedStmt.run({ id });
27923
28072
  }
28073
+ /**
28074
+ * Mark every capture already on disk as owed, as of `before`.
28075
+ *
28076
+ * The consent-time backfill, called once from `aka attach` when a human
28077
+ * grants existing-history consent — never from an ongoing drain pass, and
28078
+ * never inferred from a boundary that could later move. `before` is the
28079
+ * caller's own "now" at the moment consent was granted, so what this marks
28080
+ * is exactly the backlog the consent prompt already counted, not whatever a
28081
+ * later re-attach or key rotation might widen it to.
28082
+ *
28083
+ * Returns how many rows matched, for the caller to log or test against. Not a
28084
+ * count of NEWLY marked rows — a row still unsynced from an earlier call
28085
+ * matches again and is counted again, the same as `UPDATE`'s own `changes`.
28086
+ */
28087
+ markCaptureBacklogOwed(before) {
28088
+ return Number(this.markCaptureBacklogOwedStmt.run({ before }).changes);
28089
+ }
27924
28090
  /** Record delivery. Called only AFTER the far side has accepted the rows. */
27925
28091
  markSynced(ids, atMs) {
27926
28092
  this.stampAll(ids, atMs);
@@ -28037,15 +28203,42 @@ var SqliteHistorySyncRepository = class {
28037
28203
  *
28038
28204
  * Delivery is a fact about ONE recipient: rows sent to the deployment a
28039
28205
  * machine has just left are undelivered as far as the new one is concerned.
28040
- * All three in one transaction, so a crash between them cannot leave stamps
28041
- * attributed to the wrong deployment, or a boundary that belongs to another.
28206
+ * All four in one transaction, so a crash between them cannot leave stamps
28207
+ * attributed to the wrong deployment, a boundary that belongs to another, or
28208
+ * a disown with no re-mark to follow it.
28042
28209
  *
28043
28210
  * The boundary is written HERE and only here, which is what freezes it: a
28044
28211
  * re-attach to the SAME deployment (a key rotation) leaves the fingerprint
28045
28212
  * unchanged, so this never runs and the backlog does not widen back over rows
28046
28213
  * the live path has since delivered.
28214
+ *
28215
+ * `backfillCapturesBefore` is the caller's OWN "now" at the instant a human
28216
+ * granted existing-history consent for the deployment this call is arming —
28217
+ * a DIFFERENT instant from `backlogBefore`: the re-mark boundary is the GRANT
28218
+ * instant, `backlogBefore` is the ATTACH instant, and the two can be far
28219
+ * apart. Passed only when that grant is valid, since this method has no way
28220
+ * to check consent itself and must not mark a row owed for a machine that
28221
+ * never agreed to it. Applied AFTER the disown above, in the SAME
28222
+ * transaction: what the disown clears is every marker below `backlogBefore`,
28223
+ * which includes this deployment's OWN pre-attach rows — `aka attach` calls
28224
+ * `seedCaptureBacklogOwed` at the attach instant, so every row it marks sits
28225
+ * on the cleared side of that bound — and the re-mark in the same
28226
+ * transaction is what puts those rows back. A crash between the two cannot
28227
+ * strand the ledger disowned with nothing re-marked — the transaction either
28228
+ * lands whole or not at all, and a fingerprint mismatch that has not yet
28229
+ * committed re-enters this method on the very next pass. Omit it (the
28230
+ * structural-only tests do) to exercise the disown in isolation.
28231
+ *
28232
+ * The disown is bounded by `backlogBefore`, which is what keeps it from
28233
+ * touching a marker the NEW deployment's OWN live path has already set: B's
28234
+ * live path can mark a capture owed from the moment `aka attach` writes the
28235
+ * descriptor, before the drain's first pass ever reaches this method, and
28236
+ * such a row sits at or after the bound rather than below it. What keeps the
28237
+ * disown from eating THIS SAME CALL's own re-mark is the order, not the
28238
+ * bound — disown runs first, re-mark second, both inside the one
28239
+ * transaction above.
28047
28240
  */
28048
- rearmFor(fingerprint, backlogBefore) {
28241
+ rearmFor(fingerprint, backlogBefore, backfillCapturesBefore) {
28049
28242
  this.ensureRowStmt.run();
28050
28243
  withTransaction(
28051
28244
  this.db,
@@ -28053,7 +28246,10 @@ var SqliteHistorySyncRepository = class {
28053
28246
  const previous = getRow(this.fingerprintStmt)?.fingerprint;
28054
28247
  this.rearmStmt.run();
28055
28248
  if (previous !== null && previous !== void 0 && previous !== fingerprint) {
28056
- this.disownCapturesStmt.run();
28249
+ this.disownCapturesStmt.run({ attachedAt: backlogBefore });
28250
+ }
28251
+ if (backfillCapturesBefore !== void 0) {
28252
+ this.markCaptureBacklogOwedStmt.run({ before: backfillCapturesBefore });
28057
28253
  }
28058
28254
  this.setFingerprintStmt.run({ fingerprint, backlogBefore });
28059
28255
  },
@@ -28330,7 +28526,8 @@ function managedSettingsPaths(platform2 = process.platform) {
28330
28526
  }
28331
28527
  return [posix.join("/etc", "aka", MANAGED_SETTINGS_FILENAME)];
28332
28528
  }
28333
- function readManagedSettings(paths = managedSettingsPaths()) {
28529
+ var testOnlyManagedPaths = null;
28530
+ function readManagedSettings(paths = testOnlyManagedPaths ?? managedSettingsPaths()) {
28334
28531
  for (const path of paths) {
28335
28532
  let text;
28336
28533
  try {
@@ -30810,7 +31007,7 @@ function toUtcDateString(ms) {
30810
31007
  return new Date(ms).toISOString().slice(0, 10);
30811
31008
  }
30812
31009
  function isTimeseriesSeverity(s) {
30813
- return s === "critical" || s === "high" || s === "medium";
31010
+ return s === "critical" || s === "high" || s === "medium" || s === "low";
30814
31011
  }
30815
31012
  var SqliteSecurityRepository = class {
30816
31013
  constructor(db, now = () => Date.now()) {
@@ -30939,12 +31136,16 @@ var SqliteSecurityRepository = class {
30939
31136
  const now = this.now();
30940
31137
  const windowStart = startOfUtcDay2(now) - (lenDays - 1) * DAY_MS4;
30941
31138
  const rows = this.findingsInRange(windowStart, now);
30942
- const points = Array.from({ length: numBuckets }, (_, i) => ({
30943
- timestamp: toUtcDateString(windowStart + i * bucketMs),
30944
- critical: 0,
30945
- high: 0,
30946
- medium: 0
30947
- }));
31139
+ const points = Array.from(
31140
+ { length: numBuckets },
31141
+ (_, i) => ({
31142
+ timestamp: toUtcDateString(windowStart + i * bucketMs),
31143
+ critical: 0,
31144
+ high: 0,
31145
+ medium: 0,
31146
+ low: 0
31147
+ })
31148
+ );
30948
31149
  for (const r of rows) {
30949
31150
  const idx = Math.floor((r.occurredAt - windowStart) / bucketMs);
30950
31151
  const bucket = points[idx];
@@ -31162,6 +31363,7 @@ var SqliteSecurityRepository = class {
31162
31363
  `SELECT f.finding_key AS finding_key,
31163
31364
  d.rule_id AS rule_id,
31164
31365
  d.severity AS severity,
31366
+ e.repo AS repo,
31165
31367
  e.file_path AS path,
31166
31368
  COALESCE(f.first_detected_at, e.started_at) AS first_detected_at,
31167
31369
  latest.resolved_at AS latest_resolved_at
@@ -31181,6 +31383,7 @@ var SqliteSecurityRepository = class {
31181
31383
  const items = rows.map((r) => ({
31182
31384
  findingKey: r.finding_key,
31183
31385
  ruleId: r.rule_id,
31386
+ repo: r.repo ?? "",
31184
31387
  severity: r.severity,
31185
31388
  path: r.path ?? "",
31186
31389
  resolvedAt: new Date(r.latest_resolved_at).toISOString(),
@@ -31190,13 +31393,66 @@ var SqliteSecurityRepository = class {
31190
31393
  }));
31191
31394
  return Promise.resolve({ items });
31192
31395
  }
31396
+ /**
31397
+ * Per-rule tallies of the findings that are still OPEN, whole-store.
31398
+ *
31399
+ * Scoped by status rather than by time, because the card this feeds is a to-do
31400
+ * list: a secret committed three weeks ago and never rotated is still the most
31401
+ * important thing to fix, and any window hides it. It carried a "newest N
31402
+ * findings" cap and then a range; the first meant a different span on every
31403
+ * machine, and the second reported "no recommendations" over live exposure.
31404
+ *
31405
+ * `open` mirrors `deriveFindingStatus` — at-rest, minus resolved and dismissed —
31406
+ * so a row's count is exactly what `?status=open&type=<rule>` returns. Note that
31407
+ * is NOT `severitySummary`'s `openAtRest`, which keeps dismissed findings (a
31408
+ * dismissal is a judgement, not a remediation) and drops untracked legacy rows.
31409
+ * The two answer different questions and only this one has to match a link.
31410
+ *
31411
+ * Aggregated in SQL: the result is O(distinct rule × category × severity), so a
31412
+ * whole-store scope costs a grouped scan rather than a row per finding.
31413
+ */
31414
+ recommendationInputs() {
31415
+ const rows = allRows(
31416
+ this.db.prepare(
31417
+ `SELECT d.rule_id AS rule_id,
31418
+ d.category AS category,
31419
+ d.severity AS severity,
31420
+ COUNT(*) AS count
31421
+ FROM inspection_findings f
31422
+ JOIN audit_events e ON e.id = f.audit_event_id
31423
+ JOIN inspection_definitions d ON d.id = f.inspection_definition_id
31424
+ LEFT JOIN ${LATEST_RESOLUTION_BY_KEY_SQL} latest
31425
+ ON latest.finding_key = f.finding_key
31426
+ WHERE e.event_type IN (${CAPTURE_EVENT_TYPES_SQL})
31427
+ AND e.event_type = 'code_change'
31428
+ AND (
31429
+ f.finding_key IS NULL
31430
+ OR latest.status IS NULL
31431
+ OR latest.status NOT IN ('resolved', 'dismissed')
31432
+ )
31433
+ GROUP BY d.rule_id, d.category, d.severity`
31434
+ )
31435
+ );
31436
+ return Promise.resolve(
31437
+ rows.map((r) => ({
31438
+ ruleId: r.rule_id,
31439
+ category: r.category,
31440
+ severity: r.severity,
31441
+ count: r.count
31442
+ }))
31443
+ );
31444
+ }
31193
31445
  // Findings whose parent event occurred in [fromMs, toMs), with the parent's
31194
31446
  // epoch-millis timestamp. started_at is an INTEGER column, so the bounds stay
31195
31447
  // numeric and the JS aggregations bucket/split on ms directly.
31196
31448
  findingsInRange(fromMs, toMs) {
31197
31449
  const rows = allRows(
31198
31450
  this.db.prepare(
31199
- `SELECT e.started_at AS occurred_at, d.severity AS severity, f.action_taken AS action_taken
31451
+ // `rule_id`/`category` cost nothing extra: inspection_definitions is already
31452
+ // joined for `severity`, so they are two more columns off a row this read
31453
+ // already fetches. They feed the recommended-actions rollup.
31454
+ `SELECT e.started_at AS occurred_at, d.severity AS severity, f.action_taken AS action_taken,
31455
+ d.rule_id AS rule_id, d.category AS category
31200
31456
  FROM inspection_findings f
31201
31457
  JOIN audit_events e ON e.id = f.audit_event_id
31202
31458
  JOIN inspection_definitions d ON d.id = f.inspection_definition_id
@@ -31209,7 +31465,9 @@ var SqliteSecurityRepository = class {
31209
31465
  return rows.map((r) => ({
31210
31466
  occurredAt: r.occurred_at,
31211
31467
  severity: r.severity,
31212
- actionTaken: r.action_taken
31468
+ actionTaken: r.action_taken,
31469
+ ruleId: r.rule_id,
31470
+ category: r.category
31213
31471
  }));
31214
31472
  }
31215
31473
  };
@@ -32379,8 +32637,45 @@ function openLocalDatabase(dir) {
32379
32637
  };
32380
32638
  }
32381
32639
 
32382
- // ../../packages/persistence/src/finding-key.ts
32640
+ // ../../packages/persistence/src/egress-wire.ts
32383
32641
  import { createHash as createHash3 } from "crypto";
32642
+ function hashProjectKey(projectKey) {
32643
+ return createHash3("sha256").update(projectKey, "utf8").digest("hex");
32644
+ }
32645
+ function toIngestHit(hit) {
32646
+ return {
32647
+ host: hit.host,
32648
+ kind: hit.kind,
32649
+ name: hit.name,
32650
+ category: hit.category,
32651
+ trust: hit.trust,
32652
+ network: hit.network,
32653
+ method: hit.method,
32654
+ transport: hit.transport,
32655
+ url: hit.url,
32656
+ template: hit.template,
32657
+ dataClass: hit.dataClass,
32658
+ site: {
32659
+ file: hit.site.file,
32660
+ line: hit.site.line,
32661
+ dynamic: hit.site.dynamic,
32662
+ vendored: hit.site.vendored
32663
+ }
32664
+ };
32665
+ }
32666
+ function toEgressIngestRequest(input2) {
32667
+ const { hits, droppedFiles } = capHits(input2.hits, input2.reconcile.mode);
32668
+ const reconcile = withoutDroppedFiles(input2.reconcile, droppedFiles);
32669
+ return {
32670
+ projectKey: hashProjectKey(input2.projectKey),
32671
+ project: input2.project,
32672
+ reconcile,
32673
+ hits: hits.map(toIngestHit)
32674
+ };
32675
+ }
32676
+
32677
+ // ../../packages/persistence/src/finding-key.ts
32678
+ import { createHash as createHash4 } from "crypto";
32384
32679
 
32385
32680
  // ../../packages/persistence/src/fingerprint.ts
32386
32681
  import { createHmac, randomBytes } from "crypto";
@@ -32421,14 +32716,18 @@ function readFingerprintKey(dataDir2) {
32421
32716
  return parseKeyFile(raw);
32422
32717
  }
32423
32718
 
32424
- // ../../packages/persistence/src/history-preview.ts
32719
+ // ../../packages/persistence/src/history-backfill.ts
32425
32720
  import { existsSync as existsSync4 } from "fs";
32426
32721
  import { join as join9 } from "path";
32722
+
32723
+ // ../../packages/persistence/src/history-preview.ts
32724
+ import { existsSync as existsSync5 } from "fs";
32725
+ import { join as join10 } from "path";
32427
32726
  import { DatabaseSync as DatabaseSync3 } from "node:sqlite";
32428
32727
 
32429
32728
  // ../../packages/persistence/src/store-symlinks.ts
32430
- import { existsSync as existsSync5, lstatSync as lstatSync3, readlinkSync, realpathSync, statSync as statSync4 } from "fs";
32431
- import { dirname as dirname3, join as join10, resolve } from "path";
32729
+ import { existsSync as existsSync6, lstatSync as lstatSync3, readlinkSync, realpathSync, statSync as statSync4 } from "fs";
32730
+ import { dirname as dirname3, join as join11, resolve } from "path";
32432
32731
 
32433
32732
  // ../../packages/persistence/src/vault/crypto.ts
32434
32733
  import {
@@ -32443,62 +32742,25 @@ import {
32443
32742
  import { execFileSync } from "child_process";
32444
32743
  import { randomBytes as randomBytes2 } from "crypto";
32445
32744
  import { chmodSync as chmodSync3, readFileSync as readFileSync7, renameSync as renameSync4, rmSync as rmSync6, statSync as statSync5, writeFileSync as writeFileSync3 } from "fs";
32446
- import { join as join11 } from "path";
32745
+ import { join as join12 } from "path";
32447
32746
 
32448
32747
  // ../../packages/persistence/src/vault/vault.ts
32449
32748
  import { randomBytes as randomBytes3, randomUUID as randomUUID12 } from "crypto";
32450
32749
 
32451
32750
  // ../../packages/persistence/src/warn-era-cap.ts
32452
- import { existsSync as existsSync6, writeFileSync as writeFileSync4 } from "fs";
32453
- import { join as join12 } from "path";
32751
+ import { existsSync as existsSync7, writeFileSync as writeFileSync4 } from "fs";
32752
+ import { join as join13 } from "path";
32454
32753
  var MARKER = "warn-era-capped";
32455
32754
  function capWarnEraEnforcementOnce(db, policyMode, dataDir2) {
32456
32755
  if (policyMode !== "warn") return { capped: 0, skipped: "not-warn" };
32457
- const marker = join12(dataDir2, MARKER);
32458
- if (existsSync6(marker)) return { capped: 0, skipped: "already-run" };
32756
+ const marker = join13(dataDir2, MARKER);
32757
+ if (existsSync7(marker)) return { capped: 0, skipped: "already-run" };
32459
32758
  const capped = db.policies.capCategoryActions();
32460
32759
  writeFileSync4(marker, `${new Date(Date.now()).toISOString()}
32461
32760
  `, { mode: DATA_FILE_MODE });
32462
32761
  return { capped };
32463
32762
  }
32464
32763
 
32465
- // ../../packages/plugin-runtime/src/attached/egress-wire.ts
32466
- import { createHash as createHash4 } from "crypto";
32467
- function hashProjectKey(projectKey) {
32468
- return createHash4("sha256").update(projectKey, "utf8").digest("hex");
32469
- }
32470
- function toIngestHit(hit) {
32471
- return {
32472
- host: hit.host,
32473
- kind: hit.kind,
32474
- name: hit.name,
32475
- category: hit.category,
32476
- trust: hit.trust,
32477
- network: hit.network,
32478
- method: hit.method,
32479
- transport: hit.transport,
32480
- url: hit.url,
32481
- template: hit.template,
32482
- dataClass: hit.dataClass,
32483
- site: {
32484
- file: hit.site.file,
32485
- line: hit.site.line,
32486
- dynamic: hit.site.dynamic,
32487
- vendored: hit.site.vendored
32488
- }
32489
- };
32490
- }
32491
- function toEgressIngestRequest(input2) {
32492
- const { hits, droppedFiles } = capHits(input2.hits, input2.reconcile.mode);
32493
- const reconcile = withoutDroppedFiles(input2.reconcile, droppedFiles);
32494
- return {
32495
- projectKey: hashProjectKey(input2.projectKey),
32496
- project: input2.project,
32497
- reconcile,
32498
- hits: hits.map(toIngestHit)
32499
- };
32500
- }
32501
-
32502
32764
  // ../../packages/remote/src/http.ts
32503
32765
  import { request as httpRequest } from "http";
32504
32766
  import { request as httpsRequest } from "https";
@@ -32778,6 +33040,7 @@ function createRemoteClient(options) {
32778
33040
  url: url2(ROUTES.shares),
32779
33041
  body: JSON.stringify(validated.data)
32780
33042
  });
33043
+ if (response.status === 404) throw new RemoteRouteAbsent(ROUTES.shares);
32781
33044
  okBody(response);
32782
33045
  },
32783
33046
  async pollCommand() {
@@ -32800,19 +33063,51 @@ function createRemoteClient(options) {
32800
33063
  };
32801
33064
  }
32802
33065
 
32803
- // ../../packages/plugin-runtime/src/attached/failure.ts
33066
+ // ../../packages/remote/src/failure-kind.ts
32804
33067
  function statusOf(err) {
32805
33068
  if (typeof err !== "object" || err === null || !("status" in err)) return null;
32806
33069
  const { status } = err;
32807
33070
  if (typeof status !== "number" || !Number.isInteger(status)) return null;
32808
33071
  return status >= 100 && status <= 599 ? status : null;
32809
33072
  }
32810
- function classifyFailure(err) {
32811
- switch (statusOf(err)) {
33073
+ function nameOf(err) {
33074
+ if (typeof err !== "object" || err === null || !("name" in err)) return null;
33075
+ return typeof err.name === "string" ? err.name : null;
33076
+ }
33077
+ function classifyRemoteFailure(err) {
33078
+ switch (nameOf(err)) {
33079
+ case "RemoteRouteAbsent":
33080
+ return "route-absent";
33081
+ case "RemoteRequestInvalid":
33082
+ return "invalid-request";
33083
+ case "RemoteResponseInvalid":
33084
+ return "rejected";
33085
+ default:
33086
+ break;
33087
+ }
33088
+ const status = statusOf(err);
33089
+ if (status === null) return "unreachable";
33090
+ switch (status) {
32812
33091
  case 401:
32813
33092
  return "unauthorized";
32814
33093
  case 403:
32815
33094
  return "forbidden";
33095
+ case 429:
33096
+ return "unreachable";
33097
+ case 404:
33098
+ return "unreachable";
33099
+ default:
33100
+ return status >= 400 && status <= 499 ? "rejected" : "unreachable";
33101
+ }
33102
+ }
33103
+
33104
+ // ../../packages/plugin-runtime/src/attached/failure.ts
33105
+ function classifyFailure(err) {
33106
+ switch (classifyRemoteFailure(err)) {
33107
+ case "unauthorized":
33108
+ return "unauthorized";
33109
+ case "forbidden":
33110
+ return "forbidden";
32816
33111
  default:
32817
33112
  return "unreachable";
32818
33113
  }
@@ -32835,10 +33130,10 @@ function withTimeout(promise2, ms) {
32835
33130
 
32836
33131
  // ../../packages/plugin-runtime/src/attached/forward-drops.ts
32837
33132
  import { readFileSync as readFileSync8 } from "fs";
32838
- import { join as join13 } from "path";
33133
+ import { join as join14 } from "path";
32839
33134
  var FORWARD_DROPS_FILENAME = ATTACHED_FORWARD_DROPS_FILENAME;
32840
33135
  function forwardDropsPath(dataDir2) {
32841
- return join13(dataDir2, FORWARD_DROPS_FILENAME);
33136
+ return join14(dataDir2, FORWARD_DROPS_FILENAME);
32842
33137
  }
32843
33138
  function recordForwardDrops(dataDir2, count, nowMs) {
32844
33139
  if (count <= 0) return;
@@ -32874,13 +33169,13 @@ function readForwardDrops(dataDir2) {
32874
33169
 
32875
33170
  // ../../packages/plugin-runtime/src/attached/forward-policy.ts
32876
33171
  import { randomUUID as randomUUID15 } from "crypto";
32877
- import { readFileSync as readFileSync14 } from "fs";
33172
+ import { readFileSync as readFileSync15 } from "fs";
32878
33173
  import { readFile, rename, writeFile } from "fs/promises";
32879
- import { join as join22 } from "path";
33174
+ import { join as join24 } from "path";
32880
33175
 
32881
33176
  // ../../packages/plugin-sdk/src/config.ts
32882
- import { existsSync as existsSync7 } from "fs";
32883
- import { join as join14 } from "path";
33177
+ import { existsSync as existsSync8 } from "fs";
33178
+ import { join as join15 } from "path";
32884
33179
 
32885
33180
  // ../../packages/plugin-sdk/src/provider-env.ts
32886
33181
  var DEFAULT_ANTHROPIC_HOST = "api.anthropic.com";
@@ -32934,8 +33229,8 @@ function resolveProvider() {
32934
33229
  function loadConfig(base = defaultDataDir(), resolveProviderFn = resolveProvider) {
32935
33230
  try {
32936
33231
  ensureLayoutDirSync(base);
32937
- const settingsFile = join14(settingsDir(base), "settings.json");
32938
- if (existsSync7(settingsFile)) tightenFile(settingsFile);
33232
+ const settingsFile = join15(settingsDir(base), "settings.json");
33233
+ if (existsSync8(settingsFile)) tightenFile(settingsFile);
32939
33234
  } catch {
32940
33235
  }
32941
33236
  migrateLegacyLayout(base);
@@ -32960,7 +33255,7 @@ function resolveProviderSafe(resolveProviderFn) {
32960
33255
  // ../../packages/plugin-sdk/src/config-inventory.ts
32961
33256
  import { readdirSync as readdirSync2, readFileSync as readFileSync10, realpathSync as realpathSync2, statSync as statSync7 } from "fs";
32962
33257
  import { homedir as homedir2 } from "os";
32963
- import { basename as basename3, join as join16 } from "path";
33258
+ import { basename as basename3, join as join17 } from "path";
32964
33259
 
32965
33260
  // ../../packages/detections/src/egress/registry.ts
32966
33261
  var EXTRACTOR_VERSION = "1";
@@ -35743,24 +36038,20 @@ function bundledDetections() {
35743
36038
  }
35744
36039
 
35745
36040
  // ../../packages/plugin-sdk/src/repo.ts
35746
- import { existsSync as existsSync8, readFileSync as readFileSync9, statSync as statSync6 } from "fs";
35747
- import { basename as basename2, dirname as dirname4, isAbsolute, join as join15, sep as sep2 } from "path";
36041
+ import { existsSync as existsSync9, readFileSync as readFileSync9, statSync as statSync6 } from "fs";
36042
+ import { basename as basename2, dirname as dirname4, isAbsolute, join as join16, sep as sep2 } from "path";
35748
36043
 
35749
36044
  // ../../packages/plugin-sdk/src/events.ts
35750
36045
  import { createHash as createHash5, randomUUID as randomUUID13 } from "crypto";
35751
36046
 
35752
36047
  // ../../packages/plugin-sdk/src/isolated-scan.ts
35753
- import { existsSync as existsSync9 } from "fs";
36048
+ import { existsSync as existsSync10 } from "fs";
35754
36049
  import { fileURLToPath } from "url";
35755
36050
  import { Worker } from "worker_threads";
35756
36051
 
35757
- // ../../packages/plugin-sdk/src/ignore-layers.ts
35758
- var import_ignore = __toESM(require_ignore(), 1);
35759
- import { readFileSync as readFileSync11 } from "fs";
35760
- import { join as join17 } from "path";
35761
-
35762
- // ../../packages/plugin-sdk/src/inventory-resolver.ts
35763
- import { arch, hostname as hostname4, platform, release } from "os";
36052
+ // ../../packages/plugin-sdk/src/host-floor.ts
36053
+ import { readFileSync as readFileSync12 } from "fs";
36054
+ import { join as join19 } from "path";
35764
36055
 
35765
36056
  // ../../packages/plugin-sdk/src/model-governance.ts
35766
36057
  import {
@@ -35768,24 +36059,99 @@ import {
35768
36059
  fstatSync,
35769
36060
  mkdirSync as mkdirSync2,
35770
36061
  openSync as openSync2,
35771
- readFileSync as readFileSync12,
36062
+ readFileSync as readFileSync11,
35772
36063
  readSync,
35773
36064
  writeFileSync as writeFileSync5
35774
36065
  } from "fs";
35775
36066
  import { join as join18 } from "path";
35776
36067
  var TAIL_BYTES = 256 * 1024;
35777
36068
 
36069
+ // ../../packages/plugin-sdk/src/host-floor.ts
36070
+ var HOST_FEATURE = {
36071
+ ModelSwitch: "model-switch",
36072
+ VaultPointerDisplay: "vault-pointer-display"
36073
+ };
36074
+ var HOST_FLOORS = {
36075
+ [HOST_FEATURE.ModelSwitch]: {
36076
+ label: "model-switch protection",
36077
+ hookEvents: ["PreModelSwitch", "PostModelSwitch"],
36078
+ since: "2.1.251"
36079
+ },
36080
+ [HOST_FEATURE.VaultPointerDisplay]: {
36081
+ label: "vault pointer display",
36082
+ hookEvents: ["MessageDisplay"],
36083
+ since: "2.1.152"
36084
+ }
36085
+ };
36086
+ function hostFloorGaps(hostVersion) {
36087
+ if (hostVersion === void 0) return [];
36088
+ const gaps = [];
36089
+ for (const [feature, row] of Object.entries(HOST_FLOORS)) {
36090
+ if (compareBinaryVersions(hostVersion, row.since) < 0) {
36091
+ gaps.push({ feature, label: row.label, since: row.since });
36092
+ }
36093
+ }
36094
+ return gaps;
36095
+ }
36096
+ function requiredHostVersion(gaps) {
36097
+ let highest;
36098
+ for (const gap of gaps) {
36099
+ if (highest === void 0 || compareBinaryVersions(gap.since, highest) > 0) highest = gap.since;
36100
+ }
36101
+ return highest;
36102
+ }
36103
+ var MAX_TESTED_HOST = "2.1.260";
36104
+ function hostCeilingNotice(hostVersion) {
36105
+ if (hostVersion === void 0) return null;
36106
+ if (compareBinaryVersions(hostVersion, MAX_TESTED_HOST) <= 0) return null;
36107
+ return `This Claude Code (${hostVersion}) is newer than AKA has been tested against (${MAX_TESTED_HOST}). If something here looks wrong, that is the likely cause \u2014 we'll look into it.`;
36108
+ }
36109
+ function hostCompatibilityLines(cache) {
36110
+ if (cache === null) return [];
36111
+ const lines = [`Claude Code: ${cache.version} (last seen)`];
36112
+ const gaps = hostFloorGaps(cache.version);
36113
+ const required2 = requiredHostVersion(gaps);
36114
+ if (required2 !== void 0) {
36115
+ lines.push(` inactive: ${gaps.map((g) => g.label).join(", ")}`);
36116
+ lines.push(` update Claude Code to ${required2} or newer to turn them on`);
36117
+ }
36118
+ const ceiling = hostCeilingNotice(cache.version);
36119
+ if (ceiling !== null) lines.push(` ${ceiling}`);
36120
+ return lines;
36121
+ }
36122
+ var HOST_VERSION_MARKER = "host-version.json";
36123
+ function readHostVersionCache(dataDir2) {
36124
+ try {
36125
+ const parsed2 = JSON.parse(readFileSync12(join19(dataDir2, HOST_VERSION_MARKER), "utf8"));
36126
+ if (typeof parsed2 !== "object" || parsed2 === null) return null;
36127
+ const { version: version2, observedAt } = parsed2;
36128
+ if (typeof version2 !== "string" || !isParseableBinaryVersion(version2)) return null;
36129
+ if (typeof observedAt !== "number" || !Number.isFinite(observedAt)) return null;
36130
+ return { version: version2, observedAt };
36131
+ } catch {
36132
+ return null;
36133
+ }
36134
+ }
36135
+
36136
+ // ../../packages/plugin-sdk/src/ignore-layers.ts
36137
+ var import_ignore = __toESM(require_ignore(), 1);
36138
+ import { readFileSync as readFileSync13 } from "fs";
36139
+ import { join as join20 } from "path";
36140
+
36141
+ // ../../packages/plugin-sdk/src/inventory-resolver.ts
36142
+ import { arch, hostname as hostname4, platform, release } from "os";
36143
+
35778
36144
  // ../../packages/plugin-sdk/src/nudge.ts
35779
- import { mkdirSync as mkdirSync3, readFileSync as readFileSync13, writeFileSync as writeFileSync6 } from "fs";
35780
- import { join as join19 } from "path";
36145
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync14, writeFileSync as writeFileSync6 } from "fs";
36146
+ import { join as join21 } from "path";
35781
36147
 
35782
36148
  // ../../packages/plugin-sdk/src/paths.ts
35783
36149
  import { readdirSync as readdirSync3, realpathSync as realpathSync3 } from "fs";
35784
36150
  import { basename as basename4, dirname as dirname5, sep as sep3 } from "path";
35785
36151
 
35786
36152
  // ../../packages/plugin-sdk/src/project-files.ts
35787
- import { existsSync as existsSync10, readdirSync as readdirSync4 } from "fs";
35788
- import { basename as basename5, join as join20 } from "path";
36153
+ import { existsSync as existsSync11, readdirSync as readdirSync4 } from "fs";
36154
+ import { basename as basename5, join as join22 } from "path";
35789
36155
 
35790
36156
  // ../../packages/plugin-sdk/src/provider-env-antigravity.ts
35791
36157
  var optionalBaseUrl2 = external_exports.preprocess((v) => {
@@ -35821,7 +36187,7 @@ var THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1e3;
35821
36187
 
35822
36188
  // ../../packages/plugin-sdk/src/throttle.ts
35823
36189
  import { mkdirSync as mkdirSync4, statSync as statSync8, writeFileSync as writeFileSync7 } from "fs";
35824
- import { join as join21 } from "path";
36190
+ import { join as join23 } from "path";
35825
36191
 
35826
36192
  // ../../packages/plugin-runtime/src/attached/forward-policy.ts
35827
36193
  function isInvalidRequest(err) {
@@ -35861,7 +36227,7 @@ function parseBreakerState(raw, nowMs) {
35861
36227
  }
35862
36228
  function createForwardPolicy(deps) {
35863
36229
  const now = deps.now ?? (() => Date.now());
35864
- const file2 = join22(deps.dir, STATE_FILENAME);
36230
+ const file2 = join24(deps.dir, STATE_FILENAME);
35865
36231
  let state = null;
35866
36232
  let loading = null;
35867
36233
  async function readState() {
@@ -36583,8 +36949,8 @@ function toolAuditEvent(input2) {
36583
36949
  }
36584
36950
 
36585
36951
  // ../../packages/plugin-runtime/src/attached/history-state.ts
36586
- import { readFileSync as readFileSync15 } from "fs";
36587
- import { join as join23 } from "path";
36952
+ import { readFileSync as readFileSync16 } from "fs";
36953
+ import { join as join25 } from "path";
36588
36954
 
36589
36955
  // ../../packages/plugin-runtime/src/attached/history-sync.ts
36590
36956
  import { createHash as createHash6 } from "crypto";
@@ -36601,7 +36967,7 @@ import { fileURLToPath as fileURLToPath2 } from "url";
36601
36967
  var HISTORY_SYNC_THROTTLE_MS = 5 * 60 * 1e3;
36602
36968
 
36603
36969
  // ../../packages/plugin-runtime/src/attached/plugin-block.ts
36604
- import { readFileSync as readFileSync16 } from "fs";
36970
+ import { readFileSync as readFileSync17 } from "fs";
36605
36971
  function createPluginBlock(build, policyStore) {
36606
36972
  return async () => {
36607
36973
  const cached2 = await policyStore.read();
@@ -36620,7 +36986,7 @@ function createPluginBlock(build, policyStore) {
36620
36986
  // ../../packages/plugin-runtime/src/attached/policy-store.ts
36621
36987
  import { randomUUID as randomUUID16 } from "crypto";
36622
36988
  import { readFile as readFile2, rm, writeFile as writeFile2 } from "fs/promises";
36623
- import { join as join24 } from "path";
36989
+ import { join as join26 } from "path";
36624
36990
 
36625
36991
  // ../../packages/plugin-runtime/src/attached/atomic-publish.ts
36626
36992
  import { rename as rename2 } from "fs/promises";
@@ -36644,7 +37010,7 @@ async function publishByRename(tmp, file2, move = rename2) {
36644
37010
 
36645
37011
  // ../../packages/plugin-runtime/src/attached/policy-store.ts
36646
37012
  function createPolicyStore(dir = dataDir()) {
36647
- const file2 = join24(dir, "policy-cache.json");
37013
+ const file2 = join26(dir, "policy-cache.json");
36648
37014
  async function read() {
36649
37015
  try {
36650
37016
  const raw = await readFile2(file2, "utf8");
@@ -36875,11 +37241,11 @@ function readStorePosture(dbPath2) {
36875
37241
  // ../../packages/plugin-runtime/src/attached/posture-store.ts
36876
37242
  import { randomUUID as randomUUID17 } from "crypto";
36877
37243
  import { readFile as readFile3, rm as rm2, writeFile as writeFile3 } from "fs/promises";
36878
- import { join as join25 } from "path";
37244
+ import { join as join27 } from "path";
36879
37245
  var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
36880
37246
  function createPostureStore(dir = settingsDir(), legacyDir) {
36881
- const file2 = join25(dir, "posture-state.json");
36882
- const legacyFile = legacyDir === void 0 ? null : join25(legacyDir, "posture-state.json");
37247
+ const file2 = join27(dir, "posture-state.json");
37248
+ const legacyFile = legacyDir === void 0 ? null : join27(legacyDir, "posture-state.json");
36883
37249
  async function persist(state) {
36884
37250
  await ensureDataDir(dir);
36885
37251
  const tmp = `${file2}.${randomUUID17()}.tmp`;
@@ -36947,8 +37313,8 @@ function createPostureStore(dir = settingsDir(), legacyDir) {
36947
37313
  }
36948
37314
 
36949
37315
  // ../../packages/plugin-runtime/src/attached/sync-state.ts
36950
- import { readFileSync as readFileSync17 } from "fs";
36951
- import { join as join26 } from "path";
37316
+ import { readFileSync as readFileSync18 } from "fs";
37317
+ import { join as join28 } from "path";
36952
37318
 
36953
37319
  // ../../packages/plugin-runtime/src/attached/status.ts
36954
37320
  var REFUSAL_LINES = {
@@ -37536,7 +37902,7 @@ function fenced(body) {
37536
37902
 
37537
37903
  // ../../packages/setup-wizard/src/remediation/rotation-checklist.ts
37538
37904
  import { writeFileSync as writeFileSync8 } from "fs";
37539
- import { join as join27 } from "path";
37905
+ import { join as join29 } from "path";
37540
37906
 
37541
37907
  // ../../packages/setup-wizard/src/triage/merge.ts
37542
37908
  var RANK = Object.fromEntries(
@@ -37544,9 +37910,9 @@ var RANK = Object.fromEntries(
37544
37910
  );
37545
37911
 
37546
37912
  // ../../packages/setup-wizard/src/triage/plan-file.ts
37547
- import { mkdtempSync, readFileSync as readFileSync18, rmdirSync, rmSync as rmSync7, writeFileSync as writeFileSync9 } from "fs";
37913
+ import { mkdtempSync, readFileSync as readFileSync19, rmdirSync, rmSync as rmSync7, writeFileSync as writeFileSync9 } from "fs";
37548
37914
  import { tmpdir } from "os";
37549
- import { basename as basename6, dirname as dirname6, join as join28 } from "path";
37915
+ import { basename as basename6, dirname as dirname6, join as join30 } from "path";
37550
37916
  var SuppressionEntrySchema = external_exports.object({
37551
37917
  ruleId: external_exports.string(),
37552
37918
  category: DetectionCategory,
@@ -37593,7 +37959,6 @@ import { fileURLToPath as fileURLToPath4 } from "url";
37593
37959
  var COMMANDS_DIR = fileURLToPath4(new URL("../commands", import.meta.url));
37594
37960
 
37595
37961
  // src/render.ts
37596
- var SEVERITY_WEIGHT = { critical: 4, high: 3, medium: 2, low: 1 };
37597
37962
  var SEVERITY_GLYPH = {
37598
37963
  critical: SHADE.full,
37599
37964
  high: SHADE.dark,
@@ -37603,15 +37968,6 @@ var SEVERITY_GLYPH = {
37603
37968
  function severityGlyph(severity) {
37604
37969
  return SEVERITY_GLYPH[severity] ?? SHADE.light;
37605
37970
  }
37606
- var ADVICE = {
37607
- secret: "Rotate the exposed credentials and move them out of prompts (secrets manager / env vars).",
37608
- pii: "Remove or mask personal data before it reaches the model.",
37609
- financial: "Strip card and account numbers; share only non-sensitive references.",
37610
- phi: "Remove protected health information \u2014 it should never reach an external model.",
37611
- code_context: "Confirm this proprietary code context is safe to share.",
37612
- code_flaw: "Review the flagged pattern and apply the secure alternative (parameterized queries, safe deserializers, etc.).",
37613
- custom: "Review against your organization\u2019s custom policy."
37614
- };
37615
37971
  function shortTime(iso) {
37616
37972
  if (!iso) return "\u2014";
37617
37973
  return iso.length >= 16 ? `${iso.slice(5, 10)} ${iso.slice(11, 16)}` : iso;
@@ -37620,11 +37976,6 @@ function empty(message) {
37620
37976
  return message;
37621
37977
  }
37622
37978
  var CATEGORY_ORDER2 = DetectionCategory.options;
37623
- function healthScore(summary) {
37624
- const handled = summary.byAction.block + summary.byAction.redact + summary.byAction.warn;
37625
- const handledRatio = summary.findings === 0 ? 1 : handled / summary.findings;
37626
- return Math.round(100 * (0.6 * summary.coverage + 0.4 * handledRatio));
37627
- }
37628
37979
  function renderFindings(findings, status, severity) {
37629
37980
  if (findings.length === 0) {
37630
37981
  return empty(
@@ -37680,13 +38031,6 @@ function renderStatusBar(s, opts = {}) {
37680
38031
  const open3 = `${flag} ${String(s.openFindings)} open findings`;
37681
38032
  return `${paint.brand("\u25B8\u25B8 AKA")}${sep4}${score}${sep4}${tally}${sep4}${open3}`;
37682
38033
  }
37683
- function findingStatus(summary) {
37684
- return {
37685
- score: healthScore(summary),
37686
- unreviewed: { ...summary.bySeverity },
37687
- openFindings: summary.findings
37688
- };
37689
- }
37690
38034
  function renderHealth(r) {
37691
38035
  const lines = [`\u25CF ${r.title}`, ""];
37692
38036
  for (const g of r.gauges) lines.push(indent(renderGauge(g)));
@@ -37694,6 +38038,10 @@ function renderHealth(r) {
37694
38038
  const pct = Math.round(r.scanCoverage * 100);
37695
38039
  const stats = `Open findings ${String(r.openFindings)} Scan coverage ${String(pct)}%`;
37696
38040
  lines.push(indent(stats), "");
38041
+ if (r.host.length > 0) {
38042
+ for (const line of r.host) lines.push(indent(line));
38043
+ lines.push("");
38044
+ }
37697
38045
  lines.push(indent("Detections & actions \u2014 last 7 days"));
37698
38046
  const maxDay = Math.max(1, ...r.week.map((d) => d.total));
37699
38047
  for (const d of r.week) {
@@ -37732,7 +38080,7 @@ function weekday(isoDay2) {
37732
38080
  const date5 = /* @__PURE__ */ new Date(`${isoDay2}T00:00:00Z`);
37733
38081
  return Number.isNaN(date5.getTime()) ? isoDay2 : WEEKDAYS[date5.getUTCDay()] ?? isoDay2;
37734
38082
  }
37735
- function buildHealthReport(summary, findings, activity) {
38083
+ function buildHealthReport(summary, findings, activity, hostLines = []) {
37736
38084
  const status = findingStatus(summary);
37737
38085
  const handled = summary.byAction.block + summary.byAction.redact + summary.byAction.warn;
37738
38086
  const handledPct = summary.findings === 0 ? 100 : Math.round(handled / summary.findings * 100);
@@ -37769,48 +38117,10 @@ function buildHealthReport(summary, findings, activity) {
37769
38117
  // so "review N prioritized actions" always matches that screen.
37770
38118
  recommendCount: buildRecommendations(findings).length,
37771
38119
  unreviewed: status.unreviewed,
37772
- score: status.score
38120
+ score: status.score,
38121
+ host: [...hostLines]
37773
38122
  };
37774
38123
  }
37775
- var REC_TEMPLATE = {
37776
- secret: { title: "Exposed secret detected", action: "Rotate" },
37777
- pii: { title: "Personal data in a prompt", action: "Remove" },
37778
- financial: { title: "Financial data detected", action: "Strip" },
37779
- phi: { title: "Health information detected", action: "Remove" },
37780
- code_context: { title: "Proprietary code shared", action: "Review" },
37781
- custom: { title: "Custom policy match", action: "Review" }
37782
- };
37783
- var MAX_RECOMMENDATIONS = 10;
37784
- function buildRecommendations(findings) {
37785
- const buckets = /* @__PURE__ */ new Map();
37786
- for (const f of findings) {
37787
- const b = buckets.get(f.category) ?? {
37788
- category: f.category,
37789
- count: 0,
37790
- severity: f.severity,
37791
- weight: 0,
37792
- ruleId: f.ruleId
37793
- };
37794
- b.count++;
37795
- const w = SEVERITY_WEIGHT[f.severity] ?? 0;
37796
- if (w > b.weight) {
37797
- b.weight = w;
37798
- b.severity = f.severity;
37799
- b.ruleId = f.ruleId;
37800
- }
37801
- buckets.set(f.category, b);
37802
- }
37803
- return [...buckets.values()].sort((a, b) => b.weight - a.weight || b.count - a.count).slice(0, MAX_RECOMMENDATIONS).map((b) => {
37804
- const t = REC_TEMPLATE[b.category] ?? { title: `${b.category} finding`, action: "Review" };
37805
- return {
37806
- severity: b.severity,
37807
- title: t.title,
37808
- description: ADVICE[b.category] ?? "Review this finding against your policy.",
37809
- context: `${b.ruleId} \xB7 ${String(b.count)} finding${b.count === 1 ? "" : "s"}`,
37810
- action: t.action
37811
- };
37812
- });
37813
- }
37814
38124
  var REC_DESC_WIDTH = 72;
37815
38125
  var REC_BODY_INDENT = " ";
37816
38126
  function renderRecommend(recs, status) {
@@ -37991,7 +38301,7 @@ async function runQuery(sub2, gateway, opts = {}) {
37991
38301
  gateway.recentFindings({ limit: 500 }),
37992
38302
  gateway.activityByDay(7)
37993
38303
  ]);
37994
- return renderHealth(buildHealthReport(summary, findings, activity));
38304
+ return renderHealth(buildHealthReport(summary, findings, activity, opts.hostLines));
37995
38305
  }
37996
38306
  case "recommend": {
37997
38307
  const [findings, summary] = await Promise.all([
@@ -38047,7 +38357,16 @@ try {
38047
38357
  try {
38048
38358
  const severity = parseSeverity(args);
38049
38359
  process.stdout.write(
38050
- `${fenced(await runQuery(sub, gateway, severity !== void 0 ? { severity } : {}))}
38360
+ `${fenced(
38361
+ await runQuery(sub, gateway, {
38362
+ ...severity !== void 0 ? { severity } : {},
38363
+ // Resolved here rather than inside runQuery, which holds a gateway
38364
+ // and not the data dir. Read from the cache a hook wrote: probing
38365
+ // `claude --version` would answer for the install on PATH, which
38366
+ // need not be the one running any session.
38367
+ hostLines: hostCompatibilityLines(readHostVersionCache(config2.dataDir))
38368
+ })
38369
+ )}
38051
38370
  `
38052
38371
  );
38053
38372
  } finally {