@gethelio/proxy 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -39,7 +39,7 @@ var upstreamSchema = z.object({
39
39
  request_timeout: durationSchema.default("30s"),
40
40
  forward_headers: z.array(z.string().min(1)).default([]),
41
41
  headers: z.record(z.string(), z.string()).default({})
42
- }).refine((data) => data.transport !== "stdio" || data.command !== void 0, {
42
+ }).strict().refine((data) => data.transport !== "stdio" || data.command !== void 0, {
43
43
  message: '"command" is required when transport is "stdio"',
44
44
  path: ["command"]
45
45
  }).superRefine((data, ctx) => {
@@ -72,7 +72,7 @@ var upstreamSchema = z.object({
72
72
  var listenSchema = z.object({
73
73
  port: z.number().int().min(1).max(65535).default(3e3),
74
74
  host: z.string().default("127.0.0.1")
75
- });
75
+ }).strict();
76
76
  function isLoopbackHost(host) {
77
77
  return host === "127.0.0.1" || host === "localhost" || host === "::1";
78
78
  }
@@ -86,7 +86,7 @@ var dashboardSchema = z.object({
86
86
  api_secret: z.string().optional(),
87
87
  allow_open_mode: z.boolean().default(false),
88
88
  sse_heartbeat_interval: durationSchema.default("30s")
89
- });
89
+ }).strict();
90
90
  var inputConditionSchema = z.object({
91
91
  eq: z.unknown().optional(),
92
92
  neq: z.unknown().optional(),
@@ -216,12 +216,27 @@ var policiesSchema = z.object({
216
216
  */
217
217
  hot_reload: z.boolean().optional()
218
218
  }).strict();
219
- var budgetContributorSchema = z.object({
219
+ var budgetContributorMatchSchema = z.object({
220
220
  tool: z.string().min(1),
221
221
  // picomatch glob, same engine as match.tool
222
+ // Same operators and AND-combination as rule `match.input`. Other rule
223
+ // matchers (annotations, environment, metadata) stay strict-rejected
224
+ // until the budget charge context can actually evaluate them.
225
+ input: z.record(z.string(), inputConditionSchema).optional()
226
+ }).strict();
227
+ var modernBudgetContributorSchema = z.object({
228
+ match: budgetContributorMatchSchema,
222
229
  field: z.string().min(1)
223
230
  // dot-path into tool arguments, e.g. "$.amount"
224
231
  }).strict();
232
+ var budgetContributorSchema = z.unknown().superRefine((raw, ctx) => {
233
+ if (raw !== null && typeof raw === "object" && "tool" in raw) {
234
+ ctx.addIssue({
235
+ code: "custom",
236
+ message: 'contributor "tool" moved under "match" in v0.11.0 \u2014 write { match: { tool: "<glob>" }, field: "<path>" }'
237
+ });
238
+ }
239
+ }).pipe(modernBudgetContributorSchema);
225
240
  var budgetSchema = z.object({
226
241
  // The name is embedded in bucket keys (`budget:<name>:<scope>`) and, later,
227
242
  // ledger rows — constrain it so keys stay parseable and scope classification
@@ -280,17 +295,17 @@ var slackChannelSchema = z.object({
280
295
  bot_token: z.string(),
281
296
  signing_secret: z.string(),
282
297
  channel: z.string()
283
- });
298
+ }).strict();
284
299
  var webhookChannelSchema = z.object({
285
300
  type: z.literal("webhook"),
286
301
  name: z.string().min(1).optional(),
287
302
  url: z.string(),
288
303
  secret: z.string().optional()
289
- });
304
+ }).strict();
290
305
  var dashboardChannelSchema = z.object({
291
306
  type: z.literal("dashboard"),
292
307
  name: z.string().min(1).optional()
293
- });
308
+ }).strict();
294
309
  var approvalChannelSchema = z.discriminatedUnion("type", [
295
310
  slackChannelSchema,
296
311
  webhookChannelSchema,
@@ -300,13 +315,13 @@ var approvalSchema = z.object({
300
315
  timeout: durationSchema.default("300s"),
301
316
  default_on_timeout: z.enum(["deny", "allow"]).default("deny"),
302
317
  channels: z.array(approvalChannelSchema).default([])
303
- });
318
+ }).strict();
304
319
  var auditSchema = z.object({
305
320
  storage: z.enum(["sqlite"]).default("sqlite"),
306
321
  path: z.string().default("./helio-audit.db"),
307
322
  retention: durationSchema.default("90d"),
308
323
  include_responses: z.boolean().default(true)
309
- });
324
+ }).strict();
310
325
  var sdkSchema = z.object({
311
326
  enabled: z.boolean().default(false),
312
327
  port: z.number().int().min(1).max(65535).default(3200),
@@ -318,12 +333,11 @@ var sdkSchema = z.object({
318
333
  * decided-allowed call from the trail.
319
334
  */
320
335
  evaluation_ttl: durationSchema.default("10m")
321
- });
336
+ }).strict();
322
337
  var helioConfigBaseSchema = z.object({
323
338
  version: z.literal("1"),
324
339
  upstream: upstreamSchema,
325
340
  listen: listenSchema.prefault({}),
326
- dashboard: dashboardSchema.prefault({}),
327
341
  environment: z.string().optional(),
328
342
  policies: policiesSchema.prefault({}),
329
343
  // Budgets sit beside policies deliberately: they are the second half of the
@@ -331,9 +345,18 @@ var helioConfigBaseSchema = z.object({
331
345
  budgets: z.array(budgetSchema).default([]),
332
346
  approval: approvalSchema.prefault({}),
333
347
  audit: auditSchema.prefault({}),
348
+ // Dashboard follows audit deliberately: an operator surface, not part of
349
+ // the request path (canonical section order, #89/#163).
350
+ dashboard: dashboardSchema.prefault({}),
334
351
  sdk: sdkSchema.prefault({})
335
- });
336
- var helioConfigSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
352
+ }).strict();
353
+ function stripRootExtensionKeys(value) {
354
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
355
+ return Object.fromEntries(
356
+ Object.entries(value).filter(([key]) => !key.startsWith("x-"))
357
+ );
358
+ }
359
+ var helioConfigRefinedSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
337
360
  const hasConfiguredEnvironment = typeof cfg.environment === "string" && cfg.environment.trim().length > 0;
