@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/cli.js CHANGED
@@ -2,10 +2,10 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { Command } from "commander";
5
- import { writeFile } from "fs/promises";
5
+ import { mkdir, writeFile } from "fs/promises";
6
6
  import { existsSync } from "fs";
7
- import { randomBytes as randomBytes2 } from "crypto";
8
- import { dirname, resolve } from "path";
7
+ import { randomBytes as randomBytes3 } from "crypto";
8
+ import { dirname, join as join2, resolve } from "path";
9
9
  import { fileURLToPath } from "url";
10
10
 
11
11
  // src/version.ts
@@ -574,7 +574,7 @@ function rootConfigChecks(cfg, ctx) {
574
574
  ctx.addIssue({
575
575
  code: "custom",
576
576
  path: ["dashboard", "api_secret"],
577
- 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.)'
577
+ 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.)'
578
578
  });
579
579
  }
580
580
  }
@@ -582,7 +582,7 @@ function rootConfigChecks(cfg, ctx) {
582
582
  ctx.addIssue({
583
583
  code: "custom",
584
584
  path: ["dashboard", "api_secret"],
585
- 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."
585
+ 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."
586
586
  });
587
587
  }
588
588
  if (!requiresSecret && cfg.dashboard.enabled && !hasSecret && cfg.dashboard.allow_open_mode && !isLoopbackHost(cfg.dashboard.host)) {
@@ -948,6 +948,7 @@ function isNamedConfig(config) {
948
948
 
949
949
  // src/config/loader.ts
950
950
  import { readFile } from "fs/promises";
951
+ import { createHash } from "crypto";
951
952
  import yaml from "js-yaml";
952
953
 
953
954
  // src/util/format-zod-errors.ts
@@ -967,44 +968,53 @@ var ConfigError = class extends Error {
967
968
  }
968
969
  };
969
970
  var ENV_VAR_PATTERN = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
970
- function interpolateEnvVars(value, env = process.env) {
971
+ function interpolateTracked(value, env, path, out) {
971
972
  if (typeof value === "string") {
972
- return value.replace(ENV_VAR_PATTERN, (_match, varName) => {
973
+ let substitutions = 0;
974
+ const result = value.replace(ENV_VAR_PATTERN, (_match, varName) => {
973
975
  const envValue = env[varName];
974
976
  if (envValue === void 0) {
975
977
  throw new ConfigError(`Environment variable "${varName}" is not set`);
976
978
  }
979
+ substitutions += 1;
977
980
  return envValue;
978
981
  });
982
+ if (substitutions > 0 && out !== void 0) out.push(path.join("."));
983
+ return result;
979
984
  }
980
985
  if (Array.isArray(value)) {
981
- return value.map((item) => interpolateEnvVars(item, env));
986
+ return value.map((item, index) => interpolateTracked(item, env, [...path, String(index)], out));
982
987
  }
983
988
  if (value !== null && typeof value === "object") {
984
989
  return Object.fromEntries(
985
990
  Object.entries(value).map(([k, v]) => [
986
991
  k,
987
- interpolateEnvVars(v, env)
992
+ interpolateTracked(v, env, [...path, k], out)
988
993
  ])
989
994
  );
990
995
  }
991
996
  return value;
992
997
  }
993
- async function loadConfig(filePath, env) {
994
- let raw;
998
+ async function readConfigSource(filePath) {
999
+ let bytes;
995
1000
  try {
996
- raw = await readFile(filePath, "utf-8");
1001
+ bytes = await readFile(filePath);
997
1002
  } catch {
998
1003
  throw new ConfigError(`Cannot read config file: ${filePath}`);
999
1004
  }
1005
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
1006
+ return { raw: bytes.toString("utf-8"), sha256 };
1007
+ }
1008
+ function parseConfigSource(source, filePath, env) {
1000
1009
  let parsed;
1001
1010
  try {
1002
- parsed = yaml.load(raw);
1011
+ parsed = yaml.load(source.raw);
1003
1012
  } catch (err) {
1004
1013
  const message = err instanceof Error ? err.message : String(err);
1005
1014
  throw new ConfigError(`YAML parse error in ${filePath}: ${message}`);
1006
1015
  }
1007
- const interpolated = interpolateEnvVars(parsed, env);
1016
+ const interpolatedPaths = [];
1017
+ const interpolated = interpolateTracked(parsed, env ?? process.env, [], interpolatedPaths);
1008
1018
  const result = helioConfigSchema.safeParse(interpolated);
1009
1019
  if (!result.success) {
1010
1020
  const details = formatZodErrors(result.error).map(
@@ -1016,7 +1026,41 @@ async function loadConfig(filePath, env) {
1016
1026
  details
1017
1027
  );
1018
1028
  }
1019
- return result.data;
1029
+ return { config: result.data, interpolatedPaths };
1030
+ }
1031
+ async function loadConfigWithMeta(filePath, env) {
1032
+ const source = await readConfigSource(filePath);
1033
+ const { config, interpolatedPaths } = parseConfigSource(source, filePath, env);
1034
+ return { config, sha256: source.sha256, interpolatedPaths };
1035
+ }
1036
+ async function loadConfig(filePath, env) {
1037
+ return (await loadConfigWithMeta(filePath, env)).config;
1038
+ }
1039
+
1040
+ // src/config/reload-outcomes.ts
1041
+ var POLICY_RELOAD_OUTCOMES = [
1042
+ "applied",
1043
+ "rejected_invalid",
1044
+ "rejected_unroutable",
1045
+ "rejected_budget_flush",
1046
+ "rejected_pinned",
1047
+ "watch_failed"
1048
+ ];
1049
+
1050
+ // src/config/pin.ts
1051
+ var CONFIG_PIN_ENV = "HELIO_CONFIG_SHA256";
1052
+ var SHA256_HEX = /^[0-9a-f]{64}$/;
1053
+ function normalizeConfigPin(raw) {
1054
+ const trimmed = raw.trim();
1055
+ const unprefixed = /^sha256:/i.test(trimmed) ? trimmed.slice("sha256:".length) : trimmed;
1056
+ const hex = unprefixed.toLowerCase();
1057
+ return SHA256_HEX.test(hex) ? hex : null;
1058
+ }
1059
+ function readConfigPin(env = process.env) {
1060
+ const raw = env[CONFIG_PIN_ENV];
1061
+ if (raw === void 0) return { status: "unset" };
1062
+ const sha256 = normalizeConfigPin(raw);
1063
+ return sha256 === null ? { status: "invalid", raw } : { status: "set", sha256 };
1020
1064
  }
1021
1065
 
1022
1066
  // src/config/watcher.ts
@@ -1447,24 +1491,56 @@ function compileContributor(contributor, budgetName, index) {
1447
1491
  }
1448
1492
 
1449
1493
  // src/config/watcher.ts
1494
+ var PolicyReloadRejectedError = class extends Error {
1495
+ outcome;
1496
+ constructor(outcome, message, options) {
1497
+ super(message, options);
1498
+ this.name = "PolicyReloadRejectedError";
1499
+ this.outcome = outcome;
1500
+ }
1501
+ };
1502
+ function beforeFacts(baseline) {
1503
+ return {
1504
+ sha256Before: baseline.sha256,
1505
+ ruleCountBefore: baseline.config.policies.rules.length,
1506
+ defaultActionBefore: baseline.config.policies.default,
1507
+ budgetCountBefore: baseline.config.budgets.length
1508
+ };
1509
+ }
1510
+ function rulesRemovedBetween(previous, next) {
1511
+ const kept = new Set(
1512
+ next.policies.rules.flatMap((rule) => rule.name === void 0 ? [] : [rule.name])
1513
+ );
1514
+ return previous.policies.rules.flatMap(
1515
+ (rule) => rule.name === void 0 || kept.has(rule.name) ? [] : [rule.name]
1516
+ );
1517
+ }
1450
1518
  var ConfigWatcher = class {
1451
1519
  configPath;
1452
1520
  onReload;
1453
1521
  onError;
1454
1522
  onReady;
1455
- initialConfig;
1523
+ initial;
1456
1524
  env;
1457
1525
  debounceMs;
1526
+ /** When set, a reload whose bytes hash differently is refused before parsing (issue #341). */
1527
+ pinnedSha256;
1528
+ /** The last configuration that applied: the "before" side of the next attempt. */
1529
+ lastGood;
1458
1530
  watcher = null;
1459
1531
  debounceTimer = null;
1532
+ /** Set once the watch itself has failed; later change events and in-flight reloads are ignored (issue #351). */
1533
+ watchFailed = false;
1460
1534
  constructor(options) {
1461
1535
  this.configPath = options.configPath;
1462
1536
  this.onReload = options.onReload;
1463
1537
  this.onError = options.onError;
1464
1538
  this.onReady = options.onReady;
1465
- this.initialConfig = options.initialConfig;
1539
+ this.initial = options.initial;
1540
+ this.lastGood = options.initial;
1466
1541
  this.env = options.env;
1467
1542
  this.debounceMs = options.debounceMs ?? 200;
1543
+ this.pinnedSha256 = options.pinnedSha256;
1468
1544
  }
1469
1545
  /** Start watching the config file for changes. */
1470
1546
  start() {
@@ -1475,11 +1551,34 @@ var ConfigWatcher = class {
1475
1551
  awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 10 }
1476
1552
  });
1477
1553
  this.watcher.on("change", () => {
1554
+ if (this.watchFailed) return;
1478
1555
  this.scheduleReload();
1479
1556
  });
1480
1557
  this.watcher.on("ready", () => {
1481
1558
  if (this.watcher && this.onReady) this.onReady();
1482
1559
  });
1560
+ this.watcher.on("error", (err) => {
1561
+ const error = err instanceof Error ? err : new Error(String(err));
1562
+ if (this.watchFailed) return;
1563
+ this.watchFailed = true;
1564
+ if (this.debounceTimer !== null) {
1565
+ clearTimeout(this.debounceTimer);
1566
+ this.debounceTimer = null;
1567
+ }
1568
+ const before = beforeFacts(this.lastGood);
1569
+ this.onError(error, {
1570
+ configPath: this.configPath,
1571
+ outcome: "watch_failed",
1572
+ ...before,
1573
+ sha256After: null,
1574
+ ruleCountAfter: null,
1575
+ defaultActionAfter: null,
1576
+ budgetCountAfter: null,
1577
+ rulesRemoved: [],
1578
+ restartRequiredPaths: [],
1579
+ error: error.message
1580
+ });
1581
+ });
1483
1582
  }
1484
1583
  /** Stop watching and clean up resources. */
1485
1584
  close() {
@@ -1502,22 +1601,320 @@ var ConfigWatcher = class {
1502
1601
  }, this.debounceMs);
1503
1602
  }
1504
1603
  async reload() {
1604
+ const before = beforeFacts(this.lastGood);
1605
+ let sha256After = null;
1606
+ let parsed = null;
1607
+ let restartRequiredPaths = [];
1505
1608
  try {
1506
- const config = await loadConfig(this.configPath, this.env);
1609
+ const source = await readConfigSource(this.configPath);
1610
+ sha256After = source.sha256;
1611
+ if (this.pinnedSha256 !== void 0 && source.sha256 !== this.pinnedSha256) {
1612
+ throw new PolicyReloadRejectedError(
1613
+ "rejected_pinned",
1614
+ `config hash sha256:${source.sha256} does not match the pinned sha256:${this.pinnedSha256}`
1615
+ );
1616
+ }
1617
+ const { config } = parseConfigSource(source, this.configPath, this.env);
1618
+ parsed = config;
1507
1619
  const { policy, warnings } = compilePolicies(config.policies);
1508
1620
  const budgets = compileBudgets(config.budgets);
1509
- const restartRequiredPaths = this.initialConfig !== void 0 ? diffReloadBoundary(this.initialConfig, config).restartRequiredPaths : [];
1510
- this.onReload(policy, warnings, restartRequiredPaths, budgets);
1621
+ restartRequiredPaths = diffReloadBoundary(this.initial.config, config).restartRequiredPaths;
1622
+ const facts = {
1623
+ configPath: this.configPath,
1624
+ outcome: "applied",
1625
+ ...before,
1626
+ sha256After: source.sha256,
1627
+ ruleCountAfter: config.policies.rules.length,
1628
+ defaultActionAfter: config.policies.default,
1629
+ budgetCountAfter: config.budgets.length,
1630
+ rulesRemoved: rulesRemovedBetween(this.lastGood.config, config),
1631
+ restartRequiredPaths,
1632
+ error: null
1633
+ };
1634
+ if (this.watchFailed) return;
1635
+ this.onReload(policy, warnings, restartRequiredPaths, budgets, facts);
1636
+ this.lastGood = { config, sha256: source.sha256 };
1511
1637
  } catch (err) {
1512
- if (err instanceof Error) {
1513
- this.onError(err);
1514
- } else {
1515
- this.onError(new Error(String(err)));
1516
- }
1638
+ const error = err instanceof Error ? err : new Error(String(err));
1639
+ const outcome = error instanceof PolicyReloadRejectedError ? error.outcome : "rejected_invalid";
1640
+ if (this.watchFailed) return;
1641
+ this.onError(error, {
1642
+ configPath: this.configPath,
1643
+ outcome,
1644
+ ...before,
1645
+ sha256After,
1646
+ ruleCountAfter: parsed === null ? null : parsed.policies.rules.length,
1647
+ defaultActionAfter: parsed === null ? null : parsed.policies.default,
1648
+ budgetCountAfter: parsed === null ? null : parsed.budgets.length,
1649
+ rulesRemoved: parsed === null ? [] : rulesRemovedBetween(this.lastGood.config, parsed),
1650
+ restartRequiredPaths,
1651
+ error: error.message
1652
+ });
1517
1653
  }
1518
1654
  }
1519
1655
  };
1520
1656
 
1657
+ // src/auth/bearer.ts
1658
+ import { createHash as createHash2, timingSafeEqual } from "crypto";
1659
+ var BEARER_PREFIX = "Bearer ";
1660
+ var DIGEST_PATTERN = /^sha256:([0-9a-f]{64})$/;
1661
+ function isSecretDigest(value) {
1662
+ return DIGEST_PATTERN.test(value);
1663
+ }
1664
+ function secretDigest(plaintext) {
1665
+ return `sha256:${createHash2("sha256").update(plaintext, "utf-8").digest("hex")}`;
1666
+ }
1667
+ function verifyBearer(authHeader, expected) {
1668
+ if (!authHeader || !expected) return false;
1669
+ if (!authHeader.startsWith(BEARER_PREFIX)) return false;
1670
+ const presented = authHeader.slice(BEARER_PREFIX.length);
1671
+ const storedHex = DIGEST_PATTERN.exec(expected)?.[1];
1672
+ const expectedDigest = storedHex !== void 0 ? Buffer.from(storedHex, "hex") : createHash2("sha256").update(expected, "utf-8").digest();
1673
+ const actualDigest = createHash2("sha256").update(presented, "utf-8").digest();
1674
+ return timingSafeEqual(actualDigest, expectedDigest);
1675
+ }
1676
+
1677
+ // src/sandbox-scaffold.ts
1678
+ var SANDBOX_DEFAULT_DIR = "helio-sandbox";
1679
+ var SANDBOX_FILES = ["compose.yaml", "helio/helio.yaml", "helio/README.md"];
1680
+ var SANDBOX_FORWARDER_IMAGE = "nginx:1.30-alpine";
1681
+ var SANDBOX_AGENT_PLACEHOLDER_IMAGE = "curlimages/curl:8.22.0";
1682
+ function sandboxImageTag(version) {
1683
+ return version === "0.0.0" ? "latest" : version;
1684
+ }
1685
+ function renderSandboxCompose(options) {
1686
+ const { imageTag } = options;
1687
+ return `# Helio sidecar layout: four services on three networks. The sidecar
1688
+ # guide (docs/deployment-sidecar.md) explains every line and carries the
1689
+ # checks that prove the layout from inside the agent container.
1690
+ #
1691
+ # agent edge your coding agent (placeholder below)
1692
+ # helio-edge edge + plane forwards :3000 to Helio; the only thing the agent can reach
1693
+ # helio plane + internal the proxy; attached to no network the agent is on
1694
+ # mcp-server internal your MCP server (placeholder below)
1695
+ services:
1696
+ agent:
1697
+ # Placeholder: a shell with curl, so the verification checks run
1698
+ # before you wire your own dev container in. Replace \`image:\` with
1699
+ # your agent image or a \`build:\`; keep \`networks: [edge]\`; mount your
1700
+ # project from a sibling directory (./workspace:/workspace). Never
1701
+ # mount this directory, ./helio, a .env file, or the Docker socket
1702
+ # into it.
1703
+ image: ${SANDBOX_AGENT_PLACEHOLDER_IMAGE}
1704
+ command: ['sleep', 'infinity']
1705
+ networks: [edge]
1706
+
1707
+ helio-edge:
1708
+ # TCP forwarder for the MCP edge. Point the agent's MCP client at
1709
+ # http://helio-edge:3000/mcp. It relays port 3000 and nothing else.
1710
+ image: ${SANDBOX_FORWARDER_IMAGE}
1711
+ configs:
1712
+ - source: helio_edge_conf
1713
+ target: /etc/nginx/nginx.conf
1714
+ networks: [edge, plane]
1715
+ depends_on: [helio]
1716
+ restart: unless-stopped
1717
+
1718
+ helio:
1719
+ image: ghcr.io/gethelio/helio:${imageTag}
1720
+ networks: [plane, internal]
1721
+ environment:
1722
+ # Put HELIO_DASHBOARD_SECRET=<secret or sha256:digest> in ./.env
1723
+ # (this directory is never mounted into agent) or export it.
1724
+ # \`helio secret\` prints a fresh pair.
1725
+ HELIO_DASHBOARD_SECRET: '\${HELIO_DASHBOARD_SECRET:?put it in ./.env or export it; generate with: helio secret}'
1726
+ volumes:
1727
+ - ./helio:/config:ro
1728
+ - helio-data:/data
1729
+ ports:
1730
+ # The dashboard, the operator control plane: host loopback only.
1731
+ - '127.0.0.1:3100:3100'
1732
+ depends_on: [mcp-server]
1733
+ restart: unless-stopped
1734
+
1735
+ mcp-server:
1736
+ # Your MCP server. Set the image first: \`docker compose up\` fails at
1737
+ # the image pull until you do. Keep it on \`internal\` only. To try the
1738
+ # layout without one, save
1739
+ # https://raw.githubusercontent.com/gethelio/helio/main/docker/mcp-echo-server.mjs
1740
+ # as ./mcp-server/server.mjs and use the three commented lines
1741
+ # instead of the image line.
1742
+ image: your-org/your-mcp-server:latest
1743
+ # image: node:24-slim
1744
+ # command: ['node', '/srv/server.mjs']
1745
+ # volumes: ['./mcp-server/server.mjs:/srv/server.mjs:ro']
1746
+ networks: [internal]
1747
+ restart: unless-stopped
1748
+
1749
+ configs:
1750
+ helio_edge_conf:
1751
+ content: |
1752
+ events {}
1753
+ stream {
1754
+ resolver 127.0.0.11 valid=1s;
1755
+ server {
1756
+ listen 3000;
1757
+ proxy_timeout 1h;
1758
+ set $$helio helio:3000;
1759
+ proxy_pass $$helio;
1760
+ }
1761
+ }
1762
+
1763
+ networks:
1764
+ edge: {} # agent + helio-edge; an ordinary bridge with internet egress
1765
+ plane: {} # helio-edge + helio; Helio's route out for internet upstreams
1766
+ internal:
1767
+ internal: true # helio + mcp-server; no route anywhere else
1768
+
1769
+ volumes:
1770
+ helio-data: {}
1771
+ `;
1772
+ }
1773
+ function renderSandboxConfig() {
1774
+ return `# Helio sidecar config. This directory is mounted read-only into the
1775
+ # helio service and never into the agent. Edit it on the host; policy
1776
+ # changes reload live.
1777
+ version: '1'
1778
+
1779
+ upstream:
1780
+ url: 'http://mcp-server:8080/mcp' # compose service name on the internal network
1781
+ transport: streamable-http
1782
+ # For an upstream that needs a credential, keep the only copy in this
1783
+ # service's environment; the agent never holds it:
1784
+ # headers:
1785
+ # Authorization: 'Bearer \${UPSTREAM_TOKEN}'
1786
+
1787
+ listen:
1788
+ port: 3000
1789
+ host: '0.0.0.0' # inside the container; reached only through helio-edge
1790
+
1791
+ policies:
1792
+ default: allow
1793
+ rules:
1794
+ # Deny anything the tool marks as destructive.
1795
+ - name: block-destructive
1796
+ match:
1797
+ annotations:
1798
+ destructiveHint: true
1799
+ action: deny
1800
+ feedback:
1801
+ message: 'Destructive actions are blocked by policy.'
1802
+ suggestion: 'Use a non-destructive alternative or request approval.'
1803
+ # Allow read-only tools.
1804
+ - name: allow-reads
1805
+ match:
1806
+ annotations:
1807
+ readOnlyHint: true
1808
+ action: allow
1809
+
1810
+ audit:
1811
+ storage: sqlite
1812
+ path: /data/helio-audit.db
1813
+ retention: 90d
1814
+ include_responses: true
1815
+
1816
+ dashboard:
1817
+ enabled: true
1818
+ port: 3100
1819
+ host: '0.0.0.0' # inside the container; the agent is on no network this container is on
1820
+ # The variable may hold the secret or the sha256: digest \`helio secret\` prints.
1821
+ api_secret: '\${HELIO_DASHBOARD_SECRET}'
1822
+ `;
1823
+ }
1824
+ function renderSandboxReadme() {
1825
+ return `# Helio sidecar layout
1826
+
1827
+ Written by \`helio init --sandbox\`. This directory is the Compose
1828
+ project directory. The agent container never mounts it.
1829
+
1830
+ ## What this layout guarantees
1831
+
1832
+ From inside the \`agent\` container there is no route to the upstream
1833
+ except through Helio, no mount holding Helio's config, no route to
1834
+ Helio's dashboard by service name or address, and no copy of an
1835
+ upstream credential. Helio is attached to no network the agent is on;
1836
+ \`helio-edge\` forwards TCP port 3000 to it and nothing else.
1837
+
1838
+ ## Make it yours
1839
+
1840
+ 1. \`compose.yaml\`: set the \`agent\` image (or \`build:\`) and the
1841
+ \`mcp-server\` image. \`docker compose up\` fails at the image pull until
1842
+ the \`mcp-server\` image is set. Keep \`agent\` on \`edge\` only and
1843
+ \`mcp-server\` on \`internal\` only.
1844
+ 2. Run \`helio secret\`. Put \`HELIO_DASHBOARD_SECRET=sha256:<digest>\` in
1845
+ \`./.env\` next to \`compose.yaml\` (or export it). The variable may hold
1846
+ the secret itself or its \`sha256:\` digest; the dashboard login and the
1847
+ Bearer header always take the secret.
1848
+ 3. \`docker compose up -d\`, then run the checks below from inside the
1849
+ agent container (\`docker compose exec agent sh\`).
1850
+ 4. Point the agent's MCP client at \`http://helio-edge:3000/mcp\`. The
1851
+ dashboard is on the host at \`http://127.0.0.1:3100\`.
1852
+
1853
+ ## Hard constraints
1854
+
1855
+ - Never mount \`.\` or \`.env\` into the \`agent\` service.
1856
+ - Never mount \`docker.sock\` into it; an agent with the socket is the host.
1857
+ - Secrets only on the \`helio\` service.
1858
+ - \`./helio/\` is never mounted into \`agent\`.
1859
+ - \`edge\` has internet egress, so an upstream the agent could reach on
1860
+ its own is protected only by Helio holding the sole copy of its
1861
+ credential (\`upstream.headers\` with \`\${VAR}\` in \`helio/helio.yaml\`).
1862
+
1863
+ ## Verify from inside the agent container
1864
+
1865
+ \`\`\`sh
1866
+ # 1. No route to the upstream: must fail (exit 6 or 7).
1867
+ curl -s -m 3 http://mcp-server:8080/mcp; echo "exit: $?"
1868
+ # 2. No mount holding the config: both paths must be missing.
1869
+ ls /config /workspace/helio 2>&1; echo "exit: $?"
1870
+ # 3. No route to the dashboard: must fail (exit 6; helio-edge:3100 gives 7).
1871
+ curl -s -m 3 http://helio:3100/api/health; echo "exit: $?"
1872
+ # 4. No credential in the environment: must print nothing.
1873
+ env | grep -iE 'helio|upstream|secret'; echo "exit: $?"
1874
+ # Through Helio it works and is audited:
1875
+ curl -s -m 5 -X POST http://helio-edge:3000/mcp \\
1876
+ -H 'Content-Type: application/json' \\
1877
+ -H 'Accept: application/json, text/event-stream' \\
1878
+ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
1879
+ \`\`\`
1880
+
1881
+ On Docker Desktop (macOS and Windows) every port published to the host
1882
+ is reachable from every container through the Desktop host gateway:
1883
+ \`curl -s -m 3 http://host.docker.internal:3100/api/health\` answers from
1884
+ the agent there. Run the same probe on your own host and believe its
1885
+ answer. The dashboard's control-plane routes stay behind its secret,
1886
+ which the agent does not hold; \`/api/health\` answers without it. If that
1887
+ is not acceptable, front the dashboard with an
1888
+ authenticating reverse proxy or do not publish it while an untrusted
1889
+ agent runs.
1890
+
1891
+ ## Editing the config
1892
+
1893
+ \`./helio/\` is mounted read-only into the \`helio\` service, so edit
1894
+ \`helio/helio.yaml\` on the host; policy changes reload live. Change it
1895
+ from the host only. The agent container has no path to it, and that is
1896
+ the point.
1897
+
1898
+ ## Dev container
1899
+
1900
+ \`workspace/.devcontainer/devcontainer.json\`:
1901
+
1902
+ \`\`\`jsonc
1903
+ {
1904
+ "name": "agent-workspace",
1905
+ "dockerComposeFile": "../../compose.yaml",
1906
+ "service": "agent",
1907
+ "workspaceFolder": "/workspace"
1908
+ }
1909
+ \`\`\`
1910
+
1911
+ and in \`compose.yaml\` give \`agent\` the mount \`./workspace:/workspace:cached\`
1912
+ (a sibling of \`helio/\`, never this directory itself). Do not add a
1913
+ \`forwardPorts\` entry for the dashboard: it is published to the host by
1914
+ the \`helio\` service, and the agent has no route to \`helio\` by design.
1915
+ `;
1916
+ }
1917
+
1521
1918
  // src/server.ts
1522
1919
  import { Hono as Hono3 } from "hono";
1523
1920
  import { serve } from "@hono/node-server";
@@ -7138,6 +7535,7 @@ function clampInt(value, fallback, min, max) {
7138
7535
  // src/audit/store.ts
7139
7536
  var DRIFT_EVENT_DECISIONS_SQL = "('tool_drift', 'tool_drift_reverted')";
7140
7537
  var NON_TOOL_DECISIONS_SQL = "('tool_drift', 'tool_drift_reverted', 'rejected')";
7538
+ var POLICY_RELOAD_KIND_SQL = "'policy_reload'";
7141
7539
  var EXPORT_MAX_RECORDS = 1e4;
7142
7540
  var LIST_MAX_PAGE_SIZE = 1e3;
7143
7541
  var CREATE_TABLE_DDL = `
@@ -7171,7 +7569,8 @@ CREATE TABLE IF NOT EXISTS audit_records (
7171
7569
  metadata TEXT,
7172
7570
  protocol_version TEXT,
7173
7571
  created_at TEXT NOT NULL,
7174
- upstream TEXT
7572
+ upstream TEXT,
7573
+ config_sha256 TEXT
7175
7574
  );
7176
7575
  `;
7177
7576
  var CREATE_INDEX_DDL = `
@@ -7184,6 +7583,7 @@ CREATE INDEX IF NOT EXISTS idx_audit_upstream_status_created_at ON audit_records
7184
7583
  CREATE INDEX IF NOT EXISTS idx_audit_record_kind ON audit_records (record_kind);
7185
7584
  CREATE INDEX IF NOT EXISTS idx_audit_origin ON audit_records (origin);
7186
7585
  CREATE INDEX IF NOT EXISTS idx_audit_upstream ON audit_records (upstream);
7586
+ CREATE INDEX IF NOT EXISTS idx_audit_config_sha256 ON audit_records (config_sha256);
7187
7587
  `;
7188
7588
  var INSERT_SQL = `
7189
7589
  INSERT INTO audit_records (
@@ -7193,7 +7593,7 @@ INSERT INTO audit_records (
7193
7593
  upstream_http_status,
7194
7594
  total_duration_ms, approval_wait_ms, proxy_compute_ms,
7195
7595
  flagged_destructive, dry_run, record_kind, origin, metadata, protocol_version, created_at,
7196
- upstream
7596
+ upstream, config_sha256
7197
7597
  ) VALUES (
7198
7598
  @id, @timestamp, @session_id, @session_source, @agent_id, @environment, @tool_name, @tool_input,
7199
7599
  @policy_decision, @block_reason, @matched_rule, @matched_rule_index, @evidence_chain, @approval_status,
@@ -7201,7 +7601,7 @@ INSERT INTO audit_records (
7201
7601
  @upstream_http_status,
7202
7602
  @total_duration_ms, @approval_wait_ms, @proxy_compute_ms,
7203
7603
  @flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @protocol_version, @created_at,
7204
- @upstream
7604
+ @upstream, @config_sha256
7205
7605
  )
7206
7606
  `;
7207
7607
  var REQUIRED_AUDIT_COLUMNS = [
@@ -7221,10 +7621,14 @@ var REQUIRED_AUDIT_COLUMNS = [
7221
7621
  // Same clean break, same unreleased cycle (issue #219): released users see
7222
7622
  // ONE break, at v0.12.0.
7223
7623
  "protocol_version",
7224
- // The one ratified exception to the clean break (issue #292): a
7225
- // v0.12.0-complete database missing ONLY this column is migrated in place
7226
- // by migrateAuditUpstreamColumn instead of failing the assertion.
7227
- "upstream"
7624
+ // The ratified exception to the clean break (issue #292): a database that
7625
+ // is complete except for this column is migrated in place by
7626
+ // migrateAdditiveAuditColumns instead of failing the assertion.
7627
+ "upstream",
7628
+ // The second additive column under the same exception (issue #341): a
7629
+ // v0.13 database missing only this one, or a v0.12.0 database missing
7630
+ // both, migrates in place; anything older still clean-breaks.
7631
+ "config_sha256"
7228
7632
  ];
7229
7633
  function deserializeRow(row) {
7230
7634
  return {
@@ -7257,6 +7661,7 @@ function deserializeRow(row) {
7257
7661
  metadata: row.metadata ? JSON.parse(row.metadata) : null,
7258
7662
  protocol_version: row.protocol_version,
7259
7663
  upstream: row.upstream,
7664
+ config_sha256: row.config_sha256,
7260
7665
  created_at: row.created_at
7261
7666
  };
7262
7667
  }
@@ -7337,21 +7742,34 @@ function buildWhereClause(filters) {
7337
7742
  const clause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
7338
7743
  return { clause, params };
7339
7744
  }
7340
- function migrateAuditUpstreamColumn(db) {
7745
+ var ADDITIVE_AUDIT_COLUMNS = [
7746
+ { name: "upstream", ddl: "upstream TEXT" },
7747
+ { name: "config_sha256", ddl: "config_sha256 TEXT" }
7748
+ ];
7749
+ function migrateAdditiveAuditColumns(db) {
7341
7750
  const probe = () => {
7342
7751
  const rows = db.pragma("table_info(audit_records)");
7343
7752
  return new Set(rows.map((row) => row.name));
7344
7753
  };
7345
7754
  const existing = probe();
7346
- const missing = REQUIRED_AUDIT_COLUMNS.filter((name) => !existing.has(name));
7347
- if (missing.length !== 1 || missing[0] !== "upstream") return false;
7348
- try {
7349
- db.exec("ALTER TABLE audit_records ADD COLUMN upstream TEXT");
7350
- } catch (err) {
7351
- if (probe().has("upstream")) return false;
7352
- throw err;
7755
+ const missing = new Set(REQUIRED_AUDIT_COLUMNS.filter((name) => !existing.has(name)));
7756
+ if (missing.size === 0) return [];
7757
+ const additive = new Set(ADDITIVE_AUDIT_COLUMNS.map((column) => column.name));
7758
+ for (const name of missing) {
7759
+ if (!additive.has(name)) return [];
7760
+ }
7761
+ const added = [];
7762
+ for (const column of ADDITIVE_AUDIT_COLUMNS) {
7763
+ if (!missing.has(column.name)) continue;
7764
+ try {
7765
+ db.exec(`ALTER TABLE audit_records ADD COLUMN ${column.ddl}`);
7766
+ } catch (err) {
7767
+ if (probe().has(column.name)) continue;
7768
+ throw err;
7769
+ }
7770
+ added.push(column.name);
7353
7771
  }
7354
- return true;
7772
+ return added;
7355
7773
  }
7356
7774
  function restrictAuditFilePerms(dbPath) {
7357
7775
  if (dbPath === ":memory:" || process.platform === "win32") return;
@@ -7381,8 +7799,8 @@ var AuditStore = class {
7381
7799
  this.retentionMs = parseDuration(options.retention);
7382
7800
  this.includeResponses = options.includeResponses;
7383
7801
  this.db.exec(CREATE_TABLE_DDL);
7384
- if (migrateAuditUpstreamColumn(this.db)) {
7385
- console.error('[helio] Audit DB migrated: added column "upstream"');
7802
+ for (const name of migrateAdditiveAuditColumns(this.db)) {
7803
+ console.error(`[helio] Audit DB migrated: added column "${name}"`);
7386
7804
  }
7387
7805
  this.assertRequiredSchema(options.path);
7388
7806
  this.db.exec(CREATE_INDEX_DDL);
@@ -7493,6 +7911,7 @@ var AuditStore = class {
7493
7911
  metadata: record.metadata ? JSON.stringify(record.metadata) : null,
7494
7912
  protocol_version: record.protocol_version,
7495
7913
  upstream: record.upstream ?? null,
7914
+ config_sha256: record.config_sha256 ?? null,
7496
7915
  created_at: now
7497
7916
  });
7498
7917
  return resolvedId;
@@ -7580,26 +7999,27 @@ var AuditStore = class {
7580
7999
  const totals = this.db.prepare(
7581
8000
  `SELECT
7582
8001
  COUNT(*) as total,
7583
- 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,
7584
- COALESCE(SUM(CASE WHEN block_reason IS NOT NULL THEN 1 ELSE 0 END), 0) as blocked_total,
7585
- COALESCE(SUM(CASE WHEN dry_run = 1 THEN 1 ELSE 0 END), 0) as dry_run_total,
7586
- COALESCE(SUM(CASE WHEN dry_run = 0 THEN 1 ELSE 0 END), 0) as applied_total
8002
+ 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,
8003
+ 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,
8004
+ COALESCE(SUM(CASE WHEN dry_run = 1 AND record_kind <> ${POLICY_RELOAD_KIND_SQL} THEN 1 ELSE 0 END), 0) as dry_run_total,
8005
+ COALESCE(SUM(CASE WHEN dry_run = 0 AND record_kind <> ${POLICY_RELOAD_KIND_SQL} THEN 1 ELSE 0 END), 0) as applied_total
7587
8006
  FROM audit_records ${clause}`
7588
8007
  ).get(...params);
8008
+ const decisionClause = clause ? `${clause} AND record_kind <> ${POLICY_RELOAD_KIND_SQL}` : `WHERE record_kind <> ${POLICY_RELOAD_KIND_SQL}`;
7589
8009
  const by_decision = this.db.prepare(
7590
8010
  `SELECT policy_decision as decision, COUNT(*) as count
7591
- FROM audit_records ${clause}
8011
+ FROM audit_records ${decisionClause}
7592
8012
  GROUP BY policy_decision
7593
8013
  ORDER BY count DESC`
7594
8014
  ).all(...params);
7595
- const blockedClause = clause ? `${clause} AND block_reason IS NOT NULL` : "WHERE block_reason IS NOT NULL";
8015
+ 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}`;
7596
8016
  const by_block_reason = this.db.prepare(
7597
8017
  `SELECT block_reason as reason, COUNT(*) as count
7598
8018
  FROM audit_records ${blockedClause}
7599
8019
  GROUP BY block_reason
7600
8020
  ORDER BY count DESC`
7601
8021
  ).all(...params);
7602
- const toolsClause = clause ? `${clause} AND policy_decision NOT IN ${NON_TOOL_DECISIONS_SQL}` : `WHERE policy_decision NOT IN ${NON_TOOL_DECISIONS_SQL}`;
8022
+ 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}`;
7603
8023
  const top_tools = this.db.prepare(
7604
8024
  `SELECT tool_name, upstream, COUNT(*) as count
7605
8025
  FROM audit_records ${toolsClause}
@@ -7665,6 +8085,15 @@ var AuditWriter = class {
7665
8085
  bufferSize;
7666
8086
  onPush;
7667
8087
  onPersist;
8088
+ /**
8089
+ * The hash of the config file in force (issue #341), stamped onto every
8090
+ * record whose builder left `config_sha256` nullish. Seeded at
8091
+ * construction so no record is pushed unstamped before the first
8092
+ * `setConfigSha256`; replaced by the reload path once the new policy is
8093
+ * in force. Null only when the caller never had a hash (library
8094
+ * embeddings without a config file).
8095
+ */
8096
+ configSha256;
7668
8097
  buffer = [];
7669
8098
  timer = null;
7670
8099
  flushSoonTimer = null;
@@ -7674,6 +8103,7 @@ var AuditWriter = class {
7674
8103
  this.bufferSize = options.bufferSize ?? 50;
7675
8104
  this.onPush = options.onPush;
7676
8105
  this.onPersist = options.onPersist;
8106
+ this.configSha256 = options.configSha256 ?? null;
7677
8107
  const intervalMs = options.flushIntervalMs ?? 100;
7678
8108
  if (intervalMs > 0) {
7679
8109
  this.timer = setInterval(() => {
@@ -7694,8 +8124,9 @@ var AuditWriter = class {
7694
8124
  * is scheduled. This keeps request-path latency bounded even under bursty
7695
8125
  * write load.
7696
8126
  */
7697
- push(record, id = randomUUID4()) {
8127
+ push(input, id = randomUUID4()) {
7698
8128
  if (this.closed) return;
8129
+ const record = this.stamp(input);
7699
8130
  this.buffer.push({ id, record });
7700
8131
  this.onPush?.(record, id);
7701
8132
  if (this.buffer.length >= this.bufferSize) {
@@ -7710,12 +8141,30 @@ var AuditWriter = class {
7710
8141
  * A fatal-process crash still invokes the crash-drain hook, which calls
7711
8142
  * `flush()` synchronously before exit.
7712
8143
  */
7713
- pushImmediate(record, id = randomUUID4()) {
8144
+ pushImmediate(input, id = randomUUID4()) {
7714
8145
  if (this.closed) return;
8146
+ const record = this.stamp(input);
7715
8147
  this.buffer.push({ id, record });
7716
8148
  this.onPush?.(record, id);
7717
8149
  this.scheduleFlushSoon();
7718
8150
  }
8151
+ /**
8152
+ * Replace the config hash every later record is stamped with. Called by
8153
+ * the reload path after the new policy is in force, before the reload's
8154
+ * own record is pushed, so the reload record and every call it governs
8155
+ * carry the new hash.
8156
+ */
8157
+ setConfigSha256(hash) {
8158
+ this.configSha256 = hash;
8159
+ }
8160
+ /**
8161
+ * Stamp the active config hash onto a record whose builder left the field
8162
+ * nullish; a record that already carries a hash is passed through. The
8163
+ * stamped record is what `onPush`, the store, and `onPersist` see.
8164
+ */
8165
+ stamp(record) {
8166
+ return record.config_sha256 == null ? { ...record, config_sha256: this.configSha256 } : record;
8167
+ }
7719
8168
  /**
7720
8169
  * Schedule a flush on the next tick, coalescing multiple calls into one.
7721
8170
  */
@@ -7811,6 +8260,80 @@ function buildHeaderMismatchAuditRecord(rejection, environment, upstream) {
7811
8260
  };
7812
8261
  }
7813
8262
 
8263
+ // src/audit/policy-reload.ts
8264
+ import { basename } from "path";
8265
+ import { z as z4 } from "zod";
8266
+ var POLICY_RELOAD_DECISION = "policy_reload";
8267
+ function policyReloadEvidence(facts) {
8268
+ return {
8269
+ outcome: facts.outcome,
8270
+ config_path: facts.configPath,
8271
+ sha256_before: facts.sha256Before,
8272
+ sha256_after: facts.sha256After,
8273
+ rule_count_before: facts.ruleCountBefore,
8274
+ rule_count_after: facts.ruleCountAfter,
8275
+ default_action_before: facts.defaultActionBefore,
8276
+ default_action_after: facts.defaultActionAfter,
8277
+ budget_count_before: facts.budgetCountBefore,
8278
+ budget_count_after: facts.budgetCountAfter,
8279
+ rules_removed: [...facts.rulesRemoved],
8280
+ restart_required_paths: [...facts.restartRequiredPaths],
8281
+ error: facts.error
8282
+ };
8283
+ }
8284
+ function buildPolicyReloadRecord(facts, environment) {
8285
+ return {
8286
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
8287
+ session_id: null,
8288
+ session_source: null,
8289
+ agent_id: null,
8290
+ environment,
8291
+ tool_name: basename(facts.configPath),
8292
+ tool_input: {},
8293
+ policy_decision: POLICY_RELOAD_DECISION,
8294
+ block_reason: facts.outcome === "applied" ? null : facts.outcome,
8295
+ matched_rule: null,
8296
+ matched_rule_index: null,
8297
+ evidence_chain: { policy_reload: policyReloadEvidence(facts) },
8298
+ approval_status: null,
8299
+ approved_by: null,
8300
+ upstream_response: null,
8301
+ upstream_error: null,
8302
+ upstream_http_status: null,
8303
+ upstream_latency_ms: null,
8304
+ total_duration_ms: 0,
8305
+ approval_wait_ms: 0,
8306
+ proxy_compute_ms: 0,
8307
+ flagged_destructive: false,
8308
+ dry_run: false,
8309
+ record_kind: "policy_reload",
8310
+ origin: "config",
8311
+ metadata: null,
8312
+ protocol_version: null,
8313
+ upstream: null
8314
+ };
8315
+ }
8316
+ var policyReloadEvidenceSchema = z4.object({
8317
+ outcome: z4.enum(POLICY_RELOAD_OUTCOMES),
8318
+ config_path: z4.string(),
8319
+ sha256_before: z4.string(),
8320
+ sha256_after: z4.string().nullable(),
8321
+ rule_count_before: z4.number(),
8322
+ rule_count_after: z4.number().nullable(),
8323
+ default_action_before: z4.enum(["allow", "deny"]),
8324
+ default_action_after: z4.enum(["allow", "deny"]).nullable(),
8325
+ budget_count_before: z4.number(),
8326
+ budget_count_after: z4.number().nullable(),
8327
+ rules_removed: z4.array(z4.string()),
8328
+ restart_required_paths: z4.array(z4.string()),
8329
+ error: z4.string().nullable()
8330
+ }).strict();
8331
+ function readPolicyReloadEvidence(record) {
8332
+ if (record.record_kind !== "policy_reload") return null;
8333
+ const parsed = policyReloadEvidenceSchema.safeParse(record.evidence_chain?.["policy_reload"]);
8334
+ return parsed.success ? parsed.data : null;
8335
+ }
8336
+
7814
8337
  // src/evidence/store.ts
7815
8338
  var EvidenceStore = class _EvidenceStore {
7816
8339
  static EVIDENCE_ALLOWLIST_PREVIEW_LIMIT = 20;
@@ -8105,80 +8628,70 @@ var EvidenceStore = class _EvidenceStore {
8105
8628
  import { Hono as Hono5 } from "hono";
8106
8629
  import { bodyLimit } from "hono/body-limit";
8107
8630
  import { HTTPException } from "hono/http-exception";
8108
- import { z as z5 } from "zod";
8109
-
8110
- // src/auth/bearer.ts
8111
- import { createHash, timingSafeEqual } from "crypto";
8112
- function verifyBearer(authHeader, expected) {
8113
- if (!authHeader || !expected) return false;
8114
- const expectedHeader = `Bearer ${expected}`;
8115
- const actualDigest = createHash("sha256").update(authHeader).digest();
8116
- const expectedDigest = createHash("sha256").update(expectedHeader).digest();
8117
- return timingSafeEqual(actualDigest, expectedDigest);
8118
- }
8631
+ import { z as z6 } from "zod";
8119
8632
 
8120
8633
  // src/sideband/governance-api.ts
8121
8634
  import { Hono as Hono4 } from "hono";
8122
- import { z as z4 } from "zod";
8123
- import { createHash as createHash2 } from "crypto";
8124
- var originSchema = z4.string().regex(/^[a-z0-9_-]{1,64}$/, "origin must match ^[a-z0-9_-]{1,64}$").default("sideband");
8125
- var metadataSchema = z4.record(z4.string(), z4.unknown()).nullish();
8126
- var toolDefinitionSchema = z4.object({
8635
+ import { z as z5 } from "zod";
8636
+ import { createHash as createHash3 } from "crypto";
8637
+ var originSchema = z5.string().regex(/^[a-z0-9_-]{1,64}$/, "origin must match ^[a-z0-9_-]{1,64}$").default("sideband");
8638
+ var metadataSchema = z5.record(z5.string(), z5.unknown()).nullish();
8639
+ var toolDefinitionSchema = z5.object({
8127
8640
  // Stored verbatim in pending entries and audit rows; capped so a
8128
8641
  // caller-minted name cannot inflate the pending-entry footprint.
8129
- name: z4.string().min(1).max(256),
8130
- description: z4.string().optional(),
8131
- input_schema: z4.unknown().optional(),
8132
- output_schema: z4.unknown().optional(),
8133
- title: z4.string().optional(),
8134
- annotations: z4.record(z4.string(), z4.unknown()).optional()
8642
+ name: z5.string().min(1).max(256),
8643
+ description: z5.string().optional(),
8644
+ input_schema: z5.unknown().optional(),
8645
+ output_schema: z5.unknown().optional(),
8646
+ title: z5.string().optional(),
8647
+ annotations: z5.record(z5.string(), z5.unknown()).optional()
8135
8648
  });
8136
- var evaluateBody = z4.object({
8649
+ var evaluateBody = z5.object({
8137
8650
  origin: originSchema,
8138
- adapter_version: z4.string().max(64).optional(),
8651
+ adapter_version: z5.string().max(64).optional(),
8139
8652
  // Stored in pending entries and limit bucket keys; capped (like origin and
8140
8653
  // adapter_version) so caller-minted ids cannot inflate memory unaccounted.
8141
- agent_id: z4.string().max(128).nullish(),
8142
- session_id: z4.string().max(256).nullish(),
8654
+ agent_id: z5.string().max(128).nullish(),
8655
+ session_id: z5.string().max(256).nullish(),
8143
8656
  tool: toolDefinitionSchema,
8144
- arguments: z4.record(z4.string(), z4.unknown()).optional(),
8657
+ arguments: z5.record(z5.string(), z5.unknown()).optional(),
8145
8658
  metadata: metadataSchema
8146
8659
  });
8147
- var installScanBody = z4.object({
8660
+ var installScanBody = z5.object({
8148
8661
  origin: originSchema,
8149
- agent_id: z4.string().max(128).nullish(),
8150
- session_id: z4.string().max(256).nullish(),
8151
- package: z4.object({
8152
- name: z4.string().min(1),
8153
- version: z4.string().optional(),
8154
- source: z4.string().max(64).optional(),
8155
- spec: z4.string().optional(),
8156
- url: z4.string().optional()
8662
+ agent_id: z5.string().max(128).nullish(),
8663
+ session_id: z5.string().max(256).nullish(),
8664
+ package: z5.object({
8665
+ name: z5.string().min(1),
8666
+ version: z5.string().optional(),
8667
+ source: z5.string().max(64).optional(),
8668
+ spec: z5.string().optional(),
8669
+ url: z5.string().optional()
8157
8670
  }),
8158
8671
  metadata: metadataSchema
8159
8672
  });
8160
- var evidenceEntrySchema = z4.object({
8161
- evidence_key: z4.string().min(1),
8162
- evidence_data: z4.unknown().refine((v) => v !== void 0, { message: "Required" }),
8163
- ttl_seconds: z4.number().int().positive().optional()
8673
+ var evidenceEntrySchema = z5.object({
8674
+ evidence_key: z5.string().min(1),
8675
+ evidence_data: z5.unknown().refine((v) => v !== void 0, { message: "Required" }),
8676
+ ttl_seconds: z5.number().int().positive().optional()
8164
8677
  });
8165
- var auditBody = z4.object({
8166
- evaluation_id: z4.string().min(1),
8167
- status: z4.enum(["success", "error", "not_executed"]),
8168
- error: z4.string().optional(),
8169
- duration_ms: z4.number().optional(),
8170
- result: z4.unknown().optional(),
8171
- actual_amount: z4.number().optional(),
8678
+ var auditBody = z5.object({
8679
+ evaluation_id: z5.string().min(1),
8680
+ status: z5.enum(["success", "error", "not_executed"]),
8681
+ error: z5.string().optional(),
8682
+ duration_ms: z5.number().optional(),
8683
+ result: z5.unknown().optional(),
8684
+ actual_amount: z5.number().optional(),
8172
8685
  // No `.max()` / size refinement here on purpose (issue #11): caps are
8173
8686
  // enforced per-entry in GovernanceService.populateEvidence as soft-drops, so
8174
8687
  // an over-cap entry never 400s away the audit row for a call that already ran.
8175
- evidence: z4.array(evidenceEntrySchema).optional()
8688
+ evidence: z5.array(evidenceEntrySchema).optional()
8176
8689
  });
8177
- var resolveBody = z4.object({
8178
- resolution: z4.enum(["approved", "denied", "timeout", "cancelled"]),
8179
- resolved_by: z4.string().optional(),
8180
- reason: z4.string().optional(),
8181
- scope: z4.enum(["once", "always"]).optional()
8690
+ var resolveBody = z5.object({
8691
+ resolution: z5.enum(["approved", "denied", "timeout", "cancelled"]),
8692
+ resolved_by: z5.string().optional(),
8693
+ reason: z5.string().optional(),
8694
+ scope: z5.enum(["once", "always"]).optional()
8182
8695
  });
8183
8696
  var MAX_METADATA_BYTES = 4 * 1024;
8184
8697
  function createGovernanceApp(service) {
@@ -8277,7 +8790,7 @@ function auditPayloadHash(data) {
8277
8790
  actual_amount: data.actual_amount ?? null,
8278
8791
  evidence: canonicalEvidence(data.evidence)
8279
8792
  };
8280
- return createHash2("sha256").update(canonicalize(semantic)).digest("hex");
8793
+ return createHash3("sha256").update(canonicalize(semantic)).digest("hex");
8281
8794
  }
8282
8795
  function canonicalEvidence(evidence) {
8283
8796
  if (!evidence || evidence.length === 0) return null;
@@ -8293,20 +8806,20 @@ function asStatus(status) {
8293
8806
 
8294
8807
  // src/evidence/api.ts
8295
8808
  var SIDEBAND_BODY_LIMIT_BYTES = 1 * 1024 * 1024;
8296
- var sessionIdSchema = z5.string().min(1).refine((value) => value.trim() !== "", {
8809
+ var sessionIdSchema = z6.string().min(1).refine((value) => value.trim() !== "", {
8297
8810
  message: "session_id must not be whitespace-only"
8298
8811
  });
8299
- var postEvidenceBody = z5.object({
8812
+ var postEvidenceBody = z6.object({
8300
8813
  session_id: sessionIdSchema,
8301
- tool_name: z5.string().min(1),
8302
- evidence_key: z5.string().min(1),
8303
- evidence_data: z5.unknown().refine((v) => v !== void 0, { message: "Required" }),
8304
- ttl_seconds: z5.number().int().positive().optional()
8814
+ tool_name: z6.string().min(1),
8815
+ evidence_key: z6.string().min(1),
8816
+ evidence_data: z6.unknown().refine((v) => v !== void 0, { message: "Required" }),
8817
+ ttl_seconds: z6.number().int().positive().optional()
8305
8818
  });
8306
- var postContextBody = z5.object({
8819
+ var postContextBody = z6.object({
8307
8820
  session_id: sessionIdSchema,
8308
- key: z5.string().min(1),
8309
- value: z5.unknown().refine((v) => v !== void 0, { message: "Required" })
8821
+ key: z6.string().min(1),
8822
+ value: z6.unknown().refine((v) => v !== void 0, { message: "Required" })
8310
8823
  });
8311
8824
  function createSidebandApp(store, options = {}) {
8312
8825
  const app = new Hono5();
@@ -10371,7 +10884,7 @@ function createChannels(channels) {
10371
10884
  // src/approval/slack-actions.ts
10372
10885
  import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
10373
10886
  import { Hono as Hono6 } from "hono";
10374
- import { z as z6 } from "zod";
10887
+ import { z as z7 } from "zod";
10375
10888
  var MAX_TIMESTAMP_AGE_S = 300;
10376
10889
  var REJECTION_LOG_WINDOW_MS = 6e4;
10377
10890
  var REJECTION_LOG_SAMPLE_EVERY = 25;
@@ -10437,12 +10950,12 @@ function verifySlackSignature(secrets, timestamp, rawBody, signature) {
10437
10950
  }
10438
10951
  return false;
10439
10952
  }
10440
- var slackActionPayloadSchema = z6.object({
10441
- type: z6.string(),
10442
- user: z6.object({ id: z6.string(), username: z6.string() }),
10443
- actions: z6.array(z6.object({ action_id: z6.string() })),
10444
- channel: z6.object({ id: z6.string() }),
10445
- message: z6.object({ ts: z6.string() })
10953
+ var slackActionPayloadSchema = z7.object({
10954
+ type: z7.string(),
10955
+ user: z7.object({ id: z7.string(), username: z7.string() }),
10956
+ actions: z7.array(z7.object({ action_id: z7.string() })),
10957
+ channel: z7.object({ id: z7.string() }),
10958
+ message: z7.object({ ts: z7.string() })
10446
10959
  });
10447
10960
  function parseActionPayload(rawBody) {
10448
10961
  try {
@@ -10572,17 +11085,17 @@ function createSlackActionApp(options) {
10572
11085
 
10573
11086
  // src/approval/api.ts
10574
11087
  import { Hono as Hono7 } from "hono";
10575
- import { z as z7 } from "zod";
10576
- var approveBody = z7.object({
10577
- approved_by: z7.string().min(1)
11088
+ import { z as z8 } from "zod";
11089
+ var approveBody = z8.object({
11090
+ approved_by: z8.string().min(1)
10578
11091
  });
10579
- var denyBody = z7.object({
10580
- denied_by: z7.string().min(1),
10581
- reason: z7.string().optional()
11092
+ var denyBody = z8.object({
11093
+ denied_by: z8.string().min(1),
11094
+ reason: z8.string().optional()
10582
11095
  });
10583
- var breakGlassBody = z7.object({
10584
- approved_by: z7.string().min(1),
10585
- reason: z7.string().min(1)
11096
+ var breakGlassBody = z8.object({
11097
+ approved_by: z8.string().min(1),
11098
+ reason: z8.string().min(1)
10586
11099
  });
10587
11100
  var APPROVAL_STATUSES = [
10588
11101
  "pending",
@@ -10595,18 +11108,18 @@ var APPROVAL_STATUSES = [
10595
11108
  "cancelled"
10596
11109
  ];
10597
11110
  var approvalStatusSet = new Set(APPROVAL_STATUSES);
10598
- var listApprovalsQuery = z7.object({
10599
- status: z7.preprocess(
11111
+ var listApprovalsQuery = z8.object({
11112
+ status: z8.preprocess(
10600
11113
  (value) => typeof value === "string" && approvalStatusSet.has(value) ? value : void 0,
10601
- z7.enum(APPROVAL_STATUSES).optional()
11114
+ z8.enum(APPROVAL_STATUSES).optional()
10602
11115
  ),
10603
- limit: z7.preprocess(
11116
+ limit: z8.preprocess(
10604
11117
  (value) => clampInt(typeof value === "string" ? value : void 0, 50, 1, 1e3),
10605
- z7.number().int()
11118
+ z8.number().int()
10606
11119
  ),
10607
- offset: z7.preprocess(
11120
+ offset: z8.preprocess(
10608
11121
  (value) => clampInt(typeof value === "string" ? value : void 0, 0, 0, Number.MAX_SAFE_INTEGER),
10609
- z7.number().int()
11122
+ z8.number().int()
10610
11123
  )
10611
11124
  });
10612
11125
  function createApprovalApp(router, queue, options) {
@@ -11630,11 +12143,12 @@ var CSV_HEADERS = [
11630
12143
  "record_kind",
11631
12144
  "origin",
11632
12145
  "metadata",
11633
- // Appended LAST (issues #218, #219, #292): positional consumers of the
11634
- // existing columns keep working — new columns always go at the end.
12146
+ // Appended LAST (issues #218, #219, #292, #341): positional consumers of
12147
+ // the existing columns keep working — new columns always go at the end.
11635
12148
  "session_source",
11636
12149
  "protocol_version",
11637
- "upstream"
12150
+ "upstream",
12151
+ "config_sha256"
11638
12152
  ];
11639
12153
  var FORMULA_PREFIXES = /^[=+\-@\t\r]/;
11640
12154
  function csvEscape(value) {
@@ -11702,25 +12216,25 @@ function budgetEventsToCsv(events) {
11702
12216
  // src/dashboard/api.ts
11703
12217
  import { readFileSync } from "fs";
11704
12218
  import { join } from "path";
11705
- import { randomUUID as randomUUID8 } from "crypto";
12219
+ import { randomBytes as randomBytes2, randomUUID as randomUUID8 } from "crypto";
11706
12220
  import { Hono as Hono8 } from "hono";
11707
12221
  import { HTTPException as HTTPException2 } from "hono/http-exception";
11708
- import { z as z8 } from "zod";
12222
+ import { z as z9 } from "zod";
11709
12223
  import { cors } from "hono/cors";
11710
12224
  import { serveStatic } from "@hono/node-server/serve-static";
11711
12225
  import { streamSSE } from "hono/streaming";
11712
12226
 
11713
12227
  // src/dashboard/session.ts
11714
- import { createHash as createHash3, createHmac as createHmac3, randomBytes, timingSafeEqual as timingSafeEqual3 } from "crypto";
12228
+ import { createHash as createHash4, createHmac as createHmac3, randomBytes, timingSafeEqual as timingSafeEqual3 } from "crypto";
11715
12229
  var DashboardSessionStore = class {
11716
- secret;
12230
+ signingKey;
11717
12231
  ttlMs;
11718
12232
  now;
11719
12233
  records = /* @__PURE__ */ new Map();
11720
12234
  timer = null;
11721
12235
  closed = false;
11722
12236
  constructor(options) {
11723
- this.secret = options.secret;
12237
+ this.signingKey = options.signingKey;
11724
12238
  this.ttlMs = options.ttlMs ?? 8 * 60 * 60 * 1e3;
11725
12239
  this.now = options.now ?? Date.now;
11726
12240
  const cleanupIntervalMs = options.cleanupIntervalMs ?? 6e4;
@@ -11792,35 +12306,35 @@ var DashboardSessionStore = class {
11792
12306
  const id = token.slice(0, dot);
11793
12307
  const signature = token.slice(dot + 1);
11794
12308
  const expected = this.sign(id);
11795
- const actualDigest = createHash3("sha256").update(signature).digest();
11796
- const expectedDigest = createHash3("sha256").update(expected).digest();
12309
+ const actualDigest = createHash4("sha256").update(signature).digest();
12310
+ const expectedDigest = createHash4("sha256").update(expected).digest();
11797
12311
  if (!timingSafeEqual3(actualDigest, expectedDigest)) return void 0;
11798
12312
  return id;
11799
12313
  }
11800
12314
  sign(id) {
11801
- return createHmac3("sha256", this.secret).update(id).digest("base64url");
12315
+ return createHmac3("sha256", this.signingKey).update(id).digest("base64url");
11802
12316
  }
11803
12317
  };
11804
12318
 
11805
12319
  // src/dashboard/api.ts
11806
- var optionalQueryString = z8.preprocess(
12320
+ var optionalQueryString = z9.preprocess(
11807
12321
  (value) => typeof value === "string" && value.length > 0 ? value : void 0,
11808
- z8.string().optional()
12322
+ z9.string().optional()
11809
12323
  );
11810
- var optionalQueryInt = z8.preprocess((value) => {
12324
+ var optionalQueryInt = z9.preprocess((value) => {
11811
12325
  if (typeof value !== "string" || value.length === 0) return void 0;
11812
12326
  const parsed = Number.parseInt(value, 10);
11813
12327
  return Number.isFinite(parsed) ? parsed : void 0;
11814
- }, z8.number().int().optional());
11815
- var queryBoolean = z8.preprocess(
12328
+ }, z9.number().int().optional());
12329
+ var queryBoolean = z9.preprocess(
11816
12330
  (value) => value === "true" ? true : value === "false" ? false : void 0,
11817
- z8.boolean().optional()
12331
+ z9.boolean().optional()
11818
12332
  );
11819
- var clampedQueryInt = (fallback, min, max) => z8.preprocess(
12333
+ var clampedQueryInt = (fallback, min, max) => z9.preprocess(
11820
12334
  (value) => clampInt(typeof value === "string" ? value : void 0, fallback, min, max),
11821
- z8.number().int()
12335
+ z9.number().int()
11822
12336
  );
11823
- var feedQuerySchema = z8.object({
12337
+ var feedQuerySchema = z9.object({
11824
12338
  limit: clampedQueryInt(50, 1, 200),
11825
12339
  offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER),
11826
12340
  // The feed's server-side filters (issues #292, #316): attribution and
@@ -11830,8 +12344,8 @@ var feedQuerySchema = z8.object({
11830
12344
  upstream: optionalQueryString,
11831
12345
  session_source: optionalQueryString
11832
12346
  });
11833
- var auditExportQuerySchema = z8.object({
11834
- format: z8.preprocess((value) => value === "csv" ? "csv" : "json", z8.enum(["json", "csv"])),
12347
+ var auditExportQuerySchema = z9.object({
12348
+ format: z9.preprocess((value) => value === "csv" ? "csv" : "json", z9.enum(["json", "csv"])),
11835
12349
  limit: clampedQueryInt(EXPORT_MAX_RECORDS, 1, EXPORT_MAX_RECORDS),
11836
12350
  tool: optionalQueryString,
11837
12351
  decision: optionalQueryString,
@@ -11851,7 +12365,7 @@ var auditExportQuerySchema = z8.object({
11851
12365
  upstream: optionalQueryString,
11852
12366
  session_source: optionalQueryString
11853
12367
  });
11854
- var auditQuerySchema = z8.object({
12368
+ var auditQuerySchema = z9.object({
11855
12369
  limit: clampedQueryInt(50, 1, LIST_MAX_PAGE_SIZE),
11856
12370
  offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER),
11857
12371
  tool: optionalQueryString,
@@ -11873,21 +12387,21 @@ var auditQuerySchema = z8.object({
11873
12387
  upstream: optionalQueryString,
11874
12388
  session_source: optionalQueryString
11875
12389
  });
11876
- var budgetEventsQuerySchema = z8.object({
12390
+ var budgetEventsQuerySchema = z9.object({
11877
12391
  limit: clampedQueryInt(50, 1, LIST_MAX_PAGE_SIZE),
11878
12392
  offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER)
11879
12393
  });
11880
- var budgetEventsExportQuerySchema = z8.object({
11881
- format: z8.preprocess((value) => value === "csv" ? "csv" : "json", z8.enum(["json", "csv"])),
12394
+ var budgetEventsExportQuerySchema = z9.object({
12395
+ format: z9.preprocess((value) => value === "csv" ? "csv" : "json", z9.enum(["json", "csv"])),
11882
12396
  limit: clampedQueryInt(EXPORT_MAX_RECORDS, 1, EXPORT_MAX_RECORDS)
11883
12397
  });
11884
- var analyticsQuerySchema = z8.object({
12398
+ var analyticsQuerySchema = z9.object({
11885
12399
  from: optionalQueryString,
11886
12400
  to: optionalQueryString,
11887
12401
  upstream: optionalQueryString
11888
12402
  });
11889
- var authSessionBodySchema = z8.object({
11890
- secret: z8.string()
12403
+ var authSessionBodySchema = z9.object({
12404
+ secret: z9.string()
11891
12405
  });
11892
12406
  var SESSION_COOKIE = "helio_session";
11893
12407
  var SESSION_TTL_MS = 8 * 60 * 60 * 1e3;
@@ -11960,7 +12474,10 @@ function createDashboardAppWithLifecycle(deps, options) {
11960
12474
  budgets
11961
12475
  } = deps;
11962
12476
  const apiSecret = options?.apiSecret;
11963
- const sessionStore = apiSecret ? new DashboardSessionStore({ secret: apiSecret, ttlMs: SESSION_TTL_MS }) : void 0;
12477
+ const sessionStore = apiSecret ? new DashboardSessionStore({
12478
+ signingKey: randomBytes2(32).toString("hex"),
12479
+ ttlMs: SESSION_TTL_MS
12480
+ }) : void 0;
11964
12481
  const app = new Hono8();
11965
12482
  app.onError((err, c) => {
11966
12483
  if (err instanceof HTTPException2) return err.getResponse();
@@ -12366,7 +12883,8 @@ var EVENT_TYPES = [
12366
12883
  "limit_warning",
12367
12884
  "approval_notification_failed",
12368
12885
  "budget_update",
12369
- "budget_breached"
12886
+ "budget_breached",
12887
+ "policy_reload"
12370
12888
  ];
12371
12889
  var DashboardEventBus = class {
12372
12890
  emitter = new EventEmitter();
@@ -12435,6 +12953,10 @@ function actionEventFromRecord(record, id) {
12435
12953
  upstream: record.upstream
12436
12954
  };
12437
12955
  }
12956
+ function policyReloadEventFromRecord(record, id) {
12957
+ const evidence = readPolicyReloadEvidence(record);
12958
+ return evidence === null ? null : { ...evidence, id, at: record.timestamp };
12959
+ }
12438
12960
  function approvalRequestedEvent(ticket) {
12439
12961
  return {
12440
12962
  ticket_id: ticket.id,
@@ -12458,6 +12980,10 @@ function dashboardEventCallbacks(bus) {
12458
12980
  return {
12459
12981
  onPersist: (record, id) => {
12460
12982
  bus.emit("action", actionEventFromRecord(record, id));
12983
+ if (record.record_kind === "policy_reload") {
12984
+ const event = policyReloadEventFromRecord(record, id);
12985
+ if (event !== null) bus.emit("policy_reload", event);
12986
+ }
12461
12987
  },
12462
12988
  onApprovalSubmit: (ticket) => {
12463
12989
  bus.emit("approval_requested", approvalRequestedEvent(ticket));
@@ -12475,6 +13001,7 @@ function dashboardEventCallbacks(bus) {
12475
13001
  }
12476
13002
 
12477
13003
  // src/startup-warnings.ts
13004
+ import { accessSync, constants } from "fs";
12478
13005
  function isLoopbackHost2(host) {
12479
13006
  return host === "127.0.0.1" || host === "localhost" || host === "::1";
12480
13007
  }
@@ -12543,6 +13070,17 @@ function warnIfDashboardOpenMode(config, log = console.error) {
12543
13070
  );
12544
13071
  return true;
12545
13072
  }
13073
+ function warnIfDashboardSecretLiteral(config, source, log = console.error) {
13074
+ const secret = config.dashboard.api_secret;
13075
+ if (!config.dashboard.enabled) return false;
13076
+ if (typeof secret !== "string" || secret.length === 0) return false;
13077
+ if (isSecretDigest(secret)) return false;
13078
+ if (source.interpolatedPaths.includes("dashboard.api_secret")) return false;
13079
+ log(
13080
+ `[helio] Warning: dashboard.api_secret is stored as plaintext in ${source.configPath}. Anyone who can read this file holds the operator credential and can approve tickets. Run \`helio secret\`, store the printed digest as dashboard.api_secret, and restart.`
13081
+ );
13082
+ return true;
13083
+ }
12546
13084
  function warnIfNoEnforcement(policy, log = console.error) {
12547
13085
  if (policy.rules.length > 0 || policy.defaultAction !== "allow" || policy.dryRun) return false;
12548
13086
  log(
@@ -12550,6 +13088,23 @@ function warnIfNoEnforcement(policy, log = console.error) {
12550
13088
  );
12551
13089
  return true;
12552
13090
  }
13091
+ function defaultIsWritable(path) {
13092
+ try {
13093
+ accessSync(path, constants.W_OK);
13094
+ return true;
13095
+ } catch {
13096
+ return false;
13097
+ }
13098
+ }
13099
+ function warnIfConfigWritableByProxyUser(input, log = console.error) {
13100
+ const isWritable = input.isWritable ?? defaultIsWritable;
13101
+ if (!isWritable(input.configPath)) return false;
13102
+ const exposure = input.pinned ? "Reloads are pinned, so a changed file is refused, but a restart by this user can still drop the pin. Run the proxy as its own user to move up a tier" : input.hotReload ? "Any same-user process, including your agent, can change policy live. Run the proxy as its own user, or set HELIO_CONFIG_SHA256, to move up a tier" : "Hot reload is off, so the next restart loads whatever is in the file. Run the proxy as its own user, or set HELIO_CONFIG_SHA256, to move up a tier";
13103
+ log(
13104
+ `[helio] Enforcement posture: ${input.configPath} is writable by this user. ${exposure} (SECURITY.md, Process and filesystem boundaries).`
13105
+ );
13106
+ return true;
13107
+ }
12553
13108
 
12554
13109
  // src/shutdown.ts
12555
13110
  async function closeResources(resources) {
@@ -12628,7 +13183,7 @@ function getBundledDashboardDistPath() {
12628
13183
  const assetsSubdirPath = resolve(assetsDir, "assets");
12629
13184
  return existsSync(indexPath) && existsSync(assetsSubdirPath) ? assetsDir : null;
12630
13185
  }
12631
- function renderConfigTemplate(apiSecret) {
13186
+ function renderConfigTemplate(apiSecretDigest) {
12632
13187
  return `# Helio MCP Governance Proxy configuration
12633
13188
  # Docs: https://github.com/gethelio/helio
12634
13189
 
@@ -12693,17 +13248,19 @@ upstream:
12693
13248
  # retention: 90d
12694
13249
  # include_responses: true
12695
13250
 
12696
- # Operator dashboard + approval REST API. Bound to 127.0.0.1 by default \u2014 do
13251
+ # Operator dashboard + approval REST API. Bound to 127.0.0.1 by default. Do
12697
13252
  # not change to 0.0.0.0 without putting an authenticating reverse proxy in
12698
- # front. dashboard.api_secret is the manual dashboard login secret and also
12699
- # supports machine Bearer auth for sideband API clients. Store it safely; it
12700
- # stays valid until you rotate it. Rotate by editing this file and restarting
12701
- # the proxy. Rotation invalidates active dashboard sessions.
13253
+ # front. dashboard.api_secret holds the SHA-256 digest of the dashboard
13254
+ # secret, never the secret itself: log in to the dashboard and authenticate
13255
+ # sideband API clients with the secret that \`helio init\` printed once. To
13256
+ # rotate, run \`helio secret\`, paste the new digest here, and restart the
13257
+ # proxy; active dashboard sessions are invalidated. A plaintext value is
13258
+ # still accepted.
12702
13259
  dashboard:
12703
13260
  enabled: true
12704
13261
  port: 3100
12705
13262
  host: 127.0.0.1
12706
- api_secret: "${apiSecret}"
13263
+ api_secret: "${apiSecretDigest}"
12707
13264
 
12708
13265
  # sdk:
12709
13266
  # enabled: false
@@ -12744,8 +13301,13 @@ async function governUpstream(options) {
12744
13301
  }
12745
13302
  async function startCommand(configPath, options) {
12746
13303
  let config;
13304
+ let interpolatedPaths = [];
13305
+ let configSha256 = "";
12747
13306
  try {
12748
- config = await loadConfig(configPath);
13307
+ const loaded = await loadConfigWithMeta(configPath);
13308
+ config = loaded.config;
13309
+ interpolatedPaths = loaded.interpolatedPaths;
13310
+ configSha256 = loaded.sha256;
12749
13311
  } catch (err) {
12750
13312
  if (err instanceof ConfigError) {
12751
13313
  console.error(`Error: ${err.message}`);
@@ -12754,6 +13316,20 @@ async function startCommand(configPath, options) {
12754
13316
  }
12755
13317
  throw err;
12756
13318
  }
13319
+ const configPin = readConfigPin();
13320
+ if (configPin.status === "invalid") {
13321
+ console.error(
13322
+ `Error: HELIO_CONFIG_SHA256 is set but is not a SHA-256 hex digest (64 hex characters, with or without a sha256: prefix): "${configPin.raw.slice(0, 80)}"`
13323
+ );
13324
+ process.exit(1);
13325
+ }
13326
+ if (configPin.status === "set" && configPin.sha256 !== configSha256) {
13327
+ console.error(
13328
+ `Error: HELIO_CONFIG_SHA256 does not match ${configPath}: pinned sha256:${configPin.sha256}, file sha256:${configSha256}. Review the change and re-pin it with helio config hash.`
13329
+ );
13330
+ process.exit(1);
13331
+ }
13332
+ const pinnedSha256 = configPin.status === "set" ? configPin.sha256 : void 0;
12757
13333
  const bundledDashboardDistPath = config.dashboard.enabled ? getBundledDashboardDistPath() : null;
12758
13334
  if (config.dashboard.enabled && !bundledDashboardDistPath) {
12759
13335
  console.error(
@@ -12804,7 +13380,10 @@ async function startCommand(configPath, options) {
12804
13380
  auditStore.runRetentionSweep();
12805
13381
  const auditWriter = new AuditWriter({
12806
13382
  store: auditStore,
12807
- onPersist: cbs.onPersist
13383
+ onPersist: cbs.onPersist,
13384
+ // Seed the config hash at construction so no record is written unstamped
13385
+ // between here and the first reload; the reload path replaces it.
13386
+ configSha256
12808
13387
  });
12809
13388
  registerCrashDrainHook(() => {
12810
13389
  try {
@@ -12913,7 +13492,7 @@ async function startCommand(configPath, options) {
12913
13492
  if (config.sdk.enabled) {
12914
13493
  sidebandToken = process.env["HELIO_SDK_TOKEN"];
12915
13494
  if (!sidebandToken || sidebandToken.length === 0) {
12916
- sidebandToken = randomBytes2(32).toString("hex");
13495
+ sidebandToken = randomBytes3(32).toString("hex");
12917
13496
  process.env["HELIO_SDK_TOKEN"] = sidebandToken;
12918
13497
  sidebandTokenSource = "generated";
12919
13498
  } else {
@@ -12921,7 +13500,7 @@ async function startCommand(configPath, options) {
12921
13500
  }
12922
13501
  adapterToken = process.env["HELIO_ADAPTER_TOKEN"];
12923
13502
  if (!adapterToken || adapterToken.length === 0) {
12924
- adapterToken = randomBytes2(32).toString("hex");
13503
+ adapterToken = randomBytes3(32).toString("hex");
12925
13504
  process.env["HELIO_ADAPTER_TOKEN"] = adapterToken;
12926
13505
  adapterTokenSource = "generated";
12927
13506
  } else {
@@ -13028,6 +13607,7 @@ async function startCommand(configPath, options) {
13028
13607
  warnIfWebhookChannelUnreachable(config);
13029
13608
  warnIfSdkSidebandExposed(config);
13030
13609
  warnIfDashboardOpenMode(config);
13610
+ warnIfDashboardSecretLiteral(config, { configPath, interpolatedPaths });
13031
13611
  warnIfBudgetWindowExceedsRetention(config);
13032
13612
  const channelCount = config.approval.channels.length;
13033
13613
  console.error(
@@ -13043,29 +13623,46 @@ async function startCommand(configPath, options) {
13043
13623
  console.error(`Dry-run: ENABLED (no requests will be forwarded to upstream)`);
13044
13624
  }
13045
13625
  console.error(`Config: ${configPath}`);
13626
+ if (pinnedSha256 !== void 0) {
13627
+ console.error(
13628
+ `[helio] Config pinned to sha256:${pinnedSha256.slice(0, 12)}: reloads with a different hash will be refused`
13629
+ );
13630
+ }
13046
13631
  const hotReloadEnabled = options.noHotReload === true ? false : config.policies.hot_reload ?? true;
13047
13632
  let configWatcher;
13048
13633
  if (hotReloadEnabled) {
13049
13634
  configWatcher = new ConfigWatcher({
13050
13635
  configPath,
13051
- initialConfig: config,
13636
+ initial: { config, sha256: configSha256 },
13637
+ pinnedSha256,
13052
13638
  onReady: () => {
13053
13639
  console.error(`Watching ${configPath} for policy changes`);
13054
13640
  },
13055
- onReload: (newPolicy, reloadWarnings, restartRequiredPaths, newBudgets) => {
13641
+ onReload: (newPolicy, reloadWarnings, restartRequiredPaths, newBudgets, facts) => {
13056
13642
  const unroutable = findUnroutableApprovalReferences(newPolicy, newBudgets, {
13057
13643
  channelTypes: runtimeChannelTypes,
13058
13644
  dashboardEnabled: config.dashboard.enabled,
13059
13645
  defaultApprovalTimeoutMs: parseDuration(config.approval.timeout)
13060
13646
  });
13061
13647
  if (unroutable.length > 0) {
13062
- throw new Error(
13648
+ throw new PolicyReloadRejectedError(
13649
+ "rejected_unroutable",
13063
13650
  `approval routing is not available in the running process (restart required to apply approval.channels/dashboard changes): ${unroutable.join("; ")}`
13064
13651
  );
13065
13652
  }
13066
- budgetEngine.reconcile(newBudgets);
13653
+ try {
13654
+ budgetEngine.reconcile(newBudgets);
13655
+ } catch (err) {
13656
+ throw new PolicyReloadRejectedError(
13657
+ "rejected_budget_flush",
13658
+ `budget epoch flush failed: ${err instanceof Error ? err.message : String(err)}`,
13659
+ { cause: err }
13660
+ );
13661
+ }
13662
+ auditWriter.setConfigSha256(facts.sha256After);
13067
13663
  applyReloadedPolicy(stacks, newPolicy);
13068
13664
  governanceService?.updatePolicy(newPolicy);
13665
+ auditWriter.pushImmediate(buildPolicyReloadRecord(facts, config.environment ?? null));
13069
13666
  const budgetTotal = newBudgets.length;
13070
13667
  console.error(
13071
13668
  `[helio] Budgets reloaded: ${String(budgetTotal)} budget${budgetTotal !== 1 ? "s" : ""}`
@@ -13088,13 +13685,20 @@ async function startCommand(configPath, options) {
13088
13685
  );
13089
13686
  }
13090
13687
  },
13091
- onError: (error) => {
13092
- console.error(
13093
- `[helio] Config reload failed (keeping current configuration): ${error.message}`
13094
- );
13095
- if (error instanceof ConfigError) {
13096
- printConfigErrorDetails(error, "[helio] ");
13688
+ onError: (error, facts) => {
13689
+ if (facts.outcome === "watch_failed") {
13690
+ console.error(
13691
+ `[helio] Config watch failed (keeping current configuration; edits will not be observed until restart): ${error.message}`
13692
+ );
13693
+ } else {
13694
+ console.error(
13695
+ `[helio] Config reload failed (keeping current configuration): ${error.message}`
13696
+ );
13697
+ if (error instanceof ConfigError) {
13698
+ printConfigErrorDetails(error, "[helio] ");
13699
+ }
13097
13700
  }
13701
+ auditWriter.pushImmediate(buildPolicyReloadRecord(facts, config.environment ?? null));
13098
13702
  }
13099
13703
  });
13100
13704
  configWatcher.start();
@@ -13103,6 +13707,11 @@ async function startCommand(configPath, options) {
13103
13707
  `[helio] Hot-reload disabled \u2014 config changes to ${configPath} will require a restart`
13104
13708
  );
13105
13709
  }
13710
+ warnIfConfigWritableByProxyUser({
13711
+ configPath,
13712
+ hotReload: hotReloadEnabled,
13713
+ pinned: pinnedSha256 !== void 0
13714
+ });
13106
13715
  registerShutdown(
13107
13716
  handle,
13108
13717
  stacks.map((stack) => stack.annotationPrime),
@@ -13127,15 +13736,62 @@ async function initCommand(outputPath, force) {
13127
13736
  console.error(`Error: ${outputPath} already exists. Use --force to overwrite.`);
13128
13737
  process.exit(1);
13129
13738
  }
13130
- const apiSecret = randomBytes2(32).toString("hex");
13131
- await writeFile(outputPath, renderConfigTemplate(apiSecret), "utf-8");
13739
+ const secret = randomBytes3(32).toString("hex");
13740
+ await writeFile(outputPath, renderConfigTemplate(secretDigest(secret)), "utf-8");
13132
13741
  console.error(`Created ${outputPath}`);
13133
13742
  console.error("");
13134
- console.error("Generated dashboard.api_secret (also stored in the file above):");
13135
- console.error(` ${apiSecret}`);
13743
+ console.error("Dashboard secret (shown once; the file stores only its SHA-256 digest):");
13744
+ console.error(` ${secret}`);
13136
13745
  console.error("");
13137
- console.error("Use this as the dashboard login secret (and optional Bearer credential");
13138
- console.error("for sideband API clients at default 127.0.0.1:3100). Rotate in-file.");
13746
+ console.error("Store it in your password manager. Use it to log in to the dashboard and");
13747
+ console.error("as the Bearer credential for sideband API clients (127.0.0.1:3100 by");
13748
+ console.error("default). If you lose it, run `helio secret`, paste the new digest into");
13749
+ console.error("dashboard.api_secret, and restart the proxy.");
13750
+ }
13751
+ async function sandboxCommand(dir, force) {
13752
+ const root = resolve(dir);
13753
+ const targets = SANDBOX_FILES.map((rel) => join2(root, rel));
13754
+ const existing = targets.find((path) => existsSync(path));
13755
+ if (existing !== void 0 && !force) {
13756
+ console.error(`Error: ${existing} already exists. Use --force to overwrite.`);
13757
+ process.exit(1);
13758
+ }
13759
+ await mkdir(join2(root, "helio"), { recursive: true });
13760
+ const contents = {
13761
+ "compose.yaml": renderSandboxCompose({ imageTag: sandboxImageTag(VERSION) }),
13762
+ "helio/helio.yaml": renderSandboxConfig(),
13763
+ "helio/README.md": renderSandboxReadme()
13764
+ };
13765
+ for (const rel of SANDBOX_FILES) await writeFile(join2(root, rel), contents[rel], "utf-8");
13766
+ for (const path of targets) console.error(`Created ${path}`);
13767
+ console.error("");
13768
+ console.error("Next steps:");
13769
+ console.error(" 1. In compose.yaml, set the agent image (or build:) and the mcp-server image.");
13770
+ console.error(
13771
+ " 2. Run `helio secret` and put HELIO_DASHBOARD_SECRET=<digest> in ./.env next to compose.yaml."
13772
+ );
13773
+ console.error(` 3. cd ${dir} && docker compose up -d`);
13774
+ console.error(" 4. docker compose exec agent sh, then run the checks in helio/README.md.");
13775
+ console.error(
13776
+ "Never mount this directory, ./helio, .env, or the Docker socket into the agent service."
13777
+ );
13778
+ }
13779
+ function secretCommand() {
13780
+ const secret = randomBytes3(32).toString("hex");
13781
+ console.log(`secret: ${secret}`);
13782
+ console.log(`digest: ${secretDigest(secret)}`);
13783
+ }
13784
+ async function configHashCommand(configPath) {
13785
+ try {
13786
+ const source = await readConfigSource(configPath);
13787
+ console.log(source.sha256);
13788
+ } catch (err) {
13789
+ if (err instanceof ConfigError) {
13790
+ console.error(`Error: ${err.message}`);
13791
+ process.exit(1);
13792
+ }
13793
+ throw err;
13794
+ }
13139
13795
  }
13140
13796
  async function validateCommand(configPath) {
13141
13797
  try {
@@ -13319,7 +13975,24 @@ program.command("start").description("Load config and start the proxy server").o
13319
13975
  }
13320
13976
  )
13321
13977
  );
13322
- program.command("init").description("Scaffold a helio.yaml config file with commented defaults").option("-o, --output <path>", "Output file path", DEFAULT_CONFIG_PATH).option("-f, --force", "Overwrite existing file", false).action((opts) => initCommand(opts.output, opts.force));
13978
+ program.command("init").description("Scaffold a helio.yaml config file with commented defaults").option("-o, --output <path>", "Output file path", DEFAULT_CONFIG_PATH).option("-f, --force", "Overwrite existing file", false).option(
13979
+ "--sandbox [dir]",
13980
+ `Write the sidecar layout (compose.yaml, helio/helio.yaml, helio/README.md) into <dir> (default: ${SANDBOX_DEFAULT_DIR}) instead of a helio.yaml`
13981
+ ).action((opts, command) => {
13982
+ if (opts.sandbox === void 0) return initCommand(opts.output, opts.force);
13983
+ if (command.getOptionValueSource("output") === "cli") {
13984
+ console.error(
13985
+ "Error: --output does not apply to --sandbox; pass the directory as --sandbox <dir>."
13986
+ );
13987
+ process.exit(1);
13988
+ }
13989
+ return sandboxCommand(opts.sandbox === true ? SANDBOX_DEFAULT_DIR : opts.sandbox, opts.force);
13990
+ });
13323
13991
  program.command("validate").description("Validate a helio.yaml config file").option("-c, --config <path>", "Path to helio.yaml", DEFAULT_CONFIG_PATH).action((opts) => validateCommand(opts.config));
13992
+ program.command("secret").description("Generate a dashboard secret and the digest to store as dashboard.api_secret").action(() => {
13993
+ secretCommand();
13994
+ });
13324
13995
  program.command("export").description("Export audit records or a budget ledger to JSON or CSV").option("-c, --config <path>", "Path to helio.yaml", DEFAULT_CONFIG_PATH).option("-f, --format <format>", "Output format: json or csv", "json").option("--budgets <name>", "Export the named budget ledger instead of the audit trail").option("--tool <name>", "Filter by tool name").option("--decision <decision>", "Filter by policy decision").option("--reason <reason>", "Filter by block reason").option("--session <id>", "Filter by session ID").option("--upstream <name>", "Filter by upstream name").option("--from <iso>", "Start time (ISO 8601)").option("--to <iso>", "End time (ISO 8601)").option("--limit <n>", "Max records to export (up to 10000)", "1000").action((opts) => exportCommand(opts));
13996
+ var configCommand = program.command("config").description("Inspect a helio.yaml config file");
13997
+ configCommand.command("hash").description("Print the SHA-256 of the config file bytes, the value HELIO_CONFIG_SHA256 pins").option("-c, --config <path>", "Path to helio.yaml", DEFAULT_CONFIG_PATH).action((opts) => configHashCommand(opts.config));
13325
13998
  program.parse();