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