@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/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() {
@@ -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
- ruleIndex: index,
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 === "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
- }
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
- limitsChain: limitsBlock
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: !entry.commitState && entry.budgetsAtEvaluate ? { budgets: entry.budgetsAtEvaluate } : void 0,
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 = { ...evidenceChain ?? {}, sideband: { unreported: true } };
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 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.
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((c) => c.tool.test(ctx.toolName));
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
- # (or hot-reloading) the proxy. Rotation invalidates active dashboard sessions.
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
- if (err.details) {
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
- if (err.details) {
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();