@gethelio/proxy 0.7.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.
@@ -6,7 +6,7 @@
6
6
  <meta name="referrer" content="no-referrer" />
7
7
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
8
8
  <title>Helio Dashboard</title>
9
- <script type="module" crossorigin src="/assets/index-DgywE2WQ.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-0ylAcvX3.js"></script>
10
10
  <link rel="stylesheet" crossorigin href="/assets/index-DZKoV0Vx.css">
11
11
  </head>
12
12
  <body>
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;
@@ -946,7 +946,7 @@ interface AuditTimeBucket {
946
946
  interface AuditAggregateStats {
947
947
  /** Total number of records in the time range. */
948
948
  readonly total: number;
949
- /** Total records that resolved without a block (`block_reason IS NULL`). */
949
+ /** Total records that resolved without a block (`block_reason IS NULL`), excluding drift events. */
950
950
  readonly allowed_total: number;
951
951
  /** Total records that resolved with a block (`block_reason IS NOT NULL`). */
952
952
  readonly blocked_total: 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
@@ -3141,6 +3141,7 @@ var GovernedForwarder = class {
3141
3141
  let result;
3142
3142
  let approvalOutcome;
3143
3143
  let approvalWaitMs = 0;
3144
+ let approvalContext;
3144
3145
  let rateLimitResult;
3145
3146
  let spendLimitResult;
3146
3147
  let forwardingError;
@@ -3170,6 +3171,7 @@ var GovernedForwarder = class {
3170
3171
  result = approvalResult.result;
3171
3172
  approvalOutcome = approvalResult.outcome;
3172
3173
  approvalWaitMs = approvalResult.approvalWaitMs;
3174
+ approvalContext = approvalResult.approvalContext;
3173
3175
  }
3174
3176
  } else if (decision.action === "rate_limit") {
3175
3177
  if (!this.rateLimiter) {
@@ -3222,6 +3224,7 @@ var GovernedForwarder = class {
3222
3224
  dependencyResult,
3223
3225
  evidenceBlocked,
3224
3226
  approvalOutcome,
3227
+ approvalContext,
3225
3228
  rateLimitResult,
3226
3229
  spendLimitResult,
3227
3230
  isDryRun,
@@ -3243,6 +3246,16 @@ var GovernedForwarder = class {
3243
3246
  request.signal
3244
3247
  );
3245
3248
  const approvalWaitMs = performance.now() - approvalStart;
3249
+ const ticket = outcome.ticketId ? router.getTicket(outcome.ticketId) : void 0;
3250
+ const denialReason = outcome.status === "denied" && outcome.reason ? outcome.reason : void 0;
3251
+ const approvalContext = outcome.ticketId && (denialReason || ticket?.escalated_at) ? {
3252
+ ticket_id: outcome.ticketId,
3253
+ ...denialReason ? { denial_reason: denialReason } : {},
3254
+ ...ticket?.escalated_at ? {
3255
+ escalated_at: ticket.escalated_at,
3256
+ escalated_to: [...ticket.escalated_to ?? []]
3257
+ } : {}
3258
+ } : void 0;
3246
3259
  let result;
3247
3260
  if (outcome.status === "approved" || outcome.status === "break_glass") {
3248
3261
  if (request.signal?.aborted) {
@@ -3271,7 +3284,7 @@ var GovernedForwarder = class {
3271
3284
  result = makeErrorResult(request, POLICY_DENIED, message, { ...feedback });
3272
3285
  }
3273
3286
  }
3274
- return { result, outcome, approvalWaitMs };
3287
+ return { result, outcome, approvalWaitMs, approvalContext };
3275
3288
  }
3276
3289
  async handleRateLimit(request, decision, toolName) {
3277
3290
  const limiter = this.rateLimiter;
@@ -3451,7 +3464,7 @@ var GovernedForwarder = class {
3451
3464
  wasForwardedUpstream(decision, approvalOutcome, rateLimitResult, spendLimitResult) {
3452
3465
  return decision.action === "allow" || approvalOutcome?.status === "approved" || approvalOutcome?.status === "break_glass" || approvalOutcome?.status === "timeout" && this.approvalRouter?.defaultOnTimeout === "allow" || rateLimitResult?.allowed === true || spendLimitResult?.allowed === true;
3453
3466
  }
3454
- writeAuditRecord(request, timestamp, toolName, toolArguments, decision, result, totalDurationMs, approvalWaitMs, flaggedDestructive, evidenceResult, dependencyResult, evidenceBlocked, approvalOutcome, rateLimitResult, spendLimitResult, isDryRun, forwardingError, drift) {
3467
+ writeAuditRecord(request, timestamp, toolName, toolArguments, decision, result, totalDurationMs, approvalWaitMs, flaggedDestructive, evidenceResult, dependencyResult, evidenceBlocked, approvalOutcome, approvalContext, rateLimitResult, spendLimitResult, isDryRun, forwardingError, drift) {
3455
3468
  if (!this.auditWriter) return;
3456
3469
  const wasForwarded = this.wasForwardedUpstream(
3457
3470
  decision,
@@ -3486,6 +3499,12 @@ var GovernedForwarder = class {
3486
3499
  }
3487
3500
  };
3488
3501
  }
3502
+ if (approvalContext) {
3503
+ evidenceChain = {
3504
+ ...evidenceChain ?? {},
3505
+ approval: { ...approvalContext }
3506
+ };
3507
+ }
3489
3508
  if (rateLimitResult) {
3490
3509
  evidenceChain = {
3491
3510
  ...evidenceChain ?? {},
@@ -4536,6 +4555,7 @@ var EvidenceStore = class _EvidenceStore {
4536
4555
  // src/evidence/api.ts
4537
4556
  import { Hono as Hono5 } from "hono";
4538
4557
  import { bodyLimit } from "hono/body-limit";
4558
+ import { HTTPException } from "hono/http-exception";
4539
4559
  import { z as z5 } from "zod";
4540
4560
 
4541
4561
  // src/auth/bearer.ts
@@ -4736,6 +4756,11 @@ function createSidebandApp(store, options = {}) {
4736
4756
  const app = new Hono5();
4737
4757
  const sdkToken = options.token && options.token.length > 0 ? options.token : void 0;
4738
4758
  const adapterToken = options.adapterToken && options.adapterToken.length > 0 ? options.adapterToken : void 0;
4759
+ app.onError((err, c) => {
4760
+ if (err instanceof HTTPException) return err.getResponse();
4761
+ console.error("[helio] Unhandled sideband API error:", err);
4762
+ return c.json({ error: "Internal server error" }, 500);
4763
+ });
4739
4764
  app.use("*", async (c, next) => {
4740
4765
  const origin = c.req.header("origin");
4741
4766
  if (origin) {
@@ -4848,6 +4873,7 @@ var MAX_PENDING_BYTES = 64 * 1024 * 1024;
4848
4873
  var MAX_SENDER_KEYS = 5e4;
4849
4874
  var MAX_EVIDENCE_ENTRIES = 16;
4850
4875
  var MAX_EVIDENCE_BYTES = 64 * 1024;
4876
+ var MAX_VERSION_LOG_LINES_PER_ORIGIN = 5;
4851
4877
  var SWEEP_INTERVAL_MS2 = 3e4;
4852
4878
  var GovernanceService = class {
4853
4879
  policy;
@@ -4865,6 +4891,13 @@ var GovernanceService = class {
4865
4891
  maxSenderKeys;
4866
4892
  /** Distinct sender_id limit keys with live state (reservation registry, issue #13). */
4867
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();
4868
4901
  pending = /* @__PURE__ */ new Map();
4869
4902
  tombstones = /* @__PURE__ */ new Map();
4870
4903
  caches = /* @__PURE__ */ new Map();
@@ -4922,6 +4955,7 @@ var GovernanceService = class {
4922
4955
  return { status: 503, body: { error: "evaluation_backlog_full" } };
4923
4956
  }
4924
4957
  const cache = this.cacheFor(req.origin);
4958
+ this.recordAdapterSeen(req.origin, req.adapter_version);
4925
4959
  const toolName = req.tool.name;
4926
4960
  const hasDefinition = definitionProvided(req.tool);
4927
4961
  if (hasDefinition) {
@@ -5113,6 +5147,7 @@ var GovernanceService = class {
5113
5147
  }
5114
5148
  let approvalStatus = null;
5115
5149
  let approvedBy = null;
5150
+ let approvalContext;
5116
5151
  if (entry.approvalTicketId) {
5117
5152
  const ticket = this.getTicketStatus(entry.approvalTicketId);
5118
5153
  const status = ticket?.status;
@@ -5121,6 +5156,16 @@ var GovernanceService = class {
5121
5156
  }
5122
5157
  approvalStatus = status;
5123
5158
  approvedBy = ticket.resolved_by ?? null;
5159
+ if (ticket.denial_reason || ticket.escalated_at) {
5160
+ approvalContext = {
5161
+ ticket_id: entry.approvalTicketId,
5162
+ ...ticket.denial_reason ? { denial_reason: ticket.denial_reason } : {},
5163
+ ...ticket.escalated_at ? {
5164
+ escalated_at: ticket.escalated_at,
5165
+ escalated_to: [...ticket.escalated_to ?? []]
5166
+ } : {}
5167
+ };
5168
+ }
5124
5169
  }
5125
5170
  if (req.actual_amount !== void 0) {
5126
5171
  if (!Number.isFinite(req.actual_amount) || req.actual_amount < 0) {
@@ -5157,6 +5202,7 @@ var GovernanceService = class {
5157
5202
  limitsChain,
5158
5203
  approvalStatus,
5159
5204
  approvedBy,
5205
+ approvalContext,
5160
5206
  upstreamError: req.status === "error" ? req.error ?? "tool call failed" : null,
5161
5207
  upstreamResponse: req.result ?? null,
5162
5208
  upstreamLatencyMs: req.duration_ms ?? null
@@ -5168,6 +5214,7 @@ var GovernanceService = class {
5168
5214
  finalizedBy: "audit",
5169
5215
  expiresAtMs: this.now() + this.ttlMs
5170
5216
  });
5217
+ this.touchAdapter(entry.origin);
5171
5218
  const body = { ok: true, audit_record_id: auditId };
5172
5219
  if (evidenceOutcomes) body["evidence"] = evidenceOutcomes;
5173
5220
  return { status: 201, body };
@@ -5233,6 +5280,7 @@ var GovernanceService = class {
5233
5280
  if (reserved) {
5234
5281
  return { status: 400, body: { error: "reserved_metadata_key", key: reserved } };
5235
5282
  }
5283
+ this.touchAdapter(req.origin);
5236
5284
  const evaluationId = randomUUID2();
5237
5285
  const toolName = `install:${req.package.source ?? "pkg"}:${req.package.name}`;
5238
5286
  const verdict = this.evaluateInstall(req);
@@ -5297,6 +5345,65 @@ var GovernanceService = class {
5297
5345
  };
5298
5346
  }
5299
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
+ // -------------------------------------------------------------------------
5300
5407
  // POST /approval/:id/resolve
5301
5408
  // -------------------------------------------------------------------------
5302
5409
  resolveApproval(ticketId, req) {
@@ -5398,6 +5505,7 @@ var GovernanceService = class {
5398
5505
  this.tombstones.clear();
5399
5506
  this.caches.clear();
5400
5507
  this.senderKeys.clear();
5508
+ this.adapters.clear();
5401
5509
  this.pendingBytes = 0;
5402
5510
  }
5403
5511
  // -------------------------------------------------------------------------
@@ -5562,6 +5670,9 @@ var GovernanceService = class {
5562
5670
  if (args.sidebandUnreported) {
5563
5671
  evidenceChain = { ...evidenceChain ?? {}, sideband: { unreported: true } };
5564
5672
  }
5673
+ if (args.approvalContext) {
5674
+ evidenceChain = { ...evidenceChain ?? {}, approval: { ...args.approvalContext } };
5675
+ }
5565
5676
  const record = {
5566
5677
  timestamp: args.timestampIso,
5567
5678
  session_id: args.sessionId,
@@ -5748,6 +5859,8 @@ function clampInt(value, fallback, min, max) {
5748
5859
 
5749
5860
  // src/audit/store.ts
5750
5861
  var DRIFT_EVENT_DECISIONS_SQL = "('tool_drift', 'tool_drift_reverted')";
5862
+ var EXPORT_MAX_RECORDS = 1e4;
5863
+ var LIST_MAX_PAGE_SIZE = 1e3;
5751
5864
  var CREATE_TABLE_DDL = `
5752
5865
  CREATE TABLE IF NOT EXISTS audit_records (
5753
5866
  id TEXT PRIMARY KEY,
@@ -6051,15 +6164,28 @@ var AuditStore = class {
6051
6164
  const row = this.db.prepare("SELECT * FROM audit_records WHERE id = ?").get(id);
6052
6165
  return row ? deserializeRow(row) : void 0;
6053
6166
  }
6054
- /** Query records with filters and pagination. */
6167
+ /** Query records with filters and pagination. Capped at 1,000 per page. */
6055
6168
  list(filters = {}, pagination = {}) {
6056
- const { clause, params } = buildWhereClause(filters);
6057
- const limit = clamp(pagination.limit ?? 50, 1, 1e3);
6169
+ const limit = clamp(pagination.limit ?? 50, 1, LIST_MAX_PAGE_SIZE);
6058
6170
  const offset = Math.max(pagination.offset ?? 0, 0);
6059
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);
6060
6186
  const { total } = this.db.prepare(`SELECT COUNT(*) as total FROM audit_records ${clause}`).get(...params);
6061
6187
  const rows = this.db.prepare(
6062
- `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 ?`
6063
6189
  ).all(...params, limit, offset);
6064
6190
  return {
6065
6191
  records: rows.map(deserializeRow),
@@ -7231,6 +7357,7 @@ import { readFileSync } from "fs";
7231
7357
  import { join } from "path";
7232
7358
  import { randomUUID as randomUUID6 } from "crypto";
7233
7359
  import { Hono as Hono8 } from "hono";
7360
+ import { HTTPException as HTTPException2 } from "hono/http-exception";
7234
7361
  import { z as z8 } from "zod";
7235
7362
  import { cors } from "hono/cors";
7236
7363
  import { serveStatic } from "@hono/node-server/serve-static";
@@ -7261,7 +7388,10 @@ var CSV_HEADERS = [
7261
7388
  "dry_run",
7262
7389
  "created_at",
7263
7390
  "environment",
7264
- "matched_rule_index"
7391
+ "matched_rule_index",
7392
+ "record_kind",
7393
+ "origin",
7394
+ "metadata"
7265
7395
  ];
7266
7396
  var FORMULA_PREFIXES = /^[=+\-@\t\r]/;
7267
7397
  function csvEscape(value) {
@@ -7407,7 +7537,7 @@ var feedQuerySchema = z8.object({
7407
7537
  });
7408
7538
  var auditExportQuerySchema = z8.object({
7409
7539
  format: z8.preprocess((value) => value === "csv" ? "csv" : "json", z8.enum(["json", "csv"])),
7410
- limit: clampedQueryInt(1e4, 1, 1e4),
7540
+ limit: clampedQueryInt(EXPORT_MAX_RECORDS, 1, EXPORT_MAX_RECORDS),
7411
7541
  tool: optionalQueryString,
7412
7542
  decision: optionalQueryString,
7413
7543
  reason: optionalQueryString,
@@ -7425,7 +7555,7 @@ var auditExportQuerySchema = z8.object({
7425
7555
  sender_id: optionalQueryString
7426
7556
  });
7427
7557
  var auditQuerySchema = z8.object({
7428
- limit: clampedQueryInt(50, 1, 1e3),
7558
+ limit: clampedQueryInt(50, 1, LIST_MAX_PAGE_SIZE),
7429
7559
  offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER),
7430
7560
  tool: optionalQueryString,
7431
7561
  decision: optionalQueryString,
@@ -7497,6 +7627,16 @@ function shouldSetSecureCookie(url, xForwardedProto) {
7497
7627
  if (xForwardedProto?.toLowerCase() === "https") return true;
7498
7628
  return new URL(url).protocol === "https:";
7499
7629
  }
7630
+ function isPrivateIpv4(host) {
7631
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
7632
+ if (!match) return false;
7633
+ const a = Number(match[1]);
7634
+ const b = Number(match[2]);
7635
+ const c = Number(match[3]);
7636
+ const d = Number(match[4]);
7637
+ if (a > 255 || b > 255 || c > 255 || d > 255) return false;
7638
+ return a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168;
7639
+ }
7500
7640
  function createDashboardAppWithLifecycle(deps, options) {
7501
7641
  const {
7502
7642
  auditStore,
@@ -7505,11 +7645,17 @@ function createDashboardAppWithLifecycle(deps, options) {
7505
7645
  rateLimiter,
7506
7646
  spendLimiter,
7507
7647
  evidenceStore,
7508
- eventBus
7648
+ eventBus,
7649
+ adapterLiveness
7509
7650
  } = deps;
7510
7651
  const apiSecret = options?.apiSecret;
7511
7652
  const sessionStore = apiSecret ? new DashboardSessionStore({ secret: apiSecret, ttlMs: SESSION_TTL_MS }) : void 0;
7512
7653
  const app = new Hono8();
7654
+ app.onError((err, c) => {
7655
+ if (err instanceof HTTPException2) return err.getResponse();
7656
+ console.error("[helio] Unhandled dashboard API error:", err);
7657
+ return c.json({ error: "Internal server error" }, 500);
7658
+ });
7513
7659
  app.use(
7514
7660
  "*",
7515
7661
  cors({
@@ -7519,7 +7665,7 @@ function createDashboardAppWithLifecycle(deps, options) {
7519
7665
  const url = new URL(origin);
7520
7666
  const h = url.hostname;
7521
7667
  if (h === "localhost" || h === "127.0.0.1" || h === "0.0.0.0") return origin;
7522
- if (/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(h)) return origin;
7668
+ if (isPrivateIpv4(h)) return origin;
7523
7669
  } catch {
7524
7670
  }
7525
7671
  return null;
@@ -7655,7 +7801,7 @@ function createDashboardAppWithLifecycle(deps, options) {
7655
7801
  channel_id: query.channel_id,
7656
7802
  sender_id: query.sender_id
7657
7803
  };
7658
- const result = auditStore.list(filters, { limit, order: "asc" });
7804
+ const result = auditStore.listForExport(filters, limit);
7659
7805
  if (format === "csv") {
7660
7806
  const csv = recordsToCsv(result.records);
7661
7807
  return new Response(csv, {
@@ -7719,6 +7865,9 @@ function createDashboardAppWithLifecycle(deps, options) {
7719
7865
  spend_limits: spendLimiter.listKeyStates()
7720
7866
  });
7721
7867
  });
7868
+ app.get("/api/adapters", (c) => {
7869
+ return c.json({ adapters: adapterLiveness?.listAdapters() ?? [] });
7870
+ });
7722
7871
  app.get("/api/analytics", (c) => {
7723
7872
  const query = analyticsQuerySchema.parse(c.req.query());
7724
7873
  const now = /* @__PURE__ */ new Date();
@@ -7889,10 +8038,12 @@ export {
7889
8038
  AuditWriter,
7890
8039
  ConfigError,
7891
8040
  DashboardEventBus,
8041
+ EXPORT_MAX_RECORDS,
7892
8042
  EvidenceStore,
7893
8043
  GovernanceConfigError,
7894
8044
  GovernanceService,
7895
8045
  GovernedForwarder,
8046
+ LIST_MAX_PAGE_SIZE,
7896
8047
  PolicyParseError,
7897
8048
  QueueChannel,
7898
8049
  RateLimiter,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethelio/proxy",
3
- "version": "0.7.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",