@gethelio/proxy 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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
@@ -973,6 +1042,12 @@ function parseJsonRpcRequest(body) {
973
1042
  };
974
1043
  }
975
1044
 
1045
+ // src/transport/content-type.ts
1046
+ function isJsonContentType(header) {
1047
+ const [essence = ""] = (header ?? "").split(";");
1048
+ return essence.trim().toLowerCase() === "application/json";
1049
+ }
1050
+
976
1051
  // src/transport/forward-headers.ts
977
1052
  function buildForwardHeaders(requestHeaders, allowlist) {
978
1053
  const forwardHeaders = {};
@@ -1106,8 +1181,7 @@ function createStreamableHttpRoute(forwarder, options = {}) {
1106
1181
  const app = new Hono();
1107
1182
  const forwardHeaderAllowlist = options.forwardHeadersAllowlist ?? [];
1108
1183
  app.post("/", async (c) => {
1109
- const contentType = c.req.header("content-type") ?? "";
1110
- if (!contentType.includes("application/json")) {
1184
+ if (!isJsonContentType(c.req.header("content-type"))) {
1111
1185
  return c.json(
1112
1186
  makeJsonRpcError(null, INVALID_REQUEST, "Content-Type must be application/json"),
1113
1187
  415
@@ -1240,8 +1314,7 @@ function createSseRoute(forwarder, options = {}) {
1240
1314
  if (!session) {
1241
1315
  return c.json(makeJsonRpcError(null, INVALID_REQUEST, "unknown session"), 404);
1242
1316
  }
1243
- const contentType = c.req.header("content-type") ?? "";
1244
- if (!contentType.includes("application/json")) {
1317
+ if (!isJsonContentType(c.req.header("content-type"))) {
1245
1318
  return c.json(
1246
1319
  makeJsonRpcError(null, INVALID_REQUEST, "Content-Type must be application/json"),
1247
1320
  415
@@ -2910,11 +2983,9 @@ function extractTools(body) {
2910
2983
 
2911
2984
  // src/feedback/self-repair.ts
2912
2985
  function ruleInfo(rule) {
2913
- const index = rule?.index ?? null;
2914
2986
  return {
2915
2987
  rule: rule?.name ?? null,
2916
- ruleIndex: index,
2917
- rule_index: index
2988
+ rule_index: rule?.index ?? null
2918
2989
  };
2919
2990
  }
2920
2991
  function buildPolicyDeniedFeedback(decision) {
@@ -4392,29 +4463,33 @@ var GovernedForwarder = class {
4392
4463
  if (this.spendLimiter && decision.matchedRule?.limits?.maxSpend) {
4393
4464
  const maxSpend = decision.matchedRule.limits.maxSpend;
4394
4465
  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
- }
4466
+ if (typeof rawAmount !== "number") {
4467
+ console.error(
4468
+ `[helio] Warning: spend limit field "${maxSpend.field}" did not resolve to a number for tool "${toolName}" (got ${typeof rawAmount}) in dry-run, denying`
4469
+ );
4470
+ wouldForward = false;
4471
+ limitsOk = false;
4472
+ } else if (!Number.isFinite(rawAmount) || rawAmount < 0) {
4473
+ console.error(
4474
+ `[helio] Warning: spend limit field "${maxSpend.field}" resolved to invalid amount (${String(rawAmount)}) for tool "${toolName}" in dry-run, denying`
4475
+ );
4476
+ wouldForward = false;
4477
+ limitsOk = false;
4478
+ } else {
4479
+ const key = this.buildSpendLimitKey(
4480
+ maxSpend.key,
4481
+ toolName,
4482
+ request,
4483
+ decision.matchedRule.index
4484
+ );
4485
+ const peekResult = this.spendLimiter.peek({
4486
+ key,
4487
+ amount: rawAmount,
4488
+ limit: maxSpend.limit,
4489
+ windowMs: maxSpend.windowMs
4490
+ });
4491
+ wouldForward = peekResult.allowed;
4492
+ limitsOk = peekResult.allowed;
4418
4493
  }
4419
4494
  }
4420
4495
  break;
@@ -5082,16 +5157,26 @@ var BudgetEngine = class {
5082
5157
  /**
5083
5158
  * Resolve which budgets a call feeds and how much it charges each.
5084
5159
  *
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.
5160
+ * A contributor participates when its tool glob matches the tool name AND
5161
+ * every `match.input` condition holds (absent conditions means the glob
5162
+ * alone decides); the FIRST participating contributor (config order, over
5163
+ * that combined predicate) supplies the amount field. A call that matches
5164
+ * the glob but not the conditions simply does not feed the budget — no
5165
+ * charge, no failure. Once a contributor is selected, a missing,
5166
+ * non-numeric, negative, or non-finite amount fails closed as a `failures`
5167
+ * entry — the caller must deny the call.
5089
5168
  */
5090
5169
  resolveCharges(ctx) {
5091
5170
  const charges = [];
5092
5171
  const failures = [];
5172
+ const matchCtx = {
5173
+ toolName: ctx.toolName,
5174
+ ...ctx.toolArguments !== void 0 && { toolArguments: ctx.toolArguments }
5175
+ };
5093
5176
  for (const budget of this.budgets.values()) {
5094
- const contributor = budget.contributors.find((c) => c.tool.test(ctx.toolName));
5177
+ const contributor = budget.contributors.find(
5178
+ (c) => c.match.tool.test(ctx.toolName) && (c.match.input === void 0 || matchInput(c.match.input, matchCtx))
5179
+ );
5095
5180
  if (!contributor) continue;
5096
5181
  const raw = resolvePath(contributor.field, ctx.toolArguments ?? {});
5097
5182
  if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 0) {
@@ -6085,6 +6170,96 @@ var AuditStore = class {
6085
6170
  }
6086
6171
  };
6087
6172
 
6173
+ // src/audit/csv.ts
6174
+ var CSV_HEADERS = [
6175
+ "id",
6176
+ "timestamp",
6177
+ "session_id",
6178
+ "agent_id",
6179
+ "tool_name",
6180
+ "tool_input",
6181
+ "policy_decision",
6182
+ "block_reason",
6183
+ "matched_rule",
6184
+ "evidence_chain",
6185
+ "approval_status",
6186
+ "approved_by",
6187
+ "upstream_response",
6188
+ "upstream_error",
6189
+ "upstream_http_status",
6190
+ "upstream_latency_ms",
6191
+ "total_duration_ms",
6192
+ "approval_wait_ms",
6193
+ "proxy_compute_ms",
6194
+ "flagged_destructive",
6195
+ "dry_run",
6196
+ "created_at",
6197
+ "environment",
6198
+ "matched_rule_index",
6199
+ "record_kind",
6200
+ "origin",
6201
+ "metadata"
6202
+ ];
6203
+ var FORMULA_PREFIXES = /^[=+\-@\t\r]/;
6204
+ function csvEscape(value) {
6205
+ const needsQuote = value.includes(",") || value.includes("\n") || value.includes('"');
6206
+ const needsFormulaGuard = FORMULA_PREFIXES.test(value);
6207
+ let out = value;
6208
+ if (needsFormulaGuard) out = `'${out}`;
6209
+ if (needsQuote || needsFormulaGuard) out = `"${out.replace(/"/g, '""')}"`;
6210
+ return out;
6211
+ }
6212
+ function recordToRow(record) {
6213
+ return CSV_HEADERS.map((h) => {
6214
+ const val = record[h];
6215
+ if (val === null || val === void 0) return "";
6216
+ if (typeof val === "boolean") return val ? "true" : "false";
6217
+ if (typeof val === "number") return String(val);
6218
+ if (typeof val === "string") return csvEscape(val);
6219
+ if (typeof val === "object") return csvEscape(JSON.stringify(val));
6220
+ return "";
6221
+ }).join(",");
6222
+ }
6223
+ function recordsToCsv(records) {
6224
+ const lines = [CSV_HEADERS.join(",")];
6225
+ for (const r of records) {
6226
+ lines.push(recordToRow(r));
6227
+ }
6228
+ return lines.join("\n");
6229
+ }
6230
+
6231
+ // src/budget/csv.ts
6232
+ var BUDGET_EVENT_CSV_HEADERS = [
6233
+ "id",
6234
+ "budget_name",
6235
+ "bucket_key",
6236
+ "kind",
6237
+ "amount",
6238
+ "currency",
6239
+ "tool_name",
6240
+ "origin",
6241
+ "audit_record_id",
6242
+ "timestamp",
6243
+ "timestamp_ms",
6244
+ "created_at"
6245
+ ];
6246
+ function eventToRow(event) {
6247
+ return BUDGET_EVENT_CSV_HEADERS.map((h) => {
6248
+ const val = event[h];
6249
+ if (val === null || val === void 0) return "";
6250
+ if (typeof val === "number") return String(val);
6251
+ if (typeof val === "string") return csvEscape(val);
6252
+ return "";
6253
+ }).join(",");
6254
+ }
6255
+ function budgetEventsToCsv(events) {
6256
+ const lines = [BUDGET_EVENT_CSV_HEADERS.join(",")];
6257
+ for (const e of events) {
6258
+ lines.push(eventToRow(e));
6259
+ }
6260
+ return lines.join("\n");
6261
+ }
6262
+
6088
6263
  // src/evidence/store.ts
6089
6264
  var EvidenceStore = class _EvidenceStore {
6090
6265
  static EVIDENCE_ALLOWLIST_PREVIEW_LIMIT = 20;
@@ -6817,6 +6992,7 @@ var GovernanceService = class {
6817
6992
  let wire;
6818
6993
  const plans = [];
6819
6994
  let limitsBlock;
6995
+ let ruleLimitOk = true;
6820
6996
  const reservedThisCall = [];
6821
6997
  const reserve = (key) => {
6822
6998
  const preexisting = this.senderKeys.has(key);
@@ -6830,6 +7006,15 @@ var GovernanceService = class {
6830
7006
  const senderId = senderIdOf(req.metadata);
6831
7007
  if (pipeline.isDryRun) {
6832
7008
  wire = "dry_run";
7009
+ if (decision.action === "rate_limit") {
7010
+ const planned = this.planRate(decision, toolName, req.session_id, senderId);
7011
+ if (planned?.block) limitsBlock = { rate: planned.block };
7012
+ ruleLimitOk = planned?.allowed ?? true;
7013
+ } else if (decision.action === "spend_limit") {
7014
+ const planned = this.planSpend(decision, toolName, req.session_id, req.arguments, senderId);
7015
+ if (planned?.block) limitsBlock = { spend: planned.block };
7016
+ ruleLimitOk = planned?.allowed ?? true;
7017
+ }
6833
7018
  } else if (decision.action === "deny") {
6834
7019
  wire = "deny";
6835
7020
  } else if (decision.action === "require_approval") {
@@ -6954,9 +7139,9 @@ var GovernanceService = class {
6954
7139
  if (limitsBlock) responseBody["limits"] = limitsBlock;
6955
7140
  if (wire === "dry_run") {
6956
7141
  responseBody["dry_run"] = {
6957
- would_forward: decision.action === "allow" && !pipeline.evidenceBlocked && budgetDryRunOk,
7142
+ would_forward: (decision.action === "allow" || (decision.action === "rate_limit" || decision.action === "spend_limit") && ruleLimitOk) && !pipeline.evidenceBlocked && budgetDryRunOk,
6958
7143
  evidence_satisfied: !pipeline.evidenceBlocked,
6959
- limits_ok: budgetDryRunOk
7144
+ limits_ok: ruleLimitOk && budgetDryRunOk
6960
7145
  };
6961
7146
  }
6962
7147
  if (pipeline.driftEvent) {
@@ -6978,7 +7163,11 @@ var GovernanceService = class {
6978
7163
  flaggedDestructive: pipeline.flaggedDestructive,
6979
7164
  dryRun: wire === "dry_run",
6980
7165
  recordKind: "tool_call",
6981
- limitsChain: limitsBlock
7166
+ // A CLONE, for the same reason budgetsAtEvaluate is one below:
7167
+ // limitsBlock is also the response's `limits`, and the audit writer
7168
+ // buffers records by reference until flush — a direct embedder
7169
+ // editing the returned body must not be able to rewrite evidence.
7170
+ limitsChain: limitsBlock ? structuredClone(limitsBlock) : void 0
6982
7171
  });
6983
7172
  this.tombstones.set(evaluationId, {
6984
7173
  auditRecordId: auditId,
@@ -7480,6 +7669,7 @@ var GovernanceService = class {
7480
7669
  this.approvalRouter?.resolveNativeTicket(entry.approvalTicketId, "timeout");
7481
7670
  this.snapshotTicketResolution(entry);
7482
7671
  }
7672
+ const committed = entry.commitState;
7483
7673
  const resolution = entry.ticketResolution;
7484
7674
  const approvalContext = entry.approvalTicketId && resolution && (resolution.denialReason || resolution.escalatedAt) ? {
7485
7675
  ticket_id: entry.approvalTicketId,
@@ -7490,6 +7680,7 @@ var GovernanceService = class {
7490
7680
  } : {}
7491
7681
  } : void 0;
7492
7682
  const auditId = this.writeAudit({
7683
+ ...committed ? { id: committed.auditId } : {},
7493
7684
  timestampIso: entry.timestampIso,
7494
7685
  origin: entry.origin,
7495
7686
  agentId: entry.agentId,
@@ -7507,8 +7698,9 @@ var GovernanceService = class {
7507
7698
  approvalStatus: resolution?.status ?? null,
7508
7699
  approvedBy: resolution?.resolvedBy ?? null,
7509
7700
  approvalContext,
7510
- limitsChain: !entry.commitState && entry.budgetsAtEvaluate ? { budgets: entry.budgetsAtEvaluate } : void 0,
7511
- sidebandUnreported: true
7701
+ limitsChain: committed ? committed.limitsChain : entry.budgetsAtEvaluate ? { budgets: entry.budgetsAtEvaluate } : void 0,
7702
+ sidebandUnreported: true,
7703
+ sidebandCommitted: committed !== void 0
7512
7704
  });
7513
7705
  this.discardPending(entry);
7514
7706
  this.tombstones.set(entry.evaluationId, {
@@ -7518,7 +7710,7 @@ var GovernanceService = class {
7518
7710
  expiresAtMs: now + this.ttlMs
7519
7711
  });
7520
7712
  console.error(
7521
- `[helio] Sideband evaluation ${entry.evaluationId} expired without /audit (origin=${entry.origin}, tool=${entry.toolName}) \u2014 recorded as evaluation_expired`
7713
+ 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
7714
  );
7523
7715
  return "expired";
7524
7716
  }
@@ -7700,7 +7892,13 @@ var GovernanceService = class {
7700
7892
  const blockReason = deriveBlockReason(args);
7701
7893
  let evidenceChain = args.limitsChain ?? null;
7702
7894
  if (args.sidebandUnreported) {
7703
- evidenceChain = { ...evidenceChain ?? {}, sideband: { unreported: true } };
7895
+ evidenceChain = {
7896
+ ...evidenceChain ?? {},
7897
+ sideband: {
7898
+ unreported: true,
7899
+ ...args.sidebandCommitted ? { committed: true } : {}
7900
+ }
7901
+ };
7704
7902
  }
7705
7903
  if (args.approvalContext) {
7706
7904
  evidenceChain = { ...evidenceChain ?? {}, approval: { ...args.approvalContext } };
@@ -9022,64 +9220,6 @@ import { cors } from "hono/cors";
9022
9220
  import { serveStatic } from "@hono/node-server/serve-static";
9023
9221
  import { streamSSE } from "hono/streaming";
9024
9222
 
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
9223
  // src/dashboard/session.ts
9084
9224
  import { createHash as createHash3, createHmac as createHmac3, randomBytes, timingSafeEqual as timingSafeEqual3 } from "crypto";
9085
9225
  var DashboardSessionStore = class {
@@ -9237,6 +9377,10 @@ var budgetEventsQuerySchema = z8.object({
9237
9377
  limit: clampedQueryInt(50, 1, LIST_MAX_PAGE_SIZE),
9238
9378
  offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER)
9239
9379
  });
9380
+ var budgetEventsExportQuerySchema = z8.object({
9381
+ format: z8.preprocess((value) => value === "csv" ? "csv" : "json", z8.enum(["json", "csv"])),
9382
+ limit: clampedQueryInt(EXPORT_MAX_RECORDS, 1, EXPORT_MAX_RECORDS)
9383
+ });
9240
9384
  var analyticsQuerySchema = z8.object({
9241
9385
  from: optionalQueryString,
9242
9386
  to: optionalQueryString
@@ -9548,6 +9692,27 @@ function createDashboardAppWithLifecycle(deps, options) {
9548
9692
  offset: query.offset
9549
9693
  });
9550
9694
  });
9695
+ app.get("/api/budgets/:name/events/export", (c) => {
9696
+ const query = budgetEventsExportQuerySchema.parse(c.req.query());
9697
+ const name = c.req.param("name");
9698
+ const page = budgets?.listEventsForExport(name, query.limit) ?? { events: [], total: 0 };
9699
+ const safeName = name.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 64);
9700
+ const filename = safeName ? `helio-budget-${safeName}-events` : "helio-budget-events";
9701
+ if (query.format === "csv") {
9702
+ return new Response(budgetEventsToCsv(page.events), {
9703
+ headers: {
9704
+ "content-type": "text/csv; charset=utf-8",
9705
+ "content-disposition": `attachment; filename="${filename}.csv"`
9706
+ }
9707
+ });
9708
+ }
9709
+ return new Response(JSON.stringify(page.events, null, 2), {
9710
+ headers: {
9711
+ "content-type": "application/json",
9712
+ "content-disposition": `attachment; filename="${filename}.json"`
9713
+ }
9714
+ });
9715
+ });
9551
9716
  app.get("/api/analytics", (c) => {
9552
9717
  const query = analyticsQuerySchema.parse(c.req.query());
9553
9718
  const now = /* @__PURE__ */ new Date();