@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/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() {
|
|
@@ -1149,6 +1223,12 @@ function parseJsonRpcRequest(body) {
|
|
|
1149
1223
|
};
|
|
1150
1224
|
}
|
|
1151
1225
|
|
|
1226
|
+
// src/transport/content-type.ts
|
|
1227
|
+
function isJsonContentType(header) {
|
|
1228
|
+
const [essence = ""] = (header ?? "").split(";");
|
|
1229
|
+
return essence.trim().toLowerCase() === "application/json";
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1152
1232
|
// src/transport/forward-headers.ts
|
|
1153
1233
|
function buildForwardHeaders(requestHeaders, allowlist) {
|
|
1154
1234
|
const forwardHeaders = {};
|
|
@@ -1282,8 +1362,7 @@ function createStreamableHttpRoute(forwarder, options = {}) {
|
|
|
1282
1362
|
const app = new Hono();
|
|
1283
1363
|
const forwardHeaderAllowlist = options.forwardHeadersAllowlist ?? [];
|
|
1284
1364
|
app.post("/", async (c) => {
|
|
1285
|
-
|
|
1286
|
-
if (!contentType.includes("application/json")) {
|
|
1365
|
+
if (!isJsonContentType(c.req.header("content-type"))) {
|
|
1287
1366
|
return c.json(
|
|
1288
1367
|
makeJsonRpcError(null, INVALID_REQUEST, "Content-Type must be application/json"),
|
|
1289
1368
|
415
|
|
@@ -1416,8 +1495,7 @@ function createSseRoute(forwarder, options = {}) {
|
|
|
1416
1495
|
if (!session) {
|
|
1417
1496
|
return c.json(makeJsonRpcError(null, INVALID_REQUEST, "unknown session"), 404);
|
|
1418
1497
|
}
|
|
1419
|
-
|
|
1420
|
-
if (!contentType.includes("application/json")) {
|
|
1498
|
+
if (!isJsonContentType(c.req.header("content-type"))) {
|
|
1421
1499
|
return c.json(
|
|
1422
1500
|
makeJsonRpcError(null, INVALID_REQUEST, "Content-Type must be application/json"),
|
|
1423
1501
|
415
|
|
@@ -3116,11 +3194,9 @@ function extractTools(body) {
|
|
|
3116
3194
|
|
|
3117
3195
|
// src/feedback/self-repair.ts
|
|
3118
3196
|
function ruleInfo(rule) {
|
|
3119
|
-
const index = rule?.index ?? null;
|
|
3120
3197
|
return {
|
|
3121
3198
|
rule: rule?.name ?? null,
|
|
3122
|
-
|
|
3123
|
-
rule_index: index
|
|
3199
|
+
rule_index: rule?.index ?? null
|
|
3124
3200
|
};
|
|
3125
3201
|
}
|
|
3126
3202
|
function buildPolicyDeniedFeedback(decision) {
|
|
@@ -4598,29 +4674,33 @@ var GovernedForwarder = class {
|
|
|
4598
4674
|
if (this.spendLimiter && decision.matchedRule?.limits?.maxSpend) {
|
|
4599
4675
|
const maxSpend = decision.matchedRule.limits.maxSpend;
|
|
4600
4676
|
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
|
-
|
|
4677
|
+
if (typeof rawAmount !== "number") {
|
|
4678
|
+
console.error(
|
|
4679
|
+
`[helio] Warning: spend limit field "${maxSpend.field}" did not resolve to a number for tool "${toolName}" (got ${typeof rawAmount}) in dry-run, denying`
|
|
4680
|
+
);
|
|
4681
|
+
wouldForward = false;
|
|
4682
|
+
limitsOk = false;
|
|
4683
|
+
} else if (!Number.isFinite(rawAmount) || rawAmount < 0) {
|
|
4684
|
+
console.error(
|
|
4685
|
+
`[helio] Warning: spend limit field "${maxSpend.field}" resolved to invalid amount (${String(rawAmount)}) for tool "${toolName}" in dry-run, denying`
|
|
4686
|
+
);
|
|
4687
|
+
wouldForward = false;
|
|
4688
|
+
limitsOk = false;
|
|
4689
|
+
} else {
|
|
4690
|
+
const key = this.buildSpendLimitKey(
|
|
4691
|
+
maxSpend.key,
|
|
4692
|
+
toolName,
|
|
4693
|
+
request,
|
|
4694
|
+
decision.matchedRule.index
|
|
4695
|
+
);
|
|
4696
|
+
const peekResult = this.spendLimiter.peek({
|
|
4697
|
+
key,
|
|
4698
|
+
amount: rawAmount,
|
|
4699
|
+
limit: maxSpend.limit,
|
|
4700
|
+
windowMs: maxSpend.windowMs
|
|
4701
|
+
});
|
|
4702
|
+
wouldForward = peekResult.allowed;
|
|
4703
|
+
limitsOk = peekResult.allowed;
|
|
4624
4704
|
}
|
|
4625
4705
|
}
|
|
4626
4706
|
break;
|
|
@@ -6612,6 +6692,7 @@ var GovernanceService = class {
|
|
|
6612
6692
|
let wire;
|
|
6613
6693
|
const plans = [];
|
|
6614
6694
|
let limitsBlock;
|
|
6695
|
+
let ruleLimitOk = true;
|
|
6615
6696
|
const reservedThisCall = [];
|
|
6616
6697
|
const reserve = (key) => {
|
|
6617
6698
|
const preexisting = this.senderKeys.has(key);
|
|
@@ -6625,6 +6706,15 @@ var GovernanceService = class {
|
|
|
6625
6706
|
const senderId = senderIdOf(req.metadata);
|
|
6626
6707
|
if (pipeline.isDryRun) {
|
|
6627
6708
|
wire = "dry_run";
|
|
6709
|
+
if (decision.action === "rate_limit") {
|
|
6710
|
+
const planned = this.planRate(decision, toolName, req.session_id, senderId);
|
|
6711
|
+
if (planned?.block) limitsBlock = { rate: planned.block };
|
|
6712
|
+
ruleLimitOk = planned?.allowed ?? true;
|
|
6713
|
+
} else if (decision.action === "spend_limit") {
|
|
6714
|
+
const planned = this.planSpend(decision, toolName, req.session_id, req.arguments, senderId);
|
|
6715
|
+
if (planned?.block) limitsBlock = { spend: planned.block };
|
|
6716
|
+
ruleLimitOk = planned?.allowed ?? true;
|
|
6717
|
+
}
|
|
6628
6718
|
} else if (decision.action === "deny") {
|
|
6629
6719
|
wire = "deny";
|
|
6630
6720
|
} else if (decision.action === "require_approval") {
|
|
@@ -6749,9 +6839,9 @@ var GovernanceService = class {
|
|
|
6749
6839
|
if (limitsBlock) responseBody["limits"] = limitsBlock;
|
|
6750
6840
|
if (wire === "dry_run") {
|
|
6751
6841
|
responseBody["dry_run"] = {
|
|
6752
|
-
would_forward: decision.action === "allow" && !pipeline.evidenceBlocked && budgetDryRunOk,
|
|
6842
|
+
would_forward: (decision.action === "allow" || (decision.action === "rate_limit" || decision.action === "spend_limit") && ruleLimitOk) && !pipeline.evidenceBlocked && budgetDryRunOk,
|
|
6753
6843
|
evidence_satisfied: !pipeline.evidenceBlocked,
|
|
6754
|
-
limits_ok: budgetDryRunOk
|
|
6844
|
+
limits_ok: ruleLimitOk && budgetDryRunOk
|
|
6755
6845
|
};
|
|
6756
6846
|
}
|
|
6757
6847
|
if (pipeline.driftEvent) {
|
|
@@ -6773,7 +6863,11 @@ var GovernanceService = class {
|
|
|
6773
6863
|
flaggedDestructive: pipeline.flaggedDestructive,
|
|
6774
6864
|
dryRun: wire === "dry_run",
|
|
6775
6865
|
recordKind: "tool_call",
|
|
6776
|
-
|
|
6866
|
+
// A CLONE, for the same reason budgetsAtEvaluate is one below:
|
|
6867
|
+
// limitsBlock is also the response's `limits`, and the audit writer
|
|
6868
|
+
// buffers records by reference until flush — a direct embedder
|
|
6869
|
+
// editing the returned body must not be able to rewrite evidence.
|
|
6870
|
+
limitsChain: limitsBlock ? structuredClone(limitsBlock) : void 0
|
|
6777
6871
|
});
|
|
6778
6872
|
this.tombstones.set(evaluationId, {
|
|
6779
6873
|
auditRecordId: auditId,
|
|
@@ -7275,6 +7369,7 @@ var GovernanceService = class {
|
|
|
7275
7369
|
this.approvalRouter?.resolveNativeTicket(entry.approvalTicketId, "timeout");
|
|
7276
7370
|
this.snapshotTicketResolution(entry);
|
|
7277
7371
|
}
|
|
7372
|
+
const committed = entry.commitState;
|
|
7278
7373
|
const resolution = entry.ticketResolution;
|
|
7279
7374
|
const approvalContext = entry.approvalTicketId && resolution && (resolution.denialReason || resolution.escalatedAt) ? {
|
|
7280
7375
|
ticket_id: entry.approvalTicketId,
|
|
@@ -7285,6 +7380,7 @@ var GovernanceService = class {
|
|
|
7285
7380
|
} : {}
|
|
7286
7381
|
} : void 0;
|
|
7287
7382
|
const auditId = this.writeAudit({
|
|
7383
|
+
...committed ? { id: committed.auditId } : {},
|
|
7288
7384
|
timestampIso: entry.timestampIso,
|
|
7289
7385
|
origin: entry.origin,
|
|
7290
7386
|
agentId: entry.agentId,
|
|
@@ -7302,8 +7398,9 @@ var GovernanceService = class {
|
|
|
7302
7398
|
approvalStatus: resolution?.status ?? null,
|
|
7303
7399
|
approvedBy: resolution?.resolvedBy ?? null,
|
|
7304
7400
|
approvalContext,
|
|
7305
|
-
limitsChain:
|
|
7306
|
-
sidebandUnreported: true
|
|
7401
|
+
limitsChain: committed ? committed.limitsChain : entry.budgetsAtEvaluate ? { budgets: entry.budgetsAtEvaluate } : void 0,
|
|
7402
|
+
sidebandUnreported: true,
|
|
7403
|
+
sidebandCommitted: committed !== void 0
|
|
7307
7404
|
});
|
|
7308
7405
|
this.discardPending(entry);
|
|
7309
7406
|
this.tombstones.set(entry.evaluationId, {
|
|
@@ -7313,7 +7410,7 @@ var GovernanceService = class {
|
|
|
7313
7410
|
expiresAtMs: now + this.ttlMs
|
|
7314
7411
|
});
|
|
7315
7412
|
console.error(
|
|
7316
|
-
`[helio] Sideband evaluation ${entry.evaluationId} expired without /audit (origin=${entry.origin}, tool=${entry.toolName}) \u2014 recorded as evaluation_expired`
|
|
7413
|
+
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
7414
|
);
|
|
7318
7415
|
return "expired";
|
|
7319
7416
|
}
|
|
@@ -7495,7 +7592,13 @@ var GovernanceService = class {
|
|
|
7495
7592
|
const blockReason = deriveBlockReason(args);
|
|
7496
7593
|
let evidenceChain = args.limitsChain ?? null;
|
|
7497
7594
|
if (args.sidebandUnreported) {
|
|
7498
|
-
evidenceChain = {
|
|
7595
|
+
evidenceChain = {
|
|
7596
|
+
...evidenceChain ?? {},
|
|
7597
|
+
sideband: {
|
|
7598
|
+
unreported: true,
|
|
7599
|
+
...args.sidebandCommitted ? { committed: true } : {}
|
|
7600
|
+
}
|
|
7601
|
+
};
|
|
7499
7602
|
}
|
|
7500
7603
|
if (args.approvalContext) {
|
|
7501
7604
|
evidenceChain = { ...evidenceChain ?? {}, approval: { ...args.approvalContext } };
|
|
@@ -8747,16 +8850,26 @@ var BudgetEngine = class {
|
|
|
8747
8850
|
/**
|
|
8748
8851
|
* Resolve which budgets a call feeds and how much it charges each.
|
|
8749
8852
|
*
|
|
8750
|
-
* A
|
|
8751
|
-
*
|
|
8752
|
-
*
|
|
8753
|
-
*
|
|
8853
|
+
* A contributor participates when its tool glob matches the tool name AND
|
|
8854
|
+
* every `match.input` condition holds (absent conditions means the glob
|
|
8855
|
+
* alone decides); the FIRST participating contributor (config order, over
|
|
8856
|
+
* that combined predicate) supplies the amount field. A call that matches
|
|
8857
|
+
* the glob but not the conditions simply does not feed the budget — no
|
|
8858
|
+
* charge, no failure. Once a contributor is selected, a missing,
|
|
8859
|
+
* non-numeric, negative, or non-finite amount fails closed as a `failures`
|
|
8860
|
+
* entry — the caller must deny the call.
|
|
8754
8861
|
*/
|
|
8755
8862
|
resolveCharges(ctx) {
|
|
8756
8863
|
const charges = [];
|
|
8757
8864
|
const failures = [];
|
|
8865
|
+
const matchCtx = {
|
|
8866
|
+
toolName: ctx.toolName,
|
|
8867
|
+
...ctx.toolArguments !== void 0 && { toolArguments: ctx.toolArguments }
|
|
8868
|
+
};
|
|
8758
8869
|
for (const budget of this.budgets.values()) {
|
|
8759
|
-
const contributor = budget.contributors.find(
|
|
8870
|
+
const contributor = budget.contributors.find(
|
|
8871
|
+
(c) => c.match.tool.test(ctx.toolName) && (c.match.input === void 0 || matchInput(c.match.input, matchCtx))
|
|
8872
|
+
);
|
|
8760
8873
|
if (!contributor) continue;
|
|
8761
8874
|
const raw = resolvePath(contributor.field, ctx.toolArguments ?? {});
|
|
8762
8875
|
if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 0) {
|
|
@@ -9490,6 +9603,24 @@ var BudgetLedger = class {
|
|
|
9490
9603
|
const { total } = this.countEventsStmt.get(budgetName);
|
|
9491
9604
|
return { events, total };
|
|
9492
9605
|
}
|
|
9606
|
+
/**
|
|
9607
|
+
* A budget's spend history for bulk export (issue #155). Unlike
|
|
9608
|
+
* {@link listEvents}, which enforces the dashboard's page clamp, this path
|
|
9609
|
+
* allows up to `EXPORT_MAX_RECORDS` in a single call. Newest first — the
|
|
9610
|
+
* listing's own order, and the opposite of the audit export's oldest-first:
|
|
9611
|
+
* the export takes no time filters, so a capped export must keep the most
|
|
9612
|
+
* recent spend reachable (older rows age out via retention). Same
|
|
9613
|
+
* unknown-name and epoch-spanning posture as the listing.
|
|
9614
|
+
*/
|
|
9615
|
+
listEventsForExport(budgetName, limit) {
|
|
9616
|
+
const clamped = Math.min(
|
|
9617
|
+
Math.max(Math.trunc(limit ?? EXPORT_MAX_RECORDS), 1),
|
|
9618
|
+
EXPORT_MAX_RECORDS
|
|
9619
|
+
);
|
|
9620
|
+
const events = this.listEventsStmt.all(budgetName, clamped, 0);
|
|
9621
|
+
const { total } = this.countEventsStmt.get(budgetName);
|
|
9622
|
+
return { events, total };
|
|
9623
|
+
}
|
|
9493
9624
|
// -------------------------------------------------------------------------
|
|
9494
9625
|
// Retention
|
|
9495
9626
|
// -------------------------------------------------------------------------
|
|
@@ -9509,17 +9640,6 @@ var BudgetLedger = class {
|
|
|
9509
9640
|
}
|
|
9510
9641
|
};
|
|
9511
9642
|
|
|
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
9643
|
// src/audit/csv.ts
|
|
9524
9644
|
var CSV_HEADERS = [
|
|
9525
9645
|
"id",
|
|
@@ -9578,6 +9698,49 @@ function recordsToCsv(records) {
|
|
|
9578
9698
|
return lines.join("\n");
|
|
9579
9699
|
}
|
|
9580
9700
|
|
|
9701
|
+
// src/budget/csv.ts
|
|
9702
|
+
var BUDGET_EVENT_CSV_HEADERS = [
|
|
9703
|
+
"id",
|
|
9704
|
+
"budget_name",
|
|
9705
|
+
"bucket_key",
|
|
9706
|
+
"kind",
|
|
9707
|
+
"amount",
|
|
9708
|
+
"currency",
|
|
9709
|
+
"tool_name",
|
|
9710
|
+
"origin",
|
|
9711
|
+
"audit_record_id",
|
|
9712
|
+
"timestamp",
|
|
9713
|
+
"timestamp_ms",
|
|
9714
|
+
"created_at"
|
|
9715
|
+
];
|
|
9716
|
+
function eventToRow(event) {
|
|
9717
|
+
return BUDGET_EVENT_CSV_HEADERS.map((h) => {
|
|
9718
|
+
const val = event[h];
|
|
9719
|
+
if (val === null || val === void 0) return "";
|
|
9720
|
+
if (typeof val === "number") return String(val);
|
|
9721
|
+
if (typeof val === "string") return csvEscape(val);
|
|
9722
|
+
return "";
|
|
9723
|
+
}).join(",");
|
|
9724
|
+
}
|
|
9725
|
+
function budgetEventsToCsv(events) {
|
|
9726
|
+
const lines = [BUDGET_EVENT_CSV_HEADERS.join(",")];
|
|
9727
|
+
for (const e of events) {
|
|
9728
|
+
lines.push(eventToRow(e));
|
|
9729
|
+
}
|
|
9730
|
+
return lines.join("\n");
|
|
9731
|
+
}
|
|
9732
|
+
|
|
9733
|
+
// src/dashboard/api.ts
|
|
9734
|
+
import { readFileSync } from "fs";
|
|
9735
|
+
import { join } from "path";
|
|
9736
|
+
import { randomUUID as randomUUID8 } from "crypto";
|
|
9737
|
+
import { Hono as Hono8 } from "hono";
|
|
9738
|
+
import { HTTPException as HTTPException2 } from "hono/http-exception";
|
|
9739
|
+
import { z as z8 } from "zod";
|
|
9740
|
+
import { cors } from "hono/cors";
|
|
9741
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
9742
|
+
import { streamSSE } from "hono/streaming";
|
|
9743
|
+
|
|
9581
9744
|
// src/dashboard/session.ts
|
|
9582
9745
|
import { createHash as createHash3, createHmac as createHmac3, randomBytes, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
9583
9746
|
var DashboardSessionStore = class {
|
|
@@ -9735,6 +9898,10 @@ var budgetEventsQuerySchema = z8.object({
|
|
|
9735
9898
|
limit: clampedQueryInt(50, 1, LIST_MAX_PAGE_SIZE),
|
|
9736
9899
|
offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER)
|
|
9737
9900
|
});
|
|
9901
|
+
var budgetEventsExportQuerySchema = z8.object({
|
|
9902
|
+
format: z8.preprocess((value) => value === "csv" ? "csv" : "json", z8.enum(["json", "csv"])),
|
|
9903
|
+
limit: clampedQueryInt(EXPORT_MAX_RECORDS, 1, EXPORT_MAX_RECORDS)
|
|
9904
|
+
});
|
|
9738
9905
|
var analyticsQuerySchema = z8.object({
|
|
9739
9906
|
from: optionalQueryString,
|
|
9740
9907
|
to: optionalQueryString
|
|
@@ -10046,6 +10213,27 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
10046
10213
|
offset: query.offset
|
|
10047
10214
|
});
|
|
10048
10215
|
});
|
|
10216
|
+
app.get("/api/budgets/:name/events/export", (c) => {
|
|
10217
|
+
const query = budgetEventsExportQuerySchema.parse(c.req.query());
|
|
10218
|
+
const name = c.req.param("name");
|
|
10219
|
+
const page = budgets?.listEventsForExport(name, query.limit) ?? { events: [], total: 0 };
|
|
10220
|
+
const safeName = name.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 64);
|
|
10221
|
+
const filename = safeName ? `helio-budget-${safeName}-events` : "helio-budget-events";
|
|
10222
|
+
if (query.format === "csv") {
|
|
10223
|
+
return new Response(budgetEventsToCsv(page.events), {
|
|
10224
|
+
headers: {
|
|
10225
|
+
"content-type": "text/csv; charset=utf-8",
|
|
10226
|
+
"content-disposition": `attachment; filename="${filename}.csv"`
|
|
10227
|
+
}
|
|
10228
|
+
});
|
|
10229
|
+
}
|
|
10230
|
+
return new Response(JSON.stringify(page.events, null, 2), {
|
|
10231
|
+
headers: {
|
|
10232
|
+
"content-type": "application/json",
|
|
10233
|
+
"content-disposition": `attachment; filename="${filename}.json"`
|
|
10234
|
+
}
|
|
10235
|
+
});
|
|
10236
|
+
});
|
|
10049
10237
|
app.get("/api/analytics", (c) => {
|
|
10050
10238
|
const query = analyticsQuerySchema.parse(c.req.query());
|
|
10051
10239
|
const now = /* @__PURE__ */ new Date();
|
|
@@ -10355,11 +10543,29 @@ upstream:
|
|
|
10355
10543
|
# port: 3000
|
|
10356
10544
|
# host: 127.0.0.1
|
|
10357
10545
|
|
|
10546
|
+
# environment: production
|
|
10547
|
+
|
|
10358
10548
|
# policies:
|
|
10359
10549
|
# default: allow
|
|
10360
10550
|
# dry_run: false
|
|
10361
10551
|
# rules: []
|
|
10362
10552
|
|
|
10553
|
+
# budgets:
|
|
10554
|
+
# # One depleting pot shared by every tool that spends.
|
|
10555
|
+
# - name: agent-payments
|
|
10556
|
+
# limit: 50
|
|
10557
|
+
# currency: USD
|
|
10558
|
+
# window: session
|
|
10559
|
+
# key: session
|
|
10560
|
+
# on_exceed: deny # or require_approval for a break-glass ticket
|
|
10561
|
+
# contributors:
|
|
10562
|
+
# - match:
|
|
10563
|
+
# tool: 'stripe_*'
|
|
10564
|
+
# field: '$.amount'
|
|
10565
|
+
# - match:
|
|
10566
|
+
# tool: 'paypal_*'
|
|
10567
|
+
# field: '$.total'
|
|
10568
|
+
|
|
10363
10569
|
# approval:
|
|
10364
10570
|
# timeout: 300s
|
|
10365
10571
|
# default_on_timeout: deny
|
|
@@ -10376,7 +10582,7 @@ upstream:
|
|
|
10376
10582
|
# front. dashboard.api_secret is the manual dashboard login secret and also
|
|
10377
10583
|
# supports machine Bearer auth for sideband API clients. Store it safely; it
|
|
10378
10584
|
# stays valid until you rotate it. Rotate by editing this file and restarting
|
|
10379
|
-
#
|
|
10585
|
+
# the proxy. Rotation invalidates active dashboard sessions.
|
|
10380
10586
|
dashboard:
|
|
10381
10587
|
enabled: true
|
|
10382
10588
|
port: 3100
|
|
@@ -10392,6 +10598,12 @@ dashboard:
|
|
|
10392
10598
|
# the proxy's environment for a stable cross-restart value.
|
|
10393
10599
|
`;
|
|
10394
10600
|
}
|
|
10601
|
+
function printConfigErrorDetails(error, prefix = "") {
|
|
10602
|
+
if (!error.details) return;
|
|
10603
|
+
for (const detail of error.details) {
|
|
10604
|
+
console.error(`${prefix} ${detail.path}: ${detail.message}`);
|
|
10605
|
+
}
|
|
10606
|
+
}
|
|
10395
10607
|
var ANNOTATION_PRIME_INITIAL_WAIT_MS = 1500;
|
|
10396
10608
|
var ANNOTATION_PRIME_RETRY_BASE_MS = 1e3;
|
|
10397
10609
|
var ANNOTATION_PRIME_RETRY_MAX_MS = 3e4;
|
|
@@ -10483,11 +10695,7 @@ async function startCommand(configPath, options) {
|
|
|
10483
10695
|
} catch (err) {
|
|
10484
10696
|
if (err instanceof ConfigError) {
|
|
10485
10697
|
console.error(`Error: ${err.message}`);
|
|
10486
|
-
|
|
10487
|
-
for (const detail of err.details) {
|
|
10488
|
-
console.error(` ${detail.path}: ${detail.message}`);
|
|
10489
|
-
}
|
|
10490
|
-
}
|
|
10698
|
+
printConfigErrorDetails(err);
|
|
10491
10699
|
process.exit(1);
|
|
10492
10700
|
}
|
|
10493
10701
|
throw err;
|
|
@@ -10688,7 +10896,8 @@ async function startCommand(configPath, options) {
|
|
|
10688
10896
|
// spend history from the ledger.
|
|
10689
10897
|
budgets: {
|
|
10690
10898
|
listStates: () => budgetEngine.listStates(),
|
|
10691
|
-
listEvents: (name, page) => budgetLedger.listEvents(name, page)
|
|
10899
|
+
listEvents: (name, page) => budgetLedger.listEvents(name, page),
|
|
10900
|
+
listEventsForExport: (name, limit) => budgetLedger.listEventsForExport(name, limit)
|
|
10692
10901
|
}
|
|
10693
10902
|
},
|
|
10694
10903
|
{
|
|
@@ -10762,6 +10971,9 @@ async function startCommand(configPath, options) {
|
|
|
10762
10971
|
configWatcher = new ConfigWatcher({
|
|
10763
10972
|
configPath,
|
|
10764
10973
|
initialConfig: config,
|
|
10974
|
+
onReady: () => {
|
|
10975
|
+
console.error(`Watching ${configPath} for policy changes`);
|
|
10976
|
+
},
|
|
10765
10977
|
onReload: (newPolicy, reloadWarnings, restartRequiredPaths, newBudgets) => {
|
|
10766
10978
|
const unroutable = findUnroutableApprovalReferences(newPolicy, newBudgets, {
|
|
10767
10979
|
channelTypes: runtimeChannelTypes,
|
|
@@ -10802,10 +11014,12 @@ async function startCommand(configPath, options) {
|
|
|
10802
11014
|
console.error(
|
|
10803
11015
|
`[helio] Config reload failed (keeping current configuration): ${error.message}`
|
|
10804
11016
|
);
|
|
11017
|
+
if (error instanceof ConfigError) {
|
|
11018
|
+
printConfigErrorDetails(error, "[helio] ");
|
|
11019
|
+
}
|
|
10805
11020
|
}
|
|
10806
11021
|
});
|
|
10807
11022
|
configWatcher.start();
|
|
10808
|
-
console.error(`Watching ${configPath} for policy changes`);
|
|
10809
11023
|
} else {
|
|
10810
11024
|
console.error(
|
|
10811
11025
|
`[helio] Hot-reload disabled \u2014 config changes to ${configPath} will require a restart`
|
|
@@ -10861,17 +11075,14 @@ async function validateCommand(configPath) {
|
|
|
10861
11075
|
process.exit(1);
|
|
10862
11076
|
}
|
|
10863
11077
|
const ruleCount = config.policies.rules.length;
|
|
11078
|
+
const budgetCount = config.budgets.length;
|
|
10864
11079
|
console.error(
|
|
10865
|
-
`Config is valid: ${configPath} (${String(ruleCount)} policy rule${ruleCount !== 1 ? "s" : ""})`
|
|
11080
|
+
`Config is valid: ${configPath} (${String(ruleCount)} policy rule${ruleCount !== 1 ? "s" : ""}, ${String(budgetCount)} budget${budgetCount !== 1 ? "s" : ""})`
|
|
10866
11081
|
);
|
|
10867
11082
|
} catch (err) {
|
|
10868
11083
|
if (err instanceof ConfigError) {
|
|
10869
11084
|
console.error(`Invalid config: ${err.message}`);
|
|
10870
|
-
|
|
10871
|
-
for (const detail of err.details) {
|
|
10872
|
-
console.error(` ${detail.path}: ${detail.message}`);
|
|
10873
|
-
}
|
|
10874
|
-
}
|
|
11085
|
+
printConfigErrorDetails(err);
|
|
10875
11086
|
process.exit(1);
|
|
10876
11087
|
}
|
|
10877
11088
|
if (err instanceof PolicyParseError) {
|
|
@@ -10891,12 +11102,29 @@ async function exportCommand(opts) {
|
|
|
10891
11102
|
process.exit(1);
|
|
10892
11103
|
}
|
|
10893
11104
|
const limit = Math.min(parsedLimit, EXPORT_MAX_RECORDS);
|
|
11105
|
+
if (opts.budgets !== void 0) {
|
|
11106
|
+
const conflicting = [
|
|
11107
|
+
["--tool", opts.tool],
|
|
11108
|
+
["--decision", opts.decision],
|
|
11109
|
+
["--reason", opts.reason],
|
|
11110
|
+
["--session", opts.session],
|
|
11111
|
+
["--from", opts.from],
|
|
11112
|
+
["--to", opts.to]
|
|
11113
|
+
].filter(([, value]) => value !== void 0);
|
|
11114
|
+
if (conflicting.length > 0) {
|
|
11115
|
+
console.error(
|
|
11116
|
+
`Error: --budgets cannot be combined with audit filters (${conflicting.map(([flag]) => flag).join(", ")})`
|
|
11117
|
+
);
|
|
11118
|
+
process.exit(1);
|
|
11119
|
+
}
|
|
11120
|
+
}
|
|
10894
11121
|
let config;
|
|
10895
11122
|
try {
|
|
10896
11123
|
config = await loadConfig(opts.config);
|
|
10897
11124
|
} catch (err) {
|
|
10898
11125
|
if (err instanceof ConfigError) {
|
|
10899
11126
|
console.error(`Error: ${err.message}`);
|
|
11127
|
+
printConfigErrorDetails(err);
|
|
10900
11128
|
process.exit(1);
|
|
10901
11129
|
}
|
|
10902
11130
|
throw err;
|
|
@@ -10909,6 +11137,17 @@ async function exportCommand(opts) {
|
|
|
10909
11137
|
// No cleanup timer for one-shot CLI
|
|
10910
11138
|
});
|
|
10911
11139
|
try {
|
|
11140
|
+
if (opts.budgets !== void 0) {
|
|
11141
|
+
const ledger = new BudgetLedger({ database: store.database });
|
|
11142
|
+
const page = ledger.listEventsForExport(opts.budgets, limit);
|
|
11143
|
+
if (opts.format === "csv") {
|
|
11144
|
+
console.log(budgetEventsToCsv(page.events));
|
|
11145
|
+
} else {
|
|
11146
|
+
console.log(JSON.stringify(page.events, null, 2));
|
|
11147
|
+
}
|
|
11148
|
+
console.error(`Exported ${String(page.events.length)} of ${String(page.total)} records`);
|
|
11149
|
+
return;
|
|
11150
|
+
}
|
|
10912
11151
|
const result = store.listForExport(
|
|
10913
11152
|
{
|
|
10914
11153
|
tool_name: opts.tool,
|
|
@@ -10990,5 +11229,5 @@ program.command("start").description("Load config and start the proxy server").o
|
|
|
10990
11229
|
);
|
|
10991
11230
|
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
11231
|
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));
|
|
11232
|
+
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
11233
|
program.parse();
|