@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/index.js CHANGED
@@ -35,6 +35,11 @@ var RESERVED_TRANSPORT_HEADERS = /* @__PURE__ */ new Set([
35
35
  "content-type",
36
36
  "content-length",
37
37
  "host",
38
+ // The Accept is Helio-owned per HTTP upstream leg: where Helio
39
+ // advertises at all it advertises its own response parsing (the SSE
40
+ // message POSTs assert none), so an operator value could only
41
+ // misadvertise it, never extend it (issue #304).
42
+ "accept",
38
43
  // Modern (2026-07-28) transport headers Helio owns on the wire for every
39
44
  // Streamable HTTP POST it sends upstream — relayed client traffic and
40
45
  // proxy-initiated requests (era probe, revalidation) alike — see
@@ -44,8 +49,8 @@ var RESERVED_TRANSPORT_HEADERS = /* @__PURE__ */ new Set([
44
49
  ]);
45
50
  var transportSchema = z.enum(["streamable-http", "sse", "stdio"]);
46
51
  var protocolVersionSchema = z.enum(["auto", "2025-06-18", "2026-07-28"]);
47
- var upstreamSchema = z.object({
48
- url: z.string(),
52
+ var upstreamObjectSchema = z.object({
53
+ url: z.string().optional(),
49
54
  transport: transportSchema.default("streamable-http"),
50
55
  protocol_version: protocolVersionSchema.default("auto"),
51
56
  command: z.string().optional(),
@@ -54,10 +59,22 @@ var upstreamSchema = z.object({
54
59
  request_timeout: durationSchema.default("30s"),
55
60
  forward_headers: z.array(z.string().min(1)).default([]),
56
61
  headers: z.record(z.string(), z.string()).default({})
57
- }).strict().refine((data) => data.transport !== "stdio" || data.command !== void 0, {
58
- message: '"command" is required when transport is "stdio"',
59
- path: ["command"]
60
- }).superRefine((data, ctx) => {
62
+ }).strict();
63
+ function upstreamEntryChecks(data, ctx) {
64
+ if (data.transport === "stdio" && data.command === void 0) {
65
+ ctx.addIssue({
66
+ code: "custom",
67
+ path: ["command"],
68
+ message: '"command" is required when transport is "stdio"'
69
+ });
70
+ }
71
+ if (data.transport !== "stdio" && data.url === void 0) {
72
+ ctx.addIssue({
73
+ code: "custom",
74
+ path: ["url"],
75
+ message: `"url" is required when transport is "${data.transport}"`
76
+ });
77
+ }
61
78
  if (data.protocol_version === "2026-07-28" && data.transport !== "streamable-http") {
62
79
  ctx.addIssue({
63
80
  code: "custom",
@@ -83,6 +100,26 @@ var upstreamSchema = z.object({
83
100
  });
84
101
  }
85
102
  }
103
+ }
104
+ var upstreamSchema = upstreamObjectSchema.superRefine(upstreamEntryChecks);
105
+ var upstreamNameSchema = z.string().min(1).max(64).regex(/^[a-zA-Z0-9_-]+$/, {
106
+ message: 'Upstream names may only contain letters, digits, "_" and "-"'
107
+ });
108
+ var namedUpstreamEntrySchema = z.object({ name: upstreamNameSchema, ...upstreamObjectSchema.shape }).strict().superRefine(upstreamEntryChecks);
109
+ var upstreamsListSchema = z.array(namedUpstreamEntrySchema).min(1, {
110
+ 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.'
111
+ }).superRefine((entries, ctx) => {
112
+ const seen = /* @__PURE__ */ new Set();
113
+ for (const [index, entry] of entries.entries()) {
114
+ if (seen.has(entry.name)) {
115
+ ctx.addIssue({
116
+ code: "custom",
117
+ path: [index, "name"],
118
+ message: `Duplicate upstream name "${entry.name}". Upstream names embed in mount paths, limiter keys, and audit records \u2014 each upstream needs its own.`
119
+ });
120
+ }
121
+ seen.add(entry.name);
122
+ }
86
123
  });
87
124
  var listenSchema = z.object({
88
125
  port: z.number().int().min(1).max(65535).default(3e3),
@@ -241,7 +278,11 @@ var matchSchema = z.object({
241
278
  annotations: annotationsMatchSchema.optional(),
242
279
  input: z.record(z.string(), inputConditionSchema).optional(),
243
280
  environment: z.string().optional(),
244
- metadata: z.record(z.string(), metadataConditionSchema).optional()
281
+ metadata: z.record(z.string(), metadataConditionSchema).optional(),
282
+ /** Configured upstream names the rule is scoped to (issue #293). */
283
+ upstreams: z.array(z.string().min(1)).min(1, {
284
+ message: "match.upstreams must name at least one upstream \u2014 an empty list matches nothing."
285
+ }).optional()
245
286
  }).strict();
246
287
  var policyActionSchema = z.enum([
247
288
  "allow",
@@ -364,7 +405,11 @@ var budgetContributorMatchSchema = z.object({
364
405
  // Same operators and AND-combination as rule `match.input`. Other rule
365
406
  // matchers (annotations, environment, metadata) stay strict-rejected
366
407
  // until the budget charge context can actually evaluate them.
367
- input: z.record(z.string(), inputConditionSchema).optional()
408
+ input: z.record(z.string(), inputConditionSchema).optional(),
409
+ /** Configured upstream names the contributor is scoped to (issue #293). */
410
+ upstreams: z.array(z.string().min(1)).min(1, {
411
+ message: "match.upstreams must name at least one upstream \u2014 an empty list matches nothing."
412
+ }).optional()
368
413
  }).strict();
369
414
  var modernBudgetContributorSchema = z.object({
370
415
  match: budgetContributorMatchSchema,
@@ -476,9 +521,7 @@ var sdkSchema = z.object({
476
521
  */
477
522
  evaluation_ttl: durationSchema.default("10m")
478
523
  }).strict();
479
- var helioConfigBaseSchema = z.object({
480
- version: z.literal("1"),
481
- upstream: upstreamSchema,
524
+ var rootSectionSchemas = {
482
525
  listen: listenSchema.prefault({}),
483
526
  environment: z.string().optional(),
484
527
  // Session precedes policies deliberately: upstream/listen/environment say
@@ -495,6 +538,16 @@ var helioConfigBaseSchema = z.object({
495
538
  // the request path (canonical section order, #89/#163).
496
539
  dashboard: dashboardSchema.prefault({}),
497
540
  sdk: sdkSchema.prefault({})
541
+ };
542
+ var singularConfigBase = z.object({
543
+ version: z.literal("1"),
544
+ upstream: upstreamSchema,
545
+ ...rootSectionSchemas
546
+ }).strict();
547
+ var namedConfigBase = z.object({
548
+ version: z.literal("1"),
549
+ upstreams: upstreamsListSchema,
550
+ ...rootSectionSchemas
498
551
  }).strict();
499
552
  function stripRootExtensionKeys(value) {
500
553
  if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
@@ -502,7 +555,7 @@ function stripRootExtensionKeys(value) {
502
555
  Object.entries(value).filter(([key]) => !key.startsWith("x-"))
503
556
  );
504
557
  }
505
- var helioConfigRefinedSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
558
+ function rootConfigChecks(cfg, ctx) {
506
559
  const hasConfiguredEnvironment = typeof cfg.environment === "string" && cfg.environment.trim().length > 0;
507
560
  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");
508
561
  const hasSecret = hasDashboardApiSecret(cfg.dashboard.api_secret);
@@ -722,8 +775,166 @@ var helioConfigRefinedSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
722
775
  }
723
776
  }
724
777
  }
778
+ }
779
+ function upstreamVocabularyChecks(cfg, ctx, configuredNames) {
780
+ for (const [ruleIndex, rule] of cfg.policies.rules.entries()) {
781
+ const upstreams = rule.match.upstreams;
782
+ if (upstreams === void 0) continue;
783
+ if (configuredNames === null) {
784
+ ctx.addIssue({
785
+ code: "custom",
786
+ path: ["policies", "rules", ruleIndex, "match", "upstreams"],
787
+ 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.'
788
+ });
789
+ } else {
790
+ for (const [entryIndex, name] of upstreams.entries()) {
791
+ if (!configuredNames.has(name)) {
792
+ ctx.addIssue({
793
+ code: "custom",
794
+ path: ["policies", "rules", ruleIndex, "match", "upstreams", entryIndex],
795
+ 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.`
796
+ });
797
+ }
798
+ }
799
+ }
800
+ if (rule.match.metadata !== void 0) {
801
+ ctx.addIssue({
802
+ code: "custom",
803
+ path: ["policies", "rules", ruleIndex, "match", "upstreams"],
804
+ 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."
805
+ });
806
+ }
807
+ if (rule.limits?.key === "sender_id") {
808
+ ctx.addIssue({
809
+ code: "custom",
810
+ path: ["policies", "rules", ruleIndex, "limits", "key"],
811
+ 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.'
812
+ });
813
+ }
814
+ if (rule.limits?.max_spend?.key === "sender_id") {
815
+ ctx.addIssue({
816
+ code: "custom",
817
+ path: ["policies", "rules", ruleIndex, "limits", "max_spend", "key"],
818
+ 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.'
819
+ });
820
+ }
821
+ }
822
+ for (const [budgetIndex, budget] of cfg.budgets.entries()) {
823
+ let hasUnscopedContributor = false;
824
+ for (const [contributorIndex, contributor] of budget.contributors.entries()) {
825
+ const upstreams = contributor.match?.upstreams;
826
+ if (upstreams === void 0) {
827
+ hasUnscopedContributor = true;
828
+ continue;
829
+ }
830
+ if (configuredNames === null) {
831
+ ctx.addIssue({
832
+ code: "custom",
833
+ path: ["budgets", budgetIndex, "contributors", contributorIndex, "match", "upstreams"],
834
+ 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.'
835
+ });
836
+ } else {
837
+ for (const [entryIndex, name] of upstreams.entries()) {
838
+ if (!configuredNames.has(name)) {
839
+ ctx.addIssue({
840
+ code: "custom",
841
+ path: [
842
+ "budgets",
843
+ budgetIndex,
844
+ "contributors",
845
+ contributorIndex,
846
+ "match",
847
+ "upstreams",
848
+ entryIndex
849
+ ],
850
+ 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.`
851
+ });
852
+ }
853
+ }
854
+ }
855
+ }
856
+ if (budget.key === "sender_id" && !hasUnscopedContributor) {
857
+ ctx.addIssue({
858
+ code: "custom",
859
+ path: ["budgets", budgetIndex, "key"],
860
+ 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.'
861
+ });
862
+ }
863
+ }
864
+ if (configuredNames !== null) {
865
+ const hasEvidenceGatedRule = cfg.policies.rules.some(
866
+ (rule) => (rule.evidence?.requires.length ?? 0) > 0 || (rule.requires?.length ?? 0) > 0
867
+ );
868
+ if (hasEvidenceGatedRule) {
869
+ const legacyIndex = cfg.session.identity.findIndex(
870
+ (source) => source.source === "legacy_header"
871
+ );
872
+ if (legacyIndex !== -1) {
873
+ ctx.addIssue({
874
+ code: "custom",
875
+ path: ["session", "identity", legacyIndex],
876
+ 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.`
877
+ });
878
+ }
879
+ }
880
+ }
881
+ }
882
+ var singularConfigSchema = singularConfigBase.superRefine((cfg, ctx) => {
883
+ rootConfigChecks(cfg, ctx);
884
+ upstreamVocabularyChecks(cfg, ctx, null);
885
+ });
886
+ var namedConfigSchema = namedConfigBase.superRefine((cfg, ctx) => {
887
+ rootConfigChecks(cfg, ctx);
888
+ upstreamVocabularyChecks(cfg, ctx, new Set(cfg.upstreams.map((entry) => entry.name)));
725
889
  });
726
- var helioConfigSchema = z.preprocess(stripRootExtensionKeys, helioConfigRefinedSchema);
890
+ var objectRootSchema = z.object({});
891
+ function dispatchByMode(raw, ctx) {
892
+ const isObject2 = raw !== null && typeof raw === "object" && !Array.isArray(raw);
893
+ if (!isObject2) {
894
+ const typeResult = objectRootSchema.safeParse(raw);
895
+ if (!typeResult.success) {
896
+ for (const issue of typeResult.error.issues) {
897
+ ctx.addIssue(issue);
898
+ }
899
+ }
900
+ return z.NEVER;
901
+ }
902
+ const hasUpstream = "upstream" in raw;
903
+ const hasUpstreams = "upstreams" in raw;
904
+ if (hasUpstream && hasUpstreams) {
905
+ ctx.addIssue({
906
+ code: "custom",
907
+ path: ["upstreams"],
908
+ 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.'
909
+ });
910
+ return z.NEVER;
911
+ }
912
+ if (!hasUpstream && !hasUpstreams) {
913
+ ctx.addIssue({
914
+ code: "custom",
915
+ message: 'Missing upstream configuration: set exactly one of "upstream:" (single upstream) or "upstreams:" (named multi-upstream list).'
916
+ });
917
+ return z.NEVER;
918
+ }
919
+ const result = hasUpstreams ? namedConfigSchema.safeParse(raw) : singularConfigSchema.safeParse(raw);
920
+ if (!result.success) {
921
+ for (const issue of result.error.issues) {
922
+ ctx.addIssue(issue);
923
+ }
924
+ return z.NEVER;
925
+ }
926
+ return result.data;
927
+ }
928
+ var helioConfigSchema = z.preprocess(
929
+ stripRootExtensionKeys,
930
+ z.unknown().transform(dispatchByMode)
931
+ );
932
+ function isSingularConfig(config) {
933
+ return !("upstreams" in config);
934
+ }
935
+ function isNamedConfig(config) {
936
+ return "upstreams" in config;
937
+ }
727
938
 
728
939
  // src/config/loader.ts
729
940
  import { readFile } from "fs/promises";
@@ -904,7 +1115,8 @@ function compileMatch(match, ruleIndex, ruleName) {
904
1115
  ...match.environment !== void 0 && { environment: match.environment },
905
1116
  ...match.metadata !== void 0 && {
906
1117
  metadata: flattenMetadataConditions(match.metadata, ruleIndex, ruleName)
907
- }
1118
+ },
1119
+ ...match.upstreams !== void 0 && { upstreams: [...match.upstreams] }
908
1120
  };
909
1121
  }
910
1122
  function compileToolMatcher(pattern, ruleIndex, ruleName) {
@@ -1107,6 +1319,9 @@ function compileContributor(contributor, budgetName, index) {
1107
1319
  ) : void 0;
1108
1320
  return {
1109
1321
  match: { tool, ...input !== void 0 && { input } },
1322
+ ...contributor.match.upstreams !== void 0 && {
1323
+ upstreams: [...contributor.match.upstreams]
1324
+ },
1110
1325
  field: contributor.field
1111
1326
  };
1112
1327
  }
@@ -1371,6 +1586,8 @@ function buildStandardRequestHeaders(method, params) {
1371
1586
  }
1372
1587
 
1373
1588
  // src/upstream/merge-headers.ts
1589
+ var UPSTREAM_POST_ACCEPT = "application/json, text/event-stream";
1590
+ var UPSTREAM_SSE_CONNECT_ACCEPT = "text/event-stream";
1374
1591
  function mergeUpstreamHeaders(base, forwarded, staticHeaders) {
1375
1592
  const out = {};
1376
1593
  const apply = (headers) => {
@@ -1384,6 +1601,11 @@ function mergeUpstreamHeaders(base, forwarded, staticHeaders) {
1384
1601
  return out;
1385
1602
  }
1386
1603
 
1604
+ // src/util/log-label.ts
1605
+ function helioLogTag(upstreamName) {
1606
+ return upstreamName ? `[helio][${upstreamName}]` : "[helio]";
1607
+ }
1608
+
1387
1609
  // src/upstream/connection-error.ts
1388
1610
  var UPSTREAM_DOCS_URL = "https://github.com/gethelio/helio/blob/main/docs/getting-started.md";
1389
1611
  var UNREACHABLE_CODES = /* @__PURE__ */ new Set([
@@ -1419,7 +1641,7 @@ function describeUnreachableUpstream(error, url) {
1419
1641
  }
1420
1642
  const codeSuffix = code ? ` (${code})` : "";
1421
1643
  return new Error(
1422
- `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}`
1644
+ `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}`
1423
1645
  );
1424
1646
  }
1425
1647
 
@@ -1526,11 +1748,13 @@ var UpstreamSessionManager = class {
1526
1748
  inflight;
1527
1749
  inflightProbe;
1528
1750
  probeBackoffUntil = 0;
1751
+ logTag;
1529
1752
  constructor(options) {
1530
1753
  this.url = options.url;
1531
1754
  this.staticHeaders = options.staticHeaders;
1532
1755
  this.requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
1533
1756
  this.pin = options.protocolVersion ?? "auto";
1757
+ this.logTag = helioLogTag(options.upstreamName);
1534
1758
  }
1535
1759
  /** Return the internal session, establishing it once if needed. */
1536
1760
  ensureInternalSession() {
@@ -1662,7 +1886,7 @@ var UpstreamSessionManager = class {
1662
1886
  this.capture = void 0;
1663
1887
  this.probeBackoffUntil = Date.now() + ERA_PROBE_BACKOFF_MS;
1664
1888
  console.error(
1665
- `[helio] Upstream MCP era cleared: ${door}; relays presume legacy and re-probing is throttled for ${String(ERA_PROBE_BACKOFF_MS / 1e3)}s`
1889
+ `${this.logTag} Upstream MCP era cleared: ${door}; relays presume legacy and re-probing is throttled for ${String(ERA_PROBE_BACKOFF_MS / 1e3)}s`
1666
1890
  );
1667
1891
  }
1668
1892
  /** Convert a fetch failure into an actionable error for the given step. */
@@ -1706,7 +1930,7 @@ var UpstreamSessionManager = class {
1706
1930
  if (this.era === era) return;
1707
1931
  this.era = era;
1708
1932
  console.error(
1709
- era === "modern" ? `[helio] Upstream MCP era detected: modern (${HELIO_MCP_MODERN_PROTOCOL_VERSION}, via server/discover)` : "[helio] Upstream MCP era detected: legacy (initialize handshake)"
1933
+ 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)`
1710
1934
  );
1711
1935
  }
1712
1936
  /** A modern upstream neither mints nor echoes session ids — nothing to hold. */
@@ -1730,7 +1954,7 @@ var UpstreamSessionManager = class {
1730
1954
  const headers = mergeUpstreamHeaders(
1731
1955
  {
1732
1956
  "content-type": "application/json",
1733
- accept: "application/json, text/event-stream",
1957
+ accept: UPSTREAM_POST_ACCEPT,
1734
1958
  "mcp-protocol-version": HELIO_MCP_MODERN_PROTOCOL_VERSION,
1735
1959
  "mcp-method": "server/discover"
1736
1960
  },
@@ -1739,6 +1963,7 @@ var UpstreamSessionManager = class {
1739
1963
  );
1740
1964
  headers["mcp-method"] = "server/discover";
1741
1965
  delete headers["mcp-name"];
1966
+ headers["accept"] = UPSTREAM_POST_ACCEPT;
1742
1967
  const probeBody = {
1743
1968
  jsonrpc: "2.0",
1744
1969
  id: ERA_PROBE_REQUEST_ID,
@@ -1811,13 +2036,14 @@ var UpstreamSessionManager = class {
1811
2036
  const headers = mergeUpstreamHeaders(
1812
2037
  {
1813
2038
  "content-type": "application/json",
1814
- accept: "application/json, text/event-stream"
2039
+ accept: UPSTREAM_POST_ACCEPT
1815
2040
  },
1816
2041
  {},
1817
2042
  this.staticHeaders
1818
2043
  );
1819
2044
  delete headers["mcp-method"];
1820
2045
  delete headers["mcp-name"];
2046
+ headers["accept"] = UPSTREAM_POST_ACCEPT;
1821
2047
  const initBody = {
1822
2048
  jsonrpc: "2.0",
1823
2049
  id: 0,
@@ -2431,6 +2657,7 @@ function createSseRoute(forwarder, options = {}) {
2431
2657
  const forwardHeaderAllowlist = options.forwardHeadersAllowlist ?? [];
2432
2658
  const sessionIdentity = options.session ?? DEFAULT_SESSION_IDENTITY;
2433
2659
  const maxConcurrentSessions = options.maxConcurrentSessions ?? MAX_CONCURRENT_SESSIONS;
2660
+ const routeLabel = options.routeLabel ?? "/sse";
2434
2661
  let refusalCount = 0;
2435
2662
  let lastRefusalLogAt = null;
2436
2663
  const logRefusal = () => {
@@ -2439,7 +2666,7 @@ function createSseRoute(forwarder, options = {}) {
2439
2666
  if (lastRefusalLogAt !== null && now - lastRefusalLogAt < REFUSAL_LOG_WINDOW_MS) return;
2440
2667
  lastRefusalLogAt = now;
2441
2668
  console.error(
2442
- `[helio] /sse at session cap (${String(maxConcurrentSessions)}); refusing new streams (${String(refusalCount)} refusals so far).`
2669
+ `[helio] ${routeLabel} at session cap (${String(maxConcurrentSessions)}); refusing new streams (${String(refusalCount)} refusals so far).`
2443
2670
  );
2444
2671
  };
2445
2672
  app.use("*", createOriginGuard(options.allowedOrigins ?? []));
@@ -2645,6 +2872,11 @@ function createServerHandle(server) {
2645
2872
  };
2646
2873
  }
2647
2874
  function createApp(config, forwarder, options) {
2875
+ if (isNamedConfig(config)) {
2876
+ throw new Error(
2877
+ "createApp serves a single-upstream (upstream:) config only. Named multi-upstream configs are composed by createMultiApp."
2878
+ );
2879
+ }
2648
2880
  const app = new Hono3();
2649
2881
  const forwardHeadersAllowlist = config.upstream.forward_headers;
2650
2882
  const allowedOrigins = config.listen.allowed_origins;
@@ -2665,6 +2897,77 @@ function createApp(config, forwarder, options) {
2665
2897
  }
2666
2898
  return app;
2667
2899
  }
2900
+ function createMultiApp(config, forwarders, options) {
2901
+ if (!isNamedConfig(config)) {
2902
+ throw new Error(
2903
+ "createMultiApp composes a named multi-upstream (upstreams:) config only. Singular configs are served by createApp."
2904
+ );
2905
+ }
2906
+ const doors = [];
2907
+ const missing = [];
2908
+ for (const entry of config.upstreams) {
2909
+ const forwarder = forwarders[entry.name];
2910
+ if (forwarder === void 0) missing.push(entry.name);
2911
+ else doors.push({ entry, forwarder });
2912
+ }
2913
+ const configured = new Set(config.upstreams.map((entry) => entry.name));
2914
+ const unexpected = Object.keys(forwarders).filter((name) => !configured.has(name));
2915
+ if (missing.length > 0 || unexpected.length > 0) {
2916
+ throw new Error(
2917
+ `createMultiApp forwarders must match the configured upstream names exactly \u2014 missing: [${missing.join(", ")}], unexpected: [${unexpected.join(", ")}].`
2918
+ );
2919
+ }
2920
+ const app = new Hono3();
2921
+ const allowedOrigins = config.listen.allowed_origins;
2922
+ const session = compileSessionIdentity(config.session);
2923
+ app.get("/healthz", (c) => c.json({ status: "ok" }));
2924
+ for (const { entry, forwarder } of doors) {
2925
+ const name = entry.name;
2926
+ app.route(
2927
+ `/mcp/${name}`,
2928
+ createStreamableHttpRoute(forwarder, {
2929
+ forwardHeadersAllowlist: entry.forward_headers,
2930
+ allowedOrigins,
2931
+ session,
2932
+ onHeaderMismatch: options?.onHeaderMismatch ? (rejection) => options.onHeaderMismatch?.(rejection, name) : void 0
2933
+ })
2934
+ );
2935
+ app.route(
2936
+ `/sse/${name}`,
2937
+ createSseRoute(forwarder, {
2938
+ forwardHeadersAllowlist: entry.forward_headers,
2939
+ allowedOrigins,
2940
+ session,
2941
+ routeLabel: `/sse/${name}`,
2942
+ maxConcurrentSessions: options?.sse?.maxConcurrentSessions
2943
+ })
2944
+ );
2945
+ }
2946
+ if (options?.slackActionApp) {
2947
+ app.route("/slack/actions", options.slackActionApp);
2948
+ }
2949
+ app.all(
2950
+ "/mcp/*",
2951
+ (c) => c.json(
2952
+ makeJsonRpcErrorWithoutId(
2953
+ INVALID_REQUEST,
2954
+ "No MCP endpoint answers this request: this Helio serves named upstreams at /mcp/<name>."
2955
+ ),
2956
+ 404
2957
+ )
2958
+ );
2959
+ app.all(
2960
+ "/sse/*",
2961
+ (c) => c.json(
2962
+ makeJsonRpcErrorWithoutId(
2963
+ INVALID_REQUEST,
2964
+ "No MCP endpoint answers this request: this Helio serves named upstreams at /sse/<name>."
2965
+ ),
2966
+ 404
2967
+ )
2968
+ );
2969
+ return app;
2970
+ }
2668
2971
  function startServer(app, config) {
2669
2972
  const server = serve({
2670
2973
  fetch: app.fetch,
@@ -2718,7 +3021,8 @@ var StreamableHttpForwarder = class {
2718
3021
  url: this.url,
2719
3022
  staticHeaders: this.staticHeaders,
2720
3023
  requestTimeoutMs: this.requestTimeoutMs,
2721
- protocolVersion: options.protocolVersion
3024
+ protocolVersion: options.protocolVersion,
3025
+ upstreamName: options.upstreamName
2722
3026
  });
2723
3027
  }
2724
3028
  /** Lifecycle parity with sse/stdio. No eager connect — sessions are lazy. */
@@ -2891,11 +3195,15 @@ var StreamableHttpForwarder = class {
2891
3195
  const headers = mergeUpstreamHeaders(
2892
3196
  {
2893
3197
  "content-type": "application/json",
2894
- accept: "application/json, text/event-stream"
3198
+ accept: UPSTREAM_POST_ACCEPT
2895
3199
  },
2896
3200
  request.headers ?? {},
2897
3201
  this.staticHeaders
2898
3202
  );
3203
+ headers["content-type"] = "application/json";
3204
+ delete headers["content-length"];
3205
+ headers["accept"] = UPSTREAM_POST_ACCEPT;
3206
+ delete headers["mcp-session-id"];
2899
3207
  if (session.sessionId) headers["mcp-session-id"] = session.sessionId;
2900
3208
  if (modern) {
2901
3209
  delete headers["mcp-session-id"];
@@ -3070,13 +3378,16 @@ var SseUpstreamForwarder = class {
3070
3378
  connect() {
3071
3379
  const controller = new AbortController();
3072
3380
  this.abortController = controller;
3381
+ const headers = mergeUpstreamHeaders(
3382
+ { accept: UPSTREAM_SSE_CONNECT_ACCEPT },
3383
+ {},
3384
+ this.staticHeaders
3385
+ );
3386
+ headers["accept"] = UPSTREAM_SSE_CONNECT_ACCEPT;
3073
3387
  return new Promise((resolve, reject) => {
3074
3388
  let resolved = false;
3075
3389
  fetch(this.url, {
3076
- headers: {
3077
- accept: "text/event-stream",
3078
- ...this.staticHeaders
3079
- },
3390
+ headers,
3080
3391
  signal: AbortSignal.any([controller.signal, AbortSignal.timeout(this.connectTimeoutMs)])
3081
3392
  }).then((res) => {
3082
3393
  if (!res.ok) {
@@ -3128,9 +3439,12 @@ var SseUpstreamForwarder = class {
3128
3439
  request.headers ?? {},
3129
3440
  this.staticHeaders
3130
3441
  );
3442
+ headers["content-type"] = "application/json";
3443
+ delete headers["content-length"];
3131
3444
  delete headers["mcp-method"];
3132
3445
  delete headers["mcp-name"];
3133
3446
  delete headers["mcp-session-id"];
3447
+ delete headers["accept"];
3134
3448
  if (request.transportSessionId) {
3135
3449
  headers["mcp-session-id"] = request.transportSessionId;
3136
3450
  }
@@ -3297,6 +3611,7 @@ var StdioForwarder = class {
3297
3611
  maxRetries;
3298
3612
  retryDelayMs;
3299
3613
  pending;
3614
+ logTag;
3300
3615
  child = null;
3301
3616
  buffer = "";
3302
3617
  retryCount = 0;
@@ -3308,6 +3623,7 @@ var StdioForwarder = class {
3308
3623
  this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
3309
3624
  this.retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
3310
3625
  this.pending = new PendingRequests(options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS2);
3626
+ this.logTag = helioLogTag(options.upstreamName);
3311
3627
  }
3312
3628
  /** Spawn the child process and set up event handlers. */
3313
3629
  start() {
@@ -3436,7 +3752,9 @@ var StdioForwarder = class {
3436
3752
  }, this.retryDelayMs);
3437
3753
  } else {
3438
3754
  this.dead = true;
3439
- console.error(`[helio] Stdio forwarder: max retries (${String(this.maxRetries)}) exceeded`);
3755
+ console.error(
3756
+ `${this.logTag} Stdio forwarder: max retries (${String(this.maxRetries)}) exceeded`
3757
+ );
3440
3758
  this.pending.rejectAll(new Error("stdio forwarder is dead (max retries exceeded)"));
3441
3759
  }
3442
3760
  }
@@ -3511,6 +3829,10 @@ function matchEnvironment(required, ctx) {
3511
3829
  if (ctx.environment === void 0) return false;
3512
3830
  return ctx.environment === required;
3513
3831
  }
3832
+ function matchUpstreams(required, ctx) {
3833
+ if (ctx.upstream === void 0) return false;
3834
+ return required.includes(ctx.upstream);
3835
+ }
3514
3836
  function matchMetadata(conditions, ctx) {
3515
3837
  if (conditions.length === 0) return true;
3516
3838
  if (ctx.metadata === void 0) return false;
@@ -3536,6 +3858,7 @@ function matchRule(rule, ctx) {
3536
3858
  if (match.input !== void 0 && !matchInput(match.input, ctx)) return false;
3537
3859
  if (match.environment !== void 0 && !matchEnvironment(match.environment, ctx)) return false;
3538
3860
  if (match.metadata !== void 0 && !matchMetadata(match.metadata, ctx)) return false;
3861
+ if (match.upstreams !== void 0 && !matchUpstreams(match.upstreams, ctx)) return false;
3539
3862
  return true;
3540
3863
  }
3541
3864
 
@@ -3690,7 +4013,8 @@ function decide(input) {
3690
4013
  annotations,
3691
4014
  toolArguments,
3692
4015
  environment,
3693
- metadata
4016
+ metadata,
4017
+ upstream: input.upstream
3694
4018
  });
3695
4019
  if (driftEvent && driftMode === "log") {
3696
4020
  const currentDecision = evaluatePolicy(policy, {
@@ -3698,7 +4022,8 @@ function decide(input) {
3698
4022
  annotations: input.currentAnnotations,
3699
4023
  toolArguments,
3700
4024
  environment,
3701
- metadata
4025
+ metadata,
4026
+ upstream: input.upstream
3702
4027
  });
3703
4028
  decision = stricterDecision(decision, currentDecision);
3704
4029
  }
@@ -4311,406 +4636,101 @@ function buildBudgetApprovalTimeoutFeedback(decision, breaches, timeoutMs) {
4311
4636
  };
4312
4637
  }
4313
4638
 
4314
- // src/policy/spend-limiter.ts
4315
- function spendBucketKey(baseKey, ruleIndex) {
4639
+ // src/policy/bucket-key.ts
4640
+ function ruleBucketKey(baseKey, ruleIndex) {
4316
4641
  return `${baseKey}:rule:${String(ruleIndex)}`;
4317
4642
  }
4318
4643
  var RULE_SUFFIX_RE = /:rule:(\d+)$/;
4319
- var SpendLimiter = class {
4320
- buckets = /* @__PURE__ */ new Map();
4321
- now;
4322
- onWarning;
4323
- warningThreshold;
4324
- timer = null;
4325
- closed = false;
4326
- constructor(options = {}) {
4327
- this.now = options.now ?? Date.now;
4328
- this.onWarning = options.onWarning;
4329
- this.warningThreshold = options.warningThreshold ?? 0.8;
4330
- const intervalMs = options.cleanupIntervalMs ?? 6e4;
4331
- if (intervalMs > 0) {
4332
- this.timer = setInterval(() => {
4333
- this.cleanup();
4334
- }, intervalMs);
4335
- this.timer.unref();
4644
+ function parseRuleIndex(key) {
4645
+ const match = RULE_SUFFIX_RE.exec(key);
4646
+ return match ? Number(match[1]) : void 0;
4647
+ }
4648
+ function toolLimitKey(toolName, upstreamName) {
4649
+ return upstreamName ? `upstream:${upstreamName}:tool:${toolName}` : `tool:${toolName}`;
4650
+ }
4651
+
4652
+ // src/policy/governed-forwarder.ts
4653
+ var POLICY_DENIED = -32001;
4654
+ function blocked(result) {
4655
+ return { proceed: false, result, approvalWaitMs: 0 };
4656
+ }
4657
+ function budgetChainBlock(entry, kind) {
4658
+ return {
4659
+ name: entry.budget.name,
4660
+ bucket_key: entry.bucketKey,
4661
+ allowed: entry.allowed,
4662
+ amount: entry.amount,
4663
+ spent: entry.spent,
4664
+ limit: entry.budget.limit,
4665
+ remaining: entry.remaining,
4666
+ currency: entry.budget.currency,
4667
+ ...kind ? { kind } : {},
4668
+ ...entry.stale ? { stale: true } : {}
4669
+ };
4670
+ }
4671
+ var GovernedForwarder = class {
4672
+ inner;
4673
+ policy;
4674
+ environment;
4675
+ session;
4676
+ auditWriter;
4677
+ evidenceStore;
4678
+ approvalRouter;
4679
+ rateLimiter;
4680
+ spendLimiter;
4681
+ budgetEngine;
4682
+ upstreamName;
4683
+ annotationCache = new ToolAnnotationCache();
4684
+ agentKeyWarned = false;
4685
+ senderKeyWarned = false;
4686
+ constructor(inner, policy, options) {
4687
+ this.inner = inner;
4688
+ this.policy = policy;
4689
+ this.environment = options?.environment;
4690
+ this.auditWriter = options?.auditWriter;
4691
+ this.evidenceStore = options?.evidenceStore;
4692
+ this.approvalRouter = options?.approvalRouter;
4693
+ this.rateLimiter = options?.rateLimiter;
4694
+ this.spendLimiter = options?.spendLimiter;
4695
+ this.budgetEngine = options?.budgetEngine;
4696
+ this.upstreamName = options?.upstreamName;
4697
+ this.session = options?.session ?? DEFAULT_SESSION_IDENTITY;
4698
+ if (this.evidenceStore) {
4699
+ this.evidenceStore.setAllowedEvidenceKeys(collectAllowedEvidenceKeys(policy));
4336
4700
  }
4337
4701
  }
4338
- // -------------------------------------------------------------------------
4339
- // Core operations
4340
- // -------------------------------------------------------------------------
4341
4702
  /**
4342
- * Check and optionally record a spend against the limit.
4703
+ * Swap the compiled policy atomically and reconcile limit bucket state
4704
+ * against the new configuration.
4343
4705
  *
4344
- * Evicts expired entries, sums remaining amounts, then checks:
4345
- * - Under limit (currentSpend + amount <= limit): records and returns `allowed: true`
4346
- * - Would exceed: does NOT record (rejected spends don't consume budget)
4706
+ * Rate and spend limit buckets are preserved when their underlying rule
4707
+ * config is unchanged this is what makes a benign hot-reload (e.g. a
4708
+ * `vim :w` with no real edits, or a whitespace-only config change) safe:
4709
+ * operators do not get a surprise zero of their live rate/spend state
4710
+ * mid-window. Buckets whose config changed or whose rule was removed are
4711
+ * evicted by the limiters' `reconcile()` methods, so the next check
4712
+ * lazy-creates a fresh bucket under the new config.
4713
+ *
4714
+ * See `packages/proxy/src/policy/rate-limiter.ts` and `spend-limiter.ts`
4715
+ * for the per-bucket compare-and-evict semantics.
4347
4716
  */
4348
- check(params) {
4349
- const { key, amount, limit, windowMs } = params;
4350
- const now = this.now();
4351
- const windowStart = now - windowMs;
4352
- if (!Number.isFinite(amount) || amount < 0) {
4353
- const existing = this.buckets.get(key);
4354
- const activeEntries = existing ? existing.entries.filter((e) => e.timestamp > windowStart) : [];
4355
- const currentSpend2 = activeEntries.reduce((sum, e) => sum + e.amount, 0);
4356
- const oldest = activeEntries[0];
4357
- return {
4358
- allowed: false,
4359
- currentSpend: currentSpend2,
4360
- limit,
4361
- windowMs,
4362
- resetAtMs: oldest ? oldest.timestamp + windowMs : 0,
4363
- reason: "invalid_amount"
4364
- };
4365
- }
4366
- let bucket = this.buckets.get(key);
4367
- if (!bucket) {
4368
- bucket = { entries: [], limit, currency: "", windowMs };
4369
- this.buckets.set(key, bucket);
4370
- }
4371
- bucket.limit = limit;
4372
- bucket.windowMs = windowMs;
4373
- bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
4374
- const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
4375
- if (currentSpend + amount > limit) {
4376
- const oldest = bucket.entries[0];
4377
- return {
4378
- allowed: false,
4379
- currentSpend,
4380
- limit,
4381
- windowMs,
4382
- resetAtMs: oldest ? oldest.timestamp + windowMs : 0
4383
- };
4384
- }
4385
- bucket.entries.push({ timestamp: now, amount });
4386
- const newSpend = currentSpend + amount;
4387
- const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
4388
- if (this.onWarning && newSpend / limit >= this.warningThreshold) {
4389
- this.safeWarn({
4390
- key,
4391
- current_spend: newSpend,
4392
- limit,
4393
- currency: bucket.currency,
4394
- window_ms: windowMs,
4395
- reset_at_ms: resetAtMs
4396
- });
4397
- }
4398
- return {
4399
- allowed: true,
4400
- currentSpend: newSpend,
4401
- limit,
4402
- windowMs,
4403
- resetAtMs
4404
- };
4405
- }
4406
- /**
4407
- * Unconditionally record a spend against the limit.
4408
- *
4409
- * Unlike check(), this always appends the amount — even when it pushes the
4410
- * window past the limit — because the spend it represents has already been
4411
- * incurred. The sideband peeks at /evaluate and commits here at /audit once
4412
- * the external call ran (issue #12, D3).
4413
- *
4414
- * Throws on a negative or non-finite amount: such amounts are rejected at
4415
- * /evaluate, so one reaching record() is a logic bug we surface loudly rather
4416
- * than silently corrupt the sliding-window sum. Warnings fire only while the
4417
- * post-append spend stays within the limit (parity with check()).
4418
- */
4419
- record(params) {
4420
- const { key, amount, limit, windowMs } = params;
4421
- if (!Number.isFinite(amount) || amount < 0) {
4422
- throw new RangeError(
4423
- `SpendLimiter.record() received an invalid amount (${String(amount)}); invalid amounts must be rejected at /evaluate, never committed`
4424
- );
4425
- }
4426
- const now = this.now();
4427
- const windowStart = now - windowMs;
4428
- let bucket = this.buckets.get(key);
4429
- if (!bucket) {
4430
- bucket = { entries: [], limit, currency: "", windowMs };
4431
- this.buckets.set(key, bucket);
4432
- }
4433
- bucket.limit = limit;
4434
- bucket.windowMs = windowMs;
4435
- bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
4436
- bucket.entries.push({ timestamp: now, amount });
4437
- const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
4438
- const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
4439
- if (this.onWarning && currentSpend <= limit && currentSpend / limit >= this.warningThreshold) {
4440
- this.safeWarn({
4441
- key,
4442
- current_spend: currentSpend,
4443
- limit,
4444
- currency: bucket.currency,
4445
- window_ms: windowMs,
4446
- reset_at_ms: resetAtMs
4447
- });
4448
- }
4449
- return {
4450
- allowed: currentSpend <= limit,
4451
- currentSpend,
4452
- limit,
4453
- windowMs,
4454
- resetAtMs
4455
- };
4456
- }
4457
- /**
4458
- * Check the spend limit without recording the spend (non-destructive).
4459
- *
4460
- * Used by dry-run mode to determine what would happen without consuming
4461
- * budget in the bucket.
4462
- */
4463
- peek(params) {
4464
- const { key, amount, limit, windowMs } = params;
4465
- const now = this.now();
4466
- const windowStart = now - windowMs;
4467
- const bucket = this.buckets.get(key);
4468
- if (!Number.isFinite(amount) || amount < 0) {
4469
- const activeEntries2 = bucket ? bucket.entries.filter((e) => e.timestamp > windowStart) : [];
4470
- const currentSpend2 = activeEntries2.reduce((sum, e) => sum + e.amount, 0);
4471
- const oldest2 = activeEntries2[0];
4472
- return {
4473
- allowed: false,
4474
- currentSpend: currentSpend2,
4475
- limit,
4476
- windowMs,
4477
- resetAtMs: oldest2 ? oldest2.timestamp + windowMs : 0,
4478
- reason: "invalid_amount"
4479
- };
4480
- }
4481
- if (!bucket) {
4482
- const wouldExceed = amount > limit;
4483
- return {
4484
- allowed: !wouldExceed,
4485
- currentSpend: wouldExceed ? 0 : amount,
4486
- limit,
4487
- windowMs,
4488
- resetAtMs: now + windowMs
4489
- };
4490
- }
4491
- const activeEntries = bucket.entries.filter((e) => e.timestamp > windowStart);
4492
- const currentSpend = activeEntries.reduce((sum, e) => sum + e.amount, 0);
4493
- if (currentSpend + amount > limit) {
4494
- const oldest2 = activeEntries[0];
4495
- return {
4496
- allowed: false,
4497
- currentSpend,
4498
- limit,
4499
- windowMs,
4500
- resetAtMs: oldest2 ? oldest2.timestamp + windowMs : 0
4501
- };
4502
- }
4503
- const newSpend = currentSpend + amount;
4504
- const oldest = activeEntries[0];
4505
- return {
4506
- allowed: true,
4507
- currentSpend: newSpend,
4508
- limit,
4509
- windowMs,
4510
- resetAtMs: oldest ? oldest.timestamp + windowMs : now + windowMs
4511
- };
4512
- }
4513
- /**
4514
- * Set the display currency for a key. Called by the governed forwarder
4515
- * after check() so dashboard reads include the currency label.
4516
- */
4517
- setCurrency(key, currency) {
4518
- const bucket = this.buckets.get(key);
4519
- if (bucket) bucket.currency = currency;
4520
- }
4521
- // -------------------------------------------------------------------------
4522
- // Read operations (for dashboard API)
4523
- // -------------------------------------------------------------------------
4524
- /** Get the current state of a single key. Returns undefined if not tracked. */
4525
- getKeyState(key) {
4526
- const bucket = this.buckets.get(key);
4527
- if (!bucket) return void 0;
4528
- const windowStart = this.now() - bucket.windowMs;
4529
- bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
4530
- if (bucket.entries.length === 0) {
4531
- this.buckets.delete(key);
4532
- return void 0;
4533
- }
4534
- const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
4535
- return {
4536
- key,
4537
- current_spend: currentSpend,
4538
- limit: bucket.limit,
4539
- currency: bucket.currency,
4540
- window_ms: bucket.windowMs,
4541
- reset_at_ms: (bucket.entries[0]?.timestamp ?? 0) + bucket.windowMs
4542
- };
4543
- }
4544
- /** List all tracked keys with their current state. */
4545
- listKeyStates() {
4546
- const states = [];
4547
- for (const key of [...this.buckets.keys()]) {
4548
- const state = this.getKeyState(key);
4549
- if (state) states.push(state);
4550
- }
4551
- return states;
4552
- }
4553
- // -------------------------------------------------------------------------
4554
- // Maintenance
4555
- // -------------------------------------------------------------------------
4556
- /** Sweep all buckets: remove expired entries, delete empty buckets. */
4557
- cleanup() {
4558
- const now = this.now();
4559
- for (const [key, bucket] of this.buckets) {
4560
- const windowStart = now - bucket.windowMs;
4561
- bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
4562
- if (bucket.entries.length === 0) {
4563
- this.buckets.delete(key);
4564
- }
4565
- }
4566
- }
4567
- /** Clear all spend limit state. Called on policy hot-reload. */
4568
- reset() {
4569
- this.buckets.clear();
4570
- }
4571
- /**
4572
- * Reconcile bucket state against a new policy's spend configuration.
4573
- *
4574
- * Walks every existing bucket and checks whether its last-seen
4575
- * `{ limit, currency, windowMs }` tuple still appears in `validConfigs`.
4576
- * Buckets whose config is unchanged are left untouched — cumulative spend
4577
- * and elapsed-window progress are preserved across hot-reloads. Buckets
4578
- * whose config is gone (rule changed or removed) are evicted so the next
4579
- * check lazy-creates a fresh bucket under the new config.
4580
- *
4581
- * Keys built by {@link spendBucketKey} carry the owning rule's index, and
4582
- * for those the tuple must match at THAT index (`config.ruleIndex`): a
4583
- * reorder that shifts a spend rule's index evicts its old-index bucket
4584
- * instead of leaving an orphan no rule reads again — or worse, letting
4585
- * whatever rule now sits at that index adopt another rule's accrued spend.
4586
- * Un-suffixed keys keep the tuple-anywhere match.
4587
- *
4588
- * Currency is part of the tuple because a USD→EUR switch is a meaningful
4589
- * policy change — the same numeric limit buys a different amount of real
4590
- * spend, so the bucket must reset. This replaces the old `reset()` call
4591
- * on every hot-reload, which wiped all state even when the matching rule
4592
- * was unchanged.
4593
- */
4594
- reconcile(validConfigs) {
4595
- const valid = /* @__PURE__ */ new Set();
4596
- const byIndex = /* @__PURE__ */ new Map();
4597
- for (const config of validConfigs) {
4598
- const tuple = `${String(config.limit)}|${config.currency}|${String(config.windowMs)}`;
4599
- if (config.ruleIndex === void 0) {
4600
- valid.add(tuple);
4601
- } else {
4602
- byIndex.set(config.ruleIndex, tuple);
4603
- }
4604
- }
4605
- for (const [key, bucket] of this.buckets) {
4606
- const tuple = `${String(bucket.limit)}|${bucket.currency}|${String(bucket.windowMs)}`;
4607
- const suffix = RULE_SUFFIX_RE.exec(key);
4608
- const survives = suffix ? byIndex.get(Number(suffix[1])) === tuple : valid.has(tuple);
4609
- if (!survives) {
4610
- this.buckets.delete(key);
4611
- }
4612
- }
4613
- }
4614
- /** Stop the cleanup timer and mark as closed. */
4615
- /**
4616
- * Invoke the warning callback without letting a subscriber throw into the
4617
- * limiter's caller: a warning fires after state has already mutated, and a
4618
- * governed call must not be blocked (or double-charged on retry) by an
4619
- * observability bug.
4620
- */
4621
- safeWarn(state) {
4622
- if (!this.onWarning) return;
4623
- try {
4624
- this.onWarning(state);
4625
- } catch (err) {
4626
- console.error("[helio] limit warning subscriber threw:", err);
4627
- }
4628
- }
4629
- close() {
4630
- if (this.closed) return;
4631
- this.closed = true;
4632
- if (this.timer) {
4633
- clearInterval(this.timer);
4634
- this.timer = null;
4635
- }
4636
- this.buckets.clear();
4637
- }
4638
- };
4639
-
4640
- // src/policy/governed-forwarder.ts
4641
- var POLICY_DENIED = -32001;
4642
- function blocked(result) {
4643
- return { proceed: false, result, approvalWaitMs: 0 };
4644
- }
4645
- function budgetChainBlock(entry, kind) {
4646
- return {
4647
- name: entry.budget.name,
4648
- bucket_key: entry.bucketKey,
4649
- allowed: entry.allowed,
4650
- amount: entry.amount,
4651
- spent: entry.spent,
4652
- limit: entry.budget.limit,
4653
- remaining: entry.remaining,
4654
- currency: entry.budget.currency,
4655
- ...kind ? { kind } : {},
4656
- ...entry.stale ? { stale: true } : {}
4657
- };
4658
- }
4659
- var GovernedForwarder = class {
4660
- inner;
4661
- policy;
4662
- environment;
4663
- session;
4664
- auditWriter;
4665
- evidenceStore;
4666
- approvalRouter;
4667
- rateLimiter;
4668
- spendLimiter;
4669
- budgetEngine;
4670
- annotationCache = new ToolAnnotationCache();
4671
- agentKeyWarned = false;
4672
- senderKeyWarned = false;
4673
- constructor(inner, policy, options) {
4674
- this.inner = inner;
4675
- this.policy = policy;
4676
- this.environment = options?.environment;
4677
- this.auditWriter = options?.auditWriter;
4678
- this.evidenceStore = options?.evidenceStore;
4679
- this.approvalRouter = options?.approvalRouter;
4680
- this.rateLimiter = options?.rateLimiter;
4681
- this.spendLimiter = options?.spendLimiter;
4682
- this.budgetEngine = options?.budgetEngine;
4683
- this.session = options?.session ?? DEFAULT_SESSION_IDENTITY;
4684
- if (this.evidenceStore) {
4685
- this.evidenceStore.setAllowedEvidenceKeys(collectAllowedEvidenceKeys(policy));
4686
- }
4687
- }
4688
- /**
4689
- * Swap the compiled policy atomically and reconcile limit bucket state
4690
- * against the new configuration.
4691
- *
4692
- * Rate and spend limit buckets are preserved when their underlying rule
4693
- * config is unchanged — this is what makes a benign hot-reload (e.g. a
4694
- * `vim :w` with no real edits, or a whitespace-only config change) safe:
4695
- * operators do not get a surprise zero of their live rate/spend state
4696
- * mid-window. Buckets whose config changed or whose rule was removed are
4697
- * evicted by the limiters' `reconcile()` methods, so the next check
4698
- * lazy-creates a fresh bucket under the new config.
4699
- *
4700
- * See `packages/proxy/src/policy/rate-limiter.ts` and `spend-limiter.ts`
4701
- * for the per-bucket compare-and-evict semantics.
4702
- */
4703
- updatePolicy(policy) {
4704
- this.policy = policy;
4705
- if (this.evidenceStore) {
4706
- this.evidenceStore.setAllowedEvidenceKeys(collectAllowedEvidenceKeys(policy));
4717
+ updatePolicy(policy) {
4718
+ this.policy = policy;
4719
+ if (this.evidenceStore) {
4720
+ this.evidenceStore.setAllowedEvidenceKeys(collectAllowedEvidenceKeys(policy));
4707
4721
  }
4708
4722
  if (this.rateLimiter) {
4709
4723
  const rateConfigs = [];
4710
4724
  for (const rule of policy.rules) {
4711
4725
  const limits = rule.limits;
4712
4726
  if (limits?.maxCalls !== void 0 && limits.windowMs !== void 0) {
4713
- rateConfigs.push({ maxCalls: limits.maxCalls, windowMs: limits.windowMs });
4727
+ rateConfigs.push({
4728
+ maxCalls: limits.maxCalls,
4729
+ windowMs: limits.windowMs,
4730
+ // Rate bucket keys are rule-discriminated (ruleBucketKey), so
4731
+ // reconcile must match tuples at the owning rule's index.
4732
+ ruleIndex: rule.index
4733
+ });
4714
4734
  }
4715
4735
  }
4716
4736
  this.rateLimiter.reconcile(rateConfigs);
@@ -4724,7 +4744,7 @@ var GovernedForwarder = class {
4724
4744
  limit: maxSpend.limit,
4725
4745
  currency: maxSpend.currency,
4726
4746
  windowMs: maxSpend.windowMs,
4727
- // Spend bucket keys are rule-discriminated (spendBucketKey), so
4747
+ // Spend bucket keys are rule-discriminated (ruleBucketKey), so
4728
4748
  // reconcile must match tuples at the owning rule's index.
4729
4749
  ruleIndex: rule.index
4730
4750
  });
@@ -4870,7 +4890,8 @@ var GovernedForwarder = class {
4870
4890
  origin: "mcp",
4871
4891
  metadata: null,
4872
4892
  // Drift is a cache event, not a request: no protocol claim exists.
4873
- protocol_version: null
4893
+ protocol_version: null,
4894
+ upstream: this.upstreamName ?? null
4874
4895
  });
4875
4896
  }
4876
4897
  async handleToolsCall(original) {
@@ -4914,7 +4935,8 @@ var GovernedForwarder = class {
4914
4935
  evidenceStore: this.evidenceStore,
4915
4936
  baselineAnnotations: this.annotationCache.get(toolName),
4916
4937
  currentAnnotations: this.annotationCache.getCurrent(toolName),
4917
- driftEvent: this.annotationCache.getDrift(toolName)
4938
+ driftEvent: this.annotationCache.getDrift(toolName),
4939
+ upstream: this.upstreamName
4918
4940
  });
4919
4941
  const auditRecordId = randomUUID2();
4920
4942
  let result;
@@ -5057,6 +5079,8 @@ var GovernedForwarder = class {
5057
5079
  tool_input: toolArguments ?? {},
5058
5080
  matched_rule: decision.matchedRule,
5059
5081
  session_id: request.session?.id ?? null,
5082
+ session_source: request.session?.source ?? null,
5083
+ upstream: this.upstreamName ?? null,
5060
5084
  breached_budgets: gate.breachContexts,
5061
5085
  approval: gate.approval
5062
5086
  },
@@ -5204,8 +5228,9 @@ var GovernedForwarder = class {
5204
5228
  toolName,
5205
5229
  toolArguments,
5206
5230
  sessionId: sessionGate.ok ? sessionGate.session : null,
5207
- senderId: null
5231
+ senderId: null,
5208
5232
  // adapter context; absent on the MCP path
5233
+ upstream: this.upstreamName ?? null
5209
5234
  });
5210
5235
  if (charges.length === 0 && failures.length === 0) return { kind: "proceed" };
5211
5236
  const gated = gateBudgetCharges({ charges, failures }, sessionGate);
@@ -5351,7 +5376,8 @@ var GovernedForwarder = class {
5351
5376
  record_kind: "tool_call",
5352
5377
  origin: "mcp",
5353
5378
  metadata: null,
5354
- protocol_version: request.protocolVersion ?? null
5379
+ protocol_version: request.protocolVersion ?? null,
5380
+ upstream: this.upstreamName ?? null
5355
5381
  });
5356
5382
  }
5357
5383
  return result;
@@ -5364,7 +5390,9 @@ var GovernedForwarder = class {
5364
5390
  tool_name: toolName,
5365
5391
  tool_input: toolArguments ?? {},
5366
5392
  matched_rule: decision.matchedRule,
5367
- session_id: request.session?.id ?? null
5393
+ session_id: request.session?.id ?? null,
5394
+ session_source: request.session?.source ?? null,
5395
+ upstream: this.upstreamName ?? null
5368
5396
  },
5369
5397
  request.signal
5370
5398
  );
@@ -5446,8 +5474,9 @@ var GovernedForwarder = class {
5446
5474
  }
5447
5475
  handleRateLimit(request, decision, toolName) {
5448
5476
  const limiter = this.rateLimiter;
5449
- const limits = decision.matchedRule?.limits;
5450
- if (!limits?.maxCalls || !limits.windowMs) {
5477
+ const matchedRule = decision.matchedRule;
5478
+ const limits = matchedRule?.limits;
5479
+ if (!matchedRule || !limits?.maxCalls || !limits.windowMs) {
5451
5480
  const result = this.makePolicyMisconfiguredResult(
5452
5481
  request,
5453
5482
  decision,
@@ -5460,7 +5489,7 @@ var GovernedForwarder = class {
5460
5489
  rateLimitResult: { allowed: false, current: 0, limit: 0, windowMs: 0, resetAtMs: 0 }
5461
5490
  };
5462
5491
  }
5463
- let key;
5492
+ let baseKey;
5464
5493
  if (limits.key === "session") {
5465
5494
  const sessionKey = this.gateSessionLimitKey(request);
5466
5495
  if (sessionKey === null) {
@@ -5470,15 +5499,16 @@ var GovernedForwarder = class {
5470
5499
  approvalWaitMs: 0
5471
5500
  };
5472
5501
  }
5473
- key = sessionKey;
5502
+ baseKey = sessionKey;
5474
5503
  } else {
5475
- key = this.buildLimitKey(limits.key, toolName);
5504
+ baseKey = this.buildLimitKey(limits.key, toolName);
5476
5505
  }
5506
+ const key = ruleBucketKey(baseKey, matchedRule.index);
5477
5507
  const params = { key, maxCalls: limits.maxCalls, windowMs: limits.windowMs };
5478
5508
  const rateLimitResult = limiter.peek(params);
5479
5509
  if (!rateLimitResult.allowed) {
5480
5510
  const feedback = buildRateLimitedFeedback(decision, rateLimitResult);
5481
- const message = decision.matchedRule?.feedback?.message ?? `Rate limit exceeded for ${key}`;
5511
+ const message = matchedRule.feedback?.message ?? `Rate limit exceeded for ${key}`;
5482
5512
  return {
5483
5513
  proceed: false,
5484
5514
  result: makeErrorResult(request, POLICY_DENIED, message, { ...feedback }),
@@ -5525,7 +5555,7 @@ var GovernedForwarder = class {
5525
5555
  } else {
5526
5556
  baseKey = this.buildLimitKey(maxSpend.key, toolName);
5527
5557
  }
5528
- const key = spendBucketKey(baseKey, decision.matchedRule.index);
5558
+ const key = ruleBucketKey(baseKey, decision.matchedRule.index);
5529
5559
  const rawAmount = resolvePath(maxSpend.field, toolArguments ?? {});
5530
5560
  if (typeof rawAmount !== "number") {
5531
5561
  console.error(
@@ -5598,14 +5628,14 @@ var GovernedForwarder = class {
5598
5628
  case "rate_limit":
5599
5629
  if (this.rateLimiter && decision.matchedRule?.limits?.maxCalls && decision.matchedRule.limits.windowMs) {
5600
5630
  const limits = decision.matchedRule.limits;
5601
- const key = limits.key === "session" ? this.gateSessionLimitKey(request) : this.buildLimitKey(limits.key, toolName);
5602
- if (key === null) {
5631
+ const baseKey = limits.key === "session" ? this.gateSessionLimitKey(request) : this.buildLimitKey(limits.key, toolName);
5632
+ if (baseKey === null) {
5603
5633
  wouldForward = false;
5604
5634
  limitsOk = false;
5605
5635
  sessionUnresolved = true;
5606
5636
  } else {
5607
5637
  const peekResult = this.rateLimiter.peek({
5608
- key,
5638
+ key: ruleBucketKey(baseKey, decision.matchedRule.index),
5609
5639
  maxCalls: decision.matchedRule.limits.maxCalls,
5610
5640
  windowMs: decision.matchedRule.limits.windowMs
5611
5641
  });
@@ -5638,7 +5668,7 @@ var GovernedForwarder = class {
5638
5668
  sessionUnresolved = true;
5639
5669
  } else {
5640
5670
  const peekResult = this.spendLimiter.peek({
5641
- key: spendBucketKey(baseKey, decision.matchedRule.index),
5671
+ key: ruleBucketKey(baseKey, decision.matchedRule.index),
5642
5672
  amount: rawAmount,
5643
5673
  limit: maxSpend.limit,
5644
5674
  windowMs: maxSpend.windowMs
@@ -5658,7 +5688,8 @@ var GovernedForwarder = class {
5658
5688
  toolName,
5659
5689
  toolArguments,
5660
5690
  sessionId: sessionGate.ok ? sessionGate.session : null,
5661
- senderId: null
5691
+ senderId: null,
5692
+ upstream: this.upstreamName ?? null
5662
5693
  });
5663
5694
  if (failures.length > 0 || charges.length > 0) {
5664
5695
  const gated = gateBudgetCharges({ charges, failures }, sessionGate);
@@ -5699,7 +5730,9 @@ var GovernedForwarder = class {
5699
5730
  );
5700
5731
  }
5701
5732
  /**
5702
- * Construct a non-session limit bucket key. Session keys are deliberately
5733
+ * Construct a non-session limit bucket key. Tool-scope keys route through
5734
+ * the shared `toolLimitKey` leaf, which prefixes them with the configured
5735
+ * upstream name when one is set (issue #295). Session keys are deliberately
5703
5736
  * NOT built here: they come only from the gate module's `sessionLimitKey`,
5704
5737
  * whose `GatedSession` parameter makes skipping the identity gate a
5705
5738
  * compile error (issue #218) — call sites branch on `key === 'session'`.
@@ -5713,7 +5746,7 @@ var GovernedForwarder = class {
5713
5746
  '[helio] Warning: limits.key "agent" is not yet supported, falling back to "tool"'
5714
5747
  );
5715
5748
  }
5716
- return `tool:${toolName}`;
5749
+ return toolLimitKey(toolName, this.upstreamName);
5717
5750
  case "sender_id":
5718
5751
  if (!this.senderKeyWarned) {
5719
5752
  this.senderKeyWarned = true;
@@ -5721,10 +5754,10 @@ var GovernedForwarder = class {
5721
5754
  '[helio] Warning: limits.key "sender_id" has no sender on the MCP path, falling back to "tool"'
5722
5755
  );
5723
5756
  }
5724
- return `tool:${toolName}`;
5757
+ return toolLimitKey(toolName, this.upstreamName);
5725
5758
  case "tool":
5726
5759
  default:
5727
- return `tool:${toolName}`;
5760
+ return toolLimitKey(toolName, this.upstreamName);
5728
5761
  }
5729
5762
  }
5730
5763
  /**
@@ -5879,7 +5912,8 @@ var GovernedForwarder = class {
5879
5912
  record_kind: "tool_call",
5880
5913
  origin: "mcp",
5881
5914
  metadata: null,
5882
- protocol_version: request.protocolVersion ?? null
5915
+ protocol_version: request.protocolVersion ?? null,
5916
+ upstream: this.upstreamName ?? null
5883
5917
  };
5884
5918
  const isEnforcementDecision = !isDryRun && (!forwarded || approvalOutcome !== void 0 || budgetApproval !== void 0);
5885
5919
  if (isEnforcementDecision) {
@@ -5950,113 +5984,376 @@ var GovernedForwarder = class {
5950
5984
  headers: { "content-type": "application/json" },
5951
5985
  body
5952
5986
  };
5953
- return { response, durationMs: 0 };
5954
- }
5955
- makeEvidenceBlockResult(request, decision, evidenceResult, dependencyResult) {
5956
- const reason = evidenceResult && !evidenceResult.satisfied ? evidenceResult.expired.length > 0 ? "evidence_expired" : "evidence_missing" : "dependency_missing";
5957
- const builder = reason === "evidence_expired" ? buildEvidenceExpiredFeedback : reason === "evidence_missing" ? buildEvidenceMissingFeedback : buildDependencyMissingFeedback;
5958
- const feedback = builder(decision, evidenceResult, dependencyResult);
5959
- return makeErrorResult(
5960
- request,
5961
- POLICY_DENIED,
5962
- `Evidence grounding failed: ${decision.reason}`,
5963
- { ...feedback }
5964
- );
5965
- }
5966
- makeSessionRequiredBlockResult(request, decision) {
5967
- const feedback = buildPolicyDeniedFeedback(decision);
5968
- return makeErrorResult(request, POLICY_DENIED, decision.reason, {
5969
- ...feedback,
5970
- retry_allowed: true
5971
- });
5987
+ return { response, durationMs: 0 };
5988
+ }
5989
+ makeEvidenceBlockResult(request, decision, evidenceResult, dependencyResult) {
5990
+ const reason = evidenceResult && !evidenceResult.satisfied ? evidenceResult.expired.length > 0 ? "evidence_expired" : "evidence_missing" : "dependency_missing";
5991
+ const builder = reason === "evidence_expired" ? buildEvidenceExpiredFeedback : reason === "evidence_missing" ? buildEvidenceMissingFeedback : buildDependencyMissingFeedback;
5992
+ const feedback = builder(decision, evidenceResult, dependencyResult);
5993
+ return makeErrorResult(
5994
+ request,
5995
+ POLICY_DENIED,
5996
+ `Evidence grounding failed: ${decision.reason}`,
5997
+ { ...feedback }
5998
+ );
5999
+ }
6000
+ makeSessionRequiredBlockResult(request, decision) {
6001
+ const feedback = buildPolicyDeniedFeedback(decision);
6002
+ return makeErrorResult(request, POLICY_DENIED, decision.reason, {
6003
+ ...feedback,
6004
+ retry_allowed: true
6005
+ });
6006
+ }
6007
+ makeClientDisconnectedBlockResult(request, decision) {
6008
+ const feedback = buildClientDisconnectedFeedback(decision);
6009
+ return makeErrorResult(request, POLICY_DENIED, "Client disconnected before completion", {
6010
+ ...feedback
6011
+ });
6012
+ }
6013
+ };
6014
+ function collectAllowedEvidenceKeys(policy) {
6015
+ const keys = /* @__PURE__ */ new Set();
6016
+ for (const rule of policy.rules) {
6017
+ for (const key of rule.evidence?.requires ?? []) {
6018
+ keys.add(key);
6019
+ }
6020
+ }
6021
+ return [...keys];
6022
+ }
6023
+ function makeErrorResult(request, code, message, data) {
6024
+ const body = {
6025
+ jsonrpc: "2.0",
6026
+ id: request.id ?? null,
6027
+ error: { code, message, data }
6028
+ };
6029
+ const response = {
6030
+ status: 200,
6031
+ headers: { "content-type": "application/json" },
6032
+ body
6033
+ };
6034
+ return { response, durationMs: 0 };
6035
+ }
6036
+ function approvedByOf(outcome) {
6037
+ return outcome && "resolvedBy" in outcome ? outcome.resolvedBy : null;
6038
+ }
6039
+ function hasJsonRpcError(result) {
6040
+ const body = result.response.body;
6041
+ return body?.["error"] !== void 0;
6042
+ }
6043
+ function classifyPrimeFailure(response) {
6044
+ if (response.status >= 400) {
6045
+ return `upstream returned HTTP ${String(response.status)} to tools/list (session/initialize may be required)`;
6046
+ }
6047
+ const rawBody = response.body;
6048
+ if (typeof rawBody !== "object" || rawBody === null) {
6049
+ return `upstream tools/list returned a non-JSON body (content-type ${response.headers["content-type"] ?? "unknown"})`;
6050
+ }
6051
+ const body = rawBody;
6052
+ const error = body["error"];
6053
+ if (typeof error === "string") {
6054
+ return `upstream tools/list returned a JSON-RPC error: ${error}`;
6055
+ }
6056
+ if (error !== null && typeof error === "object") {
6057
+ const message = error["message"];
6058
+ if (typeof message === "string") {
6059
+ return `upstream tools/list returned a JSON-RPC error: ${message}`;
6060
+ }
6061
+ }
6062
+ return "upstream tools/list response was missing result.tools";
6063
+ }
6064
+ function extractBlockReason(result) {
6065
+ const body = result.response.body;
6066
+ const error = body?.["error"];
6067
+ if (!error || typeof error !== "object") return null;
6068
+ const data = error["data"];
6069
+ if (!data || data["blocked"] !== true) return null;
6070
+ return typeof data["reason"] === "string" ? data["reason"] : null;
6071
+ }
6072
+ function buildEvidenceChain(evidenceResult, dependencyResult, blocked2) {
6073
+ if (!evidenceResult && !dependencyResult) return null;
6074
+ const chain = { blocked: blocked2 ?? false };
6075
+ if (evidenceResult) {
6076
+ chain["evidence"] = {
6077
+ required: [...evidenceResult.found, ...evidenceResult.missing, ...evidenceResult.expired],
6078
+ found: evidenceResult.found,
6079
+ missing: evidenceResult.missing,
6080
+ expired: evidenceResult.expired
6081
+ };
6082
+ }
6083
+ if (dependencyResult) {
6084
+ chain["dependencies"] = {
6085
+ satisfied: dependencyResult.satisfied,
6086
+ missing: dependencyResult.missing
6087
+ };
6088
+ }
6089
+ return chain;
6090
+ }
6091
+
6092
+ // src/policy/rate-limiter.ts
6093
+ var RateLimiter = class {
6094
+ buckets = /* @__PURE__ */ new Map();
6095
+ now;
6096
+ onWarning;
6097
+ warningThreshold;
6098
+ timer = null;
6099
+ closed = false;
6100
+ constructor(options = {}) {
6101
+ this.now = options.now ?? Date.now;
6102
+ this.onWarning = options.onWarning;
6103
+ this.warningThreshold = options.warningThreshold ?? 0.8;
6104
+ const intervalMs = options.cleanupIntervalMs ?? 6e4;
6105
+ if (intervalMs > 0) {
6106
+ this.timer = setInterval(() => {
6107
+ this.cleanup();
6108
+ }, intervalMs);
6109
+ this.timer.unref();
6110
+ }
6111
+ }
6112
+ // -------------------------------------------------------------------------
6113
+ // Core operations
6114
+ // -------------------------------------------------------------------------
6115
+ /**
6116
+ * Check and optionally record a call against the rate limit.
6117
+ *
6118
+ * Evicts expired timestamps, then checks the count:
6119
+ * - Under limit: records the timestamp and returns `allowed: true`
6120
+ * - At/over limit: does NOT record (blocked calls don't consume a slot)
6121
+ */
6122
+ check(params) {
6123
+ const { key, maxCalls, windowMs } = params;
6124
+ const now = this.now();
6125
+ const windowStart = now - windowMs;
6126
+ let bucket = this.buckets.get(key);
6127
+ if (!bucket) {
6128
+ bucket = { timestamps: [], maxCalls, windowMs };
6129
+ this.buckets.set(key, bucket);
6130
+ }
6131
+ bucket.maxCalls = maxCalls;
6132
+ bucket.windowMs = windowMs;
6133
+ bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6134
+ if (bucket.timestamps.length >= maxCalls) {
6135
+ const oldest = bucket.timestamps[0] ?? 0;
6136
+ return {
6137
+ allowed: false,
6138
+ current: bucket.timestamps.length,
6139
+ limit: maxCalls,
6140
+ windowMs,
6141
+ resetAtMs: oldest + windowMs
6142
+ };
6143
+ }
6144
+ bucket.timestamps.push(now);
6145
+ const current = bucket.timestamps.length;
6146
+ const resetAtMs = (bucket.timestamps[0] ?? now) + windowMs;
6147
+ if (this.onWarning && current / maxCalls >= this.warningThreshold) {
6148
+ this.safeWarn({ key, current, limit: maxCalls, window_ms: windowMs, reset_at_ms: resetAtMs });
6149
+ }
6150
+ return {
6151
+ allowed: true,
6152
+ current,
6153
+ limit: maxCalls,
6154
+ windowMs,
6155
+ resetAtMs
6156
+ };
6157
+ }
6158
+ /**
6159
+ * Unconditionally record a call against the rate limit.
6160
+ *
6161
+ * Unlike check(), this always appends the timestamp — even when the bucket
6162
+ * is already at/over the limit — because the call it represents has already
6163
+ * executed. The sideband splits decision from execution: /evaluate peeks
6164
+ * (non-destructive), and /audit calls record() once the external call ran,
6165
+ * so refusing to record at the limit (as check() does) would let real calls
6166
+ * escape accounting and under-count subsequent peeks. (issue #12, D3.)
6167
+ *
6168
+ * Warnings fire only while the post-append count stays within the limit —
6169
+ * exact parity with check(), which never warns on its over-limit path — so a
6170
+ * burst of over-limit audits cannot flood the dashboard's limit_warning feed.
6171
+ */
6172
+ record(params) {
6173
+ const { key, maxCalls, windowMs } = params;
6174
+ const now = this.now();
6175
+ const windowStart = now - windowMs;
6176
+ let bucket = this.buckets.get(key);
6177
+ if (!bucket) {
6178
+ bucket = { timestamps: [], maxCalls, windowMs };
6179
+ this.buckets.set(key, bucket);
6180
+ }
6181
+ bucket.maxCalls = maxCalls;
6182
+ bucket.windowMs = windowMs;
6183
+ bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6184
+ bucket.timestamps.push(now);
6185
+ const current = bucket.timestamps.length;
6186
+ const resetAtMs = (bucket.timestamps[0] ?? now) + windowMs;
6187
+ if (this.onWarning && current <= maxCalls && current / maxCalls >= this.warningThreshold) {
6188
+ this.safeWarn({ key, current, limit: maxCalls, window_ms: windowMs, reset_at_ms: resetAtMs });
6189
+ }
6190
+ return {
6191
+ allowed: current <= maxCalls,
6192
+ current,
6193
+ limit: maxCalls,
6194
+ windowMs,
6195
+ resetAtMs
6196
+ };
5972
6197
  }
5973
- makeClientDisconnectedBlockResult(request, decision) {
5974
- const feedback = buildClientDisconnectedFeedback(decision);
5975
- return makeErrorResult(request, POLICY_DENIED, "Client disconnected before completion", {
5976
- ...feedback
5977
- });
6198
+ /**
6199
+ * Check the rate limit without recording the call (non-destructive).
6200
+ *
6201
+ * Used by dry-run mode to determine what would happen without consuming
6202
+ * a slot in the bucket.
6203
+ */
6204
+ peek(params) {
6205
+ const { key, maxCalls, windowMs } = params;
6206
+ const now = this.now();
6207
+ const windowStart = now - windowMs;
6208
+ const bucket = this.buckets.get(key);
6209
+ if (!bucket) {
6210
+ return {
6211
+ allowed: true,
6212
+ current: 1,
6213
+ limit: maxCalls,
6214
+ windowMs,
6215
+ resetAtMs: now + windowMs
6216
+ };
6217
+ }
6218
+ const activeCount = bucket.timestamps.filter((ts) => ts > windowStart).length;
6219
+ if (activeCount >= maxCalls) {
6220
+ const oldest2 = bucket.timestamps.find((ts) => ts > windowStart) ?? 0;
6221
+ return {
6222
+ allowed: false,
6223
+ current: activeCount,
6224
+ limit: maxCalls,
6225
+ windowMs,
6226
+ resetAtMs: oldest2 + windowMs
6227
+ };
6228
+ }
6229
+ const oldest = bucket.timestamps.find((ts) => ts > windowStart) ?? now;
6230
+ return {
6231
+ allowed: true,
6232
+ current: activeCount + 1,
6233
+ limit: maxCalls,
6234
+ windowMs,
6235
+ resetAtMs: oldest + windowMs
6236
+ };
5978
6237
  }
5979
- };
5980
- function collectAllowedEvidenceKeys(policy) {
5981
- const keys = /* @__PURE__ */ new Set();
5982
- for (const rule of policy.rules) {
5983
- for (const key of rule.evidence?.requires ?? []) {
5984
- keys.add(key);
6238
+ // -------------------------------------------------------------------------
6239
+ // Read operations (for dashboard API)
6240
+ // -------------------------------------------------------------------------
6241
+ /** Get the current state of a single key. Returns undefined if not tracked. */
6242
+ getKeyState(key) {
6243
+ const bucket = this.buckets.get(key);
6244
+ if (!bucket) return void 0;
6245
+ const windowStart = this.now() - bucket.windowMs;
6246
+ bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6247
+ if (bucket.timestamps.length === 0) {
6248
+ this.buckets.delete(key);
6249
+ return void 0;
5985
6250
  }
6251
+ return {
6252
+ key,
6253
+ current: bucket.timestamps.length,
6254
+ limit: bucket.maxCalls,
6255
+ window_ms: bucket.windowMs,
6256
+ reset_at_ms: (bucket.timestamps[0] ?? 0) + bucket.windowMs
6257
+ };
5986
6258
  }
5987
- return [...keys];
5988
- }
5989
- function makeErrorResult(request, code, message, data) {
5990
- const body = {
5991
- jsonrpc: "2.0",
5992
- id: request.id ?? null,
5993
- error: { code, message, data }
5994
- };
5995
- const response = {
5996
- status: 200,
5997
- headers: { "content-type": "application/json" },
5998
- body
5999
- };
6000
- return { response, durationMs: 0 };
6001
- }
6002
- function approvedByOf(outcome) {
6003
- return outcome && "resolvedBy" in outcome ? outcome.resolvedBy : null;
6004
- }
6005
- function hasJsonRpcError(result) {
6006
- const body = result.response.body;
6007
- return body?.["error"] !== void 0;
6008
- }
6009
- function classifyPrimeFailure(response) {
6010
- if (response.status >= 400) {
6011
- return `upstream returned HTTP ${String(response.status)} to tools/list (session/initialize may be required)`;
6259
+ /** List all tracked keys with their current state. */
6260
+ listKeyStates() {
6261
+ const states = [];
6262
+ for (const key of [...this.buckets.keys()]) {
6263
+ const state = this.getKeyState(key);
6264
+ if (state) states.push(state);
6265
+ }
6266
+ return states;
6012
6267
  }
6013
- const rawBody = response.body;
6014
- if (typeof rawBody !== "object" || rawBody === null) {
6015
- return `upstream tools/list returned a non-JSON body (content-type ${response.headers["content-type"] ?? "unknown"})`;
6268
+ // -------------------------------------------------------------------------
6269
+ // Maintenance
6270
+ // -------------------------------------------------------------------------
6271
+ /** Sweep all buckets: remove expired timestamps, delete empty buckets. */
6272
+ cleanup() {
6273
+ const now = this.now();
6274
+ for (const [key, bucket] of this.buckets) {
6275
+ const windowStart = now - bucket.windowMs;
6276
+ bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6277
+ if (bucket.timestamps.length === 0) {
6278
+ this.buckets.delete(key);
6279
+ }
6280
+ }
6016
6281
  }
6017
- const body = rawBody;
6018
- const error = body["error"];
6019
- if (typeof error === "string") {
6020
- return `upstream tools/list returned a JSON-RPC error: ${error}`;
6282
+ /** Clear all rate limit state. Called on policy hot-reload. */
6283
+ reset() {
6284
+ this.buckets.clear();
6021
6285
  }
6022
- if (error !== null && typeof error === "object") {
6023
- const message = error["message"];
6024
- if (typeof message === "string") {
6025
- return `upstream tools/list returned a JSON-RPC error: ${message}`;
6286
+ /**
6287
+ * Reconcile bucket state against a new policy's limit configuration.
6288
+ *
6289
+ * Walks every existing bucket and checks whether its last-seen
6290
+ * `{ maxCalls, windowMs }` tuple still appears in `validConfigs`.
6291
+ * Buckets whose config is still present are left untouched — counters and
6292
+ * elapsed-window progress are preserved across hot-reloads. Buckets whose
6293
+ * config is gone (rule changed or removed) are evicted so the next check
6294
+ * lazy-creates a fresh bucket under the new config.
6295
+ *
6296
+ * Keys built by `ruleBucketKey` (bucket-key.ts) carry the owning rule's
6297
+ * index, and for those the tuple must match at THAT index
6298
+ * (`config.ruleIndex`): a reorder that shifts a rate rule's index evicts
6299
+ * its old-index bucket instead of leaving an orphan no rule reads again —
6300
+ * or worse, letting whatever rule now sits at that index adopt another
6301
+ * rule's accrued calls. Un-suffixed keys keep the tuple-anywhere match,
6302
+ * but only against index-less configs — a caller that passes only indexed
6303
+ * configs (as the proxy does) evicts every un-suffixed bucket, fail-closed.
6304
+ *
6305
+ * This is the compare-and-evict semantic that replaces the old `reset()`
6306
+ * call on every hot-reload, which wiped all state even when the matching
6307
+ * rule was unchanged.
6308
+ */
6309
+ reconcile(validConfigs) {
6310
+ const valid = /* @__PURE__ */ new Set();
6311
+ const byIndex = /* @__PURE__ */ new Map();
6312
+ for (const config of validConfigs) {
6313
+ const tuple = `${String(config.maxCalls)}|${String(config.windowMs)}`;
6314
+ if (config.ruleIndex === void 0) {
6315
+ valid.add(tuple);
6316
+ } else {
6317
+ byIndex.set(config.ruleIndex, tuple);
6318
+ }
6319
+ }
6320
+ for (const [key, bucket] of this.buckets) {
6321
+ const tuple = `${String(bucket.maxCalls)}|${String(bucket.windowMs)}`;
6322
+ const ruleIndex = parseRuleIndex(key);
6323
+ const survives = ruleIndex === void 0 ? valid.has(tuple) : byIndex.get(ruleIndex) === tuple;
6324
+ if (!survives) {
6325
+ this.buckets.delete(key);
6326
+ }
6026
6327
  }
6027
6328
  }
6028
- return "upstream tools/list response was missing result.tools";
6029
- }
6030
- function extractBlockReason(result) {
6031
- const body = result.response.body;
6032
- const error = body?.["error"];
6033
- if (!error || typeof error !== "object") return null;
6034
- const data = error["data"];
6035
- if (!data || data["blocked"] !== true) return null;
6036
- return typeof data["reason"] === "string" ? data["reason"] : null;
6037
- }
6038
- function buildEvidenceChain(evidenceResult, dependencyResult, blocked2) {
6039
- if (!evidenceResult && !dependencyResult) return null;
6040
- const chain = { blocked: blocked2 ?? false };
6041
- if (evidenceResult) {
6042
- chain["evidence"] = {
6043
- required: [...evidenceResult.found, ...evidenceResult.missing, ...evidenceResult.expired],
6044
- found: evidenceResult.found,
6045
- missing: evidenceResult.missing,
6046
- expired: evidenceResult.expired
6047
- };
6329
+ /** Stop the cleanup timer and mark as closed. */
6330
+ /**
6331
+ * Invoke the warning callback without letting a subscriber throw into the
6332
+ * limiter's caller: a warning fires after state has already mutated, and a
6333
+ * governed call must not be blocked (or double-charged on retry) by an
6334
+ * observability bug.
6335
+ */
6336
+ safeWarn(state) {
6337
+ if (!this.onWarning) return;
6338
+ try {
6339
+ this.onWarning(state);
6340
+ } catch (err) {
6341
+ console.error("[helio] limit warning subscriber threw:", err);
6342
+ }
6048
6343
  }
6049
- if (dependencyResult) {
6050
- chain["dependencies"] = {
6051
- satisfied: dependencyResult.satisfied,
6052
- missing: dependencyResult.missing
6053
- };
6344
+ close() {
6345
+ if (this.closed) return;
6346
+ this.closed = true;
6347
+ if (this.timer) {
6348
+ clearInterval(this.timer);
6349
+ this.timer = null;
6350
+ }
6351
+ this.buckets.clear();
6054
6352
  }
6055
- return chain;
6056
- }
6353
+ };
6057
6354
 
6058
- // src/policy/rate-limiter.ts
6059
- var RateLimiter = class {
6355
+ // src/policy/spend-limiter.ts
6356
+ var SpendLimiter = class {
6060
6357
  buckets = /* @__PURE__ */ new Map();
6061
6358
  now;
6062
6359
  onWarning;
@@ -6079,128 +6376,185 @@ var RateLimiter = class {
6079
6376
  // Core operations
6080
6377
  // -------------------------------------------------------------------------
6081
6378
  /**
6082
- * Check and optionally record a call against the rate limit.
6379
+ * Check and optionally record a spend against the limit.
6083
6380
  *
6084
- * Evicts expired timestamps, then checks the count:
6085
- * - Under limit: records the timestamp and returns `allowed: true`
6086
- * - At/over limit: does NOT record (blocked calls don't consume a slot)
6381
+ * Evicts expired entries, sums remaining amounts, then checks:
6382
+ * - Under limit (currentSpend + amount <= limit): records and returns `allowed: true`
6383
+ * - Would exceed: does NOT record (rejected spends don't consume budget)
6087
6384
  */
6088
6385
  check(params) {
6089
- const { key, maxCalls, windowMs } = params;
6386
+ const { key, amount, limit, windowMs } = params;
6090
6387
  const now = this.now();
6091
6388
  const windowStart = now - windowMs;
6389
+ if (!Number.isFinite(amount) || amount < 0) {
6390
+ const existing = this.buckets.get(key);
6391
+ const activeEntries = existing ? existing.entries.filter((e) => e.timestamp > windowStart) : [];
6392
+ const currentSpend2 = activeEntries.reduce((sum, e) => sum + e.amount, 0);
6393
+ const oldest = activeEntries[0];
6394
+ return {
6395
+ allowed: false,
6396
+ currentSpend: currentSpend2,
6397
+ limit,
6398
+ windowMs,
6399
+ resetAtMs: oldest ? oldest.timestamp + windowMs : 0,
6400
+ reason: "invalid_amount"
6401
+ };
6402
+ }
6092
6403
  let bucket = this.buckets.get(key);
6093
6404
  if (!bucket) {
6094
- bucket = { timestamps: [], maxCalls, windowMs };
6405
+ bucket = { entries: [], limit, currency: "", windowMs };
6095
6406
  this.buckets.set(key, bucket);
6096
6407
  }
6097
- bucket.maxCalls = maxCalls;
6408
+ bucket.limit = limit;
6098
6409
  bucket.windowMs = windowMs;
6099
- bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6100
- if (bucket.timestamps.length >= maxCalls) {
6101
- const oldest = bucket.timestamps[0] ?? 0;
6410
+ bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
6411
+ const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
6412
+ if (currentSpend + amount > limit) {
6413
+ const oldest = bucket.entries[0];
6102
6414
  return {
6103
6415
  allowed: false,
6104
- current: bucket.timestamps.length,
6105
- limit: maxCalls,
6416
+ currentSpend,
6417
+ limit,
6106
6418
  windowMs,
6107
- resetAtMs: oldest + windowMs
6419
+ resetAtMs: oldest ? oldest.timestamp + windowMs : 0
6108
6420
  };
6109
6421
  }
6110
- bucket.timestamps.push(now);
6111
- const current = bucket.timestamps.length;
6112
- const resetAtMs = (bucket.timestamps[0] ?? now) + windowMs;
6113
- if (this.onWarning && current / maxCalls >= this.warningThreshold) {
6114
- this.safeWarn({ key, current, limit: maxCalls, window_ms: windowMs, reset_at_ms: resetAtMs });
6422
+ bucket.entries.push({ timestamp: now, amount });
6423
+ const newSpend = currentSpend + amount;
6424
+ const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
6425
+ if (this.onWarning && newSpend / limit >= this.warningThreshold) {
6426
+ this.safeWarn({
6427
+ key,
6428
+ current_spend: newSpend,
6429
+ limit,
6430
+ currency: bucket.currency,
6431
+ window_ms: windowMs,
6432
+ reset_at_ms: resetAtMs
6433
+ });
6115
6434
  }
6116
6435
  return {
6117
6436
  allowed: true,
6118
- current,
6119
- limit: maxCalls,
6437
+ currentSpend: newSpend,
6438
+ limit,
6120
6439
  windowMs,
6121
6440
  resetAtMs
6122
6441
  };
6123
6442
  }
6124
6443
  /**
6125
- * Unconditionally record a call against the rate limit.
6444
+ * Unconditionally record a spend against the limit.
6126
6445
  *
6127
- * Unlike check(), this always appends the timestamp — even when the bucket
6128
- * is already at/over the limit — because the call it represents has already
6129
- * executed. The sideband splits decision from execution: /evaluate peeks
6130
- * (non-destructive), and /audit calls record() once the external call ran,
6131
- * so refusing to record at the limit (as check() does) would let real calls
6132
- * escape accounting and under-count subsequent peeks. (issue #12, D3.)
6446
+ * Unlike check(), this always appends the amount — even when it pushes the
6447
+ * window past the limit — because the spend it represents has already been
6448
+ * incurred. The sideband peeks at /evaluate and commits here at /audit once
6449
+ * the external call ran (issue #12, D3).
6133
6450
  *
6134
- * Warnings fire only while the post-append count stays within the limit
6135
- * exact parity with check(), which never warns on its over-limit path — so a
6136
- * burst of over-limit audits cannot flood the dashboard's limit_warning feed.
6451
+ * Throws on a negative or non-finite amount: such amounts are rejected at
6452
+ * /evaluate, so one reaching record() is a logic bug we surface loudly rather
6453
+ * than silently corrupt the sliding-window sum. Warnings fire only while the
6454
+ * post-append spend stays within the limit (parity with check()).
6137
6455
  */
6138
6456
  record(params) {
6139
- const { key, maxCalls, windowMs } = params;
6457
+ const { key, amount, limit, windowMs } = params;
6458
+ if (!Number.isFinite(amount) || amount < 0) {
6459
+ throw new RangeError(
6460
+ `SpendLimiter.record() received an invalid amount (${String(amount)}); invalid amounts must be rejected at /evaluate, never committed`
6461
+ );
6462
+ }
6140
6463
  const now = this.now();
6141
6464
  const windowStart = now - windowMs;
6142
6465
  let bucket = this.buckets.get(key);
6143
6466
  if (!bucket) {
6144
- bucket = { timestamps: [], maxCalls, windowMs };
6467
+ bucket = { entries: [], limit, currency: "", windowMs };
6145
6468
  this.buckets.set(key, bucket);
6146
6469
  }
6147
- bucket.maxCalls = maxCalls;
6470
+ bucket.limit = limit;
6148
6471
  bucket.windowMs = windowMs;
6149
- bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6150
- bucket.timestamps.push(now);
6151
- const current = bucket.timestamps.length;
6152
- const resetAtMs = (bucket.timestamps[0] ?? now) + windowMs;
6153
- if (this.onWarning && current <= maxCalls && current / maxCalls >= this.warningThreshold) {
6154
- this.safeWarn({ key, current, limit: maxCalls, window_ms: windowMs, reset_at_ms: resetAtMs });
6472
+ bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
6473
+ bucket.entries.push({ timestamp: now, amount });
6474
+ const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
6475
+ const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
6476
+ if (this.onWarning && currentSpend <= limit && currentSpend / limit >= this.warningThreshold) {
6477
+ this.safeWarn({
6478
+ key,
6479
+ current_spend: currentSpend,
6480
+ limit,
6481
+ currency: bucket.currency,
6482
+ window_ms: windowMs,
6483
+ reset_at_ms: resetAtMs
6484
+ });
6155
6485
  }
6156
6486
  return {
6157
- allowed: current <= maxCalls,
6158
- current,
6159
- limit: maxCalls,
6487
+ allowed: currentSpend <= limit,
6488
+ currentSpend,
6489
+ limit,
6160
6490
  windowMs,
6161
6491
  resetAtMs
6162
6492
  };
6163
6493
  }
6164
6494
  /**
6165
- * Check the rate limit without recording the call (non-destructive).
6495
+ * Check the spend limit without recording the spend (non-destructive).
6166
6496
  *
6167
6497
  * Used by dry-run mode to determine what would happen without consuming
6168
- * a slot in the bucket.
6498
+ * budget in the bucket.
6169
6499
  */
6170
6500
  peek(params) {
6171
- const { key, maxCalls, windowMs } = params;
6501
+ const { key, amount, limit, windowMs } = params;
6172
6502
  const now = this.now();
6173
6503
  const windowStart = now - windowMs;
6174
6504
  const bucket = this.buckets.get(key);
6505
+ if (!Number.isFinite(amount) || amount < 0) {
6506
+ const activeEntries2 = bucket ? bucket.entries.filter((e) => e.timestamp > windowStart) : [];
6507
+ const currentSpend2 = activeEntries2.reduce((sum, e) => sum + e.amount, 0);
6508
+ const oldest2 = activeEntries2[0];
6509
+ return {
6510
+ allowed: false,
6511
+ currentSpend: currentSpend2,
6512
+ limit,
6513
+ windowMs,
6514
+ resetAtMs: oldest2 ? oldest2.timestamp + windowMs : 0,
6515
+ reason: "invalid_amount"
6516
+ };
6517
+ }
6175
6518
  if (!bucket) {
6519
+ const wouldExceed = amount > limit;
6176
6520
  return {
6177
- allowed: true,
6178
- current: 1,
6179
- limit: maxCalls,
6521
+ allowed: !wouldExceed,
6522
+ currentSpend: wouldExceed ? 0 : amount,
6523
+ limit,
6180
6524
  windowMs,
6181
6525
  resetAtMs: now + windowMs
6182
6526
  };
6183
6527
  }
6184
- const activeCount = bucket.timestamps.filter((ts) => ts > windowStart).length;
6185
- if (activeCount >= maxCalls) {
6186
- const oldest2 = bucket.timestamps.find((ts) => ts > windowStart) ?? 0;
6528
+ const activeEntries = bucket.entries.filter((e) => e.timestamp > windowStart);
6529
+ const currentSpend = activeEntries.reduce((sum, e) => sum + e.amount, 0);
6530
+ if (currentSpend + amount > limit) {
6531
+ const oldest2 = activeEntries[0];
6187
6532
  return {
6188
6533
  allowed: false,
6189
- current: activeCount,
6190
- limit: maxCalls,
6534
+ currentSpend,
6535
+ limit,
6191
6536
  windowMs,
6192
- resetAtMs: oldest2 + windowMs
6537
+ resetAtMs: oldest2 ? oldest2.timestamp + windowMs : 0
6193
6538
  };
6194
6539
  }
6195
- const oldest = bucket.timestamps.find((ts) => ts > windowStart) ?? now;
6540
+ const newSpend = currentSpend + amount;
6541
+ const oldest = activeEntries[0];
6196
6542
  return {
6197
6543
  allowed: true,
6198
- current: activeCount + 1,
6199
- limit: maxCalls,
6544
+ currentSpend: newSpend,
6545
+ limit,
6200
6546
  windowMs,
6201
- resetAtMs: oldest + windowMs
6547
+ resetAtMs: oldest ? oldest.timestamp + windowMs : now + windowMs
6202
6548
  };
6203
6549
  }
6550
+ /**
6551
+ * Set the display currency for a key. Called by the governed forwarder
6552
+ * after check() so dashboard reads include the currency label.
6553
+ */
6554
+ setCurrency(key, currency) {
6555
+ const bucket = this.buckets.get(key);
6556
+ if (bucket) bucket.currency = currency;
6557
+ }
6204
6558
  // -------------------------------------------------------------------------
6205
6559
  // Read operations (for dashboard API)
6206
6560
  // -------------------------------------------------------------------------
@@ -6209,17 +6563,19 @@ var RateLimiter = class {
6209
6563
  const bucket = this.buckets.get(key);
6210
6564
  if (!bucket) return void 0;
6211
6565
  const windowStart = this.now() - bucket.windowMs;
6212
- bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6213
- if (bucket.timestamps.length === 0) {
6566
+ bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
6567
+ if (bucket.entries.length === 0) {
6214
6568
  this.buckets.delete(key);
6215
6569
  return void 0;
6216
6570
  }
6571
+ const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
6217
6572
  return {
6218
6573
  key,
6219
- current: bucket.timestamps.length,
6220
- limit: bucket.maxCalls,
6574
+ current_spend: currentSpend,
6575
+ limit: bucket.limit,
6576
+ currency: bucket.currency,
6221
6577
  window_ms: bucket.windowMs,
6222
- reset_at_ms: (bucket.timestamps[0] ?? 0) + bucket.windowMs
6578
+ reset_at_ms: (bucket.entries[0]?.timestamp ?? 0) + bucket.windowMs
6223
6579
  };
6224
6580
  }
6225
6581
  /** List all tracked keys with their current state. */
@@ -6234,43 +6590,62 @@ var RateLimiter = class {
6234
6590
  // -------------------------------------------------------------------------
6235
6591
  // Maintenance
6236
6592
  // -------------------------------------------------------------------------
6237
- /** Sweep all buckets: remove expired timestamps, delete empty buckets. */
6593
+ /** Sweep all buckets: remove expired entries, delete empty buckets. */
6238
6594
  cleanup() {
6239
6595
  const now = this.now();
6240
6596
  for (const [key, bucket] of this.buckets) {
6241
6597
  const windowStart = now - bucket.windowMs;
6242
- bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6243
- if (bucket.timestamps.length === 0) {
6598
+ bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
6599
+ if (bucket.entries.length === 0) {
6244
6600
  this.buckets.delete(key);
6245
6601
  }
6246
6602
  }
6247
6603
  }
6248
- /** Clear all rate limit state. Called on policy hot-reload. */
6604
+ /** Clear all spend limit state. Called on policy hot-reload. */
6249
6605
  reset() {
6250
6606
  this.buckets.clear();
6251
6607
  }
6252
6608
  /**
6253
- * Reconcile bucket state against a new policy's limit configuration.
6609
+ * Reconcile bucket state against a new policy's spend configuration.
6254
6610
  *
6255
6611
  * Walks every existing bucket and checks whether its last-seen
6256
- * `{ maxCalls, windowMs }` tuple still appears anywhere in `validConfigs`.
6257
- * Buckets whose config is still present are left untouched — counters and
6258
- * elapsed-window progress are preserved across hot-reloads. Buckets whose
6259
- * config is gone (rule changed or removed) are evicted so the next check
6260
- * lazy-creates a fresh bucket under the new config.
6612
+ * `{ limit, currency, windowMs }` tuple still appears in `validConfigs`.
6613
+ * Buckets whose config is unchanged are left untouched — cumulative spend
6614
+ * and elapsed-window progress are preserved across hot-reloads. Buckets
6615
+ * whose config is gone (rule changed or removed) are evicted so the next
6616
+ * check lazy-creates a fresh bucket under the new config.
6261
6617
  *
6262
- * This is the compare-and-evict semantic that replaces the old `reset()`
6263
- * call on every hot-reload, which wiped all state even when the matching
6264
- * rule was unchanged.
6618
+ * Keys built by `ruleBucketKey` (bucket-key.ts) carry the owning rule's
6619
+ * index, and for those the tuple must match at THAT index (`config.ruleIndex`): a
6620
+ * reorder that shifts a spend rule's index evicts its old-index bucket
6621
+ * instead of leaving an orphan no rule reads again — or worse, letting
6622
+ * whatever rule now sits at that index adopt another rule's accrued spend.
6623
+ * Un-suffixed keys keep the tuple-anywhere match, but only against
6624
+ * index-less configs — a caller that passes only indexed configs (as the
6625
+ * proxy does) evicts every un-suffixed bucket, fail-closed.
6626
+ *
6627
+ * Currency is part of the tuple because a USD→EUR switch is a meaningful
6628
+ * policy change — the same numeric limit buys a different amount of real
6629
+ * spend, so the bucket must reset. This replaces the old `reset()` call
6630
+ * on every hot-reload, which wiped all state even when the matching rule
6631
+ * was unchanged.
6265
6632
  */
6266
6633
  reconcile(validConfigs) {
6267
6634
  const valid = /* @__PURE__ */ new Set();
6635
+ const byIndex = /* @__PURE__ */ new Map();
6268
6636
  for (const config of validConfigs) {
6269
- valid.add(`${String(config.maxCalls)}|${String(config.windowMs)}`);
6637
+ const tuple = `${String(config.limit)}|${config.currency}|${String(config.windowMs)}`;
6638
+ if (config.ruleIndex === void 0) {
6639
+ valid.add(tuple);
6640
+ } else {
6641
+ byIndex.set(config.ruleIndex, tuple);
6642
+ }
6270
6643
  }
6271
6644
  for (const [key, bucket] of this.buckets) {
6272
- const tuple = `${String(bucket.maxCalls)}|${String(bucket.windowMs)}`;
6273
- if (!valid.has(tuple)) {
6645
+ const tuple = `${String(bucket.limit)}|${bucket.currency}|${String(bucket.windowMs)}`;
6646
+ const ruleIndex = parseRuleIndex(key);
6647
+ const survives = ruleIndex === void 0 ? valid.has(tuple) : byIndex.get(ruleIndex) === tuple;
6648
+ if (!survives) {
6274
6649
  this.buckets.delete(key);
6275
6650
  }
6276
6651
  }
@@ -6350,14 +6725,18 @@ var BudgetEngine = class {
6350
6725
  /**
6351
6726
  * Resolve which budgets a call feeds and how much it charges each.
6352
6727
  *
6353
- * A contributor participates when its tool glob matches the tool name AND
6354
- * every `match.input` condition holds (absent conditions means the glob
6355
- * alone decides); the FIRST participating contributor (config order, over
6356
- * that combined predicate) supplies the amount field. A call that matches
6357
- * the glob but not the conditions simply does not feed the budget — no
6358
- * charge, no failure. Once a contributor is selected, a missing,
6359
- * non-numeric, negative, or non-finite amount fails closed as a `failures`
6360
- * entry the caller must deny the call.
6728
+ * A contributor participates when its upstream scope admits the call's
6729
+ * door (absent scope admits every door; a scoped contributor never
6730
+ * participates when `ctx.upstream` is null sideband, singular mode) AND
6731
+ * its tool glob matches the tool name AND every `match.input` condition
6732
+ * holds (absent conditions means the glob alone decides); the FIRST
6733
+ * participating contributor (config order, over that combined predicate)
6734
+ * supplies the amount field. A call that matches the glob but not the
6735
+ * conditions or the scope simply does not feed the budget — no charge, no
6736
+ * failure — and a later contributor may still participate. Once a
6737
+ * contributor is selected, a missing, non-numeric, negative, or non-finite
6738
+ * amount fails closed as a `failures` entry — the caller must deny the
6739
+ * call.
6361
6740
  */
6362
6741
  resolveCharges(ctx) {
6363
6742
  const charges = [];
@@ -6368,7 +6747,7 @@ var BudgetEngine = class {
6368
6747
  };
6369
6748
  for (const budget of this.budgets.values()) {
6370
6749
  const contributor = budget.contributors.find(
6371
- (c) => c.match.tool.test(ctx.toolName) && (c.match.input === void 0 || matchInput(c.match.input, matchCtx))
6750
+ (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))
6372
6751
  );
6373
6752
  if (!contributor) continue;
6374
6753
  const raw = resolvePath(contributor.field, ctx.toolArguments ?? {});
@@ -6393,7 +6772,8 @@ var BudgetEngine = class {
6393
6772
  budget,
6394
6773
  bucketKey: this.bucketKey(budget, ctx),
6395
6774
  amount: raw,
6396
- generation: this.generations.get(budget.name) ?? 0
6775
+ generation: this.generations.get(budget.name) ?? 0,
6776
+ ...ctx.upstream !== null && { upstream: ctx.upstream }
6397
6777
  });
6398
6778
  }
6399
6779
  return { charges, failures };
@@ -6473,7 +6853,8 @@ var BudgetEngine = class {
6473
6853
  remaining: snapshot.remaining,
6474
6854
  limit: charge.budget.limit,
6475
6855
  currency: charge.budget.currency,
6476
- utilization: snapshot.spent / charge.budget.limit
6856
+ utilization: snapshot.spent / charge.budget.limit,
6857
+ upstream: charge.upstream ?? null
6477
6858
  });
6478
6859
  } catch (err) {
6479
6860
  console.error("[helio] budget onCommit subscriber threw:", err);
@@ -6499,7 +6880,8 @@ var BudgetEngine = class {
6499
6880
  attempted_amount: entry.amount,
6500
6881
  spent: entry.spent,
6501
6882
  limit: entry.budget.limit,
6502
- currency: entry.budget.currency
6883
+ currency: entry.budget.currency,
6884
+ upstream: entry.upstream
6503
6885
  });
6504
6886
  } catch (err) {
6505
6887
  console.error("[helio] budget onBreach subscriber threw:", err);
@@ -6809,7 +7191,8 @@ var BudgetEngine = class {
6809
7191
  allowed: checkedAgainst + charge.amount <= charge.budget.limit,
6810
7192
  spent,
6811
7193
  remaining: Math.max(0, charge.budget.limit - spent),
6812
- resetAtMs
7194
+ resetAtMs,
7195
+ upstream: charge.upstream ?? null
6813
7196
  };
6814
7197
  }
6815
7198
  };
@@ -6839,6 +7222,14 @@ import Database from "better-sqlite3";
6839
7222
  import { randomUUID as randomUUID3 } from "crypto";
6840
7223
  import { chmodSync } from "fs";
6841
7224
 
7225
+ // src/startup-error.ts
7226
+ var StartupError = class extends Error {
7227
+ constructor(message) {
7228
+ super(message);
7229
+ this.name = "StartupError";
7230
+ }
7231
+ };
7232
+
6842
7233
  // src/upstream/response-summary.ts
6843
7234
  function extractResponseSummary(body) {
6844
7235
  if (body == null || typeof body !== "object") {
@@ -6933,7 +7324,8 @@ CREATE TABLE IF NOT EXISTS audit_records (
6933
7324
  origin TEXT NOT NULL DEFAULT 'mcp',
6934
7325
  metadata TEXT,
6935
7326
  protocol_version TEXT,
6936
- created_at TEXT NOT NULL
7327
+ created_at TEXT NOT NULL,
7328
+ upstream TEXT
6937
7329
  );
6938
7330
  `;
6939
7331
  var CREATE_INDEX_DDL = `
@@ -6945,6 +7337,7 @@ CREATE INDEX IF NOT EXISTS idx_audit_block_reason ON audit_records (block_re
6945
7337
  CREATE INDEX IF NOT EXISTS idx_audit_upstream_status_created_at ON audit_records (upstream_http_status, created_at);
6946
7338
  CREATE INDEX IF NOT EXISTS idx_audit_record_kind ON audit_records (record_kind);
6947
7339
  CREATE INDEX IF NOT EXISTS idx_audit_origin ON audit_records (origin);
7340
+ CREATE INDEX IF NOT EXISTS idx_audit_upstream ON audit_records (upstream);
6948
7341
  `;
6949
7342
  var INSERT_SQL = `
6950
7343
  INSERT INTO audit_records (
@@ -6953,14 +7346,16 @@ INSERT INTO audit_records (
6953
7346
  approved_by, upstream_response, upstream_error, upstream_latency_ms,
6954
7347
  upstream_http_status,
6955
7348
  total_duration_ms, approval_wait_ms, proxy_compute_ms,
6956
- flagged_destructive, dry_run, record_kind, origin, metadata, protocol_version, created_at
7349
+ flagged_destructive, dry_run, record_kind, origin, metadata, protocol_version, created_at,
7350
+ upstream
6957
7351
  ) VALUES (
6958
7352
  @id, @timestamp, @session_id, @session_source, @agent_id, @environment, @tool_name, @tool_input,
6959
7353
  @policy_decision, @block_reason, @matched_rule, @matched_rule_index, @evidence_chain, @approval_status,
6960
7354
  @approved_by, @upstream_response, @upstream_error, @upstream_latency_ms,
6961
7355
  @upstream_http_status,
6962
7356
  @total_duration_ms, @approval_wait_ms, @proxy_compute_ms,
6963
- @flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @protocol_version, @created_at
7357
+ @flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @protocol_version, @created_at,
7358
+ @upstream
6964
7359
  )
6965
7360
  `;
6966
7361
  var REQUIRED_AUDIT_COLUMNS = [
@@ -6979,7 +7374,11 @@ var REQUIRED_AUDIT_COLUMNS = [
6979
7374
  "session_source",
6980
7375
  // Same clean break, same unreleased cycle (issue #219): released users see
6981
7376
  // ONE break, at v0.12.0.
6982
- "protocol_version"
7377
+ "protocol_version",
7378
+ // The one ratified exception to the clean break (issue #292): a
7379
+ // v0.12.0-complete database missing ONLY this column is migrated in place
7380
+ // by migrateAuditUpstreamColumn instead of failing the assertion.
7381
+ "upstream"
6983
7382
  ];
6984
7383
  function deserializeRow(row) {
6985
7384
  return {
@@ -7011,6 +7410,7 @@ function deserializeRow(row) {
7011
7410
  origin: row.origin,
7012
7411
  metadata: row.metadata ? JSON.parse(row.metadata) : null,
7013
7412
  protocol_version: row.protocol_version,
7413
+ upstream: row.upstream,
7014
7414
  created_at: row.created_at
7015
7415
  };
7016
7416
  }
@@ -7052,6 +7452,14 @@ function buildWhereClause(filters) {
7052
7452
  conditions.push("session_id = ?");
7053
7453
  params.push(filters.session_id);
7054
7454
  }
7455
+ if (filters.session_source !== void 0) {
7456
+ conditions.push("session_source = ?");
7457
+ params.push(filters.session_source);
7458
+ }
7459
+ if (filters.upstream !== void 0) {
7460
+ conditions.push("upstream = ?");
7461
+ params.push(filters.upstream);
7462
+ }
7055
7463
  if (filters.agent_id !== void 0) {
7056
7464
  conditions.push("agent_id = ?");
7057
7465
  params.push(filters.agent_id);
@@ -7083,6 +7491,22 @@ function buildWhereClause(filters) {
7083
7491
  const clause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
7084
7492
  return { clause, params };
7085
7493
  }
7494
+ function migrateAuditUpstreamColumn(db) {
7495
+ const probe = () => {
7496
+ const rows = db.pragma("table_info(audit_records)");
7497
+ return new Set(rows.map((row) => row.name));
7498
+ };
7499
+ const existing = probe();
7500
+ const missing = REQUIRED_AUDIT_COLUMNS.filter((name) => !existing.has(name));
7501
+ if (missing.length !== 1 || missing[0] !== "upstream") return false;
7502
+ try {
7503
+ db.exec("ALTER TABLE audit_records ADD COLUMN upstream TEXT");
7504
+ } catch (err) {
7505
+ if (probe().has("upstream")) return false;
7506
+ throw err;
7507
+ }
7508
+ return true;
7509
+ }
7086
7510
  function restrictAuditFilePerms(dbPath) {
7087
7511
  if (dbPath === ":memory:" || process.platform === "win32") return;
7088
7512
  try {
@@ -7111,6 +7535,9 @@ var AuditStore = class {
7111
7535
  this.retentionMs = parseDuration(options.retention);
7112
7536
  this.includeResponses = options.includeResponses;
7113
7537
  this.db.exec(CREATE_TABLE_DDL);
7538
+ if (migrateAuditUpstreamColumn(this.db)) {
7539
+ console.error('[helio] Audit DB migrated: added column "upstream"');
7540
+ }
7114
7541
  this.assertRequiredSchema(options.path);
7115
7542
  this.db.exec(CREATE_INDEX_DDL);
7116
7543
  this.insertStmt = this.db.prepare(INSERT_SQL);
@@ -7176,7 +7603,7 @@ var AuditStore = class {
7176
7603
  const missing = REQUIRED_AUDIT_COLUMNS.filter((name) => !existing.has(name));
7177
7604
  if (missing.length === 0) return;
7178
7605
  const quotedColumns = missing.map((name) => `"${name}"`).join(", ");
7179
- throw new Error(
7606
+ throw new StartupError(
7180
7607
  `[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.`
7181
7608
  );
7182
7609
  }
@@ -7219,6 +7646,7 @@ var AuditStore = class {
7219
7646
  origin: record.origin,
7220
7647
  metadata: record.metadata ? JSON.stringify(record.metadata) : null,
7221
7648
  protocol_version: record.protocol_version,
7649
+ upstream: record.upstream ?? null,
7222
7650
  created_at: now
7223
7651
  });
7224
7652
  return resolvedId;
@@ -7294,9 +7722,14 @@ var AuditStore = class {
7294
7722
  const result = this.db.prepare(`SELECT COUNT(*) as total FROM audit_records ${clause}`).get(...params);
7295
7723
  return result.total;
7296
7724
  }
7297
- /** Get aggregate statistics for a time range. */
7298
- aggregate(from, to) {
7299
- const rangeFilters = { from, to };
7725
+ /**
7726
+ * Get aggregate statistics for a time range. The optional upstream filter
7727
+ * scopes EVERY sub-aggregate (totals, by_decision, by_block_reason,
7728
+ * top_tools, approval_rate, per_hour) — "analytics for this door", not one
7729
+ * filtered chart. Exact match: null-upstream rows never match any value.
7730
+ */
7731
+ aggregate(from, to, filters = {}) {
7732
+ const rangeFilters = { from, to, upstream: filters.upstream };
7300
7733
  const { clause, params } = buildWhereClause(rangeFilters);
7301
7734
  const totals = this.db.prepare(
7302
7735
  `SELECT
@@ -7322,9 +7755,9 @@ var AuditStore = class {
7322
7755
  ).all(...params);
7323
7756
  const toolsClause = clause ? `${clause} AND policy_decision NOT IN ${NON_TOOL_DECISIONS_SQL}` : `WHERE policy_decision NOT IN ${NON_TOOL_DECISIONS_SQL}`;
7324
7757
  const top_tools = this.db.prepare(
7325
- `SELECT tool_name, COUNT(*) as count
7758
+ `SELECT tool_name, upstream, COUNT(*) as count
7326
7759
  FROM audit_records ${toolsClause}
7327
- GROUP BY tool_name
7760
+ GROUP BY tool_name, upstream
7328
7761
  ORDER BY count DESC
7329
7762
  LIMIT 10`
7330
7763
  ).all(...params);
@@ -7408,10 +7841,11 @@ var CSV_HEADERS = [
7408
7841
  "record_kind",
7409
7842
  "origin",
7410
7843
  "metadata",
7411
- // Appended LAST (issues #218, #219): positional consumers of the existing
7412
- // columns keep working — new columns always go at the end.
7844
+ // Appended LAST (issues #218, #219, #292): positional consumers of the
7845
+ // existing columns keep working — new columns always go at the end.
7413
7846
  "session_source",
7414
- "protocol_version"
7847
+ "protocol_version",
7848
+ "upstream"
7415
7849
  ];
7416
7850
  var FORMULA_PREFIXES = /^[=+\-@\t\r]/;
7417
7851
  function csvEscape(value) {
@@ -7454,7 +7888,10 @@ var BUDGET_EVENT_CSV_HEADERS = [
7454
7888
  "audit_record_id",
7455
7889
  "timestamp",
7456
7890
  "timestamp_ms",
7457
- "created_at"
7891
+ "created_at",
7892
+ // Appended LAST (issue #292): positional consumers of the existing
7893
+ // columns keep working — new columns always go at the end.
7894
+ "upstream"
7458
7895
  ];
7459
7896
  function eventToRow(event) {
7460
7897
  return BUDGET_EVENT_CSV_HEADERS.map((h) => {
@@ -8285,7 +8722,9 @@ var GovernanceService = class {
8285
8722
  toolName,
8286
8723
  toolArguments: req.arguments,
8287
8724
  sessionId: budgetSessionGate.ok ? budgetSessionGate.session : null,
8288
- senderId
8725
+ senderId,
8726
+ upstream: null
8727
+ // a sideband call has no upstream (issue #295)
8289
8728
  });
8290
8729
  const gatedCharges = charges.length > 0 || failures.length > 0 ? gateBudgetCharges({ charges, failures }, budgetSessionGate) : void 0;
8291
8730
  if (gatedCharges && !gatedCharges.ok) {
@@ -9007,11 +9446,12 @@ var GovernanceService = class {
9007
9446
  };
9008
9447
  }
9009
9448
  planRate(decision, toolName, sessionId, senderId) {
9010
- const limits = decision.matchedRule?.limits;
9011
- if (!this.rateLimiter || !limits?.maxCalls || !limits.windowMs) {
9449
+ const matchedRule = decision.matchedRule;
9450
+ const limits = matchedRule?.limits;
9451
+ if (!this.rateLimiter || !matchedRule || !limits?.maxCalls || !limits.windowMs) {
9012
9452
  return { allowed: true };
9013
9453
  }
9014
- let key;
9454
+ let baseKey;
9015
9455
  if (limits.key === "session") {
9016
9456
  const gate = gateSession(sessionId, this.session.onUnresolved);
9017
9457
  if (!gate.ok) {
@@ -9019,10 +9459,11 @@ var GovernanceService = class {
9019
9459
  return { allowed: false, sessionUnresolved: true };
9020
9460
  }
9021
9461
  if (gate.anonymous) warnAnonymousPoolingOnce();
9022
- key = sessionLimitKey(gate.session);
9462
+ baseKey = sessionLimitKey(gate.session);
9023
9463
  } else {
9024
- key = buildLimitKey(limits.key, toolName, senderId);
9464
+ baseKey = buildLimitKey(limits.key, toolName, senderId);
9025
9465
  }
9466
+ const key = ruleBucketKey(baseKey, matchedRule.index);
9026
9467
  const peek = this.rateLimiter.peek({
9027
9468
  key,
9028
9469
  maxCalls: limits.maxCalls,
@@ -9054,7 +9495,7 @@ var GovernanceService = class {
9054
9495
  } else {
9055
9496
  baseKey = buildLimitKey(maxSpend.key, toolName, senderId);
9056
9497
  }
9057
- const key = spendBucketKey(baseKey, decision.matchedRule.index);
9498
+ const key = ruleBucketKey(baseKey, decision.matchedRule.index);
9058
9499
  const rawAmount = resolvePath(maxSpend.field, args ?? {});
9059
9500
  if (typeof rawAmount !== "number" || !Number.isFinite(rawAmount) || rawAmount < 0) {
9060
9501
  return { allowed: false, block: { reason: "invalid_amount", limit: maxSpend.limit } };
@@ -9213,7 +9654,9 @@ var GovernanceService = class {
9213
9654
  origin: args.origin,
9214
9655
  metadata: args.metadata,
9215
9656
  // The sideband has no MCP wire, so no protocol claim exists.
9216
- protocol_version: null
9657
+ protocol_version: null,
9658
+ // No door on the sideband either: upstream attribution is MCP-only.
9659
+ upstream: null
9217
9660
  };
9218
9661
  const isEnforcement = args.recordKind === "evaluation_expired" || blockReason !== null || args.approvalStatus != null;
9219
9662
  if (isEnforcement) this.auditWriter.pushImmediate(record, id);
@@ -9278,7 +9721,7 @@ function buildLimitKey(keyType, toolName, senderId) {
9278
9721
  case "agent":
9279
9722
  case "tool":
9280
9723
  default:
9281
- return `tool:${toolName}`;
9724
+ return toolLimitKey(toolName);
9282
9725
  }
9283
9726
  }
9284
9727
  function senderIdOf(metadata) {
@@ -9477,7 +9920,7 @@ var AuditWriter = class {
9477
9920
  };
9478
9921
 
9479
9922
  // src/audit/header-mismatch.ts
9480
- function buildHeaderMismatchAuditRecord(rejection, environment) {
9923
+ function buildHeaderMismatchAuditRecord(rejection, environment, upstream) {
9481
9924
  return {
9482
9925
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
9483
9926
  session_id: rejection.session?.id ?? null,
@@ -9514,7 +9957,10 @@ function buildHeaderMismatchAuditRecord(rejection, environment) {
9514
9957
  record_kind: "tool_call",
9515
9958
  origin: "mcp",
9516
9959
  metadata: null,
9517
- protocol_version: rejection.protocolVersion ?? null
9960
+ protocol_version: rejection.protocolVersion ?? null,
9961
+ // The door context lives with the caller (the composition root), not
9962
+ // the rejection payload; singular composition passes nothing.
9963
+ upstream: upstream ?? null
9518
9964
  };
9519
9965
  }
9520
9966
 
@@ -9556,6 +10002,9 @@ var ApprovalQueue = class {
9556
10002
  rule_index: params.rule_index,
9557
10003
  channel_name: params.channel_name,
9558
10004
  session_id: params.session_id,
10005
+ // Wire-darkness spelling: set only when attributed, never null.
10006
+ ...params.session_source != null && { session_source: params.session_source },
10007
+ ...params.upstream != null && { upstream: params.upstream },
9559
10008
  requested_at: new Date(now).toISOString(),
9560
10009
  timeout_at: new Date(now + params.timeout_ms).toISOString(),
9561
10010
  timeout_ms: params.timeout_ms,
@@ -9675,6 +10124,8 @@ var ApprovalRouter = class {
9675
10124
  rule_index: rule?.index ?? null,
9676
10125
  channel_name: channelName,
9677
10126
  session_id: params.session_id,
10127
+ session_source: params.session_source,
10128
+ upstream: params.upstream,
9678
10129
  timeout_ms: timeoutMs,
9679
10130
  breached_budgets: params.breached_budgets
9680
10131
  });
@@ -9771,6 +10222,10 @@ var ApprovalRouter = class {
9771
10222
  rule_index: rule?.index ?? null,
9772
10223
  channel_name: `${NATIVE_CHANNEL_PREFIX}${params.origin}`,
9773
10224
  session_id: params.session_id,
10225
+ // Adapter-supplied ids are sideband-attributed by definition (issue
10226
+ // #251); deriving it at this single choke point means no future
10227
+ // adapter can forget it. Upstream stays absent: no MCP door here.
10228
+ session_source: params.session_id != null ? "sideband" : null,
9774
10229
  timeout_ms: timeoutMs,
9775
10230
  breached_budgets: params.breached_budgets
9776
10231
  });
@@ -9940,15 +10395,22 @@ function buildApprovalBlocks(ticket) {
9940
10395
  const safeName = sanitizeCodeSpanContent(ticket.tool_name);
9941
10396
  const rawInput = truncate(JSON.stringify(ticket.tool_input), MAX_INPUT_LENGTH);
9942
10397
  const safeInput = sanitizeForCodeBlock(rawInput);
9943
- const detailLines = [`*Tool:* \`${safeName}\``, `*Input:*
10398
+ const detailLines = [`*Tool:* \`${safeName}\``];
10399
+ if (ticket.upstream) {
10400
+ detailLines.push(`*Upstream:* \`${sanitizeCodeSpanContent(ticket.upstream)}\``);
10401
+ }
10402
+ detailLines.push(`*Input:*
9944
10403
  \`\`\`
9945
10404
  ${safeInput}
9946
- \`\`\``];
10405
+ \`\`\``);
9947
10406
  if (ticket.matched_rule) {
9948
10407
  detailLines.push(`*Rule:* ${sanitizeMrkdwnText(ticket.matched_rule)}`);
9949
10408
  }
9950
10409
  if (ticket.session_id) {
9951
- detailLines.push(`*Session:* \`${sanitizeCodeSpanContent(ticket.session_id)}\``);
10410
+ const sessionLine = `*Session:* \`${sanitizeCodeSpanContent(ticket.session_id)}\``;
10411
+ detailLines.push(
10412
+ ticket.session_source ? `${sessionLine} (${sanitizeMrkdwnText(ticket.session_source)})` : sessionLine
10413
+ );
9952
10414
  }
9953
10415
  const budgetBlocks = buildBudgetBlocks(ticket);
9954
10416
  return [
@@ -10658,7 +11120,13 @@ var clampedQueryInt = (fallback, min, max) => z8.preprocess(
10658
11120
  );
10659
11121
  var feedQuerySchema = z8.object({
10660
11122
  limit: clampedQueryInt(50, 1, 200),
10661
- offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER)
11123
+ offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER),
11124
+ // The feed's server-side filters (issues #292, #316): attribution and
11125
+ // session identity source must narrow the fetch window itself, because
11126
+ // slicing an unfiltered newest-N window client-side would miss rare
11127
+ // matches on a busy stream.
11128
+ upstream: optionalQueryString,
11129
+ session_source: optionalQueryString
10662
11130
  });
10663
11131
  var auditExportQuerySchema = z8.object({
10664
11132
  format: z8.preprocess((value) => value === "csv" ? "csv" : "json", z8.enum(["json", "csv"])),
@@ -10677,7 +11145,9 @@ var auditExportQuerySchema = z8.object({
10677
11145
  origin: optionalQueryString,
10678
11146
  record_kind: optionalQueryString,
10679
11147
  channel_id: optionalQueryString,
10680
- sender_id: optionalQueryString
11148
+ sender_id: optionalQueryString,
11149
+ upstream: optionalQueryString,
11150
+ session_source: optionalQueryString
10681
11151
  });
10682
11152
  var auditQuerySchema = z8.object({
10683
11153
  limit: clampedQueryInt(50, 1, LIST_MAX_PAGE_SIZE),
@@ -10697,7 +11167,9 @@ var auditQuerySchema = z8.object({
10697
11167
  origin: optionalQueryString,
10698
11168
  record_kind: optionalQueryString,
10699
11169
  channel_id: optionalQueryString,
10700
- sender_id: optionalQueryString
11170
+ sender_id: optionalQueryString,
11171
+ upstream: optionalQueryString,
11172
+ session_source: optionalQueryString
10701
11173
  });
10702
11174
  var budgetEventsQuerySchema = z8.object({
10703
11175
  limit: clampedQueryInt(50, 1, LIST_MAX_PAGE_SIZE),
@@ -10709,7 +11181,8 @@ var budgetEventsExportQuerySchema = z8.object({
10709
11181
  });
10710
11182
  var analyticsQuerySchema = z8.object({
10711
11183
  from: optionalQueryString,
10712
- to: optionalQueryString
11184
+ to: optionalQueryString,
11185
+ upstream: optionalQueryString
10713
11186
  });
10714
11187
  var authSessionBodySchema = z8.object({
10715
11188
  secret: z8.string()
@@ -10770,6 +11243,8 @@ function isPrivateIpv4(host) {
10770
11243
  if (a > 255 || b > 255 || c > 255 || d > 255) return false;
10771
11244
  return a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168;
10772
11245
  }
11246
+ var MAX_SSE_CONNECTIONS = 256;
11247
+ var REFUSAL_LOG_WINDOW_MS2 = 1e4;
10773
11248
  function createDashboardAppWithLifecycle(deps, options) {
10774
11249
  const {
10775
11250
  auditStore,
@@ -10906,7 +11381,10 @@ function createDashboardAppWithLifecycle(deps, options) {
10906
11381
  const query = feedQuerySchema.parse(c.req.query());
10907
11382
  const limit = query.limit;
10908
11383
  const offset = query.offset;
10909
- const result = auditStore.list({}, { limit, offset, order: "desc" });
11384
+ const result = auditStore.list(
11385
+ { upstream: query.upstream, session_source: query.session_source },
11386
+ { limit, offset, order: "desc" }
11387
+ );
10910
11388
  return c.json({
10911
11389
  data: result.records,
10912
11390
  total: result.total,
@@ -10933,7 +11411,9 @@ function createDashboardAppWithLifecycle(deps, options) {
10933
11411
  origin: query.origin,
10934
11412
  record_kind: query.record_kind,
10935
11413
  channel_id: query.channel_id,
10936
- sender_id: query.sender_id
11414
+ sender_id: query.sender_id,
11415
+ upstream: query.upstream,
11416
+ session_source: query.session_source
10937
11417
  };
10938
11418
  const result = auditStore.listForExport(filters, limit);
10939
11419
  if (format === "csv") {
@@ -10979,7 +11459,9 @@ function createDashboardAppWithLifecycle(deps, options) {
10979
11459
  origin: query.origin,
10980
11460
  record_kind: query.record_kind,
10981
11461
  channel_id: query.channel_id,
10982
- sender_id: query.sender_id
11462
+ sender_id: query.sender_id,
11463
+ upstream: query.upstream,
11464
+ session_source: query.session_source
10983
11465
  };
10984
11466
  const result = auditStore.list(filters, { limit, offset, order: "desc" });
10985
11467
  return c.json({
@@ -11045,7 +11527,7 @@ function createDashboardAppWithLifecycle(deps, options) {
11045
11527
  const defaultFrom = new Date(now.getTime() - 24 * 60 * 60 * 1e3).toISOString();
11046
11528
  const from = query.from ?? defaultFrom;
11047
11529
  const to = query.to ?? now.toISOString();
11048
- const stats = auditStore.aggregate(from, to);
11530
+ const stats = auditStore.aggregate(from, to, { upstream: query.upstream });
11049
11531
  return c.json(stats);
11050
11532
  });
11051
11533
  app.get("/api/evidence/:session_id", (c) => {
@@ -11057,17 +11539,22 @@ function createDashboardAppWithLifecycle(deps, options) {
11057
11539
  let closed = false;
11058
11540
  const heartbeatMs = Math.max(options?.sseHeartbeatMs ?? 3e4, 1e3);
11059
11541
  const staleThresholdMs = heartbeatMs * 3;
11060
- const sweepIntervalMs = Math.max(heartbeatMs * 2, 1e4);
11061
- const sweepInterval = setInterval(() => {
11062
- const now = Date.now();
11063
- for (const [id, conn] of activeConnections) {
11064
- if (now - conn.lastWrite > staleThresholdMs) {
11065
- conn.cleanup();
11066
- activeConnections.delete(id);
11542
+ const sweepIntervalMs = options?.sweepIntervalMs ?? Math.max(heartbeatMs * 2, 1e4);
11543
+ const maxSseConnections = options?.maxSseConnections ?? MAX_SSE_CONNECTIONS;
11544
+ let sweepInterval;
11545
+ if (sweepIntervalMs > 0) {
11546
+ sweepInterval = setInterval(() => {
11547
+ const now = Date.now();
11548
+ for (const [id, conn] of activeConnections) {
11549
+ if (now - conn.lastWrite > staleThresholdMs) {
11550
+ conn.cleanup();
11551
+ conn.sever();
11552
+ activeConnections.delete(id);
11553
+ }
11067
11554
  }
11068
- }
11069
- }, sweepIntervalMs);
11070
- sweepInterval.unref();
11555
+ }, sweepIntervalMs);
11556
+ sweepInterval.unref();
11557
+ }
11071
11558
  const close = () => {
11072
11559
  if (closed) return;
11073
11560
  closed = true;
@@ -11077,7 +11564,22 @@ function createDashboardAppWithLifecycle(deps, options) {
11077
11564
  }
11078
11565
  activeConnections.clear();
11079
11566
  };
11567
+ let refusalCount = 0;
11568
+ let lastRefusalLogAt = null;
11569
+ const logRefusal = () => {
11570
+ refusalCount += 1;
11571
+ const now = Date.now();
11572
+ if (lastRefusalLogAt !== null && now - lastRefusalLogAt < REFUSAL_LOG_WINDOW_MS2) return;
11573
+ lastRefusalLogAt = now;
11574
+ console.error(
11575
+ `[helio] /api/events at connection cap (${String(maxSseConnections)}); refusing new streams (${String(refusalCount)} refusals so far).`
11576
+ );
11577
+ };
11080
11578
  app.get("/api/events", (c) => {
11579
+ if (activeConnections.size >= maxSseConnections) {
11580
+ logRefusal();
11581
+ return c.json({ error: "connection capacity reached" }, 503);
11582
+ }
11081
11583
  return streamSSE(c, async (stream) => {
11082
11584
  if (closed) return;
11083
11585
  const connId = randomUUID8();
@@ -11098,7 +11600,12 @@ function createDashboardAppWithLifecycle(deps, options) {
11098
11600
  activeConnections.delete(connId);
11099
11601
  releaseStream();
11100
11602
  };
11101
- activeConnections.set(connId, { cleanup, lastWrite: Date.now() });
11603
+ const sever = () => {
11604
+ stream.abort();
11605
+ c.env?.outgoing?.destroy();
11606
+ };
11607
+ if (activeConnections.size >= maxSseConnections) return;
11608
+ activeConnections.set(connId, { cleanup, sever, lastWrite: Date.now() });
11102
11609
  try {
11103
11610
  await stream.writeSSE({ data: "", event: "heartbeat" });
11104
11611
  } catch {
@@ -11237,9 +11744,12 @@ export {
11237
11744
  createApprovalApp,
11238
11745
  createChannels,
11239
11746
  createDashboardApp,
11747
+ createMultiApp,
11240
11748
  createSidebandApp,
11241
11749
  createSlackActionApp,
11242
11750
  evaluatePolicy,
11751
+ isNamedConfig,
11752
+ isSingularConfig,
11243
11753
  loadConfig,
11244
11754
  matchRule,
11245
11755
  startServer,