@gethelio/proxy 0.8.0 → 0.9.0

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/dist/cli.js CHANGED
@@ -4464,6 +4464,8 @@ function clampInt(value, fallback, min, max) {
4464
4464
 
4465
4465
  // src/audit/store.ts
4466
4466
  var DRIFT_EVENT_DECISIONS_SQL = "('tool_drift', 'tool_drift_reverted')";
4467
+ var EXPORT_MAX_RECORDS = 1e4;
4468
+ var LIST_MAX_PAGE_SIZE = 1e3;
4467
4469
  var CREATE_TABLE_DDL = `
4468
4470
  CREATE TABLE IF NOT EXISTS audit_records (
4469
4471
  id TEXT PRIMARY KEY,
@@ -4767,15 +4769,28 @@ var AuditStore = class {
4767
4769
  const row = this.db.prepare("SELECT * FROM audit_records WHERE id = ?").get(id);
4768
4770
  return row ? deserializeRow(row) : void 0;
4769
4771
  }
4770
- /** Query records with filters and pagination. */
4772
+ /** Query records with filters and pagination. Capped at 1,000 per page. */
4771
4773
  list(filters = {}, pagination = {}) {
4772
- const { clause, params } = buildWhereClause(filters);
4773
- const limit = clamp(pagination.limit ?? 50, 1, 1e3);
4774
+ const limit = clamp(pagination.limit ?? 50, 1, LIST_MAX_PAGE_SIZE);
4774
4775
  const offset = Math.max(pagination.offset ?? 0, 0);
4775
4776
  const order = pagination.order === "asc" ? "ASC" : "DESC";
4777
+ return this.query(filters, limit, offset, order);
4778
+ }
4779
+ /**
4780
+ * Query records for bulk export. Unlike `list()`, which enforces the
4781
+ * dashboard's 1,000-row page cap, this path allows up to
4782
+ * {@link EXPORT_MAX_RECORDS} in a single call. Always oldest-first
4783
+ * (ascending `created_at`), so a capped export keeps the earliest records.
4784
+ */
4785
+ listForExport(filters = {}, limit) {
4786
+ const clamped = clamp(limit ?? EXPORT_MAX_RECORDS, 1, EXPORT_MAX_RECORDS);
4787
+ return this.query(filters, clamped, 0, "ASC");
4788
+ }
4789
+ query(filters, limit, offset, order) {
4790
+ const { clause, params } = buildWhereClause(filters);
4776
4791
  const { total } = this.db.prepare(`SELECT COUNT(*) as total FROM audit_records ${clause}`).get(...params);
4777
4792
  const rows = this.db.prepare(
4778
- `SELECT * FROM audit_records ${clause} ORDER BY created_at ${order} LIMIT ? OFFSET ?`
4793
+ `SELECT * FROM audit_records ${clause} ORDER BY created_at ${order}, rowid ${order} LIMIT ? OFFSET ?`
4779
4794
  ).all(...params, limit, offset);
4780
4795
  return {
4781
4796
  records: rows.map(deserializeRow),
@@ -5592,6 +5607,7 @@ var MAX_PENDING_BYTES = 64 * 1024 * 1024;
5592
5607
  var MAX_SENDER_KEYS = 5e4;
5593
5608
  var MAX_EVIDENCE_ENTRIES = 16;
5594
5609
  var MAX_EVIDENCE_BYTES = 64 * 1024;
5610
+ var MAX_VERSION_LOG_LINES_PER_ORIGIN = 5;
5595
5611
  var SWEEP_INTERVAL_MS2 = 3e4;
5596
5612
  var GovernanceService = class {
5597
5613
  policy;
@@ -5609,6 +5625,13 @@ var GovernanceService = class {
5609
5625
  maxSenderKeys;
5610
5626
  /** Distinct sender_id limit keys with live state (reservation registry, issue #13). */
5611
5627
  senderKeys = /* @__PURE__ */ new Set();
5628
+ /**
5629
+ * Per-origin adapter liveness (issue #126). New origins are inserted ONLY on
5630
+ * the /evaluate path, which sits behind the MAX_ORIGINS cache gate — every
5631
+ * other path updates existing entries and skips unknown origins, so the
5632
+ * registry shares the origin cap instead of adding a second growth vector.
5633
+ */
5634
+ adapters = /* @__PURE__ */ new Map();
5612
5635
  pending = /* @__PURE__ */ new Map();
5613
5636
  tombstones = /* @__PURE__ */ new Map();
5614
5637
  caches = /* @__PURE__ */ new Map();
@@ -5666,6 +5689,7 @@ var GovernanceService = class {
5666
5689
  return { status: 503, body: { error: "evaluation_backlog_full" } };
5667
5690
  }
5668
5691
  const cache = this.cacheFor(req.origin);
5692
+ this.recordAdapterSeen(req.origin, req.adapter_version);
5669
5693
  const toolName = req.tool.name;
5670
5694
  const hasDefinition = definitionProvided(req.tool);
5671
5695
  if (hasDefinition) {
@@ -5924,6 +5948,7 @@ var GovernanceService = class {
5924
5948
  finalizedBy: "audit",
5925
5949
  expiresAtMs: this.now() + this.ttlMs
5926
5950
  });
5951
+ this.touchAdapter(entry.origin);
5927
5952
  const body = { ok: true, audit_record_id: auditId };
5928
5953
  if (evidenceOutcomes) body["evidence"] = evidenceOutcomes;
5929
5954
  return { status: 201, body };
@@ -5989,6 +6014,7 @@ var GovernanceService = class {
5989
6014
  if (reserved) {
5990
6015
  return { status: 400, body: { error: "reserved_metadata_key", key: reserved } };
5991
6016
  }
6017
+ this.touchAdapter(req.origin);
5992
6018
  const evaluationId = randomUUID4();
5993
6019
  const toolName = `install:${req.package.source ?? "pkg"}:${req.package.name}`;
5994
6020
  const verdict = this.evaluateInstall(req);
@@ -6053,6 +6079,65 @@ var GovernanceService = class {
6053
6079
  };
6054
6080
  }
6055
6081
  // -------------------------------------------------------------------------
6082
+ // Adapter liveness registry (issue #126)
6083
+ // -------------------------------------------------------------------------
6084
+ /** Wire-ready liveness entries, most recently seen first. */
6085
+ listAdapters() {
6086
+ return [...this.adapters.entries()].sort(([oa, a], [ob, b]) => b.lastSeenMs - a.lastSeenMs || oa.localeCompare(ob)).map(([origin, state]) => ({
6087
+ origin,
6088
+ adapter_version: state.adapterVersion,
6089
+ first_seen: new Date(state.firstSeenMs).toISOString(),
6090
+ last_seen: new Date(state.lastSeenMs).toISOString()
6091
+ }));
6092
+ }
6093
+ /** Insert-or-refresh on the /evaluate path (the only insert site). */
6094
+ recordAdapterSeen(origin, version) {
6095
+ const normalized = version && version.length <= 64 ? version : void 0;
6096
+ const now = this.now();
6097
+ const existing = this.adapters.get(origin);
6098
+ if (!existing) {
6099
+ const state = {
6100
+ adapterVersion: normalized ?? null,
6101
+ firstSeenMs: now,
6102
+ lastSeenMs: now,
6103
+ versionLogCount: 0
6104
+ };
6105
+ this.adapters.set(origin, state);
6106
+ if (normalized !== void 0) this.logVersionEvent(origin, state, null, normalized);
6107
+ return;
6108
+ }
6109
+ existing.lastSeenMs = Math.max(existing.lastSeenMs, now);
6110
+ if (normalized !== void 0 && normalized !== existing.adapterVersion) {
6111
+ this.logVersionEvent(origin, existing, existing.adapterVersion, normalized);
6112
+ existing.adapterVersion = normalized;
6113
+ }
6114
+ }
6115
+ /** Refresh-only for paths without an origin budget gate (install-scan, audit). */
6116
+ touchAdapter(origin) {
6117
+ const existing = this.adapters.get(origin);
6118
+ if (!existing) return;
6119
+ existing.lastSeenMs = Math.max(existing.lastSeenMs, this.now());
6120
+ }
6121
+ /**
6122
+ * Log a version sighting/change, capped per origin per boot. Both origin and
6123
+ * version are caller-controlled free text, so both are JSON-escaped — a
6124
+ * newline or control character must not be able to forge extra log lines
6125
+ * (the route's origin regex does not protect direct embedders).
6126
+ */
6127
+ logVersionEvent(origin, state, from, to) {
6128
+ if (state.versionLogCount > MAX_VERSION_LOG_LINES_PER_ORIGIN) return;
6129
+ state.versionLogCount += 1;
6130
+ if (state.versionLogCount > MAX_VERSION_LOG_LINES_PER_ORIGIN) {
6131
+ console.error(
6132
+ `[helio] adapter origin ${JSON.stringify(origin)}: suppressing further version logs after ${String(MAX_VERSION_LOG_LINES_PER_ORIGIN)}`
6133
+ );
6134
+ return;
6135
+ }
6136
+ console.error(
6137
+ from === null ? `[helio] adapter origin ${JSON.stringify(origin)} reports version ${JSON.stringify(to)}` : `[helio] adapter origin ${JSON.stringify(origin)} version changed ${JSON.stringify(from)} -> ${JSON.stringify(to)}`
6138
+ );
6139
+ }
6140
+ // -------------------------------------------------------------------------
6056
6141
  // POST /approval/:id/resolve
6057
6142
  // -------------------------------------------------------------------------
6058
6143
  resolveApproval(ticketId, req) {
@@ -6154,6 +6239,7 @@ var GovernanceService = class {
6154
6239
  this.tombstones.clear();
6155
6240
  this.caches.clear();
6156
6241
  this.senderKeys.clear();
6242
+ this.adapters.clear();
6157
6243
  this.pendingBytes = 0;
6158
6244
  }
6159
6245
  // -------------------------------------------------------------------------
@@ -7438,7 +7524,10 @@ var CSV_HEADERS = [
7438
7524
  "dry_run",
7439
7525
  "created_at",
7440
7526
  "environment",
7441
- "matched_rule_index"
7527
+ "matched_rule_index",
7528
+ "record_kind",
7529
+ "origin",
7530
+ "metadata"
7442
7531
  ];
7443
7532
  var FORMULA_PREFIXES = /^[=+\-@\t\r]/;
7444
7533
  function csvEscape(value) {
@@ -7584,7 +7673,7 @@ var feedQuerySchema = z8.object({
7584
7673
  });
7585
7674
  var auditExportQuerySchema = z8.object({
7586
7675
  format: z8.preprocess((value) => value === "csv" ? "csv" : "json", z8.enum(["json", "csv"])),
7587
- limit: clampedQueryInt(1e4, 1, 1e4),
7676
+ limit: clampedQueryInt(EXPORT_MAX_RECORDS, 1, EXPORT_MAX_RECORDS),
7588
7677
  tool: optionalQueryString,
7589
7678
  decision: optionalQueryString,
7590
7679
  reason: optionalQueryString,
@@ -7602,7 +7691,7 @@ var auditExportQuerySchema = z8.object({
7602
7691
  sender_id: optionalQueryString
7603
7692
  });
7604
7693
  var auditQuerySchema = z8.object({
7605
- limit: clampedQueryInt(50, 1, 1e3),
7694
+ limit: clampedQueryInt(50, 1, LIST_MAX_PAGE_SIZE),
7606
7695
  offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER),
7607
7696
  tool: optionalQueryString,
7608
7697
  decision: optionalQueryString,
@@ -7692,7 +7781,8 @@ function createDashboardAppWithLifecycle(deps, options) {
7692
7781
  rateLimiter,
7693
7782
  spendLimiter,
7694
7783
  evidenceStore,
7695
- eventBus
7784
+ eventBus,
7785
+ adapterLiveness
7696
7786
  } = deps;
7697
7787
  const apiSecret = options?.apiSecret;
7698
7788
  const sessionStore = apiSecret ? new DashboardSessionStore({ secret: apiSecret, ttlMs: SESSION_TTL_MS }) : void 0;
@@ -7847,7 +7937,7 @@ function createDashboardAppWithLifecycle(deps, options) {
7847
7937
  channel_id: query.channel_id,
7848
7938
  sender_id: query.sender_id
7849
7939
  };
7850
- const result = auditStore.list(filters, { limit, order: "asc" });
7940
+ const result = auditStore.listForExport(filters, limit);
7851
7941
  if (format === "csv") {
7852
7942
  const csv = recordsToCsv(result.records);
7853
7943
  return new Response(csv, {
@@ -7911,6 +8001,9 @@ function createDashboardAppWithLifecycle(deps, options) {
7911
8001
  spend_limits: spendLimiter.listKeyStates()
7912
8002
  });
7913
8003
  });
8004
+ app.get("/api/adapters", (c) => {
8005
+ return c.json({ adapters: adapterLiveness?.listAdapters() ?? [] });
8006
+ });
7914
8007
  app.get("/api/analytics", (c) => {
7915
8008
  const query = analyticsQuerySchema.parse(c.req.query());
7916
8009
  const now = /* @__PURE__ */ new Date();
@@ -8441,6 +8534,7 @@ async function startCommand(configPath, options) {
8441
8534
  let sidebandToken;
8442
8535
  let sidebandTokenSource;
8443
8536
  let adapterToken;
8537
+ let adapterTokenSource;
8444
8538
  let governanceService;
8445
8539
  if (config.sdk.enabled) {
8446
8540
  sidebandToken = process.env["HELIO_SDK_TOKEN"];
@@ -8455,6 +8549,9 @@ async function startCommand(configPath, options) {
8455
8549
  if (!adapterToken || adapterToken.length === 0) {
8456
8550
  adapterToken = randomBytes2(32).toString("hex");
8457
8551
  process.env["HELIO_ADAPTER_TOKEN"] = adapterToken;
8552
+ adapterTokenSource = "generated";
8553
+ } else {
8554
+ adapterTokenSource = "env";
8458
8555
  }
8459
8556
  governanceService = new GovernanceService({
8460
8557
  policy,
@@ -8485,7 +8582,10 @@ async function startCommand(configPath, options) {
8485
8582
  rateLimiter,
8486
8583
  spendLimiter,
8487
8584
  evidenceStore,
8488
- eventBus
8585
+ eventBus,
8586
+ // Adapter liveness for GET /api/adapters (issue #126); undefined
8587
+ // unless the SDK sideband is enabled → endpoint serves an empty list.
8588
+ adapterLiveness: governanceService
8489
8589
  },
8490
8590
  {
8491
8591
  apiSecret: config.dashboard.api_secret,
@@ -8517,15 +8617,14 @@ async function startCommand(configPath, options) {
8517
8617
  if (sidebandHandle) {
8518
8618
  console.error(`SDK sideband listening on http://${config.sdk.host}:${String(config.sdk.port)}`);
8519
8619
  if (sidebandToken) {
8520
- const source = sidebandTokenSource === "env" ? "reusing HELIO_SDK_TOKEN from environment" : "generated per-boot HELIO_SDK_TOKEN";
8521
8620
  console.error(
8522
- `SDK token (${source}; pass as HELIO_SDK_TOKEN env var to your SDK clients):
8621
+ sidebandTokenSource === "env" ? "SDK token: reusing HELIO_SDK_TOKEN from environment (value not shown)" : `SDK token (generated per-boot HELIO_SDK_TOKEN; pass as HELIO_SDK_TOKEN env var to your SDK clients):
8523
8622
  ${sidebandToken}`
8524
8623
  );
8525
8624
  }
8526
8625
  if (adapterToken) {
8527
8626
  console.error(
8528
- `Adapter token (governance routes; pass as HELIO_ADAPTER_TOKEN to your adapter):
8627
+ adapterTokenSource === "env" ? "Adapter token: reusing HELIO_ADAPTER_TOKEN from environment (value not shown)" : `Adapter token (generated per-boot HELIO_ADAPTER_TOKEN; governance routes; pass as HELIO_ADAPTER_TOKEN to your adapter):
8529
8628
  ${adapterToken}`
8530
8629
  );
8531
8630
  }
@@ -8656,6 +8755,14 @@ async function validateCommand(configPath) {
8656
8755
  }
8657
8756
  }
