@gethelio/proxy 0.12.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { Command } from "commander";
5
5
  import { writeFile } from "fs/promises";
6
6
  import { existsSync } from "fs";
7
- import { randomBytes as randomBytes2 } from "crypto";
7
+ import { randomBytes as randomBytes3 } from "crypto";
8
8
  import { dirname, resolve } from "path";
9
9
  import { fileURLToPath } from "url";
10
10
 
@@ -45,6 +45,11 @@ var RESERVED_TRANSPORT_HEADERS = /* @__PURE__ */ new Set([
45
45
  "content-type",
46
46
  "content-length",
47
47
  "host",
48
+ // The Accept is Helio-owned per HTTP upstream leg: where Helio
49
+ // advertises at all it advertises its own response parsing (the SSE
50
+ // message POSTs assert none), so an operator value could only
51
+ // misadvertise it, never extend it (issue #304).
52
+ "accept",
48
53
  // Modern (2026-07-28) transport headers Helio owns on the wire for every
49
54
  // Streamable HTTP POST it sends upstream — relayed client traffic and
50
55
  // proxy-initiated requests (era probe, revalidation) alike — see
@@ -54,8 +59,8 @@ var RESERVED_TRANSPORT_HEADERS = /* @__PURE__ */ new Set([
54
59
  ]);
55
60
  var transportSchema = z.enum(["streamable-http", "sse", "stdio"]);
56
61
  var protocolVersionSchema = z.enum(["auto", "2025-06-18", "2026-07-28"]);
57
- var upstreamSchema = z.object({
58
- url: z.string(),
62
+ var upstreamObjectSchema = z.object({
63
+ url: z.string().optional(),
59
64
  transport: transportSchema.default("streamable-http"),
60
65
  protocol_version: protocolVersionSchema.default("auto"),
61
66
  command: z.string().optional(),
@@ -64,10 +69,22 @@ var upstreamSchema = z.object({
64
69
  request_timeout: durationSchema.default("30s"),
65
70
  forward_headers: z.array(z.string().min(1)).default([]),
66
71
  headers: z.record(z.string(), z.string()).default({})
67
- }).strict().refine((data) => data.transport !== "stdio" || data.command !== void 0, {
68
- message: '"command" is required when transport is "stdio"',
69
- path: ["command"]
70
- }).superRefine((data, ctx) => {
72
+ }).strict();
73
+ function upstreamEntryChecks(data, ctx) {
74
+ if (data.transport === "stdio" && data.command === void 0) {
75
+ ctx.addIssue({
76
+ code: "custom",
77
+ path: ["command"],
78
+ message: '"command" is required when transport is "stdio"'
79
+ });
80
+ }
81
+ if (data.transport !== "stdio" && data.url === void 0) {
82
+ ctx.addIssue({
83
+ code: "custom",
84
+ path: ["url"],
85
+ message: `"url" is required when transport is "${data.transport}"`
86
+ });
87
+ }
71
88
  if (data.protocol_version === "2026-07-28" && data.transport !== "streamable-http") {
72
89
  ctx.addIssue({
73
90
  code: "custom",
@@ -93,6 +110,26 @@ var upstreamSchema = z.object({
93
110
  });
94
111
  }
95
112
  }
113
+ }
114
+ var upstreamSchema = upstreamObjectSchema.superRefine(upstreamEntryChecks);
115
+ var upstreamNameSchema = z.string().min(1).max(64).regex(/^[a-zA-Z0-9_-]+$/, {
116
+ message: 'Upstream names may only contain letters, digits, "_" and "-"'
117
+ });
118
+ var namedUpstreamEntrySchema = z.object({ name: upstreamNameSchema, ...upstreamObjectSchema.shape }).strict().superRefine(upstreamEntryChecks);
119
+ var upstreamsListSchema = z.array(namedUpstreamEntrySchema).min(1, {
120
+ message: 'upstreams: must declare at least one upstream \u2014 an empty list would serve nothing. For a single upstream you can keep the "upstream:" form.'
121
+ }).superRefine((entries, ctx) => {
122
+ const seen = /* @__PURE__ */ new Set();
123
+ for (const [index, entry] of entries.entries()) {
124
+ if (seen.has(entry.name)) {
125
+ ctx.addIssue({
126
+ code: "custom",
127
+ path: [index, "name"],
128
+ message: `Duplicate upstream name "${entry.name}". Upstream names embed in mount paths, limiter keys, and audit records \u2014 each upstream needs its own.`
129
+ });
130
+ }
131
+ seen.add(entry.name);
132
+ }
96
133
  });
97
134
  var listenSchema = z.object({
98
135
  port: z.number().int().min(1).max(65535).default(3e3),
@@ -251,7 +288,11 @@ var matchSchema = z.object({
251
288
  annotations: annotationsMatchSchema.optional(),
252
289
  input: z.record(z.string(), inputConditionSchema).optional(),
253
290
  environment: z.string().optional(),
254
- metadata: z.record(z.string(), metadataConditionSchema).optional()
291
+ metadata: z.record(z.string(), metadataConditionSchema).optional(),
292
+ /** Configured upstream names the rule is scoped to (issue #293). */
293
+ upstreams: z.array(z.string().min(1)).min(1, {
294
+ message: "match.upstreams must name at least one upstream \u2014 an empty list matches nothing."
295
+ }).optional()
255
296
  }).strict();
256
297
  var policyActionSchema = z.enum([
257
298
  "allow",
@@ -374,7 +415,11 @@ var budgetContributorMatchSchema = z.object({
374
415
  // Same operators and AND-combination as rule `match.input`. Other rule
375
416
  // matchers (annotations, environment, metadata) stay strict-rejected
376
417
  // until the budget charge context can actually evaluate them.
377
- input: z.record(z.string(), inputConditionSchema).optional()
418
+ input: z.record(z.string(), inputConditionSchema).optional(),
419
+ /** Configured upstream names the contributor is scoped to (issue #293). */
420
+ upstreams: z.array(z.string().min(1)).min(1, {
421
+ message: "match.upstreams must name at least one upstream \u2014 an empty list matches nothing."
422
+ }).optional()
378
423
  }).strict();
379
424
  var modernBudgetContributorSchema = z.object({
380
425
  match: budgetContributorMatchSchema,
@@ -486,9 +531,7 @@ var sdkSchema = z.object({
486
531
  */
487
532
  evaluation_ttl: durationSchema.default("10m")
488
533
  }).strict();
489
- var helioConfigBaseSchema = z.object({
490
- version: z.literal("1"),
491
- upstream: upstreamSchema,
534
+ var rootSectionSchemas = {
492
535
  listen: listenSchema.prefault({}),
493
536
  environment: z.string().optional(),
494
537
  // Session precedes policies deliberately: upstream/listen/environment say
@@ -505,6 +548,16 @@ var helioConfigBaseSchema = z.object({
505
548
  // the request path (canonical section order, #89/#163).
506
549
  dashboard: dashboardSchema.prefault({}),
507
550
  sdk: sdkSchema.prefault({})
551
+ };
552
+ var singularConfigBase = z.object({
553
+ version: z.literal("1"),
554
+ upstream: upstreamSchema,
555
+ ...rootSectionSchemas
556
+ }).strict();
557
+ var namedConfigBase = z.object({
558
+ version: z.literal("1"),
559
+ upstreams: upstreamsListSchema,
560
+ ...rootSectionSchemas
508
561
  }).strict();
509
562
  function stripRootExtensionKeys(value) {
510
563
  if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
@@ -512,7 +565,7 @@ function stripRootExtensionKeys(value) {
512
565
  Object.entries(value).filter(([key]) => !key.startsWith("x-"))
513
566
  );
514
567
  }
515
- var helioConfigRefinedSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
568
+ function rootConfigChecks(cfg, ctx) {
516
569
  const hasConfiguredEnvironment = typeof cfg.environment === "string" && cfg.environment.trim().length > 0;
517
570
  const requiresSecret = cfg.policies.flag_destructive === "require_approval" || cfg.policies.on_tool_drift === "require_approval" || cfg.policies.rules.some((rule) => rule.action === "require_approval") || cfg.budgets.some((budget) => budget.on_exceed === "require_approval");
518
571
  const hasSecret = hasDashboardApiSecret(cfg.dashboard.api_secret);
@@ -521,7 +574,7 @@ var helioConfigRefinedSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
521
574
  ctx.addIssue({
522
575
  code: "custom",
523
576
  path: ["dashboard", "api_secret"],
524
- message: 'dashboard.api_secret is required when any rule uses require_approval, any budget uses on_exceed: require_approval, or policies.flag_destructive or policies.on_tool_drift is "require_approval". Generate one with: `openssl rand -hex 32` and set it under `dashboard.api_secret` in your helio.yaml. (See docs/approvals.md.)'
577
+ message: 'dashboard.api_secret is required when any rule uses require_approval, any budget uses on_exceed: require_approval, or policies.flag_destructive or policies.on_tool_drift is "require_approval". Run `helio secret` and set the printed digest under `dashboard.api_secret` in your helio.yaml (a plaintext value is accepted but warned about at startup). (See docs/approvals.md.)'
525
578
  });
526
579
  }
527
580
  }
@@ -529,7 +582,7 @@ var helioConfigRefinedSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
529
582
  ctx.addIssue({
530
583
  code: "custom",
531
584
  path: ["dashboard", "api_secret"],
532
- message: "dashboard.api_secret is required when dashboard.enabled is true unless dashboard.allow_open_mode is explicitly set to true. Generate one with `openssl rand -hex 32` and set it under dashboard.api_secret in helio.yaml."
585
+ message: "dashboard.api_secret is required when dashboard.enabled is true unless dashboard.allow_open_mode is explicitly set to true. Run `helio secret` and set the printed digest under dashboard.api_secret in helio.yaml."
533
586
  });
534
587
  }
535
588
  if (!requiresSecret && cfg.dashboard.enabled && !hasSecret && cfg.dashboard.allow_open_mode && !isLoopbackHost(cfg.dashboard.host)) {
@@ -732,11 +785,170 @@ var helioConfigRefinedSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
732
785
  }
733
786
  }
734
787
  }
788
+ }
789
+ function upstreamVocabularyChecks(cfg, ctx, configuredNames) {
790
+ for (const [ruleIndex, rule] of cfg.policies.rules.entries()) {
791
+ const upstreams = rule.match.upstreams;
792
+ if (upstreams === void 0) continue;
793
+ if (configuredNames === null) {
794
+ ctx.addIssue({
795
+ code: "custom",
796
+ path: ["policies", "rules", ruleIndex, "match", "upstreams"],
797
+ message: 'Rule sets match.upstreams but the config declares a single "upstream:", which has no name on purpose. Upstream-scoped rules require the named "upstreams:" list.'
798
+ });
799
+ } else {
800
+ for (const [entryIndex, name] of upstreams.entries()) {
801
+ if (!configuredNames.has(name)) {
802
+ ctx.addIssue({
803
+ code: "custom",
804
+ path: ["policies", "rules", ruleIndex, "match", "upstreams", entryIndex],
805
+ message: `Rule names upstream "${name}" in match.upstreams but no configured upstream has that name. Every entry must name an upstream from the upstreams: list.`
806
+ });
807
+ }
808
+ }
809
+ }
810
+ if (rule.match.metadata !== void 0) {
811
+ ctx.addIssue({
812
+ code: "custom",
813
+ path: ["policies", "rules", ruleIndex, "match", "upstreams"],
814
+ message: "match.upstreams cannot be combined with match.metadata \u2014 metadata rules only match on the sideband (host) path and upstream-scoped rules only on the MCP path, so the combination can never match. Split it into two rules."
815
+ });
816
+ }
817
+ if (rule.limits?.key === "sender_id") {
818
+ ctx.addIssue({
819
+ code: "custom",
820
+ path: ["policies", "rules", ruleIndex, "limits", "key"],
821
+ message: 'limits.key "sender_id" cannot be combined with match.upstreams \u2014 an upstream-scoped rule only matches on the MCP path, where sender_id is absent and the key would silently collapse to tool scope.'
822
+ });
823
+ }
824
+ if (rule.limits?.max_spend?.key === "sender_id") {
825
+ ctx.addIssue({
826
+ code: "custom",
827
+ path: ["policies", "rules", ruleIndex, "limits", "max_spend", "key"],
828
+ message: 'limits.max_spend.key "sender_id" cannot be combined with match.upstreams \u2014 an upstream-scoped rule only matches on the MCP path, where sender_id is absent and the key would silently collapse to tool scope.'
829
+ });
830
+ }
831
+ }
832
+ for (const [budgetIndex, budget] of cfg.budgets.entries()) {
833
+ let hasUnscopedContributor = false;
834
+ for (const [contributorIndex, contributor] of budget.contributors.entries()) {
835
+ const upstreams = contributor.match?.upstreams;
836
+ if (upstreams === void 0) {
837
+ hasUnscopedContributor = true;
838
+ continue;
839
+ }
840
+ if (configuredNames === null) {
841
+ ctx.addIssue({
842
+ code: "custom",
843
+ path: ["budgets", budgetIndex, "contributors", contributorIndex, "match", "upstreams"],
844
+ message: 'Contributor sets match.upstreams but the config declares a single "upstream:", which has no name on purpose. Upstream-scoped contributors require the named "upstreams:" list.'
845
+ });
846
+ } else {
847
+ for (const [entryIndex, name] of upstreams.entries()) {
848
+ if (!configuredNames.has(name)) {
849
+ ctx.addIssue({
850
+ code: "custom",
851
+ path: [
852
+ "budgets",
853
+ budgetIndex,
854
+ "contributors",
855
+ contributorIndex,
856
+ "match",
857
+ "upstreams",
858
+ entryIndex
859
+ ],
860
+ message: `Contributor names upstream "${name}" in match.upstreams but no configured upstream has that name. Every entry must name an upstream from the upstreams: list.`
861
+ });
862
+ }
863
+ }
864
+ }
865
+ }
866
+ if (budget.key === "sender_id" && !hasUnscopedContributor) {
867
+ ctx.addIssue({
868
+ code: "custom",
869
+ path: ["budgets", budgetIndex, "key"],
870
+ message: 'budget key "sender_id" requires at least one contributor without an "upstreams" scope \u2014 upstream-scoped contributors only match MCP calls, which never carry a sender, so every charge would land in the shared "unknown" pot while sideband calls (the only ones with real senders) never feed this budget.'
871
+ });
872
+ }
873
+ }
874
+ if (configuredNames !== null) {
875
+ const hasEvidenceGatedRule = cfg.policies.rules.some(
876
+ (rule) => (rule.evidence?.requires.length ?? 0) > 0 || (rule.requires?.length ?? 0) > 0
877
+ );
878
+ if (hasEvidenceGatedRule) {
879
+ const legacyIndex = cfg.session.identity.findIndex(
880
+ (source) => source.source === "legacy_header"
881
+ );
882
+ if (legacyIndex !== -1) {
883
+ ctx.addIssue({
884
+ code: "custom",
885
+ path: ["session", "identity", legacyIndex],
886
+ message: `session.identity includes "legacy_header" while named upstreams and evidence-gated rules ("evidence"/"requires") are configured. On the legacy relay flow the Mcp-Session-Id a client echoes was minted by the upstream itself, so with multiple upstreams a hostile server could collide session identities across doors and pollute another door's evidence gates. Remove legacy_header from session.identity and use a caller-owned source such as the default "x-helio-session-id" header.`
887
+ });
888
+ }
889
+ }
890
+ }
891
+ }
892
+ var singularConfigSchema = singularConfigBase.superRefine((cfg, ctx) => {
893
+ rootConfigChecks(cfg, ctx);
894
+ upstreamVocabularyChecks(cfg, ctx, null);
895
+ });
896
+ var namedConfigSchema = namedConfigBase.superRefine((cfg, ctx) => {
897
+ rootConfigChecks(cfg, ctx);
898
+ upstreamVocabularyChecks(cfg, ctx, new Set(cfg.upstreams.map((entry) => entry.name)));
735
899
  });
736
- var helioConfigSchema = z.preprocess(stripRootExtensionKeys, helioConfigRefinedSchema);
900
+ var objectRootSchema = z.object({});
901
+ function dispatchByMode(raw, ctx) {
902
+ const isObject2 = raw !== null && typeof raw === "object" && !Array.isArray(raw);
903
+ if (!isObject2) {
904
+ const typeResult = objectRootSchema.safeParse(raw);
905
+ if (!typeResult.success) {
906
+ for (const issue of typeResult.error.issues) {
907
+ ctx.addIssue(issue);
908
+ }
909
+ }
910
+ return z.NEVER;
911
+ }
912
+ const hasUpstream = "upstream" in raw;
913
+ const hasUpstreams = "upstreams" in raw;
914
+ if (hasUpstream && hasUpstreams) {
915
+ ctx.addIssue({
916
+ code: "custom",
917
+ path: ["upstreams"],
918
+ message: 'Set exactly one of "upstream:" (single upstream) or "upstreams:" (named multi-upstream list) \u2014 not both. To migrate, move the upstream: fields into an upstreams: entry and give it a name.'
919
+ });
920
+ return z.NEVER;
921
+ }
922
+ if (!hasUpstream && !hasUpstreams) {
923
+ ctx.addIssue({
924
+ code: "custom",
925
+ message: 'Missing upstream configuration: set exactly one of "upstream:" (single upstream) or "upstreams:" (named multi-upstream list).'
926
+ });
927
+ return z.NEVER;
928
+ }
929
+ const result = hasUpstreams ? namedConfigSchema.safeParse(raw) : singularConfigSchema.safeParse(raw);
930
+ if (!result.success) {
931
+ for (const issue of result.error.issues) {
932
+ ctx.addIssue(issue);
933
+ }
934
+ return z.NEVER;
935
+ }
936
+ return result.data;
937
+ }
938
+ var helioConfigSchema = z.preprocess(
939
+ stripRootExtensionKeys,
940
+ z.unknown().transform(dispatchByMode)
941
+ );
942
+ function isSingularConfig(config) {
943
+ return !("upstreams" in config);
944
+ }
945
+ function isNamedConfig(config) {
946
+ return "upstreams" in config;
947
+ }
737
948
 
738
949
  // src/config/loader.ts
739
950
  import { readFile } from "fs/promises";
951
+ import { createHash } from "crypto";
740
952
  import yaml from "js-yaml";
741
953
 
742
954
  // src/util/format-zod-errors.ts
@@ -756,36 +968,42 @@ var ConfigError = class extends Error {
756
968
  }
757
969
  };
758
970
  var ENV_VAR_PATTERN = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
759
- function interpolateEnvVars(value, env = process.env) {
971
+ function interpolateTracked(value, env, path, out) {
760
972
  if (typeof value === "string") {
761
- return value.replace(ENV_VAR_PATTERN, (_match, varName) => {
973
+ let substitutions = 0;
974
+ const result = value.replace(ENV_VAR_PATTERN, (_match, varName) => {
762
975
  const envValue = env[varName];
763
976
  if (envValue === void 0) {
764
977
  throw new ConfigError(`Environment variable "${varName}" is not set`);
765
978
  }
979
+ substitutions += 1;
766
980
  return envValue;
767
981
  });
982
+ if (substitutions > 0 && out !== void 0) out.push(path.join("."));
983
+ return result;
768
984
  }
769
985
  if (Array.isArray(value)) {
770
- return value.map((item) => interpolateEnvVars(item, env));
986
+ return value.map((item, index) => interpolateTracked(item, env, [...path, String(index)], out));
771
987
  }
772
988
  if (value !== null && typeof value === "object") {
773
989
  return Object.fromEntries(
774
990
  Object.entries(value).map(([k, v]) => [
775
991
  k,
776
- interpolateEnvVars(v, env)
992
+ interpolateTracked(v, env, [...path, k], out)
777
993
  ])
778
994
  );
779
995
  }
780
996
  return value;
781
997
  }
782
- async function loadConfig(filePath, env) {
783
- let raw;
998
+ async function loadConfigWithMeta(filePath, env) {
999
+ let bytes;
784
1000
  try {
785
- raw = await readFile(filePath, "utf-8");
1001
+ bytes = await readFile(filePath);
786
1002
  } catch {
787
1003
  throw new ConfigError(`Cannot read config file: ${filePath}`);
788
1004
  }
1005
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
1006
+ const raw = bytes.toString("utf-8");
789
1007
  let parsed;
790
1008
  try {
791
1009
  parsed = yaml.load(raw);
@@ -793,7 +1011,8 @@ async function loadConfig(filePath, env) {
793
1011
  const message = err instanceof Error ? err.message : String(err);
794
1012
  throw new ConfigError(`YAML parse error in ${filePath}: ${message}`);
795
1013
  }
796
- const interpolated = interpolateEnvVars(parsed, env);
1014
+ const interpolatedPaths = [];
1015
+ const interpolated = interpolateTracked(parsed, env ?? process.env, [], interpolatedPaths);
797
1016
  const result = helioConfigSchema.safeParse(interpolated);
798
1017
  if (!result.success) {
799
1018
  const details = formatZodErrors(result.error).map(
@@ -805,7 +1024,10 @@ async function loadConfig(filePath, env) {
805
1024
  details
806
1025
  );
807
1026
  }
808
- return result.data;
1027
+ return { config: result.data, sha256, interpolatedPaths };
1028
+ }
1029
+ async function loadConfig(filePath, env) {
1030
+ return (await loadConfigWithMeta(filePath, env)).config;
809
1031
  }
810
1032
 
811
1033
  // src/config/watcher.ts
@@ -815,8 +1037,16 @@ import { watch } from "chokidar";
815
1037
  import { isDeepStrictEqual } from "util";
816
1038
  function diffReloadBoundary(previous, next) {
817
1039
  const restartRequiredPaths = [];
818
- if (!isDeepStrictEqual(previous.upstream, next.upstream)) {
819
- restartRequiredPaths.push("upstream");
1040
+ if (isSingularConfig(previous) !== isSingularConfig(next)) {
1041
+ restartRequiredPaths.push("upstream", "upstreams");
1042
+ } else if (isSingularConfig(previous) && isSingularConfig(next)) {
1043
+ if (!isDeepStrictEqual(previous.upstream, next.upstream)) {
1044
+ restartRequiredPaths.push("upstream");
1045
+ }
1046
+ } else if (isNamedConfig(previous) && isNamedConfig(next)) {
1047
+ if (!isDeepStrictEqual(previous.upstreams, next.upstreams)) {
1048
+ restartRequiredPaths.push("upstreams");
1049
+ }
820
1050
  }
821
1051
  if (!isDeepStrictEqual(previous.listen, next.listen)) {
822
1052
  restartRequiredPaths.push("listen");
@@ -1016,7 +1246,8 @@ function compileMatch(match, ruleIndex, ruleName) {
1016
1246
  ...match.environment !== void 0 && { environment: match.environment },
1017
1247
  ...match.metadata !== void 0 && {
1018
1248
  metadata: flattenMetadataConditions(match.metadata, ruleIndex, ruleName)
1019
- }
1249
+ },
1250
+ ...match.upstreams !== void 0 && { upstreams: [...match.upstreams] }
1020
1251
  };
1021
1252
  }
1022
1253
  function compileToolMatcher(pattern, ruleIndex, ruleName) {
@@ -1219,6 +1450,9 @@ function compileContributor(contributor, budgetName, index) {
1219
1450
  ) : void 0;
1220
1451
  return {
1221
1452
  match: { tool, ...input !== void 0 && { input } },
1453
+ ...contributor.match.upstreams !== void 0 && {
1454
+ upstreams: [...contributor.match.upstreams]
1455
+ },
1222
1456
  field: contributor.field
1223
1457
  };
1224
1458
  }
@@ -1295,6 +1529,26 @@ var ConfigWatcher = class {
1295
1529
  }
1296
1530
  };
1297
1531
 
1532
+ // src/auth/bearer.ts
1533
+ import { createHash as createHash2, timingSafeEqual } from "crypto";
1534
+ var BEARER_PREFIX = "Bearer ";
1535
+ var DIGEST_PATTERN = /^sha256:([0-9a-f]{64})$/;
1536
+ function isSecretDigest(value) {
1537
+ return DIGEST_PATTERN.test(value);
1538
+ }
1539
+ function secretDigest(plaintext) {
1540
+ return `sha256:${createHash2("sha256").update(plaintext, "utf-8").digest("hex")}`;
1541
+ }
1542
+ function verifyBearer(authHeader, expected) {
1543
+ if (!authHeader || !expected) return false;
1544
+ if (!authHeader.startsWith(BEARER_PREFIX)) return false;
1545
+ const presented = authHeader.slice(BEARER_PREFIX.length);
1546
+ const storedHex = DIGEST_PATTERN.exec(expected)?.[1];
1547
+ const expectedDigest = storedHex !== void 0 ? Buffer.from(storedHex, "hex") : createHash2("sha256").update(expected, "utf-8").digest();
1548
+ const actualDigest = createHash2("sha256").update(presented, "utf-8").digest();
1549
+ return timingSafeEqual(actualDigest, expectedDigest);
1550
+ }
1551
+
1298
1552
  // src/server.ts
1299
1553
  import { Hono as Hono3 } from "hono";
1300
1554
  import { serve } from "@hono/node-server";
@@ -1555,6 +1809,8 @@ function buildStandardRequestHeaders(method, params) {
1555
1809
  }
1556
1810
 
1557
1811
  // src/upstream/merge-headers.ts
1812
+ var UPSTREAM_POST_ACCEPT = "application/json, text/event-stream";
1813
+ var UPSTREAM_SSE_CONNECT_ACCEPT = "text/event-stream";
1558
1814
  function mergeUpstreamHeaders(base, forwarded, staticHeaders) {
1559
1815
  const out = {};
1560
1816
  const apply = (headers) => {
@@ -1568,6 +1824,11 @@ function mergeUpstreamHeaders(base, forwarded, staticHeaders) {
1568
1824
  return out;
1569
1825
  }
1570
1826
 
1827
+ // src/util/log-label.ts
1828
+ function helioLogTag(upstreamName) {
1829
+ return upstreamName ? `[helio][${upstreamName}]` : "[helio]";
1830
+ }
1831
+
1571
1832
  // src/upstream/connection-error.ts
1572
1833
  var UPSTREAM_DOCS_URL = "https://github.com/gethelio/helio/blob/main/docs/getting-started.md";
1573
1834
  var UNREACHABLE_CODES = /* @__PURE__ */ new Set([
@@ -1603,7 +1864,7 @@ function describeUnreachableUpstream(error, url) {
1603
1864
  }
1604
1865
  const codeSuffix = code ? ` (${code})` : "";
1605
1866
  return new Error(
1606
- `Upstream MCP server at ${url} is unreachable${codeSuffix} \u2014 is it running? Helio proxies an existing MCP server: set upstream.url in helio.yaml to a reachable server, or start the server it points at. See ${UPSTREAM_DOCS_URL}`
1867
+ `Upstream MCP server at ${url} is unreachable${codeSuffix} \u2014 is it running? Helio proxies an existing MCP server: set upstream.url (or upstreams[].url) in helio.yaml to a reachable server, or start the server it points at. See ${UPSTREAM_DOCS_URL}`
1607
1868
  );
1608
1869
  }
1609
1870
 
@@ -1710,11 +1971,13 @@ var UpstreamSessionManager = class {
1710
1971
  inflight;
1711
1972
  inflightProbe;
1712
1973
  probeBackoffUntil = 0;
1974
+ logTag;
1713
1975
  constructor(options) {
1714
1976
  this.url = options.url;
1715
1977
  this.staticHeaders = options.staticHeaders;
1716
1978
  this.requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
1717
1979
  this.pin = options.protocolVersion ?? "auto";
1980
+ this.logTag = helioLogTag(options.upstreamName);
1718
1981
  }
1719
1982
  /** Return the internal session, establishing it once if needed. */
1720
1983
  ensureInternalSession() {
@@ -1846,7 +2109,7 @@ var UpstreamSessionManager = class {
1846
2109
  this.capture = void 0;
1847
2110
  this.probeBackoffUntil = Date.now() + ERA_PROBE_BACKOFF_MS;
1848
2111
  console.error(
1849
- `[helio] Upstream MCP era cleared: ${door}; relays presume legacy and re-probing is throttled for ${String(ERA_PROBE_BACKOFF_MS / 1e3)}s`
2112
+ `${this.logTag} Upstream MCP era cleared: ${door}; relays presume legacy and re-probing is throttled for ${String(ERA_PROBE_BACKOFF_MS / 1e3)}s`
1850
2113
  );
1851
2114
  }
1852
2115
  /** Convert a fetch failure into an actionable error for the given step. */
@@ -1890,7 +2153,7 @@ var UpstreamSessionManager = class {
1890
2153
  if (this.era === era) return;
1891
2154
  this.era = era;
1892
2155
  console.error(
1893
- era === "modern" ? `[helio] Upstream MCP era detected: modern (${HELIO_MCP_MODERN_PROTOCOL_VERSION}, via server/discover)` : "[helio] Upstream MCP era detected: legacy (initialize handshake)"
2156
+ era === "modern" ? `${this.logTag} Upstream MCP era detected: modern (${HELIO_MCP_MODERN_PROTOCOL_VERSION}, via server/discover)` : `${this.logTag} Upstream MCP era detected: legacy (initialize handshake)`
1894
2157
  );
1895
2158
  }
1896
2159
  /** A modern upstream neither mints nor echoes session ids — nothing to hold. */
@@ -1914,7 +2177,7 @@ var UpstreamSessionManager = class {
1914
2177
  const headers = mergeUpstreamHeaders(
1915
2178
  {
1916
2179
  "content-type": "application/json",
1917
- accept: "application/json, text/event-stream",
2180
+ accept: UPSTREAM_POST_ACCEPT,
1918
2181
  "mcp-protocol-version": HELIO_MCP_MODERN_PROTOCOL_VERSION,
1919
2182
  "mcp-method": "server/discover"
1920
2183
  },
@@ -1923,6 +2186,7 @@ var UpstreamSessionManager = class {
1923
2186
  );
1924
2187
  headers["mcp-method"] = "server/discover";
1925
2188
  delete headers["mcp-name"];
2189
+ headers["accept"] = UPSTREAM_POST_ACCEPT;
1926
2190
  const probeBody = {
1927
2191
  jsonrpc: "2.0",
1928
2192
  id: ERA_PROBE_REQUEST_ID,
@@ -1995,13 +2259,14 @@ var UpstreamSessionManager = class {
1995
2259
  const headers = mergeUpstreamHeaders(
1996
2260
  {
1997
2261
  "content-type": "application/json",
1998
- accept: "application/json, text/event-stream"
2262
+ accept: UPSTREAM_POST_ACCEPT
1999
2263
  },
2000
2264
  {},
2001
2265
  this.staticHeaders
2002
2266
  );
2003
2267
  delete headers["mcp-method"];
2004
2268
  delete headers["mcp-name"];
2269
+ headers["accept"] = UPSTREAM_POST_ACCEPT;
2005
2270
  const initBody = {
2006
2271
  jsonrpc: "2.0",
2007
2272
  id: 0,
@@ -2615,6 +2880,7 @@ function createSseRoute(forwarder, options = {}) {
2615
2880
  const forwardHeaderAllowlist = options.forwardHeadersAllowlist ?? [];
2616
2881
  const sessionIdentity = options.session ?? DEFAULT_SESSION_IDENTITY;
2617
2882
  const maxConcurrentSessions = options.maxConcurrentSessions ?? MAX_CONCURRENT_SESSIONS;
2883
+ const routeLabel = options.routeLabel ?? "/sse";
2618
2884
  let refusalCount = 0;
2619
2885
  let lastRefusalLogAt = null;
2620
2886
  const logRefusal = () => {
@@ -2623,7 +2889,7 @@ function createSseRoute(forwarder, options = {}) {
2623
2889
  if (lastRefusalLogAt !== null && now - lastRefusalLogAt < REFUSAL_LOG_WINDOW_MS) return;
2624
2890
  lastRefusalLogAt = now;
2625
2891
  console.error(
2626
- `[helio] /sse at session cap (${String(maxConcurrentSessions)}); refusing new streams (${String(refusalCount)} refusals so far).`
2892
+ `[helio] ${routeLabel} at session cap (${String(maxConcurrentSessions)}); refusing new streams (${String(refusalCount)} refusals so far).`
2627
2893
  );
2628
2894
  };
2629
2895
  app.use("*", createOriginGuard(options.allowedOrigins ?? []));
@@ -2829,6 +3095,11 @@ function createServerHandle(server) {
2829
3095
  };
2830
3096
  }
2831
3097
  function createApp(config, forwarder, options) {
3098
+ if (isNamedConfig(config)) {
3099
+ throw new Error(
3100
+ "createApp serves a single-upstream (upstream:) config only. Named multi-upstream configs are composed by createMultiApp."
3101
+ );
3102
+ }
2832
3103
  const app = new Hono3();
2833
3104
  const forwardHeadersAllowlist = config.upstream.forward_headers;
2834
3105
  const allowedOrigins = config.listen.allowed_origins;
@@ -2849,6 +3120,77 @@ function createApp(config, forwarder, options) {
2849
3120
  }
2850
3121
  return app;
2851
3122
  }
3123
+ function createMultiApp(config, forwarders, options) {
3124
+ if (!isNamedConfig(config)) {
3125
+ throw new Error(
3126
+ "createMultiApp composes a named multi-upstream (upstreams:) config only. Singular configs are served by createApp."
3127
+ );
3128
+ }
3129
+ const doors = [];
3130
+ const missing = [];
3131
+ for (const entry of config.upstreams) {
3132
+ const forwarder = forwarders[entry.name];
3133
+ if (forwarder === void 0) missing.push(entry.name);
3134
+ else doors.push({ entry, forwarder });
3135
+ }
3136
+ const configured = new Set(config.upstreams.map((entry) => entry.name));
3137
+ const unexpected = Object.keys(forwarders).filter((name) => !configured.has(name));
3138
+ if (missing.length > 0 || unexpected.length > 0) {
3139
+ throw new Error(
3140
+ `createMultiApp forwarders must match the configured upstream names exactly \u2014 missing: [${missing.join(", ")}], unexpected: [${unexpected.join(", ")}].`
3141
+ );
3142
+ }
3143
+ const app = new Hono3();
3144
+ const allowedOrigins = config.listen.allowed_origins;
3145
+ const session = compileSessionIdentity(config.session);
3146
+ app.get("/healthz", (c) => c.json({ status: "ok" }));
3147
+ for (const { entry, forwarder } of doors) {
3148
+ const name = entry.name;
3149
+ app.route(
3150
+ `/mcp/${name}`,
3151
+ createStreamableHttpRoute(forwarder, {
3152
+ forwardHeadersAllowlist: entry.forward_headers,
3153
+ allowedOrigins,
3154
+ session,
3155
+ onHeaderMismatch: options?.onHeaderMismatch ? (rejection) => options.onHeaderMismatch?.(rejection, name) : void 0
3156
+ })
3157
+ );
3158
+ app.route(
3159
+ `/sse/${name}`,
3160
+ createSseRoute(forwarder, {
3161
+ forwardHeadersAllowlist: entry.forward_headers,
3162
+ allowedOrigins,
3163
+ session,
3164
+ routeLabel: `/sse/${name}`,
3165
+ maxConcurrentSessions: options?.sse?.maxConcurrentSessions
3166
+ })
3167
+ );
3168
+ }
3169
+ if (options?.slackActionApp) {
3170
+ app.route("/slack/actions", options.slackActionApp);
3171
+ }
3172
+ app.all(
3173
+ "/mcp/*",
3174
+ (c) => c.json(
3175
+ makeJsonRpcErrorWithoutId(
3176
+ INVALID_REQUEST,
3177
+ "No MCP endpoint answers this request: this Helio serves named upstreams at /mcp/<name>."
3178
+ ),
3179
+ 404
3180
+ )
3181
+ );
3182
+ app.all(
3183
+ "/sse/*",
3184
+ (c) => c.json(
3185
+ makeJsonRpcErrorWithoutId(
3186
+ INVALID_REQUEST,
3187
+ "No MCP endpoint answers this request: this Helio serves named upstreams at /sse/<name>."
3188
+ ),
3189
+ 404
3190
+ )
3191
+ );
3192
+ return app;
3193
+ }
2852
3194
  function startServer(app, config) {
2853
3195
  const server = serve({
2854
3196
  fetch: app.fetch,
@@ -2866,6 +3208,14 @@ function startSidebandServer(app, port, host = "127.0.0.1") {
2866
3208
  return createServerHandle(server);
2867
3209
  }
2868
3210
 
3211
+ // src/reload-fanout.ts
3212
+ function applyReloadedPolicy(stacks, newPolicy) {
3213
+ for (const stack of stacks) {
3214
+ stack.governedForwarder.updatePolicy(newPolicy);
3215
+ stack.annotationPrime.reconfigure(newPolicy.toolRevalidation);
3216
+ }
3217
+ }
3218
+
2869
3219
  // src/upstream/response.ts
2870
3220
  async function parseUpstreamResponse(res) {
2871
3221
  const headers = {};
@@ -2902,7 +3252,8 @@ var StreamableHttpForwarder = class {
2902
3252
  url: this.url,
2903
3253
  staticHeaders: this.staticHeaders,
2904
3254
  requestTimeoutMs: this.requestTimeoutMs,
2905
- protocolVersion: options.protocolVersion
3255
+ protocolVersion: options.protocolVersion,
3256
+ upstreamName: options.upstreamName
2906
3257
  });
2907
3258
  }
2908
3259
  /** Lifecycle parity with sse/stdio. No eager connect — sessions are lazy. */
@@ -3075,11 +3426,15 @@ var StreamableHttpForwarder = class {
3075
3426
  const headers = mergeUpstreamHeaders(
3076
3427
  {
3077
3428
  "content-type": "application/json",
3078
- accept: "application/json, text/event-stream"
3429
+ accept: UPSTREAM_POST_ACCEPT
3079
3430
  },
3080
3431
  request.headers ?? {},
3081
3432
  this.staticHeaders
3082
3433
  );
3434
+ headers["content-type"] = "application/json";
3435
+ delete headers["content-length"];
3436
+ headers["accept"] = UPSTREAM_POST_ACCEPT;
3437
+ delete headers["mcp-session-id"];
3083
3438
  if (session.sessionId) headers["mcp-session-id"] = session.sessionId;
3084
3439
  if (modern) {
3085
3440
  delete headers["mcp-session-id"];
@@ -3250,13 +3605,16 @@ var SseUpstreamForwarder = class {
3250
3605
  connect() {
3251
3606
  const controller = new AbortController();
3252
3607
  this.abortController = controller;
3608
+ const headers = mergeUpstreamHeaders(
3609
+ { accept: UPSTREAM_SSE_CONNECT_ACCEPT },
3610
+ {},
3611
+ this.staticHeaders
3612
+ );
3613
+ headers["accept"] = UPSTREAM_SSE_CONNECT_ACCEPT;
3253
3614
  return new Promise((resolve2, reject) => {
3254
3615
  let resolved = false;
3255
3616
  fetch(this.url, {
3256
- headers: {
3257
- accept: "text/event-stream",
3258
- ...this.staticHeaders
3259
- },
3617
+ headers,
3260
3618
  signal: AbortSignal.any([controller.signal, AbortSignal.timeout(this.connectTimeoutMs)])
3261
3619
  }).then((res) => {
3262
3620
  if (!res.ok) {
@@ -3308,9 +3666,12 @@ var SseUpstreamForwarder = class {
3308
3666
  request.headers ?? {},
3309
3667
  this.staticHeaders
3310
3668
  );
3669
+ headers["content-type"] = "application/json";
3670
+ delete headers["content-length"];
3311
3671
  delete headers["mcp-method"];
3312
3672
  delete headers["mcp-name"];
3313
3673
  delete headers["mcp-session-id"];
3674
+ delete headers["accept"];
3314
3675
  if (request.transportSessionId) {
3315
3676
  headers["mcp-session-id"] = request.transportSessionId;
3316
3677
  }
@@ -3477,6 +3838,7 @@ var StdioForwarder = class {
3477
3838
  maxRetries;
3478
3839
  retryDelayMs;
3479
3840
  pending;
3841
+ logTag;
3480
3842
  child = null;
3481
3843
  buffer = "";
3482
3844
  retryCount = 0;
@@ -3488,6 +3850,7 @@ var StdioForwarder = class {
3488
3850
  this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
3489
3851
  this.retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
3490
3852
  this.pending = new PendingRequests(options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS2);
3853
+ this.logTag = helioLogTag(options.upstreamName);
3491
3854
  }
3492
3855
  /** Spawn the child process and set up event handlers. */
3493
3856
  start() {
@@ -3616,25 +3979,28 @@ var StdioForwarder = class {
3616
3979
  }, this.retryDelayMs);
3617
3980
  } else {
3618
3981
  this.dead = true;
3619
- console.error(`[helio] Stdio forwarder: max retries (${String(this.maxRetries)}) exceeded`);
3982
+ console.error(
3983
+ `${this.logTag} Stdio forwarder: max retries (${String(this.maxRetries)}) exceeded`
3984
+ );
3620
3985
  this.pending.rejectAll(new Error("stdio forwarder is dead (max retries exceeded)"));
3621
3986
  }
3622
3987
  }
3623
3988
  };
3624
3989
 
3625
3990
  // src/cli-forwarder.ts
3626
- async function createForwarderFromConfig(config) {
3991
+ async function createForwarderFromConfig(config, upstreamName) {
3627
3992
  switch (config.upstream.transport) {
3628
3993
  case "streamable-http": {
3629
3994
  const http = new StreamableHttpForwarder({
3630
3995
  url: config.upstream.url,
3631
3996
  headers: config.upstream.headers,
3632
3997
  requestTimeoutMs: parseDuration(config.upstream.request_timeout),
3633
- protocolVersion: config.upstream.protocol_version
3998
+ protocolVersion: config.upstream.protocol_version,
3999
+ upstreamName
3634
4000
  });
3635
4001
  if (config.upstream.protocol_version !== "auto") {
3636
4002
  console.error(
3637
- `[helio] Upstream MCP protocol version pinned: ${config.upstream.protocol_version} (upstream.protocol_version)`
4003
+ `${helioLogTag(upstreamName)} Upstream MCP protocol version pinned: ${config.upstream.protocol_version} (upstream.protocol_version)`
3638
4004
  );
3639
4005
  }
3640
4006
  await http.connect();
@@ -3654,7 +4020,8 @@ async function createForwarderFromConfig(config) {
3654
4020
  const stdio = new StdioForwarder({
3655
4021
  command: config.upstream.command,
3656
4022
  args: config.upstream.args,
3657
- requestTimeoutMs: parseDuration(config.upstream.request_timeout)
4023
+ requestTimeoutMs: parseDuration(config.upstream.request_timeout),
4024
+ upstreamName
3658
4025
  });
3659
4026
  await stdio.start();
3660
4027
  return { forwarder: stdio, close: () => stdio.close() };
@@ -3731,6 +4098,10 @@ function matchEnvironment(required, ctx) {
3731
4098
  if (ctx.environment === void 0) return false;
3732
4099
  return ctx.environment === required;
3733
4100
  }
4101
+ function matchUpstreams(required, ctx) {
4102
+ if (ctx.upstream === void 0) return false;
4103
+ return required.includes(ctx.upstream);
4104
+ }
3734
4105
  function matchMetadata(conditions, ctx) {
3735
4106
  if (conditions.length === 0) return true;
3736
4107
  if (ctx.metadata === void 0) return false;
@@ -3756,6 +4127,7 @@ function matchRule(rule, ctx) {
3756
4127
  if (match.input !== void 0 && !matchInput(match.input, ctx)) return false;
3757
4128
  if (match.environment !== void 0 && !matchEnvironment(match.environment, ctx)) return false;
3758
4129
  if (match.metadata !== void 0 && !matchMetadata(match.metadata, ctx)) return false;
4130
+ if (match.upstreams !== void 0 && !matchUpstreams(match.upstreams, ctx)) return false;
3759
4131
  return true;
3760
4132
  }
3761
4133
 
@@ -3910,7 +4282,8 @@ function decide(input) {
3910
4282
  annotations,
3911
4283
  toolArguments,
3912
4284
  environment,
3913
- metadata
4285
+ metadata,
4286
+ upstream: input.upstream
3914
4287
  });
3915
4288
  if (driftEvent && driftMode === "log") {
3916
4289
  const currentDecision = evaluatePolicy(policy, {
@@ -3918,7 +4291,8 @@ function decide(input) {
3918
4291
  annotations: input.currentAnnotations,
3919
4292
  toolArguments,
3920
4293
  environment,
3921
- metadata
4294
+ metadata,
4295
+ upstream: input.upstream
3922
4296
  });
3923
4297
  decision = stricterDecision(decision, currentDecision);
3924
4298
  }
@@ -4531,394 +4905,88 @@ function buildBudgetApprovalTimeoutFeedback(decision, breaches, timeoutMs) {
4531
4905
  };
4532
4906
  }
4533
4907
 
4534
- // src/policy/spend-limiter.ts
4535
- function spendBucketKey(baseKey, ruleIndex) {
4908
+ // src/policy/bucket-key.ts
4909
+ function ruleBucketKey(baseKey, ruleIndex) {
4536
4910
  return `${baseKey}:rule:${String(ruleIndex)}`;
4537
4911
  }
4538
4912
  var RULE_SUFFIX_RE = /:rule:(\d+)$/;
4539
- var SpendLimiter = class {
4540
- buckets = /* @__PURE__ */ new Map();
4541
- now;
4542
- onWarning;
4543
- warningThreshold;
4544
- timer = null;
4545
- closed = false;
4546
- constructor(options = {}) {
4547
- this.now = options.now ?? Date.now;
4548
- this.onWarning = options.onWarning;
4549
- this.warningThreshold = options.warningThreshold ?? 0.8;
4550
- const intervalMs = options.cleanupIntervalMs ?? 6e4;
4551
- if (intervalMs > 0) {
4552
- this.timer = setInterval(() => {
4553
- this.cleanup();
4554
- }, intervalMs);
4555
- this.timer.unref();
4913
+ function parseRuleIndex(key) {
4914
+ const match = RULE_SUFFIX_RE.exec(key);
4915
+ return match ? Number(match[1]) : void 0;
4916
+ }
4917
+ function toolLimitKey(toolName, upstreamName) {
4918
+ return upstreamName ? `upstream:${upstreamName}:tool:${toolName}` : `tool:${toolName}`;
4919
+ }
4920
+ var UPSTREAM_PREFIX_RE = /^upstream:([^:]+):/;
4921
+ function upstreamFromLimitKey(key) {
4922
+ const match = UPSTREAM_PREFIX_RE.exec(key);
4923
+ return match?.[1] ?? null;
4924
+ }
4925
+
4926
+ // src/policy/governed-forwarder.ts
4927
+ var POLICY_DENIED = -32001;
4928
+ function blocked(result) {
4929
+ return { proceed: false, result, approvalWaitMs: 0 };
4930
+ }
4931
+ function budgetChainBlock(entry, kind) {
4932
+ return {
4933
+ name: entry.budget.name,
4934
+ bucket_key: entry.bucketKey,
4935
+ allowed: entry.allowed,
4936
+ amount: entry.amount,
4937
+ spent: entry.spent,
4938
+ limit: entry.budget.limit,
4939
+ remaining: entry.remaining,
4940
+ currency: entry.budget.currency,
4941
+ ...kind ? { kind } : {},
4942
+ ...entry.stale ? { stale: true } : {}
4943
+ };
4944
+ }
4945
+ var GovernedForwarder = class {
4946
+ inner;
4947
+ policy;
4948
+ environment;
4949
+ session;
4950
+ auditWriter;
4951
+ evidenceStore;
4952
+ approvalRouter;
4953
+ rateLimiter;
4954
+ spendLimiter;
4955
+ budgetEngine;
4956
+ upstreamName;
4957
+ annotationCache = new ToolAnnotationCache();
4958
+ agentKeyWarned = false;
4959
+ senderKeyWarned = false;
4960
+ constructor(inner, policy, options) {
4961
+ this.inner = inner;
4962
+ this.policy = policy;
4963
+ this.environment = options?.environment;
4964
+ this.auditWriter = options?.auditWriter;
4965
+ this.evidenceStore = options?.evidenceStore;
4966
+ this.approvalRouter = options?.approvalRouter;
4967
+ this.rateLimiter = options?.rateLimiter;
4968
+ this.spendLimiter = options?.spendLimiter;
4969
+ this.budgetEngine = options?.budgetEngine;
4970
+ this.upstreamName = options?.upstreamName;
4971
+ this.session = options?.session ?? DEFAULT_SESSION_IDENTITY;
4972
+ if (this.evidenceStore) {
4973
+ this.evidenceStore.setAllowedEvidenceKeys(collectAllowedEvidenceKeys(policy));
4556
4974
  }
4557
4975
  }
4558
- // -------------------------------------------------------------------------
4559
- // Core operations
4560
- // -------------------------------------------------------------------------
4561
4976
  /**
4562
- * Check and optionally record a spend against the limit.
4977
+ * Swap the compiled policy atomically and reconcile limit bucket state
4978
+ * against the new configuration.
4563
4979
  *
4564
- * Evicts expired entries, sums remaining amounts, then checks:
4565
- * - Under limit (currentSpend + amount <= limit): records and returns `allowed: true`
4566
- * - Would exceed: does NOT record (rejected spends don't consume budget)
4567
- */
4568
- check(params) {
4569
- const { key, amount, limit, windowMs } = params;
4570
- const now = this.now();
4571
- const windowStart = now - windowMs;
4572
- if (!Number.isFinite(amount) || amount < 0) {
4573
- const existing = this.buckets.get(key);
4574
- const activeEntries = existing ? existing.entries.filter((e) => e.timestamp > windowStart) : [];
4575
- const currentSpend2 = activeEntries.reduce((sum, e) => sum + e.amount, 0);
4576
- const oldest = activeEntries[0];
4577
- return {
4578
- allowed: false,
4579
- currentSpend: currentSpend2,
4580
- limit,
4581
- windowMs,
4582
- resetAtMs: oldest ? oldest.timestamp + windowMs : 0,
4583
- reason: "invalid_amount"
4584
- };
4585
- }
4586
- let bucket = this.buckets.get(key);
4587
- if (!bucket) {
4588
- bucket = { entries: [], limit, currency: "", windowMs };
4589
- this.buckets.set(key, bucket);
4590
- }
4591
- bucket.limit = limit;
4592
- bucket.windowMs = windowMs;
4593
- bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
4594
- const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
4595
- if (currentSpend + amount > limit) {
4596
- const oldest = bucket.entries[0];
4597
- return {
4598
- allowed: false,
4599
- currentSpend,
4600
- limit,
4601
- windowMs,
4602
- resetAtMs: oldest ? oldest.timestamp + windowMs : 0
4603
- };
4604
- }
4605
- bucket.entries.push({ timestamp: now, amount });
4606
- const newSpend = currentSpend + amount;
4607
- const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
4608
- if (this.onWarning && newSpend / limit >= this.warningThreshold) {
4609
- this.safeWarn({
4610
- key,
4611
- current_spend: newSpend,
4612
- limit,
4613
- currency: bucket.currency,
4614
- window_ms: windowMs,
4615
- reset_at_ms: resetAtMs
4616
- });
4617
- }
4618
- return {
4619
- allowed: true,
4620
- currentSpend: newSpend,
4621
- limit,
4622
- windowMs,
4623
- resetAtMs
4624
- };
4625
- }
4626
- /**
4627
- * Unconditionally record a spend against the limit.
4628
- *
4629
- * Unlike check(), this always appends the amount — even when it pushes the
4630
- * window past the limit — because the spend it represents has already been
4631
- * incurred. The sideband peeks at /evaluate and commits here at /audit once
4632
- * the external call ran (issue #12, D3).
4633
- *
4634
- * Throws on a negative or non-finite amount: such amounts are rejected at
4635
- * /evaluate, so one reaching record() is a logic bug we surface loudly rather
4636
- * than silently corrupt the sliding-window sum. Warnings fire only while the
4637
- * post-append spend stays within the limit (parity with check()).
4638
- */
4639
- record(params) {
4640
- const { key, amount, limit, windowMs } = params;
4641
- if (!Number.isFinite(amount) || amount < 0) {
4642
- throw new RangeError(
4643
- `SpendLimiter.record() received an invalid amount (${String(amount)}); invalid amounts must be rejected at /evaluate, never committed`
4644
- );
4645
- }
4646
- const now = this.now();
4647
- const windowStart = now - windowMs;
4648
- let bucket = this.buckets.get(key);
4649
- if (!bucket) {
4650
- bucket = { entries: [], limit, currency: "", windowMs };
4651
- this.buckets.set(key, bucket);
4652
- }
4653
- bucket.limit = limit;
4654
- bucket.windowMs = windowMs;
4655
- bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
4656
- bucket.entries.push({ timestamp: now, amount });
4657
- const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
4658
- const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
4659
- if (this.onWarning && currentSpend <= limit && currentSpend / limit >= this.warningThreshold) {
4660
- this.safeWarn({
4661
- key,
4662
- current_spend: currentSpend,
4663
- limit,
4664
- currency: bucket.currency,
4665
- window_ms: windowMs,
4666
- reset_at_ms: resetAtMs
4667
- });
4668
- }
4669
- return {
4670
- allowed: currentSpend <= limit,
4671
- currentSpend,
4672
- limit,
4673
- windowMs,
4674
- resetAtMs
4675
- };
4676
- }
4677
- /**
4678
- * Check the spend limit without recording the spend (non-destructive).
4679
- *
4680
- * Used by dry-run mode to determine what would happen without consuming
4681
- * budget in the bucket.
4682
- */
4683
- peek(params) {
4684
- const { key, amount, limit, windowMs } = params;
4685
- const now = this.now();
4686
- const windowStart = now - windowMs;
4687
- const bucket = this.buckets.get(key);
4688
- if (!Number.isFinite(amount) || amount < 0) {
4689
- const activeEntries2 = bucket ? bucket.entries.filter((e) => e.timestamp > windowStart) : [];
4690
- const currentSpend2 = activeEntries2.reduce((sum, e) => sum + e.amount, 0);
4691
- const oldest2 = activeEntries2[0];
4692
- return {
4693
- allowed: false,
4694
- currentSpend: currentSpend2,
4695
- limit,
4696
- windowMs,
4697
- resetAtMs: oldest2 ? oldest2.timestamp + windowMs : 0,
4698
- reason: "invalid_amount"
4699
- };
4700
- }
4701
- if (!bucket) {
4702
- const wouldExceed = amount > limit;
4703
- return {
4704
- allowed: !wouldExceed,
4705
- currentSpend: wouldExceed ? 0 : amount,
4706
- limit,
4707
- windowMs,
4708
- resetAtMs: now + windowMs
4709
- };
4710
- }
4711
- const activeEntries = bucket.entries.filter((e) => e.timestamp > windowStart);
4712
- const currentSpend = activeEntries.reduce((sum, e) => sum + e.amount, 0);
4713
- if (currentSpend + amount > limit) {
4714
- const oldest2 = activeEntries[0];
4715
- return {
4716
- allowed: false,
4717
- currentSpend,
4718
- limit,
4719
- windowMs,
4720
- resetAtMs: oldest2 ? oldest2.timestamp + windowMs : 0
4721
- };
4722
- }
4723
- const newSpend = currentSpend + amount;
4724
- const oldest = activeEntries[0];
4725
- return {
4726
- allowed: true,
4727
- currentSpend: newSpend,
4728
- limit,
4729
- windowMs,
4730
- resetAtMs: oldest ? oldest.timestamp + windowMs : now + windowMs
4731
- };
4732
- }
4733
- /**
4734
- * Set the display currency for a key. Called by the governed forwarder
4735
- * after check() so dashboard reads include the currency label.
4736
- */
4737
- setCurrency(key, currency) {
4738
- const bucket = this.buckets.get(key);
4739
- if (bucket) bucket.currency = currency;
4740
- }
4741
- // -------------------------------------------------------------------------
4742
- // Read operations (for dashboard API)
4743
- // -------------------------------------------------------------------------
4744
- /** Get the current state of a single key. Returns undefined if not tracked. */
4745
- getKeyState(key) {
4746
- const bucket = this.buckets.get(key);
4747
- if (!bucket) return void 0;
4748
- const windowStart = this.now() - bucket.windowMs;
4749
- bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
4750
- if (bucket.entries.length === 0) {
4751
- this.buckets.delete(key);
4752
- return void 0;
4753
- }
4754
- const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
4755
- return {
4756
- key,
4757
- current_spend: currentSpend,
4758
- limit: bucket.limit,
4759
- currency: bucket.currency,
4760
- window_ms: bucket.windowMs,
4761
- reset_at_ms: (bucket.entries[0]?.timestamp ?? 0) + bucket.windowMs
4762
- };
4763
- }
4764
- /** List all tracked keys with their current state. */
4765
- listKeyStates() {
4766
- const states = [];
4767
- for (const key of [...this.buckets.keys()]) {
4768
- const state = this.getKeyState(key);
4769
- if (state) states.push(state);
4770
- }
4771
- return states;
4772
- }
4773
- // -------------------------------------------------------------------------
4774
- // Maintenance
4775
- // -------------------------------------------------------------------------
4776
- /** Sweep all buckets: remove expired entries, delete empty buckets. */
4777
- cleanup() {
4778
- const now = this.now();
4779
- for (const [key, bucket] of this.buckets) {
4780
- const windowStart = now - bucket.windowMs;
4781
- bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
4782
- if (bucket.entries.length === 0) {
4783
- this.buckets.delete(key);
4784
- }
4785
- }
4786
- }
4787
- /** Clear all spend limit state. Called on policy hot-reload. */
4788
- reset() {
4789
- this.buckets.clear();
4790
- }
4791
- /**
4792
- * Reconcile bucket state against a new policy's spend configuration.
4793
- *
4794
- * Walks every existing bucket and checks whether its last-seen
4795
- * `{ limit, currency, windowMs }` tuple still appears in `validConfigs`.
4796
- * Buckets whose config is unchanged are left untouched — cumulative spend
4797
- * and elapsed-window progress are preserved across hot-reloads. Buckets
4798
- * whose config is gone (rule changed or removed) are evicted so the next
4799
- * check lazy-creates a fresh bucket under the new config.
4800
- *
4801
- * Keys built by {@link spendBucketKey} carry the owning rule's index, and
4802
- * for those the tuple must match at THAT index (`config.ruleIndex`): a
4803
- * reorder that shifts a spend rule's index evicts its old-index bucket
4804
- * instead of leaving an orphan no rule reads again — or worse, letting
4805
- * whatever rule now sits at that index adopt another rule's accrued spend.
4806
- * Un-suffixed keys keep the tuple-anywhere match.
4807
- *
4808
- * Currency is part of the tuple because a USD→EUR switch is a meaningful
4809
- * policy change — the same numeric limit buys a different amount of real
4810
- * spend, so the bucket must reset. This replaces the old `reset()` call
4811
- * on every hot-reload, which wiped all state even when the matching rule
4812
- * was unchanged.
4813
- */
4814
- reconcile(validConfigs) {
4815
- const valid = /* @__PURE__ */ new Set();
4816
- const byIndex = /* @__PURE__ */ new Map();
4817
- for (const config of validConfigs) {
4818
- const tuple = `${String(config.limit)}|${config.currency}|${String(config.windowMs)}`;
4819
- if (config.ruleIndex === void 0) {
4820
- valid.add(tuple);
4821
- } else {
4822
- byIndex.set(config.ruleIndex, tuple);
4823
- }
4824
- }
4825
- for (const [key, bucket] of this.buckets) {
4826
- const tuple = `${String(bucket.limit)}|${bucket.currency}|${String(bucket.windowMs)}`;
4827
- const suffix = RULE_SUFFIX_RE.exec(key);
4828
- const survives = suffix ? byIndex.get(Number(suffix[1])) === tuple : valid.has(tuple);
4829
- if (!survives) {
4830
- this.buckets.delete(key);
4831
- }
4832
- }
4833
- }
4834
- /** Stop the cleanup timer and mark as closed. */
4835
- /**
4836
- * Invoke the warning callback without letting a subscriber throw into the
4837
- * limiter's caller: a warning fires after state has already mutated, and a
4838
- * governed call must not be blocked (or double-charged on retry) by an
4839
- * observability bug.
4840
- */
4841
- safeWarn(state) {
4842
- if (!this.onWarning) return;
4843
- try {
4844
- this.onWarning(state);
4845
- } catch (err) {
4846
- console.error("[helio] limit warning subscriber threw:", err);
4847
- }
4848
- }
4849
- close() {
4850
- if (this.closed) return;
4851
- this.closed = true;
4852
- if (this.timer) {
4853
- clearInterval(this.timer);
4854
- this.timer = null;
4855
- }
4856
- this.buckets.clear();
4857
- }
4858
- };
4859
-
4860
- // src/policy/governed-forwarder.ts
4861
- var POLICY_DENIED = -32001;
4862
- function blocked(result) {
4863
- return { proceed: false, result, approvalWaitMs: 0 };
4864
- }
4865
- function budgetChainBlock(entry, kind) {
4866
- return {
4867
- name: entry.budget.name,
4868
- bucket_key: entry.bucketKey,
4869
- allowed: entry.allowed,
4870
- amount: entry.amount,
4871
- spent: entry.spent,
4872
- limit: entry.budget.limit,
4873
- remaining: entry.remaining,
4874
- currency: entry.budget.currency,
4875
- ...kind ? { kind } : {},
4876
- ...entry.stale ? { stale: true } : {}
4877
- };
4878
- }
4879
- var GovernedForwarder = class {
4880
- inner;
4881
- policy;
4882
- environment;
4883
- session;
4884
- auditWriter;
4885
- evidenceStore;
4886
- approvalRouter;
4887
- rateLimiter;
4888
- spendLimiter;
4889
- budgetEngine;
4890
- annotationCache = new ToolAnnotationCache();
4891
- agentKeyWarned = false;
4892
- senderKeyWarned = false;
4893
- constructor(inner, policy, options) {
4894
- this.inner = inner;
4895
- this.policy = policy;
4896
- this.environment = options?.environment;
4897
- this.auditWriter = options?.auditWriter;
4898
- this.evidenceStore = options?.evidenceStore;
4899
- this.approvalRouter = options?.approvalRouter;
4900
- this.rateLimiter = options?.rateLimiter;
4901
- this.spendLimiter = options?.spendLimiter;
4902
- this.budgetEngine = options?.budgetEngine;
4903
- this.session = options?.session ?? DEFAULT_SESSION_IDENTITY;
4904
- if (this.evidenceStore) {
4905
- this.evidenceStore.setAllowedEvidenceKeys(collectAllowedEvidenceKeys(policy));
4906
- }
4907
- }
4908
- /**
4909
- * Swap the compiled policy atomically and reconcile limit bucket state
4910
- * against the new configuration.
4911
- *
4912
- * Rate and spend limit buckets are preserved when their underlying rule
4913
- * config is unchanged — this is what makes a benign hot-reload (e.g. a
4914
- * `vim :w` with no real edits, or a whitespace-only config change) safe:
4915
- * operators do not get a surprise zero of their live rate/spend state
4916
- * mid-window. Buckets whose config changed or whose rule was removed are
4917
- * evicted by the limiters' `reconcile()` methods, so the next check
4918
- * lazy-creates a fresh bucket under the new config.
4919
- *
4920
- * See `packages/proxy/src/policy/rate-limiter.ts` and `spend-limiter.ts`
4921
- * for the per-bucket compare-and-evict semantics.
4980
+ * Rate and spend limit buckets are preserved when their underlying rule
4981
+ * config is unchanged this is what makes a benign hot-reload (e.g. a
4982
+ * `vim :w` with no real edits, or a whitespace-only config change) safe:
4983
+ * operators do not get a surprise zero of their live rate/spend state
4984
+ * mid-window. Buckets whose config changed or whose rule was removed are
4985
+ * evicted by the limiters' `reconcile()` methods, so the next check
4986
+ * lazy-creates a fresh bucket under the new config.
4987
+ *
4988
+ * See `packages/proxy/src/policy/rate-limiter.ts` and `spend-limiter.ts`
4989
+ * for the per-bucket compare-and-evict semantics.
4922
4990
  */
4923
4991
  updatePolicy(policy) {
4924
4992
  this.policy = policy;
@@ -4930,7 +4998,13 @@ var GovernedForwarder = class {
4930
4998
  for (const rule of policy.rules) {
4931
4999
  const limits = rule.limits;
4932
5000
  if (limits?.maxCalls !== void 0 && limits.windowMs !== void 0) {
4933
- rateConfigs.push({ maxCalls: limits.maxCalls, windowMs: limits.windowMs });
5001
+ rateConfigs.push({
5002
+ maxCalls: limits.maxCalls,
5003
+ windowMs: limits.windowMs,
5004
+ // Rate bucket keys are rule-discriminated (ruleBucketKey), so
5005
+ // reconcile must match tuples at the owning rule's index.
5006
+ ruleIndex: rule.index
5007
+ });
4934
5008
  }
4935
5009
  }
4936
5010
  this.rateLimiter.reconcile(rateConfigs);
@@ -4944,7 +5018,7 @@ var GovernedForwarder = class {
4944
5018
  limit: maxSpend.limit,
4945
5019
  currency: maxSpend.currency,
4946
5020
  windowMs: maxSpend.windowMs,
4947
- // Spend bucket keys are rule-discriminated (spendBucketKey), so
5021
+ // Spend bucket keys are rule-discriminated (ruleBucketKey), so
4948
5022
  // reconcile must match tuples at the owning rule's index.
4949
5023
  ruleIndex: rule.index
4950
5024
  });
@@ -5090,7 +5164,8 @@ var GovernedForwarder = class {
5090
5164
  origin: "mcp",
5091
5165
  metadata: null,
5092
5166
  // Drift is a cache event, not a request: no protocol claim exists.
5093
- protocol_version: null
5167
+ protocol_version: null,
5168
+ upstream: this.upstreamName ?? null
5094
5169
  });
5095
5170
  }
5096
5171
  async handleToolsCall(original) {
@@ -5134,7 +5209,8 @@ var GovernedForwarder = class {
5134
5209
  evidenceStore: this.evidenceStore,
5135
5210
  baselineAnnotations: this.annotationCache.get(toolName),
5136
5211
  currentAnnotations: this.annotationCache.getCurrent(toolName),
5137
- driftEvent: this.annotationCache.getDrift(toolName)
5212
+ driftEvent: this.annotationCache.getDrift(toolName),
5213
+ upstream: this.upstreamName
5138
5214
  });
5139
5215
  const auditRecordId = randomUUID2();
5140
5216
  let result;
@@ -5277,6 +5353,8 @@ var GovernedForwarder = class {
5277
5353
  tool_input: toolArguments ?? {},
5278
5354
  matched_rule: decision.matchedRule,
5279
5355
  session_id: request.session?.id ?? null,
5356
+ session_source: request.session?.source ?? null,
5357
+ upstream: this.upstreamName ?? null,
5280
5358
  breached_budgets: gate.breachContexts,
5281
5359
  approval: gate.approval
5282
5360
  },
@@ -5424,8 +5502,9 @@ var GovernedForwarder = class {
5424
5502
  toolName,
5425
5503
  toolArguments,
5426
5504
  sessionId: sessionGate.ok ? sessionGate.session : null,
5427
- senderId: null
5505
+ senderId: null,
5428
5506
  // adapter context; absent on the MCP path
5507
+ upstream: this.upstreamName ?? null
5429
5508
  });
5430
5509
  if (charges.length === 0 && failures.length === 0) return { kind: "proceed" };
5431
5510
  const gated = gateBudgetCharges({ charges, failures }, sessionGate);
@@ -5571,7 +5650,8 @@ var GovernedForwarder = class {
5571
5650
  record_kind: "tool_call",
5572
5651
  origin: "mcp",
5573
5652
  metadata: null,
5574
- protocol_version: request.protocolVersion ?? null
5653
+ protocol_version: request.protocolVersion ?? null,
5654
+ upstream: this.upstreamName ?? null
5575
5655
  });
5576
5656
  }
5577
5657
  return result;
@@ -5584,7 +5664,9 @@ var GovernedForwarder = class {
5584
5664
  tool_name: toolName,
5585
5665
  tool_input: toolArguments ?? {},
5586
5666
  matched_rule: decision.matchedRule,
5587
- session_id: request.session?.id ?? null
5667
+ session_id: request.session?.id ?? null,
5668
+ session_source: request.session?.source ?? null,
5669
+ upstream: this.upstreamName ?? null
5588
5670
  },
5589
5671
  request.signal
5590
5672
  );
@@ -5666,8 +5748,9 @@ var GovernedForwarder = class {
5666
5748
  }
5667
5749
  handleRateLimit(request, decision, toolName) {
5668
5750
  const limiter = this.rateLimiter;
5669
- const limits = decision.matchedRule?.limits;
5670
- if (!limits?.maxCalls || !limits.windowMs) {
5751
+ const matchedRule = decision.matchedRule;
5752
+ const limits = matchedRule?.limits;
5753
+ if (!matchedRule || !limits?.maxCalls || !limits.windowMs) {
5671
5754
  const result = this.makePolicyMisconfiguredResult(
5672
5755
  request,
5673
5756
  decision,
@@ -5680,7 +5763,7 @@ var GovernedForwarder = class {
5680
5763
  rateLimitResult: { allowed: false, current: 0, limit: 0, windowMs: 0, resetAtMs: 0 }
5681
5764
  };
5682
5765
  }
5683
- let key;
5766
+ let baseKey;
5684
5767
  if (limits.key === "session") {
5685
5768
  const sessionKey = this.gateSessionLimitKey(request);
5686
5769
  if (sessionKey === null) {
@@ -5690,15 +5773,16 @@ var GovernedForwarder = class {
5690
5773
  approvalWaitMs: 0
5691
5774
  };
5692
5775
  }
5693
- key = sessionKey;
5776
+ baseKey = sessionKey;
5694
5777
  } else {
5695
- key = this.buildLimitKey(limits.key, toolName);
5778
+ baseKey = this.buildLimitKey(limits.key, toolName);
5696
5779
  }
5780
+ const key = ruleBucketKey(baseKey, matchedRule.index);
5697
5781
  const params = { key, maxCalls: limits.maxCalls, windowMs: limits.windowMs };
5698
5782
  const rateLimitResult = limiter.peek(params);
5699
5783
  if (!rateLimitResult.allowed) {
5700
5784
  const feedback = buildRateLimitedFeedback(decision, rateLimitResult);
5701
- const message = decision.matchedRule?.feedback?.message ?? `Rate limit exceeded for ${key}`;
5785
+ const message = matchedRule.feedback?.message ?? `Rate limit exceeded for ${key}`;
5702
5786
  return {
5703
5787
  proceed: false,
5704
5788
  result: makeErrorResult(request, POLICY_DENIED, message, { ...feedback }),
@@ -5745,7 +5829,7 @@ var GovernedForwarder = class {
5745
5829
  } else {
5746
5830
  baseKey = this.buildLimitKey(maxSpend.key, toolName);
5747
5831
  }
5748
- const key = spendBucketKey(baseKey, decision.matchedRule.index);
5832
+ const key = ruleBucketKey(baseKey, decision.matchedRule.index);
5749
5833
  const rawAmount = resolvePath(maxSpend.field, toolArguments ?? {});
5750
5834
  if (typeof rawAmount !== "number") {
5751
5835
  console.error(
@@ -5818,14 +5902,14 @@ var GovernedForwarder = class {
5818
5902
  case "rate_limit":
5819
5903
  if (this.rateLimiter && decision.matchedRule?.limits?.maxCalls && decision.matchedRule.limits.windowMs) {
5820
5904
  const limits = decision.matchedRule.limits;
5821
- const key = limits.key === "session" ? this.gateSessionLimitKey(request) : this.buildLimitKey(limits.key, toolName);
5822
- if (key === null) {
5905
+ const baseKey = limits.key === "session" ? this.gateSessionLimitKey(request) : this.buildLimitKey(limits.key, toolName);
5906
+ if (baseKey === null) {
5823
5907
  wouldForward = false;
5824
5908
  limitsOk = false;
5825
5909
  sessionUnresolved = true;
5826
5910
  } else {
5827
5911
  const peekResult = this.rateLimiter.peek({
5828
- key,
5912
+ key: ruleBucketKey(baseKey, decision.matchedRule.index),
5829
5913
  maxCalls: decision.matchedRule.limits.maxCalls,
5830
5914
  windowMs: decision.matchedRule.limits.windowMs
5831
5915
  });
@@ -5858,7 +5942,7 @@ var GovernedForwarder = class {
5858
5942
  sessionUnresolved = true;
5859
5943
  } else {
5860
5944
  const peekResult = this.spendLimiter.peek({
5861
- key: spendBucketKey(baseKey, decision.matchedRule.index),
5945
+ key: ruleBucketKey(baseKey, decision.matchedRule.index),
5862
5946
  amount: rawAmount,
5863
5947
  limit: maxSpend.limit,
5864
5948
  windowMs: maxSpend.windowMs
@@ -5878,7 +5962,8 @@ var GovernedForwarder = class {
5878
5962
  toolName,
5879
5963
  toolArguments,
5880
5964
  sessionId: sessionGate.ok ? sessionGate.session : null,
5881
- senderId: null
5965
+ senderId: null,
5966
+ upstream: this.upstreamName ?? null
5882
5967
  });
5883
5968
  if (failures.length > 0 || charges.length > 0) {
5884
5969
  const gated = gateBudgetCharges({ charges, failures }, sessionGate);
@@ -5919,7 +6004,9 @@ var GovernedForwarder = class {
5919
6004
  );
5920
6005
  }
5921
6006
  /**
5922
- * Construct a non-session limit bucket key. Session keys are deliberately
6007
+ * Construct a non-session limit bucket key. Tool-scope keys route through
6008
+ * the shared `toolLimitKey` leaf, which prefixes them with the configured
6009
+ * upstream name when one is set (issue #295). Session keys are deliberately
5923
6010
  * NOT built here: they come only from the gate module's `sessionLimitKey`,
5924
6011
  * whose `GatedSession` parameter makes skipping the identity gate a
5925
6012
  * compile error (issue #218) — call sites branch on `key === 'session'`.
@@ -5933,7 +6020,7 @@ var GovernedForwarder = class {
5933
6020
  '[helio] Warning: limits.key "agent" is not yet supported, falling back to "tool"'
5934
6021
  );
5935
6022
  }
5936
- return `tool:${toolName}`;
6023
+ return toolLimitKey(toolName, this.upstreamName);
5937
6024
  case "sender_id":
5938
6025
  if (!this.senderKeyWarned) {
5939
6026
  this.senderKeyWarned = true;
@@ -5941,10 +6028,10 @@ var GovernedForwarder = class {
5941
6028
  '[helio] Warning: limits.key "sender_id" has no sender on the MCP path, falling back to "tool"'
5942
6029
  );
5943
6030
  }
5944
- return `tool:${toolName}`;
6031
+ return toolLimitKey(toolName, this.upstreamName);
5945
6032
  case "tool":
5946
6033
  default:
5947
- return `tool:${toolName}`;
6034
+ return toolLimitKey(toolName, this.upstreamName);
5948
6035
  }
5949
6036
  }
5950
6037
  /**
@@ -6099,7 +6186,8 @@ var GovernedForwarder = class {
6099
6186
  record_kind: "tool_call",
6100
6187
  origin: "mcp",
6101
6188
  metadata: null,
6102
- protocol_version: request.protocolVersion ?? null
6189
+ protocol_version: request.protocolVersion ?? null,
6190
+ upstream: this.upstreamName ?? null
6103
6191
  };
6104
6192
  const isEnforcementDecision = !isDryRun && (!forwarded || approvalOutcome !== void 0 || budgetApproval !== void 0);
6105
6193
  if (isEnforcementDecision) {
@@ -6230,53 +6318,316 @@ function classifyPrimeFailure(response) {
6230
6318
  if (response.status >= 400) {
6231
6319
  return `upstream returned HTTP ${String(response.status)} to tools/list (session/initialize may be required)`;
6232
6320
  }
6233
- const rawBody = response.body;
6234
- if (typeof rawBody !== "object" || rawBody === null) {
6235
- return `upstream tools/list returned a non-JSON body (content-type ${response.headers["content-type"] ?? "unknown"})`;
6321
+ const rawBody = response.body;
6322
+ if (typeof rawBody !== "object" || rawBody === null) {
6323
+ return `upstream tools/list returned a non-JSON body (content-type ${response.headers["content-type"] ?? "unknown"})`;
6324
+ }
6325
+ const body = rawBody;
6326
+ const error = body["error"];
6327
+ if (typeof error === "string") {
6328
+ return `upstream tools/list returned a JSON-RPC error: ${error}`;
6329
+ }
6330
+ if (error !== null && typeof error === "object") {
6331
+ const message = error["message"];
6332
+ if (typeof message === "string") {
6333
+ return `upstream tools/list returned a JSON-RPC error: ${message}`;
6334
+ }
6335
+ }
6336
+ return "upstream tools/list response was missing result.tools";
6337
+ }
6338
+ function extractBlockReason(result) {
6339
+ const body = result.response.body;
6340
+ const error = body?.["error"];
6341
+ if (!error || typeof error !== "object") return null;
6342
+ const data = error["data"];
6343
+ if (!data || data["blocked"] !== true) return null;
6344
+ return typeof data["reason"] === "string" ? data["reason"] : null;
6345
+ }
6346
+ function buildEvidenceChain(evidenceResult, dependencyResult, blocked2) {
6347
+ if (!evidenceResult && !dependencyResult) return null;
6348
+ const chain = { blocked: blocked2 ?? false };
6349
+ if (evidenceResult) {
6350
+ chain["evidence"] = {
6351
+ required: [...evidenceResult.found, ...evidenceResult.missing, ...evidenceResult.expired],
6352
+ found: evidenceResult.found,
6353
+ missing: evidenceResult.missing,
6354
+ expired: evidenceResult.expired
6355
+ };
6356
+ }
6357
+ if (dependencyResult) {
6358
+ chain["dependencies"] = {
6359
+ satisfied: dependencyResult.satisfied,
6360
+ missing: dependencyResult.missing
6361
+ };
6362
+ }
6363
+ return chain;
6364
+ }
6365
+
6366
+ // src/policy/rate-limiter.ts
6367
+ var RateLimiter = class {
6368
+ buckets = /* @__PURE__ */ new Map();
6369
+ now;
6370
+ onWarning;
6371
+ warningThreshold;
6372
+ timer = null;
6373
+ closed = false;
6374
+ constructor(options = {}) {
6375
+ this.now = options.now ?? Date.now;
6376
+ this.onWarning = options.onWarning;
6377
+ this.warningThreshold = options.warningThreshold ?? 0.8;
6378
+ const intervalMs = options.cleanupIntervalMs ?? 6e4;
6379
+ if (intervalMs > 0) {
6380
+ this.timer = setInterval(() => {
6381
+ this.cleanup();
6382
+ }, intervalMs);
6383
+ this.timer.unref();
6384
+ }
6385
+ }
6386
+ // -------------------------------------------------------------------------
6387
+ // Core operations
6388
+ // -------------------------------------------------------------------------
6389
+ /**
6390
+ * Check and optionally record a call against the rate limit.
6391
+ *
6392
+ * Evicts expired timestamps, then checks the count:
6393
+ * - Under limit: records the timestamp and returns `allowed: true`
6394
+ * - At/over limit: does NOT record (blocked calls don't consume a slot)
6395
+ */
6396
+ check(params) {
6397
+ const { key, maxCalls, windowMs } = params;
6398
+ const now = this.now();
6399
+ const windowStart = now - windowMs;
6400
+ let bucket = this.buckets.get(key);
6401
+ if (!bucket) {
6402
+ bucket = { timestamps: [], maxCalls, windowMs };
6403
+ this.buckets.set(key, bucket);
6404
+ }
6405
+ bucket.maxCalls = maxCalls;
6406
+ bucket.windowMs = windowMs;
6407
+ bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6408
+ if (bucket.timestamps.length >= maxCalls) {
6409
+ const oldest = bucket.timestamps[0] ?? 0;
6410
+ return {
6411
+ allowed: false,
6412
+ current: bucket.timestamps.length,
6413
+ limit: maxCalls,
6414
+ windowMs,
6415
+ resetAtMs: oldest + windowMs
6416
+ };
6417
+ }
6418
+ bucket.timestamps.push(now);
6419
+ const current = bucket.timestamps.length;
6420
+ const resetAtMs = (bucket.timestamps[0] ?? now) + windowMs;
6421
+ if (this.onWarning && current / maxCalls >= this.warningThreshold) {
6422
+ this.safeWarn({ key, current, limit: maxCalls, window_ms: windowMs, reset_at_ms: resetAtMs });
6423
+ }
6424
+ return {
6425
+ allowed: true,
6426
+ current,
6427
+ limit: maxCalls,
6428
+ windowMs,
6429
+ resetAtMs
6430
+ };
6431
+ }
6432
+ /**
6433
+ * Unconditionally record a call against the rate limit.
6434
+ *
6435
+ * Unlike check(), this always appends the timestamp — even when the bucket
6436
+ * is already at/over the limit — because the call it represents has already
6437
+ * executed. The sideband splits decision from execution: /evaluate peeks
6438
+ * (non-destructive), and /audit calls record() once the external call ran,
6439
+ * so refusing to record at the limit (as check() does) would let real calls
6440
+ * escape accounting and under-count subsequent peeks. (issue #12, D3.)
6441
+ *
6442
+ * Warnings fire only while the post-append count stays within the limit —
6443
+ * exact parity with check(), which never warns on its over-limit path — so a
6444
+ * burst of over-limit audits cannot flood the dashboard's limit_warning feed.
6445
+ */
6446
+ record(params) {
6447
+ const { key, maxCalls, windowMs } = params;
6448
+ const now = this.now();
6449
+ const windowStart = now - windowMs;
6450
+ let bucket = this.buckets.get(key);
6451
+ if (!bucket) {
6452
+ bucket = { timestamps: [], maxCalls, windowMs };
6453
+ this.buckets.set(key, bucket);
6454
+ }
6455
+ bucket.maxCalls = maxCalls;
6456
+ bucket.windowMs = windowMs;
6457
+ bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6458
+ bucket.timestamps.push(now);
6459
+ const current = bucket.timestamps.length;
6460
+ const resetAtMs = (bucket.timestamps[0] ?? now) + windowMs;
6461
+ if (this.onWarning && current <= maxCalls && current / maxCalls >= this.warningThreshold) {
6462
+ this.safeWarn({ key, current, limit: maxCalls, window_ms: windowMs, reset_at_ms: resetAtMs });
6463
+ }
6464
+ return {
6465
+ allowed: current <= maxCalls,
6466
+ current,
6467
+ limit: maxCalls,
6468
+ windowMs,
6469
+ resetAtMs
6470
+ };
6471
+ }
6472
+ /**
6473
+ * Check the rate limit without recording the call (non-destructive).
6474
+ *
6475
+ * Used by dry-run mode to determine what would happen without consuming
6476
+ * a slot in the bucket.
6477
+ */
6478
+ peek(params) {
6479
+ const { key, maxCalls, windowMs } = params;
6480
+ const now = this.now();
6481
+ const windowStart = now - windowMs;
6482
+ const bucket = this.buckets.get(key);
6483
+ if (!bucket) {
6484
+ return {
6485
+ allowed: true,
6486
+ current: 1,
6487
+ limit: maxCalls,
6488
+ windowMs,
6489
+ resetAtMs: now + windowMs
6490
+ };
6491
+ }
6492
+ const activeCount = bucket.timestamps.filter((ts) => ts > windowStart).length;
6493
+ if (activeCount >= maxCalls) {
6494
+ const oldest2 = bucket.timestamps.find((ts) => ts > windowStart) ?? 0;
6495
+ return {
6496
+ allowed: false,
6497
+ current: activeCount,
6498
+ limit: maxCalls,
6499
+ windowMs,
6500
+ resetAtMs: oldest2 + windowMs
6501
+ };
6502
+ }
6503
+ const oldest = bucket.timestamps.find((ts) => ts > windowStart) ?? now;
6504
+ return {
6505
+ allowed: true,
6506
+ current: activeCount + 1,
6507
+ limit: maxCalls,
6508
+ windowMs,
6509
+ resetAtMs: oldest + windowMs
6510
+ };
6511
+ }
6512
+ // -------------------------------------------------------------------------
6513
+ // Read operations (for dashboard API)
6514
+ // -------------------------------------------------------------------------
6515
+ /** Get the current state of a single key. Returns undefined if not tracked. */
6516
+ getKeyState(key) {
6517
+ const bucket = this.buckets.get(key);
6518
+ if (!bucket) return void 0;
6519
+ const windowStart = this.now() - bucket.windowMs;
6520
+ bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6521
+ if (bucket.timestamps.length === 0) {
6522
+ this.buckets.delete(key);
6523
+ return void 0;
6524
+ }
6525
+ return {
6526
+ key,
6527
+ current: bucket.timestamps.length,
6528
+ limit: bucket.maxCalls,
6529
+ window_ms: bucket.windowMs,
6530
+ reset_at_ms: (bucket.timestamps[0] ?? 0) + bucket.windowMs
6531
+ };
6532
+ }
6533
+ /** List all tracked keys with their current state. */
6534
+ listKeyStates() {
6535
+ const states = [];
6536
+ for (const key of [...this.buckets.keys()]) {
6537
+ const state = this.getKeyState(key);
6538
+ if (state) states.push(state);
6539
+ }
6540
+ return states;
6236
6541
  }
6237
- const body = rawBody;
6238
- const error = body["error"];
6239
- if (typeof error === "string") {
6240
- return `upstream tools/list returned a JSON-RPC error: ${error}`;
6542
+ // -------------------------------------------------------------------------
6543
+ // Maintenance
6544
+ // -------------------------------------------------------------------------
6545
+ /** Sweep all buckets: remove expired timestamps, delete empty buckets. */
6546
+ cleanup() {
6547
+ const now = this.now();
6548
+ for (const [key, bucket] of this.buckets) {
6549
+ const windowStart = now - bucket.windowMs;
6550
+ bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6551
+ if (bucket.timestamps.length === 0) {
6552
+ this.buckets.delete(key);
6553
+ }
6554
+ }
6241
6555
  }
6242
- if (error !== null && typeof error === "object") {
6243
- const message = error["message"];
6244
- if (typeof message === "string") {
6245
- return `upstream tools/list returned a JSON-RPC error: ${message}`;
6556
+ /** Clear all rate limit state. Called on policy hot-reload. */
6557
+ reset() {
6558
+ this.buckets.clear();
6559
+ }
6560
+ /**
6561
+ * Reconcile bucket state against a new policy's limit configuration.
6562
+ *
6563
+ * Walks every existing bucket and checks whether its last-seen
6564
+ * `{ maxCalls, windowMs }` tuple still appears in `validConfigs`.
6565
+ * Buckets whose config is still present are left untouched — counters and
6566
+ * elapsed-window progress are preserved across hot-reloads. Buckets whose
6567
+ * config is gone (rule changed or removed) are evicted so the next check
6568
+ * lazy-creates a fresh bucket under the new config.
6569
+ *
6570
+ * Keys built by `ruleBucketKey` (bucket-key.ts) carry the owning rule's
6571
+ * index, and for those the tuple must match at THAT index
6572
+ * (`config.ruleIndex`): a reorder that shifts a rate rule's index evicts
6573
+ * its old-index bucket instead of leaving an orphan no rule reads again —
6574
+ * or worse, letting whatever rule now sits at that index adopt another
6575
+ * rule's accrued calls. Un-suffixed keys keep the tuple-anywhere match,
6576
+ * but only against index-less configs — a caller that passes only indexed
6577
+ * configs (as the proxy does) evicts every un-suffixed bucket, fail-closed.
6578
+ *
6579
+ * This is the compare-and-evict semantic that replaces the old `reset()`
6580
+ * call on every hot-reload, which wiped all state even when the matching
6581
+ * rule was unchanged.
6582
+ */
6583
+ reconcile(validConfigs) {
6584
+ const valid = /* @__PURE__ */ new Set();
6585
+ const byIndex = /* @__PURE__ */ new Map();
6586
+ for (const config of validConfigs) {
6587
+ const tuple = `${String(config.maxCalls)}|${String(config.windowMs)}`;
6588
+ if (config.ruleIndex === void 0) {
6589
+ valid.add(tuple);
6590
+ } else {
6591
+ byIndex.set(config.ruleIndex, tuple);
6592
+ }
6593
+ }
6594
+ for (const [key, bucket] of this.buckets) {
6595
+ const tuple = `${String(bucket.maxCalls)}|${String(bucket.windowMs)}`;
6596
+ const ruleIndex = parseRuleIndex(key);
6597
+ const survives = ruleIndex === void 0 ? valid.has(tuple) : byIndex.get(ruleIndex) === tuple;
6598
+ if (!survives) {
6599
+ this.buckets.delete(key);
6600
+ }
6246
6601
  }
6247
6602
  }
6248
- return "upstream tools/list response was missing result.tools";
6249
- }
6250
- function extractBlockReason(result) {
6251
- const body = result.response.body;
6252
- const error = body?.["error"];
6253
- if (!error || typeof error !== "object") return null;
6254
- const data = error["data"];
6255
- if (!data || data["blocked"] !== true) return null;
6256
- return typeof data["reason"] === "string" ? data["reason"] : null;
6257
- }
6258
- function buildEvidenceChain(evidenceResult, dependencyResult, blocked2) {
6259
- if (!evidenceResult && !dependencyResult) return null;
6260
- const chain = { blocked: blocked2 ?? false };
6261
- if (evidenceResult) {
6262
- chain["evidence"] = {
6263
- required: [...evidenceResult.found, ...evidenceResult.missing, ...evidenceResult.expired],
6264
- found: evidenceResult.found,
6265
- missing: evidenceResult.missing,
6266
- expired: evidenceResult.expired
6267
- };
6603
+ /** Stop the cleanup timer and mark as closed. */
6604
+ /**
6605
+ * Invoke the warning callback without letting a subscriber throw into the
6606
+ * limiter's caller: a warning fires after state has already mutated, and a
6607
+ * governed call must not be blocked (or double-charged on retry) by an
6608
+ * observability bug.
6609
+ */
6610
+ safeWarn(state) {
6611
+ if (!this.onWarning) return;
6612
+ try {
6613
+ this.onWarning(state);
6614
+ } catch (err) {
6615
+ console.error("[helio] limit warning subscriber threw:", err);
6616
+ }
6268
6617
  }
6269
- if (dependencyResult) {
6270
- chain["dependencies"] = {
6271
- satisfied: dependencyResult.satisfied,
6272
- missing: dependencyResult.missing
6273
- };
6618
+ close() {
6619
+ if (this.closed) return;
6620
+ this.closed = true;
6621
+ if (this.timer) {
6622
+ clearInterval(this.timer);
6623
+ this.timer = null;
6624
+ }
6625
+ this.buckets.clear();
6274
6626
  }
6275
- return chain;
6276
- }
6627
+ };
6277
6628
 
6278
- // src/policy/rate-limiter.ts
6279
- var RateLimiter = class {
6629
+ // src/policy/spend-limiter.ts
6630
+ var SpendLimiter = class {
6280
6631
  buckets = /* @__PURE__ */ new Map();
6281
6632
  now;
6282
6633
  onWarning;
@@ -6299,128 +6650,185 @@ var RateLimiter = class {
6299
6650
  // Core operations
6300
6651
  // -------------------------------------------------------------------------
6301
6652
  /**
6302
- * Check and optionally record a call against the rate limit.
6653
+ * Check and optionally record a spend against the limit.
6303
6654
  *
6304
- * Evicts expired timestamps, then checks the count:
6305
- * - Under limit: records the timestamp and returns `allowed: true`
6306
- * - At/over limit: does NOT record (blocked calls don't consume a slot)
6655
+ * Evicts expired entries, sums remaining amounts, then checks:
6656
+ * - Under limit (currentSpend + amount <= limit): records and returns `allowed: true`
6657
+ * - Would exceed: does NOT record (rejected spends don't consume budget)
6307
6658
  */
6308
6659
  check(params) {
6309
- const { key, maxCalls, windowMs } = params;
6660
+ const { key, amount, limit, windowMs } = params;
6310
6661
  const now = this.now();
6311
6662
  const windowStart = now - windowMs;
6663
+ if (!Number.isFinite(amount) || amount < 0) {
6664
+ const existing = this.buckets.get(key);
6665
+ const activeEntries = existing ? existing.entries.filter((e) => e.timestamp > windowStart) : [];
6666
+ const currentSpend2 = activeEntries.reduce((sum, e) => sum + e.amount, 0);
6667
+ const oldest = activeEntries[0];
6668
+ return {
6669
+ allowed: false,
6670
+ currentSpend: currentSpend2,
6671
+ limit,
6672
+ windowMs,
6673
+ resetAtMs: oldest ? oldest.timestamp + windowMs : 0,
6674
+ reason: "invalid_amount"
6675
+ };
6676
+ }
6312
6677
  let bucket = this.buckets.get(key);
6313
6678
  if (!bucket) {
6314
- bucket = { timestamps: [], maxCalls, windowMs };
6679
+ bucket = { entries: [], limit, currency: "", windowMs };
6315
6680
  this.buckets.set(key, bucket);
6316
6681
  }
6317
- bucket.maxCalls = maxCalls;
6682
+ bucket.limit = limit;
6318
6683
  bucket.windowMs = windowMs;
6319
- bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6320
- if (bucket.timestamps.length >= maxCalls) {
6321
- const oldest = bucket.timestamps[0] ?? 0;
6684
+ bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
6685
+ const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
6686
+ if (currentSpend + amount > limit) {
6687
+ const oldest = bucket.entries[0];
6322
6688
  return {
6323
6689
  allowed: false,
6324
- current: bucket.timestamps.length,
6325
- limit: maxCalls,
6690
+ currentSpend,
6691
+ limit,
6326
6692
  windowMs,
6327
- resetAtMs: oldest + windowMs
6693
+ resetAtMs: oldest ? oldest.timestamp + windowMs : 0
6328
6694
  };
6329
6695
  }
6330
- bucket.timestamps.push(now);
6331
- const current = bucket.timestamps.length;
6332
- const resetAtMs = (bucket.timestamps[0] ?? now) + windowMs;
6333
- if (this.onWarning && current / maxCalls >= this.warningThreshold) {
6334
- this.safeWarn({ key, current, limit: maxCalls, window_ms: windowMs, reset_at_ms: resetAtMs });
6696
+ bucket.entries.push({ timestamp: now, amount });
6697
+ const newSpend = currentSpend + amount;
6698
+ const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
6699
+ if (this.onWarning && newSpend / limit >= this.warningThreshold) {
6700
+ this.safeWarn({
6701
+ key,
6702
+ current_spend: newSpend,
6703
+ limit,
6704
+ currency: bucket.currency,
6705
+ window_ms: windowMs,
6706
+ reset_at_ms: resetAtMs
6707
+ });
6335
6708
  }
6336
6709
  return {
6337
6710
  allowed: true,
6338
- current,
6339
- limit: maxCalls,
6711
+ currentSpend: newSpend,
6712
+ limit,
6340
6713
  windowMs,
6341
6714
  resetAtMs
6342
6715
  };
6343
6716
  }
6344
6717
  /**
6345
- * Unconditionally record a call against the rate limit.
6718
+ * Unconditionally record a spend against the limit.
6346
6719
  *
6347
- * Unlike check(), this always appends the timestamp — even when the bucket
6348
- * is already at/over the limit — because the call it represents has already
6349
- * executed. The sideband splits decision from execution: /evaluate peeks
6350
- * (non-destructive), and /audit calls record() once the external call ran,
6351
- * so refusing to record at the limit (as check() does) would let real calls
6352
- * escape accounting and under-count subsequent peeks. (issue #12, D3.)
6720
+ * Unlike check(), this always appends the amount — even when it pushes the
6721
+ * window past the limit — because the spend it represents has already been
6722
+ * incurred. The sideband peeks at /evaluate and commits here at /audit once
6723
+ * the external call ran (issue #12, D3).
6353
6724
  *
6354
- * Warnings fire only while the post-append count stays within the limit
6355
- * exact parity with check(), which never warns on its over-limit path — so a
6356
- * burst of over-limit audits cannot flood the dashboard's limit_warning feed.
6725
+ * Throws on a negative or non-finite amount: such amounts are rejected at
6726
+ * /evaluate, so one reaching record() is a logic bug we surface loudly rather
6727
+ * than silently corrupt the sliding-window sum. Warnings fire only while the
6728
+ * post-append spend stays within the limit (parity with check()).
6357
6729
  */
6358
6730
  record(params) {
6359
- const { key, maxCalls, windowMs } = params;
6731
+ const { key, amount, limit, windowMs } = params;
6732
+ if (!Number.isFinite(amount) || amount < 0) {
6733
+ throw new RangeError(
6734
+ `SpendLimiter.record() received an invalid amount (${String(amount)}); invalid amounts must be rejected at /evaluate, never committed`
6735
+ );
6736
+ }
6360
6737
  const now = this.now();
6361
6738
  const windowStart = now - windowMs;
6362
6739
  let bucket = this.buckets.get(key);
6363
6740
  if (!bucket) {
6364
- bucket = { timestamps: [], maxCalls, windowMs };
6741
+ bucket = { entries: [], limit, currency: "", windowMs };
6365
6742
  this.buckets.set(key, bucket);
6366
6743
  }
6367
- bucket.maxCalls = maxCalls;
6744
+ bucket.limit = limit;
6368
6745
  bucket.windowMs = windowMs;
6369
- bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6370
- bucket.timestamps.push(now);
6371
- const current = bucket.timestamps.length;
6372
- const resetAtMs = (bucket.timestamps[0] ?? now) + windowMs;
6373
- if (this.onWarning && current <= maxCalls && current / maxCalls >= this.warningThreshold) {
6374
- this.safeWarn({ key, current, limit: maxCalls, window_ms: windowMs, reset_at_ms: resetAtMs });
6746
+ bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
6747
+ bucket.entries.push({ timestamp: now, amount });
6748
+ const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
6749
+ const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
6750
+ if (this.onWarning && currentSpend <= limit && currentSpend / limit >= this.warningThreshold) {
6751
+ this.safeWarn({
6752
+ key,
6753
+ current_spend: currentSpend,
6754
+ limit,
6755
+ currency: bucket.currency,
6756
+ window_ms: windowMs,
6757
+ reset_at_ms: resetAtMs
6758
+ });
6375
6759
  }
6376
6760
  return {
6377
- allowed: current <= maxCalls,
6378
- current,
6379
- limit: maxCalls,
6761
+ allowed: currentSpend <= limit,
6762
+ currentSpend,
6763
+ limit,
6380
6764
  windowMs,
6381
6765
  resetAtMs
6382
6766
  };
6383
6767
  }
6384
6768
  /**
6385
- * Check the rate limit without recording the call (non-destructive).
6769
+ * Check the spend limit without recording the spend (non-destructive).
6386
6770
  *
6387
6771
  * Used by dry-run mode to determine what would happen without consuming
6388
- * a slot in the bucket.
6772
+ * budget in the bucket.
6389
6773
  */
6390
6774
  peek(params) {
6391
- const { key, maxCalls, windowMs } = params;
6775
+ const { key, amount, limit, windowMs } = params;
6392
6776
  const now = this.now();
6393
6777
  const windowStart = now - windowMs;
6394
6778
  const bucket = this.buckets.get(key);
6779
+ if (!Number.isFinite(amount) || amount < 0) {
6780
+ const activeEntries2 = bucket ? bucket.entries.filter((e) => e.timestamp > windowStart) : [];
6781
+ const currentSpend2 = activeEntries2.reduce((sum, e) => sum + e.amount, 0);
6782
+ const oldest2 = activeEntries2[0];
6783
+ return {
6784
+ allowed: false,
6785
+ currentSpend: currentSpend2,
6786
+ limit,
6787
+ windowMs,
6788
+ resetAtMs: oldest2 ? oldest2.timestamp + windowMs : 0,
6789
+ reason: "invalid_amount"
6790
+ };
6791
+ }
6395
6792
  if (!bucket) {
6793
+ const wouldExceed = amount > limit;
6396
6794
  return {
6397
- allowed: true,
6398
- current: 1,
6399
- limit: maxCalls,
6795
+ allowed: !wouldExceed,
6796
+ currentSpend: wouldExceed ? 0 : amount,
6797
+ limit,
6400
6798
  windowMs,
6401
6799
  resetAtMs: now + windowMs
6402
6800
  };
6403
6801
  }
6404
- const activeCount = bucket.timestamps.filter((ts) => ts > windowStart).length;
6405
- if (activeCount >= maxCalls) {
6406
- const oldest2 = bucket.timestamps.find((ts) => ts > windowStart) ?? 0;
6802
+ const activeEntries = bucket.entries.filter((e) => e.timestamp > windowStart);
6803
+ const currentSpend = activeEntries.reduce((sum, e) => sum + e.amount, 0);
6804
+ if (currentSpend + amount > limit) {
6805
+ const oldest2 = activeEntries[0];
6407
6806
  return {
6408
6807
  allowed: false,
6409
- current: activeCount,
6410
- limit: maxCalls,
6808
+ currentSpend,
6809
+ limit,
6411
6810
  windowMs,
6412
- resetAtMs: oldest2 + windowMs
6811
+ resetAtMs: oldest2 ? oldest2.timestamp + windowMs : 0
6413
6812
  };
6414
6813
  }
6415
- const oldest = bucket.timestamps.find((ts) => ts > windowStart) ?? now;
6814
+ const newSpend = currentSpend + amount;
6815
+ const oldest = activeEntries[0];
6416
6816
  return {
6417
6817
  allowed: true,
6418
- current: activeCount + 1,
6419
- limit: maxCalls,
6818
+ currentSpend: newSpend,
6819
+ limit,
6420
6820
  windowMs,
6421
- resetAtMs: oldest + windowMs
6821
+ resetAtMs: oldest ? oldest.timestamp + windowMs : now + windowMs
6422
6822
  };
6423
6823
  }
6824
+ /**
6825
+ * Set the display currency for a key. Called by the governed forwarder
6826
+ * after check() so dashboard reads include the currency label.
6827
+ */
6828
+ setCurrency(key, currency) {
6829
+ const bucket = this.buckets.get(key);
6830
+ if (bucket) bucket.currency = currency;
6831
+ }
6424
6832
  // -------------------------------------------------------------------------
6425
6833
  // Read operations (for dashboard API)
6426
6834
  // -------------------------------------------------------------------------
@@ -6429,17 +6837,19 @@ var RateLimiter = class {
6429
6837
  const bucket = this.buckets.get(key);
6430
6838
  if (!bucket) return void 0;
6431
6839
  const windowStart = this.now() - bucket.windowMs;
6432
- bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6433
- if (bucket.timestamps.length === 0) {
6840
+ bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
6841
+ if (bucket.entries.length === 0) {
6434
6842
  this.buckets.delete(key);
6435
6843
  return void 0;
6436
6844
  }
6845
+ const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
6437
6846
  return {
6438
6847
  key,
6439
- current: bucket.timestamps.length,
6440
- limit: bucket.maxCalls,
6848
+ current_spend: currentSpend,
6849
+ limit: bucket.limit,
6850
+ currency: bucket.currency,
6441
6851
  window_ms: bucket.windowMs,
6442
- reset_at_ms: (bucket.timestamps[0] ?? 0) + bucket.windowMs
6852
+ reset_at_ms: (bucket.entries[0]?.timestamp ?? 0) + bucket.windowMs
6443
6853
  };
6444
6854
  }
6445
6855
  /** List all tracked keys with their current state. */
@@ -6454,43 +6864,62 @@ var RateLimiter = class {
6454
6864
  // -------------------------------------------------------------------------
6455
6865
  // Maintenance
6456
6866
  // -------------------------------------------------------------------------
6457
- /** Sweep all buckets: remove expired timestamps, delete empty buckets. */
6867
+ /** Sweep all buckets: remove expired entries, delete empty buckets. */
6458
6868
  cleanup() {
6459
6869
  const now = this.now();
6460
6870
  for (const [key, bucket] of this.buckets) {
6461
6871
  const windowStart = now - bucket.windowMs;
6462
- bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6463
- if (bucket.timestamps.length === 0) {
6872
+ bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
6873
+ if (bucket.entries.length === 0) {
6464
6874
  this.buckets.delete(key);
6465
6875
  }
6466
6876
  }
6467
6877
  }
6468
- /** Clear all rate limit state. Called on policy hot-reload. */
6878
+ /** Clear all spend limit state. Called on policy hot-reload. */
6469
6879
  reset() {
6470
6880
  this.buckets.clear();
6471
6881
  }
6472
6882
  /**
6473
- * Reconcile bucket state against a new policy's limit configuration.
6883
+ * Reconcile bucket state against a new policy's spend configuration.
6474
6884
  *
6475
6885
  * Walks every existing bucket and checks whether its last-seen
6476
- * `{ maxCalls, windowMs }` tuple still appears anywhere in `validConfigs`.
6477
- * Buckets whose config is still present are left untouched — counters and
6478
- * elapsed-window progress are preserved across hot-reloads. Buckets whose
6479
- * config is gone (rule changed or removed) are evicted so the next check
6480
- * lazy-creates a fresh bucket under the new config.
6886
+ * `{ limit, currency, windowMs }` tuple still appears in `validConfigs`.
6887
+ * Buckets whose config is unchanged are left untouched — cumulative spend
6888
+ * and elapsed-window progress are preserved across hot-reloads. Buckets
6889
+ * whose config is gone (rule changed or removed) are evicted so the next
6890
+ * check lazy-creates a fresh bucket under the new config.
6481
6891
  *
6482
- * This is the compare-and-evict semantic that replaces the old `reset()`
6483
- * call on every hot-reload, which wiped all state even when the matching
6484
- * rule was unchanged.
6892
+ * Keys built by `ruleBucketKey` (bucket-key.ts) carry the owning rule's
6893
+ * index, and for those the tuple must match at THAT index (`config.ruleIndex`): a
6894
+ * reorder that shifts a spend rule's index evicts its old-index bucket
6895
+ * instead of leaving an orphan no rule reads again — or worse, letting
6896
+ * whatever rule now sits at that index adopt another rule's accrued spend.
6897
+ * Un-suffixed keys keep the tuple-anywhere match, but only against
6898
+ * index-less configs — a caller that passes only indexed configs (as the
6899
+ * proxy does) evicts every un-suffixed bucket, fail-closed.
6900
+ *
6901
+ * Currency is part of the tuple because a USD→EUR switch is a meaningful
6902
+ * policy change — the same numeric limit buys a different amount of real
6903
+ * spend, so the bucket must reset. This replaces the old `reset()` call
6904
+ * on every hot-reload, which wiped all state even when the matching rule
6905
+ * was unchanged.
6485
6906
  */
6486
6907
  reconcile(validConfigs) {
6487
6908
  const valid = /* @__PURE__ */ new Set();
6909
+ const byIndex = /* @__PURE__ */ new Map();
6488
6910
  for (const config of validConfigs) {
6489
- valid.add(`${String(config.maxCalls)}|${String(config.windowMs)}`);
6911
+ const tuple = `${String(config.limit)}|${config.currency}|${String(config.windowMs)}`;
6912
+ if (config.ruleIndex === void 0) {
6913
+ valid.add(tuple);
6914
+ } else {
6915
+ byIndex.set(config.ruleIndex, tuple);
6916
+ }
6490
6917
  }
6491
6918
  for (const [key, bucket] of this.buckets) {
6492
- const tuple = `${String(bucket.maxCalls)}|${String(bucket.windowMs)}`;
6493
- if (!valid.has(tuple)) {
6919
+ const tuple = `${String(bucket.limit)}|${bucket.currency}|${String(bucket.windowMs)}`;
6920
+ const ruleIndex = parseRuleIndex(key);
6921
+ const survives = ruleIndex === void 0 ? valid.has(tuple) : byIndex.get(ruleIndex) === tuple;
6922
+ if (!survives) {
6494
6923
  this.buckets.delete(key);
6495
6924
  }
6496
6925
  }
@@ -6526,6 +6955,13 @@ var ANNOTATION_PRIME_INITIAL_WAIT_MS = 1500;
6526
6955
  var ANNOTATION_PRIME_RETRY_BASE_MS = 1e3;
6527
6956
  var ANNOTATION_PRIME_RETRY_MAX_MS = 3e4;
6528
6957
  var ANNOTATION_PRIME_RETRY_JITTER_MS = 250;
6958
+ function sameRevalidation(a, b) {
6959
+ if (a === void 0 || b === void 0) return a === b;
6960
+ return a.enabled === b.enabled && a.intervalMs === b.intervalMs && a.maxAdvertisedTtlMs === b.maxAdvertisedTtlMs;
6961
+ }
6962
+ function describeRejection(reason) {
6963
+ return reason instanceof Error ? reason.message : String(reason);
6964
+ }
6529
6965
  function computePrimeRetryDelayMs(attempt) {
6530
6966
  const exponent = Math.max(0, attempt - 1);
6531
6967
  const baseDelay = Math.min(
@@ -6535,7 +6971,8 @@ function computePrimeRetryDelayMs(attempt) {
6535
6971
  const jitter = Math.floor(Math.random() * ANNOTATION_PRIME_RETRY_JITTER_MS);
6536
6972
  return Math.min(ANNOTATION_PRIME_RETRY_MAX_MS, baseDelay + jitter);
6537
6973
  }
6538
- async function startAnnotationPrimeLoop(forwarder, revalidation) {
6974
+ async function startAnnotationPrimeLoop(forwarder, revalidation, upstreamName) {
6975
+ const tag = helioLogTag(upstreamName);
6539
6976
  let stopped = false;
6540
6977
  let primed = false;
6541
6978
  let retryAttempt = 0;
@@ -6563,10 +7000,16 @@ async function startAnnotationPrimeLoop(forwarder, revalidation) {
6563
7000
  if (epoch !== revalidateEpoch) return;
6564
7001
  if (!result.success) {
6565
7002
  console.error(
6566
- `[helio] Tool revalidation failed: ${result.reason ?? "unknown reason"} \u2014 keeping the last baselines; next attempt in ${String(rv.intervalMs)}ms`
7003
+ `${tag} Tool revalidation failed: ${result.reason ?? "unknown reason"} \u2014 keeping the last baselines; next attempt in ${String(rv.intervalMs)}ms`
6567
7004
  );
6568
7005
  }
6569
7006
  scheduleRevalidation();
7007
+ }).catch((reason) => {
7008
+ if (epoch !== revalidateEpoch) return;
7009
+ console.error(
7010
+ `${tag} Tool revalidation attempt failed unexpectedly: ${describeRejection(reason)} \u2014 keeping the cadence`
7011
+ );
7012
+ scheduleRevalidation();
6570
7013
  });
6571
7014
  }, rv.intervalMs);
6572
7015
  revalidateTimer.unref();
@@ -6578,6 +7021,7 @@ async function startAnnotationPrimeLoop(forwarder, revalidation) {
6578
7021
  clearRevalidateTimer();
6579
7022
  };
6580
7023
  const reconfigure = (next) => {
7024
+ if (sameRevalidation(current, next)) return;
6581
7025
  current = next;
6582
7026
  revalidateEpoch += 1;
6583
7027
  clearRevalidateTimer();
@@ -6588,7 +7032,7 @@ async function startAnnotationPrimeLoop(forwarder, revalidation) {
6588
7032
  retryAttempt += 1;
6589
7033
  const delayMs = computePrimeRetryDelayMs(retryAttempt);
6590
7034
  console.error(
6591
- `[helio] Annotation cache prime retry ${String(retryAttempt)} scheduled in ${String(delayMs)}ms`
7035
+ `${tag} Annotation cache prime retry ${String(retryAttempt)} scheduled in ${String(delayMs)}ms`
6592
7036
  );
6593
7037
  retryTimer = setTimeout(() => {
6594
7038
  retryTimer = void 0;
@@ -6601,7 +7045,7 @@ async function startAnnotationPrimeLoop(forwarder, revalidation) {
6601
7045
  if (result.success) {
6602
7046
  primed = true;
6603
7047
  clearRetryTimer();
6604
- const prefix = phase === "initial" ? "[helio] Annotation cache primed" : `[helio] Annotation cache primed after retry ${String(retryAttempt)}`;
7048
+ const prefix = phase === "initial" ? `${tag} Annotation cache primed` : `${tag} Annotation cache primed after retry ${String(retryAttempt)}`;
6605
7049
  console.error(
6606
7050
  `${prefix}: ${String(result.toolsCached)} tool definitions baselined for drift detection (baselines are per-process; a restart re-baselines \u2014 review tool_drift audit records before restarting)`
6607
7051
  );
@@ -6611,18 +7055,26 @@ async function startAnnotationPrimeLoop(forwarder, revalidation) {
6611
7055
  const reason = result.reason ?? "unknown reason";
6612
7056
  if (phase === "initial") {
6613
7057
  console.error(
6614
- `[helio] Annotation cache priming failed: ${reason} \u2014 undocumented tools will be denied (fail-closed) until priming succeeds`
7058
+ `${tag} Annotation cache priming failed: ${reason} \u2014 undocumented tools will be denied (fail-closed) until priming succeeds`
6615
7059
  );
6616
7060
  } else {
6617
7061
  console.error(
6618
- `[helio] Annotation cache prime retry ${String(retryAttempt)} failed: ${reason} \u2014 still fail-closed`
7062
+ `${tag} Annotation cache prime retry ${String(retryAttempt)} failed: ${reason} \u2014 still fail-closed`
6619
7063
  );
6620
7064
  }
6621
7065
  scheduleRetry();
6622
7066
  };
6623
7067
  const runPrimeAttempt = async (phase) => {
6624
- const result = await forwarder.primeAnnotationCache();
6625
- handlePrimeResult(phase, result);
7068
+ try {
7069
+ const result = await forwarder.primeAnnotationCache();
7070
+ handlePrimeResult(phase, result);
7071
+ } catch (reason) {
7072
+ if (stopped || primed) return;
7073
+ console.error(
7074
+ `${tag} Tool revalidation attempt failed unexpectedly: ${describeRejection(reason)} \u2014 keeping the cadence`
7075
+ );
7076
+ scheduleRetry();
7077
+ }
6626
7078
  };
6627
7079
  const initialAttempt = runPrimeAttempt("initial");
6628
7080
  const initialOutcome = await Promise.race([
@@ -6635,7 +7087,7 @@ async function startAnnotationPrimeLoop(forwarder, revalidation) {
6635
7087
  ]);
6636
7088
  if (initialOutcome === "timeout") {
6637
7089
  console.error(
6638
- `[helio] Annotation cache priming did not complete within ${String(ANNOTATION_PRIME_INITIAL_WAIT_MS)}ms; continuing startup fail-closed and retrying in background`
7090
+ `${tag} Annotation cache priming did not complete within ${String(ANNOTATION_PRIME_INITIAL_WAIT_MS)}ms; continuing startup fail-closed and retrying in background`
6639
7091
  );
6640
7092
  scheduleRetry();
6641
7093
  }
@@ -6647,6 +7099,14 @@ import Database from "better-sqlite3";
6647
7099
  import { randomUUID as randomUUID3 } from "crypto";
6648
7100
  import { chmodSync } from "fs";
6649
7101
 
7102
+ // src/startup-error.ts
7103
+ var StartupError = class extends Error {
7104
+ constructor(message) {
7105
+ super(message);
7106
+ this.name = "StartupError";
7107
+ }
7108
+ };
7109
+
6650
7110
  // src/upstream/response-summary.ts
6651
7111
  function extractResponseSummary(body) {
6652
7112
  if (body == null || typeof body !== "object") {
@@ -6741,7 +7201,8 @@ CREATE TABLE IF NOT EXISTS audit_records (
6741
7201
  origin TEXT NOT NULL DEFAULT 'mcp',
6742
7202
  metadata TEXT,
6743
7203
  protocol_version TEXT,
6744
- created_at TEXT NOT NULL
7204
+ created_at TEXT NOT NULL,
7205
+ upstream TEXT
6745
7206
  );
6746
7207
  `;
6747
7208
  var CREATE_INDEX_DDL = `
@@ -6753,6 +7214,7 @@ CREATE INDEX IF NOT EXISTS idx_audit_block_reason ON audit_records (block_re
6753
7214
  CREATE INDEX IF NOT EXISTS idx_audit_upstream_status_created_at ON audit_records (upstream_http_status, created_at);
6754
7215
  CREATE INDEX IF NOT EXISTS idx_audit_record_kind ON audit_records (record_kind);
6755
7216
  CREATE INDEX IF NOT EXISTS idx_audit_origin ON audit_records (origin);
7217
+ CREATE INDEX IF NOT EXISTS idx_audit_upstream ON audit_records (upstream);
6756
7218
  `;
6757
7219
  var INSERT_SQL = `
6758
7220
  INSERT INTO audit_records (
@@ -6761,14 +7223,16 @@ INSERT INTO audit_records (
6761
7223
  approved_by, upstream_response, upstream_error, upstream_latency_ms,
6762
7224
  upstream_http_status,
6763
7225
  total_duration_ms, approval_wait_ms, proxy_compute_ms,
6764
- flagged_destructive, dry_run, record_kind, origin, metadata, protocol_version, created_at
7226
+ flagged_destructive, dry_run, record_kind, origin, metadata, protocol_version, created_at,
7227
+ upstream
6765
7228
  ) VALUES (
6766
7229
  @id, @timestamp, @session_id, @session_source, @agent_id, @environment, @tool_name, @tool_input,
6767
7230
  @policy_decision, @block_reason, @matched_rule, @matched_rule_index, @evidence_chain, @approval_status,
6768
7231
  @approved_by, @upstream_response, @upstream_error, @upstream_latency_ms,
6769
7232
  @upstream_http_status,
6770
7233
  @total_duration_ms, @approval_wait_ms, @proxy_compute_ms,
6771
- @flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @protocol_version, @created_at
7234
+ @flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @protocol_version, @created_at,
7235
+ @upstream
6772
7236
  )
6773
7237
  `;
6774
7238
  var REQUIRED_AUDIT_COLUMNS = [
@@ -6787,7 +7251,11 @@ var REQUIRED_AUDIT_COLUMNS = [
6787
7251
  "session_source",
6788
7252
  // Same clean break, same unreleased cycle (issue #219): released users see
6789
7253
  // ONE break, at v0.12.0.
6790
- "protocol_version"
7254
+ "protocol_version",
7255
+ // The one ratified exception to the clean break (issue #292): a
7256
+ // v0.12.0-complete database missing ONLY this column is migrated in place
7257
+ // by migrateAuditUpstreamColumn instead of failing the assertion.
7258
+ "upstream"
6791
7259
  ];
6792
7260
  function deserializeRow(row) {
6793
7261
  return {
@@ -6819,6 +7287,7 @@ function deserializeRow(row) {
6819
7287
  origin: row.origin,
6820
7288
  metadata: row.metadata ? JSON.parse(row.metadata) : null,
6821
7289
  protocol_version: row.protocol_version,
7290
+ upstream: row.upstream,
6822
7291
  created_at: row.created_at
6823
7292
  };
6824
7293
  }
@@ -6860,6 +7329,14 @@ function buildWhereClause(filters) {
6860
7329
  conditions.push("session_id = ?");
6861
7330
  params.push(filters.session_id);
6862
7331
  }
7332
+ if (filters.session_source !== void 0) {
7333
+ conditions.push("session_source = ?");
7334
+ params.push(filters.session_source);
7335
+ }
7336
+ if (filters.upstream !== void 0) {
7337
+ conditions.push("upstream = ?");
7338
+ params.push(filters.upstream);
7339
+ }
6863
7340
  if (filters.agent_id !== void 0) {
6864
7341
  conditions.push("agent_id = ?");
6865
7342
  params.push(filters.agent_id);
@@ -6891,6 +7368,22 @@ function buildWhereClause(filters) {
6891
7368
  const clause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
6892
7369
  return { clause, params };
6893
7370
  }
7371
+ function migrateAuditUpstreamColumn(db) {
7372
+ const probe = () => {
7373
+ const rows = db.pragma("table_info(audit_records)");
7374
+ return new Set(rows.map((row) => row.name));
7375
+ };
7376
+ const existing = probe();
7377
+ const missing = REQUIRED_AUDIT_COLUMNS.filter((name) => !existing.has(name));
7378
+ if (missing.length !== 1 || missing[0] !== "upstream") return false;
7379
+ try {
7380
+ db.exec("ALTER TABLE audit_records ADD COLUMN upstream TEXT");
7381
+ } catch (err) {
7382
+ if (probe().has("upstream")) return false;
7383
+ throw err;
7384
+ }
7385
+ return true;
7386
+ }
6894
7387
  function restrictAuditFilePerms(dbPath) {
6895
7388
  if (dbPath === ":memory:" || process.platform === "win32") return;
6896
7389
  try {
@@ -6919,6 +7412,9 @@ var AuditStore = class {
6919
7412
  this.retentionMs = parseDuration(options.retention);
6920
7413
  this.includeResponses = options.includeResponses;
6921
7414
  this.db.exec(CREATE_TABLE_DDL);
7415
+ if (migrateAuditUpstreamColumn(this.db)) {
7416
+ console.error('[helio] Audit DB migrated: added column "upstream"');
7417
+ }
6922
7418
  this.assertRequiredSchema(options.path);
6923
7419
  this.db.exec(CREATE_INDEX_DDL);
6924
7420
  this.insertStmt = this.db.prepare(INSERT_SQL);
@@ -6984,7 +7480,7 @@ var AuditStore = class {
6984
7480
  const missing = REQUIRED_AUDIT_COLUMNS.filter((name) => !existing.has(name));
6985
7481
  if (missing.length === 0) return;
6986
7482
  const quotedColumns = missing.map((name) => `"${name}"`).join(", ");
6987
- throw new Error(
7483
+ throw new StartupError(
6988
7484
  `[helio] Audit DB schema mismatch: missing required columns ${quotedColumns}. This local database was created by an older Helio build. Delete "${dbPath}", "${dbPath}-wal", and "${dbPath}-shm", then restart Helio.`
6989
7485
  );
6990
7486
  }
@@ -7027,6 +7523,7 @@ var AuditStore = class {
7027
7523
  origin: record.origin,
7028
7524
  metadata: record.metadata ? JSON.stringify(record.metadata) : null,
7029
7525
  protocol_version: record.protocol_version,
7526
+ upstream: record.upstream ?? null,
7030
7527
  created_at: now
7031
7528
  });
7032
7529
  return resolvedId;
@@ -7102,9 +7599,14 @@ var AuditStore = class {
7102
7599
  const result = this.db.prepare(`SELECT COUNT(*) as total FROM audit_records ${clause}`).get(...params);
7103
7600
  return result.total;
7104
7601
  }
7105
- /** Get aggregate statistics for a time range. */
7106
- aggregate(from, to) {
7107
- const rangeFilters = { from, to };
7602
+ /**
7603
+ * Get aggregate statistics for a time range. The optional upstream filter
7604
+ * scopes EVERY sub-aggregate (totals, by_decision, by_block_reason,
7605
+ * top_tools, approval_rate, per_hour) — "analytics for this door", not one
7606
+ * filtered chart. Exact match: null-upstream rows never match any value.
7607
+ */
7608
+ aggregate(from, to, filters = {}) {
7609
+ const rangeFilters = { from, to, upstream: filters.upstream };
7108
7610
  const { clause, params } = buildWhereClause(rangeFilters);
7109
7611
  const totals = this.db.prepare(
7110
7612
  `SELECT
@@ -7130,9 +7632,9 @@ var AuditStore = class {
7130
7632
  ).all(...params);
7131
7633
  const toolsClause = clause ? `${clause} AND policy_decision NOT IN ${NON_TOOL_DECISIONS_SQL}` : `WHERE policy_decision NOT IN ${NON_TOOL_DECISIONS_SQL}`;
7132
7634
  const top_tools = this.db.prepare(
7133
- `SELECT tool_name, COUNT(*) as count
7635
+ `SELECT tool_name, upstream, COUNT(*) as count
7134
7636
  FROM audit_records ${toolsClause}
7135
- GROUP BY tool_name
7637
+ GROUP BY tool_name, upstream
7136
7638
  ORDER BY count DESC
7137
7639
  LIMIT 10`
7138
7640
  ).all(...params);
@@ -7296,7 +7798,7 @@ var AuditWriter = class {
7296
7798
  };
7297
7799
 
7298
7800
  // src/audit/header-mismatch.ts
7299
- function buildHeaderMismatchAuditRecord(rejection, environment) {
7801
+ function buildHeaderMismatchAuditRecord(rejection, environment, upstream) {
7300
7802
  return {
7301
7803
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
7302
7804
  session_id: rejection.session?.id ?? null,
@@ -7333,7 +7835,10 @@ function buildHeaderMismatchAuditRecord(rejection, environment) {
7333
7835
  record_kind: "tool_call",
7334
7836
  origin: "mcp",
7335
7837
  metadata: null,
7336
- protocol_version: rejection.protocolVersion ?? null
7838
+ protocol_version: rejection.protocolVersion ?? null,
7839
+ // The door context lives with the caller (the composition root), not
7840
+ // the rejection payload; singular composition passes nothing.
7841
+ upstream: upstream ?? null
7337
7842
  };
7338
7843
  }
7339
7844
 
@@ -7633,20 +8138,10 @@ import { bodyLimit } from "hono/body-limit";
7633
8138
  import { HTTPException } from "hono/http-exception";
7634
8139
  import { z as z5 } from "zod";
7635
8140
 
7636
- // src/auth/bearer.ts
7637
- import { createHash, timingSafeEqual } from "crypto";
7638
- function verifyBearer(authHeader, expected) {
7639
- if (!authHeader || !expected) return false;
7640
- const expectedHeader = `Bearer ${expected}`;
7641
- const actualDigest = createHash("sha256").update(authHeader).digest();
7642
- const expectedDigest = createHash("sha256").update(expectedHeader).digest();
7643
- return timingSafeEqual(actualDigest, expectedDigest);
7644
- }
7645
-
7646
8141
  // src/sideband/governance-api.ts
7647
8142
  import { Hono as Hono4 } from "hono";
7648
8143
  import { z as z4 } from "zod";
7649
- import { createHash as createHash2 } from "crypto";
8144
+ import { createHash as createHash3 } from "crypto";
7650
8145
  var originSchema = z4.string().regex(/^[a-z0-9_-]{1,64}$/, "origin must match ^[a-z0-9_-]{1,64}$").default("sideband");
7651
8146
  var metadataSchema = z4.record(z4.string(), z4.unknown()).nullish();
7652
8147
  var toolDefinitionSchema = z4.object({
@@ -7803,7 +8298,7 @@ function auditPayloadHash(data) {
7803
8298
  actual_amount: data.actual_amount ?? null,
7804
8299
  evidence: canonicalEvidence(data.evidence)
7805
8300
  };
7806
- return createHash2("sha256").update(canonicalize(semantic)).digest("hex");
8301
+ return createHash3("sha256").update(canonicalize(semantic)).digest("hex");
7807
8302
  }
7808
8303
  function canonicalEvidence(evidence) {
7809
8304
  if (!evidence || evidence.length === 0) return null;
@@ -8149,7 +8644,9 @@ var GovernanceService = class {
8149
8644
  toolName,
8150
8645
  toolArguments: req.arguments,
8151
8646
  sessionId: budgetSessionGate.ok ? budgetSessionGate.session : null,
8152
- senderId
8647
+ senderId,
8648
+ upstream: null
8649
+ // a sideband call has no upstream (issue #295)
8153
8650
  });
8154
8651
  const gatedCharges = charges.length > 0 || failures.length > 0 ? gateBudgetCharges({ charges, failures }, budgetSessionGate) : void 0;
8155
8652
  if (gatedCharges && !gatedCharges.ok) {
@@ -8871,11 +9368,12 @@ var GovernanceService = class {
8871
9368
  };
8872
9369
  }
8873
9370
  planRate(decision, toolName, sessionId, senderId) {
8874
- const limits = decision.matchedRule?.limits;
8875
- if (!this.rateLimiter || !limits?.maxCalls || !limits.windowMs) {
9371
+ const matchedRule = decision.matchedRule;
9372
+ const limits = matchedRule?.limits;
9373
+ if (!this.rateLimiter || !matchedRule || !limits?.maxCalls || !limits.windowMs) {
8876
9374
  return { allowed: true };
8877
9375
  }
8878
- let key;
9376
+ let baseKey;
8879
9377
  if (limits.key === "session") {
8880
9378
  const gate = gateSession(sessionId, this.session.onUnresolved);
8881
9379
  if (!gate.ok) {
@@ -8883,10 +9381,11 @@ var GovernanceService = class {
8883
9381
  return { allowed: false, sessionUnresolved: true };
8884
9382
  }
8885
9383
  if (gate.anonymous) warnAnonymousPoolingOnce();
8886
- key = sessionLimitKey(gate.session);
9384
+ baseKey = sessionLimitKey(gate.session);
8887
9385
  } else {
8888
- key = buildLimitKey(limits.key, toolName, senderId);
9386
+ baseKey = buildLimitKey(limits.key, toolName, senderId);
8889
9387
  }
9388
+ const key = ruleBucketKey(baseKey, matchedRule.index);
8890
9389
  const peek = this.rateLimiter.peek({
8891
9390
  key,
8892
9391
  maxCalls: limits.maxCalls,
@@ -8918,7 +9417,7 @@ var GovernanceService = class {
8918
9417
  } else {
8919
9418
  baseKey = buildLimitKey(maxSpend.key, toolName, senderId);
8920
9419
  }
8921
- const key = spendBucketKey(baseKey, decision.matchedRule.index);
9420
+ const key = ruleBucketKey(baseKey, decision.matchedRule.index);
8922
9421
  const rawAmount = resolvePath(maxSpend.field, args ?? {});
8923
9422
  if (typeof rawAmount !== "number" || !Number.isFinite(rawAmount) || rawAmount < 0) {
8924
9423
  return { allowed: false, block: { reason: "invalid_amount", limit: maxSpend.limit } };
@@ -9077,7 +9576,9 @@ var GovernanceService = class {
9077
9576
  origin: args.origin,
9078
9577
  metadata: args.metadata,
9079
9578
  // The sideband has no MCP wire, so no protocol claim exists.
9080
- protocol_version: null
9579
+ protocol_version: null,
9580
+ // No door on the sideband either: upstream attribution is MCP-only.
9581
+ upstream: null
9081
9582
  };
9082
9583
  const isEnforcement = args.recordKind === "evaluation_expired" || blockReason !== null || args.approvalStatus != null;
9083
9584
  if (isEnforcement) this.auditWriter.pushImmediate(record, id);
@@ -9142,7 +9643,7 @@ function buildLimitKey(keyType, toolName, senderId) {
9142
9643
  case "agent":
9143
9644
  case "tool":
9144
9645
  default:
9145
- return `tool:${toolName}`;
9646
+ return toolLimitKey(toolName);
9146
9647
  }
9147
9648
  }
9148
9649
  function senderIdOf(metadata) {
@@ -9270,6 +9771,9 @@ var ApprovalQueue = class {
9270
9771
  rule_index: params.rule_index,
9271
9772
  channel_name: params.channel_name,
9272
9773
  session_id: params.session_id,
9774
+ // Wire-darkness spelling: set only when attributed, never null.
9775
+ ...params.session_source != null && { session_source: params.session_source },
9776
+ ...params.upstream != null && { upstream: params.upstream },
9273
9777
  requested_at: new Date(now).toISOString(),
9274
9778
  timeout_at: new Date(now + params.timeout_ms).toISOString(),
9275
9779
  timeout_ms: params.timeout_ms,
@@ -9389,6 +9893,8 @@ var ApprovalRouter = class {
9389
9893
  rule_index: rule?.index ?? null,
9390
9894
  channel_name: channelName,
9391
9895
  session_id: params.session_id,
9896
+ session_source: params.session_source,
9897
+ upstream: params.upstream,
9392
9898
  timeout_ms: timeoutMs,
9393
9899
  breached_budgets: params.breached_budgets
9394
9900
  });
@@ -9485,6 +9991,10 @@ var ApprovalRouter = class {
9485
9991
  rule_index: rule?.index ?? null,
9486
9992
  channel_name: `${NATIVE_CHANNEL_PREFIX}${params.origin}`,
9487
9993
  session_id: params.session_id,
9994
+ // Adapter-supplied ids are sideband-attributed by definition (issue
9995
+ // #251); deriving it at this single choke point means no future
9996
+ // adapter can forget it. Upstream stays absent: no MCP door here.
9997
+ session_source: params.session_id != null ? "sideband" : null,
9488
9998
  timeout_ms: timeoutMs,
9489
9999
  breached_budgets: params.breached_budgets
9490
10000
  });
@@ -9654,15 +10164,22 @@ function buildApprovalBlocks(ticket) {
9654
10164
  const safeName = sanitizeCodeSpanContent(ticket.tool_name);
9655
10165
  const rawInput = truncate(JSON.stringify(ticket.tool_input), MAX_INPUT_LENGTH);
9656
10166
  const safeInput = sanitizeForCodeBlock(rawInput);
9657
- const detailLines = [`*Tool:* \`${safeName}\``, `*Input:*
10167
+ const detailLines = [`*Tool:* \`${safeName}\``];
10168
+ if (ticket.upstream) {
10169
+ detailLines.push(`*Upstream:* \`${sanitizeCodeSpanContent(ticket.upstream)}\``);
10170
+ }
10171
+ detailLines.push(`*Input:*
9658
10172
  \`\`\`
9659
10173
  ${safeInput}
9660
- \`\`\``];
10174
+ \`\`\``);
9661
10175
  if (ticket.matched_rule) {
9662
10176
  detailLines.push(`*Rule:* ${sanitizeMrkdwnText(ticket.matched_rule)}`);
9663
10177
  }
9664
10178
  if (ticket.session_id) {
9665
- detailLines.push(`*Session:* \`${sanitizeCodeSpanContent(ticket.session_id)}\``);
10179
+ const sessionLine = `*Session:* \`${sanitizeCodeSpanContent(ticket.session_id)}\``;
10180
+ detailLines.push(
10181
+ ticket.session_source ? `${sessionLine} (${sanitizeMrkdwnText(ticket.session_source)})` : sessionLine
10182
+ );
9666
10183
  }
9667
10184
  const budgetBlocks = buildBudgetBlocks(ticket);
9668
10185
  return [
@@ -10298,14 +10815,18 @@ var BudgetEngine = class {
10298
10815
  /**
10299
10816
  * Resolve which budgets a call feeds and how much it charges each.
10300
10817
  *
10301
- * A contributor participates when its tool glob matches the tool name AND
10302
- * every `match.input` condition holds (absent conditions means the glob
10303
- * alone decides); the FIRST participating contributor (config order, over
10304
- * that combined predicate) supplies the amount field. A call that matches
10305
- * the glob but not the conditions simply does not feed the budget — no
10306
- * charge, no failure. Once a contributor is selected, a missing,
10307
- * non-numeric, negative, or non-finite amount fails closed as a `failures`
10308
- * entry the caller must deny the call.
10818
+ * A contributor participates when its upstream scope admits the call's
10819
+ * door (absent scope admits every door; a scoped contributor never
10820
+ * participates when `ctx.upstream` is null sideband, singular mode) AND
10821
+ * its tool glob matches the tool name AND every `match.input` condition
10822
+ * holds (absent conditions means the glob alone decides); the FIRST
10823
+ * participating contributor (config order, over that combined predicate)
10824
+ * supplies the amount field. A call that matches the glob but not the
10825
+ * conditions or the scope simply does not feed the budget — no charge, no
10826
+ * failure — and a later contributor may still participate. Once a
10827
+ * contributor is selected, a missing, non-numeric, negative, or non-finite
10828
+ * amount fails closed as a `failures` entry — the caller must deny the
10829
+ * call.
10309
10830
  */
10310
10831
  resolveCharges(ctx) {
10311
10832
  const charges = [];
@@ -10316,7 +10837,7 @@ var BudgetEngine = class {
10316
10837
  };
10317
10838
  for (const budget of this.budgets.values()) {
10318
10839
  const contributor = budget.contributors.find(
10319
- (c) => c.match.tool.test(ctx.toolName) && (c.match.input === void 0 || matchInput(c.match.input, matchCtx))
10840
+ (c) => (c.upstreams === void 0 || ctx.upstream !== null && c.upstreams.includes(ctx.upstream)) && c.match.tool.test(ctx.toolName) && (c.match.input === void 0 || matchInput(c.match.input, matchCtx))
10320
10841
  );
10321
10842
  if (!contributor) continue;
10322
10843
  const raw = resolvePath(contributor.field, ctx.toolArguments ?? {});
@@ -10341,7 +10862,8 @@ var BudgetEngine = class {
10341
10862
  budget,
10342
10863
  bucketKey: this.bucketKey(budget, ctx),
10343
10864
  amount: raw,
10344
- generation: this.generations.get(budget.name) ?? 0
10865
+ generation: this.generations.get(budget.name) ?? 0,
10866
+ ...ctx.upstream !== null && { upstream: ctx.upstream }
10345
10867
  });
10346
10868
  }
10347
10869
  return { charges, failures };
@@ -10421,7 +10943,8 @@ var BudgetEngine = class {
10421
10943
  remaining: snapshot.remaining,
10422
10944
  limit: charge.budget.limit,
10423
10945
  currency: charge.budget.currency,
10424
- utilization: snapshot.spent / charge.budget.limit
10946
+ utilization: snapshot.spent / charge.budget.limit,
10947
+ upstream: charge.upstream ?? null
10425
10948
  });
10426
10949
  } catch (err) {
10427
10950
  console.error("[helio] budget onCommit subscriber threw:", err);
@@ -10447,7 +10970,8 @@ var BudgetEngine = class {
10447
10970
  attempted_amount: entry.amount,
10448
10971
  spent: entry.spent,
10449
10972
  limit: entry.budget.limit,
10450
- currency: entry.budget.currency
10973
+ currency: entry.budget.currency,
10974
+ upstream: entry.upstream
10451
10975
  });
10452
10976
  } catch (err) {
10453
10977
  console.error("[helio] budget onBreach subscriber threw:", err);
@@ -10757,7 +11281,8 @@ var BudgetEngine = class {
10757
11281
  allowed: checkedAgainst + charge.amount <= charge.budget.limit,
10758
11282
  spent,
10759
11283
  remaining: Math.max(0, charge.budget.limit - spent),
10760
- resetAtMs
11284
+ resetAtMs,
11285
+ upstream: charge.upstream ?? null
10761
11286
  };
10762
11287
  }
10763
11288
  };
@@ -10866,11 +11391,16 @@ GROUP BY e.bucket_key
10866
11391
  ORDER BY e.bucket_key ASC
10867
11392
  `;
10868
11393
  var LIST_EVENTS_SQL = `
10869
- SELECT id, budget_name, bucket_key, kind, amount, currency, tool_name,
10870
- origin, audit_record_id, timestamp, timestamp_ms, created_at
10871
- FROM budget_events
10872
- WHERE budget_name = ?
10873
- ORDER BY timestamp_ms DESC, rowid DESC
11394
+ SELECT e.id AS id, e.budget_name AS budget_name, e.bucket_key AS bucket_key,
11395
+ e.kind AS kind, e.amount AS amount, e.currency AS currency,
11396
+ e.tool_name AS tool_name, e.origin AS origin,
11397
+ e.audit_record_id AS audit_record_id, e.timestamp AS timestamp,
11398
+ e.timestamp_ms AS timestamp_ms, e.created_at AS created_at,
11399
+ a.upstream AS upstream
11400
+ FROM budget_events e
11401
+ LEFT JOIN audit_records a ON a.id = e.audit_record_id
11402
+ WHERE e.budget_name = ?
11403
+ ORDER BY e.timestamp_ms DESC, e.rowid DESC
10874
11404
  LIMIT ? OFFSET ?
10875
11405
  `;
10876
11406
  var COUNT_EVENTS_SQL = "SELECT COUNT(*) AS total FROM budget_events WHERE budget_name = ?";
@@ -11121,10 +11651,11 @@ var CSV_HEADERS = [
11121
11651
  "record_kind",
11122
11652
  "origin",
11123
11653
  "metadata",
11124
- // Appended LAST (issues #218, #219): positional consumers of the existing
11125
- // columns keep working — new columns always go at the end.
11654
+ // Appended LAST (issues #218, #219, #292): positional consumers of the
11655
+ // existing columns keep working — new columns always go at the end.
11126
11656
  "session_source",
11127
- "protocol_version"
11657
+ "protocol_version",
11658
+ "upstream"
11128
11659
  ];
11129
11660
  var FORMULA_PREFIXES = /^[=+\-@\t\r]/;
11130
11661
  function csvEscape(value) {
@@ -11167,7 +11698,10 @@ var BUDGET_EVENT_CSV_HEADERS = [
11167
11698
  "audit_record_id",
11168
11699
  "timestamp",
11169
11700
  "timestamp_ms",
11170
- "created_at"
11701
+ "created_at",
11702
+ // Appended LAST (issue #292): positional consumers of the existing
11703
+ // columns keep working — new columns always go at the end.
11704
+ "upstream"
11171
11705
  ];
11172
11706
  function eventToRow(event) {
11173
11707
  return BUDGET_EVENT_CSV_HEADERS.map((h) => {
@@ -11189,7 +11723,7 @@ function budgetEventsToCsv(events) {
11189
11723
  // src/dashboard/api.ts
11190
11724
  import { readFileSync } from "fs";
11191
11725
  import { join } from "path";
11192
- import { randomUUID as randomUUID8 } from "crypto";
11726
+ import { randomBytes as randomBytes2, randomUUID as randomUUID8 } from "crypto";
11193
11727
  import { Hono as Hono8 } from "hono";
11194
11728
  import { HTTPException as HTTPException2 } from "hono/http-exception";
11195
11729
  import { z as z8 } from "zod";
@@ -11198,16 +11732,16 @@ import { serveStatic } from "@hono/node-server/serve-static";
11198
11732
  import { streamSSE } from "hono/streaming";
11199
11733
 
11200
11734
  // src/dashboard/session.ts
11201
- import { createHash as createHash3, createHmac as createHmac3, randomBytes, timingSafeEqual as timingSafeEqual3 } from "crypto";
11735
+ import { createHash as createHash4, createHmac as createHmac3, randomBytes, timingSafeEqual as timingSafeEqual3 } from "crypto";
11202
11736
  var DashboardSessionStore = class {
11203
- secret;
11737
+ signingKey;
11204
11738
  ttlMs;
11205
11739
  now;
11206
11740
  records = /* @__PURE__ */ new Map();
11207
11741
  timer = null;
11208
11742
  closed = false;
11209
11743
  constructor(options) {
11210
- this.secret = options.secret;
11744
+ this.signingKey = options.signingKey;
11211
11745
  this.ttlMs = options.ttlMs ?? 8 * 60 * 60 * 1e3;
11212
11746
  this.now = options.now ?? Date.now;
11213
11747
  const cleanupIntervalMs = options.cleanupIntervalMs ?? 6e4;
@@ -11279,13 +11813,13 @@ var DashboardSessionStore = class {
11279
11813
  const id = token.slice(0, dot);
11280
11814
  const signature = token.slice(dot + 1);
11281
11815
  const expected = this.sign(id);
11282
- const actualDigest = createHash3("sha256").update(signature).digest();
11283
- const expectedDigest = createHash3("sha256").update(expected).digest();
11816
+ const actualDigest = createHash4("sha256").update(signature).digest();
11817
+ const expectedDigest = createHash4("sha256").update(expected).digest();
11284
11818
  if (!timingSafeEqual3(actualDigest, expectedDigest)) return void 0;
11285
11819
  return id;
11286
11820
  }
11287
11821
  sign(id) {
11288
- return createHmac3("sha256", this.secret).update(id).digest("base64url");
11822
+ return createHmac3("sha256", this.signingKey).update(id).digest("base64url");
11289
11823
  }
11290
11824
  };
11291
11825
 
@@ -11309,7 +11843,13 @@ var clampedQueryInt = (fallback, min, max) => z8.preprocess(
11309
11843
  );
11310
11844
  var feedQuerySchema = z8.object({
11311
11845
  limit: clampedQueryInt(50, 1, 200),
11312
- offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER)
11846
+ offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER),
11847
+ // The feed's server-side filters (issues #292, #316): attribution and
11848
+ // session identity source must narrow the fetch window itself, because
11849
+ // slicing an unfiltered newest-N window client-side would miss rare
11850
+ // matches on a busy stream.
11851
+ upstream: optionalQueryString,
11852
+ session_source: optionalQueryString
11313
11853
  });
11314
11854
  var auditExportQuerySchema = z8.object({
11315
11855
  format: z8.preprocess((value) => value === "csv" ? "csv" : "json", z8.enum(["json", "csv"])),
@@ -11328,7 +11868,9 @@ var auditExportQuerySchema = z8.object({
11328
11868
  origin: optionalQueryString,
11329
11869
  record_kind: optionalQueryString,
11330
11870
  channel_id: optionalQueryString,
11331
- sender_id: optionalQueryString
11871
+ sender_id: optionalQueryString,
11872
+ upstream: optionalQueryString,
11873
+ session_source: optionalQueryString
11332
11874
  });
11333
11875
  var auditQuerySchema = z8.object({
11334
11876
  limit: clampedQueryInt(50, 1, LIST_MAX_PAGE_SIZE),
@@ -11348,7 +11890,9 @@ var auditQuerySchema = z8.object({
11348
11890
  origin: optionalQueryString,
11349
11891
  record_kind: optionalQueryString,
11350
11892
  channel_id: optionalQueryString,
11351
- sender_id: optionalQueryString
11893
+ sender_id: optionalQueryString,
11894
+ upstream: optionalQueryString,
11895
+ session_source: optionalQueryString
11352
11896
  });
11353
11897
  var budgetEventsQuerySchema = z8.object({
11354
11898
  limit: clampedQueryInt(50, 1, LIST_MAX_PAGE_SIZE),
@@ -11360,7 +11904,8 @@ var budgetEventsExportQuerySchema = z8.object({
11360
11904
  });
11361
11905
  var analyticsQuerySchema = z8.object({
11362
11906
  from: optionalQueryString,
11363
- to: optionalQueryString
11907
+ to: optionalQueryString,
11908
+ upstream: optionalQueryString
11364
11909
  });
11365
11910
  var authSessionBodySchema = z8.object({
11366
11911
  secret: z8.string()
@@ -11421,6 +11966,8 @@ function isPrivateIpv4(host) {
11421
11966
  if (a > 255 || b > 255 || c > 255 || d > 255) return false;
11422
11967
  return a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168;
11423
11968
  }
11969
+ var MAX_SSE_CONNECTIONS = 256;
11970
+ var REFUSAL_LOG_WINDOW_MS2 = 1e4;
11424
11971
  function createDashboardAppWithLifecycle(deps, options) {
11425
11972
  const {
11426
11973
  auditStore,
@@ -11434,7 +11981,10 @@ function createDashboardAppWithLifecycle(deps, options) {
11434
11981
  budgets
11435
11982
  } = deps;
11436
11983
  const apiSecret = options?.apiSecret;
11437
- const sessionStore = apiSecret ? new DashboardSessionStore({ secret: apiSecret, ttlMs: SESSION_TTL_MS }) : void 0;
11984
+ const sessionStore = apiSecret ? new DashboardSessionStore({
11985
+ signingKey: randomBytes2(32).toString("hex"),
11986
+ ttlMs: SESSION_TTL_MS
11987
+ }) : void 0;
11438
11988
  const app = new Hono8();
11439
11989
  app.onError((err, c) => {
11440
11990
  if (err instanceof HTTPException2) return err.getResponse();
@@ -11557,7 +12107,10 @@ function createDashboardAppWithLifecycle(deps, options) {
11557
12107
  const query = feedQuerySchema.parse(c.req.query());
11558
12108
  const limit = query.limit;
11559
12109
  const offset = query.offset;
11560
- const result = auditStore.list({}, { limit, offset, order: "desc" });
12110
+ const result = auditStore.list(
12111
+ { upstream: query.upstream, session_source: query.session_source },
12112
+ { limit, offset, order: "desc" }
12113
+ );
11561
12114
  return c.json({
11562
12115
  data: result.records,
11563
12116
  total: result.total,
@@ -11584,7 +12137,9 @@ function createDashboardAppWithLifecycle(deps, options) {
11584
12137
  origin: query.origin,
11585
12138
  record_kind: query.record_kind,
11586
12139
  channel_id: query.channel_id,
11587
- sender_id: query.sender_id
12140
+ sender_id: query.sender_id,
12141
+ upstream: query.upstream,
12142
+ session_source: query.session_source
11588
12143
  };
11589
12144
  const result = auditStore.listForExport(filters, limit);
11590
12145
  if (format === "csv") {
@@ -11630,7 +12185,9 @@ function createDashboardAppWithLifecycle(deps, options) {
11630
12185
  origin: query.origin,
11631
12186
  record_kind: query.record_kind,
11632
12187
  channel_id: query.channel_id,
11633
- sender_id: query.sender_id
12188
+ sender_id: query.sender_id,
12189
+ upstream: query.upstream,
12190
+ session_source: query.session_source
11634
12191
  };
11635
12192
  const result = auditStore.list(filters, { limit, offset, order: "desc" });
11636
12193
  return c.json({
@@ -11696,7 +12253,7 @@ function createDashboardAppWithLifecycle(deps, options) {
11696
12253
  const defaultFrom = new Date(now.getTime() - 24 * 60 * 60 * 1e3).toISOString();
11697
12254
  const from = query.from ?? defaultFrom;
11698
12255
  const to = query.to ?? now.toISOString();
11699
- const stats = auditStore.aggregate(from, to);
12256
+ const stats = auditStore.aggregate(from, to, { upstream: query.upstream });
11700
12257
  return c.json(stats);
11701
12258
  });
11702
12259
  app.get("/api/evidence/:session_id", (c) => {
@@ -11708,17 +12265,22 @@ function createDashboardAppWithLifecycle(deps, options) {
11708
12265
  let closed = false;
11709
12266
  const heartbeatMs = Math.max(options?.sseHeartbeatMs ?? 3e4, 1e3);
11710
12267
  const staleThresholdMs = heartbeatMs * 3;
11711
- const sweepIntervalMs = Math.max(heartbeatMs * 2, 1e4);
11712
- const sweepInterval = setInterval(() => {
11713
- const now = Date.now();
11714
- for (const [id, conn] of activeConnections) {
11715
- if (now - conn.lastWrite > staleThresholdMs) {
11716
- conn.cleanup();
11717
- activeConnections.delete(id);
12268
+ const sweepIntervalMs = options?.sweepIntervalMs ?? Math.max(heartbeatMs * 2, 1e4);
12269
+ const maxSseConnections = options?.maxSseConnections ?? MAX_SSE_CONNECTIONS;
12270
+ let sweepInterval;
12271
+ if (sweepIntervalMs > 0) {
12272
+ sweepInterval = setInterval(() => {
12273
+ const now = Date.now();
12274
+ for (const [id, conn] of activeConnections) {
12275
+ if (now - conn.lastWrite > staleThresholdMs) {
12276
+ conn.cleanup();
12277
+ conn.sever();
12278
+ activeConnections.delete(id);
12279
+ }
11718
12280
  }
11719
- }
11720
- }, sweepIntervalMs);
11721
- sweepInterval.unref();
12281
+ }, sweepIntervalMs);
12282
+ sweepInterval.unref();
12283
+ }
11722
12284
  const close = () => {
11723
12285
  if (closed) return;
11724
12286
  closed = true;
@@ -11728,7 +12290,22 @@ function createDashboardAppWithLifecycle(deps, options) {
11728
12290
  }
11729
12291
  activeConnections.clear();
11730
12292
  };
12293
+ let refusalCount = 0;
12294
+ let lastRefusalLogAt = null;
12295
+ const logRefusal = () => {
12296
+ refusalCount += 1;
12297
+ const now = Date.now();
12298
+ if (lastRefusalLogAt !== null && now - lastRefusalLogAt < REFUSAL_LOG_WINDOW_MS2) return;
12299
+ lastRefusalLogAt = now;
12300
+ console.error(
12301
+ `[helio] /api/events at connection cap (${String(maxSseConnections)}); refusing new streams (${String(refusalCount)} refusals so far).`
12302
+ );
12303
+ };
11731
12304
  app.get("/api/events", (c) => {
12305
+ if (activeConnections.size >= maxSseConnections) {
12306
+ logRefusal();
12307
+ return c.json({ error: "connection capacity reached" }, 503);
12308
+ }
11732
12309
  return streamSSE(c, async (stream) => {
11733
12310
  if (closed) return;
11734
12311
  const connId = randomUUID8();
@@ -11749,7 +12326,12 @@ function createDashboardAppWithLifecycle(deps, options) {
11749
12326
  activeConnections.delete(connId);
11750
12327
  releaseStream();
11751
12328
  };
11752
- activeConnections.set(connId, { cleanup, lastWrite: Date.now() });
12329
+ const sever = () => {
12330
+ stream.abort();
12331
+ c.env?.outgoing?.destroy();
12332
+ };
12333
+ if (activeConnections.size >= maxSseConnections) return;
12334
+ activeConnections.set(connId, { cleanup, sever, lastWrite: Date.now() });
11753
12335
  try {
11754
12336
  await stream.writeSSE({ data: "", event: "heartbeat" });
11755
12337
  } catch {
@@ -11852,6 +12434,69 @@ var DashboardEventBus = class {
11852
12434
  this.emitter.removeAllListeners();
11853
12435
  }
11854
12436
  };
12437
+ function actionEventFromRecord(record, id) {
12438
+ return {
12439
+ id,
12440
+ tool_name: record.tool_name,
12441
+ policy_decision: record.policy_decision,
12442
+ block_reason: record.block_reason,
12443
+ approval_status: record.approval_status,
12444
+ session_id: record.session_id,
12445
+ session_source: record.session_source,
12446
+ protocol_version: record.protocol_version,
12447
+ agent_id: record.agent_id,
12448
+ environment: record.environment,
12449
+ timestamp: record.timestamp,
12450
+ total_duration_ms: record.total_duration_ms,
12451
+ approval_wait_ms: record.approval_wait_ms,
12452
+ proxy_compute_ms: record.proxy_compute_ms,
12453
+ flagged_destructive: record.flagged_destructive,
12454
+ dry_run: record.dry_run,
12455
+ matched_rule: record.matched_rule,
12456
+ matched_rule_index: record.matched_rule_index,
12457
+ record_kind: record.record_kind,
12458
+ origin: record.origin,
12459
+ upstream: record.upstream
12460
+ };
12461
+ }
12462
+ function approvalRequestedEvent(ticket) {
12463
+ return {
12464
+ ticket_id: ticket.id,
12465
+ tool_name: ticket.tool_name,
12466
+ channel: ticket.channel_name,
12467
+ requested_at: ticket.requested_at,
12468
+ upstream: ticket.upstream ?? null
12469
+ };
12470
+ }
12471
+ function limitWarningEvent(type, key, current, limit) {
12472
+ return {
12473
+ key,
12474
+ type,
12475
+ current,
12476
+ limit,
12477
+ utilization: current / limit,
12478
+ upstream: upstreamFromLimitKey(key)
12479
+ };
12480
+ }
12481
+ function dashboardEventCallbacks(bus) {
12482
+ return {
12483
+ onPersist: (record, id) => {
12484
+ bus.emit("action", actionEventFromRecord(record, id));
12485
+ },
12486
+ onApprovalSubmit: (ticket) => {
12487
+ bus.emit("approval_requested", approvalRequestedEvent(ticket));
12488
+ },
12489
+ onRateWarning: (state) => {
12490
+ bus.emit("limit_warning", limitWarningEvent("rate", state.key, state.current, state.limit));
12491
+ },
12492
+ onSpendWarning: (state) => {
12493
+ bus.emit(
12494
+ "limit_warning",
12495
+ limitWarningEvent("spend", state.key, state.current_spend, state.limit)
12496
+ );
12497
+ }
12498
+ };
12499
+ }
11855
12500
 
11856
12501
  // src/startup-warnings.ts
11857
12502
  function isLoopbackHost2(host) {
@@ -11871,6 +12516,32 @@ function warnIfBudgetWindowExceedsRetention(config, log = console.error) {
11871
12516
  }
11872
12517
  return warned;
11873
12518
  }
12519
+ function warnIfManyUpstreams(config, log = console.error) {
12520
+ const count = config.upstreams.length;
12521
+ if (count <= 16) return false;
12522
+ log(
12523
+ `[helio] Warning: ${String(count)} upstreams configured. Each upstream runs its own upstream connection or child process plus an annotation prime loop; consider whether one proxy should govern this many.`
12524
+ );
12525
+ return true;
12526
+ }
12527
+ function warnIfStdioUrlIgnored(config, log = console.error) {
12528
+ let warned = false;
12529
+ const warn = (path) => {
12530
+ log(
12531
+ `[helio] Warning: ${path} is ignored when transport is "stdio" (the stdio forwarder spawns "command"). Remove the field to silence this warning.`
12532
+ );
12533
+ warned = true;
12534
+ };
12535
+ if (config.upstream?.transport === "stdio" && config.upstream.url !== void 0) {
12536
+ warn("upstream.url");
12537
+ }
12538
+ config.upstreams?.forEach((entry, index) => {
12539
+ if (entry.transport === "stdio" && entry.url !== void 0) {
12540
+ warn(`upstreams.${String(index)}.url`);
12541
+ }
12542
+ });
12543
+ return warned;
12544
+ }
11874
12545
  function warnIfWebhookChannelUnreachable(config, log = console.error) {
11875
12546
  const hasWebhookChannel = config.approval.channels.some((ch) => ch.type === "webhook");
11876
12547
  const localOnlyDashboard = config.dashboard.enabled && isLoopbackHost2(config.dashboard.host);
@@ -11896,6 +12567,17 @@ function warnIfDashboardOpenMode(config, log = console.error) {
11896
12567
  );
11897
12568
  return true;
11898
12569
  }
12570
+ function warnIfDashboardSecretLiteral(config, source, log = console.error) {
12571
+ const secret = config.dashboard.api_secret;
12572
+ if (!config.dashboard.enabled) return false;
12573
+ if (typeof secret !== "string" || secret.length === 0) return false;
12574
+ if (isSecretDigest(secret)) return false;
12575
+ if (source.interpolatedPaths.includes("dashboard.api_secret")) return false;
12576
+ log(
12577
+ `[helio] Warning: dashboard.api_secret is stored as plaintext in ${source.configPath}. Anyone who can read this file holds the operator credential and can approve tickets. Run \`helio secret\`, store the printed digest as dashboard.api_secret, and restart.`
12578
+ );
12579
+ return true;
12580
+ }
11899
12581
  function warnIfNoEnforcement(policy, log = console.error) {
11900
12582
  if (policy.rules.length > 0 || policy.defaultAction !== "allow" || policy.dryRun) return false;
11901
12583
  log(
@@ -11906,7 +12588,7 @@ function warnIfNoEnforcement(policy, log = console.error) {
11906
12588
 
11907
12589
  // src/shutdown.ts
11908
12590
  async function closeResources(resources) {
11909
- resources.annotationPrime?.stop();
12591
+ for (const prime of resources.annotationPrimes ?? []) prime.stop();
11910
12592
  resources.configWatcher?.close();
11911
12593
  resources.approvalRouter?.close();
11912
12594
  resources.approvalQueue?.close();
@@ -11921,7 +12603,7 @@ async function closeResources(resources) {
11921
12603
  resources.budgetEngine?.close();
11922
12604
  resources.evidenceStore?.close();
11923
12605
  resources.auditWriter?.close();
11924
- if (resources.closeForwarder) await resources.closeForwarder();
12606
+ for (const close of resources.closeForwarders ?? []) await close();
11925
12607
  }
11926
12608
 
11927
12609
  // src/crash-drain.ts
@@ -11981,7 +12663,7 @@ function getBundledDashboardDistPath() {
11981
12663
  const assetsSubdirPath = resolve(assetsDir, "assets");
11982
12664
  return existsSync(indexPath) && existsSync(assetsSubdirPath) ? assetsDir : null;
11983
12665
  }
11984
- function renderConfigTemplate(apiSecret) {
12666
+ function renderConfigTemplate(apiSecretDigest) {
11985
12667
  return `# Helio MCP Governance Proxy configuration
11986
12668
  # Docs: https://github.com/gethelio/helio
11987
12669
 
@@ -11995,6 +12677,12 @@ upstream:
11995
12677
  # headers:
11996
12678
  # Authorization: "Bearer \${UPSTREAM_TOKEN}"
11997
12679
 
12680
+ # Multiple named upstreams (multi-upstream mode) replace \`upstream:\` \u2014
12681
+ # set exactly one of the two. See docs/configuration.md.
12682
+ # upstreams:
12683
+ # - name: files
12684
+ # url: "http://localhost:8081/mcp"
12685
+
11998
12686
  # listen:
11999
12687
  # port: 3000
12000
12688
  # host: 127.0.0.1
@@ -12040,17 +12728,19 @@ upstream:
12040
12728
  # retention: 90d
12041
12729
  # include_responses: true
12042
12730
 
12043
- # Operator dashboard + approval REST API. Bound to 127.0.0.1 by default \u2014 do
12731
+ # Operator dashboard + approval REST API. Bound to 127.0.0.1 by default. Do
12044
12732
  # not change to 0.0.0.0 without putting an authenticating reverse proxy in
12045
- # front. dashboard.api_secret is the manual dashboard login secret and also
12046
- # supports machine Bearer auth for sideband API clients. Store it safely; it
12047
- # stays valid until you rotate it. Rotate by editing this file and restarting
12048
- # the proxy. Rotation invalidates active dashboard sessions.
12733
+ # front. dashboard.api_secret holds the SHA-256 digest of the dashboard
12734
+ # secret, never the secret itself: log in to the dashboard and authenticate
12735
+ # sideband API clients with the secret that \`helio init\` printed once. To
12736
+ # rotate, run \`helio secret\`, paste the new digest here, and restart the
12737
+ # proxy; active dashboard sessions are invalidated. A plaintext value is
12738
+ # still accepted.
12049
12739
  dashboard:
12050
12740
  enabled: true
12051
12741
  port: 3100
12052
12742
  host: 127.0.0.1
12053
- api_secret: "${apiSecret}"
12743
+ api_secret: "${apiSecretDigest}"
12054
12744
 
12055
12745
  # sdk:
12056
12746
  # enabled: false
@@ -12067,10 +12757,35 @@ function printConfigErrorDetails(error, prefix = "") {
12067
12757
  console.error(`${prefix} ${detail.path}: ${detail.message}`);
12068
12758
  }
12069
12759
  }
12760
+ async function connectUpstream(upstream, upstreamName) {
12761
+ try {
12762
+ return await createForwarderFromConfig({ upstream }, upstreamName);
12763
+ } catch (err) {
12764
+ const message = err instanceof Error ? err.message : String(err);
12765
+ throw new StartupError(
12766
+ upstreamName === void 0 ? message : `upstream "${upstreamName}": ${message}`
12767
+ );
12768
+ }
12769
+ }
12770
+ async function governUpstream(options) {
12771
+ const governedForwarder = new GovernedForwarder(options.forwarder, options.policy, {
12772
+ ...options.governance,
12773
+ upstreamName: options.upstreamName
12774
+ });
12775
+ const annotationPrime = await startAnnotationPrimeLoop(
12776
+ governedForwarder,
12777
+ options.policy.toolRevalidation,
12778
+ options.upstreamName
12779
+ );
12780
+ return { governedForwarder, annotationPrime };
12781
+ }
12070
12782
  async function startCommand(configPath, options) {
12071
12783
  let config;
12784
+ let interpolatedPaths = [];
12072
12785
  try {
12073
- config = await loadConfig(configPath);
12786
+ const loaded = await loadConfigWithMeta(configPath);
12787
+ config = loaded.config;
12788
+ interpolatedPaths = loaded.interpolatedPaths;
12074
12789
  } catch (err) {
12075
12790
  if (err instanceof ConfigError) {
12076
12791
  console.error(`Error: ${err.message}`);
@@ -12086,7 +12801,29 @@ async function startCommand(configPath, options) {
12086
12801
  );
12087
12802
  process.exit(1);
12088
12803
  }
12089
- const { forwarder, close: closeForwarder } = await createForwarderFromConfig(config);
12804
+ if (isNamedConfig(config)) {
12805
+ warnIfManyUpstreams(config);
12806
+ }
12807
+ warnIfStdioUrlIgnored(config);
12808
+ const upstreamSections = isNamedConfig(config) ? config.upstreams.map((entry) => ({ name: entry.name, upstream: entry })) : [{ name: void 0, upstream: config.upstream }];
12809
+ const doors = [];
12810
+ for (const { name, upstream } of upstreamSections) {
12811
+ try {
12812
+ const built = await connectUpstream(upstream, name);
12813
+ doors.push({ name, forwarder: built.forwarder, close: built.close });
12814
+ } catch (err) {
12815
+ for (const door of doors) {
12816
+ try {
12817
+ await door.close?.();
12818
+ } catch (closeErr) {
12819
+ console.error(
12820
+ `[helio] Ignoring a forwarder close failure while aborting startup: ${String(closeErr)}`
12821
+ );
12822
+ }
12823
+ }
12824
+ throw err;
12825
+ }
12826
+ }
12090
12827
  const { policy, warnings } = compilePolicies(config.policies);
12091
12828
  for (const w of warnings) {
12092
12829
  const label = w.ruleName ? `rule "${w.ruleName}"` : `rule ${String(w.ruleIndex)}`;
@@ -12094,6 +12831,7 @@ async function startCommand(configPath, options) {
12094
12831
  }
12095
12832
  const budgets = compileBudgets(config.budgets);
12096
12833
  const eventBus = new DashboardEventBus();
12834
+ const cbs = dashboardEventCallbacks(eventBus);
12097
12835
  const auditStore = new AuditStore({
12098
12836
  path: config.audit.path,
12099
12837
  retention: config.audit.retention,
@@ -12106,30 +12844,7 @@ async function startCommand(configPath, options) {
12106
12844
  auditStore.runRetentionSweep();
12107
12845
  const auditWriter = new AuditWriter({
12108
12846
  store: auditStore,
12109
- onPersist: (record, id) => {
12110
- eventBus.emit("action", {
12111
- id,
12112
- tool_name: record.tool_name,
12113
- policy_decision: record.policy_decision,
12114
- block_reason: record.block_reason,
12115
- approval_status: record.approval_status,
12116
- session_id: record.session_id,
12117
- session_source: record.session_source,
12118
- protocol_version: record.protocol_version,
12119
- agent_id: record.agent_id,
12120
- environment: record.environment,
12121
- timestamp: record.timestamp,
12122
- total_duration_ms: record.total_duration_ms,
12123
- approval_wait_ms: record.approval_wait_ms,
12124
- proxy_compute_ms: record.proxy_compute_ms,
12125
- flagged_destructive: record.flagged_destructive,
12126
- dry_run: record.dry_run,
12127
- matched_rule: record.matched_rule,
12128
- matched_rule_index: record.matched_rule_index,
12129
- record_kind: record.record_kind,
12130
- origin: record.origin
12131
- });
12132
- }
12847
+ onPersist: cbs.onPersist
12133
12848
  });
12134
12849
  registerCrashDrainHook(() => {
12135
12850
  try {
@@ -12147,14 +12862,7 @@ async function startCommand(configPath, options) {
12147
12862
  defaultOnTimeout: config.approval.default_on_timeout,
12148
12863
  channels,
12149
12864
  queue: approvalQueue,
12150
- onSubmit: (ticket) => {
12151
- eventBus.emit("approval_requested", {
12152
- ticket_id: ticket.id,
12153
- tool_name: ticket.tool_name,
12154
- channel: ticket.channel_name,
12155
- requested_at: ticket.requested_at
12156
- });
12157
- },
12865
+ onSubmit: cbs.onApprovalSubmit,
12158
12866
  onResolve: (ticket) => {
12159
12867
  eventBus.emit("approval_resolved", {
12160
12868
  ticket_id: ticket.id,
@@ -12168,26 +12876,10 @@ async function startCommand(configPath, options) {
12168
12876
  }
12169
12877
  });
12170
12878
  const rateLimiter = new RateLimiter({
12171
- onWarning: (state) => {
12172
- eventBus.emit("limit_warning", {
12173
- key: state.key,
12174
- type: "rate",
12175
- current: state.current,
12176
- limit: state.limit,
12177
- utilization: state.current / state.limit
12178
- });
12179
- }
12879
+ onWarning: cbs.onRateWarning
12180
12880
  });
12181
12881
  const spendLimiter = new SpendLimiter({
12182
- onWarning: (state) => {
12183
- eventBus.emit("limit_warning", {
12184
- key: state.key,
12185
- type: "spend",
12186
- current: state.current_spend,
12187
- limit: state.limit,
12188
- utilization: state.current_spend / state.limit
12189
- });
12190
- }
12882
+ onWarning: cbs.onSpendWarning
12191
12883
  });
12192
12884
  const budgetEngine = new BudgetEngine({
12193
12885
  budgets,
@@ -12201,7 +12893,7 @@ async function startCommand(configPath, options) {
12201
12893
  });
12202
12894
  budgetEngine.hydrate();
12203
12895
  const session = compileSessionIdentity(config.session);
12204
- const governedForwarder = new GovernedForwarder(forwarder, policy, {
12896
+ const governance = {
12205
12897
  environment: config.environment,
12206
12898
  auditWriter,
12207
12899
  evidenceStore,
@@ -12210,16 +12902,47 @@ async function startCommand(configPath, options) {
12210
12902
  spendLimiter,
12211
12903
  budgetEngine,
12212
12904
  session
12213
- });
12214
- const annotationPrime = await startAnnotationPrimeLoop(governedForwarder, policy.toolRevalidation);
12905
+ };
12906
+ const stacks = [];
12907
+ for (const door of doors) {
12908
+ stacks.push({
12909
+ name: door.name,
12910
+ ...await governUpstream({
12911
+ forwarder: door.forwarder,
12912
+ policy,
12913
+ governance,
12914
+ upstreamName: door.name
12915
+ })
12916
+ });
12917
+ }
12215
12918
  const hasSlackChannels = [...channels.values()].some((ch) => ch.type === "slack");
12216
12919
  const slackActionApp = hasSlackChannels ? createSlackActionApp({ router: approvalRouter, channels }) : void 0;
12217
- const app = createApp(config, governedForwarder, {
12218
- slackActionApp,
12219
- onHeaderMismatch: (rejection) => {
12220
- auditWriter.pushImmediate(buildHeaderMismatchAuditRecord(rejection, config.environment));
12920
+ let app;
12921
+ if (isNamedConfig(config)) {
12922
+ const forwarders = {};
12923
+ for (const stack of stacks) {
12924
+ if (stack.name !== void 0) forwarders[stack.name] = stack.governedForwarder;
12925
+ }
12926
+ app = createMultiApp(config, forwarders, {
12927
+ slackActionApp,
12928
+ onHeaderMismatch: (rejection, upstreamName) => {
12929
+ auditWriter.pushImmediate(
12930
+ buildHeaderMismatchAuditRecord(rejection, config.environment, upstreamName)
12931
+ );
12932
+ }
12933
+ });
12934
+ } else {
12935
+ const stack = stacks[0];
12936
+ if (stack === void 0) {
12937
+ throw new Error("unreachable: singular mode connects exactly one upstream");
12221
12938
  }
12222
- });
12939
+ app = createApp(config, stack.governedForwarder, {
12940
+ slackActionApp,
12941
+ onHeaderMismatch: (rejection) => {
12942
+ auditWriter.pushImmediate(buildHeaderMismatchAuditRecord(rejection, config.environment));
12943
+ }
12944
+ });
12945
+ }
12223
12946
  const handle = startServer(app, config);
12224
12947
  let sidebandHandle;
12225
12948
  let sidebandToken;
@@ -12230,7 +12953,7 @@ async function startCommand(configPath, options) {
12230
12953
  if (config.sdk.enabled) {
12231
12954
  sidebandToken = process.env["HELIO_SDK_TOKEN"];
12232
12955
  if (!sidebandToken || sidebandToken.length === 0) {
12233
- sidebandToken = randomBytes2(32).toString("hex");
12956
+ sidebandToken = randomBytes3(32).toString("hex");
12234
12957
  process.env["HELIO_SDK_TOKEN"] = sidebandToken;
12235
12958
  sidebandTokenSource = "generated";
12236
12959
  } else {
@@ -12238,7 +12961,7 @@ async function startCommand(configPath, options) {
12238
12961
  }
12239
12962
  adapterToken = process.env["HELIO_ADAPTER_TOKEN"];
12240
12963
  if (!adapterToken || adapterToken.length === 0) {
12241
- adapterToken = randomBytes2(32).toString("hex");
12964
+ adapterToken = randomBytes3(32).toString("hex");
12242
12965
  process.env["HELIO_ADAPTER_TOKEN"] = adapterToken;
12243
12966
  adapterTokenSource = "generated";
12244
12967
  } else {
@@ -12308,10 +13031,18 @@ async function startCommand(configPath, options) {
12308
13031
  `Policies: ${String(ruleCount)} rule${ruleCount !== 1 ? "s" : ""} loaded (default: ${policy.defaultAction})`
12309
13032
  );
12310
13033
  warnIfNoEnforcement(policy);
12311
- if (config.upstream.transport === "stdio") {
13034
+ if (isNamedConfig(config)) {
13035
+ for (const entry of config.upstreams) {
13036
+ if (entry.transport === "stdio") {
13037
+ console.error(`Upstream[${entry.name}]: ${entry.command ?? ""} (stdio)`);
13038
+ } else {
13039
+ console.error(`Upstream[${entry.name}]: ${entry.url ?? ""} (${entry.transport})`);
13040
+ }
13041
+ }
13042
+ } else if (config.upstream.transport === "stdio") {
12312
13043
  console.error(`Upstream: ${config.upstream.command ?? ""} (stdio)`);
12313
13044
  } else {
12314
- console.error(`Upstream: ${config.upstream.url} (${config.upstream.transport})`);
13045
+ console.error(`Upstream: ${config.upstream.url ?? ""} (${config.upstream.transport})`);
12315
13046
  }
12316
13047
  console.error(`Audit: ${config.audit.path} (retention: ${config.audit.retention})`);
12317
13048
  if (sidebandHandle) {
@@ -12337,6 +13068,7 @@ async function startCommand(configPath, options) {
12337
13068
  warnIfWebhookChannelUnreachable(config);
12338
13069
  warnIfSdkSidebandExposed(config);
12339
13070
  warnIfDashboardOpenMode(config);
13071
+ warnIfDashboardSecretLiteral(config, { configPath, interpolatedPaths });
12340
13072
  warnIfBudgetWindowExceedsRetention(config);
12341
13073
  const channelCount = config.approval.channels.length;
12342
13074
  console.error(
@@ -12373,8 +13105,7 @@ async function startCommand(configPath, options) {
12373
13105
  );
12374
13106
  }
12375
13107
  budgetEngine.reconcile(newBudgets);
12376
- governedForwarder.updatePolicy(newPolicy);
12377
- annotationPrime.reconfigure(newPolicy.toolRevalidation);
13108
+ applyReloadedPolicy(stacks, newPolicy);
12378
13109
  governanceService?.updatePolicy(newPolicy);
12379
13110
  const budgetTotal = newBudgets.length;
12380
13111
  console.error(
@@ -12415,8 +13146,8 @@ async function startCommand(configPath, options) {
12415
13146
  }
12416
13147
  registerShutdown(
12417
13148
  handle,
12418
- annotationPrime,
12419
- closeForwarder,
13149
+ stacks.map((stack) => stack.annotationPrime),
13150
+ doors.flatMap((door) => door.close ? [door.close] : []),
12420
13151
  auditWriter,
12421
13152
  configWatcher,
12422
13153
  sidebandHandle,
@@ -12437,15 +13168,22 @@ async function initCommand(outputPath, force) {
12437
13168
  console.error(`Error: ${outputPath} already exists. Use --force to overwrite.`);
12438
13169
  process.exit(1);
12439
13170
  }
12440
- const apiSecret = randomBytes2(32).toString("hex");
12441
- await writeFile(outputPath, renderConfigTemplate(apiSecret), "utf-8");
13171
+ const secret = randomBytes3(32).toString("hex");
13172
+ await writeFile(outputPath, renderConfigTemplate(secretDigest(secret)), "utf-8");
12442
13173
  console.error(`Created ${outputPath}`);
12443
13174
  console.error("");
12444
- console.error("Generated dashboard.api_secret (also stored in the file above):");
12445
- console.error(` ${apiSecret}`);
13175
+ console.error("Dashboard secret (shown once; the file stores only its SHA-256 digest):");
13176
+ console.error(` ${secret}`);
12446
13177
  console.error("");
12447
- console.error("Use this as the dashboard login secret (and optional Bearer credential");
12448
- console.error("for sideband API clients at default 127.0.0.1:3100). Rotate in-file.");
13178
+ console.error("Store it in your password manager. Use it to log in to the dashboard and");
13179
+ console.error("as the Bearer credential for sideband API clients (127.0.0.1:3100 by");
13180
+ console.error("default). If you lose it, run `helio secret`, paste the new digest into");
13181
+ console.error("dashboard.api_secret, and restart the proxy.");
13182
+ }
13183
+ function secretCommand() {
13184
+ const secret = randomBytes3(32).toString("hex");
13185
+ console.log(`secret: ${secret}`);
13186
+ console.log(`digest: ${secretDigest(secret)}`);
12449
13187
  }
12450
13188
  async function validateCommand(configPath) {
12451
13189
  try {
@@ -12456,6 +13194,10 @@ async function validateCommand(configPath) {
12456
13194
  console.error(`Warning: policy ${label}: ${w.message}`);
12457
13195
  }
12458
13196
  compileBudgets(config.budgets);
13197
+ if (isNamedConfig(config)) {
13198
+ warnIfManyUpstreams(config);
13199
+ }
13200
+ warnIfStdioUrlIgnored(config);
12459
13201
  if (config.dashboard.enabled && !getBundledDashboardDistPath()) {
12460
13202
  console.error(
12461
13203
  "Invalid config: dashboard.enabled is true but bundled dashboard assets are missing. " + DASHBOARD_ASSETS_RECOVERY_MESSAGE_FOR_VALIDATE
@@ -12496,6 +13238,7 @@ async function exportCommand(opts) {
12496
13238
  ["--decision", opts.decision],
12497
13239
  ["--reason", opts.reason],
12498
13240
  ["--session", opts.session],
13241
+ ["--upstream", opts.upstream],
12499
13242
  ["--from", opts.from],
12500
13243
  ["--to", opts.to]
12501
13244
  ].filter(([, value]) => value !== void 0);
@@ -12542,6 +13285,7 @@ async function exportCommand(opts) {
12542
13285
  policy_decision: opts.decision,
12543
13286
  block_reason: opts.reason,
12544
13287
  session_id: opts.session,
13288
+ upstream: opts.upstream,
12545
13289
  from: opts.from,
12546
13290
  to: opts.to
12547
13291
  },
@@ -12571,7 +13315,7 @@ function writeCsv(records) {
12571
13315
  console.log(values.join(","));
12572
13316
  }
12573
13317
  }
12574
- function registerShutdown(handle, annotationPrime, closeForwarder, auditWriter, configWatcher, sidebandHandle, evidenceStore, approvalRouter, approvalQueue, rateLimiter, spendLimiter, budgetEngine, closeDashboardApp, dashboardHandle, eventBus, governanceService) {
13318
+ function registerShutdown(handle, annotationPrimes, closeForwarders, auditWriter, configWatcher, sidebandHandle, evidenceStore, approvalRouter, approvalQueue, rateLimiter, spendLimiter, budgetEngine, closeDashboardApp, dashboardHandle, eventBus, governanceService) {
12575
13319
  let isShuttingDown = false;
12576
13320
  const shutdown = () => {
12577
13321
  if (isShuttingDown) return;
@@ -12584,8 +13328,8 @@ function registerShutdown(handle, annotationPrime, closeForwarder, auditWriter,
12584
13328
  forceShutdownTimer.unref();
12585
13329
  void closeResources({
12586
13330
  handle,
12587
- annotationPrime,
12588
- closeForwarder,
13331
+ annotationPrimes,
13332
+ closeForwarders,
12589
13333
  auditWriter,
12590
13334
  configWatcher,
12591
13335
  sidebandHandle,
@@ -12613,9 +13357,20 @@ function registerShutdown(handle, annotationPrime, closeForwarder, auditWriter,
12613
13357
  }
12614
13358
  var program = new Command().name("helio").description("Helio MCP governance proxy").version(VERSION);
12615
13359
  program.command("start").description("Load config and start the proxy server").option("-c, --config <path>", "Path to helio.yaml", DEFAULT_CONFIG_PATH).option("--no-hot-reload", "Disable policy hot-reload \u2014 config edits will require a restart").action(
12616
- (opts) => startCommand(opts.config, { config: opts.config, noHotReload: opts.hotReload === false })
13360
+ (opts) => startCommand(opts.config, { config: opts.config, noHotReload: opts.hotReload === false }).catch(
13361
+ (err) => {
13362
+ if (err instanceof StartupError) {
13363
+ console.error(err.message);
13364
+ process.exit(1);
13365
+ }
13366
+ throw err;
13367
+ }
13368
+ )
12617
13369
  );
12618
13370
  program.command("init").description("Scaffold a helio.yaml config file with commented defaults").option("-o, --output <path>", "Output file path", DEFAULT_CONFIG_PATH).option("-f, --force", "Overwrite existing file", false).action((opts) => initCommand(opts.output, opts.force));
12619
13371
  program.command("validate").description("Validate a helio.yaml config file").option("-c, --config <path>", "Path to helio.yaml", DEFAULT_CONFIG_PATH).action((opts) => validateCommand(opts.config));
12620
- program.command("export").description("Export audit records or a budget ledger to JSON or CSV").option("-c, --config <path>", "Path to helio.yaml", DEFAULT_CONFIG_PATH).option("-f, --format <format>", "Output format: json or csv", "json").option("--budgets <name>", "Export the named budget ledger instead of the audit trail").option("--tool <name>", "Filter by tool name").option("--decision <decision>", "Filter by policy decision").option("--reason <reason>", "Filter by block reason").option("--session <id>", "Filter by session ID").option("--from <iso>", "Start time (ISO 8601)").option("--to <iso>", "End time (ISO 8601)").option("--limit <n>", "Max records to export (up to 10000)", "1000").action((opts) => exportCommand(opts));
13372
+ program.command("secret").description("Generate a dashboard secret and the digest to store as dashboard.api_secret").action(() => {
13373
+ secretCommand();
13374
+ });
13375
+ program.command("export").description("Export audit records or a budget ledger to JSON or CSV").option("-c, --config <path>", "Path to helio.yaml", DEFAULT_CONFIG_PATH).option("-f, --format <format>", "Output format: json or csv", "json").option("--budgets <name>", "Export the named budget ledger instead of the audit trail").option("--tool <name>", "Filter by tool name").option("--decision <decision>", "Filter by policy decision").option("--reason <reason>", "Filter by block reason").option("--session <id>", "Filter by session ID").option("--upstream <name>", "Filter by upstream name").option("--from <iso>", "Start time (ISO 8601)").option("--to <iso>", "End time (ISO 8601)").option("--limit <n>", "Max records to export (up to 10000)", "1000").action((opts) => exportCommand(opts));
12621
13376
  program.parse();