@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/cli.js
CHANGED
|
@@ -49,7 +49,7 @@ var upstreamSchema = z.object({
|
|
|
49
49
|
request_timeout: durationSchema.default("30s"),
|
|
50
50
|
forward_headers: z.array(z.string().min(1)).default([]),
|
|
51
51
|
headers: z.record(z.string(), z.string()).default({})
|
|
52
|
-
}).refine((data) => data.transport !== "stdio" || data.command !== void 0, {
|
|
52
|
+
}).strict().refine((data) => data.transport !== "stdio" || data.command !== void 0, {
|
|
53
53
|
message: '"command" is required when transport is "stdio"',
|
|
54
54
|
path: ["command"]
|
|
55
55
|
}).superRefine((data, ctx) => {
|
|
@@ -82,7 +82,7 @@ var upstreamSchema = z.object({
|
|
|
82
82
|
var listenSchema = z.object({
|
|
83
83
|
port: z.number().int().min(1).max(65535).default(3e3),
|
|
84
84
|
host: z.string().default("127.0.0.1")
|
|
85
|
-
});
|
|
85
|
+
}).strict();
|
|
86
86
|
function isLoopbackHost(host) {
|
|
87
87
|
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
88
88
|
}
|
|
@@ -96,7 +96,7 @@ var dashboardSchema = z.object({
|
|
|
96
96
|
api_secret: z.string().optional(),
|
|
97
97
|
allow_open_mode: z.boolean().default(false),
|
|
98
98
|
sse_heartbeat_interval: durationSchema.default("30s")
|
|
99
|
-
});
|
|
99
|
+
}).strict();
|
|
100
100
|
var inputConditionSchema = z.object({
|
|
101
101
|
eq: z.unknown().optional(),
|
|
102
102
|
neq: z.unknown().optional(),
|
|
@@ -226,12 +226,27 @@ var policiesSchema = z.object({
|
|
|
226
226
|
*/
|
|
227
227
|
hot_reload: z.boolean().optional()
|
|
228
228
|
}).strict();
|
|
229
|
-
var
|
|
229
|
+
var budgetContributorMatchSchema = z.object({
|
|
230
230
|
tool: z.string().min(1),
|
|
231
231
|
// picomatch glob, same engine as match.tool
|
|
232
|
+
// Same operators and AND-combination as rule `match.input`. Other rule
|
|
233
|
+
// matchers (annotations, environment, metadata) stay strict-rejected
|
|
234
|
+
// until the budget charge context can actually evaluate them.
|
|
235
|
+
input: z.record(z.string(), inputConditionSchema).optional()
|
|
236
|
+
}).strict();
|
|
237
|
+
var modernBudgetContributorSchema = z.object({
|
|
238
|
+
match: budgetContributorMatchSchema,
|
|
232
239
|
field: z.string().min(1)
|
|
233
240
|
// dot-path into tool arguments, e.g. "$.amount"
|
|
234
241
|
}).strict();
|
|
242
|
+
var budgetContributorSchema = z.unknown().superRefine((raw, ctx) => {
|
|
243
|
+
if (raw !== null && typeof raw === "object" && "tool" in raw) {
|
|
244
|
+
ctx.addIssue({
|
|
245
|
+
code: "custom",
|
|
246
|
+
message: 'contributor "tool" moved under "match" in v0.11.0 \u2014 write { match: { tool: "<glob>" }, field: "<path>" }'
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}).pipe(modernBudgetContributorSchema);
|
|
235
250
|
var budgetSchema = z.object({
|
|
236
251
|
// The name is embedded in bucket keys (`budget:<name>:<scope>`) and, later,
|
|
237
252
|
// ledger rows — constrain it so keys stay parseable and scope classification
|
|
@@ -290,17 +305,17 @@ var slackChannelSchema = z.object({
|
|
|
290
305
|
bot_token: z.string(),
|
|
291
306
|
signing_secret: z.string(),
|
|
292
307
|
channel: z.string()
|
|
293
|
-
});
|
|
308
|
+
}).strict();
|
|
294
309
|
var webhookChannelSchema = z.object({
|
|
295
310
|
type: z.literal("webhook"),
|
|
296
311
|
name: z.string().min(1).optional(),
|
|
297
312
|
url: z.string(),
|
|
298
313
|
secret: z.string().optional()
|
|
299
|
-
});
|
|
314
|
+
}).strict();
|
|
300
315
|
var dashboardChannelSchema = z.object({
|
|
301
316
|
type: z.literal("dashboard"),
|
|
302
317
|
name: z.string().min(1).optional()
|
|
303
|
-
});
|
|
318
|
+
}).strict();
|
|
304
319
|
var approvalChannelSchema = z.discriminatedUnion("type", [
|
|
305
320
|
slackChannelSchema,
|
|
306
321
|
webhookChannelSchema,
|
|
@@ -310,13 +325,13 @@ var approvalSchema = z.object({
|
|
|
310
325
|
timeout: durationSchema.default("300s"),
|
|
311
326
|
default_on_timeout: z.enum(["deny", "allow"]).default("deny"),
|
|
312
327
|
channels: z.array(approvalChannelSchema).default([])
|
|
313
|
-
});
|
|
328
|
+
}).strict();
|
|
314
329
|
var auditSchema = z.object({
|
|
315
330
|
storage: z.enum(["sqlite"]).default("sqlite"),
|
|
316
331
|
path: z.string().default("./helio-audit.db"),
|
|
317
332
|
retention: durationSchema.default("90d"),
|
|
318
333
|
include_responses: z.boolean().default(true)
|
|
319
|
-
});
|
|
334
|
+
}).strict();
|
|
320
335
|
var sdkSchema = z.object({
|
|
321
336
|
enabled: z.boolean().default(false),
|
|
322
337
|
port: z.number().int().min(1).max(65535).default(3200),
|
|
@@ -328,12 +343,11 @@ var sdkSchema = z.object({
|
|
|
328
343
|
* decided-allowed call from the trail.
|
|
329
344
|
*/
|
|
330
345
|
evaluation_ttl: durationSchema.default("10m")
|
|
331
|
-
});
|
|
346
|
+
}).strict();
|
|
332
347
|
var helioConfigBaseSchema = z.object({
|
|
333
348
|
version: z.literal("1"),
|
|
334
349
|
upstream: upstreamSchema,
|
|
335
350
|
listen: listenSchema.prefault({}),
|
|
336
|
-
dashboard: dashboardSchema.prefault({}),
|
|
337
351
|
environment: z.string().optional(),
|
|
338
352
|
policies: policiesSchema.prefault({}),
|
|
339
353
|
// Budgets sit beside policies deliberately: they are the second half of the
|
|
@@ -341,9 +355,18 @@ var helioConfigBaseSchema = z.object({
|
|
|
341
355
|
budgets: z.array(budgetSchema).default([]),
|
|
342
356
|
approval: approvalSchema.prefault({}),
|
|
343
357
|
audit: auditSchema.prefault({}),
|
|
358
|
+
// Dashboard follows audit deliberately: an operator surface, not part of
|
|
359
|
+
// the request path (canonical section order, #89/#163).
|
|
360
|
+
dashboard: dashboardSchema.prefault({}),
|
|
344
361
|
sdk: sdkSchema.prefault({})
|
|
345
|
-
});
|
|
346
|
-
|
|
362
|
+
}).strict();
|
|
363
|
+
function stripRootExtensionKeys(value) {
|
|
364
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
|
|
365
|
+
return Object.fromEntries(
|
|
366
|
+
Object.entries(value).filter(([key]) => !key.startsWith("x-"))
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
var helioConfigRefinedSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
|
|
347
370
|
const hasConfiguredEnvironment = typeof cfg.environment === "string" && cfg.environment.trim().length > 0;
|
|
348
371
|
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");
|
|
349
372
|
const hasSecret = hasDashboardApiSecret(cfg.dashboard.api_secret);
|
|
@@ -458,6 +481,22 @@ var helioConfigSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
|
|
|
458
481
|
message: "dashboard.enabled must be true when approval.channels includes a webhook channel. Webhook notifications require the dashboard sideband approval API."
|
|
459
482
|
});
|
|
460
483
|
}
|
|
484
|
+
if (!cfg.dashboard.enabled) {
|
|
485
|
+
if (cfg.policies.flag_destructive === "require_approval") {
|
|
486
|
+
ctx.addIssue({
|
|
487
|
+
code: "custom",
|
|
488
|
+
path: ["policies", "flag_destructive"],
|
|
489
|
+
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".'
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
if (cfg.policies.on_tool_drift === "require_approval") {
|
|
493
|
+
ctx.addIssue({
|
|
494
|
+
code: "custom",
|
|
495
|
+
path: ["policies", "on_tool_drift"],
|
|
496
|
+
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".'
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
}
|
|
461
500
|
for (const [ruleIndex, rule] of cfg.policies.rules.entries()) {
|
|
462
501
|
if (rule.match.environment !== void 0 && !hasConfiguredEnvironment) {
|
|
463
502
|
ctx.addIssue({
|
|
@@ -513,6 +552,28 @@ var helioConfigSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
|
|
|
513
552
|
message: `Unknown approval channel "${channel}". Add it to approval.channels (type or name), or use "dashboard".`
|
|
514
553
|
});
|
|
515
554
|
}
|
|
555
|
+
if (rule.action === "require_approval" && !cfg.dashboard.enabled && rule.match.metadata === void 0) {
|
|
556
|
+
const effectiveChannel = rule.approval?.channel ?? "dashboard";
|
|
557
|
+
if (resolvesToDashboard(effectiveChannel)) {
|
|
558
|
+
ctx.addIssue({
|
|
559
|
+
code: "custom",
|
|
560
|
+
path: rule.approval?.channel !== void 0 ? ["policies", "rules", ruleIndex, "approval", "channel"] : ["policies", "rules", ruleIndex, "action"],
|
|
561
|
+
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."
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
const escalationAfterMs = rule.approval?.escalation_after !== void 0 ? parseDuration(rule.approval.escalation_after) : void 0;
|
|
565
|
+
const effectiveTimeoutMs = parseDuration(rule.approval?.timeout ?? cfg.approval.timeout);
|
|
566
|
+
const escalationCanFire = escalationAfterMs !== void 0 && escalationAfterMs > 0 && escalationAfterMs < effectiveTimeoutMs;
|
|
567
|
+
for (const [delegateIndex, delegate] of (rule.approval?.delegates ?? []).entries()) {
|
|
568
|
+
if (escalationCanFire && knownChannelKeys.has(delegate) && resolvesToDashboard(delegate)) {
|
|
569
|
+
ctx.addIssue({
|
|
570
|
+
code: "custom",
|
|
571
|
+
path: ["policies", "rules", ruleIndex, "approval", "delegates", delegateIndex],
|
|
572
|
+
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."
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
516
577
|
const delegates = rule.approval?.delegates;
|
|
517
578
|
if (!delegates) continue;
|
|
518
579
|
for (const [delegateIndex, delegate] of delegates.entries()) {
|
|
@@ -526,6 +587,7 @@ var helioConfigSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
|
|
|
526
587
|
}
|
|
527
588
|
}
|
|
528
589
|
});
|
|
590
|
+
var helioConfigSchema = z.preprocess(stripRootExtensionKeys, helioConfigRefinedSchema);
|
|
529
591
|
|
|
530
592
|
// src/config/loader.ts
|
|
531
593
|
import { readFile } from "fs/promises";
|
|
@@ -588,7 +650,9 @@ async function loadConfig(filePath, env) {
|
|
|
588
650
|
const interpolated = interpolateEnvVars(parsed, env);
|
|
589
651
|
const result = helioConfigSchema.safeParse(interpolated);
|
|
590
652
|
if (!result.success) {
|
|
591
|
-
const details = formatZodErrors(result.error)
|
|
653
|
+
const details = formatZodErrors(result.error).map(
|
|
654
|
+
(d) => d.path === "" ? { ...d, path: "(top level)" } : d
|
|
655
|
+
);
|
|
592
656
|
const count = details.length;
|
|
593
657
|
throw new ConfigError(
|
|
594
658
|
`Invalid configuration (${String(count)} error${count === 1 ? "" : "s"})`,
|
|
@@ -808,7 +872,7 @@ function compileToolMatcher(pattern, ruleIndex, ruleName) {
|
|
|
808
872
|
throw new PolicyParseError(`invalid glob pattern "${pattern}": ${message}`, ruleIndex, ruleName);
|
|
809
873
|
}
|
|
810
874
|
}
|
|
811
|
-
function
|
|
875
|
+
function flattenInputConditionsWith(input, makeError) {
|
|
812
876
|
const conditions = [];
|
|
813
877
|
for (const [path, conditionObj] of Object.entries(input)) {
|
|
814
878
|
for (const op of INPUT_OPERATORS) {
|
|
@@ -816,17 +880,11 @@ function flattenInputConditions(input, ruleIndex, ruleName) {
|
|
|
816
880
|
if (value === void 0) continue;
|
|
817
881
|
if (op === "regex") {
|
|
818
882
|
if (typeof value !== "string") {
|
|
819
|
-
throw
|
|
820
|
-
`regex value for input path "${path}" must be a string`,
|
|
821
|
-
ruleIndex,
|
|
822
|
-
ruleName
|
|
823
|
-
);
|
|
883
|
+
throw makeError(`regex value for input path "${path}" must be a string`);
|
|
824
884
|
}
|
|
825
885
|
if (!safeRegex(value)) {
|
|
826
|
-
throw
|
|
827
|
-
`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
|
|
828
|
-
ruleIndex,
|
|
829
|
-
ruleName
|
|
886
|
+
throw makeError(
|
|
887
|
+
`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.`
|
|
830
888
|
);
|
|
831
889
|
}
|
|
832
890
|
let compiledRegex;
|
|
@@ -834,11 +892,7 @@ function flattenInputConditions(input, ruleIndex, ruleName) {
|
|
|
834
892
|
compiledRegex = new RegExp(value);
|
|
835
893
|
} catch (err) {
|
|
836
894
|
const msg = err instanceof Error ? err.message : String(err);
|
|
837
|
-
throw
|
|
838
|
-
`invalid regex "${value}" for input path "${path}": ${msg}`,
|
|
839
|
-
ruleIndex,
|
|
840
|
-
ruleName
|
|
841
|
-
);
|
|
895
|
+
throw makeError(`invalid regex "${value}" for input path "${path}": ${msg}`);
|
|
842
896
|
}
|
|
843
897
|
conditions.push({ path, operator: op, value, regex: compiledRegex });
|
|
844
898
|
} else {
|
|
@@ -848,6 +902,12 @@ function flattenInputConditions(input, ruleIndex, ruleName) {
|
|
|
848
902
|
}
|
|
849
903
|
return conditions;
|
|
850
904
|
}
|
|
905
|
+
function flattenInputConditions(input, ruleIndex, ruleName) {
|
|
906
|
+
return flattenInputConditionsWith(
|
|
907
|
+
input,
|
|
908
|
+
(message) => new PolicyParseError(message, ruleIndex, ruleName)
|
|
909
|
+
);
|
|
910
|
+
}
|
|
851
911
|
function flattenMetadataConditions(metadata, ruleIndex, ruleName) {
|
|
852
912
|
const conditions = [];
|
|
853
913
|
for (const [key, raw] of Object.entries(metadata)) {
|
|
@@ -981,21 +1041,30 @@ function compileBudgets(budgets) {
|
|
|
981
1041
|
onExceed: budget.on_exceed,
|
|
982
1042
|
...budget.approval !== void 0 && { approval: compileApproval(budget.approval) },
|
|
983
1043
|
contributors: budget.contributors.map(
|
|
984
|
-
(contributor) => compileContributor(contributor, budget.name)
|
|
1044
|
+
(contributor, index) => compileContributor(contributor, budget.name, index)
|
|
985
1045
|
)
|
|
986
1046
|
}));
|
|
987
1047
|
}
|
|
988
|
-
function compileContributor(contributor, budgetName) {
|
|
1048
|
+
function compileContributor(contributor, budgetName, index) {
|
|
1049
|
+
let tool;
|
|
989
1050
|
try {
|
|
990
|
-
const test = picomatch2(contributor.tool, { dot: true });
|
|
991
|
-
|
|
1051
|
+
const test = picomatch2(contributor.match.tool, { dot: true });
|
|
1052
|
+
tool = { pattern: contributor.match.tool, test };
|
|
992
1053
|
} catch (err) {
|
|
993
1054
|
const message = err instanceof Error ? err.message : String(err);
|
|
994
1055
|
throw new BudgetParseError(
|
|
995
|
-
`invalid contributor glob "${contributor.tool}": ${message}`,
|
|
1056
|
+
`invalid contributor glob "${contributor.match.tool}": ${message}`,
|
|
996
1057
|
budgetName
|
|
997
1058
|
);
|
|
998
1059
|
}
|
|
1060
|
+
const input = contributor.match.input !== void 0 ? flattenInputConditionsWith(
|
|
1061
|
+
contributor.match.input,
|
|
1062
|
+
(message) => new BudgetParseError(`contributor ${String(index)}: ${message}`, budgetName)
|
|
1063
|
+
) : void 0;
|
|
1064
|
+
return {
|
|
1065
|
+
match: { tool, ...input !== void 0 && { input } },
|
|
1066
|
+
field: contributor.field
|
|
1067
|
+
};
|
|
999
1068
|
}
|
|
1000
1069
|
|
|
1001
1070
|
// src/config/watcher.ts
|
|
@@ -1003,6 +1072,7 @@ var ConfigWatcher = class {
|
|
|
1003
1072
|
configPath;
|
|
1004
1073
|
onReload;
|
|
1005
1074
|
onError;
|
|
1075
|
+
onReady;
|
|
1006
1076
|
initialConfig;
|
|
1007
1077
|
env;
|
|
1008
1078
|
debounceMs;
|
|
@@ -1012,6 +1082,7 @@ var ConfigWatcher = class {
|
|
|
1012
1082
|
this.configPath = options.configPath;
|
|
1013
1083
|
this.onReload = options.onReload;
|
|
1014
1084
|
this.onError = options.onError;
|
|
1085
|
+
this.onReady = options.onReady;
|
|
1015
1086
|
this.initialConfig = options.initialConfig;
|
|
1016
1087
|
this.env = options.env;
|
|
1017
1088
|
this.debounceMs = options.debounceMs ?? 200;
|
|
@@ -1027,6 +1098,9 @@ var ConfigWatcher = class {
|
|
|
1027
1098
|
this.watcher.on("change", () => {
|
|
1028
1099
|
this.scheduleReload();
|
|
1029
1100
|
});
|
|
1101
|
+
this.watcher.on("ready", () => {
|
|
1102
|
+
if (this.watcher && this.onReady) this.onReady();
|
|
1103
|
+
});
|
|
1030
1104
|
}
|
|
1031
1105
|
/** Stop watching and clean up resources. */
|
|
1032
1106
|
close() {
|
|
@@ -3116,11 +3190,9 @@ function extractTools(body) {
|
|
|
3116
3190
|
|
|
3117
3191
|
// src/feedback/self-repair.ts
|
|
3118
3192
|
function ruleInfo(rule) {
|
|
3119
|
-
const index = rule?.index ?? null;
|
|
3120
3193
|
return {
|
|
3121
3194
|
rule: rule?.name ?? null,
|
|
3122
|
-
|
|
3123
|
-
rule_index: index
|
|
3195
|
+
rule_index: rule?.index ?? null
|
|
3124
3196
|
};
|
|
3125
3197
|
}
|
|
3126
3198
|
function buildPolicyDeniedFeedback(decision) {
|
|
@@ -4598,29 +4670,33 @@ var GovernedForwarder = class {
|
|
|
4598
4670
|
if (this.spendLimiter && decision.matchedRule?.limits?.maxSpend) {
|
|
4599
4671
|
const maxSpend = decision.matchedRule.limits.maxSpend;
|
|
4600
4672
|
const rawAmount = resolvePath(maxSpend.field, toolArguments ?? {});
|
|
4601
|
-
if (typeof rawAmount
|
|
4602
|
-
|
|
4603
|
-
|
|
4604
|
-
|
|
4605
|
-
|
|
4606
|
-
|
|
4607
|
-
|
|
4608
|
-
|
|
4609
|
-
|
|
4610
|
-
|
|
4611
|
-
|
|
4612
|
-
|
|
4613
|
-
|
|
4614
|
-
|
|
4615
|
-
|
|
4616
|
-
|
|
4617
|
-
|
|
4618
|
-
|
|
4619
|
-
|
|
4620
|
-
|
|
4621
|
-
|
|
4622
|
-
|
|
4623
|
-
|
|
4673
|
+
if (typeof rawAmount !== "number") {
|
|
4674
|
+
console.error(
|
|
4675
|
+
`[helio] Warning: spend limit field "${maxSpend.field}" did not resolve to a number for tool "${toolName}" (got ${typeof rawAmount}) in dry-run, denying`
|
|
4676
|
+
);
|
|
4677
|
+
wouldForward = false;
|
|
4678
|
+
limitsOk = false;
|
|
4679
|
+
} else if (!Number.isFinite(rawAmount) || rawAmount < 0) {
|
|
4680
|
+
console.error(
|
|
4681
|
+
`[helio] Warning: spend limit field "${maxSpend.field}" resolved to invalid amount (${String(rawAmount)}) for tool "${toolName}" in dry-run, denying`
|
|
4682
|
+
);
|
|
4683
|
+
wouldForward = false;
|
|
4684
|
+
limitsOk = false;
|
|
4685
|
+
} else {
|
|
4686
|
+
const key = this.buildSpendLimitKey(
|
|
4687
|
+
maxSpend.key,
|
|
4688
|
+
toolName,
|
|
4689
|
+
request,
|
|
4690
|
+
decision.matchedRule.index
|
|
4691
|
+
);
|
|
4692
|
+
const peekResult = this.spendLimiter.peek({
|
|
4693
|
+
key,
|
|
4694
|
+
amount: rawAmount,
|
|
4695
|
+
limit: maxSpend.limit,
|
|
4696
|
+
windowMs: maxSpend.windowMs
|
|
4697
|
+
});
|
|
4698
|
+
wouldForward = peekResult.allowed;
|
|
4699
|
+
limitsOk = peekResult.allowed;
|
|
4624
4700
|
}
|
|
4625
4701
|
}
|
|
4626
4702
|
break;
|
|
@@ -6612,6 +6688,7 @@ var GovernanceService = class {
|
|
|
6612
6688
|
let wire;
|
|
6613
6689
|
const plans = [];
|
|
6614
6690
|
let limitsBlock;
|
|
6691
|
+
let ruleLimitOk = true;
|
|
6615
6692
|
const reservedThisCall = [];
|
|
6616
6693
|
const reserve = (key) => {
|
|
6617
6694
|
const preexisting = this.senderKeys.has(key);
|
|
@@ -6625,6 +6702,15 @@ var GovernanceService = class {
|
|
|
6625
6702
|
const senderId = senderIdOf(req.metadata);
|
|
6626
6703
|
if (pipeline.isDryRun) {
|
|
6627
6704
|
wire = "dry_run";
|
|
6705
|
+
if (decision.action === "rate_limit") {
|
|
6706
|
+
const planned = this.planRate(decision, toolName, req.session_id, senderId);
|
|
6707
|
+
if (planned?.block) limitsBlock = { rate: planned.block };
|
|
6708
|
+
ruleLimitOk = planned?.allowed ?? true;
|
|
6709
|
+
} else if (decision.action === "spend_limit") {
|
|
6710
|
+
const planned = this.planSpend(decision, toolName, req.session_id, req.arguments, senderId);
|
|
6711
|
+
if (planned?.block) limitsBlock = { spend: planned.block };
|
|
6712
|
+
ruleLimitOk = planned?.allowed ?? true;
|
|
6713
|
+
}
|
|
6628
6714
|
} else if (decision.action === "deny") {
|
|
6629
6715
|
wire = "deny";
|
|
6630
6716
|
} else if (decision.action === "require_approval") {
|
|
@@ -6749,9 +6835,9 @@ var GovernanceService = class {
|
|
|
6749
6835
|
if (limitsBlock) responseBody["limits"] = limitsBlock;
|
|
6750
6836
|
if (wire === "dry_run") {
|
|
6751
6837
|
responseBody["dry_run"] = {
|
|
6752
|
-
would_forward: decision.action === "allow" && !pipeline.evidenceBlocked && budgetDryRunOk,
|
|
6838
|
+
would_forward: (decision.action === "allow" || (decision.action === "rate_limit" || decision.action === "spend_limit") && ruleLimitOk) && !pipeline.evidenceBlocked && budgetDryRunOk,
|
|
6753
6839
|
evidence_satisfied: !pipeline.evidenceBlocked,
|
|
6754
|
-
limits_ok: budgetDryRunOk
|
|
6840
|
+
limits_ok: ruleLimitOk && budgetDryRunOk
|
|
6755
6841
|
};
|
|
6756
6842
|
}
|
|
6757
6843
|
if (pipeline.driftEvent) {
|
|
@@ -6773,7 +6859,11 @@ var GovernanceService = class {
|
|
|
6773
6859
|
flaggedDestructive: pipeline.flaggedDestructive,
|
|
6774
6860
|
dryRun: wire === "dry_run",
|
|
6775
6861
|
recordKind: "tool_call",
|
|
6776
|
-
|
|
6862
|
+
// A CLONE, for the same reason budgetsAtEvaluate is one below:
|
|
6863
|
+
// limitsBlock is also the response's `limits`, and the audit writer
|
|
6864
|
+
// buffers records by reference until flush — a direct embedder
|
|
6865
|
+
// editing the returned body must not be able to rewrite evidence.
|
|
6866
|
+
limitsChain: limitsBlock ? structuredClone(limitsBlock) : void 0
|
|
6777
6867
|
});
|
|
6778
6868
|
this.tombstones.set(evaluationId, {
|
|
6779
6869
|
auditRecordId: auditId,
|
|
@@ -7275,6 +7365,7 @@ var GovernanceService = class {
|
|
|
7275
7365
|
this.approvalRouter?.resolveNativeTicket(entry.approvalTicketId, "timeout");
|
|
7276
7366
|
this.snapshotTicketResolution(entry);
|
|
7277
7367
|
}
|
|
7368
|
+
const committed = entry.commitState;
|
|
7278
7369
|
const resolution = entry.ticketResolution;
|
|
7279
7370
|
const approvalContext = entry.approvalTicketId && resolution && (resolution.denialReason || resolution.escalatedAt) ? {
|
|
7280
7371
|
ticket_id: entry.approvalTicketId,
|
|
@@ -7285,6 +7376,7 @@ var GovernanceService = class {
|
|
|
7285
7376
|
} : {}
|
|
7286
7377
|
} : void 0;
|
|
7287
7378
|
const auditId = this.writeAudit({
|
|
7379
|
+
...committed ? { id: committed.auditId } : {},
|
|
7288
7380
|
timestampIso: entry.timestampIso,
|
|
7289
7381
|
origin: entry.origin,
|
|
7290
7382
|
agentId: entry.agentId,
|
|
@@ -7302,8 +7394,9 @@ var GovernanceService = class {
|
|
|
7302
7394
|
approvalStatus: resolution?.status ?? null,
|
|
7303
7395
|
approvedBy: resolution?.resolvedBy ?? null,
|
|
7304
7396
|
approvalContext,
|
|
7305
|
-
limitsChain:
|
|
7306
|
-
sidebandUnreported: true
|
|
7397
|
+
limitsChain: committed ? committed.limitsChain : entry.budgetsAtEvaluate ? { budgets: entry.budgetsAtEvaluate } : void 0,
|
|
7398
|
+
sidebandUnreported: true,
|
|
7399
|
+
sidebandCommitted: committed !== void 0
|
|
7307
7400
|
});
|
|
7308
7401
|
this.discardPending(entry);
|
|
7309
7402
|
this.tombstones.set(entry.evaluationId, {
|
|
@@ -7313,7 +7406,7 @@ var GovernanceService = class {
|
|
|
7313
7406
|
expiresAtMs: now + this.ttlMs
|
|
7314
7407
|
});
|
|
7315
7408
|
console.error(
|
|
7316
|
-
`[helio] Sideband evaluation ${entry.evaluationId} expired without /audit (origin=${entry.origin}, tool=${entry.toolName}) \u2014 recorded as evaluation_expired`
|
|
7409
|
+
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`
|
|
7317
7410
|
);
|
|
7318
7411
|
return "expired";
|
|
7319
7412
|
}
|
|
@@ -7495,7 +7588,13 @@ var GovernanceService = class {
|
|
|
7495
7588
|
const blockReason = deriveBlockReason(args);
|
|
7496
7589
|
let evidenceChain = args.limitsChain ?? null;
|
|
7497
7590
|
if (args.sidebandUnreported) {
|
|
7498
|
-
evidenceChain = {
|
|
7591
|
+
evidenceChain = {
|
|
7592
|
+
...evidenceChain ?? {},
|
|
7593
|
+
sideband: {
|
|
7594
|
+
unreported: true,
|
|
7595
|
+
...args.sidebandCommitted ? { committed: true } : {}
|
|
7596
|
+
}
|
|
7597
|
+
};
|
|
7499
7598
|
}
|
|
7500
7599
|
if (args.approvalContext) {
|
|
7501
7600
|
evidenceChain = { ...evidenceChain ?? {}, approval: { ...args.approvalContext } };
|
|
@@ -8747,16 +8846,26 @@ var BudgetEngine = class {
|
|
|
8747
8846
|
/**
|
|
8748
8847
|
* Resolve which budgets a call feeds and how much it charges each.
|
|
8749
8848
|
*
|
|
8750
|
-
* A
|
|
8751
|
-
*
|
|
8752
|
-
*
|
|
8753
|
-
*
|
|
8849
|
+
* A contributor participates when its tool glob matches the tool name AND
|
|
8850
|
+
* every `match.input` condition holds (absent conditions means the glob
|
|
8851
|
+
* alone decides); the FIRST participating contributor (config order, over
|
|
8852
|
+
* that combined predicate) supplies the amount field. A call that matches
|
|
8853
|
+
* the glob but not the conditions simply does not feed the budget — no
|
|
8854
|
+
* charge, no failure. Once a contributor is selected, a missing,
|
|
8855
|
+
* non-numeric, negative, or non-finite amount fails closed as a `failures`
|
|
8856
|
+
* entry — the caller must deny the call.
|
|
8754
8857
|
*/
|
|
8755
8858
|
resolveCharges(ctx) {
|
|
8756
8859
|
const charges = [];
|
|
8757
8860
|
const failures = [];
|
|
8861
|
+
const matchCtx = {
|
|
8862
|
+
toolName: ctx.toolName,
|
|
8863
|
+
...ctx.toolArguments !== void 0 && { toolArguments: ctx.toolArguments }
|
|
8864
|
+
};
|
|
8758
8865
|
for (const budget of this.budgets.values()) {
|
|
8759
|
-
const contributor = budget.contributors.find(
|
|
8866
|
+
const contributor = budget.contributors.find(
|
|
8867
|
+
(c) => c.match.tool.test(ctx.toolName) && (c.match.input === void 0 || matchInput(c.match.input, matchCtx))
|
|
8868
|
+
);
|
|
8760
8869
|
if (!contributor) continue;
|
|
8761
8870
|
const raw = resolvePath(contributor.field, ctx.toolArguments ?? {});
|
|
8762
8871
|
if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 0) {
|
|
@@ -9490,6 +9599,24 @@ var BudgetLedger = class {
|
|
|
9490
9599
|
const { total } = this.countEventsStmt.get(budgetName);
|
|
9491
9600
|
return { events, total };
|
|
9492
9601
|
}
|
|
9602
|
+
/**
|
|
9603
|
+
* A budget's spend history for bulk export (issue #155). Unlike
|
|
9604
|
+
* {@link listEvents}, which enforces the dashboard's page clamp, this path
|
|
9605
|
+
* allows up to `EXPORT_MAX_RECORDS` in a single call. Newest first — the
|
|
9606
|
+
* listing's own order, and the opposite of the audit export's oldest-first:
|
|
9607
|
+
* the export takes no time filters, so a capped export must keep the most
|
|
9608
|
+
* recent spend reachable (older rows age out via retention). Same
|
|
9609
|
+
* unknown-name and epoch-spanning posture as the listing.
|
|
9610
|
+
*/
|
|
9611
|
+
listEventsForExport(budgetName, limit) {
|
|
9612
|
+
const clamped = Math.min(
|
|
9613
|
+
Math.max(Math.trunc(limit ?? EXPORT_MAX_RECORDS), 1),
|
|
9614
|
+
EXPORT_MAX_RECORDS
|
|
9615
|
+
);
|
|
9616
|
+
const events = this.listEventsStmt.all(budgetName, clamped, 0);
|
|
9617
|
+
const { total } = this.countEventsStmt.get(budgetName);
|
|
9618
|
+
return { events, total };
|
|
9619
|
+
}
|
|
9493
9620
|
// -------------------------------------------------------------------------
|
|
9494
9621
|
// Retention
|
|
9495
9622
|
// -------------------------------------------------------------------------
|
|
@@ -9509,17 +9636,6 @@ var BudgetLedger = class {
|
|
|
9509
9636
|
}
|
|
9510
9637
|
};
|
|
9511
9638
|
|
|
9512
|
-
// src/dashboard/api.ts
|
|
9513
|
-
import { readFileSync } from "fs";
|
|
9514
|
-
import { join } from "path";
|
|
9515
|
-
import { randomUUID as randomUUID8 } from "crypto";
|
|
9516
|
-
import { Hono as Hono8 } from "hono";
|
|
9517
|
-
import { HTTPException as HTTPException2 } from "hono/http-exception";
|
|
9518
|
-
import { z as z8 } from "zod";
|
|
9519
|
-
import { cors } from "hono/cors";
|
|
9520
|
-
import { serveStatic } from "@hono/node-server/serve-static";
|
|
9521
|
-
import { streamSSE } from "hono/streaming";
|
|
9522
|
-
|
|
9523
9639
|
// src/audit/csv.ts
|
|
9524
9640
|
var CSV_HEADERS = [
|
|
9525
9641
|
"id",
|
|
@@ -9578,6 +9694,49 @@ function recordsToCsv(records) {
|
|
|
9578
9694
|
return lines.join("\n");
|
|
9579
9695
|
}
|
|
9580
9696
|
|
|
9697
|
+
// src/budget/csv.ts
|
|
9698
|
+
var BUDGET_EVENT_CSV_HEADERS = [
|
|
9699
|
+
"id",
|
|
9700
|
+
"budget_name",
|
|
9701
|
+
"bucket_key",
|
|
9702
|
+
"kind",
|
|
9703
|
+
"amount",
|
|
9704
|
+
"currency",
|
|
9705
|
+
"tool_name",
|
|
9706
|
+
"origin",
|
|
9707
|
+
"audit_record_id",
|
|
9708
|
+
"timestamp",
|
|
9709
|
+
"timestamp_ms",
|
|
9710
|
+
"created_at"
|
|
9711
|
+
];
|
|
9712
|
+
function eventToRow(event) {
|
|
9713
|
+
return BUDGET_EVENT_CSV_HEADERS.map((h) => {
|
|
9714
|
+
const val = event[h];
|
|
9715
|
+
if (val === null || val === void 0) return "";
|
|
9716
|
+
if (typeof val === "number") return String(val);
|
|
9717
|
+
if (typeof val === "string") return csvEscape(val);
|
|
9718
|
+
return "";
|
|
9719
|
+
}).join(",");
|
|
9720
|
+
}
|
|
9721
|
+
function budgetEventsToCsv(events) {
|
|
9722
|
+
const lines = [BUDGET_EVENT_CSV_HEADERS.join(",")];
|
|
9723
|
+
for (const e of events) {
|
|
9724
|
+
lines.push(eventToRow(e));
|
|
9725
|
+
}
|
|
9726
|
+
return lines.join("\n");
|
|
9727
|
+
}
|
|
9728
|
+
|
|
9729
|
+
// src/dashboard/api.ts
|
|
9730
|
+
import { readFileSync } from "fs";
|
|
9731
|
+
import { join } from "path";
|
|
9732
|
+
import { randomUUID as randomUUID8 } from "crypto";
|
|
9733
|
+
import { Hono as Hono8 } from "hono";
|
|
9734
|
+
import { HTTPException as HTTPException2 } from "hono/http-exception";
|
|
9735
|
+
import { z as z8 } from "zod";
|
|
9736
|
+
import { cors } from "hono/cors";
|
|
9737
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
9738
|
+
import { streamSSE } from "hono/streaming";
|
|
9739
|
+
|
|
9581
9740
|
// src/dashboard/session.ts
|
|
9582
9741
|
import { createHash as createHash3, createHmac as createHmac3, randomBytes, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
9583
9742
|
var DashboardSessionStore = class {
|
|
@@ -9735,6 +9894,10 @@ var budgetEventsQuerySchema = z8.object({
|
|
|
9735
9894
|
limit: clampedQueryInt(50, 1, LIST_MAX_PAGE_SIZE),
|
|
9736
9895
|
offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER)
|
|
9737
9896
|
});
|
|
9897
|
+
var budgetEventsExportQuerySchema = z8.object({
|
|
9898
|
+
format: z8.preprocess((value) => value === "csv" ? "csv" : "json", z8.enum(["json", "csv"])),
|
|
9899
|
+
limit: clampedQueryInt(EXPORT_MAX_RECORDS, 1, EXPORT_MAX_RECORDS)
|
|
9900
|
+
});
|
|
9738
9901
|
var analyticsQuerySchema = z8.object({
|
|
9739
9902
|
from: optionalQueryString,
|
|
9740
9903
|
to: optionalQueryString
|
|
@@ -10046,6 +10209,27 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
10046
10209
|
offset: query.offset
|
|
10047
10210
|
});
|
|
10048
10211
|
});
|
|
10212
|
+
app.get("/api/budgets/:name/events/export", (c) => {
|
|
10213
|
+
const query = budgetEventsExportQuerySchema.parse(c.req.query());
|
|
10214
|
+
const name = c.req.param("name");
|
|
10215
|
+
const page = budgets?.listEventsForExport(name, query.limit) ?? { events: [], total: 0 };
|
|
10216
|
+
const safeName = name.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 64);
|
|
10217
|
+
const filename = safeName ? `helio-budget-${safeName}-events` : "helio-budget-events";
|
|
10218
|
+
if (query.format === "csv") {
|
|
10219
|
+
return new Response(budgetEventsToCsv(page.events), {
|
|
10220
|
+
headers: {
|
|
10221
|
+
"content-type": "text/csv; charset=utf-8",
|
|
10222
|
+
"content-disposition": `attachment; filename="${filename}.csv"`
|
|
10223
|
+
}
|
|
10224
|
+
});
|
|
10225
|
+
}
|
|
10226
|
+
return new Response(JSON.stringify(page.events, null, 2), {
|
|
10227
|
+
headers: {
|
|
10228
|
+
"content-type": "application/json",
|
|
10229
|
+
"content-disposition": `attachment; filename="${filename}.json"`
|
|
10230
|
+
}
|
|
10231
|
+
});
|
|
10232
|
+
});
|
|
10049
10233
|
app.get("/api/analytics", (c) => {
|
|
10050
10234
|
const query = analyticsQuerySchema.parse(c.req.query());
|
|
10051
10235
|
const now = /* @__PURE__ */ new Date();
|
|
@@ -10355,11 +10539,29 @@ upstream:
|
|
|
10355
10539
|
# port: 3000
|
|
10356
10540
|
# host: 127.0.0.1
|
|
10357
10541
|
|
|
10542
|
+
# environment: production
|
|
10543
|
+
|
|
10358
10544
|
# policies:
|
|
10359
10545
|
# default: allow
|
|
10360
10546
|
# dry_run: false
|
|
10361
10547
|
# rules: []
|
|
10362
10548
|
|
|
10549
|
+
# budgets:
|
|
10550
|
+
# # One depleting pot shared by every tool that spends.
|
|
10551
|
+
# - name: agent-payments
|
|
10552
|
+
# limit: 50
|
|
10553
|
+
# currency: USD
|
|
10554
|
+
# window: session
|
|
10555
|
+
# key: session
|
|
10556
|
+
# on_exceed: deny # or require_approval for a break-glass ticket
|
|
10557
|
+
# contributors:
|
|
10558
|
+
# - match:
|
|
10559
|
+
# tool: 'stripe_*'
|
|
10560
|
+
# field: '$.amount'
|
|
10561
|
+
# - match:
|
|
10562
|
+
# tool: 'paypal_*'
|
|
10563
|
+
# field: '$.total'
|
|
10564
|
+
|
|
10363
10565
|
# approval:
|
|
10364
10566
|
# timeout: 300s
|
|
10365
10567
|
# default_on_timeout: deny
|
|
@@ -10376,7 +10578,7 @@ upstream:
|
|
|
10376
10578
|
# front. dashboard.api_secret is the manual dashboard login secret and also
|
|
10377
10579
|
# supports machine Bearer auth for sideband API clients. Store it safely; it
|
|
10378
10580
|
# stays valid until you rotate it. Rotate by editing this file and restarting
|
|
10379
|
-
#
|
|
10581
|
+
# the proxy. Rotation invalidates active dashboard sessions.
|
|
10380
10582
|
dashboard:
|
|
10381
10583
|
enabled: true
|
|
10382
10584
|
port: 3100
|
|
@@ -10392,6 +10594,12 @@ dashboard:
|
|
|
10392
10594
|
# the proxy's environment for a stable cross-restart value.
|
|
10393
10595
|
`;
|
|
10394
10596
|
}
|
|
10597
|
+
function printConfigErrorDetails(error, prefix = "") {
|
|
10598
|
+
if (!error.details) return;
|
|
10599
|
+
for (const detail of error.details) {
|
|
10600
|
+
console.error(`${prefix} ${detail.path}: ${detail.message}`);
|
|
10601
|
+
}
|
|
10602
|
+
}
|
|
10395
10603
|
var ANNOTATION_PRIME_INITIAL_WAIT_MS = 1500;
|
|
10396
10604
|
var ANNOTATION_PRIME_RETRY_BASE_MS = 1e3;
|
|
10397
10605
|
var ANNOTATION_PRIME_RETRY_MAX_MS = 3e4;
|
|
@@ -10483,11 +10691,7 @@ async function startCommand(configPath, options) {
|
|
|
10483
10691
|
} catch (err) {
|
|
10484
10692
|
if (err instanceof ConfigError) {
|
|
10485
10693
|
console.error(`Error: ${err.message}`);
|
|
10486
|
-
|
|
10487
|
-
for (const detail of err.details) {
|
|
10488
|
-
console.error(` ${detail.path}: ${detail.message}`);
|
|
10489
|
-
}
|
|
10490
|
-
}
|
|
10694
|
+
printConfigErrorDetails(err);
|
|
10491
10695
|
process.exit(1);
|
|
10492
10696
|
}
|
|
10493
10697
|
throw err;
|
|
@@ -10688,7 +10892,8 @@ async function startCommand(configPath, options) {
|
|
|
10688
10892
|
// spend history from the ledger.
|
|
10689
10893
|
budgets: {
|
|
10690
10894
|
listStates: () => budgetEngine.listStates(),
|
|
10691
|
-
listEvents: (name, page) => budgetLedger.listEvents(name, page)
|
|
10895
|
+
listEvents: (name, page) => budgetLedger.listEvents(name, page),
|
|
10896
|
+
listEventsForExport: (name, limit) => budgetLedger.listEventsForExport(name, limit)
|
|
10692
10897
|
}
|
|
10693
10898
|
},
|
|
10694
10899
|
{
|
|
@@ -10762,6 +10967,9 @@ async function startCommand(configPath, options) {
|
|
|
10762
10967
|
configWatcher = new ConfigWatcher({
|
|
10763
10968
|
configPath,
|
|
10764
10969
|
initialConfig: config,
|
|
10970
|
+
onReady: () => {
|
|
10971
|
+
console.error(`Watching ${configPath} for policy changes`);
|
|
10972
|
+
},
|
|
10765
10973
|
onReload: (newPolicy, reloadWarnings, restartRequiredPaths, newBudgets) => {
|
|
10766
10974
|
const unroutable = findUnroutableApprovalReferences(newPolicy, newBudgets, {
|
|
10767
10975
|
channelTypes: runtimeChannelTypes,
|
|
@@ -10802,10 +11010,12 @@ async function startCommand(configPath, options) {
|
|
|
10802
11010
|
console.error(
|
|
10803
11011
|
`[helio] Config reload failed (keeping current configuration): ${error.message}`
|
|
10804
11012
|
);
|
|
11013
|
+
if (error instanceof ConfigError) {
|
|
11014
|
+
printConfigErrorDetails(error, "[helio] ");
|
|
11015
|
+
}
|
|
10805
11016
|
}
|
|
10806
11017
|
});
|
|
10807
11018
|
configWatcher.start();
|
|
10808
|
-
console.error(`Watching ${configPath} for policy changes`);
|
|
10809
11019
|
} else {
|
|
10810
11020
|
console.error(
|
|
10811
11021
|
`[helio] Hot-reload disabled \u2014 config changes to ${configPath} will require a restart`
|
|
@@ -10861,17 +11071,14 @@ async function validateCommand(configPath) {
|
|
|
10861
11071
|
process.exit(1);
|
|
10862
11072
|
}
|
|
10863
11073
|
const ruleCount = config.policies.rules.length;
|
|
11074
|
+
const budgetCount = config.budgets.length;
|
|
10864
11075
|
console.error(
|
|
10865
|
-
`Config is valid: ${configPath} (${String(ruleCount)} policy rule${ruleCount !== 1 ? "s" : ""})`
|
|
11076
|
+
`Config is valid: ${configPath} (${String(ruleCount)} policy rule${ruleCount !== 1 ? "s" : ""}, ${String(budgetCount)} budget${budgetCount !== 1 ? "s" : ""})`
|
|
10866
11077
|
);
|
|
10867
11078
|
} catch (err) {
|
|
10868
11079
|
if (err instanceof ConfigError) {
|
|
10869
11080
|
console.error(`Invalid config: ${err.message}`);
|
|
10870
|
-
|
|
10871
|
-
for (const detail of err.details) {
|
|
10872
|
-
console.error(` ${detail.path}: ${detail.message}`);
|
|
10873
|
-
}
|
|
10874
|
-
}
|
|
11081
|
+
printConfigErrorDetails(err);
|
|
10875
11082
|
process.exit(1);
|
|
10876
11083
|
}
|
|
10877
11084
|
if (err instanceof PolicyParseError) {
|
|
@@ -10891,12 +11098,29 @@ async function exportCommand(opts) {
|
|
|
10891
11098
|
process.exit(1);
|
|
10892
11099
|
}
|
|
10893
11100
|
const limit = Math.min(parsedLimit, EXPORT_MAX_RECORDS);
|
|
11101
|
+
if (opts.budgets !== void 0) {
|
|
11102
|
+
const conflicting = [
|
|
11103
|
+
["--tool", opts.tool],
|
|
11104
|
+
["--decision", opts.decision],
|
|
11105
|
+
["--reason", opts.reason],
|
|
11106
|
+
["--session", opts.session],
|
|
11107
|
+
["--from", opts.from],
|
|
11108
|
+
["--to", opts.to]
|
|
11109
|
+
].filter(([, value]) => value !== void 0);
|
|
11110
|
+
if (conflicting.length > 0) {
|
|
11111
|
+
console.error(
|
|
11112
|
+
`Error: --budgets cannot be combined with audit filters (${conflicting.map(([flag]) => flag).join(", ")})`
|
|
11113
|
+
);
|
|
11114
|
+
process.exit(1);
|
|
11115
|
+
}
|
|
11116
|
+
}
|
|
10894
11117
|
let config;
|
|
10895
11118
|
try {
|
|
10896
11119
|
config = await loadConfig(opts.config);
|
|
10897
11120
|
} catch (err) {
|
|
10898
11121
|
if (err instanceof ConfigError) {
|
|
10899
11122
|
console.error(`Error: ${err.message}`);
|
|
11123
|
+
printConfigErrorDetails(err);
|
|
10900
11124
|
process.exit(1);
|
|
10901
11125
|
}
|
|
10902
11126
|
throw err;
|
|
@@ -10909,6 +11133,17 @@ async function exportCommand(opts) {
|
|
|
10909
11133
|
// No cleanup timer for one-shot CLI
|
|
10910
11134
|
});
|
|
10911
11135
|
try {
|
|
11136
|
+
if (opts.budgets !== void 0) {
|
|
11137
|
+
const ledger = new BudgetLedger({ database: store.database });
|
|
11138
|
+
const page = ledger.listEventsForExport(opts.budgets, limit);
|
|
11139
|
+
if (opts.format === "csv") {
|
|
11140
|
+
console.log(budgetEventsToCsv(page.events));
|
|
11141
|
+
} else {
|
|
11142
|
+
console.log(JSON.stringify(page.events, null, 2));
|
|
11143
|
+
}
|
|
11144
|
+
console.error(`Exported ${String(page.events.length)} of ${String(page.total)} records`);
|
|
11145
|
+
return;
|
|
11146
|
+
}
|
|
10912
11147
|
const result = store.listForExport(
|
|
10913
11148
|
{
|
|
10914
11149
|
tool_name: opts.tool,
|
|
@@ -10990,5 +11225,5 @@ program.command("start").description("Load config and start the proxy server").o
|
|
|
10990
11225
|
);
|
|
10991
11226
|
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));
|
|
10992
11227
|
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));
|
|
10993
|
-
program.command("export").description("Export audit records 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("--tool <name>", "Filter by tool name").option("--decision <decision>", "Filter by policy decision").option("--reason <reason>", "Filter by block reason").option("--session <id>", "Filter by session ID").option("--from <iso>", "Start time (ISO 8601)").option("--to <iso>", "End time (ISO 8601)").option("--limit <n>", "Max records to export (up to 10000)", "1000").action((opts) => exportCommand(opts));
|
|
11228
|
+
program.command("export").description("Export audit records or a budget ledger to JSON or CSV").option("-c, --config <path>", "Path to helio.yaml", DEFAULT_CONFIG_PATH).option("-f, --format <format>", "Output format: json or csv", "json").option("--budgets <name>", "Export the named budget ledger instead of the audit trail").option("--tool <name>", "Filter by tool name").option("--decision <decision>", "Filter by policy decision").option("--reason <reason>", "Filter by block reason").option("--session <id>", "Filter by session ID").option("--from <iso>", "Start time (ISO 8601)").option("--to <iso>", "End time (ISO 8601)").option("--limit <n>", "Max records to export (up to 10000)", "1000").action((opts) => exportCommand(opts));
|
|
10994
11229
|
program.parse();
|