338
361
  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");
339
362
  const hasSecret = hasDashboardApiSecret(cfg.dashboard.api_secret);
@@ -448,6 +471,22 @@ var helioConfigSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
448
471
  message: "dashboard.enabled must be true when approval.channels includes a webhook channel. Webhook notifications require the dashboard sideband approval API."
449
472
  });
450
473
  }
474
+ if (!cfg.dashboard.enabled) {
475
+ if (cfg.policies.flag_destructive === "require_approval") {
476
+ ctx.addIssue({
477
+ code: "custom",
478
+ path: ["policies", "flag_destructive"],
479
+ message: 'policies.flag_destructive: require_approval routes its escalation tickets to the dashboard channel, but dashboard.enabled is false \u2014 the tickets could never be resolved and would always time out. Enable the dashboard or use "log".'
480
+ });
481
+ }
482
+ if (cfg.policies.on_tool_drift === "require_approval") {
483
+ ctx.addIssue({
484
+ code: "custom",
485
+ path: ["policies", "on_tool_drift"],
486
+ message: 'policies.on_tool_drift: require_approval routes its escalation tickets to the dashboard channel, but dashboard.enabled is false \u2014 the tickets could never be resolved and would always time out. Enable the dashboard or use "block" or "log".'
487
+ });
488
+ }
489
+ }
451
490
  for (const [ruleIndex, rule] of cfg.policies.rules.entries()) {
452
491
  if (rule.match.environment !== void 0 && !hasConfiguredEnvironment) {
453
492
  ctx.addIssue({
@@ -503,6 +542,28 @@ var helioConfigSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
503
542
  message: `Unknown approval channel "${channel}". Add it to approval.channels (type or name), or use "dashboard".`
504
543
  });
505
544
  }
545
+ if (rule.action === "require_approval" && !cfg.dashboard.enabled && rule.match.metadata === void 0) {
546
+ const effectiveChannel = rule.approval?.channel ?? "dashboard";
547
+ if (resolvesToDashboard(effectiveChannel)) {
548
+ ctx.addIssue({
549
+ code: "custom",
550
+ path: rule.approval?.channel !== void 0 ? ["policies", "rules", ruleIndex, "approval", "channel"] : ["policies", "rules", ruleIndex, "action"],
551
+ message: "This rule routes approvals to the dashboard channel, but dashboard.enabled is false \u2014 the ticket could never be resolved and would always time out. Enable the dashboard or route approval.channel to a Slack channel."
552
+ });
553
+ }
554
+ const escalationAfterMs = rule.approval?.escalation_after !== void 0 ? parseDuration(rule.approval.escalation_after) : void 0;
555
+ const effectiveTimeoutMs = parseDuration(rule.approval?.timeout ?? cfg.approval.timeout);
556
+ const escalationCanFire = escalationAfterMs !== void 0 && escalationAfterMs > 0 && escalationAfterMs < effectiveTimeoutMs;
557
+ for (const [delegateIndex, delegate] of (rule.approval?.delegates ?? []).entries()) {
558
+ if (escalationCanFire && knownChannelKeys.has(delegate) && resolvesToDashboard(delegate)) {
559
+ ctx.addIssue({
560
+ code: "custom",
561
+ path: ["policies", "rules", ruleIndex, "approval", "delegates", delegateIndex],
562
+ message: "This rule escalates approvals to a dashboard channel, but dashboard.enabled is false \u2014 the delegate could never resolve the ticket. Enable the dashboard or delegate to a Slack channel."
563
+ });
564
+ }
565
+ }
566
+ }
506
567
  const delegates = rule.approval?.delegates;
507
568
  if (!delegates) continue;
508
569
  for (const [delegateIndex, delegate] of delegates.entries()) {
@@ -516,6 +577,7 @@ var helioConfigSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
516
577
  }
517
578
  }
