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