@gethelio/proxy 0.13.1 → 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
7
  import { randomBytes as randomBytes3 } from "crypto";
8
- import { dirname, resolve } from "path";
8
+ import { dirname, join as join2, resolve } from "path";
9
9
  import { fileURLToPath } from "url";
10
10
 
11
11
  // src/version.ts
@@ -995,7 +995,7 @@ function interpolateTracked(value, env, path, out) {
995
995
  }
996
996
  return value;
997
997
  }
998
- async function loadConfigWithMeta(filePath, env) {
998
+ async function readConfigSource(filePath) {
999
999
  let bytes;
1000
1000
  try {
1001
1001
  bytes = await readFile(filePath);
@@ -1003,10 +1003,12 @@ async function loadConfigWithMeta(filePath, env) {
1003
1003
  throw new ConfigError(`Cannot read config file: ${filePath}`);
1004
1004
  }
1005
1005
  const sha256 = createHash("sha256").update(bytes).digest("hex");
1006
- const raw = bytes.toString("utf-8");
1006
+ return { raw: bytes.toString("utf-8"), sha256 };
1007
+ }
1008
+ function parseConfigSource(source, filePath, env) {
1007
1009
  let parsed;
1008
1010
  try {
1009
- parsed = yaml.load(raw);
1011
+ parsed = yaml.load(source.raw);
1010
1012
  } catch (err) {
1011
1013
  const message = err instanceof Error ? err.message : String(err);
1012
1014
  throw new ConfigError(`YAML parse error in ${filePath}: ${message}`);
@@ -1024,12 +1026,43 @@ async function loadConfigWithMeta(filePath, env) {
1024
1026
  details
1025
1027
  );
1026
1028
  }
1027
- return { config: result.data, sha256, interpolatedPaths };
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 };
1028
1035
  }
1029
1036
  async function loadConfig(filePath, env) {
1030
1037
  return (await loadConfigWithMeta(filePath, env)).config;
1031
1038
  }
1032
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 };
1064
+ }
1065
+
1033
1066
  // src/config/watcher.ts
1034
1067
  import { watch } from "chokidar";
1035
1068
 
@@ -1458,24 +1491,56 @@ function compileContributor(contributor, budgetName, index) {
1458
1491
  }
1459
1492
 
1460
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
+ }
1461
1518
  var ConfigWatcher = class {
1462
1519
  configPath;
1463
1520
  onReload;
1464
1521
  onError;
1465
1522
  onReady;
1466
- initialConfig;
1523
+ initial;
1467
1524
  env;
1468
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;
1469
1530
  watcher = null;
1470
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;
1471
1534
  constructor(options) {
1472
1535
  this.configPath = options.configPath;
1473
1536
  this.onReload = options.onReload;
1474
1537
  this.onError = options.onError;
1475
1538
  this.onReady = options.onReady;
1476
- this.initialConfig = options.initialConfig;
1539
+ this.initial = options.initial;
1540
+ this.lastGood = options.initial;
1477
1541
  this.env = options.env;
1478
1542
  this.debounceMs = options.debounceMs ?? 200;
1543
+ this.pinnedSha256 = options.pinnedSha256;
1479
1544
  }
1480
1545
  /** Start watching the config file for changes. */
1481
1546
  start() {
@@ -1486,11 +1551,34 @@ var ConfigWatcher = class {
1486
1551
  awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 10 }
1487
1552
  });
1488
1553
  this.watcher.on("change", () => {
1554
+ if (this.watchFailed) return;
1489
1555
  this.scheduleReload();
1490
1556
  });
1491
1557
  this.watcher.on("ready", () => {
1492
1558
  if (this.watcher && this.onReady) this.onReady();
1493
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
+ });
1494
1582
  }
1495
1583
  /** Stop watching and clean up resources. */
1496
1584
  close() {
@@ -1513,18 +1601,55 @@ var ConfigWatcher = class {
1513
1601
  }, this.debounceMs);
1514
1602
  }
1515
1603
  async reload() {
1604
+ const before = beforeFacts(this.lastGood);
1605
+ let sha256After = null;
1606
+ let parsed = null;
1607
+ let restartRequiredPaths = [];
1516
1608
  try {
1517
- 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;
1518
1619
  const { policy, warnings } = compilePolicies(config.policies);
1519
1620
  const budgets = compileBudgets(config.budgets);
1520
- const restartRequiredPaths = this.initialConfig !== void 0 ? diffReloadBoundary(this.initialConfig, config).restartRequiredPaths : [];
1521
- 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 };
1522
1637
  } catch (err) {
1523
- if (err instanceof Error) {
1524
- this.onError(err);
1525
- } else {
1526
- this.onError(new Error(String(err)));
1527
- }
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
+ });
1528
1653
  }
