@gethelio/proxy 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -19
- package/dist/cli.js +333 -98
- 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 +291 -130
- 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
|
|
@@ -2910,11 +2979,9 @@ function extractTools(body) {
|
|
|
2910
2979
|
|
|
2911
2980
|
// src/feedback/self-repair.ts
|
|
2912
2981
|
function ruleInfo(rule) {
|
|
2913
|
-
const index = rule?.index ?? null;
|
|
2914
2982
|
return {
|
|
2915
2983
|
rule: rule?.name ?? null,
|
|
2916
|
-
|
|
2917
|
-
rule_index: index
|
|
2984
|
+
rule_index: rule?.index ?? null
|
|
2918
2985
|
};
|
|
2919
2986
|
}
|
|
2920
2987
|
function buildPolicyDeniedFeedback(decision) {
|
|
@@ -4392,29 +4459,33 @@ var GovernedForwarder = class {
|
|
|
4392
4459
|
if (this.spendLimiter && decision.matchedRule?.limits?.maxSpend) {
|
|
4393
4460
|
const maxSpend = decision.matchedRule.limits.maxSpend;
|
|
4394
4461
|
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
|
-
|
|
4462
|
+
if (typeof rawAmount !== "number") {
|
|
4463
|
+
console.error(
|
|
4464
|
+
`[helio] Warning: spend limit field "${maxSpend.field}" did not resolve to a number for tool "${toolName}" (got ${typeof rawAmount}) in dry-run, denying`
|
|
4465
|
+
);
|
|
4466
|
+
wouldForward = false;
|
|
4467
|
+
limitsOk = false;
|
|
4468
|
+
} else if (!Number.isFinite(rawAmount) || rawAmount < 0) {
|
|
4469
|
+
console.error(
|
|
4470
|
+
`[helio] Warning: spend limit field "${maxSpend.field}" resolved to invalid amount (${String(rawAmount)}) for tool "${toolName}" in dry-run, denying`
|
|
4471
|
+
);
|
|
4472
|
+
wouldForward = false;
|
|
4473
|
+
limitsOk = false;
|
|
4474
|
+
} else {
|
|
4475
|
+
const key = this.buildSpendLimitKey(
|
|
4476
|
+
maxSpend.key,
|
|
4477
|
+
toolName,
|
|
4478
|
+
request,
|
|
4479
|
+
decision.matchedRule.index
|
|
4480
|
+
);
|
|
4481
|
+
const peekResult = this.spendLimiter.peek({
|
|
4482
|
+
key,
|
|
4483
|
+
amount: rawAmount,
|
|
4484
|
+
limit: maxSpend.limit,
|
|
4485
|
+
windowMs: maxSpend.windowMs
|
|
4486
|
+
});
|
|
4487
|
+
wouldForward = peekResult.allowed;
|
|
4488
|
+
limitsOk = peekResult.allowed;
|
|
4418
4489
|
}
|
|
4419
4490
|
}
|
|
4420
4491
|
break;
|
|
@@ -5082,16 +5153,26 @@ var BudgetEngine = class {
|
|
|
5082
5153
|
/**
|
|
5083
5154
|
* Resolve which budgets a call feeds and how much it charges each.
|
|
5084
5155
|
*
|
|
5085
|
-
* A
|
|
5086
|
-
*
|
|
5087
|
-
*
|
|
5088
|
-
*
|
|
5156
|
+
* A contributor participates when its tool glob matches the tool name AND
|
|
5157
|
+
* every `match.input` condition holds (absent conditions means the glob
|
|
5158
|
+
* alone decides); the FIRST participating contributor (config order, over
|
|
5159
|
+
* that combined predicate) supplies the amount field. A call that matches
|
|
5160
|
+
* the glob but not the conditions simply does not feed the budget — no
|
|
5161
|
+
* charge, no failure. Once a contributor is selected, a missing,
|
|
5162
|
+
* non-numeric, negative, or non-finite amount fails closed as a `failures`
|
|
5163
|
+
* entry — the caller must deny the call.
|
|
5089
5164
|
*/
|
|
5090
5165
|
resolveCharges(ctx) {
|
|
5091
5166
|
const charges = [];
|
|
5092
5167
|
const failures = [];
|
|
5168
|
+
const matchCtx = {
|
|
5169
|
+
toolName: ctx.toolName,
|
|
5170
|
+
...ctx.toolArguments !== void 0 && { toolArguments: ctx.toolArguments }
|
|
5171
|
+
};
|
|
5093
5172
|
for (const budget of this.budgets.values()) {
|
|
5094
|
-
const contributor = budget.contributors.find(
|
|
5173
|
+
const contributor = budget.contributors.find(
|
|
5174
|
+
(c) => c.match.tool.test(ctx.toolName) && (c.match.input === void 0 || matchInput(c.match.input, matchCtx))
|
|
5175
|
+
);
|
|
5095
5176
|
if (!contributor) continue;
|
|
5096
5177
|
const raw = resolvePath(contributor.field, ctx.toolArguments ?? {});
|
|
5097
5178
|
if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 0) {
|
|
@@ -6085,6 +6166,96 @@ var AuditStore = class {
|
|
|
6085
6166
|
}
|
|
6086
6167
|
};
|
|
6087
6168
|
|
|
6169
|
+
// src/audit/csv.ts
|
|
6170
|
+
var CSV_HEADERS = [
|
|
6171
|
+
"id",
|
|
6172
|
+
"timestamp",
|
|
6173
|
+
"session_id",
|
|
6174
|
+
"agent_id",
|
|
6175
|
+
"tool_name",
|
|
6176
|
+
"tool_input",
|
|
6177
|
+
"policy_decision",
|
|
6178
|
+
"block_reason",
|
|
6179
|
+
"matched_rule",
|
|
6180
|
+
"evidence_chain",
|
|
6181
|
+
"approval_status",
|
|
6182
|
+
"approved_by",
|
|
6183
|
+
"upstream_response",
|
|
6184
|
+
"upstream_error",
|
|
6185
|
+
"upstream_http_status",
|
|
6186
|
+
"upstream_latency_ms",
|
|
6187
|
+
"total_duration_ms",
|
|
6188
|
+
"approval_wait_ms",
|
|
6189
|
+
"proxy_compute_ms",
|
|
6190
|
+
"flagged_destructive",
|
|
6191
|
+
"dry_run",
|
|
6192
|
+
"created_at",
|
|
6193
|
+
"environment",
|
|
6194
|
+
"matched_rule_index",
|
|
6195
|
+
"record_kind",
|
|
6196
|
+
"origin",
|
|
6197
|
+
"metadata"
|
|
6198
|
+
];
|
|
6199
|
+
var FORMULA_PREFIXES = /^[=+\-@\t\r]/;
|
|
6200
|
+
function csvEscape(value) {
|
|
6201
|
+
const needsQuote = value.includes(",") || value.includes("\n") || value.includes('"');
|
|
6202
|
+
const needsFormulaGuard = FORMULA_PREFIXES.test(value);
|
|
6203
|
+
let out = value;
|
|
6204
|
+
if (needsFormulaGuard) out = `'${out}`;
|
|
6205
|
+
if (needsQuote || needsFormulaGuard) out = `"${out.replace(/"/g, '""')}"`;
|
|
6206
|
+
return out;
|
|
6207
|
+
}
|
|
6208
|
+
function recordToRow(record) {
|
|
6209
|
+
return CSV_HEADERS.map((h) => {
|
|
6210
|
+
const val = record[h];
|
|
6211
|
+
if (val === null || val === void 0) return "";
|
|
6212
|
+
if (typeof val === "boolean") return val ? "true" : "false";
|
|
6213
|
+
if (typeof val === "number") return String(val);
|
|
6214
|
+
if (typeof val === "string") return csvEscape(val);
|
|
6215
|
+
if (typeof val === "object") return csvEscape(JSON.stringify(val));
|
|
6216
|
+
return "";
|
|
6217
|
+
}).join(",");
|
|
6218
|
+
}
|
|
6219
|
+
function recordsToCsv(records) {
|
|
6220
|
+
const lines = [CSV_HEADERS.join(",")];
|
|
6221
|
+
for (const r of records) {
|
|
6222
|
+
lines.push(recordToRow(r));
|
|
6223
|
+
}
|
|
6224
|
+
return lines.join("\n");
|
|
6225
|
+
}
|
|
6226
|
+
|
|
6227
|
+
// src/budget/csv.ts
|
|
6228
|
+
var BUDGET_EVENT_CSV_HEADERS = [
|
|
6229
|
+
"id",
|
|
6230
|
+
"budget_name",
|
|
6231
|
+
"bucket_key",
|
|
6232
|
+
"kind",
|
|
6233
|
+
"amount",
|
|
6234
|
+
"currency",
|
|
6235
|
+
"tool_name",
|
|
6236
|
+
"origin",
|
|
6237
|
+
"audit_record_id",
|
|
6238
|
+
"timestamp",
|
|
6239
|
+
"timestamp_ms",
|
|
6240
|
+
"created_at"
|
|
6241
|
+
];
|
|
6242
|
+
function eventToRow(event) {
|
|
6243
|
+
return BUDGET_EVENT_CSV_HEADERS.map((h) => {
|
|
6244
|
+
const val = event[h];
|
|
6245
|
+
if (val === null || val === void 0) return "";
|
|
6246
|
+
if (typeof val === "number") return String(val);
|
|
6247
|
+
if (typeof val === "string") return csvEscape(val);
|
|
6248
|
+
return "";
|
|
6249
|
+
}).join(",");
|
|
6250
|
+
}
|
|
6251
|
+
function budgetEventsToCsv(events) {
|
|
6252
|
+
const lines = [BUDGET_EVENT_CSV_HEADERS.join(",")];
|
|
6253
|
+
for (const e of events) {
|
|
6254
|
+
lines.push(eventToRow(e));
|
|
6255
|
+
}
|
|
6256
|
+
return lines.join("\n");
|
|
6257
|
+
}
|
|
6258
|
+
|
|
6088
6259
|
// src/evidence/store.ts
|
|
6089
6260
|
var EvidenceStore = class _EvidenceStore {
|
|
6090
6261
|
static EVIDENCE_ALLOWLIST_PREVIEW_LIMIT = 20;
|
|
@@ -6817,6 +6988,7 @@ var GovernanceService = class {
|
|
|
6817
6988
|
let wire;
|
|
6818
6989
|
const plans = [];
|
|
6819
6990
|
let limitsBlock;
|
|
6991
|
+
let ruleLimitOk = true;
|
|
6820
6992
|
const reservedThisCall = [];
|
|
6821
6993
|
const reserve = (key) => {
|
|
6822
6994
|
const preexisting = this.senderKeys.has(key);
|
|
@@ -6830,6 +7002,15 @@ var GovernanceService = class {
|
|
|
6830
7002
|
const senderId = senderIdOf(req.metadata);
|
|
6831
7003
|
if (pipeline.isDryRun) {
|
|
6832
7004
|
wire = "dry_run";
|
|
7005
|
+
if (decision.action === "rate_limit") {
|
|
7006
|
+
const planned = this.planRate(decision, toolName, req.session_id, senderId);
|
|
7007
|
+
if (planned?.block) limitsBlock = { rate: planned.block };
|
|
7008
|
+
ruleLimitOk = planned?.allowed ?? true;
|
|
7009
|
+
} else if (decision.action === "spend_limit") {
|
|
7010
|
+
const planned = this.planSpend(decision, toolName, req.session_id, req.arguments, senderId);
|
|
7011
|
+
if (planned?.block) limitsBlock = { spend: planned.block };
|
|
7012
|
+
ruleLimitOk = planned?.allowed ?? true;
|
|
7013
|
+
}
|
|
6833
7014
|
} else if (decision.action === "deny") {
|
|
6834
7015
|
wire = "deny";
|
|
6835
7016
|
} else if (decision.action === "require_approval") {
|
|
@@ -6954,9 +7135,9 @@ var GovernanceService = class {
|
|
|
6954
7135
|
if (limitsBlock) responseBody["limits"] = limitsBlock;
|
|
6955
7136
|
if (wire === "dry_run") {
|
|
6956
7137
|
responseBody["dry_run"] = {
|
|
6957
|
-
would_forward: decision.action === "allow" && !pipeline.evidenceBlocked && budgetDryRunOk,
|
|
7138
|
+
would_forward: (decision.action === "allow" || (decision.action === "rate_limit" || decision.action === "spend_limit") && ruleLimitOk) && !pipeline.evidenceBlocked && budgetDryRunOk,
|
|
6958
7139
|
evidence_satisfied: !pipeline.evidenceBlocked,
|
|
6959
|
-
limits_ok: budgetDryRunOk
|
|
7140
|
+
limits_ok: ruleLimitOk && budgetDryRunOk
|
|
6960
7141
|
};
|
|
6961
7142
|
}
|
|
6962
7143
|
if (pipeline.driftEvent) {
|
|
@@ -6978,7 +7159,11 @@ var GovernanceService = class {
|
|
|
6978
7159
|
flaggedDestructive: pipeline.flaggedDestructive,
|
|
6979
7160
|
dryRun: wire === "dry_run",
|
|
6980
7161
|
recordKind: "tool_call",
|
|
6981
|
-
|
|
7162
|
+
// A CLONE, for the same reason budgetsAtEvaluate is one below:
|
|
7163
|
+
// limitsBlock is also the response's `limits`, and the audit writer
|
|
7164
|
+
// buffers records by reference until flush — a direct embedder
|
|
7165
|
+
// editing the returned body must not be able to rewrite evidence.
|
|
7166
|
+
limitsChain: limitsBlock ? structuredClone(limitsBlock) : void 0
|
|
6982
7167
|
});
|
|
6983
7168
|
this.tombstones.set(evaluationId, {
|
|
6984
7169
|
auditRecordId: auditId,
|
|
@@ -7480,6 +7665,7 @@ var GovernanceService = class {
|
|
|
7480
7665
|
this.approvalRouter?.resolveNativeTicket(entry.approvalTicketId, "timeout");
|
|
7481
7666
|
this.snapshotTicketResolution(entry);
|
|
7482
7667
|
}
|
|
7668
|
+
const committed = entry.commitState;
|
|
7483
7669
|
const resolution = entry.ticketResolution;
|
|
7484
7670
|
const approvalContext = entry.approvalTicketId && resolution && (resolution.denialReason || resolution.escalatedAt) ? {
|
|
7485
7671
|
ticket_id: entry.approvalTicketId,
|
|
@@ -7490,6 +7676,7 @@ var GovernanceService = class {
|
|
|
7490
7676
|
} : {}
|
|
7491
7677
|
} : void 0;
|
|
7492
7678
|
const auditId = this.writeAudit({
|
|
7679
|
+
...committed ? { id: committed.auditId } : {},
|
|
7493
7680
|
timestampIso: entry.timestampIso,
|
|
7494
7681
|
origin: entry.origin,
|
|
7495
7682
|
agentId: entry.agentId,
|
|
@@ -7507,8 +7694,9 @@ var GovernanceService = class {
|
|
|
7507
7694
|
approvalStatus: resolution?.status ?? null,
|
|
7508
7695
|
approvedBy: resolution?.resolvedBy ?? null,
|
|
7509
7696
|
approvalContext,
|
|
7510
|
-
limitsChain:
|
|
7511
|
-
sidebandUnreported: true
|
|
7697
|
+
limitsChain: committed ? committed.limitsChain : entry.budgetsAtEvaluate ? { budgets: entry.budgetsAtEvaluate } : void 0,
|
|
7698
|
+
sidebandUnreported: true,
|
|
7699
|
+
sidebandCommitted: committed !== void 0
|
|
7512
7700
|
});
|
|
7513
7701
|
this.discardPending(entry);
|
|
7514
7702
|
this.tombstones.set(entry.evaluationId, {
|
|
@@ -7518,7 +7706,7 @@ var GovernanceService = class {
|
|
|
7518
7706
|
expiresAtMs: now + this.ttlMs
|
|
7519
7707
|
});
|
|
7520
7708
|
console.error(
|
|
7521
|
-
`[helio] Sideband evaluation ${entry.evaluationId} expired without /audit (origin=${entry.origin}, tool=${entry.toolName}) \u2014 recorded as evaluation_expired`
|
|
7709
|
+
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
7710
|
);
|
|
7523
7711
|
return "expired";
|
|
7524
7712
|
}
|
|
@@ -7700,7 +7888,13 @@ var GovernanceService = class {
|
|
|
7700
7888
|
const blockReason = deriveBlockReason(args);
|
|
7701
7889
|
let evidenceChain = args.limitsChain ?? null;
|
|
7702
7890
|
if (args.sidebandUnreported) {
|
|
7703
|
-
evidenceChain = {
|
|
7891
|
+
evidenceChain = {
|
|
7892
|
+
...evidenceChain ?? {},
|
|
7893
|
+
sideband: {
|
|
7894
|
+
unreported: true,
|
|
7895
|
+
...args.sidebandCommitted ? { committed: true } : {}
|
|
7896
|
+
}
|
|
7897
|
+
};
|
|
7704
7898
|
}
|
|
7705
7899
|
if (args.approvalContext) {
|
|
7706
7900
|
evidenceChain = { ...evidenceChain ?? {}, approval: { ...args.approvalContext } };
|
|
@@ -9022,64 +9216,6 @@ import { cors } from "hono/cors";
|
|
|
9022
9216
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
9023
9217
|
import { streamSSE } from "hono/streaming";
|
|
9024
9218
|
|
|
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
9219
|
// src/dashboard/session.ts
|
|
9084
9220
|
import { createHash as createHash3, createHmac as createHmac3, randomBytes, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
9085
9221
|
var DashboardSessionStore = class {
|
|
@@ -9237,6 +9373,10 @@ var budgetEventsQuerySchema = z8.object({
|
|
|
9237
9373
|
limit: clampedQueryInt(50, 1, LIST_MAX_PAGE_SIZE),
|
|
9238
9374
|
offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER)
|
|
9239
9375
|
});
|
|
9376
|
+
var budgetEventsExportQuerySchema = z8.object({
|
|
9377
|
+
format: z8.preprocess((value) => value === "csv" ? "csv" : "json", z8.enum(["json", "csv"])),
|
|
9378
|
+
limit: clampedQueryInt(EXPORT_MAX_RECORDS, 1, EXPORT_MAX_RECORDS)
|
|
9379
|
+
});
|
|
9240
9380
|
var analyticsQuerySchema = z8.object({
|
|
9241
9381
|
from: optionalQueryString,
|
|
9242
9382
|
to: optionalQueryString
|
|
@@ -9548,6 +9688,27 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
9548
9688
|
offset: query.offset
|
|
9549
9689
|
});
|
|
9550
9690
|
});
|
|
9691
|
+
app.get("/api/budgets/:name/events/export", (c) => {
|
|
9692
|
+
const query = budgetEventsExportQuerySchema.parse(c.req.query());
|
|
9693
|
+
const name = c.req.param("name");
|
|
9694
|
+
const page = budgets?.listEventsForExport(name, query.limit) ?? { events: [], total: 0 };
|
|
9695
|
+
const safeName = name.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 64);
|
|
9696
|
+
const filename = safeName ? `helio-budget-${safeName}-events` : "helio-budget-events";
|
|
9697
|
+
if (query.format === "csv") {
|
|
9698
|
+
return new Response(budgetEventsToCsv(page.events), {
|
|
9699
|
+
headers: {
|
|
9700
|
+
"content-type": "text/csv; charset=utf-8",
|
|
9701
|
+
"content-disposition": `attachment; filename="${filename}.csv"`
|
|
9702
|
+
}
|
|
9703
|
+
});
|
|
9704
|
+
}
|
|
9705
|
+
return new Response(JSON.stringify(page.events, null, 2), {
|
|
9706
|
+
headers: {
|
|
9707
|
+
"content-type": "application/json",
|
|
9708
|
+
"content-disposition": `attachment; filename="${filename}.json"`
|
|
9709
|
+
}
|
|
9710
|
+
});
|
|
9711
|
+
});
|
|
9551
9712
|
app.get("/api/analytics", (c) => {
|
|
9552
9713
|
const query = analyticsQuerySchema.parse(c.req.query());
|
|
9553
9714
|
const now = /* @__PURE__ */ new Date();
|