@gethelio/proxy 0.13.0 → 0.14.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
@@ -564,7 +564,7 @@ function rootConfigChecks(cfg, ctx) {
564
564
  ctx.addIssue({
565
565
  code: "custom",
566
566
  path: ["dashboard", "api_secret"],
567
- 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.)'
567
+ 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". Run `helio secret` and set the printed digest under `dashboard.api_secret` in your helio.yaml (a plaintext value is accepted but warned about at startup). (See docs/approvals.md.)'
568
568
  });
569
569
  }
570
570
  }
@@ -572,7 +572,7 @@ function rootConfigChecks(cfg, ctx) {
572
572
  ctx.addIssue({
573
573
  code: "custom",
574
574
  path: ["dashboard", "api_secret"],
575
- message: "dashboard.api_secret is required when dashboard.enabled is true unless dashboard.allow_open_mode is explicitly set to true. Generate one with `openssl rand -hex 32` and set it under dashboard.api_secret in helio.yaml."
575
+ message: "dashboard.api_secret is required when dashboard.enabled is true unless dashboard.allow_open_mode is explicitly set to true. Run `helio secret` and set the printed digest under dashboard.api_secret in helio.yaml."
576
576
  });
577
577
  }
578
578
  if (!requiresSecret && cfg.dashboard.enabled && !hasSecret && cfg.dashboard.allow_open_mode && !isLoopbackHost(cfg.dashboard.host)) {
@@ -938,6 +938,7 @@ function isNamedConfig(config) {
938
938
 
939
939
  // src/config/loader.ts
940
940
  import { readFile } from "fs/promises";
941
+ import { createHash } from "crypto";
941
942
  import yaml from "js-yaml";
942
943
 
943
944
  // src/util/format-zod-errors.ts
@@ -957,44 +958,53 @@ var ConfigError = class extends Error {
957
958
  }
958
959
  };
959
960
  var ENV_VAR_PATTERN = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
960
- function interpolateEnvVars(value, env = process.env) {
961
+ function interpolateTracked(value, env, path, out) {
961
962
  if (typeof value === "string") {
962
- return value.replace(ENV_VAR_PATTERN, (_match, varName) => {
963
+ let substitutions = 0;
964
+ const result = value.replace(ENV_VAR_PATTERN, (_match, varName) => {
963
965
  const envValue = env[varName];
964
966
  if (envValue === void 0) {
965
967
  throw new ConfigError(`Environment variable "${varName}" is not set`);
966
968
  }
969
+ substitutions += 1;
967
970
  return envValue;
968
971
  });
972
+ if (substitutions > 0 && out !== void 0) out.push(path.join("."));
973
+ return result;
969
974
  }
970
975
  if (Array.isArray(value)) {
971
- return value.map((item) => interpolateEnvVars(item, env));
976
+ return value.map((item, index) => interpolateTracked(item, env, [...path, String(index)], out));
972
977
  }
973
978
  if (value !== null && typeof value === "object") {
974
979
  return Object.fromEntries(
975
980
  Object.entries(value).map(([k, v]) => [
976
981
  k,
977
- interpolateEnvVars(v, env)
982
+ interpolateTracked(v, env, [...path, k], out)
978
983
  ])
979
984
  );
980
985
  }
981
986
  return value;
982
987
  }
983
- async function loadConfig(filePath, env) {
984
- let raw;
988
+ async function readConfigSource(filePath) {
989
+ let bytes;
985
990
  try {
986
- raw = await readFile(filePath, "utf-8");
991
+ bytes = await readFile(filePath);
987
992
  } catch {
988
993
  throw new ConfigError(`Cannot read config file: ${filePath}`);
989
994
  }
995
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
996
+ return { raw: bytes.toString("utf-8"), sha256 };
997
+ }
998
+ function parseConfigSource(source, filePath, env) {
990
999
  let parsed;
991
1000
  try {
992
- parsed = yaml.load(raw);
1001
+ parsed = yaml.load(source.raw);
993
1002
  } catch (err) {
994
1003
  const message = err instanceof Error ? err.message : String(err);
995
1004
  throw new ConfigError(`YAML parse error in ${filePath}: ${message}`);
996
1005
  }
997
- const interpolated = interpolateEnvVars(parsed, env);
1006
+ const interpolatedPaths = [];
1007
+ const interpolated = interpolateTracked(parsed, env ?? process.env, [], interpolatedPaths);
998
1008
  const result = helioConfigSchema.safeParse(interpolated);
999
1009
  if (!result.success) {
1000
1010
  const details = formatZodErrors(result.error).map(
@@ -1006,9 +1016,27 @@ async function loadConfig(filePath, env) {
1006
1016
  details
1007
1017
  );
1008
1018
  }
1009
- return result.data;
1019
+ return { config: result.data, interpolatedPaths };
1020
+ }
1021
+ async function loadConfigWithMeta(filePath, env) {
1022
+ const source = await readConfigSource(filePath);
1023
+ const { config, interpolatedPaths } = parseConfigSource(source, filePath, env);
1024
+ return { config, sha256: source.sha256, interpolatedPaths };
1025
+ }
1026
+ async function loadConfig(filePath, env) {
1027
+ return (await loadConfigWithMeta(filePath, env)).config;
1010
1028
  }
1011
1029
 
1030
+ // src/config/reload-outcomes.ts
1031
+ var POLICY_RELOAD_OUTCOMES = [
1032
+ "applied",
1033
+ "rejected_invalid",
1034
+ "rejected_unroutable",
1035
+ "rejected_budget_flush",
1036
+ "rejected_pinned",
1037
+ "watch_failed"
1038
+ ];
1039
+
1012
1040
  // src/config/watcher.ts
1013
1041
  import { watch } from "chokidar";
1014
1042
 
@@ -7292,6 +7320,7 @@ function clampInt(value, fallback, min, max) {
7292
7320
  // src/audit/store.ts
7293
7321
  var DRIFT_EVENT_DECISIONS_SQL = "('tool_drift', 'tool_drift_reverted')";
7294
7322
  var NON_TOOL_DECISIONS_SQL = "('tool_drift', 'tool_drift_reverted', 'rejected')";
7323
+ var POLICY_RELOAD_KIND_SQL = "'policy_reload'";
7295
7324
  var EXPORT_MAX_RECORDS = 1e4;
7296
7325
  var LIST_MAX_PAGE_SIZE = 1e3;
7297
7326
  var CREATE_TABLE_DDL = `
@@ -7325,7 +7354,8 @@ CREATE TABLE IF NOT EXISTS audit_records (
7325
7354
  metadata TEXT,
7326
7355
  protocol_version TEXT,
7327
7356
  created_at TEXT NOT NULL,
7328
- upstream TEXT
7357
+ upstream TEXT,
7358
+ config_sha256 TEXT
7329
7359
  );
7330
7360
  `;
7331
7361
  var CREATE_INDEX_DDL = `
@@ -7338,6 +7368,7 @@ CREATE INDEX IF NOT EXISTS idx_audit_upstream_status_created_at ON audit_records
7338
7368
  CREATE INDEX IF NOT EXISTS idx_audit_record_kind ON audit_records (record_kind);
7339
7369
  CREATE INDEX IF NOT EXISTS idx_audit_origin ON audit_records (origin);
7340
7370
  CREATE INDEX IF NOT EXISTS idx_audit_upstream ON audit_records (upstream);
7371
+ CREATE INDEX IF NOT EXISTS idx_audit_config_sha256 ON audit_records (config_sha256);
7341
7372
  `;
7342
7373
  var INSERT_SQL = `
7343
7374
  INSERT INTO audit_records (
@@ -7347,7 +7378,7 @@ INSERT INTO audit_records (
7347
7378
  upstream_http_status,
7348
7379
  total_duration_ms, approval_wait_ms, proxy_compute_ms,
7349
7380
  flagged_destructive, dry_run, record_kind, origin, metadata, protocol_version, created_at,
7350
- upstream
7381
+ upstream, config_sha256
7351
7382
  ) VALUES (
7352
7383
  @id, @timestamp, @session_id, @session_source, @agent_id, @environment, @tool_name, @tool_input,
7353
7384
  @policy_decision, @block_reason, @matched_rule, @matched_rule_index, @evidence_chain, @approval_status,
@@ -7355,7 +7386,7 @@ INSERT INTO audit_records (
7355
7386
  @upstream_http_status,
7356
7387
  @total_duration_ms, @approval_wait_ms, @proxy_compute_ms,
7357
7388
  @flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @protocol_version, @created_at,
7358
- @upstream
7389
+ @upstream, @config_sha256
7359
7390
  )
7360
7391
  `;
7361
7392
  var REQUIRED_AUDIT_COLUMNS = [
@@ -7375,10 +7406,14 @@ var REQUIRED_AUDIT_COLUMNS = [
7375
7406
  // Same clean break, same unreleased cycle (issue #219): released users see
7376
7407
  // ONE break, at v0.12.0.
7377
7408
  "protocol_version",
7378
- // The one ratified exception to the clean break (issue #292): a
7379
- // v0.12.0-complete database missing ONLY this column is migrated in place
7380
- // by migrateAuditUpstreamColumn instead of failing the assertion.
7381
- "upstream"
7409
+ // The ratified exception to the clean break (issue #292): a database that
7410
+ // is complete except for this column is migrated in place by
7411
+ // migrateAdditiveAuditColumns instead of failing the assertion.
7412
+ "upstream",
7413
+ // The second additive column under the same exception (issue #341): a
7414
+ // v0.13 database missing only this one, or a v0.12.0 database missing
7415
+ // both, migrates in place; anything older still clean-breaks.
7416
+ "config_sha256"
7382
7417
  ];
7383
7418
  function deserializeRow(row) {
7384
7419
  return {
@@ -7411,6 +7446,7 @@ function deserializeRow(row) {
7411
7446
  metadata: row.metadata ? JSON.parse(row.metadata) : null,
7412
7447
  protocol_version: row.protocol_version,
7413
7448
  upstream: row.upstream,
7449
+ config_sha256: row.config_sha256,
7414
7450
  created_at: row.created_at
7415
7451
  };
7416
7452
  }
@@ -7491,21 +7527,34 @@ function buildWhereClause(filters) {
7491
7527
  const clause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
7492
7528
  return { clause, params };
7493
7529
  }
7494
- function migrateAuditUpstreamColumn(db) {
7530
+ var ADDITIVE_AUDIT_COLUMNS = [
7531
+ { name: "upstream", ddl: "upstream TEXT" },
7532
+ { name: "config_sha256", ddl: "config_sha256 TEXT" }
7533
+ ];
7534
+ function migrateAdditiveAuditColumns(db) {
7495
7535
  const probe = () => {
7496
7536
  const rows = db.pragma("table_info(audit_records)");
7497
7537
  return new Set(rows.map((row) => row.name));
7498
7538
  };
7499
7539
  const existing = probe();
7500
- const missing = REQUIRED_AUDIT_COLUMNS.filter((name) => !existing.has(name));
7501
- if (missing.length !== 1 || missing[0] !== "upstream") return false;
7502
- try {
7503
- db.exec("ALTER TABLE audit_records ADD COLUMN upstream TEXT");
7504
- } catch (err) {
7505
- if (probe().has("upstream")) return false;
7506
- throw err;
7540
+ const missing = new Set(REQUIRED_AUDIT_COLUMNS.filter((name) => !existing.has(name)));
7541
+ if (missing.size === 0) return [];
7542
+ const additive = new Set(ADDITIVE_AUDIT_COLUMNS.map((column) => column.name));
7543
+ for (const name of missing) {
7544
+ if (!additive.has(name)) return [];
7545
+ }
7546
+ const added = [];
7547
+ for (const column of ADDITIVE_AUDIT_COLUMNS) {
7548
+ if (!missing.has(column.name)) continue;
7549
+ try {
7550
+ db.exec(`ALTER TABLE audit_records ADD COLUMN ${column.ddl}`);
7551
+ } catch (err) {
7552
+ if (probe().has(column.name)) continue;
7553
+ throw err;
7554
+ }
7555
+ added.push(column.name);
7507
7556
  }
7508
- return true;
7557
+ return added;
7509
7558
  }
7510
7559
  function restrictAuditFilePerms(dbPath) {
7511
7560
  if (dbPath === ":memory:" || process.platform === "win32") return;
@@ -7535,8 +7584,8 @@ var AuditStore = class {
7535
7584
  this.retentionMs = parseDuration(options.retention);
7536
7585
  this.includeResponses = options.includeResponses;
7537
7586
  this.db.exec(CREATE_TABLE_DDL);
7538
- if (migrateAuditUpstreamColumn(this.db)) {
7539
- console.error('[helio] Audit DB migrated: added column "upstream"');
7587
+ for (const name of migrateAdditiveAuditColumns(this.db)) {
7588
+ console.error(`[helio] Audit DB migrated: added column "${name}"`);
7540
7589
  }
7541
7590
  this.assertRequiredSchema(options.path);
7542
7591
  this.db.exec(CREATE_INDEX_DDL);
@@ -7647,6 +7696,7 @@ var AuditStore = class {
7647
7696
  metadata: record.metadata ? JSON.stringify(record.metadata) : null,
7648
7697
  protocol_version: record.protocol_version,
7649
7698
  upstream: record.upstream ?? null,
7699
+ config_sha256: record.config_sha256 ?? null,
7650
7700
  created_at: now
7651
7701
  });
7652
7702
  return resolvedId;
@@ -7734,26 +7784,27 @@ var AuditStore = class {
7734
7784
  const totals = this.db.prepare(
7735
7785
  `SELECT
7736
7786
  COUNT(*) as total,
7737
- COALESCE(SUM(CASE WHEN block_reason IS NULL AND policy_decision NOT IN ${DRIFT_EVENT_DECISIONS_SQL} THEN 1 ELSE 0 END), 0) as allowed_total,
7738
- COALESCE(SUM(CASE WHEN block_reason IS NOT NULL THEN 1 ELSE 0 END), 0) as blocked_total,
7739
- COALESCE(SUM(CASE WHEN dry_run = 1 THEN 1 ELSE 0 END), 0) as dry_run_total,
7740
- COALESCE(SUM(CASE WHEN dry_run = 0 THEN 1 ELSE 0 END), 0) as applied_total
7787
+ COALESCE(SUM(CASE WHEN block_reason IS NULL AND policy_decision NOT IN ${DRIFT_EVENT_DECISIONS_SQL} AND record_kind <> ${POLICY_RELOAD_KIND_SQL} THEN 1 ELSE 0 END), 0) as allowed_total,
7788
+ COALESCE(SUM(CASE WHEN block_reason IS NOT NULL AND record_kind <> ${POLICY_RELOAD_KIND_SQL} THEN 1 ELSE 0 END), 0) as blocked_total,
7789
+ COALESCE(SUM(CASE WHEN dry_run = 1 AND record_kind <> ${POLICY_RELOAD_KIND_SQL} THEN 1 ELSE 0 END), 0) as dry_run_total,
7790
+ COALESCE(SUM(CASE WHEN dry_run = 0 AND record_kind <> ${POLICY_RELOAD_KIND_SQL} THEN 1 ELSE 0 END), 0) as applied_total
7741
7791
  FROM audit_records ${clause}`
7742
7792
  ).get(...params);
7793
+ const decisionClause = clause ? `${clause} AND record_kind <> ${POLICY_RELOAD_KIND_SQL}` : `WHERE record_kind <> ${POLICY_RELOAD_KIND_SQL}`;
7743
7794
  const by_decision = this.db.prepare(
7744
7795
  `SELECT policy_decision as decision, COUNT(*) as count
7745
- FROM audit_records ${clause}
7796
+ FROM audit_records ${decisionClause}
7746
7797
  GROUP BY policy_decision
7747
7798
  ORDER BY count DESC`
7748
7799
  ).all(...params);
7749
- const blockedClause = clause ? `${clause} AND block_reason IS NOT NULL` : "WHERE block_reason IS NOT NULL";
7800
+ const blockedClause = clause ? `${clause} AND block_reason IS NOT NULL AND record_kind <> ${POLICY_RELOAD_KIND_SQL}` : `WHERE block_reason IS NOT NULL AND record_kind <> ${POLICY_RELOAD_KIND_SQL}`;
7750
7801
  const by_block_reason = this.db.prepare(
7751
7802
  `SELECT block_reason as reason, COUNT(*) as count
7752
7803
  FROM audit_records ${blockedClause}
7753
7804
  GROUP BY block_reason
7754
7805
  ORDER BY count DESC`
7755
7806
  ).all(...params);
7756
- const toolsClause = clause ? `${clause} AND policy_decision NOT IN ${NON_TOOL_DECISIONS_SQL}` : `WHERE policy_decision NOT IN ${NON_TOOL_DECISIONS_SQL}`;
7807
+ const toolsClause = clause ? `${clause} AND policy_decision NOT IN ${NON_TOOL_DECISIONS_SQL} AND record_kind <> ${POLICY_RELOAD_KIND_SQL}` : `WHERE policy_decision NOT IN ${NON_TOOL_DECISIONS_SQL} AND record_kind <> ${POLICY_RELOAD_KIND_SQL}`;
7757
7808
  const top_tools = this.db.prepare(
7758
7809
  `SELECT tool_name, upstream, COUNT(*) as count
7759
7810
  FROM audit_records ${toolsClause}
@@ -7841,11 +7892,12 @@ var CSV_HEADERS = [
7841
7892
  "record_kind",
7842
7893
  "origin",
7843
7894
  "metadata",
7844
- // Appended LAST (issues #218, #219, #292): positional consumers of the
7845
- // existing columns keep working — new columns always go at the end.
7895
+ // Appended LAST (issues #218, #219, #292, #341): positional consumers of
7896
+ // the existing columns keep working — new columns always go at the end.
7846
7897
  "session_source",
7847
7898
  "protocol_version",
7848
- "upstream"
7899
+ "upstream",
7900
+ "config_sha256"
7849
7901
  ];
7850
7902
  var FORMULA_PREFIXES = /^[=+\-@\t\r]/;
7851
7903
  function csvEscape(value) {
@@ -8207,19 +8259,23 @@ import { HTTPException } from "hono/http-exception";
8207
8259
  import { z as z5 } from "zod";
8208
8260
 
8209
8261
  // src/auth/bearer.ts
8210
- import { createHash, timingSafeEqual } from "crypto";
8262
+ import { createHash as createHash2, timingSafeEqual } from "crypto";
8263
+ var BEARER_PREFIX = "Bearer ";
8264
+ var DIGEST_PATTERN = /^sha256:([0-9a-f]{64})$/;
8211
8265
  function verifyBearer(authHeader, expected) {
8212
8266
  if (!authHeader || !expected) return false;
8213
- const expectedHeader = `Bearer ${expected}`;
8214
- const actualDigest = createHash("sha256").update(authHeader).digest();
8215
- const expectedDigest = createHash("sha256").update(expectedHeader).digest();
8267
+ if (!authHeader.startsWith(BEARER_PREFIX)) return false;
8268
+ const presented = authHeader.slice(BEARER_PREFIX.length);
8269
+ const storedHex = DIGEST_PATTERN.exec(expected)?.[1];
8270
+ const expectedDigest = storedHex !== void 0 ? Buffer.from(storedHex, "hex") : createHash2("sha256").update(expected, "utf-8").digest();
8271
+ const actualDigest = createHash2("sha256").update(presented, "utf-8").digest();
8216
8272
  return timingSafeEqual(actualDigest, expectedDigest);
8217
8273
  }
8218
8274
 
8219
8275
  // src/sideband/governance-api.ts
8220
8276
  import { Hono as Hono4 } from "hono";
8221
8277
  import { z as z4 } from "zod";
8222
- import { createHash as createHash2 } from "crypto";
8278
+ import { createHash as createHash3 } from "crypto";
8223
8279
  var originSchema = z4.string().regex(/^[a-z0-9_-]{1,64}$/, "origin must match ^[a-z0-9_-]{1,64}$").default("sideband");
8224
8280
  var metadataSchema = z4.record(z4.string(), z4.unknown()).nullish();
8225
8281
  var toolDefinitionSchema = z4.object({
@@ -8376,7 +8432,7 @@ function auditPayloadHash(data) {
8376
8432
  actual_amount: data.actual_amount ?? null,
8377
8433
  evidence: canonicalEvidence(data.evidence)
8378
8434
  };
8379
- return createHash2("sha256").update(canonicalize(semantic)).digest("hex");
8435
+ return createHash3("sha256").update(canonicalize(semantic)).digest("hex");
8380
8436
  }
8381
8437
  function canonicalEvidence(evidence) {
8382
8438
  if (!evidence || evidence.length === 0) return null;
@@ -9818,6 +9874,15 @@ var AuditWriter = class {
9818
9874
  bufferSize;
9819
9875
  onPush;
9820
9876
  onPersist;
9877
+ /**
9878
+ * The hash of the config file in force (issue #341), stamped onto every
9879
+ * record whose builder left `config_sha256` nullish. Seeded at
9880
+ * construction so no record is pushed unstamped before the first
9881
+ * `setConfigSha256`; replaced by the reload path once the new policy is
9882
+ * in force. Null only when the caller never had a hash (library
9883
+ * embeddings without a config file).
9884
+ */
9885
+ configSha256;
9821
9886
  buffer = [];
9822
9887
  timer = null;
9823
9888
  flushSoonTimer = null;
@@ -9827,6 +9892,7 @@ var AuditWriter = class {
9827
9892
  this.bufferSize = options.bufferSize ?? 50;
9828
9893
  this.onPush = options.onPush;
9829
9894
  this.onPersist = options.onPersist;
9895
+ this.configSha256 = options.configSha256 ?? null;
9830
9896
  const intervalMs = options.flushIntervalMs ?? 100;
9831
9897
  if (intervalMs > 0) {
9832
9898
  this.timer = setInterval(() => {
@@ -9847,8 +9913,9 @@ var AuditWriter = class {
9847
9913
  * is scheduled. This keeps request-path latency bounded even under bursty
9848
9914
  * write load.
9849
9915
  */
9850
- push(record, id = randomUUID6()) {
9916
+ push(input, id = randomUUID6()) {
9851
9917
  if (this.closed) return;
9918
+ const record = this.stamp(input);
9852
9919
  this.buffer.push({ id, record });
9853
9920
  this.onPush?.(record, id);
9854
9921
  if (this.buffer.length >= this.bufferSize) {
@@ -9863,12 +9930,30 @@ var AuditWriter = class {
9863
9930
  * A fatal-process crash still invokes the crash-drain hook, which calls
9864
9931
  * `flush()` synchronously before exit.
9865
9932
  */
9866
- pushImmediate(record, id = randomUUID6()) {
9933
+ pushImmediate(input, id = randomUUID6()) {
9867
9934
  if (this.closed) return;
9935
+ const record = this.stamp(input);
9868
9936
  this.buffer.push({ id, record });
9869
9937
  this.onPush?.(record, id);
9870
9938
  this.scheduleFlushSoon();
9871
9939
  }
9940
+ /**
9941
+ * Replace the config hash every later record is stamped with. Called by
9942
+ * the reload path after the new policy is in force, before the reload's
9943
+ * own record is pushed, so the reload record and every call it governs
9944
+ * carry the new hash.
9945
+ */
9946
+ setConfigSha256(hash) {
9947
+ this.configSha256 = hash;
9948
+ }
9949
+ /**
9950
+ * Stamp the active config hash onto a record whose builder left the field
9951
+ * nullish; a record that already carries a hash is passed through. The
9952
+ * stamped record is what `onPush`, the store, and `onPersist` see.
9953
+ */
9954
+ stamp(record) {
9955
+ return record.config_sha256 == null ? { ...record, config_sha256: this.configSha256 } : record;
9956
+ }
9872
9957
  /**
9873
9958
  * Schedule a flush on the next tick, coalescing multiple calls into one.
9874
9959
  */
@@ -9964,6 +10049,25 @@ function buildHeaderMismatchAuditRecord(rejection, environment, upstream) {
9964
10049
  };
9965
10050
  }
9966
10051
 
10052
+ // src/audit/policy-reload.ts
10053
+ import { basename } from "path";
10054
+ import { z as z6 } from "zod";
10055
+ var policyReloadEvidenceSchema = z6.object({
10056
+ outcome: z6.enum(POLICY_RELOAD_OUTCOMES),
10057
+ config_path: z6.string(),
10058
+ sha256_before: z6.string(),
10059
+ sha256_after: z6.string().nullable(),
10060
+ rule_count_before: z6.number(),
10061
+ rule_count_after: z6.number().nullable(),
10062
+ default_action_before: z6.enum(["allow", "deny"]),
10063
+ default_action_after: z6.enum(["allow", "deny"]).nullable(),
10064
+ budget_count_before: z6.number(),
10065
+ budget_count_after: z6.number().nullable(),
10066
+ rules_removed: z6.array(z6.string()),
10067
+ restart_required_paths: z6.array(z6.string()),
10068
+ error: z6.string().nullable()
10069
+ }).strict();
10070
+
9967
10071
  // src/approval/queue.ts
9968
10072
  import { randomUUID as randomUUID7 } from "crypto";
9969
10073
  var ApprovalQueue = class {
@@ -10623,7 +10727,7 @@ function createChannels(channels) {
10623
10727
  // src/approval/slack-actions.ts
10624
10728
  import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
10625
10729
  import { Hono as Hono6 } from "hono";
10626
- import { z as z6 } from "zod";
10730
+ import { z as z7 } from "zod";
10627
10731
  var MAX_TIMESTAMP_AGE_S = 300;
10628
10732
  var REJECTION_LOG_WINDOW_MS = 6e4;
10629
10733
  var REJECTION_LOG_SAMPLE_EVERY = 25;
@@ -10689,12 +10793,12 @@ function verifySlackSignature(secrets, timestamp, rawBody, signature) {
10689
10793
  }
10690
10794
  return false;
10691
10795
  }
10692
- var slackActionPayloadSchema = z6.object({
10693
- type: z6.string(),
10694
- user: z6.object({ id: z6.string(), username: z6.string() }),
10695
- actions: z6.array(z6.object({ action_id: z6.string() })),
10696
- channel: z6.object({ id: z6.string() }),
10697
- message: z6.object({ ts: z6.string() })
10796
+ var slackActionPayloadSchema = z7.object({
10797
+ type: z7.string(),
10798
+ user: z7.object({ id: z7.string(), username: z7.string() }),
10799
+ actions: z7.array(z7.object({ action_id: z7.string() })),
10800
+ channel: z7.object({ id: z7.string() }),
10801
+ message: z7.object({ ts: z7.string() })
10698
10802
  });
10699
10803
  function parseActionPayload(rawBody) {
10700
10804
  try {
@@ -10824,17 +10928,17 @@ function createSlackActionApp(options) {
10824
10928
 
10825
10929
  // src/approval/api.ts
10826
10930
  import { Hono as Hono7 } from "hono";
10827
- import { z as z7 } from "zod";
10828
- var approveBody = z7.object({
10829
- approved_by: z7.string().min(1)
10931
+ import { z as z8 } from "zod";
10932
+ var approveBody = z8.object({
10933
+ approved_by: z8.string().min(1)
10830
10934
  });
10831
- var denyBody = z7.object({
10832
- denied_by: z7.string().min(1),
10833
- reason: z7.string().optional()
10935
+ var denyBody = z8.object({
10936
+ denied_by: z8.string().min(1),
10937
+ reason: z8.string().optional()
10834
10938
  });
10835
- var breakGlassBody = z7.object({
10836
- approved_by: z7.string().min(1),
10837
- reason: z7.string().min(1)
10939
+ var breakGlassBody = z8.object({
10940
+ approved_by: z8.string().min(1),
10941
+ reason: z8.string().min(1)
10838
10942
  });
10839
10943
  var APPROVAL_STATUSES = [
10840
10944
  "pending",
@@ -10847,18 +10951,18 @@ var APPROVAL_STATUSES = [
10847
10951
  "cancelled"
10848
10952
  ];
10849
10953
  var approvalStatusSet = new Set(APPROVAL_STATUSES);
10850
- var listApprovalsQuery = z7.object({
10851
- status: z7.preprocess(
10954
+ var listApprovalsQuery = z8.object({
10955
+ status: z8.preprocess(
10852
10956
  (value) => typeof value === "string" && approvalStatusSet.has(value) ? value : void 0,
10853
- z7.enum(APPROVAL_STATUSES).optional()
10957
+ z8.enum(APPROVAL_STATUSES).optional()
10854
10958
  ),
10855
- limit: z7.preprocess(
10959
+ limit: z8.preprocess(
10856
10960
  (value) => clampInt(typeof value === "string" ? value : void 0, 50, 1, 1e3),
10857
- z7.number().int()
10961
+ z8.number().int()
10858
10962
  ),
10859
- offset: z7.preprocess(
10963
+ offset: z8.preprocess(
10860
10964
  (value) => clampInt(typeof value === "string" ? value : void 0, 0, 0, Number.MAX_SAFE_INTEGER),
10861
- z7.number().int()
10965
+ z8.number().int()
10862
10966
  )
10863
10967
  });
10864
10968
  function createApprovalApp(router, queue, options) {
@@ -11000,25 +11104,25 @@ function createApprovalApp(router, queue, options) {
11000
11104
  // src/dashboard/api.ts
11001
11105
  import { readFileSync } from "fs";
11002
11106
  import { join } from "path";
11003
- import { randomUUID as randomUUID8 } from "crypto";
11107
+ import { randomBytes as randomBytes2, randomUUID as randomUUID8 } from "crypto";
11004
11108
  import { Hono as Hono8 } from "hono";
11005
11109
  import { HTTPException as HTTPException2 } from "hono/http-exception";
11006
- import { z as z8 } from "zod";
11110
+ import { z as z9 } from "zod";
11007
11111
  import { cors } from "hono/cors";
11008
11112
  import { serveStatic } from "@hono/node-server/serve-static";
11009
11113
  import { streamSSE } from "hono/streaming";
11010
11114
 
11011
11115
  // src/dashboard/session.ts
11012
- import { createHash as createHash3, createHmac as createHmac3, randomBytes, timingSafeEqual as timingSafeEqual3 } from "crypto";
11116
+ import { createHash as createHash4, createHmac as createHmac3, randomBytes, timingSafeEqual as timingSafeEqual3 } from "crypto";
11013
11117
  var DashboardSessionStore = class {
11014
- secret;
11118
+ signingKey;
11015
11119
  ttlMs;
11016
11120
  now;
11017
11121
  records = /* @__PURE__ */ new Map();
11018
11122
  timer = null;
11019
11123
  closed = false;
11020
11124
  constructor(options) {
11021
- this.secret = options.secret;
11125
+ this.signingKey = options.signingKey;
11022
11126
  this.ttlMs = options.ttlMs ?? 8 * 60 * 60 * 1e3;
11023
11127
  this.now = options.now ?? Date.now;
11024
11128
  const cleanupIntervalMs = options.cleanupIntervalMs ?? 6e4;
@@ -11090,35 +11194,35 @@ var DashboardSessionStore = class {
11090
11194
  const id = token.slice(0, dot);
11091
11195
  const signature = token.slice(dot + 1);
11092
11196
  const expected = this.sign(id);
11093
- const actualDigest = createHash3("sha256").update(signature).digest();
11094
- const expectedDigest = createHash3("sha256").update(expected).digest();
11197
+ const actualDigest = createHash4("sha256").update(signature).digest();
11198
+ const expectedDigest = createHash4("sha256").update(expected).digest();
11095
11199
  if (!timingSafeEqual3(actualDigest, expectedDigest)) return void 0;
11096
11200
  return id;
11097
11201
  }
11098
11202
  sign(id) {
11099
- return createHmac3("sha256", this.secret).update(id).digest("base64url");
11203
+ return createHmac3("sha256", this.signingKey).update(id).digest("base64url");
11100
11204
  }
11101
11205
  };
11102
11206
 
11103
11207
  // src/dashboard/api.ts
11104
- var optionalQueryString = z8.preprocess(
11208
+ var optionalQueryString = z9.preprocess(
11105
11209
  (value) => typeof value === "string" && value.length > 0 ? value : void 0,
11106
- z8.string().optional()
11210
+ z9.string().optional()
11107
11211
  );
11108
- var optionalQueryInt = z8.preprocess((value) => {
11212
+ var optionalQueryInt = z9.preprocess((value) => {
11109
11213
  if (typeof value !== "string" || value.length === 0) return void 0;
11110
11214
  const parsed = Number.parseInt(value, 10);
11111
11215
  return Number.isFinite(parsed) ? parsed : void 0;
11112
- }, z8.number().int().optional());
11113
- var queryBoolean = z8.preprocess(
11216
+ }, z9.number().int().optional());
11217
+ var queryBoolean = z9.preprocess(
11114
11218
  (value) => value === "true" ? true : value === "false" ? false : void 0,
11115
- z8.boolean().optional()
11219
+ z9.boolean().optional()
11116
11220
  );
11117
- var clampedQueryInt = (fallback, min, max) => z8.preprocess(
11221
+ var clampedQueryInt = (fallback, min, max) => z9.preprocess(
11118
11222
  (value) => clampInt(typeof value === "string" ? value : void 0, fallback, min, max),
11119
- z8.number().int()
11223
+ z9.number().int()
11120
11224
  );
11121
- var feedQuerySchema = z8.object({
11225
+ var feedQuerySchema = z9.object({
11122
11226
  limit: clampedQueryInt(50, 1, 200),
11123
11227
  offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER),
11124
11228
  // The feed's server-side filters (issues #292, #316): attribution and
@@ -11128,8 +11232,8 @@ var feedQuerySchema = z8.object({
11128
11232
  upstream: optionalQueryString,
11129
11233
  session_source: optionalQueryString
11130
11234
  });
11131
- var auditExportQuerySchema = z8.object({
11132
- format: z8.preprocess((value) => value === "csv" ? "csv" : "json", z8.enum(["json", "csv"])),
11235
+ var auditExportQuerySchema = z9.object({
11236
+ format: z9.preprocess((value) => value === "csv" ? "csv" : "json", z9.enum(["json", "csv"])),
11133
11237
  limit: clampedQueryInt(EXPORT_MAX_RECORDS, 1, EXPORT_MAX_RECORDS),
11134
11238
  tool: optionalQueryString,
11135
11239
  decision: optionalQueryString,
@@ -11149,7 +11253,7 @@ var auditExportQuerySchema = z8.object({
11149
11253
  upstream: optionalQueryString,
11150
11254
  session_source: optionalQueryString
11151
11255
  });
11152
- var auditQuerySchema = z8.object({
11256
+ var auditQuerySchema = z9.object({
11153
11257
  limit: clampedQueryInt(50, 1, LIST_MAX_PAGE_SIZE),
11154
11258
  offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER),
11155
11259
  tool: optionalQueryString,
@@ -11171,21 +11275,21 @@ var auditQuerySchema = z8.object({
11171
11275
  upstream: optionalQueryString,
11172
11276
  session_source: optionalQueryString
11173
11277
  });
11174
- var budgetEventsQuerySchema = z8.object({
11278
+ var budgetEventsQuerySchema = z9.object({
11175
11279
  limit: clampedQueryInt(50, 1, LIST_MAX_PAGE_SIZE),
11176
11280
  offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER)
11177
11281
  });
11178
- var budgetEventsExportQuerySchema = z8.object({
11179
- format: z8.preprocess((value) => value === "csv" ? "csv" : "json", z8.enum(["json", "csv"])),
11282
+ var budgetEventsExportQuerySchema = z9.object({
11283
+ format: z9.preprocess((value) => value === "csv" ? "csv" : "json", z9.enum(["json", "csv"])),
11180
11284
  limit: clampedQueryInt(EXPORT_MAX_RECORDS, 1, EXPORT_MAX_RECORDS)
11181
11285
  });
11182
- var analyticsQuerySchema = z8.object({
11286
+ var analyticsQuerySchema = z9.object({
11183
11287
  from: optionalQueryString,
11184
11288
  to: optionalQueryString,
11185
11289
  upstream: optionalQueryString
11186
11290
  });
11187
- var authSessionBodySchema = z8.object({
11188
- secret: z8.string()
11291
+ var authSessionBodySchema = z9.object({
11292
+ secret: z9.string()
11189
11293
  });
11190
11294
  var SESSION_COOKIE = "helio_session";
11191
11295
  var SESSION_TTL_MS = 8 * 60 * 60 * 1e3;
@@ -11258,7 +11362,10 @@ function createDashboardAppWithLifecycle(deps, options) {
11258
11362
  budgets
11259
11363
  } = deps;
11260
11364
  const apiSecret = options?.apiSecret;
11261
- const sessionStore = apiSecret ? new DashboardSessionStore({ secret: apiSecret, ttlMs: SESSION_TTL_MS }) : void 0;
11365
+ const sessionStore = apiSecret ? new DashboardSessionStore({
11366
+ signingKey: randomBytes2(32).toString("hex"),
11367
+ ttlMs: SESSION_TTL_MS
11368
+ }) : void 0;
11262
11369
  const app = new Hono8();
11263
11370
  app.onError((err, c) => {
11264
11371
  if (err instanceof HTTPException2) return err.getResponse();
@@ -11667,7 +11774,8 @@ var EVENT_TYPES = [
11667
11774
  "limit_warning",
11668
11775
  "approval_notification_failed",
11669
11776
  "budget_update",
11670
- "budget_breached"
11777
+ "budget_breached",
11778
+ "policy_reload"
11671
11779
  ];
11672
11780
  var DashboardEventBus = class {
11673
11781
  emitter = new EventEmitter();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethelio/proxy",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "description": "Open-source MCP governance proxy for AI agents",
6
6
  "license": "Apache-2.0",
@@ -41,7 +41,7 @@
41
41
  "build": "pnpm run build:dashboard && pnpm run build:proxy && pnpm run bundle:dashboard-assets && pnpm run check:dashboard-assets",
42
42
  "test": "vitest run",
43
43
  "test:watch": "vitest",
44
- "typecheck": "tsc --noEmit",
44
+ "typecheck": "tsc --noEmit && tsc -p scripts/tsconfig.json",
45
45
  "benchmark": "tsx scripts/benchmark.ts",
46
46
  "prepack": "pnpm run check:dashboard-assets"
47
47
  },