@gethelio/proxy 0.12.0 → 0.13.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
@@ -45,6 +45,11 @@ var RESERVED_TRANSPORT_HEADERS = /* @__PURE__ */ new Set([
45
45
  "content-type",
46
46
  "content-length",
47
47
  "host",
48
+ // The Accept is Helio-owned per HTTP upstream leg: where Helio
49
+ // advertises at all it advertises its own response parsing (the SSE
50
+ // message POSTs assert none), so an operator value could only
51
+ // misadvertise it, never extend it (issue #304).
52
+ "accept",
48
53
  // Modern (2026-07-28) transport headers Helio owns on the wire for every
49
54
  // Streamable HTTP POST it sends upstream — relayed client traffic and
50
55
  // proxy-initiated requests (era probe, revalidation) alike — see
@@ -54,8 +59,8 @@ var RESERVED_TRANSPORT_HEADERS = /* @__PURE__ */ new Set([
54
59
  ]);
55
60
  var transportSchema = z.enum(["streamable-http", "sse", "stdio"]);
56
61
  var protocolVersionSchema = z.enum(["auto", "2025-06-18", "2026-07-28"]);
57
- var upstreamSchema = z.object({
58
- url: z.string(),
62
+ var upstreamObjectSchema = z.object({
63
+ url: z.string().optional(),
59
64
  transport: transportSchema.default("streamable-http"),
60
65
  protocol_version: protocolVersionSchema.default("auto"),
61
66
  command: z.string().optional(),
@@ -64,10 +69,22 @@ var upstreamSchema = z.object({
64
69
  request_timeout: durationSchema.default("30s"),
65
70
  forward_headers: z.array(z.string().min(1)).default([]),
66
71
  headers: z.record(z.string(), z.string()).default({})
67
- }).strict().refine((data) => data.transport !== "stdio" || data.command !== void 0, {
68
- message: '"command" is required when transport is "stdio"',
69
- path: ["command"]
70
- }).superRefine((data, ctx) => {
72
+ }).strict();
73
+ function upstreamEntryChecks(data, ctx) {
74
+ if (data.transport === "stdio" && data.command === void 0) {
75
+ ctx.addIssue({
76
+ code: "custom",
77
+ path: ["command"],
78
+ message: '"command" is required when transport is "stdio"'
79
+ });
80
+ }
81
+ if (data.transport !== "stdio" && data.url === void 0) {
82
+ ctx.addIssue({
83
+ code: "custom",
84
+ path: ["url"],
85
+ message: `"url" is required when transport is "${data.transport}"`
86
+ });
87
+ }
71
88
  if (data.protocol_version === "2026-07-28" && data.transport !== "streamable-http") {
72
89
  ctx.addIssue({
73
90
  code: "custom",
@@ -93,6 +110,26 @@ var upstreamSchema = z.object({
93
110
  });
94
111
  }
95
112
  }
113
+ }
114
+ var upstreamSchema = upstreamObjectSchema.superRefine(upstreamEntryChecks);
115
+ var upstreamNameSchema = z.string().min(1).max(64).regex(/^[a-zA-Z0-9_-]+$/, {
116
+ message: 'Upstream names may only contain letters, digits, "_" and "-"'
117
+ });
118
+ var namedUpstreamEntrySchema = z.object({ name: upstreamNameSchema, ...upstreamObjectSchema.shape }).strict().superRefine(upstreamEntryChecks);
119
+ var upstreamsListSchema = z.array(namedUpstreamEntrySchema).min(1, {
120
+ message: 'upstreams: must declare at least one upstream \u2014 an empty list would serve nothing. For a single upstream you can keep the "upstream:" form.'
121
+ }).superRefine((entries, ctx) => {
122
+ const seen = /* @__PURE__ */ new Set();
123
+ for (const [index, entry] of entries.entries()) {
124
+ if (seen.has(entry.name)) {
125
+ ctx.addIssue({
126
+ code: "custom",
127
+ path: [index, "name"],
128
+ message: `Duplicate upstream name "${entry.name}". Upstream names embed in mount paths, limiter keys, and audit records \u2014 each upstream needs its own.`
129
+ });
130
+ }
131
+ seen.add(entry.name);
132
+ }
96
133
  });
97
134
  var listenSchema = z.object({
98
135
  port: z.number().int().min(1).max(65535).default(3e3),
@@ -251,7 +288,11 @@ var matchSchema = z.object({
251
288
  annotations: annotationsMatchSchema.optional(),
252
289
  input: z.record(z.string(), inputConditionSchema).optional(),
253
290
  environment: z.string().optional(),
254
- metadata: z.record(z.string(), metadataConditionSchema).optional()
291
+ metadata: z.record(z.string(), metadataConditionSchema).optional(),
292
+ /** Configured upstream names the rule is scoped to (issue #293). */
293
+ upstreams: z.array(z.string().min(1)).min(1, {
294
+ message: "match.upstreams must name at least one upstream \u2014 an empty list matches nothing."
295
+ }).optional()
255
296
  }).strict();
256
297
  var policyActionSchema = z.enum([
257
298
  "allow",
@@ -374,7 +415,11 @@ var budgetContributorMatchSchema = z.object({
374
415
  // Same operators and AND-combination as rule `match.input`. Other rule
375
416
  // matchers (annotations, environment, metadata) stay strict-rejected
376
417
  // until the budget charge context can actually evaluate them.
377
- input: z.record(z.string(), inputConditionSchema).optional()
418
+ input: z.record(z.string(), inputConditionSchema).optional(),
419
+ /** Configured upstream names the contributor is scoped to (issue #293). */
420
+ upstreams: z.array(z.string().min(1)).min(1, {
421
+ message: "match.upstreams must name at least one upstream \u2014 an empty list matches nothing."
422
+ }).optional()
378
423
  }).strict();
379
424
  var modernBudgetContributorSchema = z.object({
380
425
  match: budgetContributorMatchSchema,
@@ -486,9 +531,7 @@ var sdkSchema = z.object({
486
531
  */
487
532
  evaluation_ttl: durationSchema.default("10m")
488
533
  }).strict();
489
- var helioConfigBaseSchema = z.object({
490
- version: z.literal("1"),
491
- upstream: upstreamSchema,
534
+ var rootSectionSchemas = {
492
535
  listen: listenSchema.prefault({}),
493
536
  environment: z.string().optional(),
494
537
  // Session precedes policies deliberately: upstream/listen/environment say
@@ -505,6 +548,16 @@ var helioConfigBaseSchema = z.object({
505
548
  // the request path (canonical section order, #89/#163).
506
549
  dashboard: dashboardSchema.prefault({}),
507
550
  sdk: sdkSchema.prefault({})
551
+ };
552
+ var singularConfigBase = z.object({
553
+ version: z.literal("1"),
554
+ upstream: upstreamSchema,
555
+ ...rootSectionSchemas
556
+ }).strict();
557
+ var namedConfigBase = z.object({
558
+ version: z.literal("1"),
559
+ upstreams: upstreamsListSchema,
560
+ ...rootSectionSchemas
508
561
  }).strict();
509
562
  function stripRootExtensionKeys(value) {
510
563
  if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
@@ -512,7 +565,7 @@ function stripRootExtensionKeys(value) {
512
565
  Object.entries(value).filter(([key]) => !key.startsWith("x-"))
513
566
  );
514
567
  }
515
- var helioConfigRefinedSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
568
+ function rootConfigChecks(cfg, ctx) {
516
569
  const hasConfiguredEnvironment = typeof cfg.environment === "string" && cfg.environment.trim().length > 0;
517
570
  const requiresSecret = cfg.policies.flag_destructive === "require_approval" || cfg.policies.on_tool_drift === "require_approval" || cfg.policies.rules.some((rule) => rule.action === "require_approval") || cfg.budgets.some((budget) => budget.on_exceed === "require_approval");
518
571
  const hasSecret = hasDashboardApiSecret(cfg.dashboard.api_secret);
@@ -732,8 +785,166 @@ var helioConfigRefinedSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
732
785
  }
733
786
  }
734
787
  }
788
+ }
789
+ function upstreamVocabularyChecks(cfg, ctx, configuredNames) {
790
+ for (const [ruleIndex, rule] of cfg.policies.rules.entries()) {
791
+ const upstreams = rule.match.upstreams;
792
+ if (upstreams === void 0) continue;
793
+ if (configuredNames === null) {
794
+ ctx.addIssue({
795
+ code: "custom",
796
+ path: ["policies", "rules", ruleIndex, "match", "upstreams"],
797
+ message: 'Rule sets match.upstreams but the config declares a single "upstream:", which has no name on purpose. Upstream-scoped rules require the named "upstreams:" list.'
798
+ });
799
+ } else {
800
+ for (const [entryIndex, name] of upstreams.entries()) {
801
+ if (!configuredNames.has(name)) {
802
+ ctx.addIssue({
803
+ code: "custom",
804
+ path: ["policies", "rules", ruleIndex, "match", "upstreams", entryIndex],
805
+ message: `Rule names upstream "${name}" in match.upstreams but no configured upstream has that name. Every entry must name an upstream from the upstreams: list.`
806
+ });
807
+ }
808
+ }
809
+ }
810
+ if (rule.match.metadata !== void 0) {
811
+ ctx.addIssue({
812
+ code: "custom",
813
+ path: ["policies", "rules", ruleIndex, "match", "upstreams"],
814
+ message: "match.upstreams cannot be combined with match.metadata \u2014 metadata rules only match on the sideband (host) path and upstream-scoped rules only on the MCP path, so the combination can never match. Split it into two rules."
815
+ });
816
+ }
817
+ if (rule.limits?.key === "sender_id") {
818
+ ctx.addIssue({
819
+ code: "custom",
820
+ path: ["policies", "rules", ruleIndex, "limits", "key"],
821
+ message: 'limits.key "sender_id" cannot be combined with match.upstreams \u2014 an upstream-scoped rule only matches on the MCP path, where sender_id is absent and the key would silently collapse to tool scope.'
822
+ });
823
+ }
824
+ if (rule.limits?.max_spend?.key === "sender_id") {
825
+ ctx.addIssue({
826
+ code: "custom",
827
+ path: ["policies", "rules", ruleIndex, "limits", "max_spend", "key"],
828
+ message: 'limits.max_spend.key "sender_id" cannot be combined with match.upstreams \u2014 an upstream-scoped rule only matches on the MCP path, where sender_id is absent and the key would silently collapse to tool scope.'
829
+ });
830
+ }
831
+ }
832
+ for (const [budgetIndex, budget] of cfg.budgets.entries()) {
833
+ let hasUnscopedContributor = false;
834
+ for (const [contributorIndex, contributor] of budget.contributors.entries()) {
835
+ const upstreams = contributor.match?.upstreams;
836
+ if (upstreams === void 0) {
837
+ hasUnscopedContributor = true;
838
+ continue;
839
+ }
840
+ if (configuredNames === null) {
841
+ ctx.addIssue({
842
+ code: "custom",
843
+ path: ["budgets", budgetIndex, "contributors", contributorIndex, "match", "upstreams"],
844
+ message: 'Contributor sets match.upstreams but the config declares a single "upstream:", which has no name on purpose. Upstream-scoped contributors require the named "upstreams:" list.'
845
+ });
846
+ } else {
847
+ for (const [entryIndex, name] of upstreams.entries()) {
848
+ if (!configuredNames.has(name)) {
849
+ ctx.addIssue({
850
+ code: "custom",
851
+ path: [
852
+ "budgets",
853
+ budgetIndex,
854
+ "contributors",
855
+ contributorIndex,
856
+ "match",
857
+ "upstreams",
858
+ entryIndex
859
+ ],
860
+ message: `Contributor names upstream "${name}" in match.upstreams but no configured upstream has that name. Every entry must name an upstream from the upstreams: list.`
861
+ });
862
+ }
863
+ }
864
+ }
865
+ }
866
+ if (budget.key === "sender_id" && !hasUnscopedContributor) {
867
+ ctx.addIssue({
868
+ code: "custom",
869
+ path: ["budgets", budgetIndex, "key"],
870
+ message: 'budget key "sender_id" requires at least one contributor without an "upstreams" scope \u2014 upstream-scoped contributors only match MCP calls, which never carry a sender, so every charge would land in the shared "unknown" pot while sideband calls (the only ones with real senders) never feed this budget.'
871
+ });
872
+ }
873
+ }
874
+ if (configuredNames !== null) {
875
+ const hasEvidenceGatedRule = cfg.policies.rules.some(
876
+ (rule) => (rule.evidence?.requires.length ?? 0) > 0 || (rule.requires?.length ?? 0) > 0
877
+ );
878
+ if (hasEvidenceGatedRule) {
879
+ const legacyIndex = cfg.session.identity.findIndex(
880
+ (source) => source.source === "legacy_header"
881
+ );
882
+ if (legacyIndex !== -1) {
883
+ ctx.addIssue({
884
+ code: "custom",
885
+ path: ["session", "identity", legacyIndex],
886
+ message: `session.identity includes "legacy_header" while named upstreams and evidence-gated rules ("evidence"/"requires") are configured. On the legacy relay flow the Mcp-Session-Id a client echoes was minted by the upstream itself, so with multiple upstreams a hostile server could collide session identities across doors and pollute another door's evidence gates. Remove legacy_header from session.identity and use a caller-owned source such as the default "x-helio-session-id" header.`
887
+ });
888
+ }
889
+ }
890
+ }
891
+ }
892
+ var singularConfigSchema = singularConfigBase.superRefine((cfg, ctx) => {
893
+ rootConfigChecks(cfg, ctx);
894
+ upstreamVocabularyChecks(cfg, ctx, null);
895
+ });
896
+ var namedConfigSchema = namedConfigBase.superRefine((cfg, ctx) => {
897
+ rootConfigChecks(cfg, ctx);
898
+ upstreamVocabularyChecks(cfg, ctx, new Set(cfg.upstreams.map((entry) => entry.name)));
735
899
  });
736
- var helioConfigSchema = z.preprocess(stripRootExtensionKeys, helioConfigRefinedSchema);
900
+ var objectRootSchema = z.object({});
901
+ function dispatchByMode(raw, ctx) {
902
+ const isObject2 = raw !== null && typeof raw === "object" && !Array.isArray(raw);
903
+ if (!isObject2) {
904
+ const typeResult = objectRootSchema.safeParse(raw);
905
+ if (!typeResult.success) {
906
+ for (const issue of typeResult.error.issues) {
907
+ ctx.addIssue(issue);
908
+ }
909
+ }
910
+ return z.NEVER;
911
+ }
912
+ const hasUpstream = "upstream" in raw;
913
+ const hasUpstreams = "upstreams" in raw;
914
+ if (hasUpstream && hasUpstreams) {
915
+ ctx.addIssue({
916
+ code: "custom",
917
+ path: ["upstreams"],
918
+ message: 'Set exactly one of "upstream:" (single upstream) or "upstreams:" (named multi-upstream list) \u2014 not both. To migrate, move the upstream: fields into an upstreams: entry and give it a name.'
919
+ });
920
+ return z.NEVER;
921
+ }
922
+ if (!hasUpstream && !hasUpstreams) {
923
+ ctx.addIssue({
924
+ code: "custom",
925
+ message: 'Missing upstream configuration: set exactly one of "upstream:" (single upstream) or "upstreams:" (named multi-upstream list).'
926
+ });
927
+ return z.NEVER;
928
+ }
929
+ const result = hasUpstreams ? namedConfigSchema.safeParse(raw) : singularConfigSchema.safeParse(raw);
930
+ if (!result.success) {
931
+ for (const issue of result.error.issues) {
932
+ ctx.addIssue(issue);
933
+ }
934
+ return z.NEVER;
935
+ }
936
+ return result.data;
937
+ }
938
+ var helioConfigSchema = z.preprocess(
939
+ stripRootExtensionKeys,
940
+ z.unknown().transform(dispatchByMode)
941
+ );
942
+ function isSingularConfig(config) {
943
+ return !("upstreams" in config);
944
+ }
945
+ function isNamedConfig(config) {
946
+ return "upstreams" in config;
947
+ }
737
948
 
738
949
  // src/config/loader.ts
739
950
  import { readFile } from "fs/promises";
@@ -815,8 +1026,16 @@ import { watch } from "chokidar";
815
1026
  import { isDeepStrictEqual } from "util";
816
1027
  function diffReloadBoundary(previous, next) {
817
1028
  const restartRequiredPaths = [];
818
- if (!isDeepStrictEqual(previous.upstream, next.upstream)) {
819
- restartRequiredPaths.push("upstream");
1029
+ if (isSingularConfig(previous) !== isSingularConfig(next)) {
1030
+ restartRequiredPaths.push("upstream", "upstreams");
1031
+ } else if (isSingularConfig(previous) && isSingularConfig(next)) {
1032
+ if (!isDeepStrictEqual(previous.upstream, next.upstream)) {
1033
+ restartRequiredPaths.push("upstream");
1034
+ }
1035
+ } else if (isNamedConfig(previous) && isNamedConfig(next)) {
1036
+ if (!isDeepStrictEqual(previous.upstreams, next.upstreams)) {
1037
+ restartRequiredPaths.push("upstreams");
1038
+ }
820
1039
  }
821
1040
  if (!isDeepStrictEqual(previous.listen, next.listen)) {
822
1041
  restartRequiredPaths.push("listen");
@@ -1016,7 +1235,8 @@ function compileMatch(match, ruleIndex, ruleName) {
1016
1235
  ...match.environment !== void 0 && { environment: match.environment },
1017
1236
  ...match.metadata !== void 0 && {
1018
1237
  metadata: flattenMetadataConditions(match.metadata, ruleIndex, ruleName)
1019
- }
1238
+ },
1239
+ ...match.upstreams !== void 0 && { upstreams: [...match.upstreams] }
1020
1240
  };
1021
1241
  }
1022
1242
  function compileToolMatcher(pattern, ruleIndex, ruleName) {
@@ -1219,6 +1439,9 @@ function compileContributor(contributor, budgetName, index) {
1219
1439
  ) : void 0;
1220
1440
  return {
1221
1441
  match: { tool, ...input !== void 0 && { input } },
1442
+ ...contributor.match.upstreams !== void 0 && {
1443
+ upstreams: [...contributor.match.upstreams]
1444
+ },
1222
1445
  field: contributor.field
1223
1446
  };
1224
1447
  }
@@ -1555,6 +1778,8 @@ function buildStandardRequestHeaders(method, params) {
1555
1778
  }
1556
1779
 
1557
1780
  // src/upstream/merge-headers.ts
1781
+ var UPSTREAM_POST_ACCEPT = "application/json, text/event-stream";
1782
+ var UPSTREAM_SSE_CONNECT_ACCEPT = "text/event-stream";
1558
1783
  function mergeUpstreamHeaders(base, forwarded, staticHeaders) {
1559
1784
  const out = {};
1560
1785
  const apply = (headers) => {
@@ -1568,6 +1793,11 @@ function mergeUpstreamHeaders(base, forwarded, staticHeaders) {
1568
1793
  return out;
1569
1794
  }
1570
1795
 
1796
+ // src/util/log-label.ts
1797
+ function helioLogTag(upstreamName) {
1798
+ return upstreamName ? `[helio][${upstreamName}]` : "[helio]";
1799
+ }
1800
+
1571
1801
  // src/upstream/connection-error.ts
1572
1802
  var UPSTREAM_DOCS_URL = "https://github.com/gethelio/helio/blob/main/docs/getting-started.md";
1573
1803
  var UNREACHABLE_CODES = /* @__PURE__ */ new Set([
@@ -1603,7 +1833,7 @@ function describeUnreachableUpstream(error, url) {
1603
1833
  }
1604
1834
  const codeSuffix = code ? ` (${code})` : "";
1605
1835
  return new Error(
1606
- `Upstream MCP server at ${url} is unreachable${codeSuffix} \u2014 is it running? Helio proxies an existing MCP server: set upstream.url in helio.yaml to a reachable server, or start the server it points at. See ${UPSTREAM_DOCS_URL}`
1836
+ `Upstream MCP server at ${url} is unreachable${codeSuffix} \u2014 is it running? Helio proxies an existing MCP server: set upstream.url (or upstreams[].url) in helio.yaml to a reachable server, or start the server it points at. See ${UPSTREAM_DOCS_URL}`
1607
1837
  );
1608
1838
  }
1609
1839
 
@@ -1710,11 +1940,13 @@ var UpstreamSessionManager = class {
1710
1940
  inflight;
1711
1941
  inflightProbe;
1712
1942
  probeBackoffUntil = 0;
1943
+ logTag;
1713
1944
  constructor(options) {
1714
1945
  this.url = options.url;
1715
1946
  this.staticHeaders = options.staticHeaders;
1716
1947
  this.requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
1717
1948
  this.pin = options.protocolVersion ?? "auto";
1949
+ this.logTag = helioLogTag(options.upstreamName);
1718
1950
  }
1719
1951
  /** Return the internal session, establishing it once if needed. */
1720
1952
  ensureInternalSession() {
@@ -1846,7 +2078,7 @@ var UpstreamSessionManager = class {
1846
2078
  this.capture = void 0;
1847
2079
  this.probeBackoffUntil = Date.now() + ERA_PROBE_BACKOFF_MS;
1848
2080
  console.error(
1849
- `[helio] Upstream MCP era cleared: ${door}; relays presume legacy and re-probing is throttled for ${String(ERA_PROBE_BACKOFF_MS / 1e3)}s`
2081
+ `${this.logTag} Upstream MCP era cleared: ${door}; relays presume legacy and re-probing is throttled for ${String(ERA_PROBE_BACKOFF_MS / 1e3)}s`
1850
2082
  );
1851
2083
  }
1852
2084
  /** Convert a fetch failure into an actionable error for the given step. */
@@ -1890,7 +2122,7 @@ var UpstreamSessionManager = class {
1890
2122
  if (this.era === era) return;
1891
2123
  this.era = era;
1892
2124
  console.error(
1893
- era === "modern" ? `[helio] Upstream MCP era detected: modern (${HELIO_MCP_MODERN_PROTOCOL_VERSION}, via server/discover)` : "[helio] Upstream MCP era detected: legacy (initialize handshake)"
2125
+ era === "modern" ? `${this.logTag} Upstream MCP era detected: modern (${HELIO_MCP_MODERN_PROTOCOL_VERSION}, via server/discover)` : `${this.logTag} Upstream MCP era detected: legacy (initialize handshake)`
1894
2126
  );
1895
2127
  }
1896
2128
  /** A modern upstream neither mints nor echoes session ids — nothing to hold. */
@@ -1914,7 +2146,7 @@ var UpstreamSessionManager = class {
1914
2146
  const headers = mergeUpstreamHeaders(
1915
2147
  {
1916
2148
  "content-type": "application/json",
1917
- accept: "application/json, text/event-stream",
2149
+ accept: UPSTREAM_POST_ACCEPT,
1918
2150
  "mcp-protocol-version": HELIO_MCP_MODERN_PROTOCOL_VERSION,
1919
2151
  "mcp-method": "server/discover"
1920
2152
  },
@@ -1923,6 +2155,7 @@ var UpstreamSessionManager = class {
1923
2155
  );
1924
2156
  headers["mcp-method"] = "server/discover";
1925
2157
  delete headers["mcp-name"];
2158
+ headers["accept"] = UPSTREAM_POST_ACCEPT;
1926
2159
  const probeBody = {
1927
2160
  jsonrpc: "2.0",
1928
2161
  id: ERA_PROBE_REQUEST_ID,
@@ -1995,13 +2228,14 @@ var UpstreamSessionManager = class {
1995
2228
  const headers = mergeUpstreamHeaders(
1996
2229
  {
1997
2230
  "content-type": "application/json",
1998
- accept: "application/json, text/event-stream"
2231
+ accept: UPSTREAM_POST_ACCEPT
1999
2232
  },
2000
2233
  {},
2001
2234
  this.staticHeaders
2002
2235
  );
2003
2236
  delete headers["mcp-method"];
2004
2237
  delete headers["mcp-name"];
2238
+ headers["accept"] = UPSTREAM_POST_ACCEPT;
2005
2239
  const initBody = {
2006
2240
  jsonrpc: "2.0",
2007
2241
  id: 0,
@@ -2615,6 +2849,7 @@ function createSseRoute(forwarder, options = {}) {
2615
2849
  const forwardHeaderAllowlist = options.forwardHeadersAllowlist ?? [];
2616
2850
  const sessionIdentity = options.session ?? DEFAULT_SESSION_IDENTITY;
2617
2851
  const maxConcurrentSessions = options.maxConcurrentSessions ?? MAX_CONCURRENT_SESSIONS;
2852
+ const routeLabel = options.routeLabel ?? "/sse";
2618
2853
  let refusalCount = 0;
2619
2854
  let lastRefusalLogAt = null;
2620
2855
  const logRefusal = () => {
@@ -2623,7 +2858,7 @@ function createSseRoute(forwarder, options = {}) {
2623
2858
  if (lastRefusalLogAt !== null && now - lastRefusalLogAt < REFUSAL_LOG_WINDOW_MS) return;
2624
2859
  lastRefusalLogAt = now;
2625
2860
  console.error(
2626
- `[helio] /sse at session cap (${String(maxConcurrentSessions)}); refusing new streams (${String(refusalCount)} refusals so far).`
2861
+ `[helio] ${routeLabel} at session cap (${String(maxConcurrentSessions)}); refusing new streams (${String(refusalCount)} refusals so far).`
2627
2862
  );
2628
2863
  };
2629
2864
  app.use("*", createOriginGuard(options.allowedOrigins ?? []));
@@ -2829,6 +3064,11 @@ function createServerHandle(server) {
2829
3064
  };
2830
3065
  }
2831
3066
  function createApp(config, forwarder, options) {
3067
+ if (isNamedConfig(config)) {
3068
+ throw new Error(
3069
+ "createApp serves a single-upstream (upstream:) config only. Named multi-upstream configs are composed by createMultiApp."
3070
+ );
3071
+ }
2832
3072
  const app = new Hono3();
2833
3073
  const forwardHeadersAllowlist = config.upstream.forward_headers;
2834
3074
  const allowedOrigins = config.listen.allowed_origins;
@@ -2849,6 +3089,77 @@ function createApp(config, forwarder, options) {
2849
3089
  }
2850
3090
  return app;
2851
3091
  }
3092
+ function createMultiApp(config, forwarders, options) {
3093
+ if (!isNamedConfig(config)) {
3094
+ throw new Error(
3095
+ "createMultiApp composes a named multi-upstream (upstreams:) config only. Singular configs are served by createApp."
3096
+ );
3097
+ }
3098
+ const doors = [];
3099
+ const missing = [];
3100
+ for (const entry of config.upstreams) {
3101
+ const forwarder = forwarders[entry.name];
3102
+ if (forwarder === void 0) missing.push(entry.name);
3103
+ else doors.push({ entry, forwarder });
3104
+ }
3105
+ const configured = new Set(config.upstreams.map((entry) => entry.name));
3106
+ const unexpected = Object.keys(forwarders).filter((name) => !configured.has(name));
3107
+ if (missing.length > 0 || unexpected.length > 0) {
3108
+ throw new Error(
3109
+ `createMultiApp forwarders must match the configured upstream names exactly \u2014 missing: [${missing.join(", ")}], unexpected: [${unexpected.join(", ")}].`
3110
+ );
3111
+ }
3112
+ const app = new Hono3();
3113
+ const allowedOrigins = config.listen.allowed_origins;
3114
+ const session = compileSessionIdentity(config.session);
3115
+ app.get("/healthz", (c) => c.json({ status: "ok" }));
3116
+ for (const { entry, forwarder } of doors) {
3117
+ const name = entry.name;
3118
+ app.route(
3119
+ `/mcp/${name}`,
3120
+ createStreamableHttpRoute(forwarder, {
3121
+ forwardHeadersAllowlist: entry.forward_headers,
3122
+ allowedOrigins,
3123
+ session,
3124
+ onHeaderMismatch: options?.onHeaderMismatch ? (rejection) => options.onHeaderMismatch?.(rejection, name) : void 0
3125
+ })
3126
+ );
3127
+ app.route(
3128
+ `/sse/${name}`,
3129
+ createSseRoute(forwarder, {
3130
+ forwardHeadersAllowlist: entry.forward_headers,
3131
+ allowedOrigins,
3132
+ session,
3133
+ routeLabel: `/sse/${name}`,
3134
+ maxConcurrentSessions: options?.sse?.maxConcurrentSessions
3135
+ })
3136
+ );
3137
+ }
3138
+ if (options?.slackActionApp) {
3139
+ app.route("/slack/actions", options.slackActionApp);
3140
+ }
3141
+ app.all(
3142
+ "/mcp/*",
3143
+ (c) => c.json(
3144
+ makeJsonRpcErrorWithoutId(
3145
+ INVALID_REQUEST,
3146
+ "No MCP endpoint answers this request: this Helio serves named upstreams at /mcp/<name>."
3147
+ ),
3148
+ 404
3149
+ )
3150
+ );
3151
+ app.all(
3152
+ "/sse/*",
3153
+ (c) => c.json(
3154
+ makeJsonRpcErrorWithoutId(
3155
+ INVALID_REQUEST,
3156
+ "No MCP endpoint answers this request: this Helio serves named upstreams at /sse/<name>."
3157
+ ),
3158
+ 404
3159
+ )
3160
+ );
3161
+ return app;
3162
+ }
2852
3163
  function startServer(app, config) {
2853
3164
  const server = serve({
2854
3165
  fetch: app.fetch,
@@ -2866,6 +3177,14 @@ function startSidebandServer(app, port, host = "127.0.0.1") {
2866
3177
  return createServerHandle(server);
2867
3178
  }
2868
3179
 
3180
+ // src/reload-fanout.ts
3181
+ function applyReloadedPolicy(stacks, newPolicy) {
3182
+ for (const stack of stacks) {
3183
+ stack.governedForwarder.updatePolicy(newPolicy);
3184
+ stack.annotationPrime.reconfigure(newPolicy.toolRevalidation);
3185
+ }
3186
+ }
3187
+
2869
3188
  // src/upstream/response.ts
2870
3189
  async function parseUpstreamResponse(res) {
2871
3190
  const headers = {};
@@ -2902,7 +3221,8 @@ var StreamableHttpForwarder = class {
2902
3221
  url: this.url,
2903
3222
  staticHeaders: this.staticHeaders,
2904
3223
  requestTimeoutMs: this.requestTimeoutMs,
2905
- protocolVersion: options.protocolVersion
3224
+ protocolVersion: options.protocolVersion,
3225
+ upstreamName: options.upstreamName
2906
3226
  });
2907
3227
  }
2908
3228
  /** Lifecycle parity with sse/stdio. No eager connect — sessions are lazy. */
@@ -3075,11 +3395,15 @@ var StreamableHttpForwarder = class {
3075
3395
  const headers = mergeUpstreamHeaders(
3076
3396
  {
3077
3397
  "content-type": "application/json",
3078
- accept: "application/json, text/event-stream"
3398
+ accept: UPSTREAM_POST_ACCEPT
3079
3399
  },
3080
3400
  request.headers ?? {},
3081
3401
  this.staticHeaders
3082
3402
  );
3403
+ headers["content-type"] = "application/json";
3404
+ delete headers["content-length"];
3405
+ headers["accept"] = UPSTREAM_POST_ACCEPT;
3406
+ delete headers["mcp-session-id"];
3083
3407
  if (session.sessionId) headers["mcp-session-id"] = session.sessionId;
3084
3408
  if (modern) {
3085
3409
  delete headers["mcp-session-id"];
@@ -3250,13 +3574,16 @@ var SseUpstreamForwarder = class {
3250
3574
  connect() {
3251
3575
  const controller = new AbortController();
3252
3576
  this.abortController = controller;
3577
+ const headers = mergeUpstreamHeaders(
3578
+ { accept: UPSTREAM_SSE_CONNECT_ACCEPT },
3579
+ {},
3580
+ this.staticHeaders
3581
+ );
3582
+ headers["accept"] = UPSTREAM_SSE_CONNECT_ACCEPT;
3253
3583
  return new Promise((resolve2, reject) => {
3254
3584
  let resolved = false;
3255
3585
  fetch(this.url, {
3256
- headers: {
3257
- accept: "text/event-stream",
3258
- ...this.staticHeaders
3259
- },
3586
+ headers,
3260
3587
  signal: AbortSignal.any([controller.signal, AbortSignal.timeout(this.connectTimeoutMs)])
3261
3588
  }).then((res) => {
3262
3589
  if (!res.ok) {
@@ -3308,9 +3635,12 @@ var SseUpstreamForwarder = class {
3308
3635
  request.headers ?? {},
3309
3636
  this.staticHeaders
3310
3637
  );
3638
+ headers["content-type"] = "application/json";
3639
+ delete headers["content-length"];
3311
3640
  delete headers["mcp-method"];
3312
3641
  delete headers["mcp-name"];
3313
3642
  delete headers["mcp-session-id"];
3643
+ delete headers["accept"];
3314
3644
  if (request.transportSessionId) {
3315
3645
  headers["mcp-session-id"] = request.transportSessionId;
3316
3646
  }
@@ -3477,6 +3807,7 @@ var StdioForwarder = class {
3477
3807
  maxRetries;
3478
3808
  retryDelayMs;
3479
3809
  pending;
3810
+ logTag;
3480
3811
  child = null;
3481
3812
  buffer = "";
3482
3813
  retryCount = 0;
@@ -3488,6 +3819,7 @@ var StdioForwarder = class {
3488
3819
  this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
3489
3820
  this.retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
3490
3821
  this.pending = new PendingRequests(options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS2);
3822
+ this.logTag = helioLogTag(options.upstreamName);
3491
3823
  }
3492
3824
  /** Spawn the child process and set up event handlers. */
3493
3825
  start() {
@@ -3616,25 +3948,28 @@ var StdioForwarder = class {
3616
3948
  }, this.retryDelayMs);
3617
3949
  } else {
3618
3950
  this.dead = true;
3619
- console.error(`[helio] Stdio forwarder: max retries (${String(this.maxRetries)}) exceeded`);
3951
+ console.error(
3952
+ `${this.logTag} Stdio forwarder: max retries (${String(this.maxRetries)}) exceeded`
3953
+ );
3620
3954
  this.pending.rejectAll(new Error("stdio forwarder is dead (max retries exceeded)"));
3621
3955
  }
3622
3956
  }
3623
3957
  };
3624
3958
 
3625
3959
  // src/cli-forwarder.ts
3626
- async function createForwarderFromConfig(config) {
3960
+ async function createForwarderFromConfig(config, upstreamName) {
3627
3961
  switch (config.upstream.transport) {
3628
3962
  case "streamable-http": {
3629
3963
  const http = new StreamableHttpForwarder({
3630
3964
  url: config.upstream.url,
3631
3965
  headers: config.upstream.headers,
3632
3966
  requestTimeoutMs: parseDuration(config.upstream.request_timeout),
3633
- protocolVersion: config.upstream.protocol_version
3967
+ protocolVersion: config.upstream.protocol_version,
3968
+ upstreamName
3634
3969
  });
3635
3970
  if (config.upstream.protocol_version !== "auto") {
3636
3971
  console.error(
3637
- `[helio] Upstream MCP protocol version pinned: ${config.upstream.protocol_version} (upstream.protocol_version)`
3972
+ `${helioLogTag(upstreamName)} Upstream MCP protocol version pinned: ${config.upstream.protocol_version} (upstream.protocol_version)`
3638
3973
  );
3639
3974
  }
3640
3975
  await http.connect();
@@ -3654,7 +3989,8 @@ async function createForwarderFromConfig(config) {
3654
3989
  const stdio = new StdioForwarder({
3655
3990
  command: config.upstream.command,
3656
3991
  args: config.upstream.args,
3657
- requestTimeoutMs: parseDuration(config.upstream.request_timeout)
3992
+ requestTimeoutMs: parseDuration(config.upstream.request_timeout),
3993
+ upstreamName
3658
3994
  });
3659
3995
  await stdio.start();
3660
3996
  return { forwarder: stdio, close: () => stdio.close() };
@@ -3731,6 +4067,10 @@ function matchEnvironment(required, ctx) {
3731
4067
  if (ctx.environment === void 0) return false;
3732
4068
  return ctx.environment === required;
3733
4069
  }
4070
+ function matchUpstreams(required, ctx) {
4071
+ if (ctx.upstream === void 0) return false;
4072
+ return required.includes(ctx.upstream);
4073
+ }
3734
4074
  function matchMetadata(conditions, ctx) {
3735
4075
  if (conditions.length === 0) return true;
3736
4076
  if (ctx.metadata === void 0) return false;
@@ -3756,6 +4096,7 @@ function matchRule(rule, ctx) {
3756
4096
  if (match.input !== void 0 && !matchInput(match.input, ctx)) return false;
3757
4097
  if (match.environment !== void 0 && !matchEnvironment(match.environment, ctx)) return false;
3758
4098
  if (match.metadata !== void 0 && !matchMetadata(match.metadata, ctx)) return false;
4099
+ if (match.upstreams !== void 0 && !matchUpstreams(match.upstreams, ctx)) return false;
3759
4100
  return true;
3760
4101
  }
3761
4102
 
@@ -3910,7 +4251,8 @@ function decide(input) {
3910
4251
  annotations,
3911
4252
  toolArguments,
3912
4253
  environment,
3913
- metadata
4254
+ metadata,
4255
+ upstream: input.upstream
3914
4256
  });
3915
4257
  if (driftEvent && driftMode === "log") {
3916
4258
  const currentDecision = evaluatePolicy(policy, {
@@ -3918,7 +4260,8 @@ function decide(input) {
3918
4260
  annotations: input.currentAnnotations,
3919
4261
  toolArguments,
3920
4262
  environment,
3921
- metadata
4263
+ metadata,
4264
+ upstream: input.upstream
3922
4265
  });
3923
4266
  decision = stricterDecision(decision, currentDecision);
3924
4267
  }
@@ -4531,394 +4874,88 @@ function buildBudgetApprovalTimeoutFeedback(decision, breaches, timeoutMs) {
4531
4874
  };
4532
4875
  }
4533
4876
 
4534
- // src/policy/spend-limiter.ts
4535
- function spendBucketKey(baseKey, ruleIndex) {
4877
+ // src/policy/bucket-key.ts
4878
+ function ruleBucketKey(baseKey, ruleIndex) {
4536
4879
  return `${baseKey}:rule:${String(ruleIndex)}`;
4537
4880
  }
4538
4881
  var RULE_SUFFIX_RE = /:rule:(\d+)$/;
4539
- var SpendLimiter = class {
4540
- buckets = /* @__PURE__ */ new Map();
4541
- now;
4542
- onWarning;
4543
- warningThreshold;
4544
- timer = null;
4545
- closed = false;
4546
- constructor(options = {}) {
4547
- this.now = options.now ?? Date.now;
4548
- this.onWarning = options.onWarning;
4549
- this.warningThreshold = options.warningThreshold ?? 0.8;
4550
- const intervalMs = options.cleanupIntervalMs ?? 6e4;
4551
- if (intervalMs > 0) {
4552
- this.timer = setInterval(() => {
4553
- this.cleanup();
4554
- }, intervalMs);
4555
- this.timer.unref();
4882
+ function parseRuleIndex(key) {
4883
+ const match = RULE_SUFFIX_RE.exec(key);
4884
+ return match ? Number(match[1]) : void 0;
4885
+ }
4886
+ function toolLimitKey(toolName, upstreamName) {
4887
+ return upstreamName ? `upstream:${upstreamName}:tool:${toolName}` : `tool:${toolName}`;
4888
+ }
4889
+ var UPSTREAM_PREFIX_RE = /^upstream:([^:]+):/;
4890
+ function upstreamFromLimitKey(key) {
4891
+ const match = UPSTREAM_PREFIX_RE.exec(key);
4892
+ return match?.[1] ?? null;
4893
+ }
4894
+
4895
+ // src/policy/governed-forwarder.ts
4896
+ var POLICY_DENIED = -32001;
4897
+ function blocked(result) {
4898
+ return { proceed: false, result, approvalWaitMs: 0 };
4899
+ }
4900
+ function budgetChainBlock(entry, kind) {
4901
+ return {
4902
+ name: entry.budget.name,
4903
+ bucket_key: entry.bucketKey,
4904
+ allowed: entry.allowed,
4905
+ amount: entry.amount,
4906
+ spent: entry.spent,
4907
+ limit: entry.budget.limit,
4908
+ remaining: entry.remaining,
4909
+ currency: entry.budget.currency,
4910
+ ...kind ? { kind } : {},
4911
+ ...entry.stale ? { stale: true } : {}
4912
+ };
4913
+ }
4914
+ var GovernedForwarder = class {
4915
+ inner;
4916
+ policy;
4917
+ environment;
4918
+ session;
4919
+ auditWriter;
4920
+ evidenceStore;
4921
+ approvalRouter;
4922
+ rateLimiter;
4923
+ spendLimiter;
4924
+ budgetEngine;
4925
+ upstreamName;
4926
+ annotationCache = new ToolAnnotationCache();
4927
+ agentKeyWarned = false;
4928
+ senderKeyWarned = false;
4929
+ constructor(inner, policy, options) {
4930
+ this.inner = inner;
4931
+ this.policy = policy;
4932
+ this.environment = options?.environment;
4933
+ this.auditWriter = options?.auditWriter;
4934
+ this.evidenceStore = options?.evidenceStore;
4935
+ this.approvalRouter = options?.approvalRouter;
4936
+ this.rateLimiter = options?.rateLimiter;
4937
+ this.spendLimiter = options?.spendLimiter;
4938
+ this.budgetEngine = options?.budgetEngine;
4939
+ this.upstreamName = options?.upstreamName;
4940
+ this.session = options?.session ?? DEFAULT_SESSION_IDENTITY;
4941
+ if (this.evidenceStore) {
4942
+ this.evidenceStore.setAllowedEvidenceKeys(collectAllowedEvidenceKeys(policy));
4556
4943
  }
4557
4944
  }
4558
- // -------------------------------------------------------------------------
4559
- // Core operations
4560
- // -------------------------------------------------------------------------
4561
4945
  /**
4562
- * Check and optionally record a spend against the limit.
4946
+ * Swap the compiled policy atomically and reconcile limit bucket state
4947
+ * against the new configuration.
4563
4948
  *
4564
- * Evicts expired entries, sums remaining amounts, then checks:
4565
- * - Under limit (currentSpend + amount <= limit): records and returns `allowed: true`
4566
- * - Would exceed: does NOT record (rejected spends don't consume budget)
4567
- */
4568
- check(params) {
4569
- const { key, amount, limit, windowMs } = params;
4570
- const now = this.now();
4571
- const windowStart = now - windowMs;
4572
- if (!Number.isFinite(amount) || amount < 0) {
4573
- const existing = this.buckets.get(key);
4574
- const activeEntries = existing ? existing.entries.filter((e) => e.timestamp > windowStart) : [];
4575
- const currentSpend2 = activeEntries.reduce((sum, e) => sum + e.amount, 0);
4576
- const oldest = activeEntries[0];
4577
- return {
4578
- allowed: false,
4579
- currentSpend: currentSpend2,
4580
- limit,
4581
- windowMs,
4582
- resetAtMs: oldest ? oldest.timestamp + windowMs : 0,
4583
- reason: "invalid_amount"
4584
- };
4585
- }
4586
- let bucket = this.buckets.get(key);
4587
- if (!bucket) {
4588
- bucket = { entries: [], limit, currency: "", windowMs };
4589
- this.buckets.set(key, bucket);
4590
- }
4591
- bucket.limit = limit;
4592
- bucket.windowMs = windowMs;
4593
- bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
4594
- const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
4595
- if (currentSpend + amount > limit) {
4596
- const oldest = bucket.entries[0];
4597
- return {
4598
- allowed: false,
4599
- currentSpend,
4600
- limit,
4601
- windowMs,
4602
- resetAtMs: oldest ? oldest.timestamp + windowMs : 0
4603
- };
4604
- }
4605
- bucket.entries.push({ timestamp: now, amount });
4606
- const newSpend = currentSpend + amount;
4607
- const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
4608
- if (this.onWarning && newSpend / limit >= this.warningThreshold) {
4609
- this.safeWarn({
4610
- key,
4611
- current_spend: newSpend,
4612
- limit,
4613
- currency: bucket.currency,
4614
- window_ms: windowMs,
4615
- reset_at_ms: resetAtMs
4616
- });
4617
- }
4618
- return {
4619
- allowed: true,
4620
- currentSpend: newSpend,
4621
- limit,
4622
- windowMs,
4623
- resetAtMs
4624
- };
4625
- }
4626
- /**
4627
- * Unconditionally record a spend against the limit.
4628
- *
4629
- * Unlike check(), this always appends the amount — even when it pushes the
4630
- * window past the limit — because the spend it represents has already been
4631
- * incurred. The sideband peeks at /evaluate and commits here at /audit once
4632
- * the external call ran (issue #12, D3).
4633
- *
4634
- * Throws on a negative or non-finite amount: such amounts are rejected at
4635
- * /evaluate, so one reaching record() is a logic bug we surface loudly rather
4636
- * than silently corrupt the sliding-window sum. Warnings fire only while the
4637
- * post-append spend stays within the limit (parity with check()).
4638
- */
4639
- record(params) {
4640
- const { key, amount, limit, windowMs } = params;
4641
- if (!Number.isFinite(amount) || amount < 0) {
4642
- throw new RangeError(
4643
- `SpendLimiter.record() received an invalid amount (${String(amount)}); invalid amounts must be rejected at /evaluate, never committed`
4644
- );
4645
- }
4646
- const now = this.now();
4647
- const windowStart = now - windowMs;
4648
- let bucket = this.buckets.get(key);
4649
- if (!bucket) {
4650
- bucket = { entries: [], limit, currency: "", windowMs };
4651
- this.buckets.set(key, bucket);
4652
- }
4653
- bucket.limit = limit;
4654
- bucket.windowMs = windowMs;
4655
- bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
4656
- bucket.entries.push({ timestamp: now, amount });
4657
- const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
4658
- const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
4659
- if (this.onWarning && currentSpend <= limit && currentSpend / limit >= this.warningThreshold) {
4660
- this.safeWarn({
4661
- key,
4662
- current_spend: currentSpend,
4663
- limit,
4664
- currency: bucket.currency,
4665
- window_ms: windowMs,
4666
- reset_at_ms: resetAtMs
4667
- });
4668
- }
4669
- return {
4670
- allowed: currentSpend <= limit,
4671
- currentSpend,
4672
- limit,
4673
- windowMs,
4674
- resetAtMs
4675
- };
4676
- }
4677
- /**
4678
- * Check the spend limit without recording the spend (non-destructive).
4679
- *
4680
- * Used by dry-run mode to determine what would happen without consuming
4681
- * budget in the bucket.
4682
- */
4683
- peek(params) {
4684
- const { key, amount, limit, windowMs } = params;
4685
- const now = this.now();
4686
- const windowStart = now - windowMs;
4687
- const bucket = this.buckets.get(key);
4688
- if (!Number.isFinite(amount) || amount < 0) {
4689
- const activeEntries2 = bucket ? bucket.entries.filter((e) => e.timestamp > windowStart) : [];
4690
- const currentSpend2 = activeEntries2.reduce((sum, e) => sum + e.amount, 0);
4691
- const oldest2 = activeEntries2[0];
4692
- return {
4693
- allowed: false,
4694
- currentSpend: currentSpend2,
4695
- limit,
4696
- windowMs,
4697
- resetAtMs: oldest2 ? oldest2.timestamp + windowMs : 0,
4698
- reason: "invalid_amount"
4699
- };
4700
- }
4701
- if (!bucket) {
4702
- const wouldExceed = amount > limit;
4703
- return {
4704
- allowed: !wouldExceed,
4705
- currentSpend: wouldExceed ? 0 : amount,
4706
- limit,
4707
- windowMs,
4708
- resetAtMs: now + windowMs
4709
- };
4710
- }
4711
- const activeEntries = bucket.entries.filter((e) => e.timestamp > windowStart);
4712
- const currentSpend = activeEntries.reduce((sum, e) => sum + e.amount, 0);
4713
- if (currentSpend + amount > limit) {
4714
- const oldest2 = activeEntries[0];
4715
- return {
4716
- allowed: false,
4717
- currentSpend,
4718
- limit,
4719
- windowMs,
4720
- resetAtMs: oldest2 ? oldest2.timestamp + windowMs : 0
4721
- };
4722
- }
4723
- const newSpend = currentSpend + amount;
4724
- const oldest = activeEntries[0];
4725
- return {
4726
- allowed: true,
4727
- currentSpend: newSpend,
4728
- limit,
4729
- windowMs,
4730
- resetAtMs: oldest ? oldest.timestamp + windowMs : now + windowMs
4731
- };
4732
- }
4733
- /**
4734
- * Set the display currency for a key. Called by the governed forwarder
4735
- * after check() so dashboard reads include the currency label.
4736
- */
4737
- setCurrency(key, currency) {
4738
- const bucket = this.buckets.get(key);
4739
- if (bucket) bucket.currency = currency;
4740
- }
4741
- // -------------------------------------------------------------------------
4742
- // Read operations (for dashboard API)
4743
- // -------------------------------------------------------------------------
4744
- /** Get the current state of a single key. Returns undefined if not tracked. */
4745
- getKeyState(key) {
4746
- const bucket = this.buckets.get(key);
4747
- if (!bucket) return void 0;
4748
- const windowStart = this.now() - bucket.windowMs;
4749
- bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
4750
- if (bucket.entries.length === 0) {
4751
- this.buckets.delete(key);
4752
- return void 0;
4753
- }
4754
- const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
4755
- return {
4756
- key,
4757
- current_spend: currentSpend,
4758
- limit: bucket.limit,
4759
- currency: bucket.currency,
4760
- window_ms: bucket.windowMs,
4761
- reset_at_ms: (bucket.entries[0]?.timestamp ?? 0) + bucket.windowMs
4762
- };
4763
- }
4764
- /** List all tracked keys with their current state. */
4765
- listKeyStates() {
4766
- const states = [];
4767
- for (const key of [...this.buckets.keys()]) {
4768
- const state = this.getKeyState(key);
4769
- if (state) states.push(state);
4770
- }
4771
- return states;
4772
- }
4773
- // -------------------------------------------------------------------------
4774
- // Maintenance
4775
- // -------------------------------------------------------------------------
4776
- /** Sweep all buckets: remove expired entries, delete empty buckets. */
4777
- cleanup() {
4778
- const now = this.now();
4779
- for (const [key, bucket] of this.buckets) {
4780
- const windowStart = now - bucket.windowMs;
4781
- bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
4782
- if (bucket.entries.length === 0) {
4783
- this.buckets.delete(key);
4784
- }
4785
- }
4786
- }
4787
- /** Clear all spend limit state. Called on policy hot-reload. */
4788
- reset() {
4789
- this.buckets.clear();
4790
- }
4791
- /**
4792
- * Reconcile bucket state against a new policy's spend configuration.
4793
- *
4794
- * Walks every existing bucket and checks whether its last-seen
4795
- * `{ limit, currency, windowMs }` tuple still appears in `validConfigs`.
4796
- * Buckets whose config is unchanged are left untouched — cumulative spend
4797
- * and elapsed-window progress are preserved across hot-reloads. Buckets
4798
- * whose config is gone (rule changed or removed) are evicted so the next
4799
- * check lazy-creates a fresh bucket under the new config.
4800
- *
4801
- * Keys built by {@link spendBucketKey} carry the owning rule's index, and
4802
- * for those the tuple must match at THAT index (`config.ruleIndex`): a
4803
- * reorder that shifts a spend rule's index evicts its old-index bucket
4804
- * instead of leaving an orphan no rule reads again — or worse, letting
4805
- * whatever rule now sits at that index adopt another rule's accrued spend.
4806
- * Un-suffixed keys keep the tuple-anywhere match.
4807
- *
4808
- * Currency is part of the tuple because a USD→EUR switch is a meaningful
4809
- * policy change — the same numeric limit buys a different amount of real
4810
- * spend, so the bucket must reset. This replaces the old `reset()` call
4811
- * on every hot-reload, which wiped all state even when the matching rule
4812
- * was unchanged.
4813
- */
4814
- reconcile(validConfigs) {
4815
- const valid = /* @__PURE__ */ new Set();
4816
- const byIndex = /* @__PURE__ */ new Map();
4817
- for (const config of validConfigs) {
4818
- const tuple = `${String(config.limit)}|${config.currency}|${String(config.windowMs)}`;
4819
- if (config.ruleIndex === void 0) {
4820
- valid.add(tuple);
4821
- } else {
4822
- byIndex.set(config.ruleIndex, tuple);
4823
- }
4824
- }
4825
- for (const [key, bucket] of this.buckets) {
4826
- const tuple = `${String(bucket.limit)}|${bucket.currency}|${String(bucket.windowMs)}`;
4827
- const suffix = RULE_SUFFIX_RE.exec(key);
4828
- const survives = suffix ? byIndex.get(Number(suffix[1])) === tuple : valid.has(tuple);
4829
- if (!survives) {
4830
- this.buckets.delete(key);
4831
- }
4832
- }
4833
- }
4834
- /** Stop the cleanup timer and mark as closed. */
4835
- /**
4836
- * Invoke the warning callback without letting a subscriber throw into the
4837
- * limiter's caller: a warning fires after state has already mutated, and a
4838
- * governed call must not be blocked (or double-charged on retry) by an
4839
- * observability bug.
4840
- */
4841
- safeWarn(state) {
4842
- if (!this.onWarning) return;
4843
- try {
4844
- this.onWarning(state);
4845
- } catch (err) {
4846
- console.error("[helio] limit warning subscriber threw:", err);
4847
- }
4848
- }
4849
- close() {
4850
- if (this.closed) return;
4851
- this.closed = true;
4852
- if (this.timer) {
4853
- clearInterval(this.timer);
4854
- this.timer = null;
4855
- }
4856
- this.buckets.clear();
4857
- }
4858
- };
4859
-
4860
- // src/policy/governed-forwarder.ts
4861
- var POLICY_DENIED = -32001;
4862
- function blocked(result) {
4863
- return { proceed: false, result, approvalWaitMs: 0 };
4864
- }
4865
- function budgetChainBlock(entry, kind) {
4866
- return {
4867
- name: entry.budget.name,
4868
- bucket_key: entry.bucketKey,
4869
- allowed: entry.allowed,
4870
- amount: entry.amount,
4871
- spent: entry.spent,
4872
- limit: entry.budget.limit,
4873
- remaining: entry.remaining,
4874
- currency: entry.budget.currency,
4875
- ...kind ? { kind } : {},
4876
- ...entry.stale ? { stale: true } : {}
4877
- };
4878
- }
4879
- var GovernedForwarder = class {
4880
- inner;
4881
- policy;
4882
- environment;
4883
- session;
4884
- auditWriter;
4885
- evidenceStore;
4886
- approvalRouter;
4887
- rateLimiter;
4888
- spendLimiter;
4889
- budgetEngine;
4890
- annotationCache = new ToolAnnotationCache();
4891
- agentKeyWarned = false;
4892
- senderKeyWarned = false;
4893
- constructor(inner, policy, options) {
4894
- this.inner = inner;
4895
- this.policy = policy;
4896
- this.environment = options?.environment;
4897
- this.auditWriter = options?.auditWriter;
4898
- this.evidenceStore = options?.evidenceStore;
4899
- this.approvalRouter = options?.approvalRouter;
4900
- this.rateLimiter = options?.rateLimiter;
4901
- this.spendLimiter = options?.spendLimiter;
4902
- this.budgetEngine = options?.budgetEngine;
4903
- this.session = options?.session ?? DEFAULT_SESSION_IDENTITY;
4904
- if (this.evidenceStore) {
4905
- this.evidenceStore.setAllowedEvidenceKeys(collectAllowedEvidenceKeys(policy));
4906
- }
4907
- }
4908
- /**
4909
- * Swap the compiled policy atomically and reconcile limit bucket state
4910
- * against the new configuration.
4911
- *
4912
- * Rate and spend limit buckets are preserved when their underlying rule
4913
- * config is unchanged — this is what makes a benign hot-reload (e.g. a
4914
- * `vim :w` with no real edits, or a whitespace-only config change) safe:
4915
- * operators do not get a surprise zero of their live rate/spend state
4916
- * mid-window. Buckets whose config changed or whose rule was removed are
4917
- * evicted by the limiters' `reconcile()` methods, so the next check
4918
- * lazy-creates a fresh bucket under the new config.
4919
- *
4920
- * See `packages/proxy/src/policy/rate-limiter.ts` and `spend-limiter.ts`
4921
- * for the per-bucket compare-and-evict semantics.
4949
+ * Rate and spend limit buckets are preserved when their underlying rule
4950
+ * config is unchanged this is what makes a benign hot-reload (e.g. a
4951
+ * `vim :w` with no real edits, or a whitespace-only config change) safe:
4952
+ * operators do not get a surprise zero of their live rate/spend state
4953
+ * mid-window. Buckets whose config changed or whose rule was removed are
4954
+ * evicted by the limiters' `reconcile()` methods, so the next check
4955
+ * lazy-creates a fresh bucket under the new config.
4956
+ *
4957
+ * See `packages/proxy/src/policy/rate-limiter.ts` and `spend-limiter.ts`
4958
+ * for the per-bucket compare-and-evict semantics.
4922
4959
  */
4923
4960
  updatePolicy(policy) {
4924
4961
  this.policy = policy;
@@ -4930,7 +4967,13 @@ var GovernedForwarder = class {
4930
4967
  for (const rule of policy.rules) {
4931
4968
  const limits = rule.limits;
4932
4969
  if (limits?.maxCalls !== void 0 && limits.windowMs !== void 0) {
4933
- rateConfigs.push({ maxCalls: limits.maxCalls, windowMs: limits.windowMs });
4970
+ rateConfigs.push({
4971
+ maxCalls: limits.maxCalls,
4972
+ windowMs: limits.windowMs,
4973
+ // Rate bucket keys are rule-discriminated (ruleBucketKey), so
4974
+ // reconcile must match tuples at the owning rule's index.
4975
+ ruleIndex: rule.index
4976
+ });
4934
4977
  }
4935
4978
  }
4936
4979
  this.rateLimiter.reconcile(rateConfigs);
@@ -4944,7 +4987,7 @@ var GovernedForwarder = class {
4944
4987
  limit: maxSpend.limit,
4945
4988
  currency: maxSpend.currency,
4946
4989
  windowMs: maxSpend.windowMs,
4947
- // Spend bucket keys are rule-discriminated (spendBucketKey), so
4990
+ // Spend bucket keys are rule-discriminated (ruleBucketKey), so
4948
4991
  // reconcile must match tuples at the owning rule's index.
4949
4992
  ruleIndex: rule.index
4950
4993
  });
@@ -5090,7 +5133,8 @@ var GovernedForwarder = class {
5090
5133
  origin: "mcp",
5091
5134
  metadata: null,
5092
5135
  // Drift is a cache event, not a request: no protocol claim exists.
5093
- protocol_version: null
5136
+ protocol_version: null,
5137
+ upstream: this.upstreamName ?? null
5094
5138
  });
5095
5139
  }
5096
5140
  async handleToolsCall(original) {
@@ -5134,7 +5178,8 @@ var GovernedForwarder = class {
5134
5178
  evidenceStore: this.evidenceStore,
5135
5179
  baselineAnnotations: this.annotationCache.get(toolName),
5136
5180
  currentAnnotations: this.annotationCache.getCurrent(toolName),
5137
- driftEvent: this.annotationCache.getDrift(toolName)
5181
+ driftEvent: this.annotationCache.getDrift(toolName),
5182
+ upstream: this.upstreamName
5138
5183
  });
5139
5184
  const auditRecordId = randomUUID2();
5140
5185
  let result;
@@ -5277,6 +5322,8 @@ var GovernedForwarder = class {
5277
5322
  tool_input: toolArguments ?? {},
5278
5323
  matched_rule: decision.matchedRule,
5279
5324
  session_id: request.session?.id ?? null,
5325
+ session_source: request.session?.source ?? null,
5326
+ upstream: this.upstreamName ?? null,
5280
5327
  breached_budgets: gate.breachContexts,
5281
5328
  approval: gate.approval
5282
5329
  },
@@ -5424,8 +5471,9 @@ var GovernedForwarder = class {
5424
5471
  toolName,
5425
5472
  toolArguments,
5426
5473
  sessionId: sessionGate.ok ? sessionGate.session : null,
5427
- senderId: null
5474
+ senderId: null,
5428
5475
  // adapter context; absent on the MCP path
5476
+ upstream: this.upstreamName ?? null
5429
5477
  });
5430
5478
  if (charges.length === 0 && failures.length === 0) return { kind: "proceed" };
5431
5479
  const gated = gateBudgetCharges({ charges, failures }, sessionGate);
@@ -5571,7 +5619,8 @@ var GovernedForwarder = class {
5571
5619
  record_kind: "tool_call",
5572
5620
  origin: "mcp",
5573
5621
  metadata: null,
5574
- protocol_version: request.protocolVersion ?? null
5622
+ protocol_version: request.protocolVersion ?? null,
5623
+ upstream: this.upstreamName ?? null
5575
5624
  });
5576
5625
  }
5577
5626
  return result;
@@ -5584,7 +5633,9 @@ var GovernedForwarder = class {
5584
5633
  tool_name: toolName,
5585
5634
  tool_input: toolArguments ?? {},
5586
5635
  matched_rule: decision.matchedRule,
5587
- session_id: request.session?.id ?? null
5636
+ session_id: request.session?.id ?? null,
5637
+ session_source: request.session?.source ?? null,
5638
+ upstream: this.upstreamName ?? null
5588
5639
  },
5589
5640
  request.signal
5590
5641
  );
@@ -5666,8 +5717,9 @@ var GovernedForwarder = class {
5666
5717
  }
5667
5718
  handleRateLimit(request, decision, toolName) {
5668
5719
  const limiter = this.rateLimiter;
5669
- const limits = decision.matchedRule?.limits;
5670
- if (!limits?.maxCalls || !limits.windowMs) {
5720
+ const matchedRule = decision.matchedRule;
5721
+ const limits = matchedRule?.limits;
5722
+ if (!matchedRule || !limits?.maxCalls || !limits.windowMs) {
5671
5723
  const result = this.makePolicyMisconfiguredResult(
5672
5724
  request,
5673
5725
  decision,
@@ -5680,7 +5732,7 @@ var GovernedForwarder = class {
5680
5732
  rateLimitResult: { allowed: false, current: 0, limit: 0, windowMs: 0, resetAtMs: 0 }
5681
5733
  };
5682
5734
  }
5683
- let key;
5735
+ let baseKey;
5684
5736
  if (limits.key === "session") {
5685
5737
  const sessionKey = this.gateSessionLimitKey(request);
5686
5738
  if (sessionKey === null) {
@@ -5690,15 +5742,16 @@ var GovernedForwarder = class {
5690
5742
  approvalWaitMs: 0
5691
5743
  };
5692
5744
  }
5693
- key = sessionKey;
5745
+ baseKey = sessionKey;
5694
5746
  } else {
5695
- key = this.buildLimitKey(limits.key, toolName);
5747
+ baseKey = this.buildLimitKey(limits.key, toolName);
5696
5748
  }
5749
+ const key = ruleBucketKey(baseKey, matchedRule.index);
5697
5750
  const params = { key, maxCalls: limits.maxCalls, windowMs: limits.windowMs };
5698
5751
  const rateLimitResult = limiter.peek(params);
5699
5752
  if (!rateLimitResult.allowed) {
5700
5753
  const feedback = buildRateLimitedFeedback(decision, rateLimitResult);
5701
- const message = decision.matchedRule?.feedback?.message ?? `Rate limit exceeded for ${key}`;
5754
+ const message = matchedRule.feedback?.message ?? `Rate limit exceeded for ${key}`;
5702
5755
  return {
5703
5756
  proceed: false,
5704
5757
  result: makeErrorResult(request, POLICY_DENIED, message, { ...feedback }),
@@ -5745,7 +5798,7 @@ var GovernedForwarder = class {
5745
5798
  } else {
5746
5799
  baseKey = this.buildLimitKey(maxSpend.key, toolName);
5747
5800
  }
5748
- const key = spendBucketKey(baseKey, decision.matchedRule.index);
5801
+ const key = ruleBucketKey(baseKey, decision.matchedRule.index);
5749
5802
  const rawAmount = resolvePath(maxSpend.field, toolArguments ?? {});
5750
5803
  if (typeof rawAmount !== "number") {
5751
5804
  console.error(
@@ -5818,14 +5871,14 @@ var GovernedForwarder = class {
5818
5871
  case "rate_limit":
5819
5872
  if (this.rateLimiter && decision.matchedRule?.limits?.maxCalls && decision.matchedRule.limits.windowMs) {
5820
5873
  const limits = decision.matchedRule.limits;
5821
- const key = limits.key === "session" ? this.gateSessionLimitKey(request) : this.buildLimitKey(limits.key, toolName);
5822
- if (key === null) {
5874
+ const baseKey = limits.key === "session" ? this.gateSessionLimitKey(request) : this.buildLimitKey(limits.key, toolName);
5875
+ if (baseKey === null) {
5823
5876
  wouldForward = false;
5824
5877
  limitsOk = false;
5825
5878
  sessionUnresolved = true;
5826
5879
  } else {
5827
5880
  const peekResult = this.rateLimiter.peek({
5828
- key,
5881
+ key: ruleBucketKey(baseKey, decision.matchedRule.index),
5829
5882
  maxCalls: decision.matchedRule.limits.maxCalls,
5830
5883
  windowMs: decision.matchedRule.limits.windowMs
5831
5884
  });
@@ -5858,7 +5911,7 @@ var GovernedForwarder = class {
5858
5911
  sessionUnresolved = true;
5859
5912
  } else {
5860
5913
  const peekResult = this.spendLimiter.peek({
5861
- key: spendBucketKey(baseKey, decision.matchedRule.index),
5914
+ key: ruleBucketKey(baseKey, decision.matchedRule.index),
5862
5915
  amount: rawAmount,
5863
5916
  limit: maxSpend.limit,
5864
5917
  windowMs: maxSpend.windowMs
@@ -5878,7 +5931,8 @@ var GovernedForwarder = class {
5878
5931
  toolName,
5879
5932
  toolArguments,
5880
5933
  sessionId: sessionGate.ok ? sessionGate.session : null,
5881
- senderId: null
5934
+ senderId: null,
5935
+ upstream: this.upstreamName ?? null
5882
5936
  });
5883
5937
  if (failures.length > 0 || charges.length > 0) {
5884
5938
  const gated = gateBudgetCharges({ charges, failures }, sessionGate);
@@ -5919,7 +5973,9 @@ var GovernedForwarder = class {
5919
5973
  );
5920
5974
  }
5921
5975
  /**
5922
- * Construct a non-session limit bucket key. Session keys are deliberately
5976
+ * Construct a non-session limit bucket key. Tool-scope keys route through
5977
+ * the shared `toolLimitKey` leaf, which prefixes them with the configured
5978
+ * upstream name when one is set (issue #295). Session keys are deliberately
5923
5979
  * NOT built here: they come only from the gate module's `sessionLimitKey`,
5924
5980
  * whose `GatedSession` parameter makes skipping the identity gate a
5925
5981
  * compile error (issue #218) — call sites branch on `key === 'session'`.
@@ -5933,7 +5989,7 @@ var GovernedForwarder = class {
5933
5989
  '[helio] Warning: limits.key "agent" is not yet supported, falling back to "tool"'
5934
5990
  );
5935
5991
  }
5936
- return `tool:${toolName}`;
5992
+ return toolLimitKey(toolName, this.upstreamName);
5937
5993
  case "sender_id":
5938
5994
  if (!this.senderKeyWarned) {
5939
5995
  this.senderKeyWarned = true;
@@ -5941,10 +5997,10 @@ var GovernedForwarder = class {
5941
5997
  '[helio] Warning: limits.key "sender_id" has no sender on the MCP path, falling back to "tool"'
5942
5998
  );
5943
5999
  }
5944
- return `tool:${toolName}`;
6000
+ return toolLimitKey(toolName, this.upstreamName);
5945
6001
  case "tool":
5946
6002
  default:
5947
- return `tool:${toolName}`;
6003
+ return toolLimitKey(toolName, this.upstreamName);
5948
6004
  }
5949
6005
  }
5950
6006
  /**
@@ -6099,7 +6155,8 @@ var GovernedForwarder = class {
6099
6155
  record_kind: "tool_call",
6100
6156
  origin: "mcp",
6101
6157
  metadata: null,
6102
- protocol_version: request.protocolVersion ?? null
6158
+ protocol_version: request.protocolVersion ?? null,
6159
+ upstream: this.upstreamName ?? null
6103
6160
  };
6104
6161
  const isEnforcementDecision = !isDryRun && (!forwarded || approvalOutcome !== void 0 || budgetApproval !== void 0);
6105
6162
  if (isEnforcementDecision) {
@@ -6204,79 +6261,342 @@ function collectAllowedEvidenceKeys(policy) {
6204
6261
  keys.add(key);
6205
6262
  }
6206
6263
  }
6207
- return [...keys];
6208
- }
6209
- function makeErrorResult(request, code, message, data) {
6210
- const body = {
6211
- jsonrpc: "2.0",
6212
- id: request.id ?? null,
6213
- error: { code, message, data }
6214
- };
6215
- const response = {
6216
- status: 200,
6217
- headers: { "content-type": "application/json" },
6218
- body
6219
- };
6220
- return { response, durationMs: 0 };
6221
- }
6222
- function approvedByOf(outcome) {
6223
- return outcome && "resolvedBy" in outcome ? outcome.resolvedBy : null;
6224
- }
6225
- function hasJsonRpcError(result) {
6226
- const body = result.response.body;
6227
- return body?.["error"] !== void 0;
6228
- }
6229
- function classifyPrimeFailure(response) {
6230
- if (response.status >= 400) {
6231
- return `upstream returned HTTP ${String(response.status)} to tools/list (session/initialize may be required)`;
6232
- }
6233
- const rawBody = response.body;
6234
- if (typeof rawBody !== "object" || rawBody === null) {
6235
- return `upstream tools/list returned a non-JSON body (content-type ${response.headers["content-type"] ?? "unknown"})`;
6264
+ return [...keys];
6265
+ }
6266
+ function makeErrorResult(request, code, message, data) {
6267
+ const body = {
6268
+ jsonrpc: "2.0",
6269
+ id: request.id ?? null,
6270
+ error: { code, message, data }
6271
+ };
6272
+ const response = {
6273
+ status: 200,
6274
+ headers: { "content-type": "application/json" },
6275
+ body
6276
+ };
6277
+ return { response, durationMs: 0 };
6278
+ }
6279
+ function approvedByOf(outcome) {
6280
+ return outcome && "resolvedBy" in outcome ? outcome.resolvedBy : null;
6281
+ }
6282
+ function hasJsonRpcError(result) {
6283
+ const body = result.response.body;
6284
+ return body?.["error"] !== void 0;
6285
+ }
6286
+ function classifyPrimeFailure(response) {
6287
+ if (response.status >= 400) {
6288
+ return `upstream returned HTTP ${String(response.status)} to tools/list (session/initialize may be required)`;
6289
+ }
6290
+ const rawBody = response.body;
6291
+ if (typeof rawBody !== "object" || rawBody === null) {
6292
+ return `upstream tools/list returned a non-JSON body (content-type ${response.headers["content-type"] ?? "unknown"})`;
6293
+ }
6294
+ const body = rawBody;
6295
+ const error = body["error"];
6296
+ if (typeof error === "string") {
6297
+ return `upstream tools/list returned a JSON-RPC error: ${error}`;
6298
+ }
6299
+ if (error !== null && typeof error === "object") {
6300
+ const message = error["message"];
6301
+ if (typeof message === "string") {
6302
+ return `upstream tools/list returned a JSON-RPC error: ${message}`;
6303
+ }
6304
+ }
6305
+ return "upstream tools/list response was missing result.tools";
6306
+ }
6307
+ function extractBlockReason(result) {
6308
+ const body = result.response.body;
6309
+ const error = body?.["error"];
6310
+ if (!error || typeof error !== "object") return null;
6311
+ const data = error["data"];
6312
+ if (!data || data["blocked"] !== true) return null;
6313
+ return typeof data["reason"] === "string" ? data["reason"] : null;
6314
+ }
6315
+ function buildEvidenceChain(evidenceResult, dependencyResult, blocked2) {
6316
+ if (!evidenceResult && !dependencyResult) return null;
6317
+ const chain = { blocked: blocked2 ?? false };
6318
+ if (evidenceResult) {
6319
+ chain["evidence"] = {
6320
+ required: [...evidenceResult.found, ...evidenceResult.missing, ...evidenceResult.expired],
6321
+ found: evidenceResult.found,
6322
+ missing: evidenceResult.missing,
6323
+ expired: evidenceResult.expired
6324
+ };
6325
+ }
6326
+ if (dependencyResult) {
6327
+ chain["dependencies"] = {
6328
+ satisfied: dependencyResult.satisfied,
6329
+ missing: dependencyResult.missing
6330
+ };
6331
+ }
6332
+ return chain;
6333
+ }
6334
+
6335
+ // src/policy/rate-limiter.ts
6336
+ var RateLimiter = class {
6337
+ buckets = /* @__PURE__ */ new Map();
6338
+ now;
6339
+ onWarning;
6340
+ warningThreshold;
6341
+ timer = null;
6342
+ closed = false;
6343
+ constructor(options = {}) {
6344
+ this.now = options.now ?? Date.now;
6345
+ this.onWarning = options.onWarning;
6346
+ this.warningThreshold = options.warningThreshold ?? 0.8;
6347
+ const intervalMs = options.cleanupIntervalMs ?? 6e4;
6348
+ if (intervalMs > 0) {
6349
+ this.timer = setInterval(() => {
6350
+ this.cleanup();
6351
+ }, intervalMs);
6352
+ this.timer.unref();
6353
+ }
6354
+ }
6355
+ // -------------------------------------------------------------------------
6356
+ // Core operations
6357
+ // -------------------------------------------------------------------------
6358
+ /**
6359
+ * Check and optionally record a call against the rate limit.
6360
+ *
6361
+ * Evicts expired timestamps, then checks the count:
6362
+ * - Under limit: records the timestamp and returns `allowed: true`
6363
+ * - At/over limit: does NOT record (blocked calls don't consume a slot)
6364
+ */
6365
+ check(params) {
6366
+ const { key, maxCalls, windowMs } = params;
6367
+ const now = this.now();
6368
+ const windowStart = now - windowMs;
6369
+ let bucket = this.buckets.get(key);
6370
+ if (!bucket) {
6371
+ bucket = { timestamps: [], maxCalls, windowMs };
6372
+ this.buckets.set(key, bucket);
6373
+ }
6374
+ bucket.maxCalls = maxCalls;
6375
+ bucket.windowMs = windowMs;
6376
+ bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6377
+ if (bucket.timestamps.length >= maxCalls) {
6378
+ const oldest = bucket.timestamps[0] ?? 0;
6379
+ return {
6380
+ allowed: false,
6381
+ current: bucket.timestamps.length,
6382
+ limit: maxCalls,
6383
+ windowMs,
6384
+ resetAtMs: oldest + windowMs
6385
+ };
6386
+ }
6387
+ bucket.timestamps.push(now);
6388
+ const current = bucket.timestamps.length;
6389
+ const resetAtMs = (bucket.timestamps[0] ?? now) + windowMs;
6390
+ if (this.onWarning && current / maxCalls >= this.warningThreshold) {
6391
+ this.safeWarn({ key, current, limit: maxCalls, window_ms: windowMs, reset_at_ms: resetAtMs });
6392
+ }
6393
+ return {
6394
+ allowed: true,
6395
+ current,
6396
+ limit: maxCalls,
6397
+ windowMs,
6398
+ resetAtMs
6399
+ };
6400
+ }
6401
+ /**
6402
+ * Unconditionally record a call against the rate limit.
6403
+ *
6404
+ * Unlike check(), this always appends the timestamp — even when the bucket
6405
+ * is already at/over the limit — because the call it represents has already
6406
+ * executed. The sideband splits decision from execution: /evaluate peeks
6407
+ * (non-destructive), and /audit calls record() once the external call ran,
6408
+ * so refusing to record at the limit (as check() does) would let real calls
6409
+ * escape accounting and under-count subsequent peeks. (issue #12, D3.)
6410
+ *
6411
+ * Warnings fire only while the post-append count stays within the limit —
6412
+ * exact parity with check(), which never warns on its over-limit path — so a
6413
+ * burst of over-limit audits cannot flood the dashboard's limit_warning feed.
6414
+ */
6415
+ record(params) {
6416
+ const { key, maxCalls, windowMs } = params;
6417
+ const now = this.now();
6418
+ const windowStart = now - windowMs;
6419
+ let bucket = this.buckets.get(key);
6420
+ if (!bucket) {
6421
+ bucket = { timestamps: [], maxCalls, windowMs };
6422
+ this.buckets.set(key, bucket);
6423
+ }
6424
+ bucket.maxCalls = maxCalls;
6425
+ bucket.windowMs = windowMs;
6426
+ bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6427
+ bucket.timestamps.push(now);
6428
+ const current = bucket.timestamps.length;
6429
+ const resetAtMs = (bucket.timestamps[0] ?? now) + windowMs;
6430
+ if (this.onWarning && current <= maxCalls && current / maxCalls >= this.warningThreshold) {
6431
+ this.safeWarn({ key, current, limit: maxCalls, window_ms: windowMs, reset_at_ms: resetAtMs });
6432
+ }
6433
+ return {
6434
+ allowed: current <= maxCalls,
6435
+ current,
6436
+ limit: maxCalls,
6437
+ windowMs,
6438
+ resetAtMs
6439
+ };
6440
+ }
6441
+ /**
6442
+ * Check the rate limit without recording the call (non-destructive).
6443
+ *
6444
+ * Used by dry-run mode to determine what would happen without consuming
6445
+ * a slot in the bucket.
6446
+ */
6447
+ peek(params) {
6448
+ const { key, maxCalls, windowMs } = params;
6449
+ const now = this.now();
6450
+ const windowStart = now - windowMs;
6451
+ const bucket = this.buckets.get(key);
6452
+ if (!bucket) {
6453
+ return {
6454
+ allowed: true,
6455
+ current: 1,
6456
+ limit: maxCalls,
6457
+ windowMs,
6458
+ resetAtMs: now + windowMs
6459
+ };
6460
+ }
6461
+ const activeCount = bucket.timestamps.filter((ts) => ts > windowStart).length;
6462
+ if (activeCount >= maxCalls) {
6463
+ const oldest2 = bucket.timestamps.find((ts) => ts > windowStart) ?? 0;
6464
+ return {
6465
+ allowed: false,
6466
+ current: activeCount,
6467
+ limit: maxCalls,
6468
+ windowMs,
6469
+ resetAtMs: oldest2 + windowMs
6470
+ };
6471
+ }
6472
+ const oldest = bucket.timestamps.find((ts) => ts > windowStart) ?? now;
6473
+ return {
6474
+ allowed: true,
6475
+ current: activeCount + 1,
6476
+ limit: maxCalls,
6477
+ windowMs,
6478
+ resetAtMs: oldest + windowMs
6479
+ };
6480
+ }
6481
+ // -------------------------------------------------------------------------
6482
+ // Read operations (for dashboard API)
6483
+ // -------------------------------------------------------------------------
6484
+ /** Get the current state of a single key. Returns undefined if not tracked. */
6485
+ getKeyState(key) {
6486
+ const bucket = this.buckets.get(key);
6487
+ if (!bucket) return void 0;
6488
+ const windowStart = this.now() - bucket.windowMs;
6489
+ bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6490
+ if (bucket.timestamps.length === 0) {
6491
+ this.buckets.delete(key);
6492
+ return void 0;
6493
+ }
6494
+ return {
6495
+ key,
6496
+ current: bucket.timestamps.length,
6497
+ limit: bucket.maxCalls,
6498
+ window_ms: bucket.windowMs,
6499
+ reset_at_ms: (bucket.timestamps[0] ?? 0) + bucket.windowMs
6500
+ };
6501
+ }
6502
+ /** List all tracked keys with their current state. */
6503
+ listKeyStates() {
6504
+ const states = [];
6505
+ for (const key of [...this.buckets.keys()]) {
6506
+ const state = this.getKeyState(key);
6507
+ if (state) states.push(state);
6508
+ }
6509
+ return states;
6510
+ }
6511
+ // -------------------------------------------------------------------------
6512
+ // Maintenance
6513
+ // -------------------------------------------------------------------------
6514
+ /** Sweep all buckets: remove expired timestamps, delete empty buckets. */
6515
+ cleanup() {
6516
+ const now = this.now();
6517
+ for (const [key, bucket] of this.buckets) {
6518
+ const windowStart = now - bucket.windowMs;
6519
+ bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6520
+ if (bucket.timestamps.length === 0) {
6521
+ this.buckets.delete(key);
6522
+ }
6523
+ }
6236
6524
  }
6237
- const body = rawBody;
6238
- const error = body["error"];
6239
- if (typeof error === "string") {
6240
- return `upstream tools/list returned a JSON-RPC error: ${error}`;
6525
+ /** Clear all rate limit state. Called on policy hot-reload. */
6526
+ reset() {
6527
+ this.buckets.clear();
6241
6528
  }
6242
- if (error !== null && typeof error === "object") {
6243
- const message = error["message"];
6244
- if (typeof message === "string") {
6245
- return `upstream tools/list returned a JSON-RPC error: ${message}`;
6529
+ /**
6530
+ * Reconcile bucket state against a new policy's limit configuration.
6531
+ *
6532
+ * Walks every existing bucket and checks whether its last-seen
6533
+ * `{ maxCalls, windowMs }` tuple still appears in `validConfigs`.
6534
+ * Buckets whose config is still present are left untouched — counters and
6535
+ * elapsed-window progress are preserved across hot-reloads. Buckets whose
6536
+ * config is gone (rule changed or removed) are evicted so the next check
6537
+ * lazy-creates a fresh bucket under the new config.
6538
+ *
6539
+ * Keys built by `ruleBucketKey` (bucket-key.ts) carry the owning rule's
6540
+ * index, and for those the tuple must match at THAT index
6541
+ * (`config.ruleIndex`): a reorder that shifts a rate rule's index evicts
6542
+ * its old-index bucket instead of leaving an orphan no rule reads again —
6543
+ * or worse, letting whatever rule now sits at that index adopt another
6544
+ * rule's accrued calls. Un-suffixed keys keep the tuple-anywhere match,
6545
+ * but only against index-less configs — a caller that passes only indexed
6546
+ * configs (as the proxy does) evicts every un-suffixed bucket, fail-closed.
6547
+ *
6548
+ * This is the compare-and-evict semantic that replaces the old `reset()`
6549
+ * call on every hot-reload, which wiped all state even when the matching
6550
+ * rule was unchanged.
6551
+ */
6552
+ reconcile(validConfigs) {
6553
+ const valid = /* @__PURE__ */ new Set();
6554
+ const byIndex = /* @__PURE__ */ new Map();
6555
+ for (const config of validConfigs) {
6556
+ const tuple = `${String(config.maxCalls)}|${String(config.windowMs)}`;
6557
+ if (config.ruleIndex === void 0) {
6558
+ valid.add(tuple);
6559
+ } else {
6560
+ byIndex.set(config.ruleIndex, tuple);
6561
+ }
6562
+ }
6563
+ for (const [key, bucket] of this.buckets) {
6564
+ const tuple = `${String(bucket.maxCalls)}|${String(bucket.windowMs)}`;
6565
+ const ruleIndex = parseRuleIndex(key);
6566
+ const survives = ruleIndex === void 0 ? valid.has(tuple) : byIndex.get(ruleIndex) === tuple;
6567
+ if (!survives) {
6568
+ this.buckets.delete(key);
6569
+ }
6246
6570
  }
6247
6571
  }
6248
- return "upstream tools/list response was missing result.tools";
6249
- }
6250
- function extractBlockReason(result) {
6251
- const body = result.response.body;
6252
- const error = body?.["error"];
6253
- if (!error || typeof error !== "object") return null;
6254
- const data = error["data"];
6255
- if (!data || data["blocked"] !== true) return null;
6256
- return typeof data["reason"] === "string" ? data["reason"] : null;
6257
- }
6258
- function buildEvidenceChain(evidenceResult, dependencyResult, blocked2) {
6259
- if (!evidenceResult && !dependencyResult) return null;
6260
- const chain = { blocked: blocked2 ?? false };
6261
- if (evidenceResult) {
6262
- chain["evidence"] = {
6263
- required: [...evidenceResult.found, ...evidenceResult.missing, ...evidenceResult.expired],
6264
- found: evidenceResult.found,
6265
- missing: evidenceResult.missing,
6266
- expired: evidenceResult.expired
6267
- };
6572
+ /** Stop the cleanup timer and mark as closed. */
6573
+ /**
6574
+ * Invoke the warning callback without letting a subscriber throw into the
6575
+ * limiter's caller: a warning fires after state has already mutated, and a
6576
+ * governed call must not be blocked (or double-charged on retry) by an
6577
+ * observability bug.
6578
+ */
6579
+ safeWarn(state) {
6580
+ if (!this.onWarning) return;
6581
+ try {
6582
+ this.onWarning(state);
6583
+ } catch (err) {
6584
+ console.error("[helio] limit warning subscriber threw:", err);
6585
+ }
6268
6586
  }
6269
- if (dependencyResult) {
6270
- chain["dependencies"] = {
6271
- satisfied: dependencyResult.satisfied,
6272
- missing: dependencyResult.missing
6273
- };
6587
+ close() {
6588
+ if (this.closed) return;
6589
+ this.closed = true;
6590
+ if (this.timer) {
6591
+ clearInterval(this.timer);
6592
+ this.timer = null;
6593
+ }
6594
+ this.buckets.clear();
6274
6595
  }
6275
- return chain;
6276
- }
6596
+ };
6277
6597
 
6278
- // src/policy/rate-limiter.ts
6279
- var RateLimiter = class {
6598
+ // src/policy/spend-limiter.ts
6599
+ var SpendLimiter = class {
6280
6600
  buckets = /* @__PURE__ */ new Map();
6281
6601
  now;
6282
6602
  onWarning;
@@ -6299,128 +6619,185 @@ var RateLimiter = class {
6299
6619
  // Core operations
6300
6620
  // -------------------------------------------------------------------------
6301
6621
  /**
6302
- * Check and optionally record a call against the rate limit.
6622
+ * Check and optionally record a spend against the limit.
6303
6623
  *
6304
- * Evicts expired timestamps, then checks the count:
6305
- * - Under limit: records the timestamp and returns `allowed: true`
6306
- * - At/over limit: does NOT record (blocked calls don't consume a slot)
6624
+ * Evicts expired entries, sums remaining amounts, then checks:
6625
+ * - Under limit (currentSpend + amount <= limit): records and returns `allowed: true`
6626
+ * - Would exceed: does NOT record (rejected spends don't consume budget)
6307
6627
  */
6308
6628
  check(params) {
6309
- const { key, maxCalls, windowMs } = params;
6629
+ const { key, amount, limit, windowMs } = params;
6310
6630
  const now = this.now();
6311
6631
  const windowStart = now - windowMs;
6632
+ if (!Number.isFinite(amount) || amount < 0) {
6633
+ const existing = this.buckets.get(key);
6634
+ const activeEntries = existing ? existing.entries.filter((e) => e.timestamp > windowStart) : [];
6635
+ const currentSpend2 = activeEntries.reduce((sum, e) => sum + e.amount, 0);
6636
+ const oldest = activeEntries[0];
6637
+ return {
6638
+ allowed: false,
6639
+ currentSpend: currentSpend2,
6640
+ limit,
6641
+ windowMs,
6642
+ resetAtMs: oldest ? oldest.timestamp + windowMs : 0,
6643
+ reason: "invalid_amount"
6644
+ };
6645
+ }
6312
6646
  let bucket = this.buckets.get(key);
6313
6647
  if (!bucket) {
6314
- bucket = { timestamps: [], maxCalls, windowMs };
6648
+ bucket = { entries: [], limit, currency: "", windowMs };
6315
6649
  this.buckets.set(key, bucket);
6316
6650
  }
6317
- bucket.maxCalls = maxCalls;
6651
+ bucket.limit = limit;
6318
6652
  bucket.windowMs = windowMs;
6319
- bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6320
- if (bucket.timestamps.length >= maxCalls) {
6321
- const oldest = bucket.timestamps[0] ?? 0;
6653
+ bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
6654
+ const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
6655
+ if (currentSpend + amount > limit) {
6656
+ const oldest = bucket.entries[0];
6322
6657
  return {
6323
6658
  allowed: false,
6324
- current: bucket.timestamps.length,
6325
- limit: maxCalls,
6659
+ currentSpend,
6660
+ limit,
6326
6661
  windowMs,
6327
- resetAtMs: oldest + windowMs
6662
+ resetAtMs: oldest ? oldest.timestamp + windowMs : 0
6328
6663
  };
6329
6664
  }
6330
- bucket.timestamps.push(now);
6331
- const current = bucket.timestamps.length;
6332
- const resetAtMs = (bucket.timestamps[0] ?? now) + windowMs;
6333
- if (this.onWarning && current / maxCalls >= this.warningThreshold) {
6334
- this.safeWarn({ key, current, limit: maxCalls, window_ms: windowMs, reset_at_ms: resetAtMs });
6665
+ bucket.entries.push({ timestamp: now, amount });
6666
+ const newSpend = currentSpend + amount;
6667
+ const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
6668
+ if (this.onWarning && newSpend / limit >= this.warningThreshold) {
6669
+ this.safeWarn({
6670
+ key,
6671
+ current_spend: newSpend,
6672
+ limit,
6673
+ currency: bucket.currency,
6674
+ window_ms: windowMs,
6675
+ reset_at_ms: resetAtMs
6676
+ });
6335
6677
  }
6336
6678
  return {
6337
6679
  allowed: true,
6338
- current,
6339
- limit: maxCalls,
6680
+ currentSpend: newSpend,
6681
+ limit,
6340
6682
  windowMs,
6341
6683
  resetAtMs
6342
6684
  };
6343
6685
  }
6344
6686
  /**
6345
- * Unconditionally record a call against the rate limit.
6687
+ * Unconditionally record a spend against the limit.
6346
6688
  *
6347
- * Unlike check(), this always appends the timestamp — even when the bucket
6348
- * is already at/over the limit — because the call it represents has already
6349
- * executed. The sideband splits decision from execution: /evaluate peeks
6350
- * (non-destructive), and /audit calls record() once the external call ran,
6351
- * so refusing to record at the limit (as check() does) would let real calls
6352
- * escape accounting and under-count subsequent peeks. (issue #12, D3.)
6689
+ * Unlike check(), this always appends the amount — even when it pushes the
6690
+ * window past the limit — because the spend it represents has already been
6691
+ * incurred. The sideband peeks at /evaluate and commits here at /audit once
6692
+ * the external call ran (issue #12, D3).
6353
6693
  *
6354
- * Warnings fire only while the post-append count stays within the limit
6355
- * exact parity with check(), which never warns on its over-limit path — so a
6356
- * burst of over-limit audits cannot flood the dashboard's limit_warning feed.
6694
+ * Throws on a negative or non-finite amount: such amounts are rejected at
6695
+ * /evaluate, so one reaching record() is a logic bug we surface loudly rather
6696
+ * than silently corrupt the sliding-window sum. Warnings fire only while the
6697
+ * post-append spend stays within the limit (parity with check()).
6357
6698
  */
6358
6699
  record(params) {
6359
- const { key, maxCalls, windowMs } = params;
6700
+ const { key, amount, limit, windowMs } = params;
6701
+ if (!Number.isFinite(amount) || amount < 0) {
6702
+ throw new RangeError(
6703
+ `SpendLimiter.record() received an invalid amount (${String(amount)}); invalid amounts must be rejected at /evaluate, never committed`
6704
+ );
6705
+ }
6360
6706
  const now = this.now();
6361
6707
  const windowStart = now - windowMs;
6362
6708
  let bucket = this.buckets.get(key);
6363
6709
  if (!bucket) {
6364
- bucket = { timestamps: [], maxCalls, windowMs };
6710
+ bucket = { entries: [], limit, currency: "", windowMs };
6365
6711
  this.buckets.set(key, bucket);
6366
6712
  }
6367
- bucket.maxCalls = maxCalls;
6713
+ bucket.limit = limit;
6368
6714
  bucket.windowMs = windowMs;
6369
- bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6370
- bucket.timestamps.push(now);
6371
- const current = bucket.timestamps.length;
6372
- const resetAtMs = (bucket.timestamps[0] ?? now) + windowMs;
6373
- if (this.onWarning && current <= maxCalls && current / maxCalls >= this.warningThreshold) {
6374
- this.safeWarn({ key, current, limit: maxCalls, window_ms: windowMs, reset_at_ms: resetAtMs });
6715
+ bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
6716
+ bucket.entries.push({ timestamp: now, amount });
6717
+ const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
6718
+ const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
6719
+ if (this.onWarning && currentSpend <= limit && currentSpend / limit >= this.warningThreshold) {
6720
+ this.safeWarn({
6721
+ key,
6722
+ current_spend: currentSpend,
6723
+ limit,
6724
+ currency: bucket.currency,
6725
+ window_ms: windowMs,
6726
+ reset_at_ms: resetAtMs
6727
+ });
6375
6728
  }
6376
6729
  return {
6377
- allowed: current <= maxCalls,
6378
- current,
6379
- limit: maxCalls,
6730
+ allowed: currentSpend <= limit,
6731
+ currentSpend,
6732
+ limit,
6380
6733
  windowMs,
6381
6734
  resetAtMs
6382
6735
  };
6383
6736
  }
6384
6737
  /**
6385
- * Check the rate limit without recording the call (non-destructive).
6738
+ * Check the spend limit without recording the spend (non-destructive).
6386
6739
  *
6387
6740
  * Used by dry-run mode to determine what would happen without consuming
6388
- * a slot in the bucket.
6741
+ * budget in the bucket.
6389
6742
  */
6390
6743
  peek(params) {
6391
- const { key, maxCalls, windowMs } = params;
6744
+ const { key, amount, limit, windowMs } = params;
6392
6745
  const now = this.now();
6393
6746
  const windowStart = now - windowMs;
6394
6747
  const bucket = this.buckets.get(key);
6748
+ if (!Number.isFinite(amount) || amount < 0) {
6749
+ const activeEntries2 = bucket ? bucket.entries.filter((e) => e.timestamp > windowStart) : [];
6750
+ const currentSpend2 = activeEntries2.reduce((sum, e) => sum + e.amount, 0);
6751
+ const oldest2 = activeEntries2[0];
6752
+ return {
6753
+ allowed: false,
6754
+ currentSpend: currentSpend2,
6755
+ limit,
6756
+ windowMs,
6757
+ resetAtMs: oldest2 ? oldest2.timestamp + windowMs : 0,
6758
+ reason: "invalid_amount"
6759
+ };
6760
+ }
6395
6761
  if (!bucket) {
6762
+ const wouldExceed = amount > limit;
6396
6763
  return {
6397
- allowed: true,
6398
- current: 1,
6399
- limit: maxCalls,
6764
+ allowed: !wouldExceed,
6765
+ currentSpend: wouldExceed ? 0 : amount,
6766
+ limit,
6400
6767
  windowMs,
6401
6768
  resetAtMs: now + windowMs
6402
6769
  };
6403
6770
  }
6404
- const activeCount = bucket.timestamps.filter((ts) => ts > windowStart).length;
6405
- if (activeCount >= maxCalls) {
6406
- const oldest2 = bucket.timestamps.find((ts) => ts > windowStart) ?? 0;
6771
+ const activeEntries = bucket.entries.filter((e) => e.timestamp > windowStart);
6772
+ const currentSpend = activeEntries.reduce((sum, e) => sum + e.amount, 0);
6773
+ if (currentSpend + amount > limit) {
6774
+ const oldest2 = activeEntries[0];
6407
6775
  return {
6408
6776
  allowed: false,
6409
- current: activeCount,
6410
- limit: maxCalls,
6777
+ currentSpend,
6778
+ limit,
6411
6779
  windowMs,
6412
- resetAtMs: oldest2 + windowMs
6780
+ resetAtMs: oldest2 ? oldest2.timestamp + windowMs : 0
6413
6781
  };
6414
6782
  }
6415
- const oldest = bucket.timestamps.find((ts) => ts > windowStart) ?? now;
6783
+ const newSpend = currentSpend + amount;
6784
+ const oldest = activeEntries[0];
6416
6785
  return {
6417
6786
  allowed: true,
6418
- current: activeCount + 1,
6419
- limit: maxCalls,
6787
+ currentSpend: newSpend,
6788
+ limit,
6420
6789
  windowMs,
6421
- resetAtMs: oldest + windowMs
6790
+ resetAtMs: oldest ? oldest.timestamp + windowMs : now + windowMs
6422
6791
  };
6423
6792
  }
6793
+ /**
6794
+ * Set the display currency for a key. Called by the governed forwarder
6795
+ * after check() so dashboard reads include the currency label.
6796
+ */
6797
+ setCurrency(key, currency) {
6798
+ const bucket = this.buckets.get(key);
6799
+ if (bucket) bucket.currency = currency;
6800
+ }
6424
6801
  // -------------------------------------------------------------------------
6425
6802
  // Read operations (for dashboard API)
6426
6803
  // -------------------------------------------------------------------------
@@ -6429,17 +6806,19 @@ var RateLimiter = class {
6429
6806
  const bucket = this.buckets.get(key);
6430
6807
  if (!bucket) return void 0;
6431
6808
  const windowStart = this.now() - bucket.windowMs;
6432
- bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6433
- if (bucket.timestamps.length === 0) {
6809
+ bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
6810
+ if (bucket.entries.length === 0) {
6434
6811
  this.buckets.delete(key);
6435
6812
  return void 0;
6436
6813
  }
6814
+ const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
6437
6815
  return {
6438
6816
  key,
6439
- current: bucket.timestamps.length,
6440
- limit: bucket.maxCalls,
6817
+ current_spend: currentSpend,
6818
+ limit: bucket.limit,
6819
+ currency: bucket.currency,
6441
6820
  window_ms: bucket.windowMs,
6442
- reset_at_ms: (bucket.timestamps[0] ?? 0) + bucket.windowMs
6821
+ reset_at_ms: (bucket.entries[0]?.timestamp ?? 0) + bucket.windowMs
6443
6822
  };
6444
6823
  }
6445
6824
  /** List all tracked keys with their current state. */
@@ -6454,43 +6833,62 @@ var RateLimiter = class {
6454
6833
  // -------------------------------------------------------------------------
6455
6834
  // Maintenance
6456
6835
  // -------------------------------------------------------------------------
6457
- /** Sweep all buckets: remove expired timestamps, delete empty buckets. */
6836
+ /** Sweep all buckets: remove expired entries, delete empty buckets. */
6458
6837
  cleanup() {
6459
6838
  const now = this.now();
6460
6839
  for (const [key, bucket] of this.buckets) {
6461
6840
  const windowStart = now - bucket.windowMs;
6462
- bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6463
- if (bucket.timestamps.length === 0) {
6841
+ bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
6842
+ if (bucket.entries.length === 0) {
6464
6843
  this.buckets.delete(key);
6465
6844
  }
6466
6845
  }
6467
6846
  }
6468
- /** Clear all rate limit state. Called on policy hot-reload. */
6847
+ /** Clear all spend limit state. Called on policy hot-reload. */
6469
6848
  reset() {
6470
6849
  this.buckets.clear();
6471
6850
  }
6472
6851
  /**
6473
- * Reconcile bucket state against a new policy's limit configuration.
6852
+ * Reconcile bucket state against a new policy's spend configuration.
6474
6853
  *
6475
6854
  * Walks every existing bucket and checks whether its last-seen
6476
- * `{ maxCalls, windowMs }` tuple still appears anywhere in `validConfigs`.
6477
- * Buckets whose config is still present are left untouched — counters and
6478
- * elapsed-window progress are preserved across hot-reloads. Buckets whose
6479
- * config is gone (rule changed or removed) are evicted so the next check
6480
- * lazy-creates a fresh bucket under the new config.
6855
+ * `{ limit, currency, windowMs }` tuple still appears in `validConfigs`.
6856
+ * Buckets whose config is unchanged are left untouched — cumulative spend
6857
+ * and elapsed-window progress are preserved across hot-reloads. Buckets
6858
+ * whose config is gone (rule changed or removed) are evicted so the next
6859
+ * check lazy-creates a fresh bucket under the new config.
6481
6860
  *
6482
- * This is the compare-and-evict semantic that replaces the old `reset()`
6483
- * call on every hot-reload, which wiped all state even when the matching
6484
- * rule was unchanged.
6861
+ * Keys built by `ruleBucketKey` (bucket-key.ts) carry the owning rule's
6862
+ * index, and for those the tuple must match at THAT index (`config.ruleIndex`): a
6863
+ * reorder that shifts a spend rule's index evicts its old-index bucket
6864
+ * instead of leaving an orphan no rule reads again — or worse, letting
6865
+ * whatever rule now sits at that index adopt another rule's accrued spend.
6866
+ * Un-suffixed keys keep the tuple-anywhere match, but only against
6867
+ * index-less configs — a caller that passes only indexed configs (as the
6868
+ * proxy does) evicts every un-suffixed bucket, fail-closed.
6869
+ *
6870
+ * Currency is part of the tuple because a USD→EUR switch is a meaningful
6871
+ * policy change — the same numeric limit buys a different amount of real
6872
+ * spend, so the bucket must reset. This replaces the old `reset()` call
6873
+ * on every hot-reload, which wiped all state even when the matching rule
6874
+ * was unchanged.
6485
6875
  */
6486
6876
  reconcile(validConfigs) {
6487
6877
  const valid = /* @__PURE__ */ new Set();
6878
+ const byIndex = /* @__PURE__ */ new Map();
6488
6879
  for (const config of validConfigs) {
6489
- valid.add(`${String(config.maxCalls)}|${String(config.windowMs)}`);
6880
+ const tuple = `${String(config.limit)}|${config.currency}|${String(config.windowMs)}`;
6881
+ if (config.ruleIndex === void 0) {
6882
+ valid.add(tuple);
6883
+ } else {
6884
+ byIndex.set(config.ruleIndex, tuple);
6885
+ }
6490
6886
  }
6491
6887
  for (const [key, bucket] of this.buckets) {
6492
- const tuple = `${String(bucket.maxCalls)}|${String(bucket.windowMs)}`;
6493
- if (!valid.has(tuple)) {
6888
+ const tuple = `${String(bucket.limit)}|${bucket.currency}|${String(bucket.windowMs)}`;
6889
+ const ruleIndex = parseRuleIndex(key);
6890
+ const survives = ruleIndex === void 0 ? valid.has(tuple) : byIndex.get(ruleIndex) === tuple;
6891
+ if (!survives) {
6494
6892
  this.buckets.delete(key);
6495
6893
  }
6496
6894
  }
@@ -6526,6 +6924,13 @@ var ANNOTATION_PRIME_INITIAL_WAIT_MS = 1500;
6526
6924
  var ANNOTATION_PRIME_RETRY_BASE_MS = 1e3;
6527
6925
  var ANNOTATION_PRIME_RETRY_MAX_MS = 3e4;
6528
6926
  var ANNOTATION_PRIME_RETRY_JITTER_MS = 250;
6927
+ function sameRevalidation(a, b) {
6928
+ if (a === void 0 || b === void 0) return a === b;
6929
+ return a.enabled === b.enabled && a.intervalMs === b.intervalMs && a.maxAdvertisedTtlMs === b.maxAdvertisedTtlMs;
6930
+ }
6931
+ function describeRejection(reason) {
6932
+ return reason instanceof Error ? reason.message : String(reason);
6933
+ }
6529
6934
  function computePrimeRetryDelayMs(attempt) {
6530
6935
  const exponent = Math.max(0, attempt - 1);
6531
6936
  const baseDelay = Math.min(
@@ -6535,7 +6940,8 @@ function computePrimeRetryDelayMs(attempt) {
6535
6940
  const jitter = Math.floor(Math.random() * ANNOTATION_PRIME_RETRY_JITTER_MS);
6536
6941
  return Math.min(ANNOTATION_PRIME_RETRY_MAX_MS, baseDelay + jitter);
6537
6942
  }
6538
- async function startAnnotationPrimeLoop(forwarder, revalidation) {
6943
+ async function startAnnotationPrimeLoop(forwarder, revalidation, upstreamName) {
6944
+ const tag = helioLogTag(upstreamName);
6539
6945
  let stopped = false;
6540
6946
  let primed = false;
6541
6947
  let retryAttempt = 0;
@@ -6563,10 +6969,16 @@ async function startAnnotationPrimeLoop(forwarder, revalidation) {
6563
6969
  if (epoch !== revalidateEpoch) return;
6564
6970
  if (!result.success) {
6565
6971
  console.error(
6566
- `[helio] Tool revalidation failed: ${result.reason ?? "unknown reason"} \u2014 keeping the last baselines; next attempt in ${String(rv.intervalMs)}ms`
6972
+ `${tag} Tool revalidation failed: ${result.reason ?? "unknown reason"} \u2014 keeping the last baselines; next attempt in ${String(rv.intervalMs)}ms`
6567
6973
  );
6568
6974
  }
6569
6975
  scheduleRevalidation();
6976
+ }).catch((reason) => {
6977
+ if (epoch !== revalidateEpoch) return;
6978
+ console.error(
6979
+ `${tag} Tool revalidation attempt failed unexpectedly: ${describeRejection(reason)} \u2014 keeping the cadence`
6980
+ );
6981
+ scheduleRevalidation();
6570
6982
  });
6571
6983
  }, rv.intervalMs);
6572
6984
  revalidateTimer.unref();
@@ -6578,6 +6990,7 @@ async function startAnnotationPrimeLoop(forwarder, revalidation) {
6578
6990
  clearRevalidateTimer();
6579
6991
  };
6580
6992
  const reconfigure = (next) => {
6993
+ if (sameRevalidation(current, next)) return;
6581
6994
  current = next;
6582
6995
  revalidateEpoch += 1;
6583
6996
  clearRevalidateTimer();
@@ -6588,7 +7001,7 @@ async function startAnnotationPrimeLoop(forwarder, revalidation) {
6588
7001
  retryAttempt += 1;
6589
7002
  const delayMs = computePrimeRetryDelayMs(retryAttempt);
6590
7003
  console.error(
6591
- `[helio] Annotation cache prime retry ${String(retryAttempt)} scheduled in ${String(delayMs)}ms`
7004
+ `${tag} Annotation cache prime retry ${String(retryAttempt)} scheduled in ${String(delayMs)}ms`
6592
7005
  );
6593
7006
  retryTimer = setTimeout(() => {
6594
7007
  retryTimer = void 0;
@@ -6601,7 +7014,7 @@ async function startAnnotationPrimeLoop(forwarder, revalidation) {
6601
7014
  if (result.success) {
6602
7015
  primed = true;
6603
7016
  clearRetryTimer();
6604
- const prefix = phase === "initial" ? "[helio] Annotation cache primed" : `[helio] Annotation cache primed after retry ${String(retryAttempt)}`;
7017
+ const prefix = phase === "initial" ? `${tag} Annotation cache primed` : `${tag} Annotation cache primed after retry ${String(retryAttempt)}`;
6605
7018
  console.error(
6606
7019
  `${prefix}: ${String(result.toolsCached)} tool definitions baselined for drift detection (baselines are per-process; a restart re-baselines \u2014 review tool_drift audit records before restarting)`
6607
7020
  );
@@ -6611,18 +7024,26 @@ async function startAnnotationPrimeLoop(forwarder, revalidation) {
6611
7024
  const reason = result.reason ?? "unknown reason";
6612
7025
  if (phase === "initial") {
6613
7026
  console.error(
6614
- `[helio] Annotation cache priming failed: ${reason} \u2014 undocumented tools will be denied (fail-closed) until priming succeeds`
7027
+ `${tag} Annotation cache priming failed: ${reason} \u2014 undocumented tools will be denied (fail-closed) until priming succeeds`
6615
7028
  );
6616
7029
  } else {
6617
7030
  console.error(
6618
- `[helio] Annotation cache prime retry ${String(retryAttempt)} failed: ${reason} \u2014 still fail-closed`
7031
+ `${tag} Annotation cache prime retry ${String(retryAttempt)} failed: ${reason} \u2014 still fail-closed`
6619
7032
  );
6620
7033
  }
6621
7034
  scheduleRetry();
6622
7035
  };
6623
7036
  const runPrimeAttempt = async (phase) => {
6624
- const result = await forwarder.primeAnnotationCache();
6625
- handlePrimeResult(phase, result);
7037
+ try {
7038
+ const result = await forwarder.primeAnnotationCache();
7039
+ handlePrimeResult(phase, result);
7040
+ } catch (reason) {
7041
+ if (stopped || primed) return;
7042
+ console.error(
7043
+ `${tag} Tool revalidation attempt failed unexpectedly: ${describeRejection(reason)} \u2014 keeping the cadence`
7044
+ );
7045
+ scheduleRetry();
7046
+ }
6626
7047
  };
6627
7048
  const initialAttempt = runPrimeAttempt("initial");
6628
7049
  const initialOutcome = await Promise.race([
@@ -6635,7 +7056,7 @@ async function startAnnotationPrimeLoop(forwarder, revalidation) {
6635
7056
  ]);
6636
7057
  if (initialOutcome === "timeout") {
6637
7058
  console.error(
6638
- `[helio] Annotation cache priming did not complete within ${String(ANNOTATION_PRIME_INITIAL_WAIT_MS)}ms; continuing startup fail-closed and retrying in background`
7059
+ `${tag} Annotation cache priming did not complete within ${String(ANNOTATION_PRIME_INITIAL_WAIT_MS)}ms; continuing startup fail-closed and retrying in background`
6639
7060
  );
6640
7061
  scheduleRetry();
6641
7062
  }
@@ -6647,6 +7068,14 @@ import Database from "better-sqlite3";
6647
7068
  import { randomUUID as randomUUID3 } from "crypto";
6648
7069
  import { chmodSync } from "fs";
6649
7070
 
7071
+ // src/startup-error.ts
7072
+ var StartupError = class extends Error {
7073
+ constructor(message) {
7074
+ super(message);
7075
+ this.name = "StartupError";
7076
+ }
7077
+ };
7078
+
6650
7079
  // src/upstream/response-summary.ts
6651
7080
  function extractResponseSummary(body) {
6652
7081
  if (body == null || typeof body !== "object") {
@@ -6741,7 +7170,8 @@ CREATE TABLE IF NOT EXISTS audit_records (
6741
7170
  origin TEXT NOT NULL DEFAULT 'mcp',
6742
7171
  metadata TEXT,
6743
7172
  protocol_version TEXT,
6744
- created_at TEXT NOT NULL
7173
+ created_at TEXT NOT NULL,
7174
+ upstream TEXT
6745
7175
  );
6746
7176
  `;
6747
7177
  var CREATE_INDEX_DDL = `
@@ -6753,6 +7183,7 @@ CREATE INDEX IF NOT EXISTS idx_audit_block_reason ON audit_records (block_re
6753
7183
  CREATE INDEX IF NOT EXISTS idx_audit_upstream_status_created_at ON audit_records (upstream_http_status, created_at);
6754
7184
  CREATE INDEX IF NOT EXISTS idx_audit_record_kind ON audit_records (record_kind);
6755
7185
  CREATE INDEX IF NOT EXISTS idx_audit_origin ON audit_records (origin);
7186
+ CREATE INDEX IF NOT EXISTS idx_audit_upstream ON audit_records (upstream);
6756
7187
  `;
6757
7188
  var INSERT_SQL = `
6758
7189
  INSERT INTO audit_records (
@@ -6761,14 +7192,16 @@ INSERT INTO audit_records (
6761
7192
  approved_by, upstream_response, upstream_error, upstream_latency_ms,
6762
7193
  upstream_http_status,
6763
7194
  total_duration_ms, approval_wait_ms, proxy_compute_ms,
6764
- flagged_destructive, dry_run, record_kind, origin, metadata, protocol_version, created_at
7195
+ flagged_destructive, dry_run, record_kind, origin, metadata, protocol_version, created_at,
7196
+ upstream
6765
7197
  ) VALUES (
6766
7198
  @id, @timestamp, @session_id, @session_source, @agent_id, @environment, @tool_name, @tool_input,
6767
7199
  @policy_decision, @block_reason, @matched_rule, @matched_rule_index, @evidence_chain, @approval_status,
6768
7200
  @approved_by, @upstream_response, @upstream_error, @upstream_latency_ms,
6769
7201
  @upstream_http_status,
6770
7202
  @total_duration_ms, @approval_wait_ms, @proxy_compute_ms,
6771
- @flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @protocol_version, @created_at
7203
+ @flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @protocol_version, @created_at,
7204
+ @upstream
6772
7205
  )
6773
7206
  `;
6774
7207
  var REQUIRED_AUDIT_COLUMNS = [
@@ -6787,7 +7220,11 @@ var REQUIRED_AUDIT_COLUMNS = [
6787
7220
  "session_source",
6788
7221
  // Same clean break, same unreleased cycle (issue #219): released users see
6789
7222
  // ONE break, at v0.12.0.
6790
- "protocol_version"
7223
+ "protocol_version",
7224
+ // The one ratified exception to the clean break (issue #292): a
7225
+ // v0.12.0-complete database missing ONLY this column is migrated in place
7226
+ // by migrateAuditUpstreamColumn instead of failing the assertion.
7227
+ "upstream"
6791
7228
  ];
6792
7229
  function deserializeRow(row) {
6793
7230
  return {
@@ -6819,6 +7256,7 @@ function deserializeRow(row) {
6819
7256
  origin: row.origin,
6820
7257
  metadata: row.metadata ? JSON.parse(row.metadata) : null,
6821
7258
  protocol_version: row.protocol_version,
7259
+ upstream: row.upstream,
6822
7260
  created_at: row.created_at
6823
7261
  };
6824
7262
  }
@@ -6860,6 +7298,14 @@ function buildWhereClause(filters) {
6860
7298
  conditions.push("session_id = ?");
6861
7299
  params.push(filters.session_id);
6862
7300
  }
7301
+ if (filters.session_source !== void 0) {
7302
+ conditions.push("session_source = ?");
7303
+ params.push(filters.session_source);
7304
+ }
7305
+ if (filters.upstream !== void 0) {
7306
+ conditions.push("upstream = ?");
7307
+ params.push(filters.upstream);
7308
+ }
6863
7309
  if (filters.agent_id !== void 0) {
6864
7310
  conditions.push("agent_id = ?");
6865
7311
  params.push(filters.agent_id);
@@ -6891,6 +7337,22 @@ function buildWhereClause(filters) {
6891
7337
  const clause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
6892
7338
  return { clause, params };
6893
7339
  }
7340
+ function migrateAuditUpstreamColumn(db) {
7341
+ const probe = () => {
7342
+ const rows = db.pragma("table_info(audit_records)");
7343
+ return new Set(rows.map((row) => row.name));
7344
+ };
7345
+ const existing = probe();
7346
+ const missing = REQUIRED_AUDIT_COLUMNS.filter((name) => !existing.has(name));
7347
+ if (missing.length !== 1 || missing[0] !== "upstream") return false;
7348
+ try {
7349
+ db.exec("ALTER TABLE audit_records ADD COLUMN upstream TEXT");
7350
+ } catch (err) {
7351
+ if (probe().has("upstream")) return false;
7352
+ throw err;
7353
+ }
7354
+ return true;
7355
+ }
6894
7356
  function restrictAuditFilePerms(dbPath) {
6895
7357
  if (dbPath === ":memory:" || process.platform === "win32") return;
6896
7358
  try {
@@ -6919,6 +7381,9 @@ var AuditStore = class {
6919
7381
  this.retentionMs = parseDuration(options.retention);
6920
7382
  this.includeResponses = options.includeResponses;
6921
7383
  this.db.exec(CREATE_TABLE_DDL);
7384
+ if (migrateAuditUpstreamColumn(this.db)) {
7385
+ console.error('[helio] Audit DB migrated: added column "upstream"');
7386
+ }
6922
7387
  this.assertRequiredSchema(options.path);
6923
7388
  this.db.exec(CREATE_INDEX_DDL);
6924
7389
  this.insertStmt = this.db.prepare(INSERT_SQL);
@@ -6984,7 +7449,7 @@ var AuditStore = class {
6984
7449
  const missing = REQUIRED_AUDIT_COLUMNS.filter((name) => !existing.has(name));
6985
7450
  if (missing.length === 0) return;
6986
7451
  const quotedColumns = missing.map((name) => `"${name}"`).join(", ");
6987
- throw new Error(
7452
+ throw new StartupError(
6988
7453
  `[helio] Audit DB schema mismatch: missing required columns ${quotedColumns}. This local database was created by an older Helio build. Delete "${dbPath}", "${dbPath}-wal", and "${dbPath}-shm", then restart Helio.`
6989
7454
  );
6990
7455
  }
@@ -7027,6 +7492,7 @@ var AuditStore = class {
7027
7492
  origin: record.origin,
7028
7493
  metadata: record.metadata ? JSON.stringify(record.metadata) : null,
7029
7494
  protocol_version: record.protocol_version,
7495
+ upstream: record.upstream ?? null,
7030
7496
  created_at: now
7031
7497
  });
7032
7498
  return resolvedId;
@@ -7102,9 +7568,14 @@ var AuditStore = class {
7102
7568
  const result = this.db.prepare(`SELECT COUNT(*) as total FROM audit_records ${clause}`).get(...params);
7103
7569
  return result.total;
7104
7570
  }
7105
- /** Get aggregate statistics for a time range. */
7106
- aggregate(from, to) {
7107
- const rangeFilters = { from, to };
7571
+ /**
7572
+ * Get aggregate statistics for a time range. The optional upstream filter
7573
+ * scopes EVERY sub-aggregate (totals, by_decision, by_block_reason,
7574
+ * top_tools, approval_rate, per_hour) — "analytics for this door", not one
7575
+ * filtered chart. Exact match: null-upstream rows never match any value.
7576
+ */
7577
+ aggregate(from, to, filters = {}) {
7578
+ const rangeFilters = { from, to, upstream: filters.upstream };
7108
7579
  const { clause, params } = buildWhereClause(rangeFilters);
7109
7580
  const totals = this.db.prepare(
7110
7581
  `SELECT
@@ -7130,9 +7601,9 @@ var AuditStore = class {
7130
7601
  ).all(...params);
7131
7602
  const toolsClause = clause ? `${clause} AND policy_decision NOT IN ${NON_TOOL_DECISIONS_SQL}` : `WHERE policy_decision NOT IN ${NON_TOOL_DECISIONS_SQL}`;
7132
7603
  const top_tools = this.db.prepare(
7133
- `SELECT tool_name, COUNT(*) as count
7604
+ `SELECT tool_name, upstream, COUNT(*) as count
7134
7605
  FROM audit_records ${toolsClause}
7135
- GROUP BY tool_name
7606
+ GROUP BY tool_name, upstream
7136
7607
  ORDER BY count DESC
7137
7608
  LIMIT 10`
7138
7609
  ).all(...params);
@@ -7296,7 +7767,7 @@ var AuditWriter = class {
7296
7767
  };
7297
7768
 
7298
7769
  // src/audit/header-mismatch.ts
7299
- function buildHeaderMismatchAuditRecord(rejection, environment) {
7770
+ function buildHeaderMismatchAuditRecord(rejection, environment, upstream) {
7300
7771
  return {
7301
7772
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
7302
7773
  session_id: rejection.session?.id ?? null,
@@ -7333,7 +7804,10 @@ function buildHeaderMismatchAuditRecord(rejection, environment) {
7333
7804
  record_kind: "tool_call",
7334
7805
  origin: "mcp",
7335
7806
  metadata: null,
7336
- protocol_version: rejection.protocolVersion ?? null
7807
+ protocol_version: rejection.protocolVersion ?? null,
7808
+ // The door context lives with the caller (the composition root), not
7809
+ // the rejection payload; singular composition passes nothing.
7810
+ upstream: upstream ?? null
7337
7811
  };
7338
7812
  }
7339
7813
 
@@ -8149,7 +8623,9 @@ var GovernanceService = class {
8149
8623
  toolName,
8150
8624
  toolArguments: req.arguments,
8151
8625
  sessionId: budgetSessionGate.ok ? budgetSessionGate.session : null,
8152
- senderId
8626
+ senderId,
8627
+ upstream: null
8628
+ // a sideband call has no upstream (issue #295)
8153
8629
  });
8154
8630
  const gatedCharges = charges.length > 0 || failures.length > 0 ? gateBudgetCharges({ charges, failures }, budgetSessionGate) : void 0;
8155
8631
  if (gatedCharges && !gatedCharges.ok) {
@@ -8871,11 +9347,12 @@ var GovernanceService = class {
8871
9347
  };
8872
9348
  }
8873
9349
  planRate(decision, toolName, sessionId, senderId) {
8874
- const limits = decision.matchedRule?.limits;
8875
- if (!this.rateLimiter || !limits?.maxCalls || !limits.windowMs) {
9350
+ const matchedRule = decision.matchedRule;
9351
+ const limits = matchedRule?.limits;
9352
+ if (!this.rateLimiter || !matchedRule || !limits?.maxCalls || !limits.windowMs) {
8876
9353
  return { allowed: true };
8877
9354
  }
8878
- let key;
9355
+ let baseKey;
8879
9356
  if (limits.key === "session") {
8880
9357
  const gate = gateSession(sessionId, this.session.onUnresolved);
8881
9358
  if (!gate.ok) {
@@ -8883,10 +9360,11 @@ var GovernanceService = class {
8883
9360
  return { allowed: false, sessionUnresolved: true };
8884
9361
  }
8885
9362
  if (gate.anonymous) warnAnonymousPoolingOnce();
8886
- key = sessionLimitKey(gate.session);
9363
+ baseKey = sessionLimitKey(gate.session);
8887
9364
  } else {
8888
- key = buildLimitKey(limits.key, toolName, senderId);
9365
+ baseKey = buildLimitKey(limits.key, toolName, senderId);
8889
9366
  }
9367
+ const key = ruleBucketKey(baseKey, matchedRule.index);
8890
9368
  const peek = this.rateLimiter.peek({
8891
9369
  key,
8892
9370
  maxCalls: limits.maxCalls,
@@ -8918,7 +9396,7 @@ var GovernanceService = class {
8918
9396
  } else {
8919
9397
  baseKey = buildLimitKey(maxSpend.key, toolName, senderId);
8920
9398
  }
8921
- const key = spendBucketKey(baseKey, decision.matchedRule.index);
9399
+ const key = ruleBucketKey(baseKey, decision.matchedRule.index);
8922
9400
  const rawAmount = resolvePath(maxSpend.field, args ?? {});
8923
9401
  if (typeof rawAmount !== "number" || !Number.isFinite(rawAmount) || rawAmount < 0) {
8924
9402
  return { allowed: false, block: { reason: "invalid_amount", limit: maxSpend.limit } };
@@ -9077,7 +9555,9 @@ var GovernanceService = class {
9077
9555
  origin: args.origin,
9078
9556
  metadata: args.metadata,
9079
9557
  // The sideband has no MCP wire, so no protocol claim exists.
9080
- protocol_version: null
9558
+ protocol_version: null,
9559
+ // No door on the sideband either: upstream attribution is MCP-only.
9560
+ upstream: null
9081
9561
  };
9082
9562
  const isEnforcement = args.recordKind === "evaluation_expired" || blockReason !== null || args.approvalStatus != null;
9083
9563
  if (isEnforcement) this.auditWriter.pushImmediate(record, id);
@@ -9142,7 +9622,7 @@ function buildLimitKey(keyType, toolName, senderId) {
9142
9622
  case "agent":
9143
9623
  case "tool":
9144
9624
  default:
9145
- return `tool:${toolName}`;
9625
+ return toolLimitKey(toolName);
9146
9626
  }
9147
9627
  }
9148
9628
  function senderIdOf(metadata) {
@@ -9270,6 +9750,9 @@ var ApprovalQueue = class {
9270
9750
  rule_index: params.rule_index,
9271
9751
  channel_name: params.channel_name,
9272
9752
  session_id: params.session_id,
9753
+ // Wire-darkness spelling: set only when attributed, never null.
9754
+ ...params.session_source != null && { session_source: params.session_source },
9755
+ ...params.upstream != null && { upstream: params.upstream },
9273
9756
  requested_at: new Date(now).toISOString(),
9274
9757
  timeout_at: new Date(now + params.timeout_ms).toISOString(),
9275
9758
  timeout_ms: params.timeout_ms,
@@ -9389,6 +9872,8 @@ var ApprovalRouter = class {
9389
9872
  rule_index: rule?.index ?? null,
9390
9873
  channel_name: channelName,
9391
9874
  session_id: params.session_id,
9875
+ session_source: params.session_source,
9876
+ upstream: params.upstream,
9392
9877
  timeout_ms: timeoutMs,
9393
9878
  breached_budgets: params.breached_budgets
9394
9879
  });
@@ -9485,6 +9970,10 @@ var ApprovalRouter = class {
9485
9970
  rule_index: rule?.index ?? null,
9486
9971
  channel_name: `${NATIVE_CHANNEL_PREFIX}${params.origin}`,
9487
9972
  session_id: params.session_id,
9973
+ // Adapter-supplied ids are sideband-attributed by definition (issue
9974
+ // #251); deriving it at this single choke point means no future
9975
+ // adapter can forget it. Upstream stays absent: no MCP door here.
9976
+ session_source: params.session_id != null ? "sideband" : null,
9488
9977
  timeout_ms: timeoutMs,
9489
9978
  breached_budgets: params.breached_budgets
9490
9979
  });
@@ -9654,15 +10143,22 @@ function buildApprovalBlocks(ticket) {
9654
10143
  const safeName = sanitizeCodeSpanContent(ticket.tool_name);
9655
10144
  const rawInput = truncate(JSON.stringify(ticket.tool_input), MAX_INPUT_LENGTH);
9656
10145
  const safeInput = sanitizeForCodeBlock(rawInput);
9657
- const detailLines = [`*Tool:* \`${safeName}\``, `*Input:*
10146
+ const detailLines = [`*Tool:* \`${safeName}\``];
10147
+ if (ticket.upstream) {
10148
+ detailLines.push(`*Upstream:* \`${sanitizeCodeSpanContent(ticket.upstream)}\``);
10149
+ }
10150
+ detailLines.push(`*Input:*
9658
10151
  \`\`\`
9659
10152
  ${safeInput}
9660
- \`\`\``];
10153
+ \`\`\``);
9661
10154
  if (ticket.matched_rule) {
9662
10155
  detailLines.push(`*Rule:* ${sanitizeMrkdwnText(ticket.matched_rule)}`);
9663
10156
  }
9664
10157
  if (ticket.session_id) {
9665
- detailLines.push(`*Session:* \`${sanitizeCodeSpanContent(ticket.session_id)}\``);
10158
+ const sessionLine = `*Session:* \`${sanitizeCodeSpanContent(ticket.session_id)}\``;
10159
+ detailLines.push(
10160
+ ticket.session_source ? `${sessionLine} (${sanitizeMrkdwnText(ticket.session_source)})` : sessionLine
10161
+ );
9666
10162
  }
9667
10163
  const budgetBlocks = buildBudgetBlocks(ticket);
9668
10164
  return [
@@ -10298,14 +10794,18 @@ var BudgetEngine = class {
10298
10794
  /**
10299
10795
  * Resolve which budgets a call feeds and how much it charges each.
10300
10796
  *
10301
- * A contributor participates when its tool glob matches the tool name AND
10302
- * every `match.input` condition holds (absent conditions means the glob
10303
- * alone decides); the FIRST participating contributor (config order, over
10304
- * that combined predicate) supplies the amount field. A call that matches
10305
- * the glob but not the conditions simply does not feed the budget — no
10306
- * charge, no failure. Once a contributor is selected, a missing,
10307
- * non-numeric, negative, or non-finite amount fails closed as a `failures`
10308
- * entry the caller must deny the call.
10797
+ * A contributor participates when its upstream scope admits the call's
10798
+ * door (absent scope admits every door; a scoped contributor never
10799
+ * participates when `ctx.upstream` is null sideband, singular mode) AND
10800
+ * its tool glob matches the tool name AND every `match.input` condition
10801
+ * holds (absent conditions means the glob alone decides); the FIRST
10802
+ * participating contributor (config order, over that combined predicate)
10803
+ * supplies the amount field. A call that matches the glob but not the
10804
+ * conditions or the scope simply does not feed the budget — no charge, no
10805
+ * failure — and a later contributor may still participate. Once a
10806
+ * contributor is selected, a missing, non-numeric, negative, or non-finite
10807
+ * amount fails closed as a `failures` entry — the caller must deny the
10808
+ * call.
10309
10809
  */
10310
10810
  resolveCharges(ctx) {
10311
10811
  const charges = [];
@@ -10316,7 +10816,7 @@ var BudgetEngine = class {
10316
10816
  };
10317
10817
  for (const budget of this.budgets.values()) {
10318
10818
  const contributor = budget.contributors.find(
10319
- (c) => c.match.tool.test(ctx.toolName) && (c.match.input === void 0 || matchInput(c.match.input, matchCtx))
10819
+ (c) => (c.upstreams === void 0 || ctx.upstream !== null && c.upstreams.includes(ctx.upstream)) && c.match.tool.test(ctx.toolName) && (c.match.input === void 0 || matchInput(c.match.input, matchCtx))
10320
10820
  );
10321
10821
  if (!contributor) continue;
10322
10822
  const raw = resolvePath(contributor.field, ctx.toolArguments ?? {});
@@ -10341,7 +10841,8 @@ var BudgetEngine = class {
10341
10841
  budget,
10342
10842
  bucketKey: this.bucketKey(budget, ctx),
10343
10843
  amount: raw,
10344
- generation: this.generations.get(budget.name) ?? 0
10844
+ generation: this.generations.get(budget.name) ?? 0,
10845
+ ...ctx.upstream !== null && { upstream: ctx.upstream }
10345
10846
  });
10346
10847
  }
10347
10848
  return { charges, failures };
@@ -10421,7 +10922,8 @@ var BudgetEngine = class {
10421
10922
  remaining: snapshot.remaining,
10422
10923
  limit: charge.budget.limit,
10423
10924
  currency: charge.budget.currency,
10424
- utilization: snapshot.spent / charge.budget.limit
10925
+ utilization: snapshot.spent / charge.budget.limit,
10926
+ upstream: charge.upstream ?? null
10425
10927
  });
10426
10928
  } catch (err) {
10427
10929
  console.error("[helio] budget onCommit subscriber threw:", err);
@@ -10447,7 +10949,8 @@ var BudgetEngine = class {
10447
10949
  attempted_amount: entry.amount,
10448
10950
  spent: entry.spent,
10449
10951
  limit: entry.budget.limit,
10450
- currency: entry.budget.currency
10952
+ currency: entry.budget.currency,
10953
+ upstream: entry.upstream
10451
10954
  });
10452
10955
  } catch (err) {
10453
10956
  console.error("[helio] budget onBreach subscriber threw:", err);
@@ -10757,7 +11260,8 @@ var BudgetEngine = class {
10757
11260
  allowed: checkedAgainst + charge.amount <= charge.budget.limit,
10758
11261
  spent,
10759
11262
  remaining: Math.max(0, charge.budget.limit - spent),
10760
- resetAtMs
11263
+ resetAtMs,
11264
+ upstream: charge.upstream ?? null
10761
11265
  };
10762
11266
  }
10763
11267
  };
@@ -10866,11 +11370,16 @@ GROUP BY e.bucket_key
10866
11370
  ORDER BY e.bucket_key ASC
10867
11371
  `;
10868
11372
  var LIST_EVENTS_SQL = `
10869
- SELECT id, budget_name, bucket_key, kind, amount, currency, tool_name,
10870
- origin, audit_record_id, timestamp, timestamp_ms, created_at
10871
- FROM budget_events
10872
- WHERE budget_name = ?
10873
- ORDER BY timestamp_ms DESC, rowid DESC
11373
+ SELECT e.id AS id, e.budget_name AS budget_name, e.bucket_key AS bucket_key,
11374
+ e.kind AS kind, e.amount AS amount, e.currency AS currency,
11375
+ e.tool_name AS tool_name, e.origin AS origin,
11376
+ e.audit_record_id AS audit_record_id, e.timestamp AS timestamp,
11377
+ e.timestamp_ms AS timestamp_ms, e.created_at AS created_at,
11378
+ a.upstream AS upstream
11379
+ FROM budget_events e
11380
+ LEFT JOIN audit_records a ON a.id = e.audit_record_id
11381
+ WHERE e.budget_name = ?
11382
+ ORDER BY e.timestamp_ms DESC, e.rowid DESC
10874
11383
  LIMIT ? OFFSET ?
10875
11384
  `;
10876
11385
  var COUNT_EVENTS_SQL = "SELECT COUNT(*) AS total FROM budget_events WHERE budget_name = ?";
@@ -11121,10 +11630,11 @@ var CSV_HEADERS = [
11121
11630
  "record_kind",
11122
11631
  "origin",
11123
11632
  "metadata",
11124
- // Appended LAST (issues #218, #219): positional consumers of the existing
11125
- // columns keep working — new columns always go at the end.
11633
+ // Appended LAST (issues #218, #219, #292): positional consumers of the
11634
+ // existing columns keep working — new columns always go at the end.
11126
11635
  "session_source",
11127
- "protocol_version"
11636
+ "protocol_version",
11637
+ "upstream"
11128
11638
  ];
11129
11639
  var FORMULA_PREFIXES = /^[=+\-@\t\r]/;
11130
11640
  function csvEscape(value) {
@@ -11167,7 +11677,10 @@ var BUDGET_EVENT_CSV_HEADERS = [
11167
11677
  "audit_record_id",
11168
11678
  "timestamp",
11169
11679
  "timestamp_ms",
11170
- "created_at"
11680
+ "created_at",
11681
+ // Appended LAST (issue #292): positional consumers of the existing
11682
+ // columns keep working — new columns always go at the end.
11683
+ "upstream"
11171
11684
  ];
11172
11685
  function eventToRow(event) {
11173
11686
  return BUDGET_EVENT_CSV_HEADERS.map((h) => {
@@ -11309,7 +11822,13 @@ var clampedQueryInt = (fallback, min, max) => z8.preprocess(
11309
11822
  );
11310
11823
  var feedQuerySchema = z8.object({
11311
11824
  limit: clampedQueryInt(50, 1, 200),
11312
- offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER)
11825
+ offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER),
11826
+ // The feed's server-side filters (issues #292, #316): attribution and
11827
+ // session identity source must narrow the fetch window itself, because
11828
+ // slicing an unfiltered newest-N window client-side would miss rare
11829
+ // matches on a busy stream.
11830
+ upstream: optionalQueryString,
11831
+ session_source: optionalQueryString
11313
11832
  });
11314
11833
  var auditExportQuerySchema = z8.object({
11315
11834
  format: z8.preprocess((value) => value === "csv" ? "csv" : "json", z8.enum(["json", "csv"])),
@@ -11328,7 +11847,9 @@ var auditExportQuerySchema = z8.object({
11328
11847
  origin: optionalQueryString,
11329
11848
  record_kind: optionalQueryString,
11330
11849
  channel_id: optionalQueryString,
11331
- sender_id: optionalQueryString
11850
+ sender_id: optionalQueryString,
11851
+ upstream: optionalQueryString,
11852
+ session_source: optionalQueryString
11332
11853
  });
11333
11854
  var auditQuerySchema = z8.object({
11334
11855
  limit: clampedQueryInt(50, 1, LIST_MAX_PAGE_SIZE),
@@ -11348,7 +11869,9 @@ var auditQuerySchema = z8.object({
11348
11869
  origin: optionalQueryString,
11349
11870
  record_kind: optionalQueryString,
11350
11871
  channel_id: optionalQueryString,
11351
- sender_id: optionalQueryString
11872
+ sender_id: optionalQueryString,
11873
+ upstream: optionalQueryString,
11874
+ session_source: optionalQueryString
11352
11875
  });
11353
11876
  var budgetEventsQuerySchema = z8.object({
11354
11877
  limit: clampedQueryInt(50, 1, LIST_MAX_PAGE_SIZE),
@@ -11360,7 +11883,8 @@ var budgetEventsExportQuerySchema = z8.object({
11360
11883
  });
11361
11884
  var analyticsQuerySchema = z8.object({
11362
11885
  from: optionalQueryString,
11363
- to: optionalQueryString
11886
+ to: optionalQueryString,
11887
+ upstream: optionalQueryString
11364
11888
  });
11365
11889
  var authSessionBodySchema = z8.object({
11366
11890
  secret: z8.string()
@@ -11421,6 +11945,8 @@ function isPrivateIpv4(host) {
11421
11945
  if (a > 255 || b > 255 || c > 255 || d > 255) return false;
11422
11946
  return a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168;
11423
11947
  }
11948
+ var MAX_SSE_CONNECTIONS = 256;
11949
+ var REFUSAL_LOG_WINDOW_MS2 = 1e4;
11424
11950
  function createDashboardAppWithLifecycle(deps, options) {
11425
11951
  const {
11426
11952
  auditStore,
@@ -11557,7 +12083,10 @@ function createDashboardAppWithLifecycle(deps, options) {
11557
12083
  const query = feedQuerySchema.parse(c.req.query());
11558
12084
  const limit = query.limit;
11559
12085
  const offset = query.offset;
11560
- const result = auditStore.list({}, { limit, offset, order: "desc" });
12086
+ const result = auditStore.list(
12087
+ { upstream: query.upstream, session_source: query.session_source },
12088
+ { limit, offset, order: "desc" }
12089
+ );
11561
12090
  return c.json({
11562
12091
  data: result.records,
11563
12092
  total: result.total,
@@ -11584,7 +12113,9 @@ function createDashboardAppWithLifecycle(deps, options) {
11584
12113
  origin: query.origin,
11585
12114
  record_kind: query.record_kind,
11586
12115
  channel_id: query.channel_id,
11587
- sender_id: query.sender_id
12116
+ sender_id: query.sender_id,
12117
+ upstream: query.upstream,
12118
+ session_source: query.session_source
11588
12119
  };
11589
12120
  const result = auditStore.listForExport(filters, limit);
11590
12121
  if (format === "csv") {
@@ -11630,7 +12161,9 @@ function createDashboardAppWithLifecycle(deps, options) {
11630
12161
  origin: query.origin,
11631
12162
  record_kind: query.record_kind,
11632
12163
  channel_id: query.channel_id,
11633
- sender_id: query.sender_id
12164
+ sender_id: query.sender_id,
12165
+ upstream: query.upstream,
12166
+ session_source: query.session_source
11634
12167
  };
11635
12168
  const result = auditStore.list(filters, { limit, offset, order: "desc" });
11636
12169
  return c.json({
@@ -11696,7 +12229,7 @@ function createDashboardAppWithLifecycle(deps, options) {
11696
12229
  const defaultFrom = new Date(now.getTime() - 24 * 60 * 60 * 1e3).toISOString();
11697
12230
  const from = query.from ?? defaultFrom;
11698
12231
  const to = query.to ?? now.toISOString();
11699
- const stats = auditStore.aggregate(from, to);
12232
+ const stats = auditStore.aggregate(from, to, { upstream: query.upstream });
11700
12233
  return c.json(stats);
11701
12234
  });
11702
12235
  app.get("/api/evidence/:session_id", (c) => {
@@ -11708,17 +12241,22 @@ function createDashboardAppWithLifecycle(deps, options) {
11708
12241
  let closed = false;
11709
12242
  const heartbeatMs = Math.max(options?.sseHeartbeatMs ?? 3e4, 1e3);
11710
12243
  const staleThresholdMs = heartbeatMs * 3;
11711
- const sweepIntervalMs = Math.max(heartbeatMs * 2, 1e4);
11712
- const sweepInterval = setInterval(() => {
11713
- const now = Date.now();
11714
- for (const [id, conn] of activeConnections) {
11715
- if (now - conn.lastWrite > staleThresholdMs) {
11716
- conn.cleanup();
11717
- activeConnections.delete(id);
12244
+ const sweepIntervalMs = options?.sweepIntervalMs ?? Math.max(heartbeatMs * 2, 1e4);
12245
+ const maxSseConnections = options?.maxSseConnections ?? MAX_SSE_CONNECTIONS;
12246
+ let sweepInterval;
12247
+ if (sweepIntervalMs > 0) {
12248
+ sweepInterval = setInterval(() => {
12249
+ const now = Date.now();
12250
+ for (const [id, conn] of activeConnections) {
12251
+ if (now - conn.lastWrite > staleThresholdMs) {
12252
+ conn.cleanup();
12253
+ conn.sever();
12254
+ activeConnections.delete(id);
12255
+ }
11718
12256
  }
11719
- }
11720
- }, sweepIntervalMs);
11721
- sweepInterval.unref();
12257
+ }, sweepIntervalMs);
12258
+ sweepInterval.unref();
12259
+ }
11722
12260
  const close = () => {
11723
12261
  if (closed) return;
11724
12262
  closed = true;
@@ -11728,7 +12266,22 @@ function createDashboardAppWithLifecycle(deps, options) {
11728
12266
  }
11729
12267
  activeConnections.clear();
11730
12268
  };
12269
+ let refusalCount = 0;
12270
+ let lastRefusalLogAt = null;
12271
+ const logRefusal = () => {
12272
+ refusalCount += 1;
12273
+ const now = Date.now();
12274
+ if (lastRefusalLogAt !== null && now - lastRefusalLogAt < REFUSAL_LOG_WINDOW_MS2) return;
12275
+ lastRefusalLogAt = now;
12276
+ console.error(
12277
+ `[helio] /api/events at connection cap (${String(maxSseConnections)}); refusing new streams (${String(refusalCount)} refusals so far).`
12278
+ );
12279
+ };
11731
12280
  app.get("/api/events", (c) => {
12281
+ if (activeConnections.size >= maxSseConnections) {
12282
+ logRefusal();
12283
+ return c.json({ error: "connection capacity reached" }, 503);
12284
+ }
11732
12285
  return streamSSE(c, async (stream) => {
11733
12286
  if (closed) return;
11734
12287
  const connId = randomUUID8();
@@ -11749,7 +12302,12 @@ function createDashboardAppWithLifecycle(deps, options) {
11749
12302
  activeConnections.delete(connId);
11750
12303
  releaseStream();
11751
12304
  };
11752
- activeConnections.set(connId, { cleanup, lastWrite: Date.now() });
12305
+ const sever = () => {
12306
+ stream.abort();
12307
+ c.env?.outgoing?.destroy();
12308
+ };
12309
+ if (activeConnections.size >= maxSseConnections) return;
12310
+ activeConnections.set(connId, { cleanup, sever, lastWrite: Date.now() });
11753
12311
  try {
11754
12312
  await stream.writeSSE({ data: "", event: "heartbeat" });
11755
12313
  } catch {
@@ -11852,6 +12410,69 @@ var DashboardEventBus = class {
11852
12410
  this.emitter.removeAllListeners();
11853
12411
  }
11854
12412
  };
12413
+ function actionEventFromRecord(record, id) {
12414
+ return {
12415
+ id,
12416
+ tool_name: record.tool_name,
12417
+ policy_decision: record.policy_decision,
12418
+ block_reason: record.block_reason,
12419
+ approval_status: record.approval_status,
12420
+ session_id: record.session_id,
12421
+ session_source: record.session_source,
12422
+ protocol_version: record.protocol_version,
12423
+ agent_id: record.agent_id,
12424
+ environment: record.environment,
12425
+ timestamp: record.timestamp,
12426
+ total_duration_ms: record.total_duration_ms,
12427
+ approval_wait_ms: record.approval_wait_ms,
12428
+ proxy_compute_ms: record.proxy_compute_ms,
12429
+ flagged_destructive: record.flagged_destructive,
12430
+ dry_run: record.dry_run,
12431
+ matched_rule: record.matched_rule,
12432
+ matched_rule_index: record.matched_rule_index,
12433
+ record_kind: record.record_kind,
12434
+ origin: record.origin,
12435
+ upstream: record.upstream
12436
+ };
12437
+ }
12438
+ function approvalRequestedEvent(ticket) {
12439
+ return {
12440
+ ticket_id: ticket.id,
12441
+ tool_name: ticket.tool_name,
12442
+ channel: ticket.channel_name,
12443
+ requested_at: ticket.requested_at,
12444
+ upstream: ticket.upstream ?? null
12445
+ };
12446
+ }
12447
+ function limitWarningEvent(type, key, current, limit) {
12448
+ return {
12449
+ key,
12450
+ type,
12451
+ current,
12452
+ limit,
12453
+ utilization: current / limit,
12454
+ upstream: upstreamFromLimitKey(key)
12455
+ };
12456
+ }
12457
+ function dashboardEventCallbacks(bus) {
12458
+ return {
12459
+ onPersist: (record, id) => {
12460
+ bus.emit("action", actionEventFromRecord(record, id));
12461
+ },
12462
+ onApprovalSubmit: (ticket) => {
12463
+ bus.emit("approval_requested", approvalRequestedEvent(ticket));
12464
+ },
12465
+ onRateWarning: (state) => {
12466
+ bus.emit("limit_warning", limitWarningEvent("rate", state.key, state.current, state.limit));
12467
+ },
12468
+ onSpendWarning: (state) => {
12469
+ bus.emit(
12470
+ "limit_warning",
12471
+ limitWarningEvent("spend", state.key, state.current_spend, state.limit)
12472
+ );
12473
+ }
12474
+ };
12475
+ }
11855
12476
 
11856
12477
  // src/startup-warnings.ts
11857
12478
  function isLoopbackHost2(host) {
@@ -11871,6 +12492,32 @@ function warnIfBudgetWindowExceedsRetention(config, log = console.error) {
11871
12492
  }
11872
12493
  return warned;
11873
12494
  }
12495
+ function warnIfManyUpstreams(config, log = console.error) {
12496
+ const count = config.upstreams.length;
12497
+ if (count <= 16) return false;
12498
+ log(
12499
+ `[helio] Warning: ${String(count)} upstreams configured. Each upstream runs its own upstream connection or child process plus an annotation prime loop; consider whether one proxy should govern this many.`
12500
+ );
12501
+ return true;
12502
+ }
12503
+ function warnIfStdioUrlIgnored(config, log = console.error) {
12504
+ let warned = false;
12505
+ const warn = (path) => {
12506
+ log(
12507
+ `[helio] Warning: ${path} is ignored when transport is "stdio" (the stdio forwarder spawns "command"). Remove the field to silence this warning.`
12508
+ );
12509
+ warned = true;
12510
+ };
12511
+ if (config.upstream?.transport === "stdio" && config.upstream.url !== void 0) {
12512
+ warn("upstream.url");
12513
+ }
12514
+ config.upstreams?.forEach((entry, index) => {
12515
+ if (entry.transport === "stdio" && entry.url !== void 0) {
12516
+ warn(`upstreams.${String(index)}.url`);
12517
+ }
12518
+ });
12519
+ return warned;
12520
+ }
11874
12521
  function warnIfWebhookChannelUnreachable(config, log = console.error) {
11875
12522
  const hasWebhookChannel = config.approval.channels.some((ch) => ch.type === "webhook");
11876
12523
  const localOnlyDashboard = config.dashboard.enabled && isLoopbackHost2(config.dashboard.host);
@@ -11906,7 +12553,7 @@ function warnIfNoEnforcement(policy, log = console.error) {
11906
12553
 
11907
12554
  // src/shutdown.ts
11908
12555
  async function closeResources(resources) {
11909
- resources.annotationPrime?.stop();
12556
+ for (const prime of resources.annotationPrimes ?? []) prime.stop();
11910
12557
  resources.configWatcher?.close();
11911
12558
  resources.approvalRouter?.close();
11912
12559
  resources.approvalQueue?.close();
@@ -11921,7 +12568,7 @@ async function closeResources(resources) {
11921
12568
  resources.budgetEngine?.close();
11922
12569
  resources.evidenceStore?.close();
11923
12570
  resources.auditWriter?.close();
11924
- if (resources.closeForwarder) await resources.closeForwarder();
12571
+ for (const close of resources.closeForwarders ?? []) await close();
11925
12572
  }
11926
12573
 
11927
12574
  // src/crash-drain.ts
@@ -11995,6 +12642,12 @@ upstream:
11995
12642
  # headers:
11996
12643
  # Authorization: "Bearer \${UPSTREAM_TOKEN}"
11997
12644
 
12645
+ # Multiple named upstreams (multi-upstream mode) replace \`upstream:\` \u2014
12646
+ # set exactly one of the two. See docs/configuration.md.
12647
+ # upstreams:
12648
+ # - name: files
12649
+ # url: "http://localhost:8081/mcp"
12650
+
11998
12651
  # listen:
11999
12652
  # port: 3000
12000
12653
  # host: 127.0.0.1
@@ -12067,6 +12720,28 @@ function printConfigErrorDetails(error, prefix = "") {
12067
12720
  console.error(`${prefix} ${detail.path}: ${detail.message}`);
12068
12721
  }
12069
12722
  }
12723
+ async function connectUpstream(upstream, upstreamName) {
12724
+ try {
12725
+ return await createForwarderFromConfig({ upstream }, upstreamName);
12726
+ } catch (err) {
12727
+ const message = err instanceof Error ? err.message : String(err);
12728
+ throw new StartupError(
12729
+ upstreamName === void 0 ? message : `upstream "${upstreamName}": ${message}`
12730
+ );
12731
+ }
12732
+ }
12733
+ async function governUpstream(options) {
12734
+ const governedForwarder = new GovernedForwarder(options.forwarder, options.policy, {
12735
+ ...options.governance,
12736
+ upstreamName: options.upstreamName
12737
+ });
12738
+ const annotationPrime = await startAnnotationPrimeLoop(
12739
+ governedForwarder,
12740
+ options.policy.toolRevalidation,
12741
+ options.upstreamName
12742
+ );
12743
+ return { governedForwarder, annotationPrime };
12744
+ }
12070
12745
  async function startCommand(configPath, options) {
12071
12746
  let config;
12072
12747
  try {
@@ -12086,7 +12761,29 @@ async function startCommand(configPath, options) {
12086
12761
  );
12087
12762
  process.exit(1);
12088
12763
  }
12089
- const { forwarder, close: closeForwarder } = await createForwarderFromConfig(config);
12764
+ if (isNamedConfig(config)) {
12765
+ warnIfManyUpstreams(config);
12766
+ }
12767
+ warnIfStdioUrlIgnored(config);
12768
+ const upstreamSections = isNamedConfig(config) ? config.upstreams.map((entry) => ({ name: entry.name, upstream: entry })) : [{ name: void 0, upstream: config.upstream }];
12769
+ const doors = [];
12770
+ for (const { name, upstream } of upstreamSections) {
12771
+ try {
12772
+ const built = await connectUpstream(upstream, name);
12773
+ doors.push({ name, forwarder: built.forwarder, close: built.close });
12774
+ } catch (err) {
12775
+ for (const door of doors) {
12776
+ try {
12777
+ await door.close?.();
12778
+ } catch (closeErr) {
12779
+ console.error(
12780
+ `[helio] Ignoring a forwarder close failure while aborting startup: ${String(closeErr)}`
12781
+ );
12782
+ }
12783
+ }
12784
+ throw err;
12785
+ }
12786
+ }
12090
12787
  const { policy, warnings } = compilePolicies(config.policies);
12091
12788
  for (const w of warnings) {
12092
12789
  const label = w.ruleName ? `rule "${w.ruleName}"` : `rule ${String(w.ruleIndex)}`;
@@ -12094,6 +12791,7 @@ async function startCommand(configPath, options) {
12094
12791
  }
12095
12792
  const budgets = compileBudgets(config.budgets);
12096
12793
  const eventBus = new DashboardEventBus();
12794
+ const cbs = dashboardEventCallbacks(eventBus);
12097
12795
  const auditStore = new AuditStore({
12098
12796
  path: config.audit.path,
12099
12797
  retention: config.audit.retention,
@@ -12106,30 +12804,7 @@ async function startCommand(configPath, options) {
12106
12804
  auditStore.runRetentionSweep();
12107
12805
  const auditWriter = new AuditWriter({
12108
12806
  store: auditStore,
12109
- onPersist: (record, id) => {
12110
- eventBus.emit("action", {
12111
- id,
12112
- tool_name: record.tool_name,
12113
- policy_decision: record.policy_decision,
12114
- block_reason: record.block_reason,
12115
- approval_status: record.approval_status,
12116
- session_id: record.session_id,
12117
- session_source: record.session_source,
12118
- protocol_version: record.protocol_version,
12119
- agent_id: record.agent_id,
12120
- environment: record.environment,
12121
- timestamp: record.timestamp,
12122
- total_duration_ms: record.total_duration_ms,
12123
- approval_wait_ms: record.approval_wait_ms,
12124
- proxy_compute_ms: record.proxy_compute_ms,
12125
- flagged_destructive: record.flagged_destructive,
12126
- dry_run: record.dry_run,
12127
- matched_rule: record.matched_rule,
12128
- matched_rule_index: record.matched_rule_index,
12129
- record_kind: record.record_kind,
12130
- origin: record.origin
12131
- });
12132
- }
12807
+ onPersist: cbs.onPersist
12133
12808
  });
12134
12809
  registerCrashDrainHook(() => {
12135
12810
  try {
@@ -12147,14 +12822,7 @@ async function startCommand(configPath, options) {
12147
12822
  defaultOnTimeout: config.approval.default_on_timeout,
12148
12823
  channels,
12149
12824
  queue: approvalQueue,
12150
- onSubmit: (ticket) => {
12151
- eventBus.emit("approval_requested", {
12152
- ticket_id: ticket.id,
12153
- tool_name: ticket.tool_name,
12154
- channel: ticket.channel_name,
12155
- requested_at: ticket.requested_at
12156
- });
12157
- },
12825
+ onSubmit: cbs.onApprovalSubmit,
12158
12826
  onResolve: (ticket) => {
12159
12827
  eventBus.emit("approval_resolved", {
12160
12828
  ticket_id: ticket.id,
@@ -12168,26 +12836,10 @@ async function startCommand(configPath, options) {
12168
12836
  }
12169
12837
  });
12170
12838
  const rateLimiter = new RateLimiter({
12171
- onWarning: (state) => {
12172
- eventBus.emit("limit_warning", {
12173
- key: state.key,
12174
- type: "rate",
12175
- current: state.current,
12176
- limit: state.limit,
12177
- utilization: state.current / state.limit
12178
- });
12179
- }
12839
+ onWarning: cbs.onRateWarning
12180
12840
  });
12181
12841
  const spendLimiter = new SpendLimiter({
12182
- onWarning: (state) => {
12183
- eventBus.emit("limit_warning", {
12184
- key: state.key,
12185
- type: "spend",
12186
- current: state.current_spend,
12187
- limit: state.limit,
12188
- utilization: state.current_spend / state.limit
12189
- });
12190
- }
12842
+ onWarning: cbs.onSpendWarning
12191
12843
  });
12192
12844
  const budgetEngine = new BudgetEngine({
12193
12845
  budgets,
@@ -12201,7 +12853,7 @@ async function startCommand(configPath, options) {
12201
12853
  });
12202
12854
  budgetEngine.hydrate();
12203
12855
  const session = compileSessionIdentity(config.session);
12204
- const governedForwarder = new GovernedForwarder(forwarder, policy, {
12856
+ const governance = {
12205
12857
  environment: config.environment,
12206
12858
  auditWriter,
12207
12859
  evidenceStore,
@@ -12210,16 +12862,47 @@ async function startCommand(configPath, options) {
12210
12862
  spendLimiter,
12211
12863
  budgetEngine,
12212
12864
  session
12213
- });
12214
- const annotationPrime = await startAnnotationPrimeLoop(governedForwarder, policy.toolRevalidation);
12865
+ };
12866
+ const stacks = [];
12867
+ for (const door of doors) {
12868
+ stacks.push({
12869
+ name: door.name,
12870
+ ...await governUpstream({
12871
+ forwarder: door.forwarder,
12872
+ policy,
12873
+ governance,
12874
+ upstreamName: door.name
12875
+ })
12876
+ });
12877
+ }
12215
12878
  const hasSlackChannels = [...channels.values()].some((ch) => ch.type === "slack");
12216
12879
  const slackActionApp = hasSlackChannels ? createSlackActionApp({ router: approvalRouter, channels }) : void 0;
12217
- const app = createApp(config, governedForwarder, {
12218
- slackActionApp,
12219
- onHeaderMismatch: (rejection) => {
12220
- auditWriter.pushImmediate(buildHeaderMismatchAuditRecord(rejection, config.environment));
12880
+ let app;
12881
+ if (isNamedConfig(config)) {
12882
+ const forwarders = {};
12883
+ for (const stack of stacks) {
12884
+ if (stack.name !== void 0) forwarders[stack.name] = stack.governedForwarder;
12885
+ }
12886
+ app = createMultiApp(config, forwarders, {
12887
+ slackActionApp,
12888
+ onHeaderMismatch: (rejection, upstreamName) => {
12889
+ auditWriter.pushImmediate(
12890
+ buildHeaderMismatchAuditRecord(rejection, config.environment, upstreamName)
12891
+ );
12892
+ }
12893
+ });
12894
+ } else {
12895
+ const stack = stacks[0];
12896
+ if (stack === void 0) {
12897
+ throw new Error("unreachable: singular mode connects exactly one upstream");
12221
12898
  }
12222
- });
12899
+ app = createApp(config, stack.governedForwarder, {
12900
+ slackActionApp,
12901
+ onHeaderMismatch: (rejection) => {
12902
+ auditWriter.pushImmediate(buildHeaderMismatchAuditRecord(rejection, config.environment));
12903
+ }
12904
+ });
12905
+ }
12223
12906
  const handle = startServer(app, config);
12224
12907
  let sidebandHandle;
12225
12908
  let sidebandToken;
@@ -12308,10 +12991,18 @@ async function startCommand(configPath, options) {
12308
12991
  `Policies: ${String(ruleCount)} rule${ruleCount !== 1 ? "s" : ""} loaded (default: ${policy.defaultAction})`
12309
12992
  );
12310
12993
  warnIfNoEnforcement(policy);
12311
- if (config.upstream.transport === "stdio") {
12994
+ if (isNamedConfig(config)) {
12995
+ for (const entry of config.upstreams) {
12996
+ if (entry.transport === "stdio") {
12997
+ console.error(`Upstream[${entry.name}]: ${entry.command ?? ""} (stdio)`);
12998
+ } else {
12999
+ console.error(`Upstream[${entry.name}]: ${entry.url ?? ""} (${entry.transport})`);
13000
+ }
13001
+ }
13002
+ } else if (config.upstream.transport === "stdio") {
12312
13003
  console.error(`Upstream: ${config.upstream.command ?? ""} (stdio)`);
12313
13004
  } else {
12314
- console.error(`Upstream: ${config.upstream.url} (${config.upstream.transport})`);
13005
+ console.error(`Upstream: ${config.upstream.url ?? ""} (${config.upstream.transport})`);
12315
13006
  }
12316
13007
  console.error(`Audit: ${config.audit.path} (retention: ${config.audit.retention})`);
12317
13008
  if (sidebandHandle) {
@@ -12373,8 +13064,7 @@ async function startCommand(configPath, options) {
12373
13064
  );
12374
13065
  }
12375
13066
  budgetEngine.reconcile(newBudgets);
12376
- governedForwarder.updatePolicy(newPolicy);
12377
- annotationPrime.reconfigure(newPolicy.toolRevalidation);
13067
+ applyReloadedPolicy(stacks, newPolicy);
12378
13068
  governanceService?.updatePolicy(newPolicy);
12379
13069
  const budgetTotal = newBudgets.length;
12380
13070
  console.error(
@@ -12415,8 +13105,8 @@ async function startCommand(configPath, options) {
12415
13105
  }
12416
13106
  registerShutdown(
12417
13107
  handle,
12418
- annotationPrime,
12419
- closeForwarder,
13108
+ stacks.map((stack) => stack.annotationPrime),
13109
+ doors.flatMap((door) => door.close ? [door.close] : []),
12420
13110
  auditWriter,
12421
13111
  configWatcher,
12422
13112
  sidebandHandle,
@@ -12456,6 +13146,10 @@ async function validateCommand(configPath) {
12456
13146
  console.error(`Warning: policy ${label}: ${w.message}`);
12457
13147
  }
12458
13148
  compileBudgets(config.budgets);
13149
+ if (isNamedConfig(config)) {
13150
+ warnIfManyUpstreams(config);
13151
+ }
13152
+ warnIfStdioUrlIgnored(config);
12459
13153
  if (config.dashboard.enabled && !getBundledDashboardDistPath()) {
12460
13154
  console.error(
12461
13155
  "Invalid config: dashboard.enabled is true but bundled dashboard assets are missing. " + DASHBOARD_ASSETS_RECOVERY_MESSAGE_FOR_VALIDATE
@@ -12496,6 +13190,7 @@ async function exportCommand(opts) {
12496
13190
  ["--decision", opts.decision],
12497
13191
  ["--reason", opts.reason],
12498
13192
  ["--session", opts.session],
13193
+ ["--upstream", opts.upstream],
12499
13194
  ["--from", opts.from],
12500
13195
  ["--to", opts.to]
12501
13196
  ].filter(([, value]) => value !== void 0);
@@ -12542,6 +13237,7 @@ async function exportCommand(opts) {
12542
13237
  policy_decision: opts.decision,
12543
13238
  block_reason: opts.reason,
12544
13239
  session_id: opts.session,
13240
+ upstream: opts.upstream,
12545
13241
  from: opts.from,
12546
13242
  to: opts.to
12547
13243
  },
@@ -12571,7 +13267,7 @@ function writeCsv(records) {
12571
13267
  console.log(values.join(","));
12572
13268
  }
12573
13269
  }
12574
- function registerShutdown(handle, annotationPrime, closeForwarder, auditWriter, configWatcher, sidebandHandle, evidenceStore, approvalRouter, approvalQueue, rateLimiter, spendLimiter, budgetEngine, closeDashboardApp, dashboardHandle, eventBus, governanceService) {
13270
+ function registerShutdown(handle, annotationPrimes, closeForwarders, auditWriter, configWatcher, sidebandHandle, evidenceStore, approvalRouter, approvalQueue, rateLimiter, spendLimiter, budgetEngine, closeDashboardApp, dashboardHandle, eventBus, governanceService) {
12575
13271
  let isShuttingDown = false;
12576
13272
  const shutdown = () => {
12577
13273
  if (isShuttingDown) return;
@@ -12584,8 +13280,8 @@ function registerShutdown(handle, annotationPrime, closeForwarder, auditWriter,
12584
13280
  forceShutdownTimer.unref();
12585
13281
  void closeResources({
12586
13282
  handle,
12587
- annotationPrime,
12588
- closeForwarder,
13283
+ annotationPrimes,
13284
+ closeForwarders,
12589
13285
  auditWriter,
12590
13286
  configWatcher,
12591
13287
  sidebandHandle,
@@ -12613,9 +13309,17 @@ function registerShutdown(handle, annotationPrime, closeForwarder, auditWriter,
12613
13309
  }
12614
13310
  var program = new Command().name("helio").description("Helio MCP governance proxy").version(VERSION);
12615
13311
  program.command("start").description("Load config and start the proxy server").option("-c, --config <path>", "Path to helio.yaml", DEFAULT_CONFIG_PATH).option("--no-hot-reload", "Disable policy hot-reload \u2014 config edits will require a restart").action(
12616
- (opts) => startCommand(opts.config, { config: opts.config, noHotReload: opts.hotReload === false })
13312
+ (opts) => startCommand(opts.config, { config: opts.config, noHotReload: opts.hotReload === false }).catch(
13313
+ (err) => {
13314
+ if (err instanceof StartupError) {
13315
+ console.error(err.message);
13316
+ process.exit(1);
13317
+ }
13318
+ throw err;
13319
+ }
13320
+ )
12617
13321
  );
12618
13322
  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));
12619
13323
  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));
12620
- program.command("export").description("Export audit records or a budget ledger 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("--budgets <name>", "Export the named budget ledger instead of the audit trail").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));
13324
+ program.command("export").description("Export audit records or a budget ledger 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("--budgets <name>", "Export the named budget ledger instead of the audit trail").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("--upstream <name>", "Filter by upstream name").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));
12621
13325
  program.parse();