@gethelio/proxy 0.9.0 → 0.10.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
@@ -142,9 +142,9 @@ var policyActionSchema = z.enum([
142
142
  "dry_run"
143
143
  ]);
144
144
  var ruleApprovalSchema = z.object({
145
- channel: z.string(),
145
+ channel: z.string().min(1),
146
146
  timeout: durationSchema.optional(),
147
- delegates: z.array(z.string()).optional(),
147
+ delegates: z.array(z.string().min(1)).optional(),
148
148
  escalation_after: durationSchema.optional()
149
149
  }).strict();
150
150
  var evidenceSchema = z.object({
@@ -226,22 +226,80 @@ var policiesSchema = z.object({
226
226
  */
227
227
  hot_reload: z.boolean().optional()
228
228
  }).strict();
229
+ var budgetContributorSchema = z.object({
230
+ tool: z.string().min(1),
231
+ // picomatch glob, same engine as match.tool
232
+ field: z.string().min(1)
233
+ // dot-path into tool arguments, e.g. "$.amount"
234
+ }).strict();
235
+ var budgetSchema = z.object({
236
+ // The name is embedded in bucket keys (`budget:<name>:<scope>`) and, later,
237
+ // ledger rows — constrain it so keys stay parseable and scope classification
238
+ // (e.g. the sender-key cardinality guard) cannot be confused by delimiters.
239
+ name: z.string().min(1).max(64).regex(/^[a-zA-Z0-9_-]+$/, {
240
+ message: 'Budget names may only contain letters, digits, "_" and "-"'
241
+ }),
242
+ limit: z.number().positive(),
243
+ currency: z.string().min(1),
244
+ /** A sliding duration ("1h", "7d") or "session" (a depleting pot per session key). */
245
+ window: z.union([durationSchema, z.literal("session")]),
246
+ key: z.enum(["global", "session", "sender_id"]).default("global"),
247
+ /**
248
+ * What a breach does: `deny` blocks the call outright; `require_approval`
249
+ * raises one composite break-glass ticket per call listing every breached
250
+ * budget, and the call proceeds only on an explicit approval.
251
+ */
252
+ on_exceed: z.enum(["deny", "require_approval"]).default("deny"),
253
+ /**
254
+ * Break-glass ticket routing (same shape as rule-level `approval`). Only
255
+ * valid with `on_exceed: require_approval`; when omitted, tickets fall
256
+ * back to the dashboard channel and the global `approval.timeout`. Note
257
+ * that `default_on_timeout` never applies to budget tickets — they fail
258
+ * closed on timeout regardless (money gates do not fail open).
259
+ */
260
+ approval: ruleApprovalSchema.optional(),
261
+ /** Session windows only: idle time before a session pot is collected. Default 24h. */
262
+ idle_ttl: durationSchema.optional(),
263
+ contributors: z.array(budgetContributorSchema).min(1)
264
+ }).strict().superRefine((budget, ctx) => {
265
+ if (budget.approval !== void 0 && budget.on_exceed !== "require_approval") {
266
+ ctx.addIssue({
267
+ code: "custom",
268
+ path: ["approval"],
269
+ message: 'budget approval config only applies with on_exceed: "require_approval" \u2014 with on_exceed: "deny" it is dead config. Remove it or switch on_exceed.'
270
+ });
271
+ }
272
+ if (budget.window === "session" && budget.key === "global") {
273
+ ctx.addIssue({
274
+ code: "custom",
275
+ path: ["key"],
276
+ message: 'window: "session" requires key: "session" or "sender_id" \u2014 a global bucket with session lifetime never replenishes and never ends. Pick a per-session or per-sender scope, or use a duration window.'
277
+ });
278
+ }
279
+ if (budget.window !== "session" && budget.idle_ttl !== void 0) {
280
+ ctx.addIssue({
281
+ code: "custom",
282
+ path: ["idle_ttl"],
283
+ message: 'idle_ttl only applies to window: "session" budgets. Duration windows expire entries on their own; remove idle_ttl.'
284
+ });
285
+ }
286
+ });
229
287
  var slackChannelSchema = z.object({
230
288
  type: z.literal("slack"),
231
- name: z.string().optional(),
289
+ name: z.string().min(1).optional(),
232
290
  bot_token: z.string(),
233
291
  signing_secret: z.string(),
234
292
  channel: z.string()
235
293
  });
236
294
  var webhookChannelSchema = z.object({
237
295
  type: z.literal("webhook"),
238
- name: z.string().optional(),
296
+ name: z.string().min(1).optional(),
239
297
  url: z.string(),
240
298
  secret: z.string().optional()
241
299
  });
242
300
  var dashboardChannelSchema = z.object({
243
301
  type: z.literal("dashboard"),
244
- name: z.string().optional()
302
+ name: z.string().min(1).optional()
245
303
  });
246
304
  var approvalChannelSchema = z.discriminatedUnion("type", [
247
305
  slackChannelSchema,
@@ -278,20 +336,23 @@ var helioConfigBaseSchema = z.object({
278
336
  dashboard: dashboardSchema.prefault({}),
279
337
  environment: z.string().optional(),
280
338
  policies: policiesSchema.prefault({}),
339
+ // Budgets sit beside policies deliberately: they are the second half of the
340
+ // governance declaration (policy decision → budget gate), not plumbing.
341
+ budgets: z.array(budgetSchema).default([]),
281
342
  approval: approvalSchema.prefault({}),
282
343
  audit: auditSchema.prefault({}),
283
344
  sdk: sdkSchema.prefault({})
284
345
  });
285
346
  var helioConfigSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
286
347
  const hasConfiguredEnvironment = typeof cfg.environment === "string" && cfg.environment.trim().length > 0;
287
- const requiresSecret = cfg.policies.flag_destructive === "require_approval" || cfg.policies.on_tool_drift === "require_approval" || cfg.policies.rules.some((rule) => rule.action === "require_approval");
348
+ 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");
288
349
  const hasSecret = hasDashboardApiSecret(cfg.dashboard.api_secret);
289
350
  if (requiresSecret) {
290
351
  if (!hasSecret) {
291
352
  ctx.addIssue({
292
353
  code: "custom",
293
354
  path: ["dashboard", "api_secret"],
294
- message: 'dashboard.api_secret is required when any rule uses require_approval or policies.flag_destructive or policies.on_tool_drift is "require_approval". Generate one with: `openssl rand -hex 32` and set it under `dashboard.api_secret` in your helio.yaml. (See docs/approvals.md.)'
355
+ message: 'dashboard.api_secret is required when any rule uses require_approval, any budget uses on_exceed: require_approval, or policies.flag_destructive or policies.on_tool_drift is "require_approval". Generate one with: `openssl rand -hex 32` and set it under `dashboard.api_secret` in your helio.yaml. (See docs/approvals.md.)'
295
356
  });
296
357
  }
297
358
  }
@@ -309,6 +370,86 @@ var helioConfigSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
309
370
  message: "dashboard.host must be a loopback address (127.0.0.1, localhost, or ::1) when dashboard.allow_open_mode is true and dashboard.api_secret is unset."
310
371
  });
311
372
  }
373
+ const channelTypeByKey = /* @__PURE__ */ new Map([["dashboard", "dashboard"]]);
374
+ for (const [channelIndex, channel] of cfg.approval.channels.entries()) {
375
+ const key = channel.name ?? channel.type;
376
+ if (key === "dashboard" && channel.type !== "dashboard") {
377
+ ctx.addIssue({
378
+ code: "custom",
379
+ path: ["approval", "channels", channelIndex, channel.name ? "name" : "type"],
380
+ message: 'The channel key "dashboard" is reserved for the built-in dashboard channel. Pick a different name.'
381
+ });
382
+ continue;
383
+ }
384
+ if (channelTypeByKey.has(key) && !(key === "dashboard" && channel.type === "dashboard")) {
385
+ ctx.addIssue({
386
+ code: "custom",
387
+ path: ["approval", "channels", channelIndex, channel.name ? "name" : "type"],
388
+ message: `Duplicate approval channel key "${key}". Channels register under name ?? type \u2014 give each channel a unique name.`
389
+ });
390
+ continue;
391
+ }
392
+ channelTypeByKey.set(key, channel.type);
393
+ }
394
+ const knownChannelKeys = new Set(channelTypeByKey.keys());
395
+ const resolvesToDashboard = (key) => channelTypeByKey.get(key) === "dashboard";
396
+ const seenBudgetNames = /* @__PURE__ */ new Set();
397
+ for (const [budgetIndex, budget] of cfg.budgets.entries()) {
398
+ if (seenBudgetNames.has(budget.name)) {
399
+ ctx.addIssue({
400
+ code: "custom",
401
+ path: ["budgets", budgetIndex, "name"],
402
+ message: `Duplicate budget name "${budget.name}". Budget names are the identity that preserves accrued spend across config edits \u2014 each budget needs its own.`
403
+ });
404
+ }
405
+ seenBudgetNames.add(budget.name);
406
+ if (!cfg.sdk.enabled && budget.key === "sender_id") {
407
+ ctx.addIssue({
408
+ code: "custom",
409
+ path: ["budgets", budgetIndex, "key"],
410
+ message: 'budget key "sender_id" requires the SDK sideband (sdk.enabled: true) \u2014 sender_id is supplied by hook adapters and is absent on the MCP path.'
411
+ });
412
+ }
413
+ const budgetChannel = budget.approval?.channel;
414
+ if (budgetChannel && !knownChannelKeys.has(budgetChannel)) {
415
+ ctx.addIssue({
416
+ code: "custom",
417
+ path: ["budgets", budgetIndex, "approval", "channel"],
418
+ message: `Unknown approval channel "${budgetChannel}". Add it to approval.channels (type or name), or use "dashboard".`
419
+ });
420
+ }
421
+ for (const [delegateIndex, delegate] of (budget.approval?.delegates ?? []).entries()) {
422
+ if (!knownChannelKeys.has(delegate)) {
423
+ ctx.addIssue({
424
+ code: "custom",
425
+ path: ["budgets", budgetIndex, "approval", "delegates", delegateIndex],
426
+ message: `Unknown delegate channel "${delegate}". Delegates must reference configured approval channel names.`
427
+ });
428
+ }
429
+ }
430
+ if (budget.on_exceed === "require_approval" && !cfg.dashboard.enabled) {
431
+ const effectiveChannel = budget.approval?.channel ?? "dashboard";
432
+ if (resolvesToDashboard(effectiveChannel)) {
433
+ ctx.addIssue({
434
+ code: "custom",
435
+ path: budget.approval?.channel !== void 0 ? ["budgets", budgetIndex, "approval", "channel"] : ["budgets", budgetIndex, "on_exceed"],
436
+ message: "This budget routes break-glass tickets 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."
437
+ });
438
+ }
439
+ const escalationAfterMs = budget.approval?.escalation_after !== void 0 ? parseDuration(budget.approval.escalation_after) : void 0;
440
+ const effectiveTimeoutMs = parseDuration(budget.approval?.timeout ?? cfg.approval.timeout);
441
+ const escalationCanFire = escalationAfterMs !== void 0 && escalationAfterMs > 0 && escalationAfterMs < effectiveTimeoutMs;
442
+ for (const [delegateIndex, delegate] of (budget.approval?.delegates ?? []).entries()) {
443
+ if (escalationCanFire && knownChannelKeys.has(delegate) && resolvesToDashboard(delegate)) {
444
+ ctx.addIssue({
445
+ code: "custom",
446
+ path: ["budgets", budgetIndex, "approval", "delegates", delegateIndex],
447
+ message: "This budget escalates break-glass tickets 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."
448
+ });
449
+ }
450
+ }
451
+ }
452
+ }
312
453
  const hasWebhookChannel = cfg.approval.channels.some((channel) => channel.type === "webhook");
313
454
  if (hasWebhookChannel && !cfg.dashboard.enabled) {
314
455
  ctx.addIssue({
@@ -317,13 +458,6 @@ var helioConfigSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
317
458
  message: "dashboard.enabled must be true when approval.channels includes a webhook channel. Webhook notifications require the dashboard sideband approval API."
318
459
  });
319
460
  }
320
- const knownChannelKeys = /* @__PURE__ */ new Set(["dashboard"]);
321
- for (const channel of cfg.approval.channels) {
322
- knownChannelKeys.add(channel.type);
323
- if (channel.name) {
324
- knownChannelKeys.add(channel.name);
325
- }
326
- }
327
461
  for (const [ruleIndex, rule] of cfg.policies.rules.entries()) {
328
462
  if (rule.match.environment !== void 0 && !hasConfiguredEnvironment) {
329
463
  ctx.addIssue({
@@ -499,6 +633,75 @@ function diffReloadBoundary(previous, next) {
499
633
  }
500
634
  return { restartRequiredPaths };
501
635
  }
636
+ function findUnroutableApprovalReferences(policy, budgets, surface) {
637
+ const problems = [];
638
+ const known = (key) => surface.channelTypes.has(key);
639
+ const isDashboardKey = (key) => surface.channelTypes.get(key) === "dashboard";
640
+ const escalationCanFire = (approval) => {
641
+ const timeoutMs = approval.timeoutMs ?? surface.defaultApprovalTimeoutMs;
642
+ return approval.escalationAfterMs !== void 0 && approval.escalationAfterMs > 0 && approval.escalationAfterMs < timeoutMs;
643
+ };
644
+ const needsRunningDashboard = (key, label, via) => {
645
+ if (isDashboardKey(key) && !surface.dashboardEnabled) {
646
+ problems.push(
647
+ `${label} ${via} the dashboard channel, but the running process has no dashboard server`
648
+ );
649
+ }
650
+ };
651
+ for (const rule of policy.rules) {
652
+ if (rule.action !== "require_approval") {
653
+ continue;
654
+ }
655
+ if (rule.match.metadata !== void 0) continue;
656
+ const label = rule.name ? `rule "${rule.name}"` : `rule[${String(rule.index)}]`;
657
+ const approval = rule.approval;
658
+ const effectiveChannel = approval?.channel ?? "dashboard";
659
+ if (!known(effectiveChannel)) {
660
+ problems.push(`${label} references approval channel "${effectiveChannel}"`);
661
+ } else {
662
+ needsRunningDashboard(effectiveChannel, label, "routes approvals to");
663
+ }
664
+ if (approval && escalationCanFire(approval)) {
665
+ for (const delegate of approval.delegates ?? []) {
666
+ if (!known(delegate)) {
667
+ problems.push(`${label} references delegate channel "${delegate}"`);
668
+ } else {
669
+ needsRunningDashboard(delegate, label, "escalates approvals to");
670
+ }
671
+ }
672
+ }
673
+ }
674
+ if (policy.flagDestructive === "require_approval" && !surface.dashboardEnabled) {
675
+ problems.push(
676
+ "policies.flag_destructive: require_approval routes approvals to the dashboard channel, but the running process has no dashboard server"
677
+ );
678
+ }
679
+ if (policy.onToolDrift === "require_approval" && !surface.dashboardEnabled) {
680
+ problems.push(
681
+ "policies.on_tool_drift: require_approval routes approvals to the dashboard channel, but the running process has no dashboard server"
682
+ );
683
+ }
684
+ for (const budget of budgets) {
685
+ if (budget.onExceed !== "require_approval") continue;
686
+ const label = `budget "${budget.name}"`;
687
+ const effectiveChannel = budget.approval?.channel ?? "dashboard";
688
+ if (!known(effectiveChannel)) {
689
+ problems.push(`${label} references approval channel "${effectiveChannel}"`);
690
+ } else {
691
+ needsRunningDashboard(effectiveChannel, label, "routes break-glass tickets to");
692
+ }
693
+ if (budget.approval && escalationCanFire(budget.approval)) {
694
+ for (const delegate of budget.approval.delegates ?? []) {
695
+ if (!known(delegate)) {
696
+ problems.push(`${label} references delegate channel "${delegate}"`);
697
+ } else {
698
+ needsRunningDashboard(delegate, label, "escalates break-glass tickets to");
699
+ }
700
+ }
701
+ }
702
+ }
703
+ return problems;
704
+ }
502
705
 
503
706
  // src/policy/parser.ts
504
707
  import picomatch from "picomatch";
@@ -753,10 +956,52 @@ function checkSemanticWarnings(rule, index, warnings) {
753
956
  }
754
957
  }
755
958
 
959
+ // src/budget/parser.ts
960
+ import picomatch2 from "picomatch";
961
+ var DEFAULT_IDLE_TTL_MS = 864e5;
962
+ var BudgetParseError = class extends Error {
963
+ budgetName;
964
+ constructor(message, budgetName) {
965
+ super(`Budget "${budgetName}": ${message}`);
966
+ this.name = "BudgetParseError";
967
+ this.budgetName = budgetName;
968
+ }
969
+ };
970
+ function compileBudgets(budgets) {
971
+ return budgets.map((budget) => ({
972
+ name: budget.name,
973
+ limit: budget.limit,
974
+ currency: budget.currency,
975
+ window: budget.window === "session" ? {
976
+ kind: "session",
977
+ idleTtlMs: budget.idle_ttl ? parseDuration(budget.idle_ttl) : DEFAULT_IDLE_TTL_MS
978
+ } : { kind: "duration", windowMs: parseDuration(budget.window) },
979
+ windowRaw: budget.window,
980
+ key: budget.key,
981
+ onExceed: budget.on_exceed,
982
+ ...budget.approval !== void 0 && { approval: compileApproval(budget.approval) },
983
+ contributors: budget.contributors.map(
984
+ (contributor) => compileContributor(contributor, budget.name)
985
+ )
986
+ }));
987
+ }
988
+ function compileContributor(contributor, budgetName) {
989
+ try {
990
+ const test = picomatch2(contributor.tool, { dot: true });
991
+ return { tool: { pattern: contributor.tool, test }, field: contributor.field };
992
+ } catch (err) {
993
+ const message = err instanceof Error ? err.message : String(err);
994
+ throw new BudgetParseError(
995
+ `invalid contributor glob "${contributor.tool}": ${message}`,
996
+ budgetName
997
+ );
998
+ }
999
+ }
1000
+
756
1001
  // src/config/watcher.ts
757
1002
  var ConfigWatcher = class {
758
1003
  configPath;
759
- onPolicyReload;
1004
+ onReload;
760
1005
  onError;
761
1006
  initialConfig;
762
1007
  env;
@@ -765,7 +1010,7 @@ var ConfigWatcher = class {
765
1010
  debounceTimer = null;
766
1011
  constructor(options) {
767
1012
  this.configPath = options.configPath;
768
- this.onPolicyReload = options.onPolicyReload;
1013
+ this.onReload = options.onReload;
769
1014
  this.onError = options.onError;
770
1015
  this.initialConfig = options.initialConfig;
771
1016
  this.env = options.env;
@@ -807,8 +1052,9 @@ var ConfigWatcher = class {
807
1052
  try {
808
1053
  const config = await loadConfig(this.configPath, this.env);
809
1054
  const { policy, warnings } = compilePolicies(config.policies);
1055
+ const budgets = compileBudgets(config.budgets);
810
1056
  const restartRequiredPaths = this.initialConfig !== void 0 ? diffReloadBoundary(this.initialConfig, config).restartRequiredPaths : [];
811
- this.onPolicyReload(policy, warnings, restartRequiredPaths);
1057
+ this.onReload(policy, warnings, restartRequiredPaths, budgets);
812
1058
  } catch (err) {
813
1059
  if (err instanceof Error) {
814
1060
  this.onError(err);
@@ -829,6 +1075,7 @@ import { Hono } from "hono";
829
1075
  // src/mcp/types.ts
830
1076
  var PARSE_ERROR = -32700;
831
1077
  var INVALID_REQUEST = -32600;
1078
+ var INVALID_PARAMS = -32602;
832
1079
  var INTERNAL_ERROR = -32603;
833
1080
  function makeJsonRpcError(id, code, message) {
834
1081
  return {
@@ -2459,6 +2706,9 @@ function evaluatePolicy(policy, ctx) {
2459
2706
  };
2460
2707
  }
2461
2708
 
2709
+ // src/policy/governed-forwarder.ts
2710
+ import { randomUUID as randomUUID2 } from "crypto";
2711
+
2462
2712
  // src/evidence/grounding.ts
2463
2713
  function checkEvidence(store, sessionId, requirements) {
2464
2714
  if (requirements.length === 0) {
@@ -2866,19 +3116,20 @@ function extractTools(body) {
2866
3116
 
2867
3117
  // src/feedback/self-repair.ts
2868
3118
  function ruleInfo(rule) {
3119
+ const index = rule?.index ?? null;
2869
3120
  return {
2870
3121
  rule: rule?.name ?? null,
2871
- ruleIndex: rule?.index ?? null
3122
+ ruleIndex: index,
3123
+ rule_index: index
2872
3124
  };
2873
3125
  }
2874
3126
  function buildPolicyDeniedFeedback(decision) {
2875
- const { rule, ruleIndex } = ruleInfo(decision.matchedRule);
2876
- const suggestion = decision.matchedRule?.feedback?.suggestion ?? decision.matchedRule?.feedback?.message ?? `This action was denied by policy${rule ? ` (rule: "${rule}")` : ""}. Review the policy configuration or use an allowed tool instead.`;
3127
+ const info = ruleInfo(decision.matchedRule);
3128
+ const suggestion = decision.matchedRule?.feedback?.suggestion ?? decision.matchedRule?.feedback?.message ?? `This action was denied by policy${info.rule ? ` (rule: "${info.rule}")` : ""}. Review the policy configuration or use an allowed tool instead.`;
2877
3129
  return {
2878
3130
  blocked: true,
2879
3131
  reason: "policy_denied",
2880
- rule,
2881
- ruleIndex,
3132
+ ...info,
2882
3133
  action: "deny",
2883
3134
  policy_reason: decision.reason,
2884
3135
  suggestion,
@@ -2886,14 +3137,13 @@ function buildPolicyDeniedFeedback(decision) {
2886
3137
  };
2887
3138
  }
2888
3139
  function buildEvidenceMissingFeedback(decision, evidenceResult, dependencyResult) {
2889
- const { rule, ruleIndex } = ruleInfo(decision.matchedRule);
3140
+ const info = ruleInfo(decision.matchedRule);
2890
3141
  const missing = evidenceResult?.missing ?? [];
2891
3142
  const suggestion = missing.length === 1 ? `Call the ${String(missing[0])} tool first to provide the required evidence, then retry this action.` : `Call the following tools first to provide the required evidence: ${missing.join(", ")}. Then retry this action.`;
2892
3143
  return {
2893
3144
  blocked: true,
2894
3145
  reason: "evidence_missing",
2895
- rule,
2896
- ruleIndex,
3146
+ ...info,
2897
3147
  action: "deny",
2898
3148
  missing_evidence: missing,
2899
3149
  expired_evidence: evidenceResult?.expired ?? [],
@@ -2903,14 +3153,13 @@ function buildEvidenceMissingFeedback(decision, evidenceResult, dependencyResult
2903
3153
  };
2904
3154
  }
2905
3155
  function buildEvidenceExpiredFeedback(decision, evidenceResult, dependencyResult) {
2906
- const { rule, ruleIndex } = ruleInfo(decision.matchedRule);
3156
+ const info = ruleInfo(decision.matchedRule);
2907
3157
  const expired = evidenceResult?.expired ?? [];
2908
3158
  const suggestion = expired.length === 1 ? `Evidence from ${String(expired[0])} has expired. Call it again to refresh the evidence, then retry this action.` : `Evidence from the following tools has expired: ${expired.join(", ")}. Call them again to refresh, then retry this action.`;
2909
3159
  return {
2910
3160
  blocked: true,
2911
3161
  reason: "evidence_expired",
2912
- rule,
2913
- ruleIndex,
3162
+ ...info,
2914
3163
  action: "deny",
2915
3164
  missing_evidence: evidenceResult?.missing ?? [],
2916
3165
  expired_evidence: expired,
@@ -2920,14 +3169,13 @@ function buildEvidenceExpiredFeedback(decision, evidenceResult, dependencyResult
2920
3169
  };
2921
3170
  }
2922
3171
  function buildDependencyMissingFeedback(decision, evidenceResult, dependencyResult) {
2923
- const { rule, ruleIndex } = ruleInfo(decision.matchedRule);
3172
+ const info = ruleInfo(decision.matchedRule);
2924
3173
  const missing = dependencyResult?.missing ?? [];
2925
3174
  const suggestion = missing.length === 1 ? `Call the ${String(missing[0])} tool first before attempting this action.` : `Call the following tools first: ${missing.join(", ")}. Then retry this action.`;
2926
3175
  return {
2927
3176
  blocked: true,
2928
3177
  reason: "dependency_missing",
2929
- rule,
2930
- ruleIndex,
3178
+ ...info,
2931
3179
  action: "deny",
2932
3180
  missing_evidence: evidenceResult?.missing ?? [],
2933
3181
  expired_evidence: evidenceResult?.expired ?? [],
@@ -2937,13 +3185,12 @@ function buildDependencyMissingFeedback(decision, evidenceResult, dependencyResu
2937
3185
  };
2938
3186
  }
2939
3187
  function buildApprovalDeniedFeedback(decision, deniedBy, denialReason) {
2940
- const { rule, ruleIndex } = ruleInfo(decision.matchedRule);
3188
+ const info = ruleInfo(decision.matchedRule);
2941
3189
  const suggestion = decision.matchedRule?.feedback?.suggestion ?? decision.matchedRule?.feedback?.message ?? `This action was denied by ${deniedBy}.${denialReason ? ` Reason: ${denialReason}.` : ""} Contact them for details or use an alternative approach.`;
2942
3190
  return {
2943
3191
  blocked: true,
2944
3192
  reason: "approval_denied",
2945
- rule,
2946
- ruleIndex,
3193
+ ...info,
2947
3194
  action: "require_approval",
2948
3195
  denied_by: deniedBy,
2949
3196
  denial_reason: denialReason ?? null,
@@ -2952,14 +3199,13 @@ function buildApprovalDeniedFeedback(decision, deniedBy, denialReason) {
2952
3199
  };
2953
3200
  }
2954
3201
  function buildApprovalTimeoutFeedback(decision, timeoutMs) {
2955
- const { rule, ruleIndex } = ruleInfo(decision.matchedRule);
3202
+ const info = ruleInfo(decision.matchedRule);
2956
3203
  const timeoutSeconds = Math.round(timeoutMs / 1e3);
2957
3204
  const suggestion = decision.matchedRule?.feedback?.suggestion ?? decision.matchedRule?.feedback?.message ?? `Approval request timed out after ${String(timeoutSeconds)}s. Try again or contact an approver directly.`;
2958
3205
  return {
2959
3206
  blocked: true,
2960
3207
  reason: "approval_timeout",
2961
- rule,
2962
- ruleIndex,
3208
+ ...info,
2963
3209
  action: "require_approval",
2964
3210
  timeout_seconds: timeoutSeconds,
2965
3211
  suggestion,
@@ -2967,41 +3213,38 @@ function buildApprovalTimeoutFeedback(decision, timeoutMs) {
2967
3213
  };
2968
3214
  }
2969
3215
  function buildClientDisconnectedFeedback(decision) {
2970
- const { rule, ruleIndex } = ruleInfo(decision.matchedRule);
3216
+ const info = ruleInfo(decision.matchedRule);
2971
3217
  const suggestion = decision.matchedRule?.feedback?.suggestion ?? decision.matchedRule?.feedback?.message ?? "The client disconnected before this request completed. Retry with a stable connection.";
2972
3218
  return {
2973
3219
  blocked: true,
2974
3220
  reason: "client_disconnected",
2975
- rule,
2976
- ruleIndex,
3221
+ ...info,
2977
3222
  action: "require_approval",
2978
3223
  suggestion,
2979
3224
  retry_allowed: true
2980
3225
  };
2981
3226
  }
2982
3227
  function buildShutdownCancelledFeedback(decision) {
2983
- const { rule, ruleIndex } = ruleInfo(decision.matchedRule);
3228
+ const info = ruleInfo(decision.matchedRule);
2984
3229
  const suggestion = decision.matchedRule?.feedback?.suggestion ?? decision.matchedRule?.feedback?.message ?? "The proxy was shut down while this request was awaiting approval (for example during deploy/restart). Retry once the proxy is healthy.";
2985
3230
  return {
2986
3231
  blocked: true,
2987
3232
  reason: "shutdown_cancelled",
2988
- rule,
2989
- ruleIndex,
3233
+ ...info,
2990
3234
  action: "require_approval",
2991
3235
  suggestion,
2992
3236
  retry_allowed: true
2993
3237
  };
2994
3238
  }
2995
3239
  function buildRateLimitedFeedback(decision, result) {
2996
- const { rule, ruleIndex } = ruleInfo(decision.matchedRule);
3240
+ const info = ruleInfo(decision.matchedRule);
2997
3241
  const windowSeconds = Math.round(result.windowMs / 1e3);
2998
3242
  const resetAt = new Date(result.resetAtMs).toISOString();
2999
3243
  const suggestion = decision.matchedRule?.feedback?.suggestion ?? decision.matchedRule?.feedback?.message ?? `Rate limit exceeded (${String(result.current)}/${String(result.limit)} calls in ${String(windowSeconds)}s window). Retry after ${resetAt} or reduce call frequency.`;
3000
3244
  return {
3001
3245
  blocked: true,
3002
3246
  reason: "rate_limited",
3003
- rule,
3004
- ruleIndex,
3247
+ ...info,
3005
3248
  action: "rate_limit",
3006
3249
  current_calls: result.current,
3007
3250
  max_calls: result.limit,
@@ -3016,8 +3259,7 @@ function buildToolDriftFeedback(drift, action) {
3016
3259
  return {
3017
3260
  blocked: true,
3018
3261
  reason: "tool_definition_drift",
3019
- rule: null,
3020
- ruleIndex: null,
3262
+ ...ruleInfo(void 0),
3021
3263
  action,
3022
3264
  drifted_aspects: aspects,
3023
3265
  suggestion: `The definition of "${drift.toolName}" changed upstream (${aspects.join(", ")}) after Helio baselined it. An operator must review the change; restarting the proxy re-baselines, or the upstream can revert the change.`,
@@ -3025,15 +3267,14 @@ function buildToolDriftFeedback(drift, action) {
3025
3267
  };
3026
3268
  }
3027
3269
  function buildSpendLimitedFeedback(decision, result, currency) {
3028
- const { rule, ruleIndex } = ruleInfo(decision.matchedRule);
3270
+ const info = ruleInfo(decision.matchedRule);
3029
3271
  const windowSeconds = Math.round(result.windowMs / 1e3);
3030
3272
  if (result.reason === "invalid_amount") {
3031
3273
  const field = decision.matchedRule?.limits?.maxSpend?.field ?? "amount";
3032
3274
  return {
3033
3275
  blocked: true,
3034
3276
  reason: "spend_limited",
3035
- rule,
3036
- ruleIndex,
3277
+ ...info,
3037
3278
  action: "spend_limit",
3038
3279
  current_spend: result.currentSpend,
3039
3280
  max_spend: result.limit,
@@ -3049,8 +3290,7 @@ function buildSpendLimitedFeedback(decision, result, currency) {
3049
3290
  return {
3050
3291
  blocked: true,
3051
3292
  reason: "spend_limited",
3052
- rule,
3053
- ruleIndex,
3293
+ ...info,
3054
3294
  action: "spend_limit",
3055
3295
  current_spend: result.currentSpend,
3056
3296
  max_spend: result.limit,
@@ -3061,86 +3301,506 @@ function buildSpendLimitedFeedback(decision, result, currency) {
3061
3301
  retry_allowed: true
3062
3302
  };
3063
3303
  }
3304
+ function breachBlock(entry) {
3305
+ return {
3306
+ name: entry.budget.name,
3307
+ limit: entry.budget.limit,
3308
+ spent: entry.spent,
3309
+ remaining: entry.remaining,
3310
+ attempted_amount: entry.amount,
3311
+ currency: entry.budget.currency,
3312
+ window: entry.budget.windowRaw,
3313
+ on_exceed: entry.budget.onExceed,
3314
+ reset_at: entry.resetAtMs === null ? null : new Date(entry.resetAtMs).toISOString()
3315
+ };
3316
+ }
3317
+ function buildBudgetExceededFeedback(decision, breaches, failures) {
3318
+ const info = ruleInfo(decision.matchedRule);
3319
+ const budgets = [
3320
+ ...breaches.map(breachBlock),
3321
+ ...failures.map((failure) => ({
3322
+ name: failure.budget.name,
3323
+ limit: failure.budget.limit,
3324
+ spent: failure.spent,
3325
+ remaining: failure.remaining,
3326
+ attempted_amount: null,
3327
+ currency: failure.budget.currency,
3328
+ window: failure.budget.windowRaw,
3329
+ on_exceed: failure.budget.onExceed,
3330
+ reset_at: failure.resetAtMs === null ? null : new Date(failure.resetAtMs).toISOString(),
3331
+ reason: "invalid_amount"
3332
+ }))
3333
+ ];
3334
+ const suggestion = failures.length > 0 ? `Budget ${failures.map((f) => `"${f.budget.name}"`).join(", ")} could not read a valid spend amount from this call. Retry with a non-negative finite amount in the expected field.` : `Budget ${breaches.map((b) => `"${b.budget.name}"`).join(", ")} would be exceeded by this call. Wait for the window to reset or reduce the amount.`;
3335
+ const retryAllowed = breaches.every((entry) => entry.budget.window.kind === "duration");
3336
+ return {
3337
+ blocked: true,
3338
+ reason: "budget_exceeded",
3339
+ ...info,
3340
+ action: "budget",
3341
+ budgets,
3342
+ suggestion,
3343
+ retry_allowed: retryAllowed
3344
+ };
3345
+ }
3346
+ function buildBudgetApprovalDeniedFeedback(decision, breaches, deniedBy, denialReason) {
3347
+ const info = ruleInfo(decision.matchedRule);
3348
+ const names = breaches.map((b) => `"${b.budget.name}"`).join(", ");
3349
+ return {
3350
+ blocked: true,
3351
+ reason: "budget_exceeded",
3352
+ ...info,
3353
+ action: "budget",
3354
+ denied_by: deniedBy,
3355
+ denial_reason: denialReason ?? null,
3356
+ budgets: breaches.map(breachBlock),
3357
+ suggestion: `The budget overage on ${names} was denied by ${deniedBy}.${denialReason ? ` Reason: ${denialReason}.` : ""} Wait for the window to reset, reduce the amount, or contact them for details.`,
3358
+ retry_allowed: false
3359
+ };
3360
+ }
3361
+ function buildBudgetApprovalTimeoutFeedback(decision, breaches, timeoutMs) {
3362
+ const info = ruleInfo(decision.matchedRule);
3363
+ const timeoutSeconds = Math.round(timeoutMs / 1e3);
3364
+ const names = breaches.map((b) => `"${b.budget.name}"`).join(", ");
3365
+ return {
3366
+ blocked: true,
3367
+ reason: "budget_exceeded",
3368
+ ...info,
3369
+ action: "budget",
3370
+ timeout_seconds: timeoutSeconds,
3371
+ budgets: breaches.map(breachBlock),
3372
+ suggestion: `The break-glass request for ${names} timed out after ${String(timeoutSeconds)}s (budget approvals never fail open). Try again or contact an approver directly.`,
3373
+ retry_allowed: true
3374
+ };
3375
+ }
3064
3376
 
3065
- // src/policy/governed-forwarder.ts
3066
- var POLICY_DENIED = -32001;
3067
- var GovernedForwarder = class {
3068
- inner;
3069
- policy;
3070
- environment;
3071
- auditWriter;
3072
- evidenceStore;
3073
- approvalRouter;
3074
- rateLimiter;
3075
- spendLimiter;
3076
- annotationCache = new ToolAnnotationCache();
3077
- agentKeyWarned = false;
3078
- senderKeyWarned = false;
3079
- constructor(inner, policy, options) {
3080
- this.inner = inner;
3081
- this.policy = policy;
3082
- this.environment = options?.environment;
3083
- this.auditWriter = options?.auditWriter;
3084
- this.evidenceStore = options?.evidenceStore;
3085
- this.approvalRouter = options?.approvalRouter;
3086
- this.rateLimiter = options?.rateLimiter;
3087
- this.spendLimiter = options?.spendLimiter;
3088
- if (this.evidenceStore) {
3089
- this.evidenceStore.setAllowedEvidenceKeys(collectAllowedEvidenceKeys(policy));
3377
+ // src/policy/spend-limiter.ts
3378
+ function spendBucketKey(baseKey, ruleIndex) {
3379
+ return `${baseKey}:rule:${String(ruleIndex)}`;
3380
+ }
3381
+ var RULE_SUFFIX_RE = /:rule:(\d+)$/;
3382
+ var SpendLimiter = class {
3383
+ buckets = /* @__PURE__ */ new Map();
3384
+ now;
3385
+ onWarning;
3386
+ warningThreshold;
3387
+ timer = null;
3388
+ closed = false;
3389
+ constructor(options = {}) {
3390
+ this.now = options.now ?? Date.now;
3391
+ this.onWarning = options.onWarning;
3392
+ this.warningThreshold = options.warningThreshold ?? 0.8;
3393
+ const intervalMs = options.cleanupIntervalMs ?? 6e4;
3394
+ if (intervalMs > 0) {
3395
+ this.timer = setInterval(() => {
3396
+ this.cleanup();
3397
+ }, intervalMs);
3398
+ this.timer.unref();
3090
3399
  }
3091
3400
  }
3401
+ // -------------------------------------------------------------------------
3402
+ // Core operations
3403
+ // -------------------------------------------------------------------------
3092
3404
  /**
3093
- * Swap the compiled policy atomically and reconcile limit bucket state
3094
- * against the new configuration.
3095
- *
3096
- * Rate and spend limit buckets are preserved when their underlying rule
3097
- * config is unchanged — this is what makes a benign hot-reload (e.g. a
3098
- * `vim :w` with no real edits, or a whitespace-only config change) safe:
3099
- * operators do not get a surprise zero of their live rate/spend state
3100
- * mid-window. Buckets whose config changed or whose rule was removed are
3101
- * evicted by the limiters' `reconcile()` methods, so the next check
3102
- * lazy-creates a fresh bucket under the new config.
3405
+ * Check and optionally record a spend against the limit.
3103
3406
  *
3104
- * See `packages/proxy/src/policy/rate-limiter.ts` and `spend-limiter.ts`
3105
- * for the per-bucket compare-and-evict semantics.
3407
+ * Evicts expired entries, sums remaining amounts, then checks:
3408
+ * - Under limit (currentSpend + amount <= limit): records and returns `allowed: true`
3409
+ * - Would exceed: does NOT record (rejected spends don't consume budget)
3106
3410
  */
3107
- updatePolicy(policy) {
3108
- this.policy = policy;
3109
- if (this.evidenceStore) {
3110
- this.evidenceStore.setAllowedEvidenceKeys(collectAllowedEvidenceKeys(policy));
3411
+ check(params) {
3412
+ const { key, amount, limit, windowMs } = params;
3413
+ const now = this.now();
3414
+ const windowStart = now - windowMs;
3415
+ if (!Number.isFinite(amount) || amount < 0) {
3416
+ const existing = this.buckets.get(key);
3417
+ const activeEntries = existing ? existing.entries.filter((e) => e.timestamp > windowStart) : [];
3418
+ const currentSpend2 = activeEntries.reduce((sum, e) => sum + e.amount, 0);
3419
+ const oldest = activeEntries[0];
3420
+ return {
3421
+ allowed: false,
3422
+ currentSpend: currentSpend2,
3423
+ limit,
3424
+ windowMs,
3425
+ resetAtMs: oldest ? oldest.timestamp + windowMs : 0,
3426
+ reason: "invalid_amount"
3427
+ };
3111
3428
  }
3112
- if (this.rateLimiter) {
3113
- const rateConfigs = [];
3114
- for (const rule of policy.rules) {
3115
- const limits = rule.limits;
3116
- if (limits?.maxCalls !== void 0 && limits.windowMs !== void 0) {
3117
- rateConfigs.push({ maxCalls: limits.maxCalls, windowMs: limits.windowMs });
3118
- }
3119
- }
3120
- this.rateLimiter.reconcile(rateConfigs);
3429
+ let bucket = this.buckets.get(key);
3430
+ if (!bucket) {
3431
+ bucket = { entries: [], limit, currency: "", windowMs };
3432
+ this.buckets.set(key, bucket);
3121
3433
  }
3122
- if (this.spendLimiter) {
3123
- const spendConfigs = [];
3124
- for (const rule of policy.rules) {
3125
- const maxSpend = rule.limits?.maxSpend;
3126
- if (maxSpend) {
3127
- spendConfigs.push({
3128
- limit: maxSpend.limit,
3129
- currency: maxSpend.currency,
3130
- windowMs: maxSpend.windowMs
3131
- });
3132
- }
3133
- }
3134
- this.spendLimiter.reconcile(spendConfigs);
3434
+ bucket.limit = limit;
3435
+ bucket.windowMs = windowMs;
3436
+ bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
3437
+ const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
3438
+ if (currentSpend + amount > limit) {
3439
+ const oldest = bucket.entries[0];
3440
+ return {
3441
+ allowed: false,
3442
+ currentSpend,
3443
+ limit,
3444
+ windowMs,
3445
+ resetAtMs: oldest ? oldest.timestamp + windowMs : 0
3446
+ };
3135
3447
  }
3136
- }
3137
- /**
3138
- * Prime the annotation cache by fetching tools/list directly from upstream.
3139
- *
3140
- * This path is intended for startup warm-up and intentionally bypasses policy
3141
- * and audit handling. Runtime tools/list requests still flow through forward().
3142
- *
3143
- * When the inner forwarder exposes `forwardInternal` (duck-typed), the prime
3448
+ bucket.entries.push({ timestamp: now, amount });
3449
+ const newSpend = currentSpend + amount;
3450
+ const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
3451
+ if (this.onWarning && newSpend / limit >= this.warningThreshold) {
3452
+ this.safeWarn({
3453
+ key,
3454
+ current_spend: newSpend,
3455
+ limit,
3456
+ currency: bucket.currency,
3457
+ window_ms: windowMs,
3458
+ reset_at_ms: resetAtMs
3459
+ });
3460
+ }
3461
+ return {
3462
+ allowed: true,
3463
+ currentSpend: newSpend,
3464
+ limit,
3465
+ windowMs,
3466
+ resetAtMs
3467
+ };
3468
+ }
3469
+ /**
3470
+ * Unconditionally record a spend against the limit.
3471
+ *
3472
+ * Unlike check(), this always appends the amount — even when it pushes the
3473
+ * window past the limit — because the spend it represents has already been
3474
+ * incurred. The sideband peeks at /evaluate and commits here at /audit once
3475
+ * the external call ran (issue #12, D3).
3476
+ *
3477
+ * Throws on a negative or non-finite amount: such amounts are rejected at
3478
+ * /evaluate, so one reaching record() is a logic bug we surface loudly rather
3479
+ * than silently corrupt the sliding-window sum. Warnings fire only while the
3480
+ * post-append spend stays within the limit (parity with check()).
3481
+ */
3482
+ record(params) {
3483
+ const { key, amount, limit, windowMs } = params;
3484
+ if (!Number.isFinite(amount) || amount < 0) {
3485
+ throw new RangeError(
3486
+ `SpendLimiter.record() received an invalid amount (${String(amount)}); invalid amounts must be rejected at /evaluate, never committed`
3487
+ );
3488
+ }
3489
+ const now = this.now();
3490
+ const windowStart = now - windowMs;
3491
+ let bucket = this.buckets.get(key);
3492
+ if (!bucket) {
3493
+ bucket = { entries: [], limit, currency: "", windowMs };
3494
+ this.buckets.set(key, bucket);
3495
+ }
3496
+ bucket.limit = limit;
3497
+ bucket.windowMs = windowMs;
3498
+ bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
3499
+ bucket.entries.push({ timestamp: now, amount });
3500
+ const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
3501
+ const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
3502
+ if (this.onWarning && currentSpend <= limit && currentSpend / limit >= this.warningThreshold) {
3503
+ this.safeWarn({
3504
+ key,
3505
+ current_spend: currentSpend,
3506
+ limit,
3507
+ currency: bucket.currency,
3508
+ window_ms: windowMs,
3509
+ reset_at_ms: resetAtMs
3510
+ });
3511
+ }
3512
+ return {
3513
+ allowed: currentSpend <= limit,
3514
+ currentSpend,
3515
+ limit,
3516
+ windowMs,
3517
+ resetAtMs
3518
+ };
3519
+ }
3520
+ /**
3521
+ * Check the spend limit without recording the spend (non-destructive).
3522
+ *
3523
+ * Used by dry-run mode to determine what would happen without consuming
3524
+ * budget in the bucket.
3525
+ */
3526
+ peek(params) {
3527
+ const { key, amount, limit, windowMs } = params;
3528
+ const now = this.now();
3529
+ const windowStart = now - windowMs;
3530
+ const bucket = this.buckets.get(key);
3531
+ if (!Number.isFinite(amount) || amount < 0) {
3532
+ const activeEntries2 = bucket ? bucket.entries.filter((e) => e.timestamp > windowStart) : [];
3533
+ const currentSpend2 = activeEntries2.reduce((sum, e) => sum + e.amount, 0);
3534
+ const oldest2 = activeEntries2[0];
3535
+ return {
3536
+ allowed: false,
3537
+ currentSpend: currentSpend2,
3538
+ limit,
3539
+ windowMs,
3540
+ resetAtMs: oldest2 ? oldest2.timestamp + windowMs : 0,
3541
+ reason: "invalid_amount"
3542
+ };
3543
+ }
3544
+ if (!bucket) {
3545
+ const wouldExceed = amount > limit;
3546
+ return {
3547
+ allowed: !wouldExceed,
3548
+ currentSpend: wouldExceed ? 0 : amount,
3549
+ limit,
3550
+ windowMs,
3551
+ resetAtMs: now + windowMs
3552
+ };
3553
+ }
3554
+ const activeEntries = bucket.entries.filter((e) => e.timestamp > windowStart);
3555
+ const currentSpend = activeEntries.reduce((sum, e) => sum + e.amount, 0);
3556
+ if (currentSpend + amount > limit) {
3557
+ const oldest2 = activeEntries[0];
3558
+ return {
3559
+ allowed: false,
3560
+ currentSpend,
3561
+ limit,
3562
+ windowMs,
3563
+ resetAtMs: oldest2 ? oldest2.timestamp + windowMs : 0
3564
+ };
3565
+ }
3566
+ const newSpend = currentSpend + amount;
3567
+ const oldest = activeEntries[0];
3568
+ return {
3569
+ allowed: true,
3570
+ currentSpend: newSpend,
3571
+ limit,
3572
+ windowMs,
3573
+ resetAtMs: oldest ? oldest.timestamp + windowMs : now + windowMs
3574
+ };
3575
+ }
3576
+ /**
3577
+ * Set the display currency for a key. Called by the governed forwarder
3578
+ * after check() so dashboard reads include the currency label.
3579
+ */
3580
+ setCurrency(key, currency) {
3581
+ const bucket = this.buckets.get(key);
3582
+ if (bucket) bucket.currency = currency;
3583
+ }
3584
+ // -------------------------------------------------------------------------
3585
+ // Read operations (for dashboard API)
3586
+ // -------------------------------------------------------------------------
3587
+ /** Get the current state of a single key. Returns undefined if not tracked. */
3588
+ getKeyState(key) {
3589
+ const bucket = this.buckets.get(key);
3590
+ if (!bucket) return void 0;
3591
+ const windowStart = this.now() - bucket.windowMs;
3592
+ bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
3593
+ if (bucket.entries.length === 0) {
3594
+ this.buckets.delete(key);
3595
+ return void 0;
3596
+ }
3597
+ const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
3598
+ return {
3599
+ key,
3600
+ current_spend: currentSpend,
3601
+ limit: bucket.limit,
3602
+ currency: bucket.currency,
3603
+ window_ms: bucket.windowMs,
3604
+ reset_at_ms: (bucket.entries[0]?.timestamp ?? 0) + bucket.windowMs
3605
+ };
3606
+ }
3607
+ /** List all tracked keys with their current state. */
3608
+ listKeyStates() {
3609
+ const states = [];
3610
+ for (const key of [...this.buckets.keys()]) {
3611
+ const state = this.getKeyState(key);
3612
+ if (state) states.push(state);
3613
+ }
3614
+ return states;
3615
+ }
3616
+ // -------------------------------------------------------------------------
3617
+ // Maintenance
3618
+ // -------------------------------------------------------------------------
3619
+ /** Sweep all buckets: remove expired entries, delete empty buckets. */
3620
+ cleanup() {
3621
+ const now = this.now();
3622
+ for (const [key, bucket] of this.buckets) {
3623
+ const windowStart = now - bucket.windowMs;
3624
+ bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
3625
+ if (bucket.entries.length === 0) {
3626
+ this.buckets.delete(key);
3627
+ }
3628
+ }
3629
+ }
3630
+ /** Clear all spend limit state. Called on policy hot-reload. */
3631
+ reset() {
3632
+ this.buckets.clear();
3633
+ }
3634
+ /**
3635
+ * Reconcile bucket state against a new policy's spend configuration.
3636
+ *
3637
+ * Walks every existing bucket and checks whether its last-seen
3638
+ * `{ limit, currency, windowMs }` tuple still appears in `validConfigs`.
3639
+ * Buckets whose config is unchanged are left untouched — cumulative spend
3640
+ * and elapsed-window progress are preserved across hot-reloads. Buckets
3641
+ * whose config is gone (rule changed or removed) are evicted so the next
3642
+ * check lazy-creates a fresh bucket under the new config.
3643
+ *
3644
+ * Keys built by {@link spendBucketKey} carry the owning rule's index, and
3645
+ * for those the tuple must match at THAT index (`config.ruleIndex`): a
3646
+ * reorder that shifts a spend rule's index evicts its old-index bucket
3647
+ * instead of leaving an orphan no rule reads again — or worse, letting
3648
+ * whatever rule now sits at that index adopt another rule's accrued spend.
3649
+ * Un-suffixed keys keep the tuple-anywhere match.
3650
+ *
3651
+ * Currency is part of the tuple because a USD→EUR switch is a meaningful
3652
+ * policy change — the same numeric limit buys a different amount of real
3653
+ * spend, so the bucket must reset. This replaces the old `reset()` call
3654
+ * on every hot-reload, which wiped all state even when the matching rule
3655
+ * was unchanged.
3656
+ */
3657
+ reconcile(validConfigs) {
3658
+ const valid = /* @__PURE__ */ new Set();
3659
+ const byIndex = /* @__PURE__ */ new Map();
3660
+ for (const config of validConfigs) {
3661
+ const tuple = `${String(config.limit)}|${config.currency}|${String(config.windowMs)}`;
3662
+ if (config.ruleIndex === void 0) {
3663
+ valid.add(tuple);
3664
+ } else {
3665
+ byIndex.set(config.ruleIndex, tuple);
3666
+ }
3667
+ }
3668
+ for (const [key, bucket] of this.buckets) {
3669
+ const tuple = `${String(bucket.limit)}|${bucket.currency}|${String(bucket.windowMs)}`;
3670
+ const suffix = RULE_SUFFIX_RE.exec(key);
3671
+ const survives = suffix ? byIndex.get(Number(suffix[1])) === tuple : valid.has(tuple);
3672
+ if (!survives) {
3673
+ this.buckets.delete(key);
3674
+ }
3675
+ }
3676
+ }
3677
+ /** Stop the cleanup timer and mark as closed. */
3678
+ /**
3679
+ * Invoke the warning callback without letting a subscriber throw into the
3680
+ * limiter's caller: a warning fires after state has already mutated, and a
3681
+ * governed call must not be blocked (or double-charged on retry) by an
3682
+ * observability bug.
3683
+ */
3684
+ safeWarn(state) {
3685
+ if (!this.onWarning) return;
3686
+ try {
3687
+ this.onWarning(state);
3688
+ } catch (err) {
3689
+ console.error("[helio] limit warning subscriber threw:", err);
3690
+ }
3691
+ }
3692
+ close() {
3693
+ if (this.closed) return;
3694
+ this.closed = true;
3695
+ if (this.timer) {
3696
+ clearInterval(this.timer);
3697
+ this.timer = null;
3698
+ }
3699
+ this.buckets.clear();
3700
+ }
3701
+ };
3702
+
3703
+ // src/policy/governed-forwarder.ts
3704
+ var POLICY_DENIED = -32001;
3705
+ function blocked(result) {
3706
+ return { proceed: false, result, approvalWaitMs: 0 };
3707
+ }
3708
+ function budgetChainBlock(entry, kind) {
3709
+ return {
3710
+ name: entry.budget.name,
3711
+ bucket_key: entry.bucketKey,
3712
+ allowed: entry.allowed,
3713
+ amount: entry.amount,
3714
+ spent: entry.spent,
3715
+ limit: entry.budget.limit,
3716
+ remaining: entry.remaining,
3717
+ currency: entry.budget.currency,
3718
+ ...kind ? { kind } : {},
3719
+ ...entry.stale ? { stale: true } : {}
3720
+ };
3721
+ }
3722
+ var GovernedForwarder = class {
3723
+ inner;
3724
+ policy;
3725
+ environment;
3726
+ auditWriter;
3727
+ evidenceStore;
3728
+ approvalRouter;
3729
+ rateLimiter;
3730
+ spendLimiter;
3731
+ budgetEngine;
3732
+ annotationCache = new ToolAnnotationCache();
3733
+ agentKeyWarned = false;
3734
+ senderKeyWarned = false;
3735
+ constructor(inner, policy, options) {
3736
+ this.inner = inner;
3737
+ this.policy = policy;
3738
+ this.environment = options?.environment;
3739
+ this.auditWriter = options?.auditWriter;
3740
+ this.evidenceStore = options?.evidenceStore;
3741
+ this.approvalRouter = options?.approvalRouter;
3742
+ this.rateLimiter = options?.rateLimiter;
3743
+ this.spendLimiter = options?.spendLimiter;
3744
+ this.budgetEngine = options?.budgetEngine;
3745
+ if (this.evidenceStore) {
3746
+ this.evidenceStore.setAllowedEvidenceKeys(collectAllowedEvidenceKeys(policy));
3747
+ }
3748
+ }
3749
+ /**
3750
+ * Swap the compiled policy atomically and reconcile limit bucket state
3751
+ * against the new configuration.
3752
+ *
3753
+ * Rate and spend limit buckets are preserved when their underlying rule
3754
+ * config is unchanged — this is what makes a benign hot-reload (e.g. a
3755
+ * `vim :w` with no real edits, or a whitespace-only config change) safe:
3756
+ * operators do not get a surprise zero of their live rate/spend state
3757
+ * mid-window. Buckets whose config changed or whose rule was removed are
3758
+ * evicted by the limiters' `reconcile()` methods, so the next check
3759
+ * lazy-creates a fresh bucket under the new config.
3760
+ *
3761
+ * See `packages/proxy/src/policy/rate-limiter.ts` and `spend-limiter.ts`
3762
+ * for the per-bucket compare-and-evict semantics.
3763
+ */
3764
+ updatePolicy(policy) {
3765
+ this.policy = policy;
3766
+ if (this.evidenceStore) {
3767
+ this.evidenceStore.setAllowedEvidenceKeys(collectAllowedEvidenceKeys(policy));
3768
+ }
3769
+ if (this.rateLimiter) {
3770
+ const rateConfigs = [];
3771
+ for (const rule of policy.rules) {
3772
+ const limits = rule.limits;
3773
+ if (limits?.maxCalls !== void 0 && limits.windowMs !== void 0) {
3774
+ rateConfigs.push({ maxCalls: limits.maxCalls, windowMs: limits.windowMs });
3775
+ }
3776
+ }
3777
+ this.rateLimiter.reconcile(rateConfigs);
3778
+ }
3779
+ if (this.spendLimiter) {
3780
+ const spendConfigs = [];
3781
+ for (const rule of policy.rules) {
3782
+ const maxSpend = rule.limits?.maxSpend;
3783
+ if (maxSpend) {
3784
+ spendConfigs.push({
3785
+ limit: maxSpend.limit,
3786
+ currency: maxSpend.currency,
3787
+ windowMs: maxSpend.windowMs,
3788
+ // Spend bucket keys are rule-discriminated (spendBucketKey), so
3789
+ // reconcile must match tuples at the owning rule's index.
3790
+ ruleIndex: rule.index
3791
+ });
3792
+ }
3793
+ }
3794
+ this.spendLimiter.reconcile(spendConfigs);
3795
+ }
3796
+ }
3797
+ /**
3798
+ * Prime the annotation cache by fetching tools/list directly from upstream.
3799
+ *
3800
+ * This path is intended for startup warm-up and intentionally bypasses policy
3801
+ * and audit handling. Runtime tools/list requests still flow through forward().
3802
+ *
3803
+ * When the inner forwarder exposes `forwardInternal` (duck-typed), the prime
3144
3804
  * request is routed through it so session-enforcing servers (e.g. Streamable
3145
3805
  * HTTP upstreams) receive the request on the managed internal session rather
3146
3806
  * than as a sessionless call that they would reject with HTTP 400.
@@ -3243,13 +3903,24 @@ var GovernedForwarder = class {
3243
3903
  metadata: null
3244
3904
  });
3245
3905
  }
3246
- async handleToolsCall(request) {
3906
+ async handleToolsCall(original) {
3247
3907
  const startTime = performance.now();
3248
3908
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
3909
+ let request;
3910
+ try {
3911
+ request = original.params === void 0 ? original : { ...original, params: structuredClone(original.params) };
3912
+ } catch {
3913
+ return makeErrorResult(
3914
+ original,
3915
+ INVALID_PARAMS,
3916
+ "tools/call params must be JSON-serializable",
3917
+ { blocked: true, reason: "invalid_params" }
3918
+ );
3919
+ }
3249
3920
  const params = request.params;
3250
3921
  const toolName = typeof params?.["name"] === "string" ? params["name"] : void 0;
3251
3922
  if (!toolName) {
3252
- return this.inner.forward(request);
3923
+ return this.rejectNamelessToolsCall(request, params, timestamp, startTime);
3253
3924
  }
3254
3925
  const toolArguments = params?.["arguments"] && typeof params["arguments"] === "object" ? params["arguments"] : void 0;
3255
3926
  const {
@@ -3274,6 +3945,7 @@ var GovernedForwarder = class {
3274
3945
  currentAnnotations: this.annotationCache.getCurrent(toolName),
3275
3946
  driftEvent: this.annotationCache.getDrift(toolName)
3276
3947
  });
3948
+ const auditRecordId = randomUUID2();
3277
3949
  let result;
3278
3950
  let approvalOutcome;
3279
3951
  let approvalWaitMs = 0;
@@ -3281,52 +3953,74 @@ var GovernedForwarder = class {
3281
3953
  let rateLimitResult;
3282
3954
  let spendLimitResult;
3283
3955
  let forwardingError;
3956
+ let forwarded = false;
3957
+ let budgetsChain;
3958
+ let budgetApproval;
3284
3959
  try {
3285
3960
  if (isDryRun) {
3286
3961
  result = this.handleDryRun(request, decision, toolName, toolArguments, evidenceBlocked);
3287
- } else if (sessionBlocked) {
3288
- result = this.makeSessionRequiredBlockResult(request, decision);
3289
- } else if (evidenceBlocked) {
3290
- result = this.makeEvidenceBlockResult(request, decision, evidenceResult, dependencyResult);
3291
- } else if (driftBlocked && driftEvent) {
3292
- result = this.makeDriftBlockResult(request, driftEvent);
3293
- } else if (decision.action === "allow") {
3294
- result = await this.inner.forward(request);
3295
- } else if (decision.action === "deny") {
3296
- result = this.makeDenyResult(request, decision);
3297
- } else if (decision.action === "require_approval") {
3298
- if (!this.approvalRouter) {
3299
- result = this.makeUnsupportedResult(request, decision, toolName);
3300
- } else {
3301
- const approvalResult = await this.handleApproval(
3302
- request,
3303
- decision,
3304
- toolName,
3305
- toolArguments
3306
- );
3307
- result = approvalResult.result;
3308
- approvalOutcome = approvalResult.outcome;
3309
- approvalWaitMs = approvalResult.approvalWaitMs;
3310
- approvalContext = approvalResult.approvalContext;
3311
- }
3312
- } else if (decision.action === "rate_limit") {
3313
- if (!this.rateLimiter) {
3314
- result = this.makeUnsupportedResult(request, decision, toolName);
3315
- } else {
3316
- const rlResult = await this.handleRateLimit(request, decision, toolName);
3317
- result = rlResult.result;
3318
- rateLimitResult = rlResult.rateLimitResult;
3319
- }
3320
- } else if (decision.action === "spend_limit") {
3321
- if (!this.spendLimiter) {
3322
- result = this.makeUnsupportedResult(request, decision, toolName);
3962
+ } else {
3963
+ const gate = decision.action === "require_approval" && this.approvalRouter ? await this.handleApproval(request, decision, toolName, toolArguments) : this.resolveActionGate(request, decision, toolName, toolArguments, {
3964
+ sessionBlocked,
3965
+ evidenceBlocked,
3966
+ driftBlocked,
3967
+ driftEvent,
3968
+ evidenceResult,
3969
+ dependencyResult
3970
+ });
3971
+ approvalOutcome = gate.approvalOutcome;
3972
+ approvalWaitMs = gate.approvalWaitMs;
3973
+ approvalContext = gate.approvalContext;
3974
+ rateLimitResult = gate.rateLimitResult;
3975
+ spendLimitResult = gate.spendLimitResult;
3976
+ if (!gate.proceed) {
3977
+ result = gate.result;
3323
3978
  } else {
3324
- const slResult = await this.handleSpendLimit(request, decision, toolName, toolArguments);
3325
- result = slResult.result;
3326
- spendLimitResult = slResult.spendLimitResult;
3979
+ const budgetGate = this.gateBudgets(request, decision, toolName, toolArguments);
3980
+ budgetsChain = budgetGate.chain;
3981
+ let budgetBlock;
3982
+ if (budgetGate.kind === "approval") {
3983
+ const held = await this.handleBudgetApproval(
3984
+ request,
3985
+ decision,
3986
+ toolName,
3987
+ toolArguments,
3988
+ budgetGate
3989
+ );
3990
+ budgetApproval = held.audit;
3991
+ approvalWaitMs += held.waitMs;
3992
+ if (!held.proceed) budgetBlock = held.result;
3993
+ }
3994
+ if (budgetGate.kind === "blocked") {
3995
+ result = budgetGate.result;
3996
+ } else if (budgetBlock) {
3997
+ result = budgetBlock;
3998
+ } else {
3999
+ let ledgerBlock;
4000
+ try {
4001
+ budgetsChain = budgetGate.commit?.(auditRecordId) ?? budgetsChain;
4002
+ } catch (ledgerError) {
4003
+ const reason = ledgerError instanceof Error ? ledgerError.message : String(ledgerError);
4004
+ console.error(
4005
+ `[helio] Budget ledger write failed for tool "${toolName}"; blocking the call: ${reason}`
4006
+ );
4007
+ ledgerBlock = makeErrorResult(request, INTERNAL_ERROR, "budget ledger write failed", {
4008
+ blocked: true,
4009
+ reason: "budget_ledger_write_failed",
4010
+ failure_class: "budget_ledger_write_failed",
4011
+ failure_reason: reason
4012
+ });
4013
+ budgetsChain = [{ ledger_write_failed: true, reason }];
4014
+ }
4015
+ if (ledgerBlock) {
4016
+ result = ledgerBlock;
4017
+ } else {
4018
+ gate.commitRuleLimit?.();
4019
+ forwarded = true;
4020
+ result = await this.inner.forward(request);
4021
+ }
4022
+ }
3327
4023
  }
3328
- } else {
3329
- result = this.makeDenyResult(request, decision);
3330
4024
  }
3331
4025
  } catch (error) {
3332
4026
  forwardingError = error instanceof Error ? error : new Error(String(error));
@@ -3335,19 +4029,18 @@ var GovernedForwarder = class {
3335
4029
  failure_reason: forwardingError.message
3336
4030
  });
3337
4031
  }
3338
- const wasForwarded = this.wasForwardedUpstream(
3339
- decision,
3340
- approvalOutcome,
3341
- rateLimitResult,
3342
- spendLimitResult
3343
- );
3344
- if (wasForwarded && !isDryRun && this.evidenceStore && request.sessionId && toolName) {
3345
- const succeeded = !hasJsonRpcError(result);
3346
- this.evidenceStore.recordToolCall(request.sessionId, toolName, succeeded);
4032
+ try {
4033
+ if (forwarded && !isDryRun && this.evidenceStore && request.sessionId && toolName) {
4034
+ const succeeded = !hasJsonRpcError(result);
4035
+ this.evidenceStore.recordToolCall(request.sessionId, toolName, succeeded);
4036
+ }
4037
+ } catch (err) {
4038
+ console.error("[helio] dependency tracking failed after forward:", err);
3347
4039
  }
3348
4040
  const totalDurationMs = performance.now() - startTime;
3349
- this.writeAuditRecord(
4041
+ this.writeAuditRecordSafely(
3350
4042
  request,
4043
+ auditRecordId,
3351
4044
  timestamp,
3352
4045
  toolName,
3353
4046
  toolArguments,
@@ -3356,6 +4049,7 @@ var GovernedForwarder = class {
3356
4049
  totalDurationMs,
3357
4050
  approvalWaitMs,
3358
4051
  flaggedDestructive,
4052
+ forwarded,
3359
4053
  evidenceResult,
3360
4054
  dependencyResult,
3361
4055
  evidenceBlocked,
@@ -3363,12 +4057,320 @@ var GovernedForwarder = class {
3363
4057
  approvalContext,
3364
4058
  rateLimitResult,
3365
4059
  spendLimitResult,
4060
+ budgetsChain,
4061
+ budgetApproval,
3366
4062
  isDryRun,
3367
4063
  forwardingError,
3368
4064
  driftEvent ? { event: driftEvent, mode: driftMode } : void 0
3369
4065
  );
3370
4066
  return result;
3371
4067
  }
4068
+ /**
4069
+ * Await the composite break-glass ticket for a budget overage (issue #14).
4070
+ *
4071
+ * The ticket is routed by the BUDGET's approval config (first breached
4072
+ * budget in config order), never the matched rule's. Deviation from rule
4073
+ * approvals, by design: timeout ALWAYS fails closed — `default_on_timeout:
4074
+ * allow` would forward an unapproved overage, and recording it as
4075
+ * `approved_overage` would be a lie while not recording it would corrupt
4076
+ * the pot. Money gates do not fail open.
4077
+ */
4078
+ async handleBudgetApproval(request, decision, toolName, toolArguments, gate) {
4079
+ const router = this.approvalRouter;
4080
+ const approvalStart = performance.now();
4081
+ const outcome = await router.submit(
4082
+ {
4083
+ tool_name: toolName,
4084
+ tool_input: toolArguments ?? {},
4085
+ matched_rule: decision.matchedRule,
4086
+ session_id: request.sessionId ?? null,
4087
+ breached_budgets: gate.breachContexts,
4088
+ approval: gate.approval
4089
+ },
4090
+ request.signal
4091
+ );
4092
+ const waitMs = performance.now() - approvalStart;
4093
+ const ticket = outcome.ticketId ? router.getTicket(outcome.ticketId) : void 0;
4094
+ const denialReason = outcome.status === "denied" && outcome.reason ? outcome.reason : void 0;
4095
+ const audit = {
4096
+ outcome,
4097
+ ...denialReason ? { denialReason } : {},
4098
+ ...ticket?.escalated_at ? { escalatedAt: ticket.escalated_at, escalatedTo: [...ticket.escalated_to ?? []] } : {}
4099
+ };
4100
+ if (outcome.status === "approved" || outcome.status === "break_glass") {
4101
+ if (request.signal?.aborted) {
4102
+ return {
4103
+ proceed: false,
4104
+ result: this.makeClientDisconnectedBlockResult(request, decision),
4105
+ audit,
4106
+ waitMs
4107
+ };
4108
+ }
4109
+ return { proceed: true, audit, waitMs };
4110
+ }
4111
+ if (outcome.status === "denied") {
4112
+ const feedback2 = buildBudgetApprovalDeniedFeedback(
4113
+ decision,
4114
+ gate.breaches,
4115
+ outcome.resolvedBy,
4116
+ outcome.reason
4117
+ );
4118
+ return {
4119
+ proceed: false,
4120
+ result: makeErrorResult(
4121
+ request,
4122
+ POLICY_DENIED,
4123
+ `Budget overage denied by ${outcome.resolvedBy}`,
4124
+ { ...feedback2 }
4125
+ ),
4126
+ audit,
4127
+ waitMs
4128
+ };
4129
+ }
4130
+ if (outcome.status === "client_disconnected" || request.signal?.aborted) {
4131
+ return {
4132
+ proceed: false,
4133
+ result: this.makeClientDisconnectedBlockResult(request, decision),
4134
+ audit,
4135
+ waitMs
4136
+ };
4137
+ }
4138
+ if (outcome.status === "shutdown_cancelled") {
4139
+ const feedback2 = buildShutdownCancelledFeedback(decision);
4140
+ return {
4141
+ proceed: false,
4142
+ result: makeErrorResult(
4143
+ request,
4144
+ POLICY_DENIED,
4145
+ "Budget approval cancelled by proxy shutdown",
4146
+ { ...feedback2 }
4147
+ ),
4148
+ audit,
4149
+ waitMs
4150
+ };
4151
+ }
4152
+ const feedback = buildBudgetApprovalTimeoutFeedback(decision, gate.breaches, outcome.timeoutMs);
4153
+ return {
4154
+ proceed: false,
4155
+ result: makeErrorResult(request, POLICY_DENIED, "Budget approval timed out", {
4156
+ ...feedback
4157
+ }),
4158
+ audit,
4159
+ waitMs
4160
+ };
4161
+ }
4162
+ /** writeAuditRecord, isolated: an audit-writer bug must not reject the response. */
4163
+ writeAuditRecordSafely(...args) {
4164
+ try {
4165
+ this.writeAuditRecord(...args);
4166
+ } catch (err) {
4167
+ console.error("[helio] audit record write failed:", err);
4168
+ }
4169
+ }
4170
+ /**
4171
+ * Everything the non-approval action branches decide, minus the forward
4172
+ * itself. Deliberately SYNCHRONOUS: the caller must reach the phase-3
4173
+ * commits without yielding to the microtask queue, or concurrent calls
4174
+ * could double-spend a peeked limiter slot. The approval branch (the only
4175
+ * one that genuinely waits) is dispatched by the caller directly.
4176
+ */
4177
+ resolveActionGate(request, decision, toolName, toolArguments, pipeline) {
4178
+ if (pipeline.sessionBlocked) {
4179
+ return blocked(this.makeSessionRequiredBlockResult(request, decision));
4180
+ }
4181
+ if (pipeline.evidenceBlocked) {
4182
+ return blocked(
4183
+ this.makeEvidenceBlockResult(
4184
+ request,
4185
+ decision,
4186
+ pipeline.evidenceResult,
4187
+ pipeline.dependencyResult
4188
+ )
4189
+ );
4190
+ }
4191
+ if (pipeline.driftBlocked && pipeline.driftEvent) {
4192
+ return blocked(this.makeDriftBlockResult(request, pipeline.driftEvent));
4193
+ }
4194
+ switch (decision.action) {
4195
+ case "allow":
4196
+ return { proceed: true, approvalWaitMs: 0 };
4197
+ case "deny":
4198
+ return blocked(this.makeDenyResult(request, decision));
4199
+ case "require_approval":
4200
+ return blocked(this.makeUnsupportedResult(request, decision, toolName));
4201
+ case "rate_limit":
4202
+ if (!this.rateLimiter) {
4203
+ return blocked(this.makeUnsupportedResult(request, decision, toolName));
4204
+ }
4205
+ return this.handleRateLimit(request, decision, toolName);
4206
+ case "spend_limit":
4207
+ if (!this.spendLimiter) {
4208
+ return blocked(this.makeUnsupportedResult(request, decision, toolName));
4209
+ }
4210
+ return this.handleSpendLimit(request, decision, toolName, toolArguments);
4211
+ default:
4212
+ return blocked(this.makeDenyResult(request, decision));
4213
+ }
4214
+ }
4215
+ /**
4216
+ * Phase 2: check every budget the call feeds, all-or-nothing (issue #14).
4217
+ *
4218
+ * Any `on_exceed: deny` breach (or invalid amount) denies and records
4219
+ * NOTHING on any budget — rejected calls never consume budget anywhere.
4220
+ * Breaches that are all `on_exceed: require_approval` yield the `approval`
4221
+ * variant: one composite break-glass ticket per call, and only an explicit
4222
+ * approval commits (breached budgets as `approved_overage`). On proceed,
4223
+ * the returned `commit` records every charge together (ledger rows first,
4224
+ * atomically, referencing the pre-generated audit id).
4225
+ */
4226
+ gateBudgets(request, decision, toolName, toolArguments) {
4227
+ const engine = this.budgetEngine;
4228
+ if (!engine) return { kind: "proceed" };
4229
+ const { charges, failures } = engine.resolveCharges({
4230
+ toolName,
4231
+ toolArguments,
4232
+ sessionId: request.sessionId ?? null,
4233
+ senderId: null
4234
+ // adapter context; absent on the MCP path
4235
+ });
4236
+ if (charges.length === 0 && failures.length === 0) return { kind: "proceed" };
4237
+ const peek = charges.length > 0 ? engine.peekAll(charges) : { allowed: true, entries: [] };
4238
+ const breaches = peek.entries.filter((entry) => !entry.allowed);
4239
+ const anyHardDeny = failures.length > 0 || breaches.some((entry) => entry.budget.onExceed === "deny");
4240
+ if (breaches.length > 0) engine.reportBreaches(breaches);
4241
+ if (anyHardDeny) {
4242
+ if (failures.length > 0) {
4243
+ console.error(
4244
+ `[helio] Warning: budget ${failures.map((f) => `"${f.budget.name}"`).join(", ")} could not resolve a valid amount for tool "${toolName}", denying request`
4245
+ );
4246
+ }
4247
+ const feedback = buildBudgetExceededFeedback(decision, breaches, failures);
4248
+ const message = failures.length > 0 ? `Budget denied: invalid amount for ${failures.map((f) => `"${f.budget.name}"`).join(", ")}` : `Budget exceeded: ${breaches.map((entry) => `"${entry.budget.name}"`).join(", ")}`;
4249
+ return {
4250
+ kind: "blocked",
4251
+ result: makeErrorResult(request, POLICY_DENIED, message, { ...feedback }),
4252
+ chain: [
4253
+ ...peek.entries.map((entry) => budgetChainBlock(entry)),
4254
+ ...failures.map((failure) => ({
4255
+ name: failure.budget.name,
4256
+ bucket_key: failure.bucketKey,
4257
+ allowed: false,
4258
+ reason: failure.reason,
4259
+ spent: failure.spent,
4260
+ limit: failure.budget.limit,
4261
+ remaining: failure.remaining,
4262
+ currency: failure.budget.currency
4263
+ }))
4264
+ ]
4265
+ };
4266
+ }
4267
+ const peekBlockByName = new Map(
4268
+ peek.entries.map((entry) => [entry.budget.name, budgetChainBlock(entry)])
4269
+ );
4270
+ const commit = (auditRecordId, kinds) => engine.recordAll(charges, {
4271
+ kind: "spend",
4272
+ ...kinds ? { kinds } : {},
4273
+ auditRecordId,
4274
+ origin: "mcp",
4275
+ toolName,
4276
+ timestampIso: (/* @__PURE__ */ new Date()).toISOString()
4277
+ }).map((entry) => {
4278
+ const kind = kinds?.get(entry.budget.name) ?? "spend";
4279
+ const frozen = peekBlockByName.get(entry.budget.name);
4280
+ return entry.stale && frozen ? { ...frozen, kind, stale: true } : budgetChainBlock(entry, kind);
4281
+ });
4282
+ if (breaches.length > 0) {
4283
+ if (!this.approvalRouter) {
4284
+ console.error(
4285
+ `[helio] Budget ${breaches.map((b) => `"${b.budget.name}"`).join(", ")} requires break-glass approval but no approval router is configured; denying request`
4286
+ );
4287
+ const feedback = buildBudgetExceededFeedback(decision, breaches, []);
4288
+ return {
4289
+ kind: "blocked",
4290
+ result: makeErrorResult(
4291
+ request,
4292
+ POLICY_DENIED,
4293
+ `Budget exceeded: ${breaches.map((entry) => `"${entry.budget.name}"`).join(", ")}`,
4294
+ { ...feedback }
4295
+ ),
4296
+ chain: peek.entries.map((entry) => budgetChainBlock(entry))
4297
+ };
4298
+ }
4299
+ const kinds = new Map(
4300
+ breaches.map((entry) => [entry.budget.name, "approved_overage"])
4301
+ );
4302
+ return {
4303
+ kind: "approval",
4304
+ chain: peek.entries.map((entry) => budgetChainBlock(entry)),
4305
+ commit: (auditRecordId) => commit(auditRecordId, kinds),
4306
+ breaches,
4307
+ breachContexts: breaches.map((entry) => ({
4308
+ name: entry.budget.name,
4309
+ limit: entry.budget.limit,
4310
+ spent: entry.spent,
4311
+ attempted_amount: entry.amount,
4312
+ currency: entry.budget.currency,
4313
+ window: entry.budget.windowRaw
4314
+ })),
4315
+ approval: breaches[0]?.budget.approval ?? { channel: "dashboard" }
4316
+ };
4317
+ }
4318
+ return {
4319
+ kind: "proceed",
4320
+ chain: peek.entries.map((entry) => budgetChainBlock(entry)),
4321
+ commit: (auditRecordId) => commit(auditRecordId)
4322
+ };
4323
+ }
4324
+ /**
4325
+ * Reject a `tools/call` that carries no usable tool name and record it.
4326
+ *
4327
+ * The rejection is its own audit shape, not a governed decision: no rule was
4328
+ * evaluated, so it is written directly (like {@link writeDriftAuditRecord})
4329
+ * rather than threaded through {@link writeAuditRecord}, whose
4330
+ * `PolicyDecision.action` union has no `rejected` member and whose
4331
+ * forwarded-upstream logic does not apply. The raw `params` are preserved in
4332
+ * `tool_input` so an investigator can see exactly what a lenient upstream
4333
+ * could have keyed off.
4334
+ */
4335
+ rejectNamelessToolsCall(request, params, timestamp, startTime) {
4336
+ const result = makeErrorResult(
4337
+ request,
4338
+ INVALID_PARAMS,
4339
+ "tools/call requires a string params.name",
4340
+ { blocked: true, reason: "missing_tool_name" }
4341
+ );
4342
+ if (this.auditWriter) {
4343
+ const toolInput = { raw_params: params ?? null };
4344
+ this.auditWriter.pushImmediate({
4345
+ timestamp,
4346
+ session_id: request.sessionId ?? null,
4347
+ agent_id: null,
4348
+ environment: this.environment ?? null,
4349
+ tool_name: "<nameless>",
4350
+ tool_input: toolInput,
4351
+ policy_decision: "rejected",
4352
+ block_reason: "missing_tool_name",
4353
+ matched_rule: null,
4354
+ matched_rule_index: null,
4355
+ evidence_chain: null,
4356
+ approval_status: null,
4357
+ approved_by: null,
4358
+ upstream_response: null,
4359
+ upstream_error: null,
4360
+ upstream_http_status: null,
4361
+ upstream_latency_ms: null,
4362
+ total_duration_ms: performance.now() - startTime,
4363
+ approval_wait_ms: 0,
4364
+ proxy_compute_ms: performance.now() - startTime,
4365
+ flagged_destructive: false,
4366
+ dry_run: false,
4367
+ record_kind: "tool_call",
4368
+ origin: "mcp",
4369
+ metadata: null
4370
+ });
4371
+ }
4372
+ return result;
4373
+ }
3372
4374
  async handleApproval(request, decision, toolName, toolArguments) {
3373
4375
  const router = this.approvalRouter;
3374
4376
  const approvalStart = performance.now();
@@ -3392,81 +4394,126 @@ var GovernedForwarder = class {
3392
4394
  escalated_to: [...ticket.escalated_to ?? []]
3393
4395
  } : {}
3394
4396
  } : void 0;
3395
- let result;
3396
4397
  if (outcome.status === "approved" || outcome.status === "break_glass") {
3397
4398
  if (request.signal?.aborted) {
3398
- result = this.makeClientDisconnectedBlockResult(request, decision);
3399
- } else {
3400
- result = await this.inner.forward(request);
3401
- }
3402
- } else if (outcome.status === "denied") {
3403
- const feedback = buildApprovalDeniedFeedback(decision, outcome.resolvedBy, outcome.reason);
3404
- const message = decision.matchedRule?.feedback?.message ?? `Approval denied by ${outcome.resolvedBy}`;
3405
- result = makeErrorResult(request, POLICY_DENIED, message, { ...feedback });
3406
- } else if (outcome.status === "client_disconnected") {
3407
- result = this.makeClientDisconnectedBlockResult(request, decision);
3408
- } else if (outcome.status === "shutdown_cancelled") {
3409
- const feedback = buildShutdownCancelledFeedback(decision);
3410
- const message = decision.matchedRule?.feedback?.message ?? "Approval cancelled by proxy shutdown";
3411
- result = makeErrorResult(request, POLICY_DENIED, message, { ...feedback });
3412
- } else {
3413
- if (router.defaultOnTimeout === "allow" && !request.signal?.aborted) {
3414
- result = await this.inner.forward(request);
3415
- } else if (request.signal?.aborted) {
3416
- result = this.makeClientDisconnectedBlockResult(request, decision);
3417
- } else {
3418
- const feedback = buildApprovalTimeoutFeedback(decision, outcome.timeoutMs);
3419
- const message = decision.matchedRule?.feedback?.message ?? `Approval timed out`;
3420
- result = makeErrorResult(request, POLICY_DENIED, message, { ...feedback });
4399
+ return {
4400
+ proceed: false,
4401
+ result: this.makeClientDisconnectedBlockResult(request, decision),
4402
+ approvalOutcome: outcome,
4403
+ approvalWaitMs,
4404
+ approvalContext
4405
+ };
3421
4406
  }
4407
+ return { proceed: true, approvalOutcome: outcome, approvalWaitMs, approvalContext };
4408
+ }
4409
+ if (outcome.status === "denied") {
4410
+ const feedback2 = buildApprovalDeniedFeedback(decision, outcome.resolvedBy, outcome.reason);
4411
+ const message2 = decision.matchedRule?.feedback?.message ?? `Approval denied by ${outcome.resolvedBy}`;
4412
+ return {
4413
+ proceed: false,
4414
+ result: makeErrorResult(request, POLICY_DENIED, message2, { ...feedback2 }),
4415
+ approvalOutcome: outcome,
4416
+ approvalWaitMs,
4417
+ approvalContext
4418
+ };
3422
4419
  }
3423
- return { result, outcome, approvalWaitMs, approvalContext };
4420
+ if (outcome.status === "client_disconnected") {
4421
+ return {
4422
+ proceed: false,
4423
+ result: this.makeClientDisconnectedBlockResult(request, decision),
4424
+ approvalOutcome: outcome,
4425
+ approvalWaitMs,
4426
+ approvalContext
4427
+ };
4428
+ }
4429
+ if (outcome.status === "shutdown_cancelled") {
4430
+ const feedback2 = buildShutdownCancelledFeedback(decision);
4431
+ const message2 = decision.matchedRule?.feedback?.message ?? "Approval cancelled by proxy shutdown";
4432
+ return {
4433
+ proceed: false,
4434
+ result: makeErrorResult(request, POLICY_DENIED, message2, { ...feedback2 }),
4435
+ approvalOutcome: outcome,
4436
+ approvalWaitMs,
4437
+ approvalContext
4438
+ };
4439
+ }
4440
+ if (router.defaultOnTimeout === "allow" && !request.signal?.aborted) {
4441
+ return { proceed: true, approvalOutcome: outcome, approvalWaitMs, approvalContext };
4442
+ }
4443
+ if (request.signal?.aborted) {
4444
+ return {
4445
+ proceed: false,
4446
+ result: this.makeClientDisconnectedBlockResult(request, decision),
4447
+ approvalOutcome: outcome,
4448
+ approvalWaitMs,
4449
+ approvalContext
4450
+ };
4451
+ }
4452
+ const feedback = buildApprovalTimeoutFeedback(decision, outcome.timeoutMs);
4453
+ const message = decision.matchedRule?.feedback?.message ?? `Approval timed out`;
4454
+ return {
4455
+ proceed: false,
4456
+ result: makeErrorResult(request, POLICY_DENIED, message, { ...feedback }),
4457
+ approvalOutcome: outcome,
4458
+ approvalWaitMs,
4459
+ approvalContext
4460
+ };
3424
4461
  }
3425
- async handleRateLimit(request, decision, toolName) {
4462
+ handleRateLimit(request, decision, toolName) {
3426
4463
  const limiter = this.rateLimiter;
3427
4464
  const limits = decision.matchedRule?.limits;
3428
4465
  if (!limits?.maxCalls || !limits.windowMs) {
3429
- const result2 = this.makePolicyMisconfiguredResult(
4466
+ const result = this.makePolicyMisconfiguredResult(
3430
4467
  request,
3431
4468
  decision,
3432
4469
  `Policy misconfigured: rate_limit rule for "${toolName}" requires limits.max_calls and limits.window`
3433
4470
  );
3434
4471
  return {
3435
- result: result2,
4472
+ proceed: false,
4473
+ result,
4474
+ approvalWaitMs: 0,
3436
4475
  rateLimitResult: { allowed: false, current: 0, limit: 0, windowMs: 0, resetAtMs: 0 }
3437
4476
  };
3438
4477
  }
3439
4478
  const key = this.buildLimitKey(limits.key, toolName, request);
3440
- const rateLimitResult = limiter.check({
3441
- key,
3442
- maxCalls: limits.maxCalls,
3443
- windowMs: limits.windowMs
3444
- });
3445
- let result;
3446
- if (rateLimitResult.allowed) {
3447
- result = await this.inner.forward(request);
3448
- } else {
4479
+ const params = { key, maxCalls: limits.maxCalls, windowMs: limits.windowMs };
4480
+ const rateLimitResult = limiter.peek(params);
4481
+ if (!rateLimitResult.allowed) {
3449
4482
  const feedback = buildRateLimitedFeedback(decision, rateLimitResult);
3450
4483
  const message = decision.matchedRule?.feedback?.message ?? `Rate limit exceeded for ${key}`;
3451
- result = makeErrorResult(request, POLICY_DENIED, message, { ...feedback });
4484
+ return {
4485
+ proceed: false,
4486
+ result: makeErrorResult(request, POLICY_DENIED, message, { ...feedback }),
4487
+ approvalWaitMs: 0,
4488
+ rateLimitResult
4489
+ };
3452
4490
  }
3453
- return { result, rateLimitResult };
4491
+ return {
4492
+ proceed: true,
4493
+ approvalWaitMs: 0,
4494
+ rateLimitResult,
4495
+ commitRuleLimit: () => {
4496
+ limiter.record(params);
4497
+ }
4498
+ };
3454
4499
  }
3455
- async handleSpendLimit(request, decision, toolName, toolArguments) {
4500
+ handleSpendLimit(request, decision, toolName, toolArguments) {
3456
4501
  const limiter = this.spendLimiter;
3457
4502
  const maxSpend = decision.matchedRule?.limits?.maxSpend;
3458
4503
  if (!maxSpend) {
3459
- const result2 = this.makePolicyMisconfiguredResult(
4504
+ const result = this.makePolicyMisconfiguredResult(
3460
4505
  request,
3461
4506
  decision,
3462
4507
  `Policy misconfigured: spend_limit rule for "${toolName}" requires limits.max_spend`
3463
4508
  );
3464
4509
  return {
3465
- result: result2,
4510
+ proceed: false,
4511
+ result,
4512
+ approvalWaitMs: 0,
3466
4513
  spendLimitResult: { allowed: false, currentSpend: 0, limit: 0, windowMs: 0, resetAtMs: 0 }
3467
4514
  };
3468
4515
  }
3469
- const key = this.buildLimitKey(maxSpend.key, toolName, request);
4516
+ const key = this.buildSpendLimitKey(maxSpend.key, toolName, request, decision.matchedRule.index);
3470
4517
  const rawAmount = resolvePath(maxSpend.field, toolArguments ?? {});
3471
4518
  if (typeof rawAmount !== "number") {
3472
4519
  console.error(
@@ -3482,39 +4529,45 @@ var GovernedForwarder = class {
3482
4529
  reason: "invalid_amount"
3483
4530
  };
3484
4531
  const feedback = buildSpendLimitedFeedback(decision, invalidAmount, maxSpend.currency);
3485
- const result2 = makeErrorResult(
4532
+ const result = makeErrorResult(
3486
4533
  request,
3487
4534
  POLICY_DENIED,
3488
4535
  `Spend limit denied: invalid amount for field "${maxSpend.field}"`,
3489
4536
  { ...feedback }
3490
4537
  );
3491
4538
  return {
3492
- result: result2,
4539
+ proceed: false,
4540
+ result,
4541
+ approvalWaitMs: 0,
3493
4542
  spendLimitResult: invalidAmount
3494
4543
  };
3495
4544
  }
3496
- const spendLimitResult = limiter.check({
3497
- key,
3498
- amount: rawAmount,
3499
- limit: maxSpend.limit,
3500
- windowMs: maxSpend.windowMs
3501
- });
4545
+ const params = { key, amount: rawAmount, limit: maxSpend.limit, windowMs: maxSpend.windowMs };
4546
+ const spendLimitResult = limiter.peek(params);
3502
4547
  if (spendLimitResult.reason === "invalid_amount") {
3503
4548
  console.error(
3504
4549
  `[helio] Warning: spend limit field "${maxSpend.field}" resolved to invalid amount (${String(rawAmount)}) for tool "${toolName}" (rule: ${decision.matchedRule.name ?? "unnamed"}), denying request`
3505
4550
  );
3506
- } else {
3507
- limiter.setCurrency(key, maxSpend.currency);
3508
4551
  }
3509
- let result;
3510
- if (spendLimitResult.allowed) {
3511
- result = await this.inner.forward(request);
3512
- } else {
4552
+ if (!spendLimitResult.allowed) {
3513
4553
  const feedback = buildSpendLimitedFeedback(decision, spendLimitResult, maxSpend.currency);
3514
4554
  const message = spendLimitResult.reason === "invalid_amount" ? `Spend limit denied: invalid amount for field "${maxSpend.field}"` : decision.matchedRule.feedback?.message ?? `Spend limit exceeded for ${key} (${maxSpend.currency})`;
3515
- result = makeErrorResult(request, POLICY_DENIED, message, { ...feedback });
4555
+ return {
4556
+ proceed: false,
4557
+ result: makeErrorResult(request, POLICY_DENIED, message, { ...feedback }),
4558
+ approvalWaitMs: 0,
4559
+ spendLimitResult
4560
+ };
3516
4561
  }
3517
- return { result, spendLimitResult };
4562
+ return {
4563
+ proceed: true,
4564
+ approvalWaitMs: 0,
4565
+ spendLimitResult,
4566
+ commitRuleLimit: () => {
4567
+ limiter.record(params);
4568
+ limiter.setCurrency(key, maxSpend.currency);
4569
+ }
4570
+ };
3518
4571
  }
3519
4572
  /**
3520
4573
  * Handle dry-run mode: compute what would have happened without forwarding
@@ -3553,7 +4606,12 @@ var GovernedForwarder = class {
3553
4606
  wouldForward = false;
3554
4607
  limitsOk = false;
3555
4608
  } else {
3556
- const key = this.buildLimitKey(maxSpend.key, toolName, request);
4609
+ const key = this.buildSpendLimitKey(
4610
+ maxSpend.key,
4611
+ toolName,
4612
+ request,
4613
+ decision.matchedRule.index
4614
+ );
3557
4615
  const peekResult = this.spendLimiter.peek({
3558
4616
  key,
3559
4617
  amount: rawAmount,
@@ -3568,7 +4626,42 @@ var GovernedForwarder = class {
3568
4626
  break;
3569
4627
  }
3570
4628
  }
3571
- return this.makeDryRunResult(request, decision, wouldForward, evidenceSatisfied, limitsOk);
4629
+ let budgets;
4630
+ if (wouldForward && this.budgetEngine) {
4631
+ const { charges, failures } = this.budgetEngine.resolveCharges({
4632
+ toolName,
4633
+ toolArguments,
4634
+ sessionId: request.sessionId ?? null,
4635
+ senderId: null
4636
+ });
4637
+ if (failures.length > 0 || charges.length > 0) {
4638
+ const peek = charges.length > 0 ? this.budgetEngine.peekAll(charges) : { allowed: true, entries: [] };
4639
+ const ok = failures.length === 0 && peek.allowed;
4640
+ wouldForward &&= ok;
4641
+ limitsOk &&= ok;
4642
+ budgets = [
4643
+ ...peek.entries.map((entry) => budgetChainBlock(entry)),
4644
+ ...failures.map((failure) => ({
4645
+ name: failure.budget.name,
4646
+ bucket_key: failure.bucketKey,
4647
+ allowed: false,
4648
+ reason: failure.reason,
4649
+ spent: failure.spent,
4650
+ limit: failure.budget.limit,
4651
+ remaining: failure.remaining,
4652
+ currency: failure.budget.currency
4653
+ }))
4654
+ ];
4655
+ }
4656
+ }
4657
+ return this.makeDryRunResult(
4658
+ request,
4659
+ decision,
4660
+ wouldForward,
4661
+ evidenceSatisfied,
4662
+ limitsOk,
4663
+ budgets
4664
+ );
3572
4665
  }
3573
4666
  /** Construct a limit bucket key based on the configured key type. */
3574
4667
  buildLimitKey(keyType, toolName, request) {
@@ -3596,19 +4689,17 @@ var GovernedForwarder = class {
3596
4689
  return `tool:${toolName}`;
3597
4690
  }
3598
4691
  }
3599
- /** Determine if the request was actually forwarded to the upstream MCP server. */
3600
- wasForwardedUpstream(decision, approvalOutcome, rateLimitResult, spendLimitResult) {
3601
- return decision.action === "allow" || approvalOutcome?.status === "approved" || approvalOutcome?.status === "break_glass" || approvalOutcome?.status === "timeout" && this.approvalRouter?.defaultOnTimeout === "allow" || rateLimitResult?.allowed === true || spendLimitResult?.allowed === true;
4692
+ /**
4693
+ * Construct a spend bucket key via the shared {@link spendBucketKey}
4694
+ * composer see its doc for why spend buckets are rule-discriminated.
4695
+ * Rate buckets keep the undiscriminated keys.
4696
+ */
4697
+ buildSpendLimitKey(keyType, toolName, request, ruleIndex) {
4698
+ return spendBucketKey(this.buildLimitKey(keyType, toolName, request), ruleIndex);
3602
4699
  }
3603
- writeAuditRecord(request, timestamp, toolName, toolArguments, decision, result, totalDurationMs, approvalWaitMs, flaggedDestructive, evidenceResult, dependencyResult, evidenceBlocked, approvalOutcome, approvalContext, rateLimitResult, spendLimitResult, isDryRun, forwardingError, drift) {
4700
+ writeAuditRecord(request, auditRecordId, timestamp, toolName, toolArguments, decision, result, totalDurationMs, approvalWaitMs, flaggedDestructive, forwarded, evidenceResult, dependencyResult, evidenceBlocked, approvalOutcome, approvalContext, rateLimitResult, spendLimitResult, budgetsChain, budgetApproval, isDryRun, forwardingError, drift) {
3604
4701
  if (!this.auditWriter) return;
3605
- const wasForwarded = this.wasForwardedUpstream(
3606
- decision,
3607
- approvalOutcome,
3608
- rateLimitResult,
3609
- spendLimitResult
3610
- );
3611
- const actuallyForwarded = wasForwarded && !isDryRun;
4702
+ const actuallyForwarded = forwarded && !isDryRun;
3612
4703
  const hadForwardingError = forwardingError !== void 0;
3613
4704
  let upstreamError = null;
3614
4705
  const upstreamHttpStatus = actuallyForwarded && !hadForwardingError ? result.response.status : null;
@@ -3626,12 +4717,13 @@ var GovernedForwarder = class {
3626
4717
  upstreamError = "invalid spend amount";
3627
4718
  }
3628
4719
  let evidenceChain = buildEvidenceChain(evidenceResult, dependencyResult, evidenceBlocked);
3629
- if (approvalOutcome?.status === "break_glass" && "reason" in approvalOutcome) {
4720
+ const breakGlassOutcome = approvalOutcome?.status === "break_glass" ? approvalOutcome : budgetApproval?.outcome.status === "break_glass" ? budgetApproval.outcome : void 0;
4721
+ if (breakGlassOutcome && "reason" in breakGlassOutcome) {
3630
4722
  evidenceChain = {
3631
4723
  ...evidenceChain ?? {},
3632
4724
  break_glass: {
3633
- reason: approvalOutcome.reason,
3634
- invoked_by: approvalOutcome.resolvedBy
4725
+ reason: breakGlassOutcome.reason,
4726
+ invoked_by: breakGlassOutcome.resolvedBy
3635
4727
  }
3636
4728
  };
3637
4729
  }
@@ -3641,6 +4733,22 @@ var GovernedForwarder = class {
3641
4733
  approval: { ...approvalContext }
3642
4734
  };
3643
4735
  }
4736
+ if (budgetApproval && budgetApproval.outcome.ticketId) {
4737
+ const outcome = budgetApproval.outcome;
4738
+ evidenceChain = {
4739
+ ...evidenceChain ?? {},
4740
+ budget_approval: {
4741
+ ticket_id: outcome.ticketId,
4742
+ status: outcome.status,
4743
+ ..."resolvedBy" in outcome ? { resolved_by: outcome.resolvedBy } : {},
4744
+ ...budgetApproval.denialReason ? { denial_reason: budgetApproval.denialReason } : {},
4745
+ ...budgetApproval.escalatedAt ? {
4746
+ escalated_at: budgetApproval.escalatedAt,
4747
+ escalated_to: budgetApproval.escalatedTo ?? []
4748
+ } : {}
4749
+ }
4750
+ };
4751
+ }
3644
4752
  if (rateLimitResult) {
3645
4753
  evidenceChain = {
3646
4754
  ...evidenceChain ?? {},
@@ -3666,6 +4774,12 @@ var GovernedForwarder = class {
3666
4774
  }
3667
4775
  };
3668
4776
  }
4777
+ if (budgetsChain && budgetsChain.length > 0) {
4778
+ evidenceChain = {
4779
+ ...evidenceChain ?? {},
4780
+ budgets: budgetsChain
4781
+ };
4782
+ }
3669
4783
  if (drift) {
3670
4784
  evidenceChain = {
3671
4785
  ...evidenceChain ?? {},
@@ -3688,8 +4802,11 @@ var GovernedForwarder = class {
3688
4802
  matched_rule: decision.matchedRule?.name ?? null,
3689
4803
  matched_rule_index: decision.matchedRule?.index ?? null,
3690
4804
  evidence_chain: evidenceChain,
3691
- approval_status: approvalOutcome?.status ?? null,
3692
- approved_by: approvalOutcome && "resolvedBy" in approvalOutcome ? approvalOutcome.resolvedBy : null,
4805
+ // The approval columns describe the rule gate when one ran; on a
4806
+ // budget-only ticket (no rule approval) they carry the break-glass
4807
+ // outcome so a human-denied overage reads as denied here too.
4808
+ approval_status: (approvalOutcome ?? budgetApproval?.outcome)?.status ?? null,
4809
+ approved_by: approvedByOf(approvalOutcome ?? budgetApproval?.outcome),
3693
4810
  upstream_response: actuallyForwarded && !hadForwardingError ? result.response.body : null,
3694
4811
  upstream_error: upstreamError,
3695
4812
  upstream_http_status: upstreamHttpStatus,
@@ -3703,11 +4820,11 @@ var GovernedForwarder = class {
3703
4820
  origin: "mcp",
3704
4821
  metadata: null
3705
4822
  };
3706
- const isEnforcementDecision = !isDryRun && (!wasForwarded || approvalOutcome !== void 0);
4823
+ const isEnforcementDecision = !isDryRun && (!forwarded || approvalOutcome !== void 0 || budgetApproval !== void 0);
3707
4824
  if (isEnforcementDecision) {
3708
- this.auditWriter.pushImmediate(record);
4825
+ this.auditWriter.pushImmediate(record, auditRecordId);
3709
4826
  } else {
3710
- this.auditWriter.push(record);
4827
+ this.auditWriter.push(record, auditRecordId);
3711
4828
  }
3712
4829
  }
3713
4830
  makeDriftBlockResult(request, drift) {
@@ -3738,21 +4855,21 @@ var GovernedForwarder = class {
3738
4855
  const message = decision.matchedRule ? `Action "${action}" matched by ${decision.matchedRule.name ? `"${decision.matchedRule.name}"` : `rule[${String(decision.matchedRule.index)}]`} is not yet supported` : `Destructive tool "${toolName ?? "unknown"}" requires approval (flag_destructive policy)`;
3739
4856
  return makeErrorResult(request, POLICY_DENIED, message, {
3740
4857
  blocked: true,
3741
- rule: decision.matchedRule?.name ?? null,
3742
- ruleIndex: decision.matchedRule?.index ?? null,
4858
+ ...ruleInfo(decision.matchedRule),
3743
4859
  action,
3744
4860
  reason: decision.reason,
3745
4861
  unsupported: true
3746
4862
  });
3747
4863
  }
3748
- makeDryRunResult(request, decision, wouldForward, evidenceSatisfied, limitsOk) {
4864
+ makeDryRunResult(request, decision, wouldForward, evidenceSatisfied, limitsOk, budgets) {
3749
4865
  const payload = {
3750
4866
  dry_run: true,
3751
4867
  would_forward: wouldForward,
3752
4868
  policy_decision: decision.action,
3753
4869
  matched_rule: decision.matchedRule?.name ?? null,
3754
4870
  evidence_satisfied: evidenceSatisfied,
3755
- limits_ok: limitsOk
4871
+ limits_ok: limitsOk,
4872
+ ...budgets ? { budgets } : {}
3756
4873
  };
3757
4874
  const body = {
3758
4875
  jsonrpc: "2.0",
@@ -3820,6 +4937,9 @@ function makeErrorResult(request, code, message, data) {
3820
4937
  };
3821
4938
  return { response, durationMs: 0 };
3822
4939
  }
4940
+ function approvedByOf(outcome) {
4941
+ return outcome && "resolvedBy" in outcome ? outcome.resolvedBy : null;
4942
+ }
3823
4943
  function hasJsonRpcError(result) {
3824
4944
  const body = result.response.body;
3825
4945
  return body?.["error"] !== void 0;
@@ -3853,9 +4973,9 @@ function extractBlockReason(result) {
3853
4973
  if (!data || data["blocked"] !== true) return null;
3854
4974
  return typeof data["reason"] === "string" ? data["reason"] : null;
3855
4975
  }
3856
- function buildEvidenceChain(evidenceResult, dependencyResult, blocked) {
4976
+ function buildEvidenceChain(evidenceResult, dependencyResult, blocked2) {
3857
4977
  if (!evidenceResult && !dependencyResult) return null;
3858
- const chain = { blocked: blocked ?? false };
4978
+ const chain = { blocked: blocked2 ?? false };
3859
4979
  if (evidenceResult) {
3860
4980
  chain["evidence"] = {
3861
4981
  required: [...evidenceResult.found, ...evidenceResult.missing, ...evidenceResult.expired],
@@ -3929,7 +5049,7 @@ var RateLimiter = class {
3929
5049
  const current = bucket.timestamps.length;
3930
5050
  const resetAtMs = (bucket.timestamps[0] ?? now) + windowMs;
3931
5051
  if (this.onWarning && current / maxCalls >= this.warningThreshold) {
3932
- this.onWarning({ key, current, limit: maxCalls, window_ms: windowMs, reset_at_ms: resetAtMs });
5052
+ this.safeWarn({ key, current, limit: maxCalls, window_ms: windowMs, reset_at_ms: resetAtMs });
3933
5053
  }
3934
5054
  return {
3935
5055
  allowed: true,
@@ -3942,372 +5062,83 @@ var RateLimiter = class {
3942
5062
  /**
3943
5063
  * Unconditionally record a call against the rate limit.
3944
5064
  *
3945
- * Unlike check(), this always appends the timestamp — even when the bucket
3946
- * is already at/over the limit — because the call it represents has already
3947
- * executed. The sideband splits decision from execution: /evaluate peeks
3948
- * (non-destructive), and /audit calls record() once the external call ran,
3949
- * so refusing to record at the limit (as check() does) would let real calls
3950
- * escape accounting and under-count subsequent peeks. (issue #12, D3.)
3951
- *
3952
- * Warnings fire only while the post-append count stays within the limit —
3953
- * exact parity with check(), which never warns on its over-limit path — so a
3954
- * burst of over-limit audits cannot flood the dashboard's limit_warning feed.
3955
- */
3956
- record(params) {
3957
- const { key, maxCalls, windowMs } = params;
3958
- const now = this.now();
3959
- const windowStart = now - windowMs;
3960
- let bucket = this.buckets.get(key);
3961
- if (!bucket) {
3962
- bucket = { timestamps: [], maxCalls, windowMs };
3963
- this.buckets.set(key, bucket);
3964
- }
3965
- bucket.maxCalls = maxCalls;
3966
- bucket.windowMs = windowMs;
3967
- bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
3968
- bucket.timestamps.push(now);
3969
- const current = bucket.timestamps.length;
3970
- const resetAtMs = (bucket.timestamps[0] ?? now) + windowMs;
3971
- if (this.onWarning && current <= maxCalls && current / maxCalls >= this.warningThreshold) {
3972
- this.onWarning({ key, current, limit: maxCalls, window_ms: windowMs, reset_at_ms: resetAtMs });
3973
- }
3974
- return {
3975
- allowed: current <= maxCalls,
3976
- current,
3977
- limit: maxCalls,
3978
- windowMs,
3979
- resetAtMs
3980
- };
3981
- }
3982
- /**
3983
- * Check the rate limit without recording the call (non-destructive).
3984
- *
3985
- * Used by dry-run mode to determine what would happen without consuming
3986
- * a slot in the bucket.
3987
- */
3988
- peek(params) {
3989
- const { key, maxCalls, windowMs } = params;
3990
- const now = this.now();
3991
- const windowStart = now - windowMs;
3992
- const bucket = this.buckets.get(key);
3993
- if (!bucket) {
3994
- return {
3995
- allowed: true,
3996
- current: 1,
3997
- limit: maxCalls,
3998
- windowMs,
3999
- resetAtMs: now + windowMs
4000
- };
4001
- }
4002
- const activeCount = bucket.timestamps.filter((ts) => ts > windowStart).length;
4003
- if (activeCount >= maxCalls) {
4004
- const oldest2 = bucket.timestamps.find((ts) => ts > windowStart) ?? 0;
4005
- return {
4006
- allowed: false,
4007
- current: activeCount,
4008
- limit: maxCalls,
4009
- windowMs,
4010
- resetAtMs: oldest2 + windowMs
4011
- };
4012
- }
4013
- const oldest = bucket.timestamps.find((ts) => ts > windowStart) ?? now;
4014
- return {
4015
- allowed: true,
4016
- current: activeCount + 1,
4017
- limit: maxCalls,
4018
- windowMs,
4019
- resetAtMs: oldest + windowMs
4020
- };
4021
- }
4022
- // -------------------------------------------------------------------------
4023
- // Read operations (for dashboard API)
4024
- // -------------------------------------------------------------------------
4025
- /** Get the current state of a single key. Returns undefined if not tracked. */
4026
- getKeyState(key) {
4027
- const bucket = this.buckets.get(key);
4028
- if (!bucket) return void 0;
4029
- const windowStart = this.now() - bucket.windowMs;
4030
- bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
4031
- if (bucket.timestamps.length === 0) {
4032
- this.buckets.delete(key);
4033
- return void 0;
4034
- }
4035
- return {
4036
- key,
4037
- current: bucket.timestamps.length,
4038
- limit: bucket.maxCalls,
4039
- window_ms: bucket.windowMs,
4040
- reset_at_ms: (bucket.timestamps[0] ?? 0) + bucket.windowMs
4041
- };
4042
- }
4043
- /** List all tracked keys with their current state. */
4044
- listKeyStates() {
4045
- const states = [];
4046
- for (const key of [...this.buckets.keys()]) {
4047
- const state = this.getKeyState(key);
4048
- if (state) states.push(state);
4049
- }
4050
- return states;
4051
- }
4052
- // -------------------------------------------------------------------------
4053
- // Maintenance
4054
- // -------------------------------------------------------------------------
4055
- /** Sweep all buckets: remove expired timestamps, delete empty buckets. */
4056
- cleanup() {
4057
- const now = this.now();
4058
- for (const [key, bucket] of this.buckets) {
4059
- const windowStart = now - bucket.windowMs;
4060
- bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
4061
- if (bucket.timestamps.length === 0) {
4062
- this.buckets.delete(key);
4063
- }
4064
- }
4065
- }
4066
- /** Clear all rate limit state. Called on policy hot-reload. */
4067
- reset() {
4068
- this.buckets.clear();
4069
- }
4070
- /**
4071
- * Reconcile bucket state against a new policy's limit configuration.
4072
- *
4073
- * Walks every existing bucket and checks whether its last-seen
4074
- * `{ maxCalls, windowMs }` tuple still appears anywhere in `validConfigs`.
4075
- * Buckets whose config is still present are left untouched — counters and
4076
- * elapsed-window progress are preserved across hot-reloads. Buckets whose
4077
- * config is gone (rule changed or removed) are evicted so the next check
4078
- * lazy-creates a fresh bucket under the new config.
4079
- *
4080
- * This is the compare-and-evict semantic that replaces the old `reset()`
4081
- * call on every hot-reload, which wiped all state even when the matching
4082
- * rule was unchanged.
4083
- */
4084
- reconcile(validConfigs) {
4085
- const valid = /* @__PURE__ */ new Set();
4086
- for (const config of validConfigs) {
4087
- valid.add(`${String(config.maxCalls)}|${String(config.windowMs)}`);
4088
- }
4089
- for (const [key, bucket] of this.buckets) {
4090
- const tuple = `${String(bucket.maxCalls)}|${String(bucket.windowMs)}`;
4091
- if (!valid.has(tuple)) {
4092
- this.buckets.delete(key);
4093
- }
4094
- }
4095
- }
4096
- /** Stop the cleanup timer and mark as closed. */
4097
- close() {
4098
- if (this.closed) return;
4099
- this.closed = true;
4100
- if (this.timer) {
4101
- clearInterval(this.timer);
4102
- this.timer = null;
4103
- }
4104
- this.buckets.clear();
4105
- }
4106
- };
4107
-
4108
- // src/policy/spend-limiter.ts
4109
- var SpendLimiter = class {
4110
- buckets = /* @__PURE__ */ new Map();
4111
- now;
4112
- onWarning;
4113
- warningThreshold;
4114
- timer = null;
4115
- closed = false;
4116
- constructor(options = {}) {
4117
- this.now = options.now ?? Date.now;
4118
- this.onWarning = options.onWarning;
4119
- this.warningThreshold = options.warningThreshold ?? 0.8;
4120
- const intervalMs = options.cleanupIntervalMs ?? 6e4;
4121
- if (intervalMs > 0) {
4122
- this.timer = setInterval(() => {
4123
- this.cleanup();
4124
- }, intervalMs);
4125
- this.timer.unref();
4126
- }
4127
- }
4128
- // -------------------------------------------------------------------------
4129
- // Core operations
4130
- // -------------------------------------------------------------------------
4131
- /**
4132
- * Check and optionally record a spend against the limit.
4133
- *
4134
- * Evicts expired entries, sums remaining amounts, then checks:
4135
- * - Under limit (currentSpend + amount <= limit): records and returns `allowed: true`
4136
- * - Would exceed: does NOT record (rejected spends don't consume budget)
4137
- */
4138
- check(params) {
4139
- const { key, amount, limit, windowMs } = params;
4140
- const now = this.now();
4141
- const windowStart = now - windowMs;
4142
- if (!Number.isFinite(amount) || amount < 0) {
4143
- const existing = this.buckets.get(key);
4144
- const activeEntries = existing ? existing.entries.filter((e) => e.timestamp > windowStart) : [];
4145
- const currentSpend2 = activeEntries.reduce((sum, e) => sum + e.amount, 0);
4146
- const oldest = activeEntries[0];
4147
- return {
4148
- allowed: false,
4149
- currentSpend: currentSpend2,
4150
- limit,
4151
- windowMs,
4152
- resetAtMs: oldest ? oldest.timestamp + windowMs : 0,
4153
- reason: "invalid_amount"
4154
- };
4155
- }
4156
- let bucket = this.buckets.get(key);
4157
- if (!bucket) {
4158
- bucket = { entries: [], limit, currency: "", windowMs };
4159
- this.buckets.set(key, bucket);
4160
- }
4161
- bucket.limit = limit;
4162
- bucket.windowMs = windowMs;
4163
- bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
4164
- const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
4165
- if (currentSpend + amount > limit) {
4166
- const oldest = bucket.entries[0];
4167
- return {
4168
- allowed: false,
4169
- currentSpend,
4170
- limit,
4171
- windowMs,
4172
- resetAtMs: oldest ? oldest.timestamp + windowMs : 0
4173
- };
4174
- }
4175
- bucket.entries.push({ timestamp: now, amount });
4176
- const newSpend = currentSpend + amount;
4177
- const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
4178
- if (this.onWarning && newSpend / limit >= this.warningThreshold) {
4179
- this.onWarning({
4180
- key,
4181
- current_spend: newSpend,
4182
- limit,
4183
- currency: bucket.currency,
4184
- window_ms: windowMs,
4185
- reset_at_ms: resetAtMs
4186
- });
4187
- }
4188
- return {
4189
- allowed: true,
4190
- currentSpend: newSpend,
4191
- limit,
4192
- windowMs,
4193
- resetAtMs
4194
- };
4195
- }
4196
- /**
4197
- * Unconditionally record a spend against the limit.
4198
- *
4199
- * Unlike check(), this always appends the amount — even when it pushes the
4200
- * window past the limit — because the spend it represents has already been
4201
- * incurred. The sideband peeks at /evaluate and commits here at /audit once
4202
- * the external call ran (issue #12, D3).
5065
+ * Unlike check(), this always appends the timestamp — even when the bucket
5066
+ * is already at/over the limit — because the call it represents has already
5067
+ * executed. The sideband splits decision from execution: /evaluate peeks
5068
+ * (non-destructive), and /audit calls record() once the external call ran,
5069
+ * so refusing to record at the limit (as check() does) would let real calls
5070
+ * escape accounting and under-count subsequent peeks. (issue #12, D3.)
4203
5071
  *
4204
- * Throws on a negative or non-finite amount: such amounts are rejected at
4205
- * /evaluate, so one reaching record() is a logic bug we surface loudly rather
4206
- * than silently corrupt the sliding-window sum. Warnings fire only while the
4207
- * post-append spend stays within the limit (parity with check()).
5072
+ * Warnings fire only while the post-append count stays within the limit
5073
+ * exact parity with check(), which never warns on its over-limit path — so a
5074
+ * burst of over-limit audits cannot flood the dashboard's limit_warning feed.
4208
5075
  */
4209
5076
  record(params) {
4210
- const { key, amount, limit, windowMs } = params;
4211
- if (!Number.isFinite(amount) || amount < 0) {
4212
- throw new RangeError(
4213
- `SpendLimiter.record() received an invalid amount (${String(amount)}); invalid amounts must be rejected at /evaluate, never committed`
4214
- );
4215
- }
5077
+ const { key, maxCalls, windowMs } = params;
4216
5078
  const now = this.now();
4217
5079
  const windowStart = now - windowMs;
4218
5080
  let bucket = this.buckets.get(key);
4219
5081
  if (!bucket) {
4220
- bucket = { entries: [], limit, currency: "", windowMs };
5082
+ bucket = { timestamps: [], maxCalls, windowMs };
4221
5083
  this.buckets.set(key, bucket);
4222
5084
  }
4223
- bucket.limit = limit;
5085
+ bucket.maxCalls = maxCalls;
4224
5086
  bucket.windowMs = windowMs;
4225
- bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
4226
- bucket.entries.push({ timestamp: now, amount });
4227
- const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
4228
- const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
4229
- if (this.onWarning && currentSpend <= limit && currentSpend / limit >= this.warningThreshold) {
4230
- this.onWarning({
4231
- key,
4232
- current_spend: currentSpend,
4233
- limit,
4234
- currency: bucket.currency,
4235
- window_ms: windowMs,
4236
- reset_at_ms: resetAtMs
4237
- });
5087
+ bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
5088
+ bucket.timestamps.push(now);
5089
+ const current = bucket.timestamps.length;
5090
+ const resetAtMs = (bucket.timestamps[0] ?? now) + windowMs;
5091
+ if (this.onWarning && current <= maxCalls && current / maxCalls >= this.warningThreshold) {
5092
+ this.safeWarn({ key, current, limit: maxCalls, window_ms: windowMs, reset_at_ms: resetAtMs });
4238
5093
  }
4239
5094
  return {
4240
- allowed: currentSpend <= limit,
4241
- currentSpend,
4242
- limit,
5095
+ allowed: current <= maxCalls,
5096
+ current,
5097
+ limit: maxCalls,
4243
5098
  windowMs,
4244
5099
  resetAtMs
4245
5100
  };
4246
5101
  }
4247
5102
  /**
4248
- * Check the spend limit without recording the spend (non-destructive).
5103
+ * Check the rate limit without recording the call (non-destructive).
4249
5104
  *
4250
5105
  * Used by dry-run mode to determine what would happen without consuming
4251
- * budget in the bucket.
5106
+ * a slot in the bucket.
4252
5107
  */
4253
5108
  peek(params) {
4254
- const { key, amount, limit, windowMs } = params;
5109
+ const { key, maxCalls, windowMs } = params;
4255
5110
  const now = this.now();
4256
5111
  const windowStart = now - windowMs;
4257
5112
  const bucket = this.buckets.get(key);
4258
- if (!Number.isFinite(amount) || amount < 0) {
4259
- const activeEntries2 = bucket ? bucket.entries.filter((e) => e.timestamp > windowStart) : [];
4260
- const currentSpend2 = activeEntries2.reduce((sum, e) => sum + e.amount, 0);
4261
- const oldest2 = activeEntries2[0];
4262
- return {
4263
- allowed: false,
4264
- currentSpend: currentSpend2,
4265
- limit,
4266
- windowMs,
4267
- resetAtMs: oldest2 ? oldest2.timestamp + windowMs : 0,
4268
- reason: "invalid_amount"
4269
- };
4270
- }
4271
5113
  if (!bucket) {
4272
- const wouldExceed = amount > limit;
4273
5114
  return {
4274
- allowed: !wouldExceed,
4275
- currentSpend: wouldExceed ? 0 : amount,
4276
- limit,
5115
+ allowed: true,
5116
+ current: 1,
5117
+ limit: maxCalls,
4277
5118
  windowMs,
4278
5119
  resetAtMs: now + windowMs
4279
5120
  };
4280
5121
  }
4281
- const activeEntries = bucket.entries.filter((e) => e.timestamp > windowStart);
4282
- const currentSpend = activeEntries.reduce((sum, e) => sum + e.amount, 0);
4283
- if (currentSpend + amount > limit) {
4284
- const oldest2 = activeEntries[0];
5122
+ const activeCount = bucket.timestamps.filter((ts) => ts > windowStart).length;
5123
+ if (activeCount >= maxCalls) {
5124
+ const oldest2 = bucket.timestamps.find((ts) => ts > windowStart) ?? 0;
4285
5125
  return {
4286
5126
  allowed: false,
4287
- currentSpend,
4288
- limit,
5127
+ current: activeCount,
5128
+ limit: maxCalls,
4289
5129
  windowMs,
4290
- resetAtMs: oldest2 ? oldest2.timestamp + windowMs : 0
5130
+ resetAtMs: oldest2 + windowMs
4291
5131
  };
4292
5132
  }
4293
- const newSpend = currentSpend + amount;
4294
- const oldest = activeEntries[0];
5133
+ const oldest = bucket.timestamps.find((ts) => ts > windowStart) ?? now;
4295
5134
  return {
4296
5135
  allowed: true,
4297
- currentSpend: newSpend,
4298
- limit,
5136
+ current: activeCount + 1,
5137
+ limit: maxCalls,
4299
5138
  windowMs,
4300
- resetAtMs: oldest ? oldest.timestamp + windowMs : now + windowMs
5139
+ resetAtMs: oldest + windowMs
4301
5140
  };
4302
5141
  }
4303
- /**
4304
- * Set the display currency for a key. Called by the governed forwarder
4305
- * after check() so dashboard reads include the currency label.
4306
- */
4307
- setCurrency(key, currency) {
4308
- const bucket = this.buckets.get(key);
4309
- if (bucket) bucket.currency = currency;
4310
- }
4311
5142
  // -------------------------------------------------------------------------
4312
5143
  // Read operations (for dashboard API)
4313
5144
  // -------------------------------------------------------------------------
@@ -4316,19 +5147,17 @@ var SpendLimiter = class {
4316
5147
  const bucket = this.buckets.get(key);
4317
5148
  if (!bucket) return void 0;
4318
5149
  const windowStart = this.now() - bucket.windowMs;
4319
- bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
4320
- if (bucket.entries.length === 0) {
5150
+ bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
5151
+ if (bucket.timestamps.length === 0) {
4321
5152
  this.buckets.delete(key);
4322
5153
  return void 0;
4323
5154
  }
4324
- const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
4325
5155
  return {
4326
5156
  key,
4327
- current_spend: currentSpend,
4328
- limit: bucket.limit,
4329
- currency: bucket.currency,
5157
+ current: bucket.timestamps.length,
5158
+ limit: bucket.maxCalls,
4330
5159
  window_ms: bucket.windowMs,
4331
- reset_at_ms: (bucket.entries[0]?.timestamp ?? 0) + bucket.windowMs
5160
+ reset_at_ms: (bucket.timestamps[0] ?? 0) + bucket.windowMs
4332
5161
  };
4333
5162
  }
4334
5163
  /** List all tracked keys with their current state. */
@@ -4343,50 +5172,62 @@ var SpendLimiter = class {
4343
5172
  // -------------------------------------------------------------------------
4344
5173
  // Maintenance
4345
5174
  // -------------------------------------------------------------------------
4346
- /** Sweep all buckets: remove expired entries, delete empty buckets. */
5175
+ /** Sweep all buckets: remove expired timestamps, delete empty buckets. */
4347
5176
  cleanup() {
4348
5177
  const now = this.now();
4349
5178
  for (const [key, bucket] of this.buckets) {
4350
5179
  const windowStart = now - bucket.windowMs;
4351
- bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
4352
- if (bucket.entries.length === 0) {
5180
+ bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
5181
+ if (bucket.timestamps.length === 0) {
4353
5182
  this.buckets.delete(key);
4354
5183
  }
4355
5184
  }
4356
5185
  }
4357
- /** Clear all spend limit state. Called on policy hot-reload. */
5186
+ /** Clear all rate limit state. Called on policy hot-reload. */
4358
5187
  reset() {
4359
5188
  this.buckets.clear();
4360
5189
  }
4361
5190
  /**
4362
- * Reconcile bucket state against a new policy's spend configuration.
5191
+ * Reconcile bucket state against a new policy's limit configuration.
4363
5192
  *
4364
5193
  * Walks every existing bucket and checks whether its last-seen
4365
- * `{ limit, currency, windowMs }` tuple still appears in `validConfigs`.
4366
- * Buckets whose config is unchanged are left untouched — cumulative spend
4367
- * and elapsed-window progress are preserved across hot-reloads. Buckets
4368
- * whose config is gone (rule changed or removed) are evicted so the next
4369
- * check lazy-creates a fresh bucket under the new config.
5194
+ * `{ maxCalls, windowMs }` tuple still appears anywhere in `validConfigs`.
5195
+ * Buckets whose config is still present are left untouched — counters and
5196
+ * elapsed-window progress are preserved across hot-reloads. Buckets whose
5197
+ * config is gone (rule changed or removed) are evicted so the next check
5198
+ * lazy-creates a fresh bucket under the new config.
4370
5199
  *
4371
- * Currency is part of the tuple because a USD→EUR switch is a meaningful
4372
- * policy change the same numeric limit buys a different amount of real
4373
- * spend, so the bucket must reset. This replaces the old `reset()` call
4374
- * on every hot-reload, which wiped all state even when the matching rule
4375
- * was unchanged.
5200
+ * This is the compare-and-evict semantic that replaces the old `reset()`
5201
+ * call on every hot-reload, which wiped all state even when the matching
5202
+ * rule was unchanged.
4376
5203
  */
4377
5204
  reconcile(validConfigs) {
4378
5205
  const valid = /* @__PURE__ */ new Set();
4379
5206
  for (const config of validConfigs) {
4380
- valid.add(`${String(config.limit)}|${config.currency}|${String(config.windowMs)}`);
5207
+ valid.add(`${String(config.maxCalls)}|${String(config.windowMs)}`);
4381
5208
  }
4382
5209
  for (const [key, bucket] of this.buckets) {
4383
- const tuple = `${String(bucket.limit)}|${bucket.currency}|${String(bucket.windowMs)}`;
5210
+ const tuple = `${String(bucket.maxCalls)}|${String(bucket.windowMs)}`;
4384
5211
  if (!valid.has(tuple)) {
4385
5212
  this.buckets.delete(key);
4386
5213
  }
4387
5214
  }
4388
5215
  }
4389
5216
  /** Stop the cleanup timer and mark as closed. */
5217
+ /**
5218
+ * Invoke the warning callback without letting a subscriber throw into the
5219
+ * limiter's caller: a warning fires after state has already mutated, and a
5220
+ * governed call must not be blocked (or double-charged on retry) by an
5221
+ * observability bug.
5222
+ */
5223
+ safeWarn(state) {
5224
+ if (!this.onWarning) return;
5225
+ try {
5226
+ this.onWarning(state);
5227
+ } catch (err) {
5228
+ console.error("[helio] limit warning subscriber threw:", err);
5229
+ }
5230
+ }
4390
5231
  close() {
4391
5232
  if (this.closed) return;
4392
5233
  this.closed = true;
@@ -4400,7 +5241,7 @@ var SpendLimiter = class {
4400
5241
 
4401
5242
  // src/audit/store.ts
4402
5243
  import Database from "better-sqlite3";
4403
- import { randomUUID as randomUUID2 } from "crypto";
5244
+ import { randomUUID as randomUUID3 } from "crypto";
4404
5245
  import { chmodSync } from "fs";
4405
5246
 
4406
5247
  // src/upstream/response-summary.ts
@@ -4464,6 +5305,7 @@ function clampInt(value, fallback, min, max) {
4464
5305
 
4465
5306
  // src/audit/store.ts
4466
5307
  var DRIFT_EVENT_DECISIONS_SQL = "('tool_drift', 'tool_drift_reverted')";
5308
+ var NON_TOOL_DECISIONS_SQL = "('tool_drift', 'tool_drift_reverted', 'rejected')";
4467
5309
  var EXPORT_MAX_RECORDS = 1e4;
4468
5310
  var LIST_MAX_PAGE_SIZE = 1e3;
4469
5311
  var CREATE_TABLE_DDL = `
@@ -4654,6 +5496,7 @@ var AuditStore = class {
4654
5496
  insertStmt;
4655
5497
  retentionMs;
4656
5498
  includeResponses;
5499
+ retentionSweepHooks = [];
4657
5500
  cleanupTimer = null;
4658
5501
  constructor(options) {
4659
5502
  this.db = new Database(options.path);
@@ -4671,12 +5514,51 @@ var AuditStore = class {
4671
5514
  const intervalMs = options.cleanupIntervalMs ?? 864e5;
4672
5515
  if (intervalMs > 0) {
4673
5516
  this.cleanupTimer = setInterval(() => {
4674
- this.purgeExpired();
5517
+ this.runRetentionSweep();
4675
5518
  restrictAuditFilePerms(options.path);
4676
5519
  }, intervalMs);
4677
5520
  this.cleanupTimer.unref();
4678
5521
  }
4679
5522
  }
5523
+ /**
5524
+ * Register a hook to run on every retention sweep, receiving the sweep's
5525
+ * cutoff. This is how co-resident tables (the budget ledger) join the
5526
+ * store's single sweep schedule instead of running their own timers.
5527
+ * Hooks registered after construction miss the constructor's initial
5528
+ * purge — call {@link runRetentionSweep} once after registering to cover
5529
+ * rows that aged out while the process was down.
5530
+ */
5531
+ onRetentionSweep(fn) {
5532
+ this.retentionSweepHooks.push(fn);
5533
+ }
5534
+ /**
5535
+ * One full retention sweep: purge expired audit records, then fire every
5536
+ * registered hook with the sweep's cutoff. Hook failures degrade to a
5537
+ * logged error — a broken co-resident purge must not stop the audit
5538
+ * table's own retention.
5539
+ */
5540
+ runRetentionSweep() {
5541
+ const ms = Date.now() - this.retentionMs;
5542
+ const cutoff = { iso: new Date(ms).toISOString(), ms };
5543
+ this.purgeBefore(cutoff.iso);
5544
+ for (const hook of this.retentionSweepHooks) {
5545
+ try {
5546
+ hook(cutoff);
5547
+ } catch (err) {
5548
+ console.error("[helio] retention sweep hook failed:", err);
5549
+ }
5550
+ }
5551
+ }
5552
+ /**
5553
+ * Package-internal: the store's open database handle, for components that
5554
+ * co-locate their tables in the audit db (the budget ledger). Sharing the
5555
+ * handle keeps one connection, one WAL domain, and one file-permission
5556
+ * hardening pass. Not part of the public embedding API — do not re-export
5557
+ * anything built on this from the package root.
5558
+ */
5559
+ get database() {
5560
+ return this.db;
5561
+ }
4680
5562
  /**
4681
5563
  * Validate that the on-disk audit schema contains all required canonical columns.
4682
5564
  *
@@ -4701,7 +5583,7 @@ var AuditStore = class {
4701
5583
  * @param id - Optional pre-generated ID (used by AuditWriter to share ID with SSE event bus).
4702
5584
  */
4703
5585
  insert(record, createdAt, id) {
4704
- const resolvedId = id ?? randomUUID2();
5586
+ const resolvedId = id ?? randomUUID3();
4705
5587
  const now = createdAt ?? (/* @__PURE__ */ new Date()).toISOString();
4706
5588
  this.insertStmt.run({
4707
5589
  id: resolvedId,
@@ -4831,7 +5713,7 @@ var AuditStore = class {
4831
5713
  GROUP BY block_reason
4832
5714
  ORDER BY count DESC`
4833
5715
  ).all(...params);
4834
- const toolsClause = clause ? `${clause} AND policy_decision NOT IN ${DRIFT_EVENT_DECISIONS_SQL}` : `WHERE policy_decision NOT IN ${DRIFT_EVENT_DECISIONS_SQL}`;
5716
+ const toolsClause = clause ? `${clause} AND policy_decision NOT IN ${NON_TOOL_DECISIONS_SQL}` : `WHERE policy_decision NOT IN ${NON_TOOL_DECISIONS_SQL}`;
4835
5717
  const top_tools = this.db.prepare(
4836
5718
  `SELECT tool_name, COUNT(*) as count
4837
5719
  FROM audit_records ${toolsClause}
@@ -4874,8 +5756,10 @@ var AuditStore = class {
4874
5756
  }
4875
5757
  /** Delete records older than the retention period. Returns the count of deleted records. */
4876
5758
  purgeExpired() {
4877
- const cutoff = new Date(Date.now() - this.retentionMs).toISOString();
4878
- const result = this.db.prepare("DELETE FROM audit_records WHERE created_at < ?").run(cutoff);
5759
+ return this.purgeBefore(new Date(Date.now() - this.retentionMs).toISOString());
5760
+ }
5761
+ purgeBefore(cutoffIso) {
5762
+ const result = this.db.prepare("DELETE FROM audit_records WHERE created_at < ?").run(cutoffIso);
4879
5763
  return result.changes;
4880
5764
  }
4881
5765
  /** Close the database and stop the cleanup timer. */
@@ -4889,7 +5773,7 @@ var AuditStore = class {
4889
5773
  };
4890
5774
 
4891
5775
  // src/audit/writer.ts
4892
- import { randomUUID as randomUUID3 } from "crypto";
5776
+ import { randomUUID as randomUUID4 } from "crypto";
4893
5777
  var AuditWriter = class {
4894
5778
  store;
4895
5779
  bufferSize;
@@ -4924,7 +5808,7 @@ var AuditWriter = class {
4924
5808
  * is scheduled. This keeps request-path latency bounded even under bursty
4925
5809
  * write load.
4926
5810
  */
4927
- push(record, id = randomUUID3()) {
5811
+ push(record, id = randomUUID4()) {
4928
5812
  if (this.closed) return;
4929
5813
  this.buffer.push({ id, record });
4930
5814
  this.onPush?.(record, id);
@@ -4940,7 +5824,7 @@ var AuditWriter = class {
4940
5824
  * A fatal-process crash still invokes the crash-drain hook, which calls
4941
5825
  * `flush()` synchronously before exit.
4942
5826
  */
4943
- pushImmediate(record, id = randomUUID3()) {
5827
+ pushImmediate(record, id = randomUUID4()) {
4944
5828
  if (this.closed) return;
4945
5829
  this.buffer.push({ id, record });
4946
5830
  this.onPush?.(record, id);
@@ -5309,7 +6193,9 @@ import { createHash as createHash2 } from "crypto";
5309
6193
  var originSchema = z4.string().regex(/^[a-z0-9_-]{1,64}$/, "origin must match ^[a-z0-9_-]{1,64}$").default("sideband");
5310
6194
  var metadataSchema = z4.record(z4.string(), z4.unknown()).nullish();
5311
6195
  var toolDefinitionSchema = z4.object({
5312
- name: z4.string().min(1),
6196
+ // Stored verbatim in pending entries and audit rows; capped so a
6197
+ // caller-minted name cannot inflate the pending-entry footprint.
6198
+ name: z4.string().min(1).max(256),
5313
6199
  description: z4.string().optional(),
5314
6200
  input_schema: z4.unknown().optional(),
5315
6201
  output_schema: z4.unknown().optional(),
@@ -5319,16 +6205,18 @@ var toolDefinitionSchema = z4.object({
5319
6205
  var evaluateBody = z4.object({
5320
6206
  origin: originSchema,
5321
6207
  adapter_version: z4.string().max(64).optional(),
5322
- agent_id: z4.string().nullish(),
5323
- session_id: z4.string().nullish(),
6208
+ // Stored in pending entries and limit bucket keys; capped (like origin and
6209
+ // adapter_version) so caller-minted ids cannot inflate memory unaccounted.
6210
+ agent_id: z4.string().max(128).nullish(),
6211
+ session_id: z4.string().max(256).nullish(),
5324
6212
  tool: toolDefinitionSchema,
5325
6213
  arguments: z4.record(z4.string(), z4.unknown()).optional(),
5326
6214
  metadata: metadataSchema
5327
6215
  });
5328
6216
  var installScanBody = z4.object({
5329
6217
  origin: originSchema,
5330
- agent_id: z4.string().nullish(),
5331
- session_id: z4.string().nullish(),
6218
+ agent_id: z4.string().max(128).nullish(),
6219
+ session_id: z4.string().max(256).nullish(),
5332
6220
  package: z4.object({
5333
6221
  name: z4.string().min(1),
5334
6222
  version: z4.string().optional(),
@@ -5588,7 +6476,7 @@ function createSidebandApp(store, options = {}) {
5588
6476
  }
5589
6477
 
5590
6478
  // src/sideband/governance-service.ts
5591
- import { randomUUID as randomUUID4 } from "crypto";
6479
+ import { randomUUID as randomUUID5 } from "crypto";
5592
6480
 
5593
6481
  // src/sideband/errors.ts
5594
6482
  var GovernanceConfigError = class extends Error {
@@ -5616,6 +6504,7 @@ var GovernanceService = class {
5616
6504
  approvalRouter;
5617
6505
  rateLimiter;
5618
6506
  spendLimiter;
6507
+ budgetEngine;
5619
6508
  auditWriter;
5620
6509
  approvalTimeoutMs;
5621
6510
  ttlMs;
@@ -5648,6 +6537,7 @@ var GovernanceService = class {
5648
6537
  this.approvalRouter = options.approvalRouter;
5649
6538
  this.rateLimiter = options.rateLimiter;
5650
6539
  this.spendLimiter = options.spendLimiter;
6540
+ this.budgetEngine = options.budgetEngine;
5651
6541
  this.auditWriter = options.auditWriter;
5652
6542
  this.approvalTimeoutMs = options.approvalTimeoutMs ?? 3e5;
5653
6543
  this.ttlMs = options.ttlMs ?? 6e5;
@@ -5681,7 +6571,12 @@ var GovernanceService = class {
5681
6571
  if (inputBytes > MAX_TOOL_INPUT_BYTES) {
5682
6572
  return { status: 413, body: { error: "tool_input_too_large" } };
5683
6573
  }
5684
- const entryBytes = inputBytes + byteLength(req.metadata ?? {});
6574
+ const entryBytes = inputBytes + byteLength(req.metadata ?? {}) + byteLength({
6575
+ tool: req.tool.name,
6576
+ agent_id: req.agent_id,
6577
+ session_id: req.session_id,
6578
+ origin: req.origin
6579
+ });
5685
6580
  if (!this.caches.has(req.origin) && this.caches.size >= MAX_ORIGINS) {
5686
6581
  return { status: 400, body: { error: "origin_limit_exceeded" } };
5687
6582
  }
@@ -5712,11 +6607,21 @@ var GovernanceService = class {
5712
6607
  agentId: req.agent_id ?? void 0
5713
6608
  });
5714
6609
  const { decision } = pipeline;
5715
- const evaluationId = randomUUID4();
6610
+ const evaluationId = randomUUID5();
5716
6611
  const timestampIso = new Date(this.now()).toISOString();
5717
6612
  let wire;
5718
- let limitPlan;
6613
+ const plans = [];
5719
6614
  let limitsBlock;
6615
+ const reservedThisCall = [];
6616
+ const reserve = (key) => {
6617
+ const preexisting = this.senderKeys.has(key);
6618
+ if (!this.reserveSenderKey(key)) return false;
6619
+ if (!preexisting && this.senderKeys.has(key)) reservedThisCall.push(key);
6620
+ return true;
6621
+ };
6622
+ const releaseReservations = () => {
6623
+ for (const key of reservedThisCall) this.senderKeys.delete(key);
6624
+ };
5720
6625
  const senderId = senderIdOf(req.metadata);
5721
6626
  if (pipeline.isDryRun) {
5722
6627
  wire = "dry_run";
@@ -5726,23 +6631,97 @@ var GovernanceService = class {
5726
6631
  wire = "require_approval";
5727
6632
  } else if (decision.action === "rate_limit") {
5728
6633
  const planned = this.planRate(decision, toolName, req.session_id, senderId);
5729
- if (planned?.plan && !this.reserveSenderKey(planned.plan.key)) {
6634
+ if (planned?.plan && !reserve(planned.plan.key)) {
5730
6635
  return { status: 503, body: { error: "limit_capacity_exhausted" } };
5731
6636
  }
5732
- limitPlan = planned?.plan;
6637
+ if (planned?.plan) plans.push(planned.plan);
5733
6638
  limitsBlock = planned?.block ? { rate: planned.block } : void 0;
5734
6639
  wire = planned?.allowed ? "allow" : "rate_limited";
5735
6640
  } else if (decision.action === "spend_limit") {
5736
6641
  const planned = this.planSpend(decision, toolName, req.session_id, req.arguments, senderId);
5737
- if (planned?.plan && !this.reserveSenderKey(planned.plan.key)) {
6642
+ if (planned?.plan && !reserve(planned.plan.key)) {
5738
6643
  return { status: 503, body: { error: "limit_capacity_exhausted" } };
5739
6644
  }
5740
- limitPlan = planned?.plan;
6645
+ if (planned?.plan) plans.push(planned.plan);
5741
6646
  limitsBlock = planned?.block ? { spend: planned.block } : void 0;
5742
6647
  wire = planned?.allowed ? "allow" : "spend_limited";
5743
6648
  } else {
5744
6649
  wire = "allow";
5745
6650
  }
6651
+ let budgetsBlock;
6652
+ let budgetDryRunOk = true;
6653
+ let budgetDenial;
6654
+ let budgetBreachContexts;
6655
+ let budgetBreachEntries;
6656
+ let budgetTicketTimeoutMs;
6657
+ let budgetTriggeredApproval = false;
6658
+ if (this.budgetEngine && (wire === "allow" || wire === "require_approval" || wire === "dry_run")) {
6659
+ const { charges, failures } = this.budgetEngine.resolveCharges({
6660
+ toolName,
6661
+ toolArguments: req.arguments,
6662
+ sessionId: req.session_id,
6663
+ senderId
6664
+ });
6665
+ if (charges.length > 0 || failures.length > 0) {
6666
+ const peek = charges.length > 0 ? this.budgetEngine.peekAll(charges) : { allowed: true, entries: [] };
6667
+ budgetsBlock = [
6668
+ ...peek.entries.map((entry2) => budgetWireBlock(entry2)),
6669
+ ...failures.map((failure) => budgetFailureBlock(failure))
6670
+ ];
6671
+ const breaches = peek.entries.filter((entry2) => !entry2.allowed);
6672
+ const canBreakGlass = this.approvalRouter !== void 0 && breaches.every((entry2) => entry2.budget.onExceed === "require_approval");
6673
+ if (failures.length > 0 || breaches.length > 0 && !canBreakGlass) {
6674
+ budgetDryRunOk = false;
6675
+ if (wire !== "dry_run") {
6676
+ releaseReservations();
6677
+ plans.length = 0;
6678
+ wire = "budget_exceeded";
6679
+ budgetDenial = {
6680
+ breached: breaches.map((entry2) => entry2.budget.name),
6681
+ invalid: failures.map((failure) => failure.budget.name)
6682
+ };
6683
+ if (breaches.length > 0) this.budgetEngine.reportBreaches(breaches);
6684
+ }
6685
+ } else {
6686
+ if (breaches.length > 0) budgetDryRunOk = false;
6687
+ if (wire !== "dry_run") {
6688
+ for (const [index, charge] of charges.entries()) {
6689
+ if (!reserve(charge.bucketKey)) {
6690
+ releaseReservations();
6691
+ return { status: 503, body: { error: "limit_capacity_exhausted" } };
6692
+ }
6693
+ plans.push({
6694
+ kind: "budget",
6695
+ budget: charge.budget,
6696
+ bucketKey: charge.bucketKey,
6697
+ amount: charge.amount,
6698
+ generation: charge.generation,
6699
+ breached: peek.entries[index]?.allowed === false
6700
+ });
6701
+ }
6702
+ if (breaches.length > 0) {
6703
+ budgetBreachEntries = breaches;
6704
+ budgetBreachContexts = breaches.map((entry2) => ({
6705
+ name: entry2.budget.name,
6706
+ limit: entry2.budget.limit,
6707
+ spent: entry2.spent,
6708
+ attempted_amount: entry2.amount,
6709
+ currency: entry2.budget.currency,
6710
+ window: entry2.budget.windowRaw
6711
+ }));
6712
+ budgetTicketTimeoutMs = breaches[0]?.budget.approval?.timeoutMs;
6713
+ if (wire === "allow") {
6714
+ budgetTriggeredApproval = true;
6715
+ wire = "require_approval";
6716
+ }
6717
+ }
6718
+ }
6719
+ }
6720
+ }
6721
+ }
6722
+ if (budgetsBlock) {
6723
+ limitsBlock = { ...limitsBlock ?? {}, budgets: budgetsBlock };
6724
+ }
5746
6725
  const matchedRuleName = decision.matchedRule?.name ?? null;
5747
6726
  const matchedRuleIndex = decision.matchedRule?.index ?? null;
5748
6727
  const responseBody = {
@@ -5752,15 +6731,27 @@ var GovernanceService = class {
5752
6731
  matched_rule: matchedRuleName,
5753
6732
  matched_rule_index: matchedRuleIndex
5754
6733
  };
5755
- if (isBlocking(wire)) {
6734
+ if (shouldAttachFeedback(wire, decision)) {
5756
6735
  responseBody["feedback"] = buildFeedback(decision.matchedRule, decision.reason);
5757
6736
  }
6737
+ if (wire === "require_approval" && budgetTriggeredApproval && budgetBreachContexts) {
6738
+ responseBody["feedback"] = {
6739
+ message: `Budget ${budgetBreachContexts.map((b) => `"${b.name}"`).join(", ")} would be exceeded by this call; break-glass approval required`,
6740
+ suggestion: "Await the approval decision, reduce the amount, or wait for the window to reset."
6741
+ };
6742
+ }
6743
+ if (wire === "budget_exceeded" && budgetDenial) {
6744
+ responseBody["feedback"] = {
6745
+ message: budgetDenial.invalid.length > 0 ? `Budget ${budgetDenial.invalid.map((n) => `"${n}"`).join(", ")} could not read a valid spend amount from this call` : `Budget ${budgetDenial.breached.map((n) => `"${n}"`).join(", ")} would be exceeded by this call`,
6746
+ suggestion: budgetDenial.invalid.length > 0 ? "Retry with a non-negative finite amount in the expected field." : "Wait for the window to reset or reduce the amount."
6747
+ };
6748
+ }
5758
6749
  if (limitsBlock) responseBody["limits"] = limitsBlock;
5759
6750
  if (wire === "dry_run") {
5760
6751
  responseBody["dry_run"] = {
5761
- would_forward: decision.action === "allow" && !pipeline.evidenceBlocked,
6752
+ would_forward: decision.action === "allow" && !pipeline.evidenceBlocked && budgetDryRunOk,
5762
6753
  evidence_satisfied: !pipeline.evidenceBlocked,
5763
- limits_ok: true
6754
+ limits_ok: budgetDryRunOk
5764
6755
  };
5765
6756
  }
5766
6757
  if (pipeline.driftEvent) {
@@ -5792,6 +6783,12 @@ var GovernanceService = class {
5792
6783
  });
5793
6784
  return { status: 200, body: responseBody };
5794
6785
  }
6786
+ const budgetsAtEvaluate = plans.some((plan) => plan.kind === "budget") ? structuredClone(budgetsBlock) : void 0;
6787
+ const totalBytes = entryBytes + planBytes(plans) + (budgetsAtEvaluate ? byteLength(budgetsAtEvaluate) : 0);
6788
+ if (this.pendingBytes + totalBytes > this.maxPendingBytes) {
6789
+ releaseReservations();
6790
+ return { status: 503, body: { error: "evaluation_backlog_full" } };
6791
+ }
5795
6792
  let approvalTicketId;
5796
6793
  let ticketTimeoutAtMs;
5797
6794
  if (wire === "require_approval") {
@@ -5801,14 +6798,18 @@ var GovernanceService = class {
5801
6798
  "[helio] invariant violation: require_approval decision without an approvalRouter"
5802
6799
  );
5803
6800
  }
5804
- const timeoutMs = decision.matchedRule?.approval?.timeoutMs ?? this.approvalTimeoutMs;
6801
+ const timeoutMs = (decision.action === "require_approval" ? decision.matchedRule?.approval?.timeoutMs : budgetTicketTimeoutMs) ?? this.approvalTimeoutMs;
5805
6802
  const ticket = router.createNativeTicket({
5806
6803
  tool_name: toolName,
5807
- tool_input: req.arguments ?? {},
6804
+ // Cloned: the ticket is what the APPROVER sees, and a direct
6805
+ // embedder mutating its arguments object after /evaluate must not
6806
+ // rewrite it (same guard as the pending entry's evidence below).
6807
+ tool_input: structuredClone(req.arguments ?? {}),
5808
6808
  matched_rule: decision.matchedRule,
5809
6809
  session_id: req.session_id,
5810
6810
  origin: req.origin,
5811
- timeout_ms: timeoutMs
6811
+ timeout_ms: timeoutMs,
6812
+ breached_budgets: budgetBreachContexts
5812
6813
  });
5813
6814
  approvalTicketId = ticket.id;
5814
6815
  ticketTimeoutAtMs = this.now() + timeoutMs;
@@ -5817,6 +6818,7 @@ var GovernanceService = class {
5817
6818
  timeout_ms: timeoutMs,
5818
6819
  resolve_path: `/approval/${ticket.id}/resolve`
5819
6820
  };
6821
+ if (budgetBreachEntries) this.budgetEngine?.reportBreaches(budgetBreachEntries);
5820
6822
  }
5821
6823
  const entry = {
5822
6824
  evaluationId,
@@ -5824,22 +6826,27 @@ var GovernanceService = class {
5824
6826
  agentId: req.agent_id,
5825
6827
  sessionId: req.session_id,
5826
6828
  toolName,
5827
- toolInput: req.arguments ?? {},
5828
- metadata: req.metadata,
6829
+ // Cloned: direct embedders share these references and could otherwise
6830
+ // mutate the audit evidence (and desync the byte accounting) after
6831
+ // admission. The HTTP route always builds fresh objects; this guards
6832
+ // the library surface.
6833
+ toolInput: structuredClone(req.arguments ?? {}),
6834
+ metadata: req.metadata === null ? null : structuredClone(req.metadata),
5829
6835
  action: decision.action,
5830
6836
  matchedRuleName,
5831
6837
  matchedRuleIndex,
5832
6838
  flaggedDestructive: pipeline.flaggedDestructive,
5833
- limitPlan,
6839
+ plans,
6840
+ budgetsAtEvaluate,
5834
6841
  approvalTicketId,
5835
6842
  timestampIso,
5836
6843
  createdAtMs: this.now(),
5837
6844
  evaluationExpiresAtMs: this.now() + this.ttlMs,
5838
6845
  ticketTimeoutAtMs,
5839
- bytes: entryBytes
6846
+ bytes: totalBytes
5840
6847
  };
5841
6848
  this.pending.set(evaluationId, entry);
5842
- this.pendingBytes += entryBytes;
6849
+ this.pendingBytes += totalBytes;
5843
6850
  if (approvalTicketId) this.ticketToEvaluation.set(approvalTicketId, evaluationId);
5844
6851
  return { status: 200, body: responseBody };
5845
6852
  }
@@ -5883,20 +6890,20 @@ var GovernanceService = class {
5883
6890
  let approvedBy = null;
5884
6891
  let approvalContext;
5885
6892
  if (entry.approvalTicketId) {
5886
- const ticket = this.getTicketStatus(entry.approvalTicketId);
5887
- const status = ticket?.status;
5888
- if (!status || status === "pending") {
6893
+ this.snapshotTicketResolution(entry);
6894
+ const resolution = entry.ticketResolution;
6895
+ if (!resolution) {
5889
6896
  return { status: 409, body: { error: "approval_unresolved" } };
5890
6897
  }
5891
- approvalStatus = status;
5892
- approvedBy = ticket.resolved_by ?? null;
5893
- if (ticket.denial_reason || ticket.escalated_at) {
6898
+ approvalStatus = resolution.status;
6899
+ approvedBy = resolution.resolvedBy;
6900
+ if (resolution.denialReason || resolution.escalatedAt) {
5894
6901
  approvalContext = {
5895
6902
  ticket_id: entry.approvalTicketId,
5896
- ...ticket.denial_reason ? { denial_reason: ticket.denial_reason } : {},
5897
- ...ticket.escalated_at ? {
5898
- escalated_at: ticket.escalated_at,
5899
- escalated_to: [...ticket.escalated_to ?? []]
6903
+ ...resolution.denialReason ? { denial_reason: resolution.denialReason } : {},
6904
+ ...resolution.escalatedAt ? {
6905
+ escalated_at: resolution.escalatedAt,
6906
+ escalated_to: [...resolution.escalatedTo ?? []]
5900
6907
  } : {}
5901
6908
  };
5902
6909
  }
@@ -5905,20 +6912,32 @@ var GovernanceService = class {
5905
6912
  if (!Number.isFinite(req.actual_amount) || req.actual_amount < 0) {
5906
6913
  return { status: 400, body: { error: "invalid_actual_amount" } };
5907
6914
  }
5908
- if (entry.limitPlan?.kind !== "spend") {
6915
+ const hasMoneyPlan = entry.plans.some(
6916
+ (plan) => plan.kind === "spend" || plan.kind === "budget"
6917
+ );
6918
+ if (!hasMoneyPlan) {
5909
6919
  return { status: 400, body: { error: "no_spend_rule" } };
5910
6920
  }
5911
6921
  }
5912
6922
  const callHappened = req.status === "success" || req.status === "error";
5913
- let limitsChain;
5914
- if (callHappened && entry.limitPlan) {
5915
- limitsChain = this.commitLimit(entry.limitPlan, req.actual_amount);
6923
+ if (entry.commitState && entry.commitState.payloadHash !== payloadHash) {
6924
+ return { status: 409, body: { error: "evaluation_conflict" } };
6925
+ }
6926
+ const auditId = entry.commitState?.auditId ?? randomUUID5();
6927
+ let limitsChain = entry.commitState?.limitsChain;
6928
+ if (callHappened && entry.plans.length > 0 && !entry.commitState) {
6929
+ limitsChain = this.commitPlans(entry, req.actual_amount, auditId, approvalStatus);
6930
+ entry.commitState = { auditId, limitsChain, payloadHash };
6931
+ } else if (!callHappened && !entry.commitState && entry.budgetsAtEvaluate) {
6932
+ limitsChain = { ...limitsChain ?? {}, budgets: entry.budgetsAtEvaluate };
5916
6933
  }
6934
+ const budgetBreachBlocked = !callHappened && entry.action !== "require_approval" && (approvalStatus === "denied" || approvalStatus === "timeout") && entry.plans.some((plan) => plan.kind === "budget" && plan.breached);
5917
6935
  if (callHappened && this.evidenceStore && entry.sessionId) {
5918
6936
  this.evidenceStore.recordToolCall(entry.sessionId, entry.toolName, req.status === "success");
5919
6937
  }
5920
6938
  const evidenceOutcomes = this.populateEvidence(req, entry);
5921
- const auditId = this.writeAudit({
6939
+ this.writeAudit({
6940
+ id: auditId,
5922
6941
  timestampIso: entry.timestampIso,
5923
6942
  origin: entry.origin,
5924
6943
  agentId: entry.agentId,
@@ -5927,6 +6946,7 @@ var GovernanceService = class {
5927
6946
  toolInput: entry.toolInput,
5928
6947
  metadata: entry.metadata,
5929
6948
  action: entry.action,
6949
+ budgetBreachBlocked,
5930
6950
  wire: entry.action === "require_approval" ? "require_approval" : "allow",
5931
6951
  matchedRuleName: entry.matchedRuleName,
5932
6952
  matchedRuleIndex: entry.matchedRuleIndex,
@@ -6015,7 +7035,7 @@ var GovernanceService = class {
6015
7035
  return { status: 400, body: { error: "reserved_metadata_key", key: reserved } };
6016
7036
  }
6017
7037
  this.touchAdapter(req.origin);
6018
- const evaluationId = randomUUID4();
7038
+ const evaluationId = randomUUID5();
6019
7039
  const toolName = `install:${req.package.source ?? "pkg"}:${req.package.name}`;
6020
7040
  const verdict = this.evaluateInstall(req);
6021
7041
  const denied = verdict.decision === "deny";
@@ -6167,6 +7187,7 @@ var GovernanceService = class {
6167
7187
  if (!resolved) {
6168
7188
  return { status: 409, body: { error: "already_resolved" } };
6169
7189
  }
7190
+ if (entry) this.snapshotTicketResolution(entry);
6170
7191
  return { status: 200, body: { ok: true } };
6171
7192
  }
6172
7193
  // -------------------------------------------------------------------------
@@ -6192,7 +7213,7 @@ var GovernanceService = class {
6192
7213
  * closed, so an emptied bucket frees its slot without waiting for the sweep.
6193
7214
  */
6194
7215
  reserveSenderKey(key) {
6195
- if (!key.startsWith("sender:")) return true;
7216
+ if (!isSenderScopedKey(key)) return true;
6196
7217
  if (this.senderKeys.has(key)) return true;
6197
7218
  if (this.hasLiveBucket(key)) {
6198
7219
  this.senderKeys.add(key);
@@ -6210,8 +7231,9 @@ var GovernanceService = class {
6210
7231
  if (this.senderKeys.size === 0) return;
6211
7232
  const inUse = /* @__PURE__ */ new Set();
6212
7233
  for (const entry of this.pending.values()) {
6213
- if (entry.limitPlan && entry.limitPlan.key.startsWith("sender:")) {
6214
- inUse.add(entry.limitPlan.key);
7234
+ for (const plan of entry.plans) {
7235
+ const key = plan.kind === "budget" ? plan.bucketKey : plan.key;
7236
+ if (isSenderScopedKey(key)) inUse.add(key);
6215
7237
  }
6216
7238
  }
6217
7239
  for (const key of this.senderKeys) {
@@ -6226,7 +7248,7 @@ var GovernanceService = class {
6226
7248
  * an emptied bucket IS the prune-on-touch mechanism.
6227
7249
  */
6228
7250
  hasLiveBucket(key) {
6229
- return this.rateLimiter?.getKeyState(key) !== void 0 || this.spendLimiter?.getKeyState(key) !== void 0;
7251
+ return this.rateLimiter?.getKeyState(key) !== void 0 || this.spendLimiter?.getKeyState(key) !== void 0 || this.budgetEngine?.hasBucket(key) === true;
6230
7252
  }
6231
7253
  close() {
6232
7254
  if (this.closed) return;
@@ -6251,7 +7273,17 @@ var GovernanceService = class {
6251
7273
  if (now >= entry.evaluationExpiresAtMs) {
6252
7274
  if (entry.approvalTicketId) {
6253
7275
  this.approvalRouter?.resolveNativeTicket(entry.approvalTicketId, "timeout");
7276
+ this.snapshotTicketResolution(entry);
6254
7277
  }
7278
+ const resolution = entry.ticketResolution;
7279
+ const approvalContext = entry.approvalTicketId && resolution && (resolution.denialReason || resolution.escalatedAt) ? {
7280
+ ticket_id: entry.approvalTicketId,
7281
+ ...resolution.denialReason ? { denial_reason: resolution.denialReason } : {},
7282
+ ...resolution.escalatedAt ? {
7283
+ escalated_at: resolution.escalatedAt,
7284
+ escalated_to: [...resolution.escalatedTo ?? []]
7285
+ } : {}
7286
+ } : void 0;
6255
7287
  const auditId = this.writeAudit({
6256
7288
  timestampIso: entry.timestampIso,
6257
7289
  origin: entry.origin,
@@ -6267,6 +7299,10 @@ var GovernanceService = class {
6267
7299
  flaggedDestructive: entry.flaggedDestructive,
6268
7300
  dryRun: false,
6269
7301
  recordKind: "evaluation_expired",
7302
+ approvalStatus: resolution?.status ?? null,
7303
+ approvedBy: resolution?.resolvedBy ?? null,
7304
+ approvalContext,
7305
+ limitsChain: !entry.commitState && entry.budgetsAtEvaluate ? { budgets: entry.budgetsAtEvaluate } : void 0,
6270
7306
  sidebandUnreported: true
6271
7307
  });
6272
7308
  this.discardPending(entry);
@@ -6283,6 +7319,7 @@ var GovernanceService = class {
6283
7319
  }
6284
7320
  if (entry.approvalTicketId && entry.ticketTimeoutAtMs !== void 0 && now >= entry.ticketTimeoutAtMs) {
6285
7321
  this.approvalRouter?.resolveNativeTicket(entry.approvalTicketId, "timeout");
7322
+ this.snapshotTicketResolution(entry);
6286
7323
  }
6287
7324
  return "active";
6288
7325
  }
@@ -6303,6 +7340,18 @@ var GovernanceService = class {
6303
7340
  getTicketStatus(ticketId) {
6304
7341
  return this.approvalRouter?.getTicket(ticketId);
6305
7342
  }
7343
+ /** Latch the entry's ticket resolution while the ticket still exists. */
7344
+ snapshotTicketResolution(entry) {
7345
+ if (entry.ticketResolution || !entry.approvalTicketId) return;
7346
+ const ticket = this.getTicketStatus(entry.approvalTicketId);
7347
+ if (!ticket || ticket.status === "pending") return;
7348
+ entry.ticketResolution = {
7349
+ status: ticket.status,
7350
+ resolvedBy: ticket.resolved_by ?? null,
7351
+ ...ticket.denial_reason ? { denialReason: ticket.denial_reason } : {},
7352
+ ...ticket.escalated_at ? { escalatedAt: ticket.escalated_at, escalatedTo: [...ticket.escalated_to ?? []] } : {}
7353
+ };
7354
+ }
6306
7355
  planRate(decision, toolName, sessionId, senderId) {
6307
7356
  const limits = decision.matchedRule?.limits;
6308
7357
  if (!this.rateLimiter || !limits?.maxCalls || !limits.windowMs) {
@@ -6328,7 +7377,10 @@ var GovernanceService = class {
6328
7377
  planSpend(decision, toolName, sessionId, args, senderId) {
6329
7378
  const maxSpend = decision.matchedRule?.limits?.maxSpend;
6330
7379
  if (!this.spendLimiter || !maxSpend) return { allowed: true };
6331
- const key = buildLimitKey(maxSpend.key, toolName, sessionId, senderId);
7380
+ const key = spendBucketKey(
7381
+ buildLimitKey(maxSpend.key, toolName, sessionId, senderId),
7382
+ decision.matchedRule.index
7383
+ );
6332
7384
  const rawAmount = resolvePath(maxSpend.field, args ?? {});
6333
7385
  if (typeof rawAmount !== "number" || !Number.isFinite(rawAmount) || rawAmount < 0) {
6334
7386
  return { allowed: false, block: { reason: "invalid_amount", limit: maxSpend.limit } };
@@ -6357,47 +7409,88 @@ var GovernanceService = class {
6357
7409
  allowed: peek.allowed
6358
7410
  };
6359
7411
  }
6360
- /** Commit a limit plan at /audit time and return the evidence_chain block. */
6361
- commitLimit(plan, actualAmount) {
6362
- if (plan.kind === "rate" && this.rateLimiter && plan.limits.maxCalls && plan.limits.windowMs) {
6363
- const r = this.rateLimiter.record({
6364
- key: plan.key,
6365
- maxCalls: plan.limits.maxCalls,
6366
- windowMs: plan.limits.windowMs
6367
- });
6368
- return {
6369
- rate_limit: {
6370
- allowed: r.allowed,
6371
- current: r.current,
6372
- limit: r.limit,
6373
- window_ms: r.windowMs,
6374
- reset_at_ms: r.resetAtMs
7412
+ /** Commit every plan of one call at /audit time; returns the chain blocks. */
7413
+ commitPlans(entry, actualAmount, auditId, approvalStatus) {
7414
+ let chain;
7415
+ const budgetPlans = entry.plans.filter((plan) => plan.kind === "budget");
7416
+ if (budgetPlans.length > 0 && this.budgetEngine) {
7417
+ const kinds = new Map(
7418
+ budgetPlans.filter((plan) => plan.breached && approvalStatus === "approved").map((plan) => [plan.budget.name, "approved_overage"])
7419
+ );
7420
+ const snapshots = this.budgetEngine.recordAll(
7421
+ budgetPlans.map((plan) => ({
7422
+ budget: plan.budget,
7423
+ bucketKey: plan.bucketKey,
7424
+ amount: actualAmount ?? plan.amount,
7425
+ generation: plan.generation
7426
+ })),
7427
+ {
7428
+ kind: "spend",
7429
+ ...kinds.size > 0 ? { kinds } : {},
7430
+ auditRecordId: auditId,
7431
+ origin: entry.origin,
7432
+ toolName: entry.toolName,
7433
+ timestampIso: new Date(this.now()).toISOString()
6375
7434
  }
7435
+ );
7436
+ const frozenByName = new Map(
7437
+ (entry.budgetsAtEvaluate ?? []).map((block) => [block["name"], block])
7438
+ );
7439
+ chain = {
7440
+ ...chain ?? {},
7441
+ budgets: snapshots.map((snapshot) => {
7442
+ const kind = kinds.get(snapshot.budget.name) ?? "spend";
7443
+ const frozen = frozenByName.get(snapshot.budget.name);
7444
+ return snapshot.stale && frozen ? { ...frozen, kind, stale: true } : budgetWireBlock(snapshot, kind);
7445
+ })
6376
7446
  };
6377
7447
  }
6378
- if (plan.kind === "spend" && this.spendLimiter && plan.limits.maxSpend) {
6379
- const amount = actualAmount ?? plan.amount ?? 0;
6380
- const r = this.spendLimiter.record({
6381
- key: plan.key,
6382
- amount,
6383
- limit: plan.limits.maxSpend.limit,
6384
- windowMs: plan.limits.maxSpend.windowMs
6385
- });
6386
- this.spendLimiter.setCurrency(plan.key, plan.limits.maxSpend.currency);
6387
- return {
6388
- spend_limit: {
6389
- allowed: r.allowed,
6390
- current_spend: r.currentSpend,
6391
- limit: r.limit,
6392
- window_ms: r.windowMs,
6393
- reset_at_ms: r.resetAtMs
6394
- }
6395
- };
7448
+ for (const plan of entry.plans) {
7449
+ if (plan.kind === "budget") {
7450
+ continue;
7451
+ }
7452
+ if (plan.kind === "rate" && this.rateLimiter && plan.limits.maxCalls && plan.limits.windowMs) {
7453
+ const r = this.rateLimiter.record({
7454
+ key: plan.key,
7455
+ maxCalls: plan.limits.maxCalls,
7456
+ windowMs: plan.limits.windowMs
7457
+ });
7458
+ chain = {
7459
+ ...chain ?? {},
7460
+ rate_limit: {
7461
+ allowed: r.allowed,
7462
+ current: r.current,
7463
+ limit: r.limit,
7464
+ window_ms: r.windowMs,
7465
+ reset_at_ms: r.resetAtMs
7466
+ }
7467
+ };
7468
+ }
7469
+ if (plan.kind === "spend" && this.spendLimiter && plan.limits.maxSpend) {
7470
+ const amount = actualAmount ?? plan.amount ?? 0;
7471
+ const r = this.spendLimiter.record({
7472
+ key: plan.key,
7473
+ amount,
7474
+ limit: plan.limits.maxSpend.limit,
7475
+ windowMs: plan.limits.maxSpend.windowMs
7476
+ });
7477
+ this.spendLimiter.setCurrency(plan.key, plan.limits.maxSpend.currency);
7478
+ chain = {
7479
+ ...chain ?? {},
7480
+ spend_limit: {
7481
+ allowed: r.allowed,
7482
+ current_spend: r.currentSpend,
7483
+ limit: r.limit,
7484
+ window_ms: r.windowMs,
7485
+ reset_at_ms: r.resetAtMs
7486
+ }
7487
+ };
7488
+ }
6396
7489
  }
6397
- return void 0;
7490
+ return chain;
6398
7491
  }
6399
7492
  writeAudit(args) {
6400
- const id = randomUUID4();
7493
+ const id = args.id ?? randomUUID5();
6401
7494
  if (!this.auditWriter) return id;
6402
7495
  const blockReason = deriveBlockReason(args);
6403
7496
  let evidenceChain = args.limitsChain ?? null;
@@ -6450,6 +7543,7 @@ function deriveBlockReason(args) {
6450
7543
  if (args.recordKind === "evaluation_expired") return null;
6451
7544
  if (args.recordKind === "install_scan") return args.wire === "deny" ? "install_denied" : null;
6452
7545
  if (args.dryRun) return null;
7546
+ if (args.budgetBreachBlocked) return "budget_exceeded";
6453
7547
  if (args.approvalStatus === "denied") return "approval_denied";
6454
7548
  if (args.approvalStatus === "timeout") return "approval_timeout";
6455
7549
  if (args.approvalStatus === "cancelled") return "cancelled";
@@ -6460,6 +7554,8 @@ function deriveBlockReason(args) {
6460
7554
  return "rate_limited";
6461
7555
  case "spend_limited":
6462
7556
  return "spend_limited";
7557
+ case "budget_exceeded":
7558
+ return "budget_exceeded";
6463
7559
  default:
6464
7560
  return null;
6465
7561
  }
@@ -6470,10 +7566,15 @@ function buildFeedback(rule, reason) {
6470
7566
  return suggestion ? { message, suggestion } : { message };
6471
7567
  }
6472
7568
  function isBlocking(wire) {
6473
- return wire === "deny" || wire === "rate_limited" || wire === "spend_limited";
7569
+ return wire === "deny" || wire === "rate_limited" || wire === "spend_limited" || wire === "budget_exceeded";
7570
+ }
7571
+ function shouldAttachFeedback(wire, decision) {
7572
+ if (isBlocking(wire)) return true;
7573
+ const gating = wire === "require_approval" || wire === "dry_run";
7574
+ return gating && decision.action !== "allow" && decision.matchedRule?.feedback != null;
6474
7575
  }
6475
7576
  function isTerminalAtEvaluate(wire) {
6476
- return wire === "deny" || wire === "rate_limited" || wire === "spend_limited" || wire === "dry_run";
7577
+ return wire === "deny" || wire === "rate_limited" || wire === "spend_limited" || wire === "budget_exceeded" || wire === "dry_run";
6477
7578
  }
6478
7579
  function policyCanRequireApproval(policy) {
6479
7580
  if (policy.flagDestructive === "require_approval" || policy.onToolDrift === "require_approval") {
@@ -6526,9 +7627,62 @@ function toMcpToolDef(tool) {
6526
7627
  function byteLength(value) {
6527
7628
  return Buffer.byteLength(canonicalize(value), "utf8");
6528
7629
  }
7630
+ function isSenderScopedKey(key) {
7631
+ if (key.startsWith("sender:")) return true;
7632
+ if (!key.startsWith("budget:")) return false;
7633
+ const scope = key.slice("budget:".length);
7634
+ const sep = scope.indexOf(":");
7635
+ return sep !== -1 && scope.slice(sep + 1).startsWith("sender:");
7636
+ }
7637
+ function budgetWireBlock(entry, kind) {
7638
+ return {
7639
+ ...kind ? { kind } : {},
7640
+ name: entry.budget.name,
7641
+ limit: entry.budget.limit,
7642
+ spent: entry.spent,
7643
+ remaining: entry.remaining,
7644
+ attempted_amount: entry.amount,
7645
+ currency: entry.budget.currency,
7646
+ window: entry.budget.windowRaw,
7647
+ on_exceed: entry.budget.onExceed,
7648
+ allowed: entry.allowed,
7649
+ reset_at_ms: entry.resetAtMs,
7650
+ ...entry.stale ? { stale: true } : {}
7651
+ };
7652
+ }
7653
+ function budgetFailureBlock(failure) {
7654
+ return {
7655
+ name: failure.budget.name,
7656
+ limit: failure.budget.limit,
7657
+ spent: failure.spent,
7658
+ remaining: failure.remaining,
7659
+ attempted_amount: null,
7660
+ currency: failure.budget.currency,
7661
+ window: failure.budget.windowRaw,
7662
+ on_exceed: failure.budget.onExceed,
7663
+ allowed: false,
7664
+ reason: "invalid_amount",
7665
+ reset_at_ms: failure.resetAtMs
7666
+ };
7667
+ }
7668
+ function planBytes(plans) {
7669
+ let total = 0;
7670
+ for (const plan of plans) {
7671
+ total += byteLength(
7672
+ plan.kind === "budget" ? {
7673
+ kind: plan.kind,
7674
+ name: plan.budget.name,
7675
+ key: plan.bucketKey,
7676
+ amount: plan.amount,
7677
+ breached: plan.breached
7678
+ } : { kind: plan.kind, key: plan.key, amount: plan.amount ?? 0 }
7679
+ );
7680
+ }
7681
+ return total;
7682
+ }
6529
7683
 
6530
7684
  // src/approval/queue.ts
6531
- import { randomUUID as randomUUID5 } from "crypto";
7685
+ import { randomUUID as randomUUID6 } from "crypto";
6532
7686
  var ApprovalQueue = class {
6533
7687
  tickets = /* @__PURE__ */ new Map();
6534
7688
  now;
@@ -6558,7 +7712,7 @@ var ApprovalQueue = class {
6558
7712
  if (this.closed) throw new Error("ApprovalQueue is closed");
6559
7713
  const now = this.now();
6560
7714
  const ticket = {
6561
- id: randomUUID5(),
7715
+ id: randomUUID6(),
6562
7716
  tool_name: params.tool_name,
6563
7717
  tool_input: params.tool_input,
6564
7718
  matched_rule: params.matched_rule,
@@ -6568,6 +7722,9 @@ var ApprovalQueue = class {
6568
7722
  requested_at: new Date(now).toISOString(),
6569
7723
  timeout_at: new Date(now + params.timeout_ms).toISOString(),
6570
7724
  timeout_ms: params.timeout_ms,
7725
+ // Absent (not empty) on plain rule tickets: presence is the marker a
7726
+ // future standing-approval store must exclude (issue #127).
7727
+ ...params.breached_budgets?.length ? { breached_budgets: params.breached_budgets } : {},
6571
7728
  status: "pending",
6572
7729
  notification_failures: []
6573
7730
  };
@@ -6671,8 +7828,9 @@ var ApprovalRouter = class {
6671
7828
  return { status: "denied", resolvedBy: "system", reason: "Router is closed", ticketId: "" };
6672
7829
  }
6673
7830
  const rule = params.matched_rule;
6674
- const timeoutMs = rule?.approval?.timeoutMs ?? this.defaultTimeoutMs;
6675
- const channelName = rule?.approval?.channel ?? "dashboard";
7831
+ const approvalConfig = params.approval ?? rule?.approval;
7832
+ const timeoutMs = approvalConfig?.timeoutMs ?? this.defaultTimeoutMs;
7833
+ const channelName = approvalConfig?.channel ?? "dashboard";
6676
7834
  const ticket = this.queue.add({
6677
7835
  tool_name: params.tool_name,
6678
7836
  tool_input: params.tool_input,
@@ -6680,7 +7838,8 @@ var ApprovalRouter = class {
6680
7838
  rule_index: rule?.index ?? null,
6681
7839
  channel_name: channelName,
6682
7840
  session_id: params.session_id,
6683
- timeout_ms: timeoutMs
7841
+ timeout_ms: timeoutMs,
7842
+ breached_budgets: params.breached_budgets
6684
7843
  });
6685
7844
  this.onSubmit?.(ticket);
6686
7845
  const outcome = await new Promise((resolve2) => {
@@ -6698,8 +7857,8 @@ var ApprovalRouter = class {
6698
7857
  }, timeoutMs);
6699
7858
  timer.unref();
6700
7859
  let escalationTimer;
6701
- const delegates = rule?.approval?.delegates;
6702
- const escalationAfterMs = rule?.approval?.escalationAfterMs;
7860
+ const delegates = approvalConfig?.delegates;
7861
+ const escalationAfterMs = approvalConfig?.escalationAfterMs;
6703
7862
  if (escalationAfterMs !== void 0 && escalationAfterMs > 0 && escalationAfterMs < timeoutMs) {
6704
7863
  escalationTimer = setTimeout(() => {
6705
7864
  if (!this.pending.has(ticket.id)) return;
@@ -6775,7 +7934,8 @@ var ApprovalRouter = class {
6775
7934
  rule_index: rule?.index ?? null,
6776
7935
  channel_name: `${NATIVE_CHANNEL_PREFIX}${params.origin}`,
6777
7936
  session_id: params.session_id,
6778
- timeout_ms: timeoutMs
7937
+ timeout_ms: timeoutMs,
7938
+ breached_budgets: params.breached_budgets
6779
7939
  });
6780
7940
  this.onSubmit?.(ticket);
6781
7941
  return ticket;
@@ -6953,6 +8113,7 @@ ${safeInput}
6953
8113
  if (ticket.session_id) {
6954
8114
  detailLines.push(`*Session:* \`${sanitizeCodeSpanContent(ticket.session_id)}\``);
6955
8115
  }
8116
+ const budgetBlocks = buildBudgetBlocks(ticket);
6956
8117
  return [
6957
8118
  {
6958
8119
  type: "header",
@@ -6962,6 +8123,7 @@ ${safeInput}
6962
8123
  type: "section",
6963
8124
  text: { type: "mrkdwn", text: detailLines.join("\n") }
6964
8125
  },
8126
+ ...budgetBlocks,
6965
8127
  {
6966
8128
  type: "context",
6967
8129
  elements: [
@@ -6988,8 +8150,56 @@ ${safeInput}
6988
8150
  action_id: `helio_deny:${ticket.id}`
6989
8151
  }
6990
8152
  ]
6991
- }
6992
- ];
8153
+ }
8154
+ ];
8155
+ }
8156
+ var MAX_SECTION_TEXT = 2900;
8157
+ var MAX_BUDGET_SECTIONS = 40;
8158
+ function buildBudgetBlocks(ticket) {
8159
+ const breached = ticket.breached_budgets;
8160
+ if (!breached?.length) return [];
8161
+ const lines = breached.map(
8162
+ (b) => `\u2022 \`${sanitizeCodeSpanContent(b.name)}\` \u2014 ${String(b.spent)}/${String(b.limit)} ${sanitizeMrkdwnText(b.currency)} spent, attempting +${String(b.attempted_amount)} (${sanitizeMrkdwnText(b.window)} window)`
8163
+ );
8164
+ const sections = [];
8165
+ let current = "*Breached budgets (approval spends past the limit):*";
8166
+ let pendingLines = 0;
8167
+ let rendered = 0;
8168
+ let capped = false;
8169
+ for (const line of lines) {
8170
+ if (current.length + 1 + line.length > MAX_SECTION_TEXT) {
8171
+ sections.push(current);
8172
+ if (sections.length >= MAX_BUDGET_SECTIONS) {
8173
+ capped = true;
8174
+ break;
8175
+ }
8176
+ current = line;
8177
+ pendingLines = 1;
8178
+ } else {
8179
+ current = `${current}
8180
+ ${line}`;
8181
+ pendingLines += 1;
8182
+ }
8183
+ rendered += 1;
8184
+ }
8185
+ if (!capped && pendingLines > 0) sections.push(current);
8186
+ const blocks = sections.map((text) => ({
8187
+ type: "section",
8188
+ text: { type: "mrkdwn", text }
8189
+ }));
8190
+ const omitted = lines.length - rendered;
8191
+ if (omitted > 0) {
8192
+ blocks.push({
8193
+ type: "context",
8194
+ elements: [
8195
+ {
8196
+ type: "mrkdwn",
8197
+ text: `\u2026and ${String(omitted)} more breached budget${omitted === 1 ? "" : "s"} \u2014 the approval ticket carries the full list (approvals REST API / dashboard).`
8198
+ }
8199
+ ]
8200
+ });
8201
+ }
8202
+ return blocks;
6993
8203
  }
6994
8204
  var SlackChannel = class {
6995
8205
  type = "slack";
@@ -7488,10 +8698,821 @@ function createApprovalApp(router, queue, options) {
7488
8698
  return app;
7489
8699
  }
7490
8700
 
8701
+ // src/budget/engine.ts
8702
+ function kindOf(meta, budgetName) {
8703
+ return meta.kinds?.get(budgetName) ?? meta.kind;
8704
+ }
8705
+ function isBudgetPersistence(sink) {
8706
+ const candidate = sink;
8707
+ return typeof candidate.readMeta === "function" && typeof candidate.readAllMeta === "function" && typeof candidate.writeMeta === "function" && typeof candidate.writeMetaBatch === "function" && typeof candidate.maxEventEpoch === "function" && typeof candidate.replayDurationEvents === "function" && typeof candidate.replaySessionBuckets === "function" && typeof candidate.recordBucketGc === "function";
8708
+ }
8709
+ var NOOP_LEDGER = { commitAll: () => {
8710
+ } };
8711
+ var BudgetEngine = class {
8712
+ budgets = /* @__PURE__ */ new Map();
8713
+ /** budget name → bucket key → bucket. */
8714
+ state = /* @__PURE__ */ new Map();
8715
+ /** budget name → config generation; bumped whenever the pot resets. */
8716
+ generations = /* @__PURE__ */ new Map();
8717
+ now;
8718
+ ledger;
8719
+ /** The sink again, when it carries the full persistence contract. */
8720
+ persistence;
8721
+ onCommit;
8722
+ onBreach;
8723
+ timer = null;
8724
+ closed = false;
8725
+ hydrated = false;
8726
+ constructor(options = {}) {
8727
+ this.now = options.now ?? Date.now;
8728
+ this.ledger = options.ledger ?? NOOP_LEDGER;
8729
+ this.persistence = isBudgetPersistence(this.ledger) ? this.ledger : null;
8730
+ this.onCommit = options.onCommit;
8731
+ this.onBreach = options.onBreach;
8732
+ for (const budget of options.budgets ?? []) {
8733
+ this.budgets.set(budget.name, budget);
8734
+ this.generations.set(budget.name, 1);
8735
+ }
8736
+ const intervalMs = options.cleanupIntervalMs ?? 6e4;
8737
+ if (intervalMs > 0) {
8738
+ this.timer = setInterval(() => {
8739
+ this.gc();
8740
+ }, intervalMs);
8741
+ this.timer.unref();
8742
+ }
8743
+ }
8744
+ // -------------------------------------------------------------------------
8745
+ // Gate operations
8746
+ // -------------------------------------------------------------------------
8747
+ /**
8748
+ * Resolve which budgets a call feeds and how much it charges each.
8749
+ *
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.
8754
+ */
8755
+ resolveCharges(ctx) {
8756
+ const charges = [];
8757
+ const failures = [];
8758
+ for (const budget of this.budgets.values()) {
8759
+ const contributor = budget.contributors.find((c) => c.tool.test(ctx.toolName));
8760
+ if (!contributor) continue;
8761
+ const raw = resolvePath(contributor.field, ctx.toolArguments ?? {});
8762
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 0) {
8763
+ const bucketKey = this.bucketKey(budget, ctx);
8764
+ const nowMs = this.now();
8765
+ const bucket = this.liveBucket(budget, bucketKey, nowMs);
8766
+ const spent = bucket ? this.spentOf(budget, bucket, nowMs) : 0;
8767
+ failures.push({
8768
+ budget,
8769
+ bucketKey,
8770
+ reason: "invalid_amount",
8771
+ spent,
8772
+ remaining: Math.max(0, budget.limit - spent),
8773
+ // null is reserved for session pots on the wire; an empty duration
8774
+ // bucket resets one window from now.
8775
+ resetAtMs: budget.window.kind === "duration" ? bucket && bucket.entries.length > 0 ? (bucket.entries[0]?.timestampMs ?? nowMs) + budget.window.windowMs : nowMs + budget.window.windowMs : null
8776
+ });
8777
+ continue;
8778
+ }
8779
+ charges.push({
8780
+ budget,
8781
+ bucketKey: this.bucketKey(budget, ctx),
8782
+ amount: raw,
8783
+ generation: this.generations.get(budget.name) ?? 0
8784
+ });
8785
+ }
8786
+ return { charges, failures };
8787
+ }
8788
+ /** Check every charge without mutating. All-or-nothing: one deny flips `allowed`. */
8789
+ peekAll(charges) {
8790
+ const entries = charges.map((charge) => this.snapshot(charge));
8791
+ return { allowed: entries.every((entry) => entry.allowed), entries };
8792
+ }
8793
+ /**
8794
+ * Commit every charge of one call: ledger first (one atomic batch), then
8795
+ * in-memory state, then the commit events. A sink throw propagates and
8796
+ * leaves ALL in-memory buckets untouched — no partial commit, ever.
8797
+ * Recording is unconditional past the sink (an approved overage
8798
+ * legitimately pushes a bucket past its limit).
8799
+ */
8800
+ recordAll(charges, meta) {
8801
+ const nowMs = this.now();
8802
+ const current = [];
8803
+ const stale = [];
8804
+ for (const charge of charges) {
8805
+ if (this.generations.get(charge.budget.name) === charge.generation) {
8806
+ current.push(charge);
8807
+ } else {
8808
+ stale.push(charge);
8809
+ console.error(
8810
+ `[helio] Budget "${charge.budget.name}": an in-flight charge outlived a config change; its amount is ledgered under the old generation but does not count against the current pot`
8811
+ );
8812
+ }
8813
+ }
8814
+ this.ledger.commitAll(
8815
+ charges.map((charge) => ({
8816
+ budget_name: charge.budget.name,
8817
+ bucket_key: charge.bucketKey,
8818
+ kind: kindOf(meta, charge.budget.name),
8819
+ amount: charge.amount,
8820
+ currency: charge.budget.currency,
8821
+ tool_name: meta.toolName,
8822
+ origin: meta.origin,
8823
+ audit_record_id: meta.auditRecordId,
8824
+ timestamp: meta.timestampIso,
8825
+ timestamp_ms: nowMs,
8826
+ generation: charge.generation
8827
+ }))
8828
+ );
8829
+ const snapshots = [];
8830
+ for (const charge of current) {
8831
+ const bucket = this.bucketFor(charge.budget.name, charge.bucketKey);
8832
+ if (charge.budget.window.kind === "duration") {
8833
+ this.evictExpired(bucket, charge.budget.window.windowMs, nowMs);
8834
+ bucket.entries.push({ timestampMs: nowMs, amount: charge.amount });
8835
+ } else {
8836
+ bucket.total += charge.amount;
8837
+ }
8838
+ bucket.lastActivityMs = nowMs;
8839
+ snapshots.push(this.snapshot(charge, { postRecord: true }));
8840
+ }
8841
+ for (const charge of stale) {
8842
+ const liveBudget = this.budgets.get(charge.budget.name) ?? charge.budget;
8843
+ snapshots.push({ ...this.snapshot({ ...charge, budget: liveBudget }), stale: true });
8844
+ }
8845
+ for (let i = 0; i < current.length; i++) {
8846
+ const charge = current[i];
8847
+ const snapshot = snapshots[i];
8848
+ if (!charge || !snapshot || !this.onCommit) continue;
8849
+ try {
8850
+ this.onCommit({
8851
+ name: charge.budget.name,
8852
+ bucket_key: charge.bucketKey,
8853
+ kind: kindOf(meta, charge.budget.name),
8854
+ amount: charge.amount,
8855
+ spent: snapshot.spent,
8856
+ remaining: snapshot.remaining,
8857
+ limit: charge.budget.limit,
8858
+ currency: charge.budget.currency,
8859
+ utilization: snapshot.spent / charge.budget.limit
8860
+ });
8861
+ } catch (err) {
8862
+ console.error("[helio] budget onCommit subscriber threw:", err);
8863
+ }
8864
+ }
8865
+ return snapshots;
8866
+ }
8867
+ /**
8868
+ * Fire one `onBreach` event per breached entry. Called by the doors at the
8869
+ * moment a peek outcome actually denies the call or raises the composite
8870
+ * break-glass ticket (never for dry-run peeks, never for invalid-amount
8871
+ * failures — those are input errors, not breaches). Subscriber throws are
8872
+ * isolated: a dashboard bug must never affect a gate outcome.
8873
+ */
8874
+ reportBreaches(entries) {
8875
+ if (!this.onBreach) return;
8876
+ for (const entry of entries) {
8877
+ try {
8878
+ this.onBreach({
8879
+ name: entry.budget.name,
8880
+ bucket_key: entry.bucketKey,
8881
+ on_exceed: entry.budget.onExceed,
8882
+ attempted_amount: entry.amount,
8883
+ spent: entry.spent,
8884
+ limit: entry.budget.limit,
8885
+ currency: entry.budget.currency
8886
+ });
8887
+ } catch (err) {
8888
+ console.error("[helio] budget onBreach subscriber threw:", err);
8889
+ }
8890
+ }
8891
+ }
8892
+ // -------------------------------------------------------------------------
8893
+ // Lifecycle
8894
+ // -------------------------------------------------------------------------
8895
+ /**
8896
+ * Rebuild in-memory state from the ledger. Call once at startup, after
8897
+ * construction and before serving traffic; a no-op when the configured
8898
+ * sink does not carry the persistence contract (in-memory mode).
8899
+ *
8900
+ * Per configured budget, `budget_meta` decides:
8901
+ * - no row → first boot for this name: mint epoch 1, nothing to replay;
8902
+ * - a different `{limit, currency, window, key}` tuple → the config
8903
+ * changed while down: bump the epoch, replay nothing (the same reset a
8904
+ * live tuple-changing reload performs, extended across restarts). Old
8905
+ * rows keep their epoch — history stays queryable, replay ignores it;
8906
+ * - a matching tuple → replay at the meta epoch: duration windows rebuild
8907
+ * entry lists from a window lookback (bit-equivalent to never having
8908
+ * restarted), session windows rebuild still-live pots (idle-TTL bound)
8909
+ * from their post-GC-watermark lifetime sums.
8910
+ *
8911
+ * Meta writes here propagate failures: a ledger that cannot record epochs
8912
+ * at startup must fail the boot loudly, the same posture as the audit
8913
+ * store's schema assertion.
8914
+ */
8915
+ hydrate() {
8916
+ if (!this.persistence) return;
8917
+ if (this.hydrated) return;
8918
+ this.hydrated = true;
8919
+ const nowMs = this.now();
8920
+ const metaByName = new Map(
8921
+ this.persistence.readAllMeta().map((meta) => [meta.budget_name, meta])
8922
+ );
8923
+ for (const budget of this.budgets.values()) {
8924
+ const meta = metaByName.get(budget.name);
8925
+ if (!meta) {
8926
+ const epoch = this.persistence.maxEventEpoch(budget.name) + 1;
8927
+ this.persistence.writeMeta(metaOf(budget, epoch));
8928
+ this.generations.set(budget.name, epoch);
8929
+ continue;
8930
+ }
8931
+ if (metaTupleChanged(meta, budget)) {
8932
+ const epoch = Math.max(meta.epoch, this.persistence.maxEventEpoch(budget.name)) + 1;
8933
+ this.persistence.writeMeta(metaOf(budget, epoch));
8934
+ this.generations.set(budget.name, epoch);
8935
+ continue;
8936
+ }
8937
+ this.generations.set(budget.name, meta.epoch);
8938
+ if (budget.window.kind === "duration") {
8939
+ const events = this.persistence.replayDurationEvents(
8940
+ budget.name,
8941
+ meta.epoch,
8942
+ nowMs - budget.window.windowMs
8943
+ );
8944
+ for (const event of events) {
8945
+ const bucket = this.bucketFor(budget.name, event.bucket_key);
8946
+ bucket.entries.push({ timestampMs: event.timestamp_ms, amount: event.amount });
8947
+ bucket.lastActivityMs = event.timestamp_ms;
8948
+ }
8949
+ } else {
8950
+ const liveAfterMs = nowMs - budget.window.idleTtlMs;
8951
+ for (const row of this.persistence.replaySessionBuckets(budget.name, meta.epoch)) {
8952
+ if (row.last_activity_ms >= liveAfterMs) {
8953
+ const bucket = this.bucketFor(budget.name, row.bucket_key);
8954
+ bucket.total = row.total;
8955
+ bucket.lastActivityMs = row.last_activity_ms;
8956
+ } else if (row.total > 0) {
8957
+ this.persistence.recordBucketGc(budget.name, row.bucket_key, nowMs);
8958
+ }
8959
+ }
8960
+ }
8961
+ }
8962
+ for (const [name, meta] of metaByName) {
8963
+ if (this.budgets.has(name)) continue;
8964
+ const maxEventEpoch = this.persistence.maxEventEpoch(name);
8965
+ if (maxEventEpoch < meta.epoch) continue;
8966
+ this.persistence.writeMeta({ ...meta, epoch: Math.max(meta.epoch, maxEventEpoch) + 1 });
8967
+ }
8968
+ }
8969
+ /**
8970
+ * Swap budget configs on hot-reload. Identity is the NAME: removed names
8971
+ * drop their live buckets; a changed `{limit, currency, window, key}` tuple
8972
+ * resets the budget's buckets (a different pool or scope structure);
8973
+ * everything else — contributors, on_exceed — applies to the accrued state
8974
+ * as-is, because those edits do not change what was already spent.
8975
+ *
8976
+ * Persist-before-swap: every epoch this reload mints lands in
8977
+ * `budget_meta` in ONE transaction BEFORE any memory changes. A throw
8978
+ * rejects the whole reload — the caller keeps the previous config — so
8979
+ * disk and memory can never diverge; a failed reload simply never
8980
+ * happened, and no later restart can misread it. (A swallow-and-continue
8981
+ * posture here would let an A→B reload with a failed flush resurrect the
8982
+ * retired A pot after a revert-and-restart.)
8983
+ *
8984
+ * Removed names mint too: an in-flight charge frozen before the removal
8985
+ * must go stale, or its commit would recreate hidden bucket state for a
8986
+ * budget that no longer exists — and without the on-disk tombstone, a
8987
+ * restart with the budget back in the config would resurrect the
8988
+ * pre-removal pot that the removal had reset. Generations for removed
8989
+ * names are kept (not deleted) so a later re-add keeps counting up.
8990
+ *
8991
+ * @throws When the epoch flush fails; the engine is unchanged.
8992
+ */
8993
+ reconcile(next) {
8994
+ const nextByName = new Map(next.map((budget) => [budget.name, budget]));
8995
+ const mints = [];
8996
+ for (const [name, budget] of nextByName) {
8997
+ const current = this.budgets.get(name);
8998
+ if (!current || tupleChanged(current, budget)) {
8999
+ mints.push({ name, tuple: budget, epoch: this.nextEpoch(name) });
9000
+ }
9001
+ }
9002
+ for (const [name, removed] of this.budgets) {
9003
+ if (nextByName.has(name)) continue;
9004
+ mints.push({ name, tuple: removed, epoch: this.nextEpoch(name) });
9005
+ }
9006
+ if (this.persistence && mints.length > 0) {
9007
+ this.persistence.writeMetaBatch(mints.map((mint) => metaOf(mint.tuple, mint.epoch)));
9008
+ }
9009
+ for (const mint of mints) {
9010
+ this.state.delete(mint.name);
9011
+ this.generations.set(mint.name, mint.epoch);
9012
+ }
9013
+ this.budgets = nextByName;
9014
+ }
9015
+ /** Sweep: collect idle session pots, evict expired duration entries. */
9016
+ gc() {
9017
+ const nowMs = this.now();
9018
+ for (const [name, buckets] of this.state) {
9019
+ const budget = this.budgets.get(name);
9020
+ if (!budget) {
9021
+ this.state.delete(name);
9022
+ continue;
9023
+ }
9024
+ for (const [key, bucket] of buckets) {
9025
+ if (budget.window.kind === "session") {
9026
+ if (nowMs - bucket.lastActivityMs > budget.window.idleTtlMs) {
9027
+ if (this.persistence) {
9028
+ try {
9029
+ this.persistence.recordBucketGc(name, key, nowMs);
9030
+ } catch (err) {
9031
+ console.error(
9032
+ `[helio] Budget "${name}": failed to record the GC watermark for "${key}"; keeping the idle pot until the next sweep:`,
9033
+ err
9034
+ );
9035
+ continue;
9036
+ }
9037
+ }
9038
+ buckets.delete(key);
9039
+ }
9040
+ } else {
9041
+ this.evictExpired(bucket, budget.window.windowMs, nowMs);
9042
+ if (bucket.entries.length === 0) buckets.delete(key);
9043
+ }
9044
+ }
9045
+ if (buckets.size === 0) this.state.delete(name);
9046
+ }
9047
+ }
9048
+ // -------------------------------------------------------------------------
9049
+ // Read surface
9050
+ // -------------------------------------------------------------------------
9051
+ /**
9052
+ * Wire-ready state for `GET /api/budgets`. Configured budgets appear even
9053
+ * with zero live buckets, so the dashboard shows every pot at headroom.
9054
+ */
9055
+ listStates() {
9056
+ const nowMs = this.now();
9057
+ return [...this.budgets.values()].map((budget) => {
9058
+ const buckets = [];
9059
+ for (const key of [...this.state.get(budget.name)?.keys() ?? []]) {
9060
+ const bucket = this.liveBucket(budget, key, nowMs);
9061
+ if (!bucket) continue;
9062
+ const spent = this.spentOf(budget, bucket, nowMs);
9063
+ buckets.push({
9064
+ bucket_key: key,
9065
+ spent,
9066
+ remaining: Math.max(0, budget.limit - spent),
9067
+ reset_at_ms: budget.window.kind === "duration" ? (bucket.entries[0]?.timestampMs ?? nowMs) + budget.window.windowMs : null,
9068
+ last_activity_ms: bucket.lastActivityMs
9069
+ });
9070
+ }
9071
+ return {
9072
+ name: budget.name,
9073
+ limit: budget.limit,
9074
+ currency: budget.currency,
9075
+ window: budget.windowRaw,
9076
+ key: budget.key,
9077
+ on_exceed: budget.onExceed,
9078
+ buckets
9079
+ };
9080
+ });
9081
+ }
9082
+ /** Whether any budget holds a live bucket under `key` (cardinality probes). */
9083
+ hasBucket(key) {
9084
+ const nowMs = this.now();
9085
+ for (const name of [...this.state.keys()]) {
9086
+ const budget = this.budgets.get(name);
9087
+ if (!budget) continue;
9088
+ if (this.liveBucket(budget, key, nowMs)) return true;
9089
+ }
9090
+ return false;
9091
+ }
9092
+ close() {
9093
+ if (this.closed) return;
9094
+ this.closed = true;
9095
+ if (this.timer) {
9096
+ clearInterval(this.timer);
9097
+ this.timer = null;
9098
+ }
9099
+ this.state.clear();
9100
+ }
9101
+ // -------------------------------------------------------------------------
9102
+ // Internals
9103
+ // -------------------------------------------------------------------------
9104
+ /**
9105
+ * The next epoch for a name: one past the highest that memory, the meta
9106
+ * row, or the rows themselves have seen. Pure — the caller applies it to
9107
+ * `generations` only after the mint is durable. The meta consult matters
9108
+ * for names this process has no memory of (a hot-reload re-add after a
9109
+ * restart); the rows consult is a backstop against historical divergence
9110
+ * (rows at an epoch no meta row records) — minting from memory or meta
9111
+ * alone could collide into an epoch that already has rows and replay them
9112
+ * into a different pot.
9113
+ */
9114
+ nextEpoch(name) {
9115
+ const memory = this.generations.get(name) ?? 0;
9116
+ const disk = this.persistence ? Math.max(this.persistence.readMeta(name)?.epoch ?? 0, this.persistence.maxEventEpoch(name)) : 0;
9117
+ return Math.max(memory, disk) + 1;
9118
+ }
9119
+ /**
9120
+ * The key format is part of the ON-DISK contract: hydrate rebuilds buckets
9121
+ * from `budget_events.bucket_key` verbatim, so renaming any segment here
9122
+ * would strand every persisted bucket of an unchanged tuple as an
9123
+ * unreachable ghost (displayed, never charged). Changing the format
9124
+ * requires folding a format version into the epoch decision.
9125
+ */
9126
+ bucketKey(budget, ctx) {
9127
+ switch (budget.key) {
9128
+ case "session":
9129
+ return `budget:${budget.name}:session:${ctx.sessionId ?? "unknown"}`;
9130
+ case "sender_id":
9131
+ return `budget:${budget.name}:sender:${ctx.senderId ?? "unknown"}`;
9132
+ case "global":
9133
+ return `budget:${budget.name}:global`;
9134
+ }
9135
+ }
9136
+ bucketFor(name, key) {
9137
+ let buckets = this.state.get(name);
9138
+ if (!buckets) {
9139
+ buckets = /* @__PURE__ */ new Map();
9140
+ this.state.set(name, buckets);
9141
+ }
9142
+ let bucket = buckets.get(key);
9143
+ if (!bucket) {
9144
+ bucket = { entries: [], total: 0, lastActivityMs: this.now() };
9145
+ buckets.set(key, bucket);
9146
+ }
9147
+ return bucket;
9148
+ }
9149
+ evictExpired(bucket, windowMs, nowMs) {
9150
+ const windowStart = nowMs - windowMs;
9151
+ bucket.entries = bucket.entries.filter((entry) => entry.timestampMs > windowStart);
9152
+ }
9153
+ /**
9154
+ * Fetch a bucket for reading, evicting expired duration entries first and
9155
+ * pruning the bucket if nothing is left. Reads must never see (or keep
9156
+ * alive, via `hasBucket`-driven capacity slots) state the window has
9157
+ * already expired — expiry is lazy on read, not just on the sweep timer.
9158
+ */
9159
+ liveBucket(budget, key, nowMs) {
9160
+ const buckets = this.state.get(budget.name);
9161
+ const bucket = buckets?.get(key);
9162
+ if (!bucket || !buckets) return void 0;
9163
+ if (budget.window.kind === "duration") {
9164
+ this.evictExpired(bucket, budget.window.windowMs, nowMs);
9165
+ if (bucket.entries.length === 0) {
9166
+ buckets.delete(key);
9167
+ if (buckets.size === 0) this.state.delete(budget.name);
9168
+ return void 0;
9169
+ }
9170
+ }
9171
+ return bucket;
9172
+ }
9173
+ spentOf(budget, bucket, nowMs) {
9174
+ if (budget.window.kind === "session") return bucket.total;
9175
+ const windowStart = nowMs - budget.window.windowMs;
9176
+ return bucket.entries.reduce(
9177
+ (sum, entry) => entry.timestampMs > windowStart ? sum + entry.amount : sum,
9178
+ 0
9179
+ );
9180
+ }
9181
+ snapshot(charge, options = {}) {
9182
+ const nowMs = this.now();
9183
+ const bucket = this.liveBucket(charge.budget, charge.bucketKey, nowMs);
9184
+ const accrued = bucket ? this.spentOf(charge.budget, bucket, nowMs) : 0;
9185
+ const spent = accrued;
9186
+ const checkedAgainst = options.postRecord ? accrued - charge.amount : accrued;
9187
+ const resetAtMs = charge.budget.window.kind === "duration" ? bucket && bucket.entries.length > 0 ? (bucket.entries[0]?.timestampMs ?? nowMs) + charge.budget.window.windowMs : nowMs + charge.budget.window.windowMs : null;
9188
+ return {
9189
+ budget: charge.budget,
9190
+ bucketKey: charge.bucketKey,
9191
+ amount: charge.amount,
9192
+ allowed: checkedAgainst + charge.amount <= charge.budget.limit,
9193
+ spent,
9194
+ remaining: Math.max(0, charge.budget.limit - spent),
9195
+ resetAtMs
9196
+ };
9197
+ }
9198
+ };
9199
+ function metaOf(budget, epoch) {
9200
+ return {
9201
+ budget_name: budget.name,
9202
+ limit_amount: budget.limit,
9203
+ currency: budget.currency,
9204
+ window: budget.windowRaw,
9205
+ key: budget.key,
9206
+ epoch
9207
+ };
9208
+ }
9209
+ function metaTupleChanged(meta, budget) {
9210
+ return meta.limit_amount !== budget.limit || meta.currency !== budget.currency || meta.window !== budget.windowRaw || meta.key !== budget.key;
9211
+ }
9212
+ function tupleChanged(a, b) {
9213
+ return a.limit !== b.limit || a.currency !== b.currency || a.windowRaw !== b.windowRaw || a.key !== b.key;
9214
+ }
9215
+
9216
+ // src/budget/ledger.ts
9217
+ import Database2 from "better-sqlite3";
9218
+ import { randomUUID as randomUUID7 } from "crypto";
9219
+ var CREATE_TABLES_DDL = `
9220
+ CREATE TABLE IF NOT EXISTS budget_meta (
9221
+ budget_name TEXT PRIMARY KEY,
9222
+ limit_amount REAL NOT NULL,
9223
+ currency TEXT NOT NULL,
9224
+ window TEXT NOT NULL,
9225
+ key TEXT NOT NULL,
9226
+ epoch INTEGER NOT NULL,
9227
+ updated_at TEXT NOT NULL
9228
+ );
9229
+
9230
+ CREATE TABLE IF NOT EXISTS budget_events (
9231
+ id TEXT PRIMARY KEY,
9232
+ budget_name TEXT NOT NULL,
9233
+ epoch INTEGER NOT NULL,
9234
+ bucket_key TEXT NOT NULL,
9235
+ kind TEXT NOT NULL,
9236
+ amount REAL NOT NULL,
9237
+ currency TEXT NOT NULL,
9238
+ tool_name TEXT NOT NULL,
9239
+ origin TEXT NOT NULL,
9240
+ audit_record_id TEXT,
9241
+ timestamp TEXT NOT NULL,
9242
+ timestamp_ms INTEGER NOT NULL,
9243
+ created_at TEXT NOT NULL
9244
+ );
9245
+
9246
+ CREATE TABLE IF NOT EXISTS budget_bucket_gc (
9247
+ budget_name TEXT NOT NULL,
9248
+ bucket_key TEXT NOT NULL,
9249
+ gc_after_ms INTEGER NOT NULL,
9250
+ PRIMARY KEY (budget_name, bucket_key)
9251
+ );
9252
+ `;
9253
+ var CREATE_INDEX_DDL2 = `
9254
+ CREATE INDEX IF NOT EXISTS idx_budget_events_replay
9255
+ ON budget_events (budget_name, epoch, bucket_key, timestamp_ms);
9256
+ CREATE INDEX IF NOT EXISTS idx_budget_events_timestamp_ms
9257
+ ON budget_events (timestamp_ms);
9258
+ `;
9259
+ var BUDGET_TABLES = ["budget_meta", "budget_events", "budget_bucket_gc"];
9260
+ var INSERT_EVENT_SQL = `
9261
+ INSERT INTO budget_events (
9262
+ id, budget_name, epoch, bucket_key, kind, amount, currency,
9263
+ tool_name, origin, audit_record_id, timestamp, timestamp_ms, created_at
9264
+ ) VALUES (
9265
+ @id, @budget_name, @epoch, @bucket_key, @kind, @amount, @currency,
9266
+ @tool_name, @origin, @audit_record_id, @timestamp, @timestamp_ms, @created_at
9267
+ )
9268
+ `;
9269
+ var UPSERT_META_SQL = `
9270
+ INSERT INTO budget_meta (budget_name, limit_amount, currency, window, key, epoch, updated_at)
9271
+ VALUES (@budget_name, @limit_amount, @currency, @window, @key, @epoch, @updated_at)
9272
+ ON CONFLICT (budget_name) DO UPDATE SET
9273
+ limit_amount = excluded.limit_amount,
9274
+ currency = excluded.currency,
9275
+ window = excluded.window,
9276
+ key = excluded.key,
9277
+ epoch = excluded.epoch,
9278
+ updated_at = excluded.updated_at
9279
+ `;
9280
+ var UPSERT_GC_SQL = `
9281
+ INSERT INTO budget_bucket_gc (budget_name, bucket_key, gc_after_ms)
9282
+ VALUES (@budget_name, @bucket_key, @gc_after_ms)
9283
+ ON CONFLICT (budget_name, bucket_key) DO UPDATE SET
9284
+ gc_after_ms = excluded.gc_after_ms
9285
+ `;
9286
+ var REPLAY_DURATION_SQL = `
9287
+ SELECT bucket_key, amount, timestamp_ms
9288
+ FROM budget_events
9289
+ WHERE budget_name = ? AND epoch = ? AND timestamp_ms > ?
9290
+ ORDER BY timestamp_ms ASC, rowid ASC
9291
+ `;
9292
+ var REPLAY_SESSION_SQL = `
9293
+ SELECT e.bucket_key AS bucket_key,
9294
+ SUM(CASE WHEN e.timestamp_ms >= COALESCE(g.gc_after_ms, 0) THEN e.amount ELSE 0 END) AS total,
9295
+ MAX(e.timestamp_ms) AS last_activity_ms
9296
+ FROM budget_events e
9297
+ LEFT JOIN budget_bucket_gc g
9298
+ ON g.budget_name = e.budget_name AND g.bucket_key = e.bucket_key
9299
+ WHERE e.budget_name = ? AND e.epoch = ?
9300
+ GROUP BY e.bucket_key
9301
+ ORDER BY e.bucket_key ASC
9302
+ `;
9303
+ var LIST_EVENTS_SQL = `
9304
+ SELECT id, budget_name, bucket_key, kind, amount, currency, tool_name,
9305
+ origin, audit_record_id, timestamp, timestamp_ms, created_at
9306
+ FROM budget_events
9307
+ WHERE budget_name = ?
9308
+ ORDER BY timestamp_ms DESC, rowid DESC
9309
+ LIMIT ? OFFSET ?
9310
+ `;
9311
+ var COUNT_EVENTS_SQL = "SELECT COUNT(*) AS total FROM budget_events WHERE budget_name = ?";
9312
+ var LIST_EVENTS_DEFAULT_LIMIT = 50;
9313
+ function describeColumn(column) {
9314
+ return `"${column.type}${column.notnull ? " NOT NULL" : ""}${column.pk ? " PRIMARY KEY" : ""}"`;
9315
+ }
9316
+ var BudgetLedger = class {
9317
+ db;
9318
+ now;
9319
+ insertEventStmt;
9320
+ upsertMetaStmt;
9321
+ upsertGcStmt;
9322
+ readMetaStmt;
9323
+ readAllMetaStmt;
9324
+ maxEventEpochStmt;
9325
+ replayDurationStmt;
9326
+ replaySessionStmt;
9327
+ listEventsStmt;
9328
+ countEventsStmt;
9329
+ commitTxn;
9330
+ writeMetaTxn;
9331
+ purgeTxn;
9332
+ constructor(options) {
9333
+ this.db = options.database;
9334
+ this.now = options.now ?? Date.now;
9335
+ this.db.exec(CREATE_TABLES_DDL);
9336
+ this.assertRequiredSchema();
9337
+ this.db.exec(CREATE_INDEX_DDL2);
9338
+ this.insertEventStmt = this.db.prepare(INSERT_EVENT_SQL);
9339
+ this.upsertMetaStmt = this.db.prepare(UPSERT_META_SQL);
9340
+ this.upsertGcStmt = this.db.prepare(UPSERT_GC_SQL);
9341
+ this.readMetaStmt = this.db.prepare(
9342
+ "SELECT budget_name, limit_amount, currency, window, key, epoch FROM budget_meta WHERE budget_name = ?"
9343
+ );
9344
+ this.readAllMetaStmt = this.db.prepare(
9345
+ "SELECT budget_name, limit_amount, currency, window, key, epoch FROM budget_meta"
9346
+ );
9347
+ this.maxEventEpochStmt = this.db.prepare(
9348
+ "SELECT COALESCE(MAX(epoch), 0) AS epoch FROM budget_events WHERE budget_name = ?"
9349
+ );
9350
+ this.replayDurationStmt = this.db.prepare(REPLAY_DURATION_SQL);
9351
+ this.replaySessionStmt = this.db.prepare(REPLAY_SESSION_SQL);
9352
+ this.listEventsStmt = this.db.prepare(LIST_EVENTS_SQL);
9353
+ this.countEventsStmt = this.db.prepare(COUNT_EVENTS_SQL);
9354
+ const purgeEventsStmt = this.db.prepare("DELETE FROM budget_events WHERE timestamp_ms < ?");
9355
+ const purgeGcStmt = this.db.prepare("DELETE FROM budget_bucket_gc WHERE gc_after_ms < ?");
9356
+ this.purgeTxn = this.db.transaction((cutoffMs) => ({
9357
+ events: purgeEventsStmt.run(cutoffMs).changes,
9358
+ watermarks: purgeGcStmt.run(cutoffMs).changes
9359
+ }));
9360
+ this.writeMetaTxn = this.db.transaction((metas) => {
9361
+ for (const meta of metas) this.writeMeta(meta);
9362
+ });
9363
+ this.commitTxn = this.db.transaction((rows) => {
9364
+ const createdAt = new Date(this.now()).toISOString();
9365
+ for (const row of rows) {
9366
+ this.insertEventStmt.run({
9367
+ id: randomUUID7(),
9368
+ budget_name: row.budget_name,
9369
+ epoch: row.generation,
9370
+ bucket_key: row.bucket_key,
9371
+ kind: row.kind,
9372
+ amount: row.amount,
9373
+ currency: row.currency,
9374
+ tool_name: row.tool_name,
9375
+ origin: row.origin,
9376
+ audit_record_id: row.audit_record_id,
9377
+ timestamp: row.timestamp,
9378
+ timestamp_ms: row.timestamp_ms,
9379
+ created_at: createdAt
9380
+ });
9381
+ }
9382
+ });
9383
+ }
9384
+ /**
9385
+ * Validate the on-disk budget schema against the canonical DDL — column
9386
+ * NAMES, declared TYPES, NOT NULL constraints, and PRIMARY KEYS — with the
9387
+ * same clean-break recovery contract as the audit table (store.ts):
9388
+ * pre-1.0 local databases are deleted, not migrated. Types matter beyond
9389
+ * presence: a `timestamp_ms` with TEXT affinity would make every replay
9390
+ * and retention comparison lexicographic ('900' > '1000'), silently
9391
+ * resurrecting expired spend. The canonical shape is derived by executing
9392
+ * the DDL itself in a scratch database, so the assertion cannot drift
9393
+ * from what the DDL creates. Extra columns in the live table are
9394
+ * tolerated (forward compatibility, matching the audit store's posture).
9395
+ */
9396
+ assertRequiredSchema() {
9397
+ const canonical = new Database2(":memory:");
9398
+ let mismatches;
9399
+ try {
9400
+ canonical.exec(CREATE_TABLES_DDL);
9401
+ mismatches = [];
9402
+ for (const table of BUDGET_TABLES) {
9403
+ const expected = canonical.pragma(`table_info(${table})`);
9404
+ const live = new Map(
9405
+ this.db.pragma(`table_info(${table})`).map((col) => [col.name, col])
9406
+ );
9407
+ for (const column of expected) {
9408
+ const actual = live.get(column.name);
9409
+ if (!actual) {
9410
+ mismatches.push(`${table}.${column.name} (missing)`);
9411
+ continue;
9412
+ }
9413
+ if (actual.type !== column.type || actual.notnull !== column.notnull || actual.pk !== column.pk) {
9414
+ mismatches.push(
9415
+ `${table}.${column.name} (found ${describeColumn(actual)}, expected ${describeColumn(column)})`
9416
+ );
9417
+ }
9418
+ }
9419
+ }
9420
+ } finally {
9421
+ canonical.close();
9422
+ }
9423
+ if (mismatches.length === 0) return;
9424
+ const dbPath = this.db.name;
9425
+ throw new Error(
9426
+ `[helio] Budget ledger schema mismatch: incompatible columns ${mismatches.join(", ")}. This local database was created by an older Helio build. Delete "${dbPath}", "${dbPath}-wal", and "${dbPath}-shm", then restart Helio.`
9427
+ );
9428
+ }
9429
+ // -------------------------------------------------------------------------
9430
+ // BudgetPersistence
9431
+ // -------------------------------------------------------------------------
9432
+ /** Persist every row of one call in a single transaction (all or nothing). */
9433
+ commitAll(rows) {
9434
+ this.commitTxn(rows);
9435
+ }
9436
+ readMeta(budgetName) {
9437
+ return this.readMetaStmt.get(budgetName);
9438
+ }
9439
+ readAllMeta() {
9440
+ return this.readAllMetaStmt.all();
9441
+ }
9442
+ maxEventEpoch(budgetName) {
9443
+ const { epoch } = this.maxEventEpochStmt.get(budgetName);
9444
+ return epoch;
9445
+ }
9446
+ writeMeta(meta) {
9447
+ this.upsertMetaStmt.run({
9448
+ budget_name: meta.budget_name,
9449
+ limit_amount: meta.limit_amount,
9450
+ currency: meta.currency,
9451
+ window: meta.window,
9452
+ key: meta.key,
9453
+ epoch: meta.epoch,
9454
+ updated_at: new Date(this.now()).toISOString()
9455
+ });
9456
+ }
9457
+ /** All of one reload's epoch mints in a single transaction (all or nothing). */
9458
+ writeMetaBatch(metas) {
9459
+ this.writeMetaTxn(metas);
9460
+ }
9461
+ replayDurationEvents(budgetName, epoch, sinceMs) {
9462
+ return this.replayDurationStmt.all(budgetName, epoch, sinceMs);
9463
+ }
9464
+ replaySessionBuckets(budgetName, epoch) {
9465
+ return this.replaySessionStmt.all(budgetName, epoch);
9466
+ }
9467
+ recordBucketGc(budgetName, bucketKey, gcAfterMs) {
9468
+ this.upsertGcStmt.run({
9469
+ budget_name: budgetName,
9470
+ bucket_key: bucketKey,
9471
+ gc_after_ms: gcAfterMs
9472
+ });
9473
+ }
9474
+ // -------------------------------------------------------------------------
9475
+ // Dashboard read surface
9476
+ // -------------------------------------------------------------------------
9477
+ /**
9478
+ * One page of a budget's spend history for the dashboard, newest first.
9479
+ * `limit` defaults to 50 and clamps to `LIST_MAX_PAGE_SIZE`; `offset`
9480
+ * floors at 0. An unknown budget name simply lists nothing (names are
9481
+ * config, not secrets — no 404 semantics on hot-reload races).
9482
+ */
9483
+ listEvents(budgetName, page) {
9484
+ const limit = Math.min(
9485
+ Math.max(Math.trunc(page.limit ?? LIST_EVENTS_DEFAULT_LIMIT), 1),
9486
+ LIST_MAX_PAGE_SIZE
9487
+ );
9488
+ const offset = Math.max(Math.trunc(page.offset ?? 0), 0);
9489
+ const events = this.listEventsStmt.all(budgetName, limit, offset);
9490
+ const { total } = this.countEventsStmt.get(budgetName);
9491
+ return { events, total };
9492
+ }
9493
+ // -------------------------------------------------------------------------
9494
+ // Retention
9495
+ // -------------------------------------------------------------------------
9496
+ /**
9497
+ * Purge events past the retention cutoff (event-time milliseconds — the
9498
+ * same axis every replay query filters on, so the retention bound and the
9499
+ * replay bound cannot diverge). Watermarks older than the cutoff prune
9500
+ * with the same statement's cutoff: every row such a watermark could
9501
+ * filter has `timestamp_ms <= gc_after_ms < cutoffMs` and is deleted here
9502
+ * too, so the watermark guards nothing and is safe to drop.
9503
+ *
9504
+ * Called from the audit store's retention sweep (one sweep schedule); the
9505
+ * cutoff is computed once per sweep by the store.
9506
+ */
9507
+ purgeExpired(cutoffMs) {
9508
+ return this.purgeTxn(cutoffMs);
9509
+ }
9510
+ };
9511
+
7491
9512
  // src/dashboard/api.ts
7492
9513
  import { readFileSync } from "fs";
7493
9514
  import { join } from "path";
7494
- import { randomUUID as randomUUID6 } from "crypto";
9515
+ import { randomUUID as randomUUID8 } from "crypto";
7495
9516
  import { Hono as Hono8 } from "hono";
7496
9517
  import { HTTPException as HTTPException2 } from "hono/http-exception";
7497
9518
  import { z as z8 } from "zod";
@@ -7710,6 +9731,10 @@ var auditQuerySchema = z8.object({
7710
9731
  channel_id: optionalQueryString,
7711
9732
  sender_id: optionalQueryString
7712
9733
  });
9734
+ var budgetEventsQuerySchema = z8.object({
9735
+ limit: clampedQueryInt(50, 1, LIST_MAX_PAGE_SIZE),
9736
+ offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER)
9737
+ });
7713
9738
  var analyticsQuerySchema = z8.object({
7714
9739
  from: optionalQueryString,
7715
9740
  to: optionalQueryString
@@ -7782,7 +9807,8 @@ function createDashboardAppWithLifecycle(deps, options) {
7782
9807
  spendLimiter,
7783
9808
  evidenceStore,
7784
9809
  eventBus,
7785
- adapterLiveness
9810
+ adapterLiveness,
9811
+ budgets
7786
9812
  } = deps;
7787
9813
  const apiSecret = options?.apiSecret;
7788
9814
  const sessionStore = apiSecret ? new DashboardSessionStore({ secret: apiSecret, ttlMs: SESSION_TTL_MS }) : void 0;
@@ -8004,6 +10030,22 @@ function createDashboardAppWithLifecycle(deps, options) {
8004
10030
  app.get("/api/adapters", (c) => {
8005
10031
  return c.json({ adapters: adapterLiveness?.listAdapters() ?? [] });
8006
10032
  });
10033
+ app.get("/api/budgets", (c) => {
10034
+ return c.json({ budgets: budgets?.listStates() ?? [] });
10035
+ });
10036
+ app.get("/api/budgets/:name/events", (c) => {
10037
+ const query = budgetEventsQuerySchema.parse(c.req.query());
10038
+ const page = budgets?.listEvents(c.req.param("name"), {
10039
+ limit: query.limit,
10040
+ offset: query.offset
10041
+ }) ?? { events: [], total: 0 };
10042
+ return c.json({
10043
+ data: page.events,
10044
+ total: page.total,
10045
+ limit: query.limit,
10046
+ offset: query.offset
10047
+ });
10048
+ });
8007
10049
  app.get("/api/analytics", (c) => {
8008
10050
  const query = analyticsQuerySchema.parse(c.req.query());
8009
10051
  const now = /* @__PURE__ */ new Date();
@@ -8045,7 +10087,7 @@ function createDashboardAppWithLifecycle(deps, options) {
8045
10087
  app.get("/api/events", (c) => {
8046
10088
  return streamSSE(c, async (stream) => {
8047
10089
  if (closed) return;
8048
- const connId = randomUUID6();
10090
+ const connId = randomUUID8();
8049
10091
  let streamClosed = false;
8050
10092
  let stopHeartbeat = () => {
8051
10093
  };
@@ -8074,7 +10116,7 @@ function createDashboardAppWithLifecycle(deps, options) {
8074
10116
  void stream.writeSSE({
8075
10117
  event: eventType,
8076
10118
  data: JSON.stringify(data),
8077
- id: randomUUID6()
10119
+ id: randomUUID8()
8078
10120
  }).then(() => {
8079
10121
  const conn = activeConnections.get(connId);
8080
10122
  if (conn) conn.lastWrite = Date.now();
@@ -8120,7 +10162,9 @@ var EVENT_TYPES = [
8120
10162
  "approval_requested",
8121
10163
  "approval_resolved",
8122
10164
  "limit_warning",
8123
- "approval_notification_failed"
10165
+ "approval_notification_failed",
10166
+ "budget_update",
10167
+ "budget_breached"
8124
10168
  ];
8125
10169
  var DashboardEventBus = class {
8126
10170
  emitter = new EventEmitter();
@@ -8169,6 +10213,20 @@ var DashboardEventBus = class {
8169
10213
  function isLoopbackHost2(host) {
8170
10214
  return host === "127.0.0.1" || host === "localhost" || host === "::1";
8171
10215
  }
10216
+ function warnIfBudgetWindowExceedsRetention(config, log = console.error) {
10217
+ const retentionMs = parseDuration(config.audit.retention);
10218
+ let warned = false;
10219
+ for (const budget of config.budgets) {
10220
+ const horizonMs = budget.window === "session" ? parseDuration(budget.idle_ttl ?? "24h") : parseDuration(budget.window);
10221
+ const horizonLabel = budget.window === "session" ? `idle_ttl ${budget.idle_ttl ?? "24h"}` : `window ${budget.window}`;
10222
+ if (horizonMs <= retentionMs) continue;
10223
+ log(
10224
+ `[helio] Warning: budget "${budget.name}" has ${horizonLabel}, longer than audit.retention ${config.audit.retention}. The spend ledger is purged on the retention sweep, so a restart can forget in-window spend and re-open the pot. Raise audit.retention or shorten the budget window.`
10225
+ );
10226
+ warned = true;
10227
+ }
10228
+ return warned;
10229
+ }
8172
10230
  function warnIfWebhookChannelUnreachable(config, log = console.error) {
8173
10231
  const hasWebhookChannel = config.approval.channels.some((ch) => ch.type === "webhook");
8174
10232
  const localOnlyDashboard = config.dashboard.enabled && isLoopbackHost2(config.dashboard.host);
@@ -8202,6 +10260,26 @@ function warnIfNoEnforcement(policy, log = console.error) {
8202
10260
  return true;
8203
10261
  }
8204
10262
 
10263
+ // src/shutdown.ts
10264
+ async function closeResources(resources) {
10265
+ resources.annotationPrime?.stop();
10266
+ resources.configWatcher?.close();
10267
+ resources.approvalRouter?.close();
10268
+ resources.approvalQueue?.close();
10269
+ resources.closeDashboardApp?.();
10270
+ resources.eventBus?.close();
10271
+ if (resources.dashboardHandle) await resources.dashboardHandle.close();
10272
+ if (resources.sidebandHandle) await resources.sidebandHandle.close();
10273
+ await resources.handle.close();
10274
+ resources.governanceService?.close();
10275
+ resources.rateLimiter?.close();
10276
+ resources.spendLimiter?.close();
10277
+ resources.budgetEngine?.close();
10278
+ resources.evidenceStore?.close();
10279
+ resources.auditWriter?.close();
10280
+ if (resources.closeForwarder) await resources.closeForwarder();
10281
+ }
10282
+
8205
10283
  // src/crash-drain.ts
8206
10284
  var hooks = [];
8207
10285
  var draining = false;
@@ -8427,12 +10505,18 @@ async function startCommand(configPath, options) {
8427
10505
  const label = w.ruleName ? `rule "${w.ruleName}"` : `rule ${String(w.ruleIndex)}`;
8428
10506
  console.error(`Warning: policy ${label}: ${w.message}`);
8429
10507
  }
10508
+ const budgets = compileBudgets(config.budgets);
8430
10509
  const eventBus = new DashboardEventBus();
8431
10510
  const auditStore = new AuditStore({
8432
10511
  path: config.audit.path,
8433
10512
  retention: config.audit.retention,
8434
10513
  includeResponses: config.audit.include_responses
8435
10514
  });
10515
+ const budgetLedger = new BudgetLedger({ database: auditStore.database });
10516
+ auditStore.onRetentionSweep((cutoff) => {
10517
+ budgetLedger.purgeExpired(cutoff.ms);
10518
+ });
10519
+ auditStore.runRetentionSweep();
8436
10520
  const auditWriter = new AuditWriter({
8437
10521
  store: auditStore,
8438
10522
  onPersist: (record, id) => {
@@ -8468,6 +10552,7 @@ async function startCommand(configPath, options) {
8468
10552
  const evidenceStore = new EvidenceStore();
8469
10553
  const approvalQueue = new ApprovalQueue();
8470
10554
  const channels = createChannels(config.approval.channels);
10555
+ const runtimeChannelTypes = new Map([...channels].map(([key, ch]) => [key, ch.type]));
8471
10556
  const approvalRouter = new ApprovalRouter({
8472
10557
  defaultTimeoutMs: parseDuration(config.approval.timeout),
8473
10558
  defaultOnTimeout: config.approval.default_on_timeout,
@@ -8515,13 +10600,25 @@ async function startCommand(configPath, options) {
8515
10600
  });
8516
10601
  }
8517
10602
  });
10603
+ const budgetEngine = new BudgetEngine({
10604
+ budgets,
10605
+ ledger: budgetLedger,
10606
+ onCommit: (event) => {
10607
+ eventBus.emit("budget_update", event);
10608
+ },
10609
+ onBreach: (event) => {
10610
+ eventBus.emit("budget_breached", event);
10611
+ }
10612
+ });
10613
+ budgetEngine.hydrate();
8518
10614
  const governedForwarder = new GovernedForwarder(forwarder, policy, {
8519
10615
  environment: config.environment,
8520
10616
  auditWriter,
8521
10617
  evidenceStore,
8522
10618
  approvalRouter,
8523
10619
  rateLimiter,
8524
- spendLimiter
10620
+ spendLimiter,
10621
+ budgetEngine
8525
10622
  });
8526
10623
  const annotationPrime = await startAnnotationPrimeLoop(governedForwarder);
8527
10624
  const hasSlackChannels = [...channels.values()].some((ch) => ch.type === "slack");
@@ -8560,6 +10657,7 @@ async function startCommand(configPath, options) {
8560
10657
  approvalRouter,
8561
10658
  rateLimiter,
8562
10659
  spendLimiter,
10660
+ budgetEngine,
8563
10661
  auditWriter,
8564
10662
  approvalTimeoutMs: parseDuration(config.approval.timeout),
8565
10663
  ttlMs: parseDuration(config.sdk.evaluation_ttl)
@@ -8585,7 +10683,13 @@ async function startCommand(configPath, options) {
8585
10683
  eventBus,
8586
10684
  // Adapter liveness for GET /api/adapters (issue #126); undefined
8587
10685
  // unless the SDK sideband is enabled → endpoint serves an empty list.
8588
- adapterLiveness: governanceService
10686
+ adapterLiveness: governanceService,
10687
+ // Budget read surface (issue #14): live pot states from the engine,
10688
+ // spend history from the ledger.
10689
+ budgets: {
10690
+ listStates: () => budgetEngine.listStates(),
10691
+ listEvents: (name, page) => budgetLedger.listEvents(name, page)
10692
+ }
8589
10693
  },
8590
10694
  {
8591
10695
  apiSecret: config.dashboard.api_secret,
@@ -8637,12 +10741,17 @@ async function startCommand(configPath, options) {
8637
10741
  warnIfWebhookChannelUnreachable(config);
8638
10742
  warnIfSdkSidebandExposed(config);
8639
10743
  warnIfDashboardOpenMode(config);
10744
+ warnIfBudgetWindowExceedsRetention(config);
8640
10745
  const channelCount = config.approval.channels.length;
8641
10746
  console.error(
8642
10747
  `Approvals: timeout ${config.approval.timeout}, default on timeout: ${config.approval.default_on_timeout}, ${String(channelCount)} channel${channelCount !== 1 ? "s" : ""} configured`
8643
10748
  );
8644
10749
  console.error(`Rate limits: enabled`);
8645
10750
  console.error(`Spend limits: enabled`);
10751
+ const budgetCount = budgets.length;
10752
+ console.error(
10753
+ `Budgets: ${String(budgetCount)} configured${budgetCount > 0 ? ` (${budgets.map((b) => b.name).join(", ")})` : ""}`
10754
+ );
8646
10755
  if (policy.dryRun) {
8647
10756
  console.error(`Dry-run: ENABLED (no requests will be forwarded to upstream)`);
8648
10757
  }
@@ -8653,9 +10762,24 @@ async function startCommand(configPath, options) {
8653
10762
  configWatcher = new ConfigWatcher({
8654
10763
  configPath,
8655
10764
  initialConfig: config,
8656
- onPolicyReload: (newPolicy, reloadWarnings, restartRequiredPaths) => {
10765
+ onReload: (newPolicy, reloadWarnings, restartRequiredPaths, newBudgets) => {
10766
+ const unroutable = findUnroutableApprovalReferences(newPolicy, newBudgets, {
10767
+ channelTypes: runtimeChannelTypes,
10768
+ dashboardEnabled: config.dashboard.enabled,
10769
+ defaultApprovalTimeoutMs: parseDuration(config.approval.timeout)
10770
+ });
10771
+ if (unroutable.length > 0) {
10772
+ throw new Error(
10773
+ `approval routing is not available in the running process (restart required to apply approval.channels/dashboard changes): ${unroutable.join("; ")}`
10774
+ );
10775
+ }
10776
+ budgetEngine.reconcile(newBudgets);
8657
10777
  governedForwarder.updatePolicy(newPolicy);
8658
10778
  governanceService?.updatePolicy(newPolicy);
10779
+ const budgetTotal = newBudgets.length;
10780
+ console.error(
10781
+ `[helio] Budgets reloaded: ${String(budgetTotal)} budget${budgetTotal !== 1 ? "s" : ""}`
10782
+ );
8659
10783
  const count = newPolicy.rules.length;
8660
10784
  console.error(
8661
10785
  `[helio] Policy reloaded: ${String(count)} rule${count !== 1 ? "s" : ""} (default: ${newPolicy.defaultAction})`
@@ -8675,7 +10799,9 @@ async function startCommand(configPath, options) {
8675
10799
  }
8676
10800
  },
8677
10801
  onError: (error) => {
8678
- console.error(`[helio] Config reload failed (keeping current policy): ${error.message}`);
10802
+ console.error(
10803
+ `[helio] Config reload failed (keeping current configuration): ${error.message}`
10804
+ );
8679
10805
  }
8680
10806
  });
8681
10807
  configWatcher.start();
@@ -8697,6 +10823,7 @@ async function startCommand(configPath, options) {
8697
10823
  approvalQueue,
8698
10824
  rateLimiter,
8699
10825
  spendLimiter,
10826
+ budgetEngine,
8700
10827
  closeDashboardApp,
8701
10828
  dashboardHandle,
8702
10829
  eventBus,
@@ -8726,6 +10853,7 @@ async function validateCommand(configPath) {
8726
10853
  const label = w.ruleName ? `rule "${w.ruleName}"` : `rule ${String(w.ruleIndex)}`;
8727
10854
  console.error(`Warning: policy ${label}: ${w.message}`);
8728
10855
  }
10856
+ compileBudgets(config.budgets);
8729
10857
  if (config.dashboard.enabled && !getBundledDashboardDistPath()) {
8730
10858
  console.error(
8731
10859
  "Invalid config: dashboard.enabled is true but bundled dashboard assets are missing. " + DASHBOARD_ASSETS_RECOVERY_MESSAGE_FOR_VALIDATE
@@ -8816,7 +10944,7 @@ function writeCsv(records) {
8816
10944
  console.log(values.join(","));
8817
10945
  }
8818
10946
  }
8819
- function registerShutdown(handle, annotationPrime, closeForwarder, auditWriter, configWatcher, sidebandHandle, evidenceStore, approvalRouter, approvalQueue, rateLimiter, spendLimiter, closeDashboardApp, dashboardHandle, eventBus, governanceService) {
10947
+ function registerShutdown(handle, annotationPrime, closeForwarder, auditWriter, configWatcher, sidebandHandle, evidenceStore, approvalRouter, approvalQueue, rateLimiter, spendLimiter, budgetEngine, closeDashboardApp, dashboardHandle, eventBus, governanceService) {
8820
10948
  let isShuttingDown = false;
8821
10949
  const shutdown = () => {
8822
10950
  if (isShuttingDown) return;
@@ -8827,24 +10955,24 @@ function registerShutdown(handle, annotationPrime, closeForwarder, auditWriter,
8827
10955
  process.exit(1);
8828
10956
  }, SHUTDOWN_TIMEOUT_MS);
8829
10957
  forceShutdownTimer.unref();
8830
- const closeAll = async () => {
8831
- if (annotationPrime) annotationPrime.stop();
8832
- if (configWatcher) configWatcher.close();
8833
- if (closeDashboardApp) closeDashboardApp();
8834
- if (eventBus) eventBus.close();
8835
- if (governanceService) governanceService.close();
8836
- if (rateLimiter) rateLimiter.close();
8837
- if (spendLimiter) spendLimiter.close();
8838
- if (approvalRouter) approvalRouter.close();
8839
- if (approvalQueue) approvalQueue.close();
8840
- if (dashboardHandle) await dashboardHandle.close();
8841
- if (sidebandHandle) await sidebandHandle.close();
8842
- await handle.close();
8843
- if (evidenceStore) evidenceStore.close();
8844
- if (auditWriter) auditWriter.close();
8845
- if (closeForwarder) await closeForwarder();
8846
- };
8847
- void closeAll().then(() => {
10958
+ void closeResources({
10959
+ handle,
10960
+ annotationPrime,
10961
+ closeForwarder,
10962
+ auditWriter,
10963
+ configWatcher,
10964
+ sidebandHandle,
10965
+ evidenceStore,
10966
+ approvalRouter,
10967
+ approvalQueue,
10968
+ rateLimiter,
10969
+ spendLimiter,
10970
+ budgetEngine,
10971
+ closeDashboardApp,
10972
+ dashboardHandle,
10973
+ eventBus,
10974
+ governanceService
10975
+ }).then(() => {
8848
10976
  clearTimeout(forceShutdownTimer);
8849
10977
  process.exit(0);
8850
10978
  }).catch((err) => {