@gethelio/proxy 0.10.0 → 0.11.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 +60 -19
- package/dist/cli.js +341 -102
- package/dist/dashboard-assets/assets/{index-DNjpdKac.css → index-CPoQ6xns.css} +1 -1
- package/dist/dashboard-assets/assets/{index-Ba99PYDi.js → index-D19fYKEH.js} +29 -29
- package/dist/dashboard-assets/index.html +2 -2
- package/dist/index.d.ts +73 -38
- package/dist/index.js +299 -134
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -39,7 +39,7 @@ var upstreamSchema = z.object({
|
|
|
39
39
|
request_timeout: durationSchema.default("30s"),
|
|
40
40
|
forward_headers: z.array(z.string().min(1)).default([]),
|
|
41
41
|
headers: z.record(z.string(), z.string()).default({})
|
|
42
|
-
}).refine((data) => data.transport !== "stdio" || data.command !== void 0, {
|
|
42
|
+
}).strict().refine((data) => data.transport !== "stdio" || data.command !== void 0, {
|
|
43
43
|
message: '"command" is required when transport is "stdio"',
|
|
44
44
|
path: ["command"]
|
|
45
45
|
}).superRefine((data, ctx) => {
|
|
@@ -72,7 +72,7 @@ var upstreamSchema = z.object({
|
|
|
72
72
|
var listenSchema = z.object({
|
|
73
73
|
port: z.number().int().min(1).max(65535).default(3e3),
|
|
74
74
|
host: z.string().default("127.0.0.1")
|
|
75
|
-
});
|
|
75
|
+
}).strict();
|
|
76
76
|
function isLoopbackHost(host) {
|
|
77
77
|
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
78
78
|
}
|
|
@@ -86,7 +86,7 @@ var dashboardSchema = z.object({
|
|
|
86
86
|
api_secret: z.string().optional(),
|
|
87
87
|
allow_open_mode: z.boolean().default(false),
|
|
88
88
|
sse_heartbeat_interval: durationSchema.default("30s")
|
|
89
|
-
});
|
|
89
|
+
}).strict();
|
|
90
90
|
var inputConditionSchema = z.object({
|
|
91
91
|
eq: z.unknown().optional(),
|
|
92
92
|
neq: z.unknown().optional(),
|
|
@@ -216,12 +216,27 @@ var policiesSchema = z.object({
|
|
|
216
216
|
*/
|
|
217
217
|
hot_reload: z.boolean().optional()
|
|
218
218
|
}).strict();
|
|
219
|
-
var
|
|
219
|
+
var budgetContributorMatchSchema = z.object({
|
|
220
220
|
tool: z.string().min(1),
|
|
221
221
|
// picomatch glob, same engine as match.tool
|
|
222
|
+
// Same operators and AND-combination as rule `match.input`. Other rule
|
|
223
|
+
// matchers (annotations, environment, metadata) stay strict-rejected
|
|
224
|
+
// until the budget charge context can actually evaluate them.
|
|
225
|
+
input: z.record(z.string(), inputConditionSchema).optional()
|
|
226
|
+
}).strict();
|
|
227
|
+
var modernBudgetContributorSchema = z.object({
|
|
228
|
+
match: budgetContributorMatchSchema,
|
|
222
229
|
field: z.string().min(1)
|
|
223
230
|
// dot-path into tool arguments, e.g. "$.amount"
|
|
224
231
|
}).strict();
|
|
232
|
+
var budgetContributorSchema = z.unknown().superRefine((raw, ctx) => {
|
|
233
|
+
if (raw !== null && typeof raw === "object" && "tool" in raw) {
|
|
234
|
+
ctx.addIssue({
|
|
235
|
+
code: "custom",
|
|
236
|
+
message: 'contributor "tool" moved under "match" in v0.11.0 \u2014 write { match: { tool: "<glob>" }, field: "<path>" }'
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}).pipe(modernBudgetContributorSchema);
|
|
225
240
|
var budgetSchema = z.object({
|
|
226
241
|
// The name is embedded in bucket keys (`budget:<name>:<scope>`) and, later,
|
|
227
242
|
// ledger rows — constrain it so keys stay parseable and scope classification
|
|
@@ -280,17 +295,17 @@ var slackChannelSchema = z.object({
|
|
|
280
295
|
bot_token: z.string(),
|
|
281
296
|
signing_secret: z.string(),
|
|
282
297
|
channel: z.string()
|
|
283
|
-
});
|
|
298
|
+
}).strict();
|
|
284
299
|
var webhookChannelSchema = z.object({
|
|
285
300
|
type: z.literal("webhook"),
|
|
286
301
|
name: z.string().min(1).optional(),
|
|
287
302
|
url: z.string(),
|
|
288
303
|
secret: z.string().optional()
|
|
289
|
-
});
|
|
304
|
+
}).strict();
|
|
290
305
|
var dashboardChannelSchema = z.object({
|
|
291
306
|
type: z.literal("dashboard"),
|
|
292
307
|
name: z.string().min(1).optional()
|
|
293
|
-
});
|
|
308
|
+
}).strict();
|
|
294
309
|
var approvalChannelSchema = z.discriminatedUnion("type", [
|
|
295
310
|
slackChannelSchema,
|
|
296
311
|
webhookChannelSchema,
|
|
@@ -300,13 +315,13 @@ var approvalSchema = z.object({
|
|
|
300
315
|
timeout: durationSchema.default("300s"),
|
|
301
316
|
default_on_timeout: z.enum(["deny", "allow"]).default("deny"),
|
|
302
317
|
channels: z.array(approvalChannelSchema).default([])
|
|
303
|
-
});
|
|
318
|
+
}).strict();
|
|
304
319
|
var auditSchema = z.object({
|
|
305
320
|
storage: z.enum(["sqlite"]).default("sqlite"),
|
|
306
321
|
path: z.string().default("./helio-audit.db"),
|
|
307
322
|
retention: durationSchema.default("90d"),
|
|
308
323
|
include_responses: z.boolean().default(true)
|
|
309
|
-
});
|
|
324
|
+
}).strict();
|
|
310
325
|
var sdkSchema = z.object({
|
|
311
326
|
enabled: z.boolean().default(false),
|
|
312
327
|
port: z.number().int().min(1).max(65535).default(3200),
|
|
@@ -318,12 +333,11 @@ var sdkSchema = z.object({
|
|
|
318
333
|
* decided-allowed call from the trail.
|
|
319
334
|
*/
|
|
320
335
|
evaluation_ttl: durationSchema.default("10m")
|
|
321
|
-
});
|
|
336
|
+
}).strict();
|
|
322
337
|
var helioConfigBaseSchema = z.object({
|
|
323
338
|
version: z.literal("1"),
|
|
324
339
|
upstream: upstreamSchema,
|
|
325
340
|
listen: listenSchema.prefault({}),
|
|
326
|
-
dashboard: dashboardSchema.prefault({}),
|
|
327
341
|
environment: z.string().optional(),
|
|
328
342
|
policies: policiesSchema.prefault({}),
|
|
329
343
|
// Budgets sit beside policies deliberately: they are the second half of the
|
|
@@ -331,9 +345,18 @@ var helioConfigBaseSchema = z.object({
|
|
|
331
345
|
budgets: z.array(budgetSchema).default([]),
|
|
332
346
|
approval: approvalSchema.prefault({}),
|
|
333
347
|
audit: auditSchema.prefault({}),
|
|
348
|
+
// Dashboard follows audit deliberately: an operator surface, not part of
|
|
349
|
+
// the request path (canonical section order, #89/#163).
|
|
350
|
+
dashboard: dashboardSchema.prefault({}),
|
|
334
351
|
sdk: sdkSchema.prefault({})
|
|
335
|
-
});
|
|
336
|
-
|
|
352
|
+
}).strict();
|
|
353
|
+
function stripRootExtensionKeys(value) {
|
|
354
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
|
|
355
|
+
return Object.fromEntries(
|
|
356
|
+
Object.entries(value).filter(([key]) => !key.startsWith("x-"))
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
var helioConfigRefinedSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
|
|
337
360
|
const hasConfiguredEnvironment = typeof cfg.environment === "string" && cfg.environment.trim().length > 0;
|
|
338
361
|
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");
|
|
339
362
|
const hasSecret = hasDashboardApiSecret(cfg.dashboard.api_secret);
|
|
@@ -448,6 +471,22 @@ var helioConfigSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
|
|
|
448
471
|
message: "dashboard.enabled must be true when approval.channels includes a webhook channel. Webhook notifications require the dashboard sideband approval API."
|
|
449
472
|
});
|
|
450
473
|
}
|
|
474
|
+
if (!cfg.dashboard.enabled) {
|
|
475
|
+
if (cfg.policies.flag_destructive === "require_approval") {
|
|
476
|
+
ctx.addIssue({
|
|
477
|
+
code: "custom",
|
|
478
|
+
path: ["policies", "flag_destructive"],
|
|
479
|
+
message: 'policies.flag_destructive: require_approval routes its escalation tickets to the dashboard channel, but dashboard.enabled is false \u2014 the tickets could never be resolved and would always time out. Enable the dashboard or use "log".'
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
if (cfg.policies.on_tool_drift === "require_approval") {
|
|
483
|
+
ctx.addIssue({
|
|
484
|
+
code: "custom",
|
|
485
|
+
path: ["policies", "on_tool_drift"],
|
|
486
|
+
message: 'policies.on_tool_drift: require_approval routes its escalation tickets to the dashboard channel, but dashboard.enabled is false \u2014 the tickets could never be resolved and would always time out. Enable the dashboard or use "block" or "log".'
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
451
490
|
for (const [ruleIndex, rule] of cfg.policies.rules.entries()) {
|
|
452
491
|
if (rule.match.environment !== void 0 && !hasConfiguredEnvironment) {
|
|
453
492
|
ctx.addIssue({
|
|
@@ -503,6 +542,28 @@ var helioConfigSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
|
|
|
503
542
|
message: `Unknown approval channel "${channel}". Add it to approval.channels (type or name), or use "dashboard".`
|
|
504
543
|
});
|
|
505
544
|
}
|
|
545
|
+
if (rule.action === "require_approval" && !cfg.dashboard.enabled && rule.match.metadata === void 0) {
|
|
546
|
+
const effectiveChannel = rule.approval?.channel ?? "dashboard";
|
|
547
|
+
if (resolvesToDashboard(effectiveChannel)) {
|
|
548
|
+
ctx.addIssue({
|
|
549
|
+
code: "custom",
|
|
550
|
+
path: rule.approval?.channel !== void 0 ? ["policies", "rules", ruleIndex, "approval", "channel"] : ["policies", "rules", ruleIndex, "action"],
|
|
551
|
+
message: "This rule routes approvals to the dashboard channel, but dashboard.enabled is false \u2014 the ticket could never be resolved and would always time out. Enable the dashboard or route approval.channel to a Slack channel."
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
const escalationAfterMs = rule.approval?.escalation_after !== void 0 ? parseDuration(rule.approval.escalation_after) : void 0;
|
|
555
|
+
const effectiveTimeoutMs = parseDuration(rule.approval?.timeout ?? cfg.approval.timeout);
|
|
556
|
+
const escalationCanFire = escalationAfterMs !== void 0 && escalationAfterMs > 0 && escalationAfterMs < effectiveTimeoutMs;
|
|
557
|
+
for (const [delegateIndex, delegate] of (rule.approval?.delegates ?? []).entries()) {
|
|
558
|
+
if (escalationCanFire && knownChannelKeys.has(delegate) && resolvesToDashboard(delegate)) {
|
|
559
|
+
ctx.addIssue({
|
|
560
|
+
code: "custom",
|
|
561
|
+
path: ["policies", "rules", ruleIndex, "approval", "delegates", delegateIndex],
|
|
562
|
+
message: "This rule escalates approvals to a dashboard channel, but dashboard.enabled is false \u2014 the delegate could never resolve the ticket. Enable the dashboard or delegate to a Slack channel."
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
506
567
|
const delegates = rule.approval?.delegates;
|
|
507
568
|
if (!delegates) continue;
|
|
508
569
|
for (const [delegateIndex, delegate] of delegates.entries()) {
|
|
@@ -516,6 +577,7 @@ var helioConfigSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
|
|
|
516
577
|
}
|
|
517
578
|
}
|
|
518
579
|
});
|
|
580
|
+
var helioConfigSchema = z.preprocess(stripRootExtensionKeys, helioConfigRefinedSchema);
|
|
519
581
|
|
|
520
582
|
// src/config/loader.ts
|
|
521
583
|
import { readFile } from "fs/promises";
|
|
@@ -578,7 +640,9 @@ async function loadConfig(filePath, env) {
|
|
|
578
640
|
const interpolated = interpolateEnvVars(parsed, env);
|
|
579
641
|
const result = helioConfigSchema.safeParse(interpolated);
|
|
580
642
|
if (!result.success) {
|
|
581
|
-
const details = formatZodErrors(result.error)
|
|
643
|
+
const details = formatZodErrors(result.error).map(
|
|
644
|
+
(d) => d.path === "" ? { ...d, path: "(top level)" } : d
|
|
645
|
+
);
|
|
582
646
|
const count = details.length;
|
|
583
647
|
throw new ConfigError(
|
|
584
648
|
`Invalid configuration (${String(count)} error${count === 1 ? "" : "s"})`,
|
|
@@ -699,7 +763,7 @@ function compileToolMatcher(pattern, ruleIndex, ruleName) {
|
|
|
699
763
|
throw new PolicyParseError(`invalid glob pattern "${pattern}": ${message}`, ruleIndex, ruleName);
|
|
700
764
|
}
|
|
701
765
|
}
|
|
702
|
-
function
|
|
766
|
+
function flattenInputConditionsWith(input, makeError) {
|
|
703
767
|
const conditions = [];
|
|
704
768
|
for (const [path, conditionObj] of Object.entries(input)) {
|
|
705
769
|
for (const op of INPUT_OPERATORS) {
|
|
@@ -707,17 +771,11 @@ function flattenInputConditions(input, ruleIndex, ruleName) {
|
|
|
707
771
|
if (value === void 0) continue;
|
|
708
772
|
if (op === "regex") {
|
|
709
773
|
if (typeof value !== "string") {
|
|
710
|
-
throw
|
|
711
|
-
`regex value for input path "${path}" must be a string`,
|
|
712
|
-
ruleIndex,
|
|
713
|
-
ruleName
|
|
714
|
-
);
|
|
774
|
+
throw makeError(`regex value for input path "${path}" must be a string`);
|
|
715
775
|
}
|
|
716
776
|
if (!safeRegex(value)) {
|
|
717
|
-
throw
|
|
718
|
-
`catastrophic regex "${value}" for input path "${path}": pattern is vulnerable to ReDoS and has been rejected. Rewrite with bounded quantifiers (e.g. {1,100}) or split into simpler rules
|
|
719
|
-
ruleIndex,
|
|
720
|
-
ruleName
|
|
777
|
+
throw makeError(
|
|
778
|
+
`catastrophic regex "${value}" for input path "${path}": pattern is vulnerable to ReDoS and has been rejected. Rewrite with bounded quantifiers (e.g. {1,100}) or split into simpler rules.`
|
|
721
779
|
);
|
|
722
780
|
}
|
|
723
781
|
let compiledRegex;
|
|
@@ -725,11 +783,7 @@ function flattenInputConditions(input, ruleIndex, ruleName) {
|
|
|
725
783
|
compiledRegex = new RegExp(value);
|
|
726
784
|
} catch (err) {
|
|
727
785
|
const msg = err instanceof Error ? err.message : String(err);
|
|
728
|
-
throw
|
|
729
|
-
`invalid regex "${value}" for input path "${path}": ${msg}`,
|
|
730
|
-
ruleIndex,
|
|
731
|
-
ruleName
|
|
732
|
-
);
|
|
786
|
+
throw makeError(`invalid regex "${value}" for input path "${path}": ${msg}`);
|
|
733
787
|
}
|
|
734
788
|
conditions.push({ path, operator: op, value, regex: compiledRegex });
|
|
735
789
|
} else {
|
|
@@ -739,6 +793,12 @@ function flattenInputConditions(input, ruleIndex, ruleName) {
|
|
|
739
793
|
}
|
|
740
794
|
return conditions;
|
|
741
795
|
}
|
|
796
|
+
function flattenInputConditions(input, ruleIndex, ruleName) {
|
|
797
|
+
return flattenInputConditionsWith(
|
|
798
|
+
input,
|
|
799
|
+
(message) => new PolicyParseError(message, ruleIndex, ruleName)
|
|
800
|
+
);
|
|
801
|
+
}
|
|
742
802
|
function flattenMetadataConditions(metadata, ruleIndex, ruleName) {
|
|
743
803
|
const conditions = [];
|
|
744
804
|
for (const [key, raw] of Object.entries(metadata)) {
|
|
@@ -872,21 +932,30 @@ function compileBudgets(budgets) {
|
|
|
872
932
|
onExceed: budget.on_exceed,
|
|
873
933
|
...budget.approval !== void 0 && { approval: compileApproval(budget.approval) },
|
|
874
934
|
contributors: budget.contributors.map(
|
|
875
|
-
(contributor) => compileContributor(contributor, budget.name)
|
|
935
|
+
(contributor, index) => compileContributor(contributor, budget.name, index)
|
|
876
936
|
)
|
|
877
937
|
}));
|
|
878
938
|
}
|
|
879
|
-
function compileContributor(contributor, budgetName) {
|
|
939
|
+
function compileContributor(contributor, budgetName, index) {
|
|
940
|
+
let tool;
|
|
880
941
|
try {
|
|
881
|
-
const test = picomatch2(contributor.tool, { dot: true });
|
|
882
|
-
|
|
942
|
+
const test = picomatch2(contributor.match.tool, { dot: true });
|
|
943
|
+
tool = { pattern: contributor.match.tool, test };
|
|
883
944
|
} catch (err) {
|
|
884
945
|
const message = err instanceof Error ? err.message : String(err);
|
|
885
946
|
throw new BudgetParseError(
|
|
886
|
-
`invalid contributor glob "${contributor.tool}": ${message}`,
|
|
947
|
+
`invalid contributor glob "${contributor.match.tool}": ${message}`,
|
|
887
948
|
budgetName
|
|
888
949
|
);
|
|
889
950
|
}
|
|
951
|
+
const input = contributor.match.input !== void 0 ? flattenInputConditionsWith(
|
|
952
|
+
contributor.match.input,
|
|
953
|
+
(message) => new BudgetParseError(`contributor ${String(index)}: ${message}`, budgetName)
|
|
954
|
+
) : void 0;
|
|
955
|
+
return {
|
|
956
|
+
match: { tool, ...input !== void 0 && { input } },
|
|
957
|
+
field: contributor.field
|
|
958
|
+
};
|
|
890
959
|
}
|
|
891
960
|
|
|
892
961
|
// src/server.ts
|
|
@@ -973,6 +1042,12 @@ function parseJsonRpcRequest(body) {
|
|
|
973
1042
|
};
|
|
974
1043
|
}
|
|
975
1044
|
|
|
1045
|
+
// src/transport/content-type.ts
|
|
1046
|
+
function isJsonContentType(header) {
|
|
1047
|
+
const [essence = ""] = (header ?? "").split(";");
|
|
1048
|
+
return essence.trim().toLowerCase() === "application/json";
|
|
1049
|
+
}
|
|
1050
|
+
|
|
976
1051
|
// src/transport/forward-headers.ts
|
|
977
1052
|
function buildForwardHeaders(requestHeaders, allowlist) {
|
|
978
1053
|
const forwardHeaders = {};
|
|
@@ -1106,8 +1181,7 @@ function createStreamableHttpRoute(forwarder, options = {}) {
|
|
|
1106
1181
|
const app = new Hono();
|
|
1107
1182
|
const forwardHeaderAllowlist = options.forwardHeadersAllowlist ?? [];
|
|
1108
1183
|
app.post("/", async (c) => {
|
|
1109
|
-
|
|
1110
|
-
if (!contentType.includes("application/json")) {
|
|
1184
|
+
if (!isJsonContentType(c.req.header("content-type"))) {
|
|
1111
1185
|
return c.json(
|
|
1112
1186
|
makeJsonRpcError(null, INVALID_REQUEST, "Content-Type must be application/json"),
|
|
1113
1187
|
415
|
|
@@ -1240,8 +1314,7 @@ function createSseRoute(forwarder, options = {}) {
|
|
|
1240
1314
|
if (!session) {
|
|
1241
1315
|
return c.json(makeJsonRpcError(null, INVALID_REQUEST, "unknown session"), 404);
|
|
1242
1316
|
}
|
|
1243
|
-
|
|
1244
|
-
if (!contentType.includes("application/json")) {
|
|
1317
|
+
if (!isJsonContentType(c.req.header("content-type"))) {
|
|
1245
1318
|
return c.json(
|
|
1246
1319
|
makeJsonRpcError(null, INVALID_REQUEST, "Content-Type must be application/json"),
|
|
1247
1320
|
415
|
|
@@ -2910,11 +2983,9 @@ function extractTools(body) {
|
|
|
2910
2983
|
|
|
2911
2984
|
// src/feedback/self-repair.ts
|
|
2912
2985
|
function ruleInfo(rule) {
|
|
2913
|
-
const index = rule?.index ?? null;
|
|
2914
2986
|
return {
|
|
2915
2987
|
rule: rule?.name ?? null,
|
|
2916
|
-
|
|
2917
|
-
rule_index: index
|
|
2988
|
+
rule_index: rule?.index ?? null
|
|
2918
2989
|
};
|
|
2919
2990
|
}
|
|
2920
2991
|
function buildPolicyDeniedFeedback(decision) {
|
|
@@ -4392,29 +4463,33 @@ var GovernedForwarder = class {
|
|
|
4392
4463
|
if (this.spendLimiter && decision.matchedRule?.limits?.maxSpend) {
|
|
4393
4464
|
const maxSpend = decision.matchedRule.limits.maxSpend;
|
|
4394
4465
|
const rawAmount = resolvePath(maxSpend.field, toolArguments ?? {});
|
|
4395
|
-
if (typeof rawAmount
|
|
4396
|
-
|
|
4397
|
-
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4404
|
-
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4466
|
+
if (typeof rawAmount !== "number") {
|
|
4467
|
+
console.error(
|
|
4468
|
+
`[helio] Warning: spend limit field "${maxSpend.field}" did not resolve to a number for tool "${toolName}" (got ${typeof rawAmount}) in dry-run, denying`
|
|
4469
|
+
);
|
|
4470
|
+
wouldForward = false;
|
|
4471
|
+
limitsOk = false;
|
|
4472
|
+
} else if (!Number.isFinite(rawAmount) || rawAmount < 0) {
|
|
4473
|
+
console.error(
|
|
4474
|
+
`[helio] Warning: spend limit field "${maxSpend.field}" resolved to invalid amount (${String(rawAmount)}) for tool "${toolName}" in dry-run, denying`
|
|
4475
|
+
);
|
|
4476
|
+
wouldForward = false;
|
|
4477
|
+
limitsOk = false;
|
|
4478
|
+
} else {
|
|
4479
|
+
const key = this.buildSpendLimitKey(
|
|
4480
|
+
maxSpend.key,
|
|
4481
|
+
toolName,
|
|
4482
|
+
request,
|
|
4483
|
+
decision.matchedRule.index
|
|
4484
|
+
);
|
|
4485
|
+
const peekResult = this.spendLimiter.peek({
|
|
4486
|
+
key,
|
|
4487
|
+
amount: rawAmount,
|
|
4488
|
+
limit: maxSpend.limit,
|
|
4489
|
+
windowMs: maxSpend.windowMs
|
|
4490
|
+
});
|
|
4491
|
+
wouldForward = peekResult.allowed;
|
|
4492
|
+
limitsOk = peekResult.allowed;
|
|
4418
4493
|
}
|
|
4419
4494
|
}
|
|
4420
4495
|
break;
|
|
@@ -5082,16 +5157,26 @@ var BudgetEngine = class {
|
|
|
5082
5157
|
/**
|
|
5083
5158
|
* Resolve which budgets a call feeds and how much it charges each.
|
|
5084
5159
|
*
|
|
5085
|
-
* A
|
|
5086
|
-
*
|
|
5087
|
-
*
|
|
5088
|
-
*
|
|
5160
|
+
* A contributor participates when its tool glob matches the tool name AND
|
|
5161
|
+
* every `match.input` condition holds (absent conditions means the glob
|
|
5162
|
+
* alone decides); the FIRST participating contributor (config order, over
|
|
5163
|
+
* that combined predicate) supplies the amount field. A call that matches
|
|
5164
|
+
* the glob but not the conditions simply does not feed the budget — no
|
|
5165
|
+
* charge, no failure. Once a contributor is selected, a missing,
|
|
5166
|
+
* non-numeric, negative, or non-finite amount fails closed as a `failures`
|
|
5167
|
+
* entry — the caller must deny the call.
|
|
5089
5168
|
*/
|
|
5090
5169
|
resolveCharges(ctx) {
|
|
5091
5170
|
const charges = [];
|
|
5092
5171
|
const failures = [];
|
|
5172
|
+
const matchCtx = {
|
|
5173
|
+
toolName: ctx.toolName,
|
|
5174
|
+
...ctx.toolArguments !== void 0 && { toolArguments: ctx.toolArguments }
|
|
5175
|
+
};
|
|
5093
5176
|
for (const budget of this.budgets.values()) {
|
|
5094
|
-
const contributor = budget.contributors.find(
|
|
5177
|
+
const contributor = budget.contributors.find(
|
|
5178
|
+
(c) => c.match.tool.test(ctx.toolName) && (c.match.input === void 0 || matchInput(c.match.input, matchCtx))
|
|
5179
|
+
);
|
|
5095
5180
|
if (!contributor) continue;
|
|
5096
5181
|
const raw = resolvePath(contributor.field, ctx.toolArguments ?? {});
|
|
5097
5182
|
if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 0) {
|
|
@@ -6085,6 +6170,96 @@ var AuditStore = class {
|
|
|
6085
6170
|
}
|
|
6086
6171
|
};
|
|
6087
6172
|
|
|
6173
|
+
// src/audit/csv.ts
|
|
6174
|
+
var CSV_HEADERS = [
|
|
6175
|
+
"id",
|
|
6176
|
+
"timestamp",
|
|
6177
|
+
"session_id",
|
|
6178
|
+
"agent_id",
|
|
6179
|
+
"tool_name",
|
|
6180
|
+
"tool_input",
|
|
6181
|
+
"policy_decision",
|
|
6182
|
+
"block_reason",
|
|
6183
|
+
"matched_rule",
|
|
6184
|
+
"evidence_chain",
|
|
6185
|
+
"approval_status",
|
|
6186
|
+
"approved_by",
|
|
6187
|
+
"upstream_response",
|
|
6188
|
+
"upstream_error",
|
|
6189
|
+
"upstream_http_status",
|
|
6190
|
+
"upstream_latency_ms",
|
|
6191
|
+
"total_duration_ms",
|
|
6192
|
+
"approval_wait_ms",
|
|
6193
|
+
"proxy_compute_ms",
|
|
6194
|
+
"flagged_destructive",
|
|
6195
|
+
"dry_run",
|
|
6196
|
+
"created_at",
|
|
6197
|
+
"environment",
|
|
6198
|
+
"matched_rule_index",
|
|
6199
|
+
"record_kind",
|
|
6200
|
+
"origin",
|
|
6201
|
+
"metadata"
|
|
6202
|
+
];
|
|
6203
|
+
var FORMULA_PREFIXES = /^[=+\-@\t\r]/;
|
|
6204
|
+
function csvEscape(value) {
|
|
6205
|
+
const needsQuote = value.includes(",") || value.includes("\n") || value.includes('"');
|
|
6206
|
+
const needsFormulaGuard = FORMULA_PREFIXES.test(value);
|
|
6207
|
+
let out = value;
|
|
6208
|
+
if (needsFormulaGuard) out = `'${out}`;
|
|
6209
|
+
if (needsQuote || needsFormulaGuard) out = `"${out.replace(/"/g, '""')}"`;
|
|
6210
|
+
return out;
|
|
6211
|
+
}
|
|
6212
|
+
function recordToRow(record) {
|
|
6213
|
+
return CSV_HEADERS.map((h) => {
|
|
6214
|
+
const val = record[h];
|
|
6215
|
+
if (val === null || val === void 0) return "";
|
|
6216
|
+
if (typeof val === "boolean") return val ? "true" : "false";
|
|
6217
|
+
if (typeof val === "number") return String(val);
|
|
6218
|
+
if (typeof val === "string") return csvEscape(val);
|
|
6219
|
+
if (typeof val === "object") return csvEscape(JSON.stringify(val));
|
|
6220
|
+
return "";
|
|
6221
|
+
}).join(",");
|
|
6222
|
+
}
|
|
6223
|
+
function recordsToCsv(records) {
|
|
6224
|
+
const lines = [CSV_HEADERS.join(",")];
|
|
6225
|
+
for (const r of records) {
|
|
6226
|
+
lines.push(recordToRow(r));
|
|
6227
|
+
}
|
|
6228
|
+
return lines.join("\n");
|
|
6229
|
+
}
|
|
6230
|
+
|
|
6231
|
+
// src/budget/csv.ts
|
|
6232
|
+
var BUDGET_EVENT_CSV_HEADERS = [
|
|
6233
|
+
"id",
|
|
6234
|
+
"budget_name",
|
|
6235
|
+
"bucket_key",
|
|
6236
|
+
"kind",
|
|
6237
|
+
"amount",
|
|
6238
|
+
"currency",
|
|
6239
|
+
"tool_name",
|
|
6240
|
+
"origin",
|
|
6241
|
+
"audit_record_id",
|
|
6242
|
+
"timestamp",
|
|
6243
|
+
"timestamp_ms",
|
|
6244
|
+
"created_at"
|
|
6245
|
+
];
|
|
6246
|
+
function eventToRow(event) {
|
|
6247
|
+
return BUDGET_EVENT_CSV_HEADERS.map((h) => {
|
|
6248
|
+
const val = event[h];
|
|
6249
|
+
if (val === null || val === void 0) return "";
|
|
6250
|
+
if (typeof val === "number") return String(val);
|
|
6251
|
+
if (typeof val === "string") return csvEscape(val);
|
|
6252
|
+
return "";
|
|
6253
|
+
}).join(",");
|
|
6254
|
+
}
|
|
6255
|
+
function budgetEventsToCsv(events) {
|
|
6256
|
+
const lines = [BUDGET_EVENT_CSV_HEADERS.join(",")];
|
|
6257
|
+
for (const e of events) {
|
|
6258
|
+
lines.push(eventToRow(e));
|
|
6259
|
+
}
|
|
6260
|
+
return lines.join("\n");
|
|
6261
|
+
}
|
|
6262
|
+
|
|
6088
6263
|
// src/evidence/store.ts
|
|
6089
6264
|
var EvidenceStore = class _EvidenceStore {
|
|
6090
6265
|
static EVIDENCE_ALLOWLIST_PREVIEW_LIMIT = 20;
|
|
@@ -6817,6 +6992,7 @@ var GovernanceService = class {
|
|
|
6817
6992
|
let wire;
|
|
6818
6993
|
const plans = [];
|
|
6819
6994
|
let limitsBlock;
|
|
6995
|
+
let ruleLimitOk = true;
|
|
6820
6996
|
const reservedThisCall = [];
|
|
6821
6997
|
const reserve = (key) => {
|
|
6822
6998
|
const preexisting = this.senderKeys.has(key);
|
|
@@ -6830,6 +7006,15 @@ var GovernanceService = class {
|
|
|
6830
7006
|
const senderId = senderIdOf(req.metadata);
|
|
6831
7007
|
if (pipeline.isDryRun) {
|
|
6832
7008
|
wire = "dry_run";
|
|
7009
|
+
if (decision.action === "rate_limit") {
|
|
7010
|
+
const planned = this.planRate(decision, toolName, req.session_id, senderId);
|
|
7011
|
+
if (planned?.block) limitsBlock = { rate: planned.block };
|
|
7012
|
+
ruleLimitOk = planned?.allowed ?? true;
|
|
7013
|
+
} else if (decision.action === "spend_limit") {
|
|
7014
|
+
const planned = this.planSpend(decision, toolName, req.session_id, req.arguments, senderId);
|
|
7015
|
+
if (planned?.block) limitsBlock = { spend: planned.block };
|
|
7016
|
+
ruleLimitOk = planned?.allowed ?? true;
|
|
7017
|
+
}
|
|
6833
7018
|
} else if (decision.action === "deny") {
|
|
6834
7019
|
wire = "deny";
|
|
6835
7020
|
} else if (decision.action === "require_approval") {
|
|
@@ -6954,9 +7139,9 @@ var GovernanceService = class {
|
|
|
6954
7139
|
if (limitsBlock) responseBody["limits"] = limitsBlock;
|
|
6955
7140
|
if (wire === "dry_run") {
|
|
6956
7141
|
responseBody["dry_run"] = {
|
|
6957
|
-
would_forward: decision.action === "allow" && !pipeline.evidenceBlocked && budgetDryRunOk,
|
|
7142
|
+
would_forward: (decision.action === "allow" || (decision.action === "rate_limit" || decision.action === "spend_limit") && ruleLimitOk) && !pipeline.evidenceBlocked && budgetDryRunOk,
|
|
6958
7143
|
evidence_satisfied: !pipeline.evidenceBlocked,
|
|
6959
|
-
limits_ok: budgetDryRunOk
|
|
7144
|
+
limits_ok: ruleLimitOk && budgetDryRunOk
|
|
6960
7145
|
};
|
|
6961
7146
|
}
|
|
6962
7147
|
if (pipeline.driftEvent) {
|
|
@@ -6978,7 +7163,11 @@ var GovernanceService = class {
|
|
|
6978
7163
|
flaggedDestructive: pipeline.flaggedDestructive,
|
|
6979
7164
|
dryRun: wire === "dry_run",
|
|
6980
7165
|
recordKind: "tool_call",
|
|
6981
|
-
|
|
7166
|
+
// A CLONE, for the same reason budgetsAtEvaluate is one below:
|
|
7167
|
+
// limitsBlock is also the response's `limits`, and the audit writer
|
|
7168
|
+
// buffers records by reference until flush — a direct embedder
|
|
7169
|
+
// editing the returned body must not be able to rewrite evidence.
|
|
7170
|
+
limitsChain: limitsBlock ? structuredClone(limitsBlock) : void 0
|
|
6982
7171
|
});
|
|
6983
7172
|
this.tombstones.set(evaluationId, {
|
|
6984
7173
|
auditRecordId: auditId,
|
|
@@ -7480,6 +7669,7 @@ var GovernanceService = class {
|
|
|
7480
7669
|
this.approvalRouter?.resolveNativeTicket(entry.approvalTicketId, "timeout");
|
|
7481
7670
|
this.snapshotTicketResolution(entry);
|
|
7482
7671
|
}
|
|
7672
|
+
const committed = entry.commitState;
|
|
7483
7673
|
const resolution = entry.ticketResolution;
|
|
7484
7674
|
const approvalContext = entry.approvalTicketId && resolution && (resolution.denialReason || resolution.escalatedAt) ? {
|
|
7485
7675
|
ticket_id: entry.approvalTicketId,
|
|
@@ -7490,6 +7680,7 @@ var GovernanceService = class {
|
|
|
7490
7680
|
} : {}
|
|
7491
7681
|
} : void 0;
|
|
7492
7682
|
const auditId = this.writeAudit({
|
|
7683
|
+
...committed ? { id: committed.auditId } : {},
|
|
7493
7684
|
timestampIso: entry.timestampIso,
|
|
7494
7685
|
origin: entry.origin,
|
|
7495
7686
|
agentId: entry.agentId,
|
|
@@ -7507,8 +7698,9 @@ var GovernanceService = class {
|
|
|
7507
7698
|
approvalStatus: resolution?.status ?? null,
|
|
7508
7699
|
approvedBy: resolution?.resolvedBy ?? null,
|
|
7509
7700
|
approvalContext,
|
|
7510
|
-
limitsChain:
|
|
7511
|
-
sidebandUnreported: true
|
|
7701
|
+
limitsChain: committed ? committed.limitsChain : entry.budgetsAtEvaluate ? { budgets: entry.budgetsAtEvaluate } : void 0,
|
|
7702
|
+
sidebandUnreported: true,
|
|
7703
|
+
sidebandCommitted: committed !== void 0
|
|
7512
7704
|
});
|
|
7513
7705
|
this.discardPending(entry);
|
|
7514
7706
|
this.tombstones.set(entry.evaluationId, {
|
|
@@ -7518,7 +7710,7 @@ var GovernanceService = class {
|
|
|
7518
7710
|
expiresAtMs: now + this.ttlMs
|
|
7519
7711
|
});
|
|
7520
7712
|
console.error(
|
|
7521
|
-
`[helio] Sideband evaluation ${entry.evaluationId} expired without /audit (origin=${entry.origin}, tool=${entry.toolName}) \u2014 recorded as evaluation_expired`
|
|
7713
|
+
committed ? `[helio] Sideband evaluation ${entry.evaluationId} expired after a failed /audit finalization (origin=${entry.origin}, tool=${entry.toolName}) \u2014 recorded as evaluation_expired under the committed audit id` : `[helio] Sideband evaluation ${entry.evaluationId} expired without /audit (origin=${entry.origin}, tool=${entry.toolName}) \u2014 recorded as evaluation_expired`
|
|
7522
7714
|
);
|
|
7523
7715
|
return "expired";
|
|
7524
7716
|
}
|
|
@@ -7700,7 +7892,13 @@ var GovernanceService = class {
|
|
|
7700
7892
|
const blockReason = deriveBlockReason(args);
|
|
7701
7893
|
let evidenceChain = args.limitsChain ?? null;
|
|
7702
7894
|
if (args.sidebandUnreported) {
|
|
7703
|
-
evidenceChain = {
|
|
7895
|
+
evidenceChain = {
|
|
7896
|
+
...evidenceChain ?? {},
|
|
7897
|
+
sideband: {
|
|
7898
|
+
unreported: true,
|
|
7899
|
+
...args.sidebandCommitted ? { committed: true } : {}
|
|
7900
|
+
}
|
|
7901
|
+
};
|
|
7704
7902
|
}
|
|
7705
7903
|
if (args.approvalContext) {
|
|
7706
7904
|
evidenceChain = { ...evidenceChain ?? {}, approval: { ...args.approvalContext } };
|
|
@@ -9022,64 +9220,6 @@ import { cors } from "hono/cors";
|
|
|
9022
9220
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
9023
9221
|
import { streamSSE } from "hono/streaming";
|
|
9024
9222
|
|
|
9025
|
-
// src/audit/csv.ts
|
|
9026
|
-
var CSV_HEADERS = [
|
|
9027
|
-
"id",
|
|
9028
|
-
"timestamp",
|
|
9029
|
-
"session_id",
|
|
9030
|
-
"agent_id",
|
|
9031
|
-
"tool_name",
|
|
9032
|
-
"tool_input",
|
|
9033
|
-
"policy_decision",
|
|
9034
|
-
"block_reason",
|
|
9035
|
-
"matched_rule",
|
|
9036
|
-
"evidence_chain",
|
|
9037
|
-
"approval_status",
|
|
9038
|
-
"approved_by",
|
|
9039
|
-
"upstream_response",
|
|
9040
|
-
"upstream_error",
|
|
9041
|
-
"upstream_http_status",
|
|
9042
|
-
"upstream_latency_ms",
|
|
9043
|
-
"total_duration_ms",
|
|
9044
|
-
"approval_wait_ms",
|
|
9045
|
-
"proxy_compute_ms",
|
|
9046
|
-
"flagged_destructive",
|
|
9047
|
-
"dry_run",
|
|
9048
|
-
"created_at",
|
|
9049
|
-
"environment",
|
|
9050
|
-
"matched_rule_index",
|
|
9051
|
-
"record_kind",
|
|
9052
|
-
"origin",
|
|
9053
|
-
"metadata"
|
|
9054
|
-
];
|
|
9055
|
-
var FORMULA_PREFIXES = /^[=+\-@\t\r]/;
|
|
9056
|
-
function csvEscape(value) {
|
|
9057
|
-
const needsQuote = value.includes(",") || value.includes("\n") || value.includes('"');
|
|
9058
|
-
const needsFormulaGuard = FORMULA_PREFIXES.test(value);
|
|
9059
|
-
let out = value;
|
|
9060
|
-
if (needsFormulaGuard) out = `'${out}`;
|
|
9061
|
-
if (needsQuote || needsFormulaGuard) out = `"${out.replace(/"/g, '""')}"`;
|
|
9062
|
-
return out;
|
|
9063
|
-
}
|
|
9064
|
-
function recordToRow(record) {
|
|
9065
|
-
return CSV_HEADERS.map((h) => {
|
|
9066
|
-
const val = record[h];
|
|
9067
|
-
if (val === null || val === void 0) return "";
|
|
9068
|
-
if (typeof val === "boolean") return val ? "true" : "false";
|
|
9069
|
-
if (typeof val === "number") return String(val);
|
|
9070
|
-
if (typeof val === "string") return csvEscape(val);
|
|
9071
|
-
if (typeof val === "object") return csvEscape(JSON.stringify(val));
|
|
9072
|
-
return "";
|
|
9073
|
-
}).join(",");
|
|
9074
|
-
}
|
|
9075
|
-
function recordsToCsv(records) {
|
|
9076
|
-
const lines = [CSV_HEADERS.join(",")];
|
|
9077
|
-
for (const r of records) {
|
|
9078
|
-
lines.push(recordToRow(r));
|
|
9079
|
-
}
|
|
9080
|
-
return lines.join("\n");
|
|
9081
|
-
}
|
|
9082
|
-
|
|
9083
9223
|
// src/dashboard/session.ts
|
|
9084
9224
|
import { createHash as createHash3, createHmac as createHmac3, randomBytes, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
9085
9225
|
var DashboardSessionStore = class {
|
|
@@ -9237,6 +9377,10 @@ var budgetEventsQuerySchema = z8.object({
|
|
|
9237
9377
|
limit: clampedQueryInt(50, 1, LIST_MAX_PAGE_SIZE),
|
|
9238
9378
|
offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER)
|
|
9239
9379
|
});
|
|
9380
|
+
var budgetEventsExportQuerySchema = z8.object({
|
|
9381
|
+
format: z8.preprocess((value) => value === "csv" ? "csv" : "json", z8.enum(["json", "csv"])),
|
|
9382
|
+
limit: clampedQueryInt(EXPORT_MAX_RECORDS, 1, EXPORT_MAX_RECORDS)
|
|
9383
|
+
});
|
|
9240
9384
|
var analyticsQuerySchema = z8.object({
|
|
9241
9385
|
from: optionalQueryString,
|
|
9242
9386
|
to: optionalQueryString
|
|
@@ -9548,6 +9692,27 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
9548
9692
|
offset: query.offset
|
|
9549
9693
|
});
|
|
9550
9694
|
});
|
|
9695
|
+
app.get("/api/budgets/:name/events/export", (c) => {
|
|
9696
|
+
const query = budgetEventsExportQuerySchema.parse(c.req.query());
|
|
9697
|
+
const name = c.req.param("name");
|
|
9698
|
+
const page = budgets?.listEventsForExport(name, query.limit) ?? { events: [], total: 0 };
|
|
9699
|
+
const safeName = name.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 64);
|
|
9700
|
+
const filename = safeName ? `helio-budget-${safeName}-events` : "helio-budget-events";
|
|
9701
|
+
if (query.format === "csv") {
|
|
9702
|
+
return new Response(budgetEventsToCsv(page.events), {
|
|
9703
|
+
headers: {
|
|
9704
|
+
"content-type": "text/csv; charset=utf-8",
|
|
9705
|
+
"content-disposition": `attachment; filename="${filename}.csv"`
|
|
9706
|
+
}
|
|
9707
|
+
});
|
|
9708
|
+
}
|
|
9709
|
+
return new Response(JSON.stringify(page.events, null, 2), {
|
|
9710
|
+
headers: {
|
|
9711
|
+
"content-type": "application/json",
|
|
9712
|
+
"content-disposition": `attachment; filename="${filename}.json"`
|
|
9713
|
+
}
|
|
9714
|
+
});
|
|
9715
|
+
});
|
|
9551
9716
|
app.get("/api/analytics", (c) => {
|
|
9552
9717
|
const query = analyticsQuerySchema.parse(c.req.query());
|
|
9553
9718
|
const now = /* @__PURE__ */ new Date();
|