8658
8757
  async function exportCommand(opts) {
8758
+ const parsedLimit = Number(opts.limit);
8759
+ if (!Number.isInteger(parsedLimit) || parsedLimit < 1) {
8760
+ console.error(
8761
+ `Error: --limit must be an integer between 1 and ${String(EXPORT_MAX_RECORDS)} (got "${opts.limit}")`
8762
+ );
8763
+ process.exit(1);
8764
+ }
8765
+ const limit = Math.min(parsedLimit, EXPORT_MAX_RECORDS);
8659
8766
  let config;
8660
8767
  try {
8661
8768
  config = await loadConfig(opts.config);
@@ -8674,7 +8781,7 @@ async function exportCommand(opts) {
8674
8781
  // No cleanup timer for one-shot CLI
8675
8782
  });
8676
8783
  try {
8677
- const result = store.list(
8784
+ const result = store.listForExport(
8678
8785
  {
8679
8786
  tool_name: opts.tool,
8680
8787
  policy_decision: opts.decision,
@@ -8683,7 +8790,7 @@ async function exportCommand(opts) {
8683
8790
  from: opts.from,
8684
8791
  to: opts.to
8685
8792
  },
8686
- { limit: Number(opts.limit), order: "asc" }
8793
+ limit
8687
8794
  );
8688
8795
  if (opts.format === "csv") {
8689
8796
  writeCsv(result.records);
@@ -8755,5 +8862,5 @@ program.command("start").description("Load config and start the proxy server").o
8755
8862
  );
8756
8863
  program.command("init").description("Scaffold a helio.yaml config file with commented defaults").option("-o, --output <path>", "Output file path", DEFAULT_CONFIG_PATH).option("-f, --force", "Overwrite existing file", false).action((opts) => initCommand(opts.output, opts.force));
8757
8864
  program.command("validate").description("Validate a helio.yaml config file").option("-c, --config <path>", "Path to helio.yaml", DEFAULT_CONFIG_PATH).action((opts) => validateCommand(opts.config));
8758
- program.command("export").description("Export audit records to JSON or CSV").option("-c, --config <path>", "Path to helio.yaml", DEFAULT_CONFIG_PATH).option("-f, --format <format>", "Output format: json or csv", "json").option("--tool <name>", "Filter by tool name").option("--decision <decision>", "Filter by policy decision").option("--reason <reason>", "Filter by block reason").option("--session <id>", "Filter by session ID").option("--from <iso>", "Start time (ISO 8601)").option("--to <iso>", "End time (ISO 8601)").option("--limit <n>", "Max records to export", "1000").action((opts) => exportCommand(opts));
8865
+ program.command("export").description("Export audit records to JSON or CSV").option("-c, --config <path>", "Path to helio.yaml", DEFAULT_CONFIG_PATH).option("-f, --format <format>", "Output format: json or csv", "json").option("--tool <name>", "Filter by tool name").option("--decision <decision>", "Filter by policy decision").option("--reason <reason>", "Filter by block reason").option("--session <id>", "Filter by session ID").option("--from <iso>", "Start time (ISO 8601)").option("--to <iso>", "End time (ISO 8601)").option("--limit <n>", "Max records to export (up to 10000)", "1000").action((opts) => exportCommand(opts));
8759
8866
  program.parse();
package/dist/index.d.ts CHANGED
@@ -921,7 +921,7 @@ interface AuditQueryFilters {
921
921
  }
922
922
  /** Pagination options for list queries. */
923
923
  interface AuditPaginationOptions {
924
- /** Maximum number of records to return (default: 50, max: 1000). */
924
+ /** Maximum number of records to return (default: 50, max: 1,000 — `LIST_MAX_PAGE_SIZE`). */
925
925
  readonly limit?: number;
926
926
  /** Number of records to skip (default: 0). */
927
927
  readonly offset?: number;
@@ -986,6 +986,17 @@ interface AuditStoreOptions {
986
986
  readonly cleanupIntervalMs?: number;
987
987
  }
988
988
 
989
+ /**
990
+ * Maximum records a single bulk export may return. Shared by the dashboard
991
+ * export route schema and the CLI export command so the advertised cap and
992
+ * the store's actual cap cannot diverge.
993
+ */
994
+ declare const EXPORT_MAX_RECORDS = 10000;
995
+ /**
996
+ * Maximum page size for paginated `list()` reads. Shared with the dashboard
997
+ * audit route schema for the same reason as {@link EXPORT_MAX_RECORDS}.
998
+ */
999
+ declare const LIST_MAX_PAGE_SIZE = 1000;
989
1000
  /**
990
1001
  * SQLite-backed audit record store.
991
1002
  *
@@ -1032,8 +1043,16 @@ declare class AuditStore {
1032
1043
  insertBatch(records: ReadonlyArray<Omit<AuditRecord, 'id' | 'created_at'>>, onError?: (record: Omit<AuditRecord, 'id' | 'created_at'>, err: unknown) => void, ids?: ReadonlyArray<string>, onPersist?: (record: Omit<AuditRecord, 'id' | 'created_at'>, id: string) => void): number;
1033
1044
  /** Get a single record by ID, or undefined if not found. */
1034
1045
  get(id: string): AuditRecord | undefined;
1035
- /** Query records with filters and pagination. */
1046
+ /** Query records with filters and pagination. Capped at 1,000 per page. */
1036
1047
  list(filters?: AuditQueryFilters, pagination?: AuditPaginationOptions): AuditListResult;
1048
+ /**
1049
+ * Query records for bulk export. Unlike `list()`, which enforces the
1050
+ * dashboard's 1,000-row page cap, this path allows up to
1051
+ * {@link EXPORT_MAX_RECORDS} in a single call. Always oldest-first
1052
+ * (ascending `created_at`), so a capped export keeps the earliest records.
1053
+ */
1054
+ listForExport(filters?: AuditQueryFilters, limit?: number): AuditListResult;
1055
+ private query;
1037
1056
  /** Count records matching the given filters. */
1038
1057
  count(filters?: AuditQueryFilters): number;
1039
1058
  /** Get aggregate statistics for a time range. */
@@ -1962,6 +1981,17 @@ interface ServiceResult {
1962
1981
  readonly status: number;
1963
1982
  readonly body: Record<string, unknown>;
1964
1983
  }
1984
+ /**
1985
+ * Per-origin adapter liveness, wire-ready for the dashboard's
1986
+ * `GET /api/adapters` (issue #126). ISO-8601 timestamps; `adapter_version`
1987
+ * stays null until an /evaluate supplies one.
1988
+ */
1989
+ interface AdapterLivenessEntry {
1990
+ readonly origin: string;
1991
+ readonly adapter_version: string | null;
1992
+ readonly first_seen: string;
1993
+ readonly last_seen: string;
1994
+ }
1965
1995
  interface GovernanceServiceOptions {
1966
1996
  readonly policy: CompiledPolicy;
1967
1997
  readonly environment?: string;
@@ -2001,6 +2031,13 @@ declare class GovernanceService {
2001
2031
  private readonly maxSenderKeys;
2002
2032
  /** Distinct sender_id limit keys with live state (reservation registry, issue #13). */
2003
2033
  private readonly senderKeys;
2034
+ /**
2035
+ * Per-origin adapter liveness (issue #126). New origins are inserted ONLY on
2036
+ * the /evaluate path, which sits behind the MAX_ORIGINS cache gate — every
2037
+ * other path updates existing entries and skips unknown origins, so the
2038
+ * registry shares the origin cap instead of adding a second growth vector.
2039
+ */
2040
+ private readonly adapters;
2004
2041
  private readonly pending;
2005
2042
  private readonly tombstones;
2006
2043
  private readonly caches;
@@ -2031,6 +2068,19 @@ declare class GovernanceService {
2031
2068
  installScan(req: InstallScanInput): ServiceResult;
2032
2069
  /** First-match-wins evaluation of the compiled install policy (issue #13). */
2033
2070
  private evaluateInstall;
2071
+ /** Wire-ready liveness entries, most recently seen first. */
2072
+ listAdapters(): AdapterLivenessEntry[];
2073
+ /** Insert-or-refresh on the /evaluate path (the only insert site). */
2074
+ private recordAdapterSeen;
2075
+ /** Refresh-only for paths without an origin budget gate (install-scan, audit). */
2076
+ private touchAdapter;
2077
+ /**
2078
+ * Log a version sighting/change, capped per origin per boot. Both origin and
2079
+ * version are caller-controlled free text, so both are JSON-escaped — a
2080
+ * newline or control character must not be able to forge extra log lines
2081
+ * (the route's origin regex does not protect direct embedders).
2082
+ */
2083
+ private logVersionEvent;
2034
2084
  resolveApproval(ticketId: string, req: ResolveApprovalInput): ServiceResult;
2035
2085
  sweep(): void;
2036
2086
  /**
@@ -2338,6 +2388,14 @@ interface DashboardAppDeps {
2338
2388
  readonly spendLimiter: SpendLimiter;
2339
2389
  readonly evidenceStore: EvidenceStore;
2340
2390
  readonly eventBus: DashboardEventBus;
2391
+ /**
2392
+ * Adapter liveness source for `GET /api/adapters` (issue #126) — a narrow
2393
+ * view of the SDK sideband's GovernanceService. Absent when the SDK
2394
+ * sideband is disabled; the endpoint then serves an empty list.
2395
+ */
2396
+ readonly adapterLiveness?: {
2397
+ listAdapters(): AdapterLivenessEntry[];
2398
+ };
2341
2399
  }
2342
2400
  /** Options for the dashboard API. */
2343
2401
  interface DashboardAppOptions {
@@ -2356,4 +2414,4 @@ interface DashboardAppOptions {
2356
2414
  */
2357
2415
  declare function createDashboardApp(deps: DashboardAppDeps, options?: DashboardAppOptions): Hono;
2358
2416
 
2359
- export { type ApprovalAppOptions, type ApprovalChannel, type ApprovalOutcome, ApprovalQueue, type ApprovalQueueOptions, ApprovalRouter, type ApprovalRouterOptions, type ApprovalStatus, type ApprovalTicket, type AuditAggregateStats, type AuditInput, type AuditListResult, type AuditPaginationOptions, type AuditQueryFilters, type AuditRecord, AuditStore, type AuditStoreOptions, type AuditTimeBucket, AuditWriter, type AuditWriterOptions, type CompilePoliciesResult, type CompiledPolicy, type CompiledPolicyRule, ConfigError, type CreateAppOptions, type DashboardAppDeps, type DashboardAppOptions, DashboardEventBus, type DashboardEventType, type DashboardEvents, type EvaluateInput, type EvidenceEntry, EvidenceStore, type EvidenceStoreOptions, GovernanceConfigError, GovernanceService, type GovernanceServiceOptions, GovernedForwarder, type GovernedForwarderOptions, type HelioConfig, type InstallScanInput, type MatchContext, type PolicyDecision, PolicyParseError, QueueChannel, type RateLimitCheckParams, type RateLimitKeyState, type RateLimitResult, RateLimiter, type RateLimiterOptions, type ResolveApprovalInput, type ServerHandle, type SessionState, type SlackActionAppOptions, SlackChannel, type SlackChannelOptions, type SpendLimitCheckParams, type SpendLimitKeyState, type SpendLimitResult, SpendLimiter, type SpendLimiterOptions, SseUpstreamForwarder, type SseUpstreamForwarderOptions, StdioForwarder, type StdioForwarderOptions, StreamableHttpForwarder, type StreamableHttpForwarderOptions, UpstreamForwarder, type UpstreamForwarderOptions, VERSION, WebhookChannel, type WebhookChannelOptions, type WireDecision, compilePolicies, createApp, createApprovalApp, createChannels, createDashboardApp, createSidebandApp, createSlackActionApp, evaluatePolicy, loadConfig, matchRule, startServer, startSidebandServer };
2417
+ export { type AdapterLivenessEntry, type ApprovalAppOptions, type ApprovalChannel, type ApprovalOutcome, ApprovalQueue, type ApprovalQueueOptions, ApprovalRouter, type ApprovalRouterOptions, type ApprovalStatus, type ApprovalTicket, type AuditAggregateStats, type AuditInput, type AuditListResult, type AuditPaginationOptions, type AuditQueryFilters, type AuditRecord, AuditStore, type AuditStoreOptions, type AuditTimeBucket, AuditWriter, type AuditWriterOptions, type CompilePoliciesResult, type CompiledPolicy, type CompiledPolicyRule, ConfigError, type CreateAppOptions, type DashboardAppDeps, type DashboardAppOptions, DashboardEventBus, type DashboardEventType, type DashboardEvents, EXPORT_MAX_RECORDS, type EvaluateInput, type EvidenceEntry, EvidenceStore, type EvidenceStoreOptions, GovernanceConfigError, GovernanceService, type GovernanceServiceOptions, GovernedForwarder, type GovernedForwarderOptions, type HelioConfig, type InstallScanInput, LIST_MAX_PAGE_SIZE, type MatchContext, type PolicyDecision, PolicyParseError, QueueChannel, type RateLimitCheckParams, type RateLimitKeyState, type RateLimitResult, RateLimiter, type RateLimiterOptions, type ResolveApprovalInput, type ServerHandle, type SessionState, type SlackActionAppOptions, SlackChannel, type SlackChannelOptions, type SpendLimitCheckParams, type SpendLimitKeyState, type SpendLimitResult, SpendLimiter, type SpendLimiterOptions, SseUpstreamForwarder, type SseUpstreamForwarderOptions, StdioForwarder, type StdioForwarderOptions, StreamableHttpForwarder, type StreamableHttpForwarderOptions, UpstreamForwarder, type UpstreamForwarderOptions, VERSION, WebhookChannel, type WebhookChannelOptions, type WireDecision, compilePolicies, createApp, createApprovalApp, createChannels, createDashboardApp, createSidebandApp, createSlackActionApp, evaluatePolicy, loadConfig, matchRule, startServer, startSidebandServer };
package/dist/index.js CHANGED
@@ -4873,6 +4873,7 @@ var MAX_PENDING_BYTES = 64 * 1024 * 1024;
4873
4873
  var MAX_SENDER_KEYS = 5e4;
4874
4874
  var MAX_EVIDENCE_ENTRIES = 16;
4875
4875
  var MAX_EVIDENCE_BYTES = 64 * 1024;
4876
+ var MAX_VERSION_LOG_LINES_PER_ORIGIN = 5;
4876
4877
  var SWEEP_INTERVAL_MS2 = 3e4;
4877
4878
  var GovernanceService = class {
4878
4879
  policy;
@@ -4890,6 +4891,13 @@ var GovernanceService = class {
4890
4891
  maxSenderKeys;
4891
4892
  /** Distinct sender_id limit keys with live state (reservation registry, issue #13). */
4892
4893
  senderKeys = /* @__PURE__ */ new Set();
4894
+ /**
4895
+ * Per-origin adapter liveness (issue #126). New origins are inserted ONLY on
4896
+ * the /evaluate path, which sits behind the MAX_ORIGINS cache gate — every
4897
+ * other path updates existing entries and skips unknown origins, so the
4898
+ * registry shares the origin cap instead of adding a second growth vector.
4899
+ */
4900
+ adapters = /* @__PURE__ */ new Map();
4893
4901
  pending = /* @__PURE__ */ new Map();
4894
4902
  tombstones = /* @__PURE__ */ new Map();
4895
4903
  caches = /* @__PURE__ */ new Map();
@@ -4947,6 +4955,7 @@ var GovernanceService = class {
4947
4955
  return { status: 503, body: { error: "evaluation_backlog_full" } };
4948
4956
  }
4949
4957
  const cache = this.cacheFor(req.origin);
4958
+ this.recordAdapterSeen(req.origin, req.adapter_version);
4950
4959
  const toolName = req.tool.name;
4951
4960
  const hasDefinition = definitionProvided(req.tool);
4952
4961
  if (hasDefinition) {
@@ -5205,6 +5214,7 @@ var GovernanceService = class {
5205
5214
  finalizedBy: "audit",
5206
5215
  expiresAtMs: this.now() + this.ttlMs
5207
5216
  });
5217
+ this.touchAdapter(entry.origin);
5208
5218
  const body = { ok: true, audit_record_id: auditId };
5209
5219
  if (evidenceOutcomes) body["evidence"] = evidenceOutcomes;
5210
5220
  return { status: 201, body };
@@ -5270,6 +5280,7 @@ var GovernanceService = class {
5270
5280
  if (reserved) {
5271
5281
  return { status: 400, body: { error: "reserved_metadata_key", key: reserved } };
5272
5282
  }
5283
+ this.touchAdapter(req.origin);
5273
5284
  const evaluationId = randomUUID2();
5274
5285
  const toolName = `install:${req.package.source ?? "pkg"}:${req.package.name}`;
5275
5286
  const verdict = this.evaluateInstall(req);
@@ -5334,6 +5345,65 @@ var GovernanceService = class {
5334
5345
  };
5335
5346
  }
5336
5347
  // -------------------------------------------------------------------------
5348
+ // Adapter liveness registry (issue #126)
5349
+ // -------------------------------------------------------------------------
5350
+ /** Wire-ready liveness entries, most recently seen first. */
5351
+ listAdapters() {
5352
+ return [...this.adapters.entries()].sort(([oa, a], [ob, b]) => b.lastSeenMs - a.lastSeenMs || oa.localeCompare(ob)).map(([origin, state]) => ({
5353
+ origin,
5354
+ adapter_version: state.adapterVersion,
5355
+ first_seen: new Date(state.firstSeenMs).toISOString(),
5356
+ last_seen: new Date(state.lastSeenMs).toISOString()
5357
+ }));
5358
+ }
5359
+ /** Insert-or-refresh on the /evaluate path (the only insert site). */
5360
+ recordAdapterSeen(origin, version) {
5361
+ const normalized = version && version.length <= 64 ? version : void 0;
5362
+ const now = this.now();
5363
+ const existing = this.adapters.get(origin);
5364
+ if (!existing) {
5365
+ const state = {
5366
+ adapterVersion: normalized ?? null,
5367
+ firstSeenMs: now,
5368
+ lastSeenMs: now,
5369
+ versionLogCount: 0
5370
+ };
5371
+ this.adapters.set(origin, state);
5372
+ if (normalized !== void 0) this.logVersionEvent(origin, state, null, normalized);
5373
+ return;
5374
+ }
5375
+ existing.lastSeenMs = Math.max(existing.lastSeenMs, now);
5376
+ if (normalized !== void 0 && normalized !== existing.adapterVersion) {
5377
+ this.logVersionEvent(origin, existing, existing.adapterVersion, normalized);
5378
+ existing.adapterVersion = normalized;
5379
+ }
5380
+ }
5381
+ /** Refresh-only for paths without an origin budget gate (install-scan, audit). */
5382
+ touchAdapter(origin) {
5383
+ const existing = this.adapters.get(origin);
5384
+ if (!existing) return;
5385
+ existing.lastSeenMs = Math.max(existing.lastSeenMs, this.now());
5386
+ }
5387
+ /**
5388
+ * Log a version sighting/change, capped per origin per boot. Both origin and
5389
+ * version are caller-controlled free text, so both are JSON-escaped — a
5390
+ * newline or control character must not be able to forge extra log lines
5391
+ * (the route's origin regex does not protect direct embedders).
5392
+ */
5393
+ logVersionEvent(origin, state, from, to) {
5394
+ if (state.versionLogCount > MAX_VERSION_LOG_LINES_PER_ORIGIN) return;
5395
+ state.versionLogCount += 1;
5396
+ if (state.versionLogCount > MAX_VERSION_LOG_LINES_PER_ORIGIN) {
5397
+ console.error(
5398
+ `[helio] adapter origin ${JSON.stringify(origin)}: suppressing further version logs after ${String(MAX_VERSION_LOG_LINES_PER_ORIGIN)}`
5399
+ );
5400
+ return;
5401
+ }
5402
+ console.error(
5403
+ from === null ? `[helio] adapter origin ${JSON.stringify(origin)} reports version ${JSON.stringify(to)}` : `[helio] adapter origin ${JSON.stringify(origin)} version changed ${JSON.stringify(from)} -> ${JSON.stringify(to)}`
5404
+ );
5405
+ }
5406
+ // -------------------------------------------------------------------------
5337
5407
  // POST /approval/:id/resolve
5338
5408
  // -------------------------------------------------------------------------
5339
5409
  resolveApproval(ticketId, req) {
@@ -5435,6 +5505,7 @@ var GovernanceService = class {
5435
5505
  this.tombstones.clear();
5436
5506
  this.caches.clear();
5437
5507
  this.senderKeys.clear();
5508
+ this.adapters.clear();
5438
5509
  this.pendingBytes = 0;
5439
5510
  }
5440
5511
  // -------------------------------------------------------------------------
@@ -5788,6 +5859,8 @@ function clampInt(value, fallback, min, max) {
5788
5859
 
5789
5860
  // src/audit/store.ts
5790
5861
  var DRIFT_EVENT_DECISIONS_SQL = "('tool_drift', 'tool_drift_reverted')";
5862
+ var EXPORT_MAX_RECORDS = 1e4;
5863
+ var LIST_MAX_PAGE_SIZE = 1e3;
5791
5864
  var CREATE_TABLE_DDL = `
5792
5865
  CREATE TABLE IF NOT EXISTS audit_records (
5793
5866
  id TEXT PRIMARY KEY,
@@ -6091,15 +6164,28 @@ var AuditStore = class {
6091
6164
  const row = this.db.prepare("SELECT * FROM audit_records WHERE id = ?").get(id);
6092
6165
  return row ? deserializeRow(row) : void 0;
6093
6166
  }
6094
- /** Query records with filters and pagination. */
6167
+ /** Query records with filters and pagination. Capped at 1,000 per page. */
6095
6168
  list(filters = {}, pagination = {}) {
6096
- const { clause, params } = buildWhereClause(filters);
6097
- const limit = clamp(pagination.limit ?? 50, 1, 1e3);
6169
+ const limit = clamp(pagination.limit ?? 50, 1, LIST_MAX_PAGE_SIZE);
6098
6170
  const offset = Math.max(pagination.offset ?? 0, 0);
6099
6171
  const order = pagination.order === "asc" ? "ASC" : "DESC";
6172
+ return this.query(filters, limit, offset, order);
6173
+ }
6174
+ /**
6175
+ * Query records for bulk export. Unlike `list()`, which enforces the
6176
+ * dashboard's 1,000-row page cap, this path allows up to
6177
+ * {@link EXPORT_MAX_RECORDS} in a single call. Always oldest-first
6178
+ * (ascending `created_at`), so a capped export keeps the earliest records.
6179
+ */
6180
+ listForExport(filters = {}, limit) {
6181
+ const clamped = clamp(limit ?? EXPORT_MAX_RECORDS, 1, EXPORT_MAX_RECORDS);
6182
+ return this.query(filters, clamped, 0, "ASC");
6183
+ }
6184
+ query(filters, limit, offset, order) {
6185
+ const { clause, params } = buildWhereClause(filters);
6100
6186
  const { total } = this.db.prepare(`SELECT COUNT(*) as total FROM audit_records ${clause}`).get(...params);
6101
6187
  const rows = this.db.prepare(
6102
- `SELECT * FROM audit_records ${clause} ORDER BY created_at ${order} LIMIT ? OFFSET ?`
6188
+ `SELECT * FROM audit_records ${clause} ORDER BY created_at ${order}, rowid ${order} LIMIT ? OFFSET ?`
6103
6189
  ).all(...params, limit, offset);
6104
6190
  return {
6105
6191
  records: rows.map(deserializeRow),
@@ -7302,7 +7388,10 @@ var CSV_HEADERS = [
7302
7388
  "dry_run",
7303
7389
  "created_at",
7304
7390
  "environment",
7305
- "matched_rule_index"
7391
+ "matched_rule_index",
7392
+ "record_kind",
7393
+ "origin",
7394
+ "metadata"
7306
7395
  ];
7307
7396
  var FORMULA_PREFIXES = /^[=+\-@\t\r]/;
7308
7397
  function csvEscape(value) {
@@ -7448,7 +7537,7 @@ var feedQuerySchema = z8.object({
7448
7537
  });
7449
7538
  var auditExportQuerySchema = z8.object({
7450
7539
  format: z8.preprocess((value) => value === "csv" ? "csv" : "json", z8.enum(["json", "csv"])),
7451
- limit: clampedQueryInt(1e4, 1, 1e4),
7540
+ limit: clampedQueryInt(EXPORT_MAX_RECORDS, 1, EXPORT_MAX_RECORDS),
7452
7541
  tool: optionalQueryString,
7453
7542
  decision: optionalQueryString,
7454
7543
  reason: optionalQueryString,
@@ -7466,7 +7555,7 @@ var auditExportQuerySchema = z8.object({
7466
7555
  sender_id: optionalQueryString
7467
7556
  });
7468
7557
  var auditQuerySchema = z8.object({
7469
- limit: clampedQueryInt(50, 1, 1e3),
7558
+ limit: clampedQueryInt(50, 1, LIST_MAX_PAGE_SIZE),
7470
7559
  offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER),
7471
7560
  tool: optionalQueryString,
7472
7561
  decision: optionalQueryString,
@@ -7556,7 +7645,8 @@ function createDashboardAppWithLifecycle(deps, options) {
7556
7645
  rateLimiter,
7557
7646
  spendLimiter,
7558
7647
  evidenceStore,
7559
- eventBus
7648
+ eventBus,
7649
+ adapterLiveness
7560
7650
  } = deps;
7561
7651
  const apiSecret = options?.apiSecret;
7562
7652
  const sessionStore = apiSecret ? new DashboardSessionStore({ secret: apiSecret, ttlMs: SESSION_TTL_MS }) : void 0;
@@ -7711,7 +7801,7 @@ function createDashboardAppWithLifecycle(deps, options) {
7711
7801
  channel_id: query.channel_id,
7712
7802
  sender_id: query.sender_id
7713
7803
  };
7714
- const result = auditStore.list(filters, { limit, order: "asc" });
7804
+ const result = auditStore.listForExport(filters, limit);
7715
7805
  if (format === "csv") {
7716
7806
  const csv = recordsToCsv(result.records);
7717
7807
  return new Response(csv, {
@@ -7775,6 +7865,9 @@ function createDashboardAppWithLifecycle(deps, options) {
7775
7865
  spend_limits: spendLimiter.listKeyStates()
7776
7866
  });
7777
7867
  });
7868
+ app.get("/api/adapters", (c) => {
7869
+ return c.json({ adapters: adapterLiveness?.listAdapters() ?? [] });
7870
+ });
7778
7871
  app.get("/api/analytics", (c) => {
7779
7872
  const query = analyticsQuerySchema.parse(c.req.query());
7780
7873
  const now = /* @__PURE__ */ new Date();
@@ -7945,10 +8038,12 @@ export {
7945
8038
  AuditWriter,
7946
8039
  ConfigError,
7947
8040
  DashboardEventBus,
8041
+ EXPORT_MAX_RECORDS,
7948
8042
  EvidenceStore,
7949
8043
  GovernanceConfigError,
7950
8044
  GovernanceService,
7951
8045
  GovernedForwarder,
8046
+ LIST_MAX_PAGE_SIZE,
7952
8047
  PolicyParseError,
7953
8048
  QueueChannel,
7954
8049
  RateLimiter,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethelio/proxy",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "Open-source MCP governance proxy for AI agents",
6
6
  "license": "Apache-2.0",