1529
1654
  }
1530
1655
  };
@@ -1549,6 +1674,247 @@ function verifyBearer(authHeader, expected) {
1549
1674
  return timingSafeEqual(actualDigest, expectedDigest);
1550
1675
  }
1551
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
+
1552
1918
  // src/server.ts
1553
1919
  import { Hono as Hono3 } from "hono";
1554
1920
  import { serve } from "@hono/node-server";
@@ -7169,6 +7535,7 @@ function clampInt(value, fallback, min, max) {
7169
7535
  // src/audit/store.ts
7170
7536
  var DRIFT_EVENT_DECISIONS_SQL = "('tool_drift', 'tool_drift_reverted')";
7171
7537
  var NON_TOOL_DECISIONS_SQL = "('tool_drift', 'tool_drift_reverted', 'rejected')";
7538
+ var POLICY_RELOAD_KIND_SQL = "'policy_reload'";
7172
7539
  var EXPORT_MAX_RECORDS = 1e4;
7173
7540
  var LIST_MAX_PAGE_SIZE = 1e3;
7174
7541
  var CREATE_TABLE_DDL = `
@@ -7202,7 +7569,8 @@ CREATE TABLE IF NOT EXISTS audit_records (
7202
7569
  metadata TEXT,
7203
7570
  protocol_version TEXT,
7204
7571
  created_at TEXT NOT NULL,
7205
- upstream TEXT
7572
+ upstream TEXT,
7573
+ config_sha256 TEXT
7206
7574
  );
7207
7575
  `;
7208
7576
  var CREATE_INDEX_DDL = `
@@ -7215,6 +7583,7 @@ CREATE INDEX IF NOT EXISTS idx_audit_upstream_status_created_at ON audit_records
7215
7583
  CREATE INDEX IF NOT EXISTS idx_audit_record_kind ON audit_records (record_kind);
7216
7584
  CREATE INDEX IF NOT EXISTS idx_audit_origin ON audit_records (origin);
7217
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);
7218
7587
  `;
7219
7588
  var INSERT_SQL = `
7220
7589
  INSERT INTO audit_records (
@@ -7224,7 +7593,7 @@ INSERT INTO audit_records (
7224
7593
  upstream_http_status,
7225
7594
  total_duration_ms, approval_wait_ms, proxy_compute_ms,
7226
7595
  flagged_destructive, dry_run, record_kind, origin, metadata, protocol_version, created_at,
7227
- upstream
7596
+ upstream, config_sha256
7228
7597
  ) VALUES (
7229
7598
  @id, @timestamp, @session_id, @session_source, @agent_id, @environment, @tool_name, @tool_input,
7230
7599
  @policy_decision, @block_reason, @matched_rule, @matched_rule_index, @evidence_chain, @approval_status,
@@ -7232,7 +7601,7 @@ INSERT INTO audit_records (
7232
7601
  @upstream_http_status,
7233
7602
  @total_duration_ms, @approval_wait_ms, @proxy_compute_ms,
7234
7603
  @flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @protocol_version, @created_at,
7235
- @upstream
7604
+ @upstream, @config_sha256
7236
7605
  )
7237
7606
  `;
7238
7607
  var REQUIRED_AUDIT_COLUMNS = [
@@ -7252,10 +7621,14 @@ var REQUIRED_AUDIT_COLUMNS = [
7252
7621
  // Same clean break, same unreleased cycle (issue #219): released users see
7253
7622
  // ONE break, at v0.12.0.
7254
7623
  "protocol_version",
7255
- // The one ratified exception to the clean break (issue #292): a
7256
- // v0.12.0-complete database missing ONLY this column is migrated in place
7257
- // by migrateAuditUpstreamColumn instead of failing the assertion.
7258
- "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"
7259
7632
  ];
7260
7633
  function deserializeRow(row) {
7261
7634
  return {
@@ -7288,6 +7661,7 @@ function deserializeRow(row) {
7288
7661
  metadata: row.metadata ? JSON.parse(row.metadata) : null,
7289
7662
  protocol_version: row.protocol_version,
7290
7663
  upstream: row.upstream,
7664
+ config_sha256: row.config_sha256,
7291
7665
  created_at: row.created_at
7292
7666
  };
7293
7667
  }
@@ -7368,21 +7742,34 @@ function buildWhereClause(filters) {
7368
7742
  const clause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
7369
7743
  return { clause, params };
7370
7744
  }
7371
- 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) {
7372
7750
  const probe = () => {
7373
7751
  const rows = db.pragma("table_info(audit_records)");
7374
7752
  return new Set(rows.map((row) => row.name));
7375
7753
  };
7376
7754
  const existing = probe();
7377
- const missing = REQUIRED_AUDIT_COLUMNS.filter((name) => !existing.has(name));
7378
- if (missing.length !== 1 || missing[0] !== "upstream") return false;
7379
- try {
7380
- db.exec("ALTER TABLE audit_records ADD COLUMN upstream TEXT");
7381
- } catch (err) {
7382
- if (probe().has("upstream")) return false;
7383
- 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);
7384
7771
  }
7385
- return true;
7772
+ return added;
7386
7773
  }
7387
7774
  function restrictAuditFilePerms(dbPath) {
7388
7775
  if (dbPath === ":memory:" || process.platform === "win32") return;
@@ -7412,8 +7799,8 @@ var AuditStore = class {
7412
7799
  this.retentionMs = parseDuration(options.retention);
7413
7800
  this.includeResponses = options.includeResponses;
7414
7801
  this.db.exec(CREATE_TABLE_DDL);
7415
- if (migrateAuditUpstreamColumn(this.db)) {
7416
- 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}"`);
7417
7804
  }
7418
7805
  this.assertRequiredSchema(options.path);
7419
7806
  this.db.exec(CREATE_INDEX_DDL);
@@ -7524,6 +7911,7 @@ var AuditStore = class {
7524
7911
  metadata: record.metadata ? JSON.stringify(record.metadata) : null,
7525
7912
  protocol_version: record.protocol_version,
7526
7913
  upstream: record.upstream ?? null,
7914
+ config_sha256: record.config_sha256 ?? null,
7527
7915
  created_at: now
7528
7916
  });
7529
7917
  return resolvedId;
@@ -7611,26 +7999,27 @@ var AuditStore = class {
7611
7999
  const totals = this.db.prepare(
7612
8000
  `SELECT
7613
8001
  COUNT(*) as total,
7614
- 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,
7615
- COALESCE(SUM(CASE WHEN block_reason IS NOT NULL THEN 1 ELSE 0 END), 0) as blocked_total,
7616
- COALESCE(SUM(CASE WHEN dry_run = 1 THEN 1 ELSE 0 END), 0) as dry_run_total,
7617
- 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
7618
8006
  FROM audit_records ${clause}`
7619
8007
  ).get(...params);
8008
+ const decisionClause = clause ? `${clause} AND record_kind <> ${POLICY_RELOAD_KIND_SQL}` : `WHERE record_kind <> ${POLICY_RELOAD_KIND_SQL}`;
7620
8009
  const by_decision = this.db.prepare(
7621
8010
  `SELECT policy_decision as decision, COUNT(*) as count
7622
- FROM audit_records ${clause}
8011
+ FROM audit_records ${decisionClause}
7623
8012
  GROUP BY policy_decision
7624
8013
  ORDER BY count DESC`
7625
8014
  ).all(...params);
7626
- 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}`;
7627
8016
  const by_block_reason = this.db.prepare(
7628
8017
  `SELECT block_reason as reason, COUNT(*) as count
7629
8018
  FROM audit_records ${blockedClause}
7630
8019
  GROUP BY block_reason
7631
8020
  ORDER BY count DESC`
7632
8021
  ).all(...params);
7633
- 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}`;
7634
8023
  const top_tools = this.db.prepare(
7635
8024
  `SELECT tool_name, upstream, COUNT(*) as count
7636
8025
  FROM audit_records ${toolsClause}
@@ -7696,6 +8085,15 @@ var AuditWriter = class {
7696
8085
  bufferSize;
7697
8086
  onPush;
7698
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;
7699
8097
  buffer = [];
7700
8098
  timer = null;
7701
8099
  flushSoonTimer = null;
@@ -7705,6 +8103,7 @@ var AuditWriter = class {
7705
8103
  this.bufferSize = options.bufferSize ?? 50;
7706
8104
  this.onPush = options.onPush;
7707
8105
  this.onPersist = options.onPersist;
8106
+ this.configSha256 = options.configSha256 ?? null;
7708
8107
  const intervalMs = options.flushIntervalMs ?? 100;
7709
8108
  if (intervalMs > 0) {
7710
8109
  this.timer = setInterval(() => {
@@ -7725,8 +8124,9 @@ var AuditWriter = class {
7725
8124
  * is scheduled. This keeps request-path latency bounded even under bursty
7726
8125
  * write load.
7727
8126
  */
7728
- push(record, id = randomUUID4()) {
8127
+ push(input, id = randomUUID4()) {
7729
8128
  if (this.closed) return;
8129
+ const record = this.stamp(input);
7730
8130
  this.buffer.push({ id, record });
7731
8131
  this.onPush?.(record, id);
7732
8132
  if (this.buffer.length >= this.bufferSize) {
@@ -7741,12 +8141,30 @@ var AuditWriter = class {
7741
8141
  * A fatal-process crash still invokes the crash-drain hook, which calls
7742
8142
  * `flush()` synchronously before exit.
7743
8143
  */
7744
- pushImmediate(record, id = randomUUID4()) {
8144
+ pushImmediate(input, id = randomUUID4()) {
7745
8145
  if (this.closed) return;
8146
+ const record = this.stamp(input);
7746
8147
  this.buffer.push({ id, record });
7747
8148
  this.onPush?.(record, id);
7748
8149
  this.scheduleFlushSoon();
7749
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
+ }
7750
8168
  /**
7751
8169
  * Schedule a flush on the next tick, coalescing multiple calls into one.
7752
8170
  */
@@ -7842,6 +8260,80 @@ function buildHeaderMismatchAuditRecord(rejection, environment, upstream) {
7842
8260
  };
7843
8261
  }
7844
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
+
7845
8337
  // src/evidence/store.ts
7846
8338
  var EvidenceStore = class _EvidenceStore {
7847
8339
  static EVIDENCE_ALLOWLIST_PREVIEW_LIMIT = 20;
@@ -8136,70 +8628,70 @@ var EvidenceStore = class _EvidenceStore {
8136
8628
  import { Hono as Hono5 } from "hono";
8137
8629
  import { bodyLimit } from "hono/body-limit";
8138
8630
  import { HTTPException } from "hono/http-exception";
8139
- import { z as z5 } from "zod";
8631
+ import { z as z6 } from "zod";
8140
8632
 
8141
8633
  // src/sideband/governance-api.ts
8142
8634
  import { Hono as Hono4 } from "hono";
8143
- import { z as z4 } from "zod";
8635
+ import { z as z5 } from "zod";
8144
8636
  import { createHash as createHash3 } from "crypto";
8145
- var originSchema = z4.string().regex(/^[a-z0-9_-]{1,64}$/, "origin must match ^[a-z0-9_-]{1,64}$").default("sideband");
8146
- var metadataSchema = z4.record(z4.string(), z4.unknown()).nullish();
8147
- var toolDefinitionSchema = z4.object({
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({
8148
8640
  // Stored verbatim in pending entries and audit rows; capped so a
8149
8641
  // caller-minted name cannot inflate the pending-entry footprint.
8150
- name: z4.string().min(1).max(256),
8151
- description: z4.string().optional(),
8152
- input_schema: z4.unknown().optional(),
8153
- output_schema: z4.unknown().optional(),
8154
- title: z4.string().optional(),
8155
- 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()
8156
8648
  });
8157
- var evaluateBody = z4.object({
8649
+ var evaluateBody = z5.object({
8158
8650
  origin: originSchema,
8159
- adapter_version: z4.string().max(64).optional(),
8651
+ adapter_version: z5.string().max(64).optional(),
8160
8652
  // Stored in pending entries and limit bucket keys; capped (like origin and
8161
8653
  // adapter_version) so caller-minted ids cannot inflate memory unaccounted.
8162
- agent_id: z4.string().max(128).nullish(),
8163
- session_id: z4.string().max(256).nullish(),
8654
+ agent_id: z5.string().max(128).nullish(),
8655
+ session_id: z5.string().max(256).nullish(),
8164
8656
  tool: toolDefinitionSchema,
8165
- arguments: z4.record(z4.string(), z4.unknown()).optional(),
8657
+ arguments: z5.record(z5.string(), z5.unknown()).optional(),
8166
8658
  metadata: metadataSchema
8167
8659
  });
8168
- var installScanBody = z4.object({
8660
+ var installScanBody = z5.object({
8169
8661
  origin: originSchema,
8170
- agent_id: z4.string().max(128).nullish(),
8171
- session_id: z4.string().max(256).nullish(),
8172
- package: z4.object({
8173
- name: z4.string().min(1),
8174
- version: z4.string().optional(),
8175
- source: z4.string().max(64).optional(),
8176
- spec: z4.string().optional(),
8177
- 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()
8178
8670
  }),
8179
8671
  metadata: metadataSchema
8180
8672
  });
8181
- var evidenceEntrySchema = z4.object({
8182
- evidence_key: z4.string().min(1),
8183
- evidence_data: z4.unknown().refine((v) => v !== void 0, { message: "Required" }),
8184
- 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()
8185
8677
  });
8186
- var auditBody = z4.object({
8187
- evaluation_id: z4.string().min(1),
8188
- status: z4.enum(["success", "error", "not_executed"]),
8189
- error: z4.string().optional(),
8190
- duration_ms: z4.number().optional(),
8191
- result: z4.unknown().optional(),
8192
- 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(),
8193
8685
  // No `.max()` / size refinement here on purpose (issue #11): caps are
8194
8686
  // enforced per-entry in GovernanceService.populateEvidence as soft-drops, so
8195
8687
  // an over-cap entry never 400s away the audit row for a call that already ran.
8196
- evidence: z4.array(evidenceEntrySchema).optional()
8688
+ evidence: z5.array(evidenceEntrySchema).optional()
8197
8689
  });
8198
- var resolveBody = z4.object({
8199
- resolution: z4.enum(["approved", "denied", "timeout", "cancelled"]),
8200
- resolved_by: z4.string().optional(),
8201
- reason: z4.string().optional(),
8202
- 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()
8203
8695
  });
8204
8696
  var MAX_METADATA_BYTES = 4 * 1024;
8205
8697
  function createGovernanceApp(service) {
@@ -8314,20 +8806,20 @@ function asStatus(status) {
8314
8806
 
8315
8807
  // src/evidence/api.ts
8316
8808
  var SIDEBAND_BODY_LIMIT_BYTES = 1 * 1024 * 1024;
8317
- var sessionIdSchema = z5.string().min(1).refine((value) => value.trim() !== "", {
8809
+ var sessionIdSchema = z6.string().min(1).refine((value) => value.trim() !== "", {
8318
8810
  message: "session_id must not be whitespace-only"
8319
8811
  });
8320
- var postEvidenceBody = z5.object({
8812
+ var postEvidenceBody = z6.object({
8321
8813
  session_id: sessionIdSchema,
8322
- tool_name: z5.string().min(1),
8323
- evidence_key: z5.string().min(1),
8324
- evidence_data: z5.unknown().refine((v) => v !== void 0, { message: "Required" }),
8325
- 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()
8326
8818
  });
8327
- var postContextBody = z5.object({
8819
+ var postContextBody = z6.object({
8328
8820
  session_id: sessionIdSchema,
8329
- key: z5.string().min(1),
8330
- 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" })
8331
8823
  });
8332
8824
  function createSidebandApp(store, options = {}) {
8333
8825
  const app = new Hono5();
@@ -10392,7 +10884,7 @@ function createChannels(channels) {
10392
10884
  // src/approval/slack-actions.ts
10393
10885
  import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
10394
10886
  import { Hono as Hono6 } from "hono";
10395
- import { z as z6 } from "zod";
10887
+ import { z as z7 } from "zod";
10396
10888
  var MAX_TIMESTAMP_AGE_S = 300;
10397
10889
  var REJECTION_LOG_WINDOW_MS = 6e4;
10398
10890
  var REJECTION_LOG_SAMPLE_EVERY = 25;
@@ -10458,12 +10950,12 @@ function verifySlackSignature(secrets, timestamp, rawBody, signature) {
10458
10950
  }
10459
10951
  return false;
10460
10952
  }
10461
- var slackActionPayloadSchema = z6.object({
10462
- type: z6.string(),
10463
- user: z6.object({ id: z6.string(), username: z6.string() }),
10464
- actions: z6.array(z6.object({ action_id: z6.string() })),
10465
- channel: z6.object({ id: z6.string() }),
10466
- 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() })
10467
10959
  });
10468
10960
  function parseActionPayload(rawBody) {
10469
10961
  try {
@@ -10593,17 +11085,17 @@ function createSlackActionApp(options) {
10593
11085
 
10594
11086
  // src/approval/api.ts
10595
11087
  import { Hono as Hono7 } from "hono";
10596
- import { z as z7 } from "zod";
10597
- var approveBody = z7.object({
10598
- 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)
10599
11091
  });
10600
- var denyBody = z7.object({
10601
- denied_by: z7.string().min(1),
10602
- reason: z7.string().optional()
11092
+ var denyBody = z8.object({
11093
+ denied_by: z8.string().min(1),
11094
+ reason: z8.string().optional()
10603
11095
  });
10604
- var breakGlassBody = z7.object({
10605
- approved_by: z7.string().min(1),
10606
- reason: z7.string().min(1)
11096
+ var breakGlassBody = z8.object({
11097
+ approved_by: z8.string().min(1),
11098
+ reason: z8.string().min(1)
10607
11099
  });
10608
11100
  var APPROVAL_STATUSES = [
10609
11101
  "pending",
@@ -10616,18 +11108,18 @@ var APPROVAL_STATUSES = [
10616
11108
  "cancelled"
10617
11109
  ];
10618
11110
  var approvalStatusSet = new Set(APPROVAL_STATUSES);
10619
- var listApprovalsQuery = z7.object({
10620
- status: z7.preprocess(
11111
+ var listApprovalsQuery = z8.object({
11112
+ status: z8.preprocess(
10621
11113
  (value) => typeof value === "string" && approvalStatusSet.has(value) ? value : void 0,
10622
- z7.enum(APPROVAL_STATUSES).optional()
11114
+ z8.enum(APPROVAL_STATUSES).optional()
10623
11115
  ),
10624
- limit: z7.preprocess(
11116
+ limit: z8.preprocess(
10625
11117
  (value) => clampInt(typeof value === "string" ? value : void 0, 50, 1, 1e3),
10626
- z7.number().int()
11118
+ z8.number().int()
10627
11119
  ),
10628
- offset: z7.preprocess(
11120
+ offset: z8.preprocess(
10629
11121
  (value) => clampInt(typeof value === "string" ? value : void 0, 0, 0, Number.MAX_SAFE_INTEGER),
10630
- z7.number().int()
11122
+ z8.number().int()
10631
11123
  )
10632
11124
  });
10633
11125
  function createApprovalApp(router, queue, options) {
@@ -11651,11 +12143,12 @@ var CSV_HEADERS = [
11651
12143
  "record_kind",
11652
12144
  "origin",
11653
12145
  "metadata",
11654
- // Appended LAST (issues #218, #219, #292): positional consumers of the
11655
- // 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.
11656
12148
  "session_source",
11657
12149
  "protocol_version",
11658
- "upstream"
12150
+ "upstream",
12151
+ "config_sha256"
11659
12152
  ];
11660
12153
  var FORMULA_PREFIXES = /^[=+\-@\t\r]/;
11661
12154
  function csvEscape(value) {
@@ -11726,7 +12219,7 @@ import { join } from "path";
11726
12219
  import { randomBytes as randomBytes2, randomUUID as randomUUID8 } from "crypto";
11727
12220
  import { Hono as Hono8 } from "hono";
11728
12221
  import { HTTPException as HTTPException2 } from "hono/http-exception";
11729
- import { z as z8 } from "zod";
12222
+ import { z as z9 } from "zod";
11730
12223
  import { cors } from "hono/cors";
11731
12224
  import { serveStatic } from "@hono/node-server/serve-static";
11732
12225
  import { streamSSE } from "hono/streaming";
@@ -11824,24 +12317,24 @@ var DashboardSessionStore = class {
11824
12317
  };
11825
12318
 
11826
12319
  // src/dashboard/api.ts
11827
- var optionalQueryString = z8.preprocess(
12320
+ var optionalQueryString = z9.preprocess(
11828
12321
  (value) => typeof value === "string" && value.length > 0 ? value : void 0,
11829
- z8.string().optional()
12322
+ z9.string().optional()
11830
12323
  );
11831
- var optionalQueryInt = z8.preprocess((value) => {
12324
+ var optionalQueryInt = z9.preprocess((value) => {
11832
12325
  if (typeof value !== "string" || value.length === 0) return void 0;
11833
12326
  const parsed = Number.parseInt(value, 10);
11834
12327
  return Number.isFinite(parsed) ? parsed : void 0;
11835
- }, z8.number().int().optional());
11836
- var queryBoolean = z8.preprocess(
12328
+ }, z9.number().int().optional());
12329
+ var queryBoolean = z9.preprocess(
11837
12330
  (value) => value === "true" ? true : value === "false" ? false : void 0,
11838
- z8.boolean().optional()
12331
+ z9.boolean().optional()
11839
12332
  );
11840
- var clampedQueryInt = (fallback, min, max) => z8.preprocess(
12333
+ var clampedQueryInt = (fallback, min, max) => z9.preprocess(
11841
12334
  (value) => clampInt(typeof value === "string" ? value : void 0, fallback, min, max),
11842
- z8.number().int()
12335
+ z9.number().int()
11843
12336
  );
11844
- var feedQuerySchema = z8.object({
12337
+ var feedQuerySchema = z9.object({
11845
12338
  limit: clampedQueryInt(50, 1, 200),
11846
12339
  offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER),
11847
12340
  // The feed's server-side filters (issues #292, #316): attribution and
@@ -11851,8 +12344,8 @@ var feedQuerySchema = z8.object({
11851
12344
  upstream: optionalQueryString,
11852
12345
  session_source: optionalQueryString
11853
12346
  });
11854
- var auditExportQuerySchema = z8.object({
11855
- 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"])),
11856
12349
  limit: clampedQueryInt(EXPORT_MAX_RECORDS, 1, EXPORT_MAX_RECORDS),
11857
12350
  tool: optionalQueryString,
11858
12351
  decision: optionalQueryString,
@@ -11872,7 +12365,7 @@ var auditExportQuerySchema = z8.object({
11872
12365
  upstream: optionalQueryString,
11873
12366
  session_source: optionalQueryString
11874
12367
  });
11875
- var auditQuerySchema = z8.object({
12368
+ var auditQuerySchema = z9.object({
11876
12369
  limit: clampedQueryInt(50, 1, LIST_MAX_PAGE_SIZE),
11877
12370
  offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER),
11878
12371
  tool: optionalQueryString,
@@ -11894,21 +12387,21 @@ var auditQuerySchema = z8.object({
11894
12387
  upstream: optionalQueryString,
11895
12388
  session_source: optionalQueryString
11896
12389
  });
11897
- var budgetEventsQuerySchema = z8.object({
12390
+ var budgetEventsQuerySchema = z9.object({
11898
12391
  limit: clampedQueryInt(50, 1, LIST_MAX_PAGE_SIZE),
11899
12392
  offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER)
11900
12393
  });
11901
- var budgetEventsExportQuerySchema = z8.object({
11902
- 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"])),
11903
12396
  limit: clampedQueryInt(EXPORT_MAX_RECORDS, 1, EXPORT_MAX_RECORDS)
11904
12397
  });
11905
- var analyticsQuerySchema = z8.object({
12398
+ var analyticsQuerySchema = z9.object({
11906
12399
  from: optionalQueryString,
11907
12400
  to: optionalQueryString,
11908
12401
  upstream: optionalQueryString
11909
12402
  });
11910
- var authSessionBodySchema = z8.object({
11911
- secret: z8.string()
12403
+ var authSessionBodySchema = z9.object({
12404
+ secret: z9.string()
11912
12405
  });
11913
12406
  var SESSION_COOKIE = "helio_session";
11914
12407
  var SESSION_TTL_MS = 8 * 60 * 60 * 1e3;
@@ -12390,7 +12883,8 @@ var EVENT_TYPES = [
12390
12883
  "limit_warning",
12391
12884
  "approval_notification_failed",
12392
12885
  "budget_update",
12393
- "budget_breached"
12886
+ "budget_breached",
12887
+ "policy_reload"
12394
12888
  ];
12395
12889
  var DashboardEventBus = class {
12396
12890
  emitter = new EventEmitter();
@@ -12459,6 +12953,10 @@ function actionEventFromRecord(record, id) {
12459
12953
  upstream: record.upstream
12460
12954
  };
12461
12955
  }
12956
+ function policyReloadEventFromRecord(record, id) {
12957
+ const evidence = readPolicyReloadEvidence(record);
12958
+ return evidence === null ? null : { ...evidence, id, at: record.timestamp };
12959
+ }
12462
12960
  function approvalRequestedEvent(ticket) {
12463
12961
  return {
12464
12962
  ticket_id: ticket.id,
@@ -12482,6 +12980,10 @@ function dashboardEventCallbacks(bus) {
12482
12980
  return {
12483
12981
  onPersist: (record, id) => {
12484
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
+ }
12485
12987
  },
12486
12988
  onApprovalSubmit: (ticket) => {
12487
12989
  bus.emit("approval_requested", approvalRequestedEvent(ticket));
@@ -12499,6 +13001,7 @@ function dashboardEventCallbacks(bus) {
12499
13001
  }
12500
13002
 
12501
13003
  // src/startup-warnings.ts
13004
+ import { accessSync, constants } from "fs";
12502
13005
  function isLoopbackHost2(host) {
12503
13006
  return host === "127.0.0.1" || host === "localhost" || host === "::1";
12504
13007
  }
@@ -12585,6 +13088,23 @@ function warnIfNoEnforcement(policy, log = console.error) {
12585
13088
  );
12586
13089
  return true;
12587
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
+ }
12588
13108
 
12589
13109
  // src/shutdown.ts
12590
13110
  async function closeResources(resources) {
@@ -12782,10 +13302,12 @@ async function governUpstream(options) {
12782
13302
  async function startCommand(configPath, options) {
12783
13303
  let config;
12784
13304
  let interpolatedPaths = [];
13305
+ let configSha256 = "";
12785
13306
  try {
12786
13307
  const loaded = await loadConfigWithMeta(configPath);
12787
13308
  config = loaded.config;
12788
13309
  interpolatedPaths = loaded.interpolatedPaths;
13310
+ configSha256 = loaded.sha256;
12789
13311
  } catch (err) {
12790
13312
  if (err instanceof ConfigError) {
12791
13313
  console.error(`Error: ${err.message}`);
@@ -12794,6 +13316,20 @@ async function startCommand(configPath, options) {
12794
13316
  }
12795
13317
  throw err;
12796
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;
12797
13333
  const bundledDashboardDistPath = config.dashboard.enabled ? getBundledDashboardDistPath() : null;
12798
13334
  if (config.dashboard.enabled && !bundledDashboardDistPath) {
12799
13335
  console.error(
@@ -12844,7 +13380,10 @@ async function startCommand(configPath, options) {
12844
13380
  auditStore.runRetentionSweep();
12845
13381
  const auditWriter = new AuditWriter({
12846
13382
  store: auditStore,
12847
- 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
12848
13387
  });
12849
13388
  registerCrashDrainHook(() => {
12850
13389
  try {
@@ -13084,29 +13623,46 @@ async function startCommand(configPath, options) {
13084
13623
  console.error(`Dry-run: ENABLED (no requests will be forwarded to upstream)`);
13085
13624
  }
13086
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
+ }
13087
13631
  const hotReloadEnabled = options.noHotReload === true ? false : config.policies.hot_reload ?? true;
13088
13632
  let configWatcher;
13089
13633
  if (hotReloadEnabled) {
13090
13634
  configWatcher = new ConfigWatcher({
13091
13635
  configPath,
13092
- initialConfig: config,
13636
+ initial: { config, sha256: configSha256 },
13637
+ pinnedSha256,
13093
13638
  onReady: () => {
13094
13639
  console.error(`Watching ${configPath} for policy changes`);
13095
13640
  },
13096
- onReload: (newPolicy, reloadWarnings, restartRequiredPaths, newBudgets) => {
13641
+ onReload: (newPolicy, reloadWarnings, restartRequiredPaths, newBudgets, facts) => {
13097
13642
  const unroutable = findUnroutableApprovalReferences(newPolicy, newBudgets, {
13098
13643
  channelTypes: runtimeChannelTypes,
13099
13644
  dashboardEnabled: config.dashboard.enabled,
13100
13645
  defaultApprovalTimeoutMs: parseDuration(config.approval.timeout)
13101
13646
  });
13102
13647
  if (unroutable.length > 0) {
13103
- throw new Error(
13648
+ throw new PolicyReloadRejectedError(
13649
+ "rejected_unroutable",
13104
13650
  `approval routing is not available in the running process (restart required to apply approval.channels/dashboard changes): ${unroutable.join("; ")}`
13105
13651
  );
13106
13652
  }
13107
- 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);
13108
13663
  applyReloadedPolicy(stacks, newPolicy);
13109
13664
  governanceService?.updatePolicy(newPolicy);
13665
+ auditWriter.pushImmediate(buildPolicyReloadRecord(facts, config.environment ?? null));
13110
13666
  const budgetTotal = newBudgets.length;
13111
13667
  console.error(
13112
13668
  `[helio] Budgets reloaded: ${String(budgetTotal)} budget${budgetTotal !== 1 ? "s" : ""}`
@@ -13129,13 +13685,20 @@ async function startCommand(configPath, options) {
13129
13685
  );
13130
13686
  }
13131
13687
  },
13132
- onError: (error) => {
13133
- console.error(
13134
- `[helio] Config reload failed (keeping current configuration): ${error.message}`
13135
- );
13136
- if (error instanceof ConfigError) {
13137
- 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
+ }
13138
13700
  }
13701
+ auditWriter.pushImmediate(buildPolicyReloadRecord(facts, config.environment ?? null));
13139
13702
  }
13140
13703
  });
13141
13704
  configWatcher.start();
@@ -13144,6 +13707,11 @@ async function startCommand(configPath, options) {
13144
13707
  `[helio] Hot-reload disabled \u2014 config changes to ${configPath} will require a restart`
13145
13708
  );
13146
13709
  }
13710
+ warnIfConfigWritableByProxyUser({
13711
+ configPath,
13712
+ hotReload: hotReloadEnabled,
13713
+ pinned: pinnedSha256 !== void 0
13714
+ });
13147
13715
  registerShutdown(
13148
13716
  handle,
13149
13717
  stacks.map((stack) => stack.annotationPrime),
@@ -13180,11 +13748,51 @@ async function initCommand(outputPath, force) {
13180
13748
  console.error("default). If you lose it, run `helio secret`, paste the new digest into");
13181
13749
  console.error("dashboard.api_secret, and restart the proxy.");
13182
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
+ }
13183
13779
  function secretCommand() {
13184
13780
  const secret = randomBytes3(32).toString("hex");
13185
13781
  console.log(`secret: ${secret}`);
13186
13782
  console.log(`digest: ${secretDigest(secret)}`);
13187
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
+ }
13795
+ }
13188
13796
  async function validateCommand(configPath) {
13189
13797
  try {
13190
13798
  const config = await loadConfig(configPath);
@@ -13367,10 +13975,24 @@ program.command("start").description("Load config and start the proxy server").o
13367
13975
  }
13368
13976
  )
13369
13977
  );
13370
- 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
+ });
13371
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));
13372
13992
  program.command("secret").description("Generate a dashboard secret and the digest to store as dashboard.api_secret").action(() => {
13373
13993
  secretCommand();
13374
13994
  });
13375
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));
13376
13998
  program.parse();