518
579
  });
580
+ var helioConfigSchema = z.preprocess(stripRootExtensionKeys, helioConfigRefinedSchema);
519
581
 
520
582
  // src/config/loader.ts
521
583
  import { readFile } from "fs/promises";
@@ -578,7 +640,9 @@ async function loadConfig(filePath, env) {
578
640
  const interpolated = interpolateEnvVars(parsed, env);
579
641
  const result = helioConfigSchema.safeParse(interpolated);
580
642
  if (!result.success) {
581
- const details = formatZodErrors(result.error);
643
+ const details = formatZodErrors(result.error).map(
644
+ (d) => d.path === "" ? { ...d, path: "(top level)" } : d
645
+ );
582
646
  const count = details.length;
583
647
  throw new ConfigError(
584
648
  `Invalid configuration (${String(count)} error${count === 1 ? "" : "s"})`,
@@ -699,7 +763,7 @@ function compileToolMatcher(pattern, ruleIndex, ruleName) {
699
763
  throw new PolicyParseError(`invalid glob pattern "${pattern}": ${message}`, ruleIndex, ruleName);
700
764
  }
701
765
  }
702
- function flattenInputConditions(input, ruleIndex, ruleName) {
766
+ function flattenInputConditionsWith(input, makeError) {
703
767
  const conditions = [];
704
768
  for (const [path, conditionObj] of Object.entries(input)) {
705
769
  for (const op of INPUT_OPERATORS) {
@@ -707,17 +771,11 @@ function flattenInputConditions(input, ruleIndex, ruleName) {
707
771
  if (value === void 0) continue;
708
772
  if (op === "regex") {
709
773
  if (typeof value !== "string") {
710
- throw new PolicyParseError(
711
- `regex value for input path "${path}" must be a string`,
712
- ruleIndex,
713
- ruleName
714
- );
774
+ throw makeError(`regex value for input path "${path}" must be a string`);
715
775
  }
716
776
  if (!safeRegex(value)) {
717
- throw new PolicyParseError(
718
- `catastrophic regex "${value}" for input path "${path}": pattern is vulnerable to ReDoS and has been rejected. Rewrite with bounded quantifiers (e.g. {1,100}) or split into simpler rules.`,
719
- ruleIndex,
720
- ruleName
777
+ throw makeError(
778
+ `catastrophic regex "${value}" for input path "${path}": pattern is vulnerable to ReDoS and has been rejected. Rewrite with bounded quantifiers (e.g. {1,100}) or split into simpler rules.`
721
779
  );
722
780
  }
723
781
  let compiledRegex;
@@ -725,11 +783,7 @@ function flattenInputConditions(input, ruleIndex, ruleName) {
725
783
  compiledRegex = new RegExp(value);
726
784
  } catch (err) {
727
785
  const msg = err instanceof Error ? err.message : String(err);
728
- throw new PolicyParseError(
729
- `invalid regex "${value}" for input path "${path}": ${msg}`,
730
- ruleIndex,
731
- ruleName
732
- );
786
+ throw makeError(`invalid regex "${value}" for input path "${path}": ${msg}`);
733
787
  }
734
788
  conditions.push({ path, operator: op, value, regex: compiledRegex });
735
789
  } else {
@@ -739,6 +793,12 @@ function flattenInputConditions(input, ruleIndex, ruleName) {
739
793
  }
740
794
  return conditions;
741
795
  }
796
+ function flattenInputConditions(input, ruleIndex, ruleName) {
797
+ return flattenInputConditionsWith(
798
+ input,
799
+ (message) => new PolicyParseError(message, ruleIndex, ruleName)
800
+ );
801
+ }
742
802
  function flattenMetadataConditions(metadata, ruleIndex, ruleName) {
743
803
  const conditions = [];
744
804
  for (const [key, raw] of Object.entries(metadata)) {
@@ -872,21 +932,30 @@ function compileBudgets(budgets) {
872
932
  onExceed: budget.on_exceed,
873
933
  ...budget.approval !== void 0 && { approval: compileApproval(budget.approval) },
874
934
  contributors: budget.contributors.map(
875
- (contributor) => compileContributor(contributor, budget.name)
935
+ (contributor, index) => compileContributor(contributor, budget.name, index)
876
936
  )
877
937
  }));
878
938
  }
879
- function compileContributor(contributor, budgetName) {
939
+ function compileContributor(contributor, budgetName, index) {
940
+ let tool;
880
941
  try {
881
- const test = picomatch2(contributor.tool, { dot: true });
882
- return { tool: { pattern: contributor.tool, test }, field: contributor.field };
942
+ const test = picomatch2(contributor.match.tool, { dot: true });
943
+ tool = { pattern: contributor.match.tool, test };
883
944
  } catch (err) {
884
945
  const message = err instanceof Error ? err.message : String(err);
885
946
  throw new BudgetParseError(
886
- `invalid contributor glob "${contributor.tool}": ${message}`,
947
+ `invalid contributor glob "${contributor.match.tool}": ${message}`,
887
948
  budgetName
888
949
  );
889
950
  }
951
+ const input = contributor.match.input !== void 0 ? flattenInputConditionsWith(
952
+ contributor.match.input,
953
+ (message) => new BudgetParseError(`contributor ${String(index)}: ${message}`, budgetName)
954
+ ) : void 0;
955
+ return {
956
+ match: { tool, ...input !== void 0 && { input } },
957
+ field: contributor.field
958
+ };
890
959
  }
891
960
 
892
961
  // src/server.ts
@@ -2910,11 +2979,9 @@ function extractTools(body) {
2910
2979
 
2911
2980
  // src/feedback/self-repair.ts
2912
2981
  function ruleInfo(rule) {
2913
- const index = rule?.index ?? null;
2914
2982
  return {
2915
2983
  rule: rule?.name ?? null,
2916
- ruleIndex: index,
2917
- rule_index: index
2984
+ rule_index: rule?.index ?? null
2918
2985
  };
2919
2986
  }
2920
2987
  function buildPolicyDeniedFeedback(decision) {
@@ -4392,29 +4459,33 @@ var GovernedForwarder = class {
4392
4459
  if (this.spendLimiter && decision.matchedRule?.limits?.maxSpend) {
4393
4460
  const maxSpend = decision.matchedRule.limits.maxSpend;
4394
4461
  const rawAmount = resolvePath(maxSpend.field, toolArguments ?? {});
4395
- if (typeof rawAmount === "number") {
4396
- if (!Number.isFinite(rawAmount) || rawAmount < 0) {
4397
- console.error(
4398
- `[helio] Warning: spend limit field "${maxSpend.field}" resolved to invalid amount (${String(rawAmount)}) for tool "${toolName}" in dry-run, denying`
4399
- );
4400
- wouldForward = false;
4401
- limitsOk = false;
4402
- } else {
4403
- const key = this.buildSpendLimitKey(
4404
- maxSpend.key,
4405
- toolName,
4406
- request,
4407
- decision.matchedRule.index
4408
- );
4409
- const peekResult = this.spendLimiter.peek({
4410
- key,
4411
- amount: rawAmount,
4412
- limit: maxSpend.limit,
4413
- windowMs: maxSpend.windowMs
4414
- });
4415
- wouldForward = peekResult.allowed;
4416
- limitsOk = peekResult.allowed;
4417
- }
4462
+ if (typeof rawAmount !== "number") {
4463
+ console.error(
4464
+ `[helio] Warning: spend limit field "${maxSpend.field}" did not resolve to a number for tool "${toolName}" (got ${typeof rawAmount}) in dry-run, denying`
4465
+ );
4466
+ wouldForward = false;
4467
+ limitsOk = false;
4468
+ } else if (!Number.isFinite(rawAmount) || rawAmount < 0) {
4469
+ console.error(
4470
+ `[helio] Warning: spend limit field "${maxSpend.field}" resolved to invalid amount (${String(rawAmount)}) for tool "${toolName}" in dry-run, denying`
4471
+ );
4472
+ wouldForward = false;
4473
+ limitsOk = false;
4474
+ } else {
4475
+ const key = this.buildSpendLimitKey(
4476
+ maxSpend.key,
4477
+ toolName,
4478
+ request,
4479
+ decision.matchedRule.index
4480
+ );
4481
+ const peekResult = this.spendLimiter.peek({
4482
+ key,
4483
+ amount: rawAmount,
4484
+ limit: maxSpend.limit,
4485
+ windowMs: maxSpend.windowMs
4486
+ });
4487
+ wouldForward = peekResult.allowed;
4488
+ limitsOk = peekResult.allowed;
4418
4489
  }
4419
4490
  }
4420
4491
  break;
@@ -5082,16 +5153,26 @@ var BudgetEngine = class {
5082
5153
  /**
5083
5154
  * Resolve which budgets a call feeds and how much it charges each.
5084
5155
  *
5085
- * A budget participates when any contributor glob matches the tool name;
5086
- * the FIRST matching contributor (config order) supplies the amount field.
5087
- * A missing, non-numeric, negative, or non-finite amount fails closed as a
5088
- * `failures` entry the caller must deny the call.
5156
+ * A contributor participates when its tool glob matches the tool name AND
5157
+ * every `match.input` condition holds (absent conditions means the glob
5158
+ * alone decides); the FIRST participating contributor (config order, over
5159
+ * that combined predicate) supplies the amount field. A call that matches
5160
+ * the glob but not the conditions simply does not feed the budget — no
5161
+ * charge, no failure. Once a contributor is selected, a missing,
5162
+ * non-numeric, negative, or non-finite amount fails closed as a `failures`
5163
+ * entry — the caller must deny the call.
5089
5164
  */
5090
5165
  resolveCharges(ctx) {
5091
5166
  const charges = [];
5092
5167
  const failures = [];
5168
+ const matchCtx = {
5169
+ toolName: ctx.toolName,
5170
+ ...ctx.toolArguments !== void 0 && { toolArguments: ctx.toolArguments }
5171
+ };
5093
5172
  for (const budget of this.budgets.values()) {
5094
- const contributor = budget.contributors.find((c) => c.tool.test(ctx.toolName));
5173
+ const contributor = budget.contributors.find(
5174
+ (c) => c.match.tool.test(ctx.toolName) && (c.match.input === void 0 || matchInput(c.match.input, matchCtx))
5175
+ );
5095
5176
  if (!contributor) continue;
5096
5177
  const raw = resolvePath(contributor.field, ctx.toolArguments ?? {});
5097
5178
  if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 0) {
@@ -6085,6 +6166,96 @@ var AuditStore = class {
6085
6166
  }
6086
6167
  };
6087
6168
 
6169
+ // src/audit/csv.ts
6170
+ var CSV_HEADERS = [
6171
+ "id",
6172
+ "timestamp",
6173
+ "session_id",
6174
+ "agent_id",
6175
+ "tool_name",
6176
+ "tool_input",
6177
+ "policy_decision",
6178
+ "block_reason",
6179
+ "matched_rule",
6180
+ "evidence_chain",
6181
+ "approval_status",
6182
+ "approved_by",
6183
+ "upstream_response",
6184
+ "upstream_error",
6185
+ "upstream_http_status",
6186
+ "upstream_latency_ms",
6187
+ "total_duration_ms",
6188
+ "approval_wait_ms",
6189
+ "proxy_compute_ms",
6190
+ "flagged_destructive",
6191
+ "dry_run",
6192
+ "created_at",
6193
+ "environment",
6194
+ "matched_rule_index",
6195
+ "record_kind",
6196
+ "origin",
6197
+ "metadata"
6198
+ ];
6199
+ var FORMULA_PREFIXES = /^[=+\-@\t\r]/;
6200
+ function csvEscape(value) {
6201
+ const needsQuote = value.includes(",") || value.includes("\n") || value.includes('"');
6202
+ const needsFormulaGuard = FORMULA_PREFIXES.test(value);
6203
+ let out = value;
6204
+ if (needsFormulaGuard) out = `'${out}`;
6205
+ if (needsQuote || needsFormulaGuard) out = `"${out.replace(/"/g, '""')}"`;
6206
+ return out;
6207
+ }
6208
+ function recordToRow(record) {
6209
+ return CSV_HEADERS.map((h) => {
6210
+ const val = record[h];
6211
+ if (val === null || val === void 0) return "";
6212
+ if (typeof val === "boolean") return val ? "true" : "false";
6213
+ if (typeof val === "number") return String(val);
6214
+ if (typeof val === "string") return csvEscape(val);
6215
+ if (typeof val === "object") return csvEscape(JSON.stringify(val));
6216
+ return "";
6217
+ }).join(",");
6218
+ }
6219
+ function recordsToCsv(records) {
6220
+ const lines = [CSV_HEADERS.join(",")];
6221
+ for (const r of records) {
6222
+ lines.push(recordToRow(r));
6223
+ }
6224
+ return lines.join("\n");
6225
+ }
6226
+
6227
+ // src/budget/csv.ts
6228
+ var BUDGET_EVENT_CSV_HEADERS = [
6229
+ "id",
6230
+ "budget_name",
6231
+ "bucket_key",
6232
+ "kind",
6233
+ "amount",
6234
+ "currency",
6235
+ "tool_name",
6236
+ "origin",
6237
+ "audit_record_id",
6238
+ "timestamp",
6239
+ "timestamp_ms",
6240
+ "created_at"
6241
+ ];
6242
+ function eventToRow(event) {
6243
+ return BUDGET_EVENT_CSV_HEADERS.map((h) => {
6244
+ const val = event[h];
6245
+ if (val === null || val === void 0) return "";
6246
+ if (typeof val === "number") return String(val);
6247
+ if (typeof val === "string") return csvEscape(val);
6248
+ return "";
6249
+ }).join(",");
6250
+ }
6251
+ function budgetEventsToCsv(events) {
6252
+ const lines = [BUDGET_EVENT_CSV_HEADERS.join(",")];
6253
+ for (const e of events) {
6254
+ lines.push(eventToRow(e));
6255
+ }
6256
+ return lines.join("\n");
6257
+ }
6258
+
6088
6259
  // src/evidence/store.ts
6089
6260
  var EvidenceStore = class _EvidenceStore {
6090
6261
  static EVIDENCE_ALLOWLIST_PREVIEW_LIMIT = 20;
@@ -6817,6 +6988,7 @@ var GovernanceService = class {
6817
6988
  let wire;
6818
6989
  const plans = [];
6819
6990
  let limitsBlock;
6991
+ let ruleLimitOk = true;
6820
6992
  const reservedThisCall = [];
6821
6993
  const reserve = (key) => {
6822
6994
  const preexisting = this.senderKeys.has(key);
@@ -6830,6 +7002,15 @@ var GovernanceService = class {
6830
7002
  const senderId = senderIdOf(req.metadata);
6831
7003
  if (pipeline.isDryRun) {
6832
7004
  wire = "dry_run";
7005
+ if (decision.action === "rate_limit") {
7006
+ const planned = this.planRate(decision, toolName, req.session_id, senderId);
7007
+ if (planned?.block) limitsBlock = { rate: planned.block };
7008
+ ruleLimitOk = planned?.allowed ?? true;
7009
+ } else if (decision.action === "spend_limit") {
7010
+ const planned = this.planSpend(decision, toolName, req.session_id, req.arguments, senderId);
7011
+ if (planned?.block) limitsBlock = { spend: planned.block };
7012
+ ruleLimitOk = planned?.allowed ?? true;
7013
+ }
6833
7014
  } else if (decision.action === "deny") {
6834
7015
  wire = "deny";
6835
7016
  } else if (decision.action === "require_approval") {
@@ -6954,9 +7135,9 @@ var GovernanceService = class {
6954
7135
  if (limitsBlock) responseBody["limits"] = limitsBlock;
6955
7136
  if (wire === "dry_run") {
6956
7137
  responseBody["dry_run"] = {
6957
- would_forward: decision.action === "allow" && !pipeline.evidenceBlocked && budgetDryRunOk,
7138
+ would_forward: (decision.action === "allow" || (decision.action === "rate_limit" || decision.action === "spend_limit") && ruleLimitOk) && !pipeline.evidenceBlocked && budgetDryRunOk,
6958
7139
  evidence_satisfied: !pipeline.evidenceBlocked,
6959
- limits_ok: budgetDryRunOk
7140
+ limits_ok: ruleLimitOk && budgetDryRunOk
6960
7141
  };
6961
7142
  }
6962
7143
  if (pipeline.driftEvent) {
@@ -6978,7 +7159,11 @@ var GovernanceService = class {
6978
7159
  flaggedDestructive: pipeline.flaggedDestructive,
6979
7160
  dryRun: wire === "dry_run",
6980
7161
  recordKind: "tool_call",
6981
- limitsChain: limitsBlock
7162
+ // A CLONE, for the same reason budgetsAtEvaluate is one below:
7163
+ // limitsBlock is also the response's `limits`, and the audit writer
7164
+ // buffers records by reference until flush — a direct embedder
7165
+ // editing the returned body must not be able to rewrite evidence.
7166
+ limitsChain: limitsBlock ? structuredClone(limitsBlock) : void 0
6982
7167
  });
6983
7168
  this.tombstones.set(evaluationId, {
6984
7169
  auditRecordId: auditId,
@@ -7480,6 +7665,7 @@ var GovernanceService = class {
7480
7665
  this.approvalRouter?.resolveNativeTicket(entry.approvalTicketId, "timeout");
7481
7666
  this.snapshotTicketResolution(entry);
7482
7667
  }
7668
+ const committed = entry.commitState;
7483
7669
  const resolution = entry.ticketResolution;
7484
7670
  const approvalContext = entry.approvalTicketId && resolution && (resolution.denialReason || resolution.escalatedAt) ? {
7485
7671
  ticket_id: entry.approvalTicketId,
@@ -7490,6 +7676,7 @@ var GovernanceService = class {
7490
7676
  } : {}
7491
7677
  } : void 0;
7492
7678
  const auditId = this.writeAudit({
7679
+ ...committed ? { id: committed.auditId } : {},
7493
7680
  timestampIso: entry.timestampIso,
7494
7681
  origin: entry.origin,
7495
7682
  agentId: entry.agentId,
@@ -7507,8 +7694,9 @@ var GovernanceService = class {
7507
7694
  approvalStatus: resolution?.status ?? null,
7508
7695
  approvedBy: resolution?.resolvedBy ?? null,
7509
7696
  approvalContext,
7510
- limitsChain: !entry.commitState && entry.budgetsAtEvaluate ? { budgets: entry.budgetsAtEvaluate } : void 0,
7511
- sidebandUnreported: true
7697
+ limitsChain: committed ? committed.limitsChain : entry.budgetsAtEvaluate ? { budgets: entry.budgetsAtEvaluate } : void 0,
7698
+ sidebandUnreported: true,
7699
+ sidebandCommitted: committed !== void 0
7512
7700
  });
7513
7701
  this.discardPending(entry);
7514
7702
  this.tombstones.set(entry.evaluationId, {
@@ -7518,7 +7706,7 @@ var GovernanceService = class {
7518
7706
  expiresAtMs: now + this.ttlMs
7519
7707
  });
7520
7708
  console.error(
7521
- `[helio] Sideband evaluation ${entry.evaluationId} expired without /audit (origin=${entry.origin}, tool=${entry.toolName}) \u2014 recorded as evaluation_expired`
7709
+ committed ? `[helio] Sideband evaluation ${entry.evaluationId} expired after a failed /audit finalization (origin=${entry.origin}, tool=${entry.toolName}) \u2014 recorded as evaluation_expired under the committed audit id` : `[helio] Sideband evaluation ${entry.evaluationId} expired without /audit (origin=${entry.origin}, tool=${entry.toolName}) \u2014 recorded as evaluation_expired`
7522
7710
  );
7523
7711
  return "expired";
7524
7712
  }
@@ -7700,7 +7888,13 @@ var GovernanceService = class {
7700
7888
  const blockReason = deriveBlockReason(args);
7701
7889
  let evidenceChain = args.limitsChain ?? null;
7702
7890
  if (args.sidebandUnreported) {
7703
- evidenceChain = { ...evidenceChain ?? {}, sideband: { unreported: true } };
7891
+ evidenceChain = {
7892
+ ...evidenceChain ?? {},
7893
+ sideband: {
7894
+ unreported: true,
7895
+ ...args.sidebandCommitted ? { committed: true } : {}
7896
+ }
7897
+ };
7704
7898
  }
7705
7899
  if (args.approvalContext) {
7706
7900
  evidenceChain = { ...evidenceChain ?? {}, approval: { ...args.approvalContext } };
@@ -9022,64 +9216,6 @@ import { cors } from "hono/cors";
9022
9216
  import { serveStatic } from "@hono/node-server/serve-static";
9023
9217
  import { streamSSE } from "hono/streaming";
9024
9218
 
9025
- // src/audit/csv.ts
9026
- var CSV_HEADERS = [
9027
- "id",
9028
- "timestamp",
9029
- "session_id",
9030
- "agent_id",
9031
- "tool_name",
9032
- "tool_input",
9033
- "policy_decision",
9034
- "block_reason",
9035
- "matched_rule",
9036
- "evidence_chain",
9037
- "approval_status",
9038
- "approved_by",
9039
- "upstream_response",
9040
- "upstream_error",
9041
- "upstream_http_status",
9042
- "upstream_latency_ms",
9043
- "total_duration_ms",
9044
- "approval_wait_ms",
9045
- "proxy_compute_ms",
9046
- "flagged_destructive",
9047
- "dry_run",
9048
- "created_at",
9049
- "environment",
9050
- "matched_rule_index",
9051
- "record_kind",
9052
- "origin",
9053
- "metadata"
9054
- ];
9055
- var FORMULA_PREFIXES = /^[=+\-@\t\r]/;
9056
- function csvEscape(value) {
9057
- const needsQuote = value.includes(",") || value.includes("\n") || value.includes('"');
9058
- const needsFormulaGuard = FORMULA_PREFIXES.test(value);
9059
- let out = value;
9060
- if (needsFormulaGuard) out = `'${out}`;
9061
- if (needsQuote || needsFormulaGuard) out = `"${out.replace(/"/g, '""')}"`;
9062
- return out;
9063
- }
9064
- function recordToRow(record) {
9065
- return CSV_HEADERS.map((h) => {
9066
- const val = record[h];
9067
- if (val === null || val === void 0) return "";
9068
- if (typeof val === "boolean") return val ? "true" : "false";
9069
- if (typeof val === "number") return String(val);
9070
- if (typeof val === "string") return csvEscape(val);
9071
- if (typeof val === "object") return csvEscape(JSON.stringify(val));
9072
- return "";
9073
- }).join(",");
9074
- }
9075
- function recordsToCsv(records) {
9076
- const lines = [CSV_HEADERS.join(",")];
9077
- for (const r of records) {
9078
- lines.push(recordToRow(r));
9079
- }
9080
- return lines.join("\n");
9081
- }
9082
-
9083
9219
  // src/dashboard/session.ts
9084
9220
  import { createHash as createHash3, createHmac as createHmac3, randomBytes, timingSafeEqual as timingSafeEqual3 } from "crypto";
9085
9221
  var DashboardSessionStore = class {
@@ -9237,6 +9373,10 @@ var budgetEventsQuerySchema = z8.object({
9237
9373
  limit: clampedQueryInt(50, 1, LIST_MAX_PAGE_SIZE),
9238
9374
  offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER)
9239
9375
  });
9376
+ var budgetEventsExportQuerySchema = z8.object({
9377
+ format: z8.preprocess((value) => value === "csv" ? "csv" : "json", z8.enum(["json", "csv"])),
9378
+ limit: clampedQueryInt(EXPORT_MAX_RECORDS, 1, EXPORT_MAX_RECORDS)
9379
+ });
9240
9380
  var analyticsQuerySchema = z8.object({
9241
9381
  from: optionalQueryString,
9242
9382
  to: optionalQueryString
@@ -9548,6 +9688,27 @@ function createDashboardAppWithLifecycle(deps, options) {
9548
9688
  offset: query.offset
9549
9689
  });
9550
9690
  });
9691
+ app.get("/api/budgets/:name/events/export", (c) => {
9692
+ const query = budgetEventsExportQuerySchema.parse(c.req.query());
9693
+ const name = c.req.param("name");
9694
+ const page = budgets?.listEventsForExport(name, query.limit) ?? { events: [], total: 0 };
9695
+ const safeName = name.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 64);
9696
+ const filename = safeName ? `helio-budget-${safeName}-events` : "helio-budget-events";
9697
+ if (query.format === "csv") {
9698
+ return new Response(budgetEventsToCsv(page.events), {
9699
+ headers: {
9700
+ "content-type": "text/csv; charset=utf-8",
9701
+ "content-disposition": `attachment; filename="${filename}.csv"`
9702
+ }
9703
+ });
9704
+ }
9705
+ return new Response(JSON.stringify(page.events, null, 2), {
9706
+ headers: {
9707
+ "content-type": "application/json",
9708
+ "content-disposition": `attachment; filename="${filename}.json"`
9709
+ }
9710
+ });
9711
+ });
9551
9712
  app.get("/api/analytics", (c) => {
9552
9713
  const query = analyticsQuerySchema.parse(c.req.query());
9553
9714
  const now = /* @__PURE__ */ new Date();