@gethelio/proxy 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +743 -94
- package/dist/index.d.ts +64 -8
- package/dist/index.js +738 -86
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -171,6 +171,19 @@ var policiesSchema = z.object({
|
|
|
171
171
|
flag_destructive: z.enum(["log", "require_approval"]).optional(),
|
|
172
172
|
dry_run: z.boolean().default(false),
|
|
173
173
|
rules: z.array(policyRuleSchema).default([]),
|
|
174
|
+
/**
|
|
175
|
+
* How to treat calls to a tool whose definition (annotations, schemas,
|
|
176
|
+
* description) has drifted from the baseline Helio captured on first
|
|
177
|
+
* sight.
|
|
178
|
+
* - "block": deny the call until the proxy is restarted (re-baselines)
|
|
179
|
+
* or the upstream reverts. Conservative default when omitted.
|
|
180
|
+
* - "require_approval": escalate the call through the approval channel.
|
|
181
|
+
* - "log": audit the drift; rules evaluate against both baseline and
|
|
182
|
+
* current annotations and the stricter decision wins.
|
|
183
|
+
* Kept optional (like hot_reload) so PoliciesConfig literal fixtures
|
|
184
|
+
* don't need the field; undefined is treated as "block".
|
|
185
|
+
*/
|
|
186
|
+
on_tool_drift: z.enum(["block", "require_approval", "log"]).optional(),
|
|
174
187
|
/**
|
|
175
188
|
* Whether `helio start` should watch the config file for changes and
|
|
176
189
|
* reconcile policy state on every save. Defaults to `true` when omitted.
|
|
@@ -233,14 +246,14 @@ var helioConfigBaseSchema = z.object({
|
|
|
233
246
|
});
|
|
234
247
|
var helioConfigSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
|
|
235
248
|
const hasConfiguredEnvironment = typeof cfg.environment === "string" && cfg.environment.trim().length > 0;
|
|
236
|
-
const requiresSecret = cfg.policies.flag_destructive === "require_approval" || cfg.policies.rules.some((rule) => rule.action === "require_approval");
|
|
249
|
+
const requiresSecret = cfg.policies.flag_destructive === "require_approval" || cfg.policies.on_tool_drift === "require_approval" || cfg.policies.rules.some((rule) => rule.action === "require_approval");
|
|
237
250
|
const hasSecret = hasDashboardApiSecret(cfg.dashboard.api_secret);
|
|
238
251
|
if (requiresSecret) {
|
|
239
252
|
if (!hasSecret) {
|
|
240
253
|
ctx.addIssue({
|
|
241
254
|
code: "custom",
|
|
242
255
|
path: ["dashboard", "api_secret"],
|
|
243
|
-
message: 'dashboard.api_secret is required when any rule uses require_approval or policies.flag_destructive is "require_approval". Generate one with: `openssl rand -hex 32` and set it under `dashboard.api_secret` in your helio.yaml. (See docs/approvals.md.)'
|
|
256
|
+
message: 'dashboard.api_secret is required when any rule uses require_approval or policies.flag_destructive or policies.on_tool_drift is "require_approval". Generate one with: `openssl rand -hex 32` and set it under `dashboard.api_secret` in your helio.yaml. (See docs/approvals.md.)'
|
|
244
257
|
});
|
|
245
258
|
}
|
|
246
259
|
}
|
|
@@ -459,6 +472,7 @@ function compilePolicies(config) {
|
|
|
459
472
|
defaultAction: config.default,
|
|
460
473
|
flagDestructive: config.flag_destructive,
|
|
461
474
|
...config.dry_run && { dryRun: true },
|
|
475
|
+
...config.on_tool_drift && { onToolDrift: config.on_tool_drift },
|
|
462
476
|
rules
|
|
463
477
|
};
|
|
464
478
|
return { policy, warnings };
|
|
@@ -1215,6 +1229,93 @@ async function parseUpstreamResponse(res) {
|
|
|
1215
1229
|
return { status: res.status, headers, body };
|
|
1216
1230
|
}
|
|
1217
1231
|
|
|
1232
|
+
// src/upstream/sse-parse.ts
|
|
1233
|
+
function parseSseChunk(chunk, state, onEvent) {
|
|
1234
|
+
let { event, data, remainder } = state;
|
|
1235
|
+
const text = remainder + chunk;
|
|
1236
|
+
const lines = text.split("\n");
|
|
1237
|
+
remainder = lines.pop() ?? "";
|
|
1238
|
+
for (const rawLine of lines) {
|
|
1239
|
+
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
1240
|
+
if (line === "") {
|
|
1241
|
+
if (event || data) {
|
|
1242
|
+
onEvent(event, data);
|
|
1243
|
+
event = "";
|
|
1244
|
+
data = "";
|
|
1245
|
+
}
|
|
1246
|
+
} else if (line.startsWith("event:")) {
|
|
1247
|
+
const value = line.slice(6).replace(/^ /, "");
|
|
1248
|
+
event = value;
|
|
1249
|
+
} else if (line.startsWith("data:")) {
|
|
1250
|
+
const value = line.slice(5).replace(/^ /, "");
|
|
1251
|
+
data = data ? data + "\n" + value : value;
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
return { event, data, remainder };
|
|
1255
|
+
}
|
|
1256
|
+
async function readSseJsonRpcResponse(res, requestId) {
|
|
1257
|
+
if (!res.body) {
|
|
1258
|
+
throw new Error("upstream SSE response had no body");
|
|
1259
|
+
}
|
|
1260
|
+
const reader = res.body.getReader();
|
|
1261
|
+
const decoder = new TextDecoder();
|
|
1262
|
+
let state = { event: "", data: "", remainder: "" };
|
|
1263
|
+
let found;
|
|
1264
|
+
const onEvent = (event, data) => {
|
|
1265
|
+
if (event && event !== "message") return;
|
|
1266
|
+
let parsed;
|
|
1267
|
+
try {
|
|
1268
|
+
parsed = JSON.parse(data);
|
|
1269
|
+
} catch {
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
if (parsed === null || typeof parsed !== "object") return;
|
|
1273
|
+
const id = parsed["id"];
|
|
1274
|
+
if (id === requestId) {
|
|
1275
|
+
found = parsed;
|
|
1276
|
+
}
|
|
1277
|
+
};
|
|
1278
|
+
const processChunk = (chunk) => {
|
|
1279
|
+
state = parseSseChunk(chunk, state, onEvent);
|
|
1280
|
+
};
|
|
1281
|
+
for (; ; ) {
|
|
1282
|
+
const result = await reader.read();
|
|
1283
|
+
if (result.value !== void 0) {
|
|
1284
|
+
const chunk = result.value;
|
|
1285
|
+
processChunk(decoder.decode(chunk, { stream: true }));
|
|
1286
|
+
if (found) {
|
|
1287
|
+
await reader.cancel().catch(() => void 0);
|
|
1288
|
+
return found;
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
if (result.done) {
|
|
1292
|
+
const tail = decoder.decode();
|
|
1293
|
+
if (tail) {
|
|
1294
|
+
processChunk(tail);
|
|
1295
|
+
if (found) return found;
|
|
1296
|
+
}
|
|
1297
|
+
break;
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
throw new Error(
|
|
1301
|
+
`upstream SSE stream closed with no JSON-RPC response for id ${String(requestId)}`
|
|
1302
|
+
);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// src/upstream/merge-headers.ts
|
|
1306
|
+
function mergeUpstreamHeaders(base, forwarded, staticHeaders) {
|
|
1307
|
+
const out = {};
|
|
1308
|
+
const apply = (headers) => {
|
|
1309
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
1310
|
+
out[name.toLowerCase()] = value;
|
|
1311
|
+
}
|
|
1312
|
+
};
|
|
1313
|
+
apply(base);
|
|
1314
|
+
apply(forwarded);
|
|
1315
|
+
apply(staticHeaders);
|
|
1316
|
+
return out;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1218
1319
|
// src/upstream/connection-error.ts
|
|
1219
1320
|
var UPSTREAM_DOCS_URL = "https://github.com/gethelio/helio/blob/main/docs/getting-started.md";
|
|
1220
1321
|
var UNREACHABLE_CODES = /* @__PURE__ */ new Set([
|
|
@@ -1254,31 +1355,314 @@ function describeUnreachableUpstream(error, url) {
|
|
|
1254
1355
|
);
|
|
1255
1356
|
}
|
|
1256
1357
|
|
|
1257
|
-
// src/upstream/
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1358
|
+
// src/upstream/upstream-session-manager.ts
|
|
1359
|
+
var HELIO_MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
1360
|
+
var MAX_SSE_ERROR_SCAN_BYTES = 256 * 1024;
|
|
1361
|
+
var UpstreamSessionManager = class {
|
|
1362
|
+
url;
|
|
1363
|
+
staticHeaders;
|
|
1364
|
+
requestTimeoutMs;
|
|
1365
|
+
internal;
|
|
1366
|
+
inflight;
|
|
1367
|
+
constructor(options) {
|
|
1368
|
+
this.url = options.url;
|
|
1369
|
+
this.staticHeaders = options.staticHeaders;
|
|
1370
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
|
|
1371
|
+
}
|
|
1372
|
+
/** Return the internal session, performing the handshake once if needed. */
|
|
1373
|
+
ensureInternalSession() {
|
|
1374
|
+
if (this.internal) return Promise.resolve(this.internal);
|
|
1375
|
+
this.inflight ??= this.initialize().then((session) => {
|
|
1376
|
+
this.internal = session;
|
|
1377
|
+
return session;
|
|
1378
|
+
}).finally(() => {
|
|
1379
|
+
this.inflight = void 0;
|
|
1380
|
+
});
|
|
1381
|
+
return this.inflight;
|
|
1382
|
+
}
|
|
1383
|
+
/**
|
|
1384
|
+
* Drop the cached internal session so the next call re-initializes.
|
|
1385
|
+
* Does not cancel any in-flight initialize.
|
|
1386
|
+
*/
|
|
1387
|
+
invalidateInternalSession() {
|
|
1388
|
+
this.internal = void 0;
|
|
1389
|
+
}
|
|
1390
|
+
/** Convert a fetch failure into an actionable error for the given step. */
|
|
1391
|
+
describeFetchFailure(error, step) {
|
|
1392
|
+
if (error instanceof Error && error.name === "TimeoutError") {
|
|
1393
|
+
return new Error(`upstream ${step} timed out after ${String(this.requestTimeoutMs)}ms`);
|
|
1263
1394
|
}
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1395
|
+
return describeUnreachableUpstream(error, this.url) ?? (error instanceof Error ? error : new Error(String(error)));
|
|
1396
|
+
}
|
|
1397
|
+
async initialize() {
|
|
1398
|
+
const headers = mergeUpstreamHeaders(
|
|
1399
|
+
{
|
|
1400
|
+
"content-type": "application/json",
|
|
1401
|
+
accept: "application/json, text/event-stream"
|
|
1402
|
+
},
|
|
1403
|
+
{},
|
|
1404
|
+
this.staticHeaders
|
|
1405
|
+
);
|
|
1406
|
+
const initBody = {
|
|
1407
|
+
jsonrpc: "2.0",
|
|
1408
|
+
id: 0,
|
|
1409
|
+
method: "initialize",
|
|
1410
|
+
params: {
|
|
1411
|
+
protocolVersion: HELIO_MCP_PROTOCOL_VERSION,
|
|
1412
|
+
capabilities: {},
|
|
1413
|
+
clientInfo: { name: "helio-proxy", version: "0" }
|
|
1414
|
+
}
|
|
1415
|
+
};
|
|
1416
|
+
let res;
|
|
1417
|
+
try {
|
|
1418
|
+
res = await fetch(this.url, {
|
|
1419
|
+
method: "POST",
|
|
1420
|
+
headers,
|
|
1421
|
+
body: JSON.stringify(initBody),
|
|
1422
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs)
|
|
1423
|
+
});
|
|
1424
|
+
} catch (error) {
|
|
1425
|
+
throw this.describeFetchFailure(error, "initialize");
|
|
1426
|
+
}
|
|
1427
|
+
if (!res.ok) {
|
|
1428
|
+
throw new Error(`upstream initialize failed: HTTP ${String(res.status)}`);
|
|
1429
|
+
}
|
|
1430
|
+
const sessionId = res.headers.get("mcp-session-id") ?? void 0;
|
|
1431
|
+
const initializeEnvelope = await this.readRequiredJsonRpcEnvelope(
|
|
1432
|
+
res,
|
|
1433
|
+
initBody.id,
|
|
1434
|
+
"initialize"
|
|
1435
|
+
);
|
|
1436
|
+
const initializeError = extractJsonRpcErrorMessage(initializeEnvelope);
|
|
1437
|
+
if (initializeError) {
|
|
1438
|
+
throw new Error(`upstream initialize returned JSON-RPC error: ${initializeError}`);
|
|
1439
|
+
}
|
|
1440
|
+
const negotiatedProtocolVersion = extractNegotiatedProtocolVersion(initializeEnvelope);
|
|
1441
|
+
const notifyHeaders = { ...headers };
|
|
1442
|
+
if (sessionId) notifyHeaders["mcp-session-id"] = sessionId;
|
|
1443
|
+
notifyHeaders["mcp-protocol-version"] = negotiatedProtocolVersion;
|
|
1444
|
+
const notifyRes = await fetch(this.url, {
|
|
1445
|
+
method: "POST",
|
|
1446
|
+
headers: notifyHeaders,
|
|
1447
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
|
|
1448
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs)
|
|
1449
|
+
}).catch((error) => {
|
|
1450
|
+
throw this.describeFetchFailure(error, "notifications/initialized");
|
|
1451
|
+
});
|
|
1452
|
+
if (!notifyRes.ok) {
|
|
1453
|
+
throw new Error(`upstream notifications/initialized failed: HTTP ${String(notifyRes.status)}`);
|
|
1454
|
+
}
|
|
1455
|
+
const notifyError = await this.readOptionalJsonRpcError(notifyRes);
|
|
1456
|
+
if (notifyError) {
|
|
1457
|
+
throw new Error(`upstream notifications/initialized returned JSON-RPC error: ${notifyError}`);
|
|
1458
|
+
}
|
|
1459
|
+
return { sessionId, protocolVersion: negotiatedProtocolVersion };
|
|
1460
|
+
}
|
|
1461
|
+
async readRequiredJsonRpcEnvelope(res, requestId, step) {
|
|
1462
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
1463
|
+
if (contentType.includes("text/event-stream")) {
|
|
1464
|
+
const payload = await readSseJsonRpcResponse(res, requestId);
|
|
1465
|
+
return payload;
|
|
1466
|
+
}
|
|
1467
|
+
const raw = await res.text();
|
|
1468
|
+
if (!raw.trim()) {
|
|
1469
|
+
throw new Error(`upstream ${step} returned an empty body`);
|
|
1470
|
+
}
|
|
1471
|
+
let parsed;
|
|
1472
|
+
try {
|
|
1473
|
+
parsed = JSON.parse(raw);
|
|
1474
|
+
} catch {
|
|
1475
|
+
throw new Error(`upstream ${step} returned non-JSON body`);
|
|
1476
|
+
}
|
|
1477
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
1478
|
+
throw new Error(`upstream ${step} returned non-object JSON`);
|
|
1479
|
+
}
|
|
1480
|
+
return parsed;
|
|
1481
|
+
}
|
|
1482
|
+
async readOptionalJsonRpcError(res) {
|
|
1483
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
1484
|
+
if (contentType.includes("text/event-stream")) {
|
|
1485
|
+
if (!res.body) return void 0;
|
|
1486
|
+
let errorMessage;
|
|
1487
|
+
const reader = res.body.getReader();
|
|
1488
|
+
const decoder = new TextDecoder();
|
|
1489
|
+
let state = { event: "", data: "", remainder: "" };
|
|
1490
|
+
let scannedBytes = 0;
|
|
1491
|
+
const deadline = Date.now() + this.requestTimeoutMs;
|
|
1492
|
+
const onEvent = (event, data) => {
|
|
1493
|
+
if (errorMessage) return;
|
|
1494
|
+
if (event && event !== "message") return;
|
|
1495
|
+
let parsed2;
|
|
1496
|
+
try {
|
|
1497
|
+
parsed2 = JSON.parse(data);
|
|
1498
|
+
} catch {
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
if (typeof parsed2 !== "object" || parsed2 === null) return;
|
|
1502
|
+
errorMessage = extractJsonRpcErrorMessage(parsed2);
|
|
1503
|
+
};
|
|
1504
|
+
for (; ; ) {
|
|
1505
|
+
const remainingMs = deadline - Date.now();
|
|
1506
|
+
if (remainingMs <= 0) {
|
|
1507
|
+
await reader.cancel().catch(() => void 0);
|
|
1508
|
+
throw new Error(
|
|
1509
|
+
`upstream notifications/initialized SSE response timed out after ${String(this.requestTimeoutMs)}ms`
|
|
1510
|
+
);
|
|
1511
|
+
}
|
|
1512
|
+
let chunk;
|
|
1513
|
+
try {
|
|
1514
|
+
chunk = await readSseChunkWithTimeout(reader, remainingMs);
|
|
1515
|
+
} catch {
|
|
1516
|
+
await reader.cancel().catch(() => void 0);
|
|
1517
|
+
throw new Error(
|
|
1518
|
+
`upstream notifications/initialized SSE response timed out after ${String(this.requestTimeoutMs)}ms`
|
|
1519
|
+
);
|
|
1520
|
+
}
|
|
1521
|
+
const { done, value } = chunk;
|
|
1522
|
+
if (value !== void 0) {
|
|
1523
|
+
scannedBytes += value.byteLength;
|
|
1524
|
+
if (scannedBytes > MAX_SSE_ERROR_SCAN_BYTES) {
|
|
1525
|
+
await reader.cancel().catch(() => void 0);
|
|
1526
|
+
throw new Error(
|
|
1527
|
+
`upstream notifications/initialized SSE response exceeded ${String(MAX_SSE_ERROR_SCAN_BYTES)} bytes`
|
|
1528
|
+
);
|
|
1529
|
+
}
|
|
1530
|
+
state = parseSseChunk(decoder.decode(value, { stream: true }), state, onEvent);
|
|
1531
|
+
if (errorMessage) {
|
|
1532
|
+
await reader.cancel().catch(() => void 0);
|
|
1533
|
+
return errorMessage;
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
if (done) {
|
|
1537
|
+
const tail = decoder.decode();
|
|
1538
|
+
if (tail) {
|
|
1539
|
+
state = parseSseChunk(tail, state, onEvent);
|
|
1540
|
+
}
|
|
1541
|
+
return errorMessage;
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
const raw = await res.text();
|
|
1546
|
+
if (!raw.trim()) return void 0;
|
|
1547
|
+
let parsed;
|
|
1548
|
+
try {
|
|
1549
|
+
parsed = JSON.parse(raw);
|
|
1550
|
+
} catch {
|
|
1551
|
+
return void 0;
|
|
1552
|
+
}
|
|
1553
|
+
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
1554
|
+
return extractJsonRpcErrorMessage(parsed);
|
|
1555
|
+
}
|
|
1556
|
+
};
|
|
1557
|
+
async function readSseChunkWithTimeout(reader, timeoutMs) {
|
|
1558
|
+
let timeoutHandle;
|
|
1559
|
+
try {
|
|
1560
|
+
const result = await Promise.race([
|
|
1561
|
+
reader.read(),
|
|
1562
|
+
new Promise((_, reject) => {
|
|
1563
|
+
timeoutHandle = setTimeout(() => {
|
|
1564
|
+
reject(new Error(`sse read timed out after ${String(timeoutMs)}ms`));
|
|
1565
|
+
}, timeoutMs);
|
|
1566
|
+
})
|
|
1567
|
+
]);
|
|
1568
|
+
if (!isSseReadChunk(result)) {
|
|
1569
|
+
throw new Error("upstream notifications/initialized SSE response returned invalid chunk");
|
|
1570
|
+
}
|
|
1571
|
+
return result;
|
|
1572
|
+
} finally {
|
|
1573
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
function isSseReadChunk(value) {
|
|
1577
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1578
|
+
const candidate = value;
|
|
1579
|
+
if (typeof candidate.done !== "boolean") return false;
|
|
1580
|
+
if (candidate.value === void 0) return true;
|
|
1581
|
+
return candidate.value instanceof Uint8Array;
|
|
1582
|
+
}
|
|
1583
|
+
function extractJsonRpcErrorMessage(payload) {
|
|
1584
|
+
const error = payload["error"];
|
|
1585
|
+
if (typeof error === "string") return error;
|
|
1586
|
+
if (typeof error !== "object" || error === null) return void 0;
|
|
1587
|
+
const message = error["message"];
|
|
1588
|
+
if (typeof message === "string" && message.trim()) return message;
|
|
1589
|
+
return "unknown JSON-RPC error";
|
|
1590
|
+
}
|
|
1591
|
+
function extractNegotiatedProtocolVersion(payload) {
|
|
1592
|
+
const result = payload["result"];
|
|
1593
|
+
if (typeof result !== "object" || result === null) {
|
|
1594
|
+
return HELIO_MCP_PROTOCOL_VERSION;
|
|
1595
|
+
}
|
|
1596
|
+
const protocolVersion = result["protocolVersion"];
|
|
1597
|
+
return typeof protocolVersion === "string" && protocolVersion.trim() ? protocolVersion : HELIO_MCP_PROTOCOL_VERSION;
|
|
1269
1598
|
}
|
|
1270
1599
|
|
|
1271
|
-
// src/upstream/forwarder.ts
|
|
1272
|
-
var
|
|
1600
|
+
// src/upstream/streamable-http-forwarder.ts
|
|
1601
|
+
var StreamableHttpForwarder = class {
|
|
1273
1602
|
url;
|
|
1274
1603
|
staticHeaders;
|
|
1275
1604
|
requestTimeoutMs;
|
|
1605
|
+
sessions;
|
|
1276
1606
|
constructor(options) {
|
|
1277
1607
|
this.url = options.url;
|
|
1278
1608
|
this.staticHeaders = options.headers ?? {};
|
|
1279
1609
|
this.requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
|
|
1610
|
+
this.sessions = new UpstreamSessionManager({
|
|
1611
|
+
url: this.url,
|
|
1612
|
+
staticHeaders: this.staticHeaders,
|
|
1613
|
+
requestTimeoutMs: this.requestTimeoutMs
|
|
1614
|
+
});
|
|
1615
|
+
}
|
|
1616
|
+
/** Lifecycle parity with sse/stdio. No eager connect — sessions are lazy. */
|
|
1617
|
+
connect() {
|
|
1618
|
+
return Promise.resolve();
|
|
1619
|
+
}
|
|
1620
|
+
/** Lifecycle parity with sse/stdio. */
|
|
1621
|
+
close() {
|
|
1622
|
+
this.sessions.invalidateInternalSession();
|
|
1623
|
+
return Promise.resolve();
|
|
1280
1624
|
}
|
|
1281
1625
|
async forward(request) {
|
|
1626
|
+
if (request.method === "initialize") {
|
|
1627
|
+
return this.send(
|
|
1628
|
+
request,
|
|
1629
|
+
request.sessionId,
|
|
1630
|
+
/* protocolVersion */
|
|
1631
|
+
void 0
|
|
1632
|
+
);
|
|
1633
|
+
}
|
|
1634
|
+
return this.send(request, request.sessionId, HELIO_MCP_PROTOCOL_VERSION);
|
|
1635
|
+
}
|
|
1636
|
+
/**
|
|
1637
|
+
* Helio-internal execution path (startup prime / internal maintenance) that
|
|
1638
|
+
* may borrow the proxy-managed internal session.
|
|
1639
|
+
*/
|
|
1640
|
+
async forwardInternal(request) {
|
|
1641
|
+
const session = await this.sessions.ensureInternalSession();
|
|
1642
|
+
try {
|
|
1643
|
+
return await this.send(
|
|
1644
|
+
request,
|
|
1645
|
+
session.sessionId,
|
|
1646
|
+
session.protocolVersion,
|
|
1647
|
+
/* internalManaged */
|
|
1648
|
+
true
|
|
1649
|
+
);
|
|
1650
|
+
} catch (error) {
|
|
1651
|
+
if (error instanceof UpstreamSessionExpiredError) {
|
|
1652
|
+
this.sessions.invalidateInternalSession();
|
|
1653
|
+
const fresh = await this.sessions.ensureInternalSession();
|
|
1654
|
+
return this.send(
|
|
1655
|
+
request,
|
|
1656
|
+
fresh.sessionId,
|
|
1657
|
+
fresh.protocolVersion,
|
|
1658
|
+
/* internalManaged */
|
|
1659
|
+
true
|
|
1660
|
+
);
|
|
1661
|
+
}
|
|
1662
|
+
throw error;
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
async send(request, sessionId, protocolVersion, internalManaged = false) {
|
|
1282
1666
|
const headers = mergeUpstreamHeaders(
|
|
1283
1667
|
{
|
|
1284
1668
|
"content-type": "application/json",
|
|
@@ -1287,8 +1671,9 @@ var UpstreamForwarder = class {
|
|
|
1287
1671
|
request.headers ?? {},
|
|
1288
1672
|
this.staticHeaders
|
|
1289
1673
|
);
|
|
1290
|
-
if (
|
|
1291
|
-
|
|
1674
|
+
if (sessionId) headers["mcp-session-id"] = sessionId;
|
|
1675
|
+
if (protocolVersion && headers["mcp-protocol-version"] === void 0) {
|
|
1676
|
+
headers["mcp-protocol-version"] = protocolVersion;
|
|
1292
1677
|
}
|
|
1293
1678
|
const body = {
|
|
1294
1679
|
jsonrpc: request.jsonrpc,
|
|
@@ -1302,31 +1687,46 @@ var UpstreamForwarder = class {
|
|
|
1302
1687
|
const signal = requestSignal ? AbortSignal.any([requestSignal, timeoutSignal]) : timeoutSignal;
|
|
1303
1688
|
let res;
|
|
1304
1689
|
try {
|
|
1305
|
-
res = await fetch(this.url, {
|
|
1306
|
-
method: "POST",
|
|
1307
|
-
headers,
|
|
1308
|
-
body: JSON.stringify(body),
|
|
1309
|
-
signal
|
|
1310
|
-
});
|
|
1690
|
+
res = await fetch(this.url, { method: "POST", headers, body: JSON.stringify(body), signal });
|
|
1311
1691
|
} catch (error) {
|
|
1312
1692
|
const isTimeout = error instanceof Error && error.name === "TimeoutError";
|
|
1313
|
-
if (requestSignal?.aborted)
|
|
1314
|
-
throw new Error("request aborted by downstream client");
|
|
1315
|
-
}
|
|
1693
|
+
if (requestSignal?.aborted) throw new Error("request aborted by downstream client");
|
|
1316
1694
|
if (isTimeout) {
|
|
1317
1695
|
throw new Error(`upstream request timed out after ${String(this.requestTimeoutMs)}ms`);
|
|
1318
1696
|
}
|
|
1319
1697
|
throw describeUnreachableUpstream(error, this.url) ?? error;
|
|
1320
1698
|
}
|
|
1321
|
-
|
|
1699
|
+
if (internalManaged && res.status === 404 && sessionId) {
|
|
1700
|
+
await res.text().catch(() => void 0);
|
|
1701
|
+
throw new UpstreamSessionExpiredError();
|
|
1702
|
+
}
|
|
1322
1703
|
const contentType = res.headers.get("content-type") ?? "";
|
|
1323
1704
|
if (contentType.includes("text/event-stream")) {
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1705
|
+
const responseHeaders = {};
|
|
1706
|
+
res.headers.forEach((value, key) => {
|
|
1707
|
+
responseHeaders[key] = value;
|
|
1708
|
+
});
|
|
1709
|
+
if (request.id === void 0) {
|
|
1710
|
+
await res.body?.cancel().catch(() => void 0);
|
|
1711
|
+
const response3 = {
|
|
1712
|
+
status: res.status,
|
|
1713
|
+
headers: responseHeaders,
|
|
1714
|
+
body: { jsonrpc: "2.0" }
|
|
1715
|
+
};
|
|
1716
|
+
return { response: response3, durationMs: performance.now() - start };
|
|
1717
|
+
}
|
|
1718
|
+
const jsonRpc = await readSseJsonRpcResponse(res, request.id);
|
|
1719
|
+
const response2 = { status: res.status, headers: responseHeaders, body: jsonRpc };
|
|
1720
|
+
return { response: response2, durationMs: performance.now() - start };
|
|
1327
1721
|
}
|
|
1328
1722
|
const response = await parseUpstreamResponse(res);
|
|
1329
|
-
return { response, durationMs };
|
|
1723
|
+
return { response, durationMs: performance.now() - start };
|
|
1724
|
+
}
|
|
1725
|
+
};
|
|
1726
|
+
var UpstreamSessionExpiredError = class extends Error {
|
|
1727
|
+
constructor() {
|
|
1728
|
+
super("upstream session expired (HTTP 404) for Helio-managed internal session");
|
|
1729
|
+
this.name = "UpstreamSessionExpiredError";
|
|
1330
1730
|
}
|
|
1331
1731
|
};
|
|
1332
1732
|
|
|
@@ -1399,26 +1799,6 @@ function buildRequestSignal(request, timeoutMs) {
|
|
|
1399
1799
|
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
1400
1800
|
return request.signal ? AbortSignal.any([request.signal, timeoutSignal]) : timeoutSignal;
|
|
1401
1801
|
}
|
|
1402
|
-
function parseSseChunk(chunk, state, onEvent) {
|
|
1403
|
-
let { event, data, remainder } = state;
|
|
1404
|
-
const text = remainder + chunk;
|
|
1405
|
-
const lines = text.split("\n");
|
|
1406
|
-
remainder = lines.pop() ?? "";
|
|
1407
|
-
for (const line of lines) {
|
|
1408
|
-
if (line === "") {
|
|
1409
|
-
if (event || data) {
|
|
1410
|
-
onEvent(event, data);
|
|
1411
|
-
event = "";
|
|
1412
|
-
data = "";
|
|
1413
|
-
}
|
|
1414
|
-
} else if (line.startsWith("event: ")) {
|
|
1415
|
-
event = line.slice(7);
|
|
1416
|
-
} else if (line.startsWith("data: ")) {
|
|
1417
|
-
data = data ? data + "\n" + line.slice(6) : line.slice(6);
|
|
1418
|
-
}
|
|
1419
|
-
}
|
|
1420
|
-
return { event, data, remainder };
|
|
1421
|
-
}
|
|
1422
1802
|
var SseUpstreamForwarder = class {
|
|
1423
1803
|
url;
|
|
1424
1804
|
staticHeaders;
|
|
@@ -1812,13 +2192,13 @@ var StdioForwarder = class {
|
|
|
1812
2192
|
async function createForwarderFromConfig(config) {
|
|
1813
2193
|
switch (config.upstream.transport) {
|
|
1814
2194
|
case "streamable-http": {
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
};
|
|
2195
|
+
const http = new StreamableHttpForwarder({
|
|
2196
|
+
url: config.upstream.url,
|
|
2197
|
+
headers: config.upstream.headers,
|
|
2198
|
+
requestTimeoutMs: parseDuration(config.upstream.request_timeout)
|
|
2199
|
+
});
|
|
2200
|
+
await http.connect();
|
|
2201
|
+
return { forwarder: http, close: () => http.close() };
|
|
1822
2202
|
}
|
|
1823
2203
|
case "sse": {
|
|
1824
2204
|
const sse = new SseUpstreamForwarder({
|
|
@@ -1940,49 +2320,161 @@ function evaluatePolicy(policy, ctx) {
|
|
|
1940
2320
|
}
|
|
1941
2321
|
|
|
1942
2322
|
// src/policy/annotation-cache.ts
|
|
2323
|
+
var ASPECT_FIELDS = [
|
|
2324
|
+
"annotations",
|
|
2325
|
+
"inputSchema",
|
|
2326
|
+
"description",
|
|
2327
|
+
"outputSchema",
|
|
2328
|
+
"title"
|
|
2329
|
+
];
|
|
1943
2330
|
var ToolAnnotationCache = class {
|
|
1944
|
-
|
|
1945
|
-
|
|
2331
|
+
baselines = /* @__PURE__ */ new Map();
|
|
2332
|
+
present = /* @__PURE__ */ new Set();
|
|
2333
|
+
currentAnnotations = /* @__PURE__ */ new Map();
|
|
2334
|
+
driftedTools = /* @__PURE__ */ new Map();
|
|
2335
|
+
/** Number of tools present in the most recent tools/list. */
|
|
1946
2336
|
get size() {
|
|
1947
|
-
return this.
|
|
2337
|
+
return this.present.size;
|
|
1948
2338
|
}
|
|
1949
|
-
/**
|
|
1950
|
-
* Update the cache from a tools/list JSON-RPC response body.
|
|
1951
|
-
*
|
|
1952
|
-
* Performs a full replacement — tools that existed in the previous cache
|
|
1953
|
-
* but are absent from the new response are removed. This correctly handles
|
|
1954
|
-
* tool list changes (additions, removals, annotation updates).
|
|
1955
|
-
*
|
|
1956
|
-
* @returns `true` if the response body was a valid tools/list response and
|
|
1957
|
-
* the cache was updated, `false` if the body shape was unexpected.
|
|
1958
|
-
*/
|
|
2339
|
+
/** Diff a tools/list JSON-RPC response body against the baselines. */
|
|
1959
2340
|
update(responseBody) {
|
|
1960
2341
|
const tools = extractTools(responseBody);
|
|
1961
|
-
if (!tools) return false;
|
|
1962
|
-
|
|
2342
|
+
if (!tools) return { updated: false, baselined: [], drifted: [], reverted: [] };
|
|
2343
|
+
const baselined = [];
|
|
2344
|
+
const drifted = [];
|
|
2345
|
+
const reverted = [];
|
|
2346
|
+
const present = /* @__PURE__ */ new Set();
|
|
2347
|
+
const currentAnnotations = /* @__PURE__ */ new Map();
|
|
2348
|
+
const entries = [];
|
|
2349
|
+
const nameCounts = /* @__PURE__ */ new Map();
|
|
1963
2350
|
for (const tool of tools) {
|
|
1964
2351
|
if (typeof tool !== "object" || tool === null) continue;
|
|
1965
2352
|
const t = tool;
|
|
1966
2353
|
const name = t["name"];
|
|
1967
2354
|
if (typeof name !== "string") continue;
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
2355
|
+
entries.push({ name, definition: t });
|
|
2356
|
+
nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1);
|
|
2357
|
+
}
|
|
2358
|
+
const duplicateNames = /* @__PURE__ */ new Set();
|
|
2359
|
+
for (const { name, definition: t } of entries) {
|
|
2360
|
+
const isDuplicate = (nameCounts.get(name) ?? 0) > 1;
|
|
2361
|
+
if (isDuplicate) {
|
|
2362
|
+
present.add(name);
|
|
2363
|
+
currentAnnotations.set(name, void 0);
|
|
2364
|
+
if (duplicateNames.has(name)) continue;
|
|
2365
|
+
duplicateNames.add(name);
|
|
2366
|
+
const baseline2 = this.baselines.get(name);
|
|
2367
|
+
const allDefinitions = entries.filter((e) => e.name === name).map((e) => e.definition);
|
|
2368
|
+
const changes2 = [
|
|
2369
|
+
{
|
|
2370
|
+
aspect: "duplicate",
|
|
2371
|
+
baseline: baseline2?.definition,
|
|
2372
|
+
current: allDefinitions
|
|
2373
|
+
}
|
|
2374
|
+
];
|
|
2375
|
+
const event2 = { toolName: name, changes: changes2 };
|
|
2376
|
+
const existing2 = this.driftedTools.get(name);
|
|
2377
|
+
const isNewDrift2 = !existing2 || canonicalize(existing2.changes) !== canonicalize(changes2);
|
|
2378
|
+
this.driftedTools.set(name, event2);
|
|
2379
|
+
if (isNewDrift2) drifted.push(event2);
|
|
2380
|
+
continue;
|
|
2381
|
+
}
|
|
2382
|
+
present.add(name);
|
|
2383
|
+
const annotations = extractAnnotations(t);
|
|
2384
|
+
currentAnnotations.set(name, annotations);
|
|
2385
|
+
const definitionKey = canonicalize(t);
|
|
2386
|
+
const baseline = this.baselines.get(name);
|
|
2387
|
+
if (!baseline) {
|
|
2388
|
+
this.baselines.set(name, { definition: t, definitionKey, annotations });
|
|
2389
|
+
baselined.push(name);
|
|
2390
|
+
if (this.driftedTools.has(name)) {
|
|
2391
|
+
this.driftedTools.delete(name);
|
|
2392
|
+
reverted.push(name);
|
|
2393
|
+
}
|
|
2394
|
+
continue;
|
|
1973
2395
|
}
|
|
2396
|
+
if (definitionKey === baseline.definitionKey) {
|
|
2397
|
+
if (this.driftedTools.has(name)) {
|
|
2398
|
+
this.driftedTools.delete(name);
|
|
2399
|
+
reverted.push(name);
|
|
2400
|
+
}
|
|
2401
|
+
continue;
|
|
2402
|
+
}
|
|
2403
|
+
const changes = [];
|
|
2404
|
+
for (const field of ASPECT_FIELDS) {
|
|
2405
|
+
const baselineValue = baseline.definition[field];
|
|
2406
|
+
const currentValue = t[field];
|
|
2407
|
+
if (canonicalize(baselineValue) !== canonicalize(currentValue)) {
|
|
2408
|
+
changes.push({ aspect: field, baseline: baselineValue, current: currentValue });
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
if (changes.length === 0) {
|
|
2412
|
+
changes.push({ aspect: "other", baseline: baseline.definition, current: t });
|
|
2413
|
+
}
|
|
2414
|
+
const event = { toolName: name, changes };
|
|
2415
|
+
const existing = this.driftedTools.get(name);
|
|
2416
|
+
const isNewDrift = !existing || canonicalize(existing.changes) !== canonicalize(changes);
|
|
2417
|
+
this.driftedTools.set(name, event);
|
|
2418
|
+
if (isNewDrift) drifted.push(event);
|
|
1974
2419
|
}
|
|
1975
|
-
|
|
2420
|
+
this.present = present;
|
|
2421
|
+
this.currentAnnotations = currentAnnotations;
|
|
2422
|
+
return { updated: true, baselined, drifted, reverted };
|
|
1976
2423
|
}
|
|
1977
|
-
/**
|
|
2424
|
+
/**
|
|
2425
|
+
* Get the **baseline** annotations for a tool — the definition first seen,
|
|
2426
|
+
* not the latest upstream claim. Returns `undefined` if the tool has no
|
|
2427
|
+
* annotations or was never seen.
|
|
2428
|
+
*/
|
|
1978
2429
|
get(toolName) {
|
|
1979
|
-
return this.
|
|
2430
|
+
return this.baselines.get(toolName)?.annotations;
|
|
1980
2431
|
}
|
|
1981
|
-
/**
|
|
2432
|
+
/**
|
|
2433
|
+
* Get the annotations from the most recent tools/list. Used for the
|
|
2434
|
+
* stricter-of-both evaluation of drifted tools in on_tool_drift: log mode.
|
|
2435
|
+
* Returns `undefined` for tools absent from the latest list.
|
|
2436
|
+
*/
|
|
2437
|
+
getCurrent(toolName) {
|
|
2438
|
+
return this.currentAnnotations.get(toolName);
|
|
2439
|
+
}
|
|
2440
|
+
/** Whether the tool was present in the most recent tools/list. */
|
|
1982
2441
|
has(toolName) {
|
|
1983
|
-
return this.
|
|
2442
|
+
return this.present.has(toolName);
|
|
2443
|
+
}
|
|
2444
|
+
/** Whether the tool's current definition differs from its baseline. */
|
|
2445
|
+
isDrifted(toolName) {
|
|
2446
|
+
return this.driftedTools.has(toolName);
|
|
2447
|
+
}
|
|
2448
|
+
/** The active drift event for a tool, if any. */
|
|
2449
|
+
getDrift(toolName) {
|
|
2450
|
+
return this.driftedTools.get(toolName);
|
|
1984
2451
|
}
|
|
1985
2452
|
};
|
|
2453
|
+
function extractAnnotations(tool) {
|
|
2454
|
+
const annotations = tool["annotations"];
|
|
2455
|
+
return annotations && typeof annotations === "object" ? annotations : void 0;
|
|
2456
|
+
}
|
|
2457
|
+
function canonicalize(value) {
|
|
2458
|
+
const encoded = JSON.stringify(sortKeysDeep(value));
|
|
2459
|
+
return encoded ?? "";
|
|
2460
|
+
}
|
|
2461
|
+
function sortKeysDeep(value) {
|
|
2462
|
+
if (Array.isArray(value)) return value.map(sortKeysDeep);
|
|
2463
|
+
if (value !== null && typeof value === "object") {
|
|
2464
|
+
const source = value;
|
|
2465
|
+
const out = {};
|
|
2466
|
+
for (const key of Object.keys(source).sort()) {
|
|
2467
|
+
Object.defineProperty(out, key, {
|
|
2468
|
+
value: sortKeysDeep(source[key]),
|
|
2469
|
+
enumerable: true,
|
|
2470
|
+
writable: true,
|
|
2471
|
+
configurable: true
|
|
2472
|
+
});
|
|
2473
|
+
}
|
|
2474
|
+
return out;
|
|
2475
|
+
}
|
|
2476
|
+
return value;
|
|
2477
|
+
}
|
|
1986
2478
|
function extractTools(body) {
|
|
1987
2479
|
if (typeof body !== "object" || body === null) return null;
|
|
1988
2480
|
const b = body;
|
|
@@ -2184,6 +2676,19 @@ function buildRateLimitedFeedback(decision, result) {
|
|
|
2184
2676
|
retry_allowed: true
|
|
2185
2677
|
};
|
|
2186
2678
|
}
|
|
2679
|
+
function buildToolDriftFeedback(drift, action) {
|
|
2680
|
+
const aspects = drift.changes.map((change) => change.aspect);
|
|
2681
|
+
return {
|
|
2682
|
+
blocked: true,
|
|
2683
|
+
reason: "tool_definition_drift",
|
|
2684
|
+
rule: null,
|
|
2685
|
+
ruleIndex: null,
|
|
2686
|
+
action,
|
|
2687
|
+
drifted_aspects: aspects,
|
|
2688
|
+
suggestion: `The definition of "${drift.toolName}" changed upstream (${aspects.join(", ")}) after Helio baselined it. An operator must review the change; restarting the proxy re-baselines, or the upstream can revert the change.`,
|
|
2689
|
+
retry_allowed: false
|
|
2690
|
+
};
|
|
2691
|
+
}
|
|
2187
2692
|
function buildSpendLimitedFeedback(decision, result, currency) {
|
|
2188
2693
|
const { rule, ruleIndex } = ruleInfo(decision.matchedRule);
|
|
2189
2694
|
const windowSeconds = Math.round(result.windowMs / 1e3);
|
|
@@ -2298,6 +2803,11 @@ var GovernedForwarder = class {
|
|
|
2298
2803
|
*
|
|
2299
2804
|
* This path is intended for startup warm-up and intentionally bypasses policy
|
|
2300
2805
|
* and audit handling. Runtime tools/list requests still flow through forward().
|
|
2806
|
+
*
|
|
2807
|
+
* When the inner forwarder exposes `forwardInternal` (duck-typed), the prime
|
|
2808
|
+
* request is routed through it so session-enforcing servers (e.g. Streamable
|
|
2809
|
+
* HTTP upstreams) receive the request on the managed internal session rather
|
|
2810
|
+
* than as a sessionless call that they would reject with HTTP 400.
|
|
2301
2811
|
*/
|
|
2302
2812
|
async primeAnnotationCache() {
|
|
2303
2813
|
const syntheticToolsList = {
|
|
@@ -2305,14 +2815,22 @@ var GovernedForwarder = class {
|
|
|
2305
2815
|
id: "helio-prime-annotations",
|
|
2306
2816
|
method: "tools/list"
|
|
2307
2817
|
};
|
|
2818
|
+
const internal = this.inner;
|
|
2308
2819
|
try {
|
|
2309
|
-
const result = await this.inner.forward(syntheticToolsList);
|
|
2310
|
-
|
|
2311
|
-
if (!updated) {
|
|
2820
|
+
const result = typeof internal.forwardInternal === "function" ? await internal.forwardInternal(syntheticToolsList) : await this.inner.forward(syntheticToolsList);
|
|
2821
|
+
if (result.response.status >= 400) {
|
|
2312
2822
|
return {
|
|
2313
2823
|
success: false,
|
|
2314
2824
|
toolsCached: this.annotationCache.size,
|
|
2315
|
-
reason:
|
|
2825
|
+
reason: classifyPrimeFailure(result.response)
|
|
2826
|
+
};
|
|
2827
|
+
}
|
|
2828
|
+
const update = this.applyToolDefinitionUpdate(result.response.body, void 0);
|
|
2829
|
+
if (!update.updated) {
|
|
2830
|
+
return {
|
|
2831
|
+
success: false,
|
|
2832
|
+
toolsCached: this.annotationCache.size,
|
|
2833
|
+
reason: classifyPrimeFailure(result.response)
|
|
2316
2834
|
};
|
|
2317
2835
|
}
|
|
2318
2836
|
return { success: true, toolsCached: this.annotationCache.size };
|
|
@@ -2330,10 +2848,62 @@ var GovernedForwarder = class {
|
|
|
2330
2848
|
}
|
|
2331
2849
|
const result = await this.inner.forward(request);
|
|
2332
2850
|
if (request.method === "tools/list") {
|
|
2333
|
-
this.
|
|
2851
|
+
this.applyToolDefinitionUpdate(result.response.body, request.sessionId);
|
|
2334
2852
|
}
|
|
2335
2853
|
return result;
|
|
2336
2854
|
}
|
|
2855
|
+
/**
|
|
2856
|
+
* Apply a tools/list response to the definition cache and surface any
|
|
2857
|
+
* drift: console warning + immediate audit record per event. Single entry
|
|
2858
|
+
* point for both runtime tools/list responses and startup priming, so the
|
|
2859
|
+
* cache is updated exactly once per response.
|
|
2860
|
+
*/
|
|
2861
|
+
applyToolDefinitionUpdate(responseBody, sessionId) {
|
|
2862
|
+
const update = this.annotationCache.update(responseBody);
|
|
2863
|
+
if (!update.updated) return update;
|
|
2864
|
+
for (const drift of update.drifted) {
|
|
2865
|
+
const aspects = drift.changes.map((change) => change.aspect).join(", ");
|
|
2866
|
+
console.error(
|
|
2867
|
+
`[helio] Tool definition drift detected: "${drift.toolName}" changed (${aspects}) after baseline \u2014 calls governed by policies.on_tool_drift (${this.policy.onToolDrift ?? "block"})`
|
|
2868
|
+
);
|
|
2869
|
+
this.writeDriftAuditRecord(drift, sessionId, "tool_drift");
|
|
2870
|
+
}
|
|
2871
|
+
for (const toolName of update.reverted) {
|
|
2872
|
+
console.error(
|
|
2873
|
+
`[helio] Tool definition drift cleared: "${toolName}" returned to its baseline definition`
|
|
2874
|
+
);
|
|
2875
|
+
this.writeDriftAuditRecord({ toolName, changes: [] }, sessionId, "tool_drift_reverted");
|
|
2876
|
+
}
|
|
2877
|
+
return update;
|
|
2878
|
+
}
|
|
2879
|
+
/** Write an immediate audit record for a drift event (not a tool call). */
|
|
2880
|
+
writeDriftAuditRecord(drift, sessionId, decision) {
|
|
2881
|
+
if (!this.auditWriter) return;
|
|
2882
|
+
this.auditWriter.pushImmediate({
|
|
2883
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2884
|
+
session_id: sessionId ?? null,
|
|
2885
|
+
agent_id: null,
|
|
2886
|
+
environment: this.environment ?? null,
|
|
2887
|
+
tool_name: drift.toolName,
|
|
2888
|
+
tool_input: {},
|
|
2889
|
+
policy_decision: decision,
|
|
2890
|
+
block_reason: null,
|
|
2891
|
+
matched_rule: null,
|
|
2892
|
+
matched_rule_index: null,
|
|
2893
|
+
evidence_chain: decision === "tool_drift" ? { tool_drift: { changes: drift.changes } } : null,
|
|
2894
|
+
approval_status: null,
|
|
2895
|
+
approved_by: null,
|
|
2896
|
+
upstream_response: null,
|
|
2897
|
+
upstream_error: null,
|
|
2898
|
+
upstream_http_status: null,
|
|
2899
|
+
upstream_latency_ms: null,
|
|
2900
|
+
total_duration_ms: 0,
|
|
2901
|
+
approval_wait_ms: 0,
|
|
2902
|
+
proxy_compute_ms: 0,
|
|
2903
|
+
flagged_destructive: false,
|
|
2904
|
+
dry_run: false
|
|
2905
|
+
});
|
|
2906
|
+
}
|
|
2337
2907
|
async handleToolsCall(request) {
|
|
2338
2908
|
const startTime = performance.now();
|
|
2339
2909
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -2344,13 +2914,26 @@ var GovernedForwarder = class {
|
|
|
2344
2914
|
}
|
|
2345
2915
|
const toolArguments = params?.["arguments"] && typeof params["arguments"] === "object" ? params["arguments"] : void 0;
|
|
2346
2916
|
const annotations = this.annotationCache.get(toolName);
|
|
2917
|
+
const driftEvent = this.annotationCache.getDrift(toolName);
|
|
2918
|
+
const driftMode = this.policy.onToolDrift ?? "block";
|
|
2347
2919
|
let decision = evaluatePolicy(this.policy, {
|
|
2348
2920
|
toolName,
|
|
2349
2921
|
annotations,
|
|
2350
2922
|
toolArguments,
|
|
2351
2923
|
environment: this.environment
|
|
2352
2924
|
});
|
|
2353
|
-
|
|
2925
|
+
if (driftEvent && driftMode === "log") {
|
|
2926
|
+
const currentDecision = evaluatePolicy(this.policy, {
|
|
2927
|
+
toolName,
|
|
2928
|
+
annotations: this.annotationCache.getCurrent(toolName),
|
|
2929
|
+
toolArguments,
|
|
2930
|
+
environment: this.environment
|
|
2931
|
+
});
|
|
2932
|
+
decision = stricterDecision(decision, currentDecision);
|
|
2933
|
+
}
|
|
2934
|
+
const baselineDestructive = annotations?.destructiveHint ?? true;
|
|
2935
|
+
const currentDestructive = driftEvent && driftMode === "log" ? this.annotationCache.getCurrent(toolName)?.destructiveHint ?? true : false;
|
|
2936
|
+
const isDestructive = baselineDestructive || currentDestructive;
|
|
2354
2937
|
let flaggedDestructive = false;
|
|
2355
2938
|
if (isDestructive && !decision.matchedRule && this.policy.flagDestructive) {
|
|
2356
2939
|
flaggedDestructive = true;
|
|
@@ -2364,6 +2947,15 @@ var GovernedForwarder = class {
|
|
|
2364
2947
|
};
|
|
2365
2948
|
}
|
|
2366
2949
|
}
|
|
2950
|
+
let driftBlocked = false;
|
|
2951
|
+
if (driftEvent && driftMode !== "log") {
|
|
2952
|
+
driftBlocked = driftMode === "block";
|
|
2953
|
+
decision = {
|
|
2954
|
+
action: driftMode === "block" ? "deny" : "require_approval",
|
|
2955
|
+
matchedRule: void 0,
|
|
2956
|
+
reason: `Tool "${toolName}" definition drifted from baseline (${driftEvent.changes.map((change) => change.aspect).join(", ")})`
|
|
2957
|
+
};
|
|
2958
|
+
}
|
|
2367
2959
|
const originalAction = decision.action;
|
|
2368
2960
|
let evidenceResult;
|
|
2369
2961
|
let dependencyResult;
|
|
@@ -2427,6 +3019,8 @@ var GovernedForwarder = class {
|
|
|
2427
3019
|
result = this.makeSessionRequiredBlockResult(request, decision);
|
|
2428
3020
|
} else if (evidenceBlocked) {
|
|
2429
3021
|
result = this.makeEvidenceBlockResult(request, decision, evidenceResult, dependencyResult);
|
|
3022
|
+
} else if (driftBlocked && driftEvent) {
|
|
3023
|
+
result = this.makeDriftBlockResult(request, driftEvent);
|
|
2430
3024
|
} else if (decision.action === "allow") {
|
|
2431
3025
|
result = await this.inner.forward(request);
|
|
2432
3026
|
} else if (decision.action === "deny") {
|
|
@@ -2499,7 +3093,8 @@ var GovernedForwarder = class {
|
|
|
2499
3093
|
rateLimitResult,
|
|
2500
3094
|
spendLimitResult,
|
|
2501
3095
|
isDryRun,
|
|
2502
|
-
forwardingError
|
|
3096
|
+
forwardingError,
|
|
3097
|
+
driftEvent ? { event: driftEvent, mode: driftMode } : void 0
|
|
2503
3098
|
);
|
|
2504
3099
|
return result;
|
|
2505
3100
|
}
|
|
@@ -2716,7 +3311,7 @@ var GovernedForwarder = class {
|
|
|
2716
3311
|
wasForwardedUpstream(decision, approvalOutcome, rateLimitResult, spendLimitResult) {
|
|
2717
3312
|
return decision.action === "allow" || approvalOutcome?.status === "approved" || approvalOutcome?.status === "break_glass" || approvalOutcome?.status === "timeout" && this.approvalRouter?.defaultOnTimeout === "allow" || rateLimitResult?.allowed === true || spendLimitResult?.allowed === true;
|
|
2718
3313
|
}
|
|
2719
|
-
writeAuditRecord(request, timestamp, toolName, toolArguments, decision, result, totalDurationMs, approvalWaitMs, flaggedDestructive, evidenceResult, dependencyResult, evidenceBlocked, approvalOutcome, rateLimitResult, spendLimitResult, isDryRun, forwardingError) {
|
|
3314
|
+
writeAuditRecord(request, timestamp, toolName, toolArguments, decision, result, totalDurationMs, approvalWaitMs, flaggedDestructive, evidenceResult, dependencyResult, evidenceBlocked, approvalOutcome, rateLimitResult, spendLimitResult, isDryRun, forwardingError, drift) {
|
|
2720
3315
|
if (!this.auditWriter) return;
|
|
2721
3316
|
const wasForwarded = this.wasForwardedUpstream(
|
|
2722
3317
|
decision,
|
|
@@ -2776,6 +3371,15 @@ var GovernedForwarder = class {
|
|
|
2776
3371
|
}
|
|
2777
3372
|
};
|
|
2778
3373
|
}
|
|
3374
|
+
if (drift) {
|
|
3375
|
+
evidenceChain = {
|
|
3376
|
+
...evidenceChain ?? {},
|
|
3377
|
+
tool_drift: {
|
|
3378
|
+
mode: drift.mode,
|
|
3379
|
+
changes: drift.event.changes
|
|
3380
|
+
}
|
|
3381
|
+
};
|
|
3382
|
+
}
|
|
2779
3383
|
const blockReason = extractBlockReason(result);
|
|
2780
3384
|
const record = {
|
|
2781
3385
|
timestamp,
|
|
@@ -2808,6 +3412,15 @@ var GovernedForwarder = class {
|
|
|
2808
3412
|
this.auditWriter.push(record);
|
|
2809
3413
|
}
|
|
2810
3414
|
}
|
|
3415
|
+
makeDriftBlockResult(request, drift) {
|
|
3416
|
+
const feedback = buildToolDriftFeedback(drift, "deny");
|
|
3417
|
+
return makeErrorResult(
|
|
3418
|
+
request,
|
|
3419
|
+
POLICY_DENIED,
|
|
3420
|
+
`Tool definition drift: "${drift.toolName}" changed after baseline`,
|
|
3421
|
+
{ ...feedback }
|
|
3422
|
+
);
|
|
3423
|
+
}
|
|
2811
3424
|
makeDenyResult(request, decision) {
|
|
2812
3425
|
const feedback = buildPolicyDeniedFeedback(decision);
|
|
2813
3426
|
const message = decision.matchedRule?.feedback?.message ?? `Policy denied: ${decision.reason}`;
|
|
@@ -2896,6 +3509,17 @@ function collectAllowedEvidenceKeys(policy) {
|
|
|
2896
3509
|
}
|
|
2897
3510
|
return [...keys];
|
|
2898
3511
|
}
|
|
3512
|
+
var ACTION_SEVERITY = {
|
|
3513
|
+
deny: 5,
|
|
3514
|
+
require_approval: 4,
|
|
3515
|
+
dry_run: 3,
|
|
3516
|
+
spend_limit: 2,
|
|
3517
|
+
rate_limit: 1,
|
|
3518
|
+
allow: 0
|
|
3519
|
+
};
|
|
3520
|
+
function stricterDecision(a, b) {
|
|
3521
|
+
return ACTION_SEVERITY[b.action] > ACTION_SEVERITY[a.action] ? b : a;
|
|
3522
|
+
}
|
|
2899
3523
|
function makeErrorResult(request, code, message, data) {
|
|
2900
3524
|
const body = {
|
|
2901
3525
|
jsonrpc: "2.0",
|
|
@@ -2913,6 +3537,27 @@ function hasJsonRpcError(result) {
|
|
|
2913
3537
|
const body = result.response.body;
|
|
2914
3538
|
return body?.["error"] !== void 0;
|
|
2915
3539
|
}
|
|
3540
|
+
function classifyPrimeFailure(response) {
|
|
3541
|
+
if (response.status >= 400) {
|
|
3542
|
+
return `upstream returned HTTP ${String(response.status)} to tools/list (session/initialize may be required)`;
|
|
3543
|
+
}
|
|
3544
|
+
const rawBody = response.body;
|
|
3545
|
+
if (typeof rawBody !== "object" || rawBody === null) {
|
|
3546
|
+
return `upstream tools/list returned a non-JSON body (content-type ${response.headers["content-type"] ?? "unknown"})`;
|
|
3547
|
+
}
|
|
3548
|
+
const body = rawBody;
|
|
3549
|
+
const error = body["error"];
|
|
3550
|
+
if (typeof error === "string") {
|
|
3551
|
+
return `upstream tools/list returned a JSON-RPC error: ${error}`;
|
|
3552
|
+
}
|
|
3553
|
+
if (error !== null && typeof error === "object") {
|
|
3554
|
+
const message = error["message"];
|
|
3555
|
+
if (typeof message === "string") {
|
|
3556
|
+
return `upstream tools/list returned a JSON-RPC error: ${message}`;
|
|
3557
|
+
}
|
|
3558
|
+
}
|
|
3559
|
+
return "upstream tools/list response was missing result.tools";
|
|
3560
|
+
}
|
|
2916
3561
|
function extractBlockReason(result) {
|
|
2917
3562
|
const body = result.response.body;
|
|
2918
3563
|
const error = body?.["error"];
|
|
@@ -3440,6 +4085,7 @@ function clampInt(value, fallback, min, max) {
|
|
|
3440
4085
|
}
|
|
3441
4086
|
|
|
3442
4087
|
// src/audit/store.ts
|
|
4088
|
+
var DRIFT_EVENT_DECISIONS_SQL = "('tool_drift', 'tool_drift_reverted')";
|
|
3443
4089
|
var CREATE_TABLE_DDL = `
|
|
3444
4090
|
CREATE TABLE IF NOT EXISTS audit_records (
|
|
3445
4091
|
id TEXT PRIMARY KEY,
|
|
@@ -3743,7 +4389,7 @@ var AuditStore = class {
|
|
|
3743
4389
|
const totals = this.db.prepare(
|
|
3744
4390
|
`SELECT
|
|
3745
4391
|
COUNT(*) as total,
|
|
3746
|
-
COALESCE(SUM(CASE WHEN block_reason IS NULL THEN 1 ELSE 0 END), 0) as allowed_total,
|
|
4392
|
+
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,
|
|
3747
4393
|
COALESCE(SUM(CASE WHEN block_reason IS NOT NULL THEN 1 ELSE 0 END), 0) as blocked_total,
|
|
3748
4394
|
COALESCE(SUM(CASE WHEN dry_run = 1 THEN 1 ELSE 0 END), 0) as dry_run_total,
|
|
3749
4395
|
COALESCE(SUM(CASE WHEN dry_run = 0 THEN 1 ELSE 0 END), 0) as applied_total
|
|
@@ -3762,9 +4408,10 @@ var AuditStore = class {
|
|
|
3762
4408
|
GROUP BY block_reason
|
|
3763
4409
|
ORDER BY count DESC`
|
|
3764
4410
|
).all(...params);
|
|
4411
|
+
const toolsClause = clause ? `${clause} AND policy_decision NOT IN ${DRIFT_EVENT_DECISIONS_SQL}` : `WHERE policy_decision NOT IN ${DRIFT_EVENT_DECISIONS_SQL}`;
|
|
3765
4412
|
const top_tools = this.db.prepare(
|
|
3766
4413
|
`SELECT tool_name, COUNT(*) as count
|
|
3767
|
-
FROM audit_records ${
|
|
4414
|
+
FROM audit_records ${toolsClause}
|
|
3768
4415
|
GROUP BY tool_name
|
|
3769
4416
|
ORDER BY count DESC
|
|
3770
4417
|
LIMIT 10`
|
|
@@ -6035,7 +6682,9 @@ async function startAnnotationPrimeLoop(governedForwarder) {
|
|
|
6035
6682
|
primed = true;
|
|
6036
6683
|
clearRetryTimer();
|
|
6037
6684
|
const prefix = phase === "initial" ? "[helio] Annotation cache primed" : `[helio] Annotation cache primed after retry ${String(retryAttempt)}`;
|
|
6038
|
-
console.error(
|
|
6685
|
+
console.error(
|
|
6686
|
+
`${prefix}: ${String(result.toolsCached)} tool definitions baselined for drift detection (baselines are per-process; a restart re-baselines \u2014 review tool_drift audit records before restarting)`
|
|
6687
|
+
);
|
|
6039
6688
|
return;
|
|
6040
6689
|
}
|
|
6041
6690
|
const reason = result.reason ?? "unknown reason";
|