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