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