@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/README.md +7 -1
- package/dist/cli.js +774 -152
- package/dist/dashboard-assets/assets/{index-BRvkMXWl.js → index-D9zeFAzU.js} +2 -2
- package/dist/dashboard-assets/index.html +1 -1
- package/dist/index.d.ts +75 -9
- package/dist/index.js +167 -77
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
1521
|
-
|
|
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
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
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
|
|
7256
|
-
//
|
|
7257
|
-
//
|
|
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
|
-
|
|
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.
|
|
7379
|
-
|
|
7380
|
-
|
|
7381
|
-
|
|
7382
|
-
|
|
7383
|
-
|
|
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
|
|
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
|
-
|
|
7416
|
-
console.error(
|
|
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 ${
|
|
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` :
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
8635
|
+
import { z as z5 } from "zod";
|
|
8144
8636
|
import { createHash as createHash3 } from "crypto";
|
|
8145
|
-
var originSchema =
|
|
8146
|
-
var metadataSchema =
|
|
8147
|
-
var toolDefinitionSchema =
|
|
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:
|
|
8151
|
-
description:
|
|
8152
|
-
input_schema:
|
|
8153
|
-
output_schema:
|
|
8154
|
-
title:
|
|
8155
|
-
annotations:
|
|
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 =
|
|
8649
|
+
var evaluateBody = z5.object({
|
|
8158
8650
|
origin: originSchema,
|
|
8159
|
-
adapter_version:
|
|
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:
|
|
8163
|
-
session_id:
|
|
8654
|
+
agent_id: z5.string().max(128).nullish(),
|
|
8655
|
+
session_id: z5.string().max(256).nullish(),
|
|
8164
8656
|
tool: toolDefinitionSchema,
|
|
8165
|
-
arguments:
|
|
8657
|
+
arguments: z5.record(z5.string(), z5.unknown()).optional(),
|
|
8166
8658
|
metadata: metadataSchema
|
|
8167
8659
|
});
|
|
8168
|
-
var installScanBody =
|
|
8660
|
+
var installScanBody = z5.object({
|
|
8169
8661
|
origin: originSchema,
|
|
8170
|
-
agent_id:
|
|
8171
|
-
session_id:
|
|
8172
|
-
package:
|
|
8173
|
-
name:
|
|
8174
|
-
version:
|
|
8175
|
-
source:
|
|
8176
|
-
spec:
|
|
8177
|
-
url:
|
|
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 =
|
|
8182
|
-
evidence_key:
|
|
8183
|
-
evidence_data:
|
|
8184
|
-
ttl_seconds:
|
|
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 =
|
|
8187
|
-
evaluation_id:
|
|
8188
|
-
status:
|
|
8189
|
-
error:
|
|
8190
|
-
duration_ms:
|
|
8191
|
-
result:
|
|
8192
|
-
actual_amount:
|
|
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:
|
|
8688
|
+
evidence: z5.array(evidenceEntrySchema).optional()
|
|
8197
8689
|
});
|
|
8198
|
-
var resolveBody =
|
|
8199
|
-
resolution:
|
|
8200
|
-
resolved_by:
|
|
8201
|
-
reason:
|
|
8202
|
-
scope:
|
|
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 =
|
|
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 =
|
|
8812
|
+
var postEvidenceBody = z6.object({
|
|
8321
8813
|
session_id: sessionIdSchema,
|
|
8322
|
-
tool_name:
|
|
8323
|
-
evidence_key:
|
|
8324
|
-
evidence_data:
|
|
8325
|
-
ttl_seconds:
|
|
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 =
|
|
8819
|
+
var postContextBody = z6.object({
|
|
8328
8820
|
session_id: sessionIdSchema,
|
|
8329
|
-
key:
|
|
8330
|
-
value:
|
|
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
|
|
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 =
|
|
10462
|
-
type:
|
|
10463
|
-
user:
|
|
10464
|
-
actions:
|
|
10465
|
-
channel:
|
|
10466
|
-
message:
|
|
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
|
|
10597
|
-
var approveBody =
|
|
10598
|
-
approved_by:
|
|
11088
|
+
import { z as z8 } from "zod";
|
|
11089
|
+
var approveBody = z8.object({
|
|
11090
|
+
approved_by: z8.string().min(1)
|
|
10599
11091
|
});
|
|
10600
|
-
var denyBody =
|
|
10601
|
-
denied_by:
|
|
10602
|
-
reason:
|
|
11092
|
+
var denyBody = z8.object({
|
|
11093
|
+
denied_by: z8.string().min(1),
|
|
11094
|
+
reason: z8.string().optional()
|
|
10603
11095
|
});
|
|
10604
|
-
var breakGlassBody =
|
|
10605
|
-
approved_by:
|
|
10606
|
-
reason:
|
|
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 =
|
|
10620
|
-
status:
|
|
11111
|
+
var listApprovalsQuery = z8.object({
|
|
11112
|
+
status: z8.preprocess(
|
|
10621
11113
|
(value) => typeof value === "string" && approvalStatusSet.has(value) ? value : void 0,
|
|
10622
|
-
|
|
11114
|
+
z8.enum(APPROVAL_STATUSES).optional()
|
|
10623
11115
|
),
|
|
10624
|
-
limit:
|
|
11116
|
+
limit: z8.preprocess(
|
|
10625
11117
|
(value) => clampInt(typeof value === "string" ? value : void 0, 50, 1, 1e3),
|
|
10626
|
-
|
|
11118
|
+
z8.number().int()
|
|
10627
11119
|
),
|
|
10628
|
-
offset:
|
|
11120
|
+
offset: z8.preprocess(
|
|
10629
11121
|
(value) => clampInt(typeof value === "string" ? value : void 0, 0, 0, Number.MAX_SAFE_INTEGER),
|
|
10630
|
-
|
|
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
|
|
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
|
|
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 =
|
|
12320
|
+
var optionalQueryString = z9.preprocess(
|
|
11828
12321
|
(value) => typeof value === "string" && value.length > 0 ? value : void 0,
|
|
11829
|
-
|
|
12322
|
+
z9.string().optional()
|
|
11830
12323
|
);
|
|
11831
|
-
var optionalQueryInt =
|
|
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
|
-
},
|
|
11836
|
-
var queryBoolean =
|
|
12328
|
+
}, z9.number().int().optional());
|
|
12329
|
+
var queryBoolean = z9.preprocess(
|
|
11837
12330
|
(value) => value === "true" ? true : value === "false" ? false : void 0,
|
|
11838
|
-
|
|
12331
|
+
z9.boolean().optional()
|
|
11839
12332
|
);
|
|
11840
|
-
var clampedQueryInt = (fallback, min, max) =>
|
|
12333
|
+
var clampedQueryInt = (fallback, min, max) => z9.preprocess(
|
|
11841
12334
|
(value) => clampInt(typeof value === "string" ? value : void 0, fallback, min, max),
|
|
11842
|
-
|
|
12335
|
+
z9.number().int()
|
|
11843
12336
|
);
|
|
11844
|
-
var feedQuerySchema =
|
|
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 =
|
|
11855
|
-
format:
|
|
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 =
|
|
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 =
|
|
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 =
|
|
11902
|
-
format:
|
|
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 =
|
|
12398
|
+
var analyticsQuerySchema = z9.object({
|
|
11906
12399
|
from: optionalQueryString,
|
|
11907
12400
|
to: optionalQueryString,
|
|
11908
12401
|
upstream: optionalQueryString
|
|
11909
12402
|
});
|
|
11910
|
-
var authSessionBodySchema =
|
|
11911
|
-
secret:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
13134
|
-
|
|
13135
|
-
|
|
13136
|
-
|
|
13137
|
-
|
|
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).
|
|
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();
|