@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/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 budgetContributorSchema = z.object({
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
- var helioConfigSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
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 flattenInputConditions(input, ruleIndex, ruleName) {
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 new PolicyParseError(
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 new PolicyParseError(
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 new PolicyParseError(
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
- return { tool: { pattern: contributor.tool, test }, field: contributor.field };
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
- const contentType = c.req.header("content-type") ?? "";
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
- const contentType = c.req.header("content-type") ?? "";
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
- ruleIndex: index,
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 === "number") {
4602
- if (!Number.isFinite(rawAmount) || rawAmount < 0) {
4603
- console.error(
4604
- `[helio] Warning: spend limit field "${maxSpend.field}" resolved to invalid amount (${String(rawAmount)}) for tool "${toolName}" in dry-run, denying`
4605
- );
4606
- wouldForward = false;
4607
- limitsOk = false;
4608
- } else {
4609
- const key = this.buildSpendLimitKey(
4610
- maxSpend.key,
4611
- toolName,
4612
- request,
4613
- decision.matchedRule.index
4614
- );
4615
- const peekResult = this.spendLimiter.peek({
4616
- key,
4617
- amount: rawAmount,
4618
- limit: maxSpend.limit,
4619
- windowMs: maxSpend.windowMs
4620
- });
4621
- wouldForward = peekResult.allowed;
4622
- limitsOk = peekResult.allowed;
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
- limitsChain: limitsBlock
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: !entry.commitState && entry.budgetsAtEvaluate ? { budgets: entry.budgetsAtEvaluate } : void 0,
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 = { ...evidenceChain ?? {}, sideband: { unreported: true } };
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 budget participates when any contributor glob matches the tool name;
8751
- * the FIRST matching contributor (config order) supplies the amount field.
8752
- * A missing, non-numeric, negative, or non-finite amount fails closed as a
8753
- * `failures` entry the caller must deny the call.
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((c) => c.tool.test(ctx.toolName));
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
- # (or hot-reloading) the proxy. Rotation invalidates active dashboard sessions.
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
- if (err.details) {
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
- if (err.details) {
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();