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