@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/index.js
CHANGED
|
@@ -161,6 +161,19 @@ var policiesSchema = z.object({
|
|
|
161
161
|
flag_destructive: z.enum(["log", "require_approval"]).optional(),
|
|
162
162
|
dry_run: z.boolean().default(false),
|
|
163
163
|
rules: z.array(policyRuleSchema).default([]),
|
|
164
|
+
/**
|
|
165
|
+
* How to treat calls to a tool whose definition (annotations, schemas,
|
|
166
|
+
* description) has drifted from the baseline Helio captured on first
|
|
167
|
+
* sight.
|
|
168
|
+
* - "block": deny the call until the proxy is restarted (re-baselines)
|
|
169
|
+
* or the upstream reverts. Conservative default when omitted.
|
|
170
|
+
* - "require_approval": escalate the call through the approval channel.
|
|
171
|
+
* - "log": audit the drift; rules evaluate against both baseline and
|
|
172
|
+
* current annotations and the stricter decision wins.
|
|
173
|
+
* Kept optional (like hot_reload) so PoliciesConfig literal fixtures
|
|
174
|
+
* don't need the field; undefined is treated as "block".
|
|
175
|
+
*/
|
|
176
|
+
on_tool_drift: z.enum(["block", "require_approval", "log"]).optional(),
|
|
164
177
|
/**
|
|
165
178
|
* Whether `helio start` should watch the config file for changes and
|
|
166
179
|
* reconcile policy state on every save. Defaults to `true` when omitted.
|
|
@@ -223,14 +236,14 @@ var helioConfigBaseSchema = z.object({
|
|
|
223
236
|
});
|
|
224
237
|
var helioConfigSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
|
|
225
238
|
const hasConfiguredEnvironment = typeof cfg.environment === "string" && cfg.environment.trim().length > 0;
|
|
226
|
-
const requiresSecret = cfg.policies.flag_destructive === "require_approval" || cfg.policies.rules.some((rule) => rule.action === "require_approval");
|
|
239
|
+
const requiresSecret = cfg.policies.flag_destructive === "require_approval" || cfg.policies.on_tool_drift === "require_approval" || cfg.policies.rules.some((rule) => rule.action === "require_approval");
|
|
227
240
|
const hasSecret = hasDashboardApiSecret(cfg.dashboard.api_secret);
|
|
228
241
|
if (requiresSecret) {
|
|
229
242
|
if (!hasSecret) {
|
|
230
243
|
ctx.addIssue({
|
|
231
244
|
code: "custom",
|
|
232
245
|
path: ["dashboard", "api_secret"],
|
|
233
|
-
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.)'
|
|
246
|
+
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.)'
|
|
234
247
|
});
|
|
235
248
|
}
|
|
236
249
|
}
|
|
@@ -419,6 +432,7 @@ function compilePolicies(config) {
|
|
|
419
432
|
defaultAction: config.default,
|
|
420
433
|
flagDestructive: config.flag_destructive,
|
|
421
434
|
...config.dry_run && { dryRun: true },
|
|
435
|
+
...config.on_tool_drift && { onToolDrift: config.on_tool_drift },
|
|
422
436
|
rules
|
|
423
437
|
};
|
|
424
438
|
return { policy, warnings };
|
|
@@ -1109,6 +1123,93 @@ async function parseUpstreamResponse(res) {
|
|
|
1109
1123
|
return { status: res.status, headers, body };
|
|
1110
1124
|
}
|
|
1111
1125
|
|
|
1126
|
+
// src/upstream/sse-parse.ts
|
|
1127
|
+
function parseSseChunk(chunk, state, onEvent) {
|
|
1128
|
+
let { event, data, remainder } = state;
|
|
1129
|
+
const text = remainder + chunk;
|
|
1130
|
+
const lines = text.split("\n");
|
|
1131
|
+
remainder = lines.pop() ?? "";
|
|
1132
|
+
for (const rawLine of lines) {
|
|
1133
|
+
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
1134
|
+
if (line === "") {
|
|
1135
|
+
if (event || data) {
|
|
1136
|
+
onEvent(event, data);
|
|
1137
|
+
event = "";
|
|
1138
|
+
data = "";
|
|
1139
|
+
}
|
|
1140
|
+
} else if (line.startsWith("event:")) {
|
|
1141
|
+
const value = line.slice(6).replace(/^ /, "");
|
|
1142
|
+
event = value;
|
|
1143
|
+
} else if (line.startsWith("data:")) {
|
|
1144
|
+
const value = line.slice(5).replace(/^ /, "");
|
|
1145
|
+
data = data ? data + "\n" + value : value;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
return { event, data, remainder };
|
|
1149
|
+
}
|
|
1150
|
+
async function readSseJsonRpcResponse(res, requestId) {
|
|
1151
|
+
if (!res.body) {
|
|
1152
|
+
throw new Error("upstream SSE response had no body");
|
|
1153
|
+
}
|
|
1154
|
+
const reader = res.body.getReader();
|
|
1155
|
+
const decoder = new TextDecoder();
|
|
1156
|
+
let state = { event: "", data: "", remainder: "" };
|
|
1157
|
+
let found;
|
|
1158
|
+
const onEvent = (event, data) => {
|
|
1159
|
+
if (event && event !== "message") return;
|
|
1160
|
+
let parsed;
|
|
1161
|
+
try {
|
|
1162
|
+
parsed = JSON.parse(data);
|
|
1163
|
+
} catch {
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
if (parsed === null || typeof parsed !== "object") return;
|
|
1167
|
+
const id = parsed["id"];
|
|
1168
|
+
if (id === requestId) {
|
|
1169
|
+
found = parsed;
|
|
1170
|
+
}
|
|
1171
|
+
};
|
|
1172
|
+
const processChunk = (chunk) => {
|
|
1173
|
+
state = parseSseChunk(chunk, state, onEvent);
|
|
1174
|
+
};
|
|
1175
|
+
for (; ; ) {
|
|
1176
|
+
const result = await reader.read();
|
|
1177
|
+
if (result.value !== void 0) {
|
|
1178
|
+
const chunk = result.value;
|
|
1179
|
+
processChunk(decoder.decode(chunk, { stream: true }));
|
|
1180
|
+
if (found) {
|
|
1181
|
+
await reader.cancel().catch(() => void 0);
|
|
1182
|
+
return found;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
if (result.done) {
|
|
1186
|
+
const tail = decoder.decode();
|
|
1187
|
+
if (tail) {
|
|
1188
|
+
processChunk(tail);
|
|
1189
|
+
if (found) return found;
|
|
1190
|
+
}
|
|
1191
|
+
break;
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
throw new Error(
|
|
1195
|
+
`upstream SSE stream closed with no JSON-RPC response for id ${String(requestId)}`
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// src/upstream/merge-headers.ts
|
|
1200
|
+
function mergeUpstreamHeaders(base, forwarded, staticHeaders) {
|
|
1201
|
+
const out = {};
|
|
1202
|
+
const apply = (headers) => {
|
|
1203
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
1204
|
+
out[name.toLowerCase()] = value;
|
|
1205
|
+
}
|
|
1206
|
+
};
|
|
1207
|
+
apply(base);
|
|
1208
|
+
apply(forwarded);
|
|
1209
|
+
apply(staticHeaders);
|
|
1210
|
+
return out;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1112
1213
|
// src/upstream/connection-error.ts
|
|
1113
1214
|
var UPSTREAM_DOCS_URL = "https://github.com/gethelio/helio/blob/main/docs/getting-started.md";
|
|
1114
1215
|
var UNREACHABLE_CODES = /* @__PURE__ */ new Set([
|
|
@@ -1148,31 +1249,314 @@ function describeUnreachableUpstream(error, url) {
|
|
|
1148
1249
|
);
|
|
1149
1250
|
}
|
|
1150
1251
|
|
|
1151
|
-
// src/upstream/
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1252
|
+
// src/upstream/upstream-session-manager.ts
|
|
1253
|
+
var HELIO_MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
1254
|
+
var MAX_SSE_ERROR_SCAN_BYTES = 256 * 1024;
|
|
1255
|
+
var UpstreamSessionManager = class {
|
|
1256
|
+
url;
|
|
1257
|
+
staticHeaders;
|
|
1258
|
+
requestTimeoutMs;
|
|
1259
|
+
internal;
|
|
1260
|
+
inflight;
|
|
1261
|
+
constructor(options) {
|
|
1262
|
+
this.url = options.url;
|
|
1263
|
+
this.staticHeaders = options.staticHeaders;
|
|
1264
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
|
|
1265
|
+
}
|
|
1266
|
+
/** Return the internal session, performing the handshake once if needed. */
|
|
1267
|
+
ensureInternalSession() {
|
|
1268
|
+
if (this.internal) return Promise.resolve(this.internal);
|
|
1269
|
+
this.inflight ??= this.initialize().then((session) => {
|
|
1270
|
+
this.internal = session;
|
|
1271
|
+
return session;
|
|
1272
|
+
}).finally(() => {
|
|
1273
|
+
this.inflight = void 0;
|
|
1274
|
+
});
|
|
1275
|
+
return this.inflight;
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Drop the cached internal session so the next call re-initializes.
|
|
1279
|
+
* Does not cancel any in-flight initialize.
|
|
1280
|
+
*/
|
|
1281
|
+
invalidateInternalSession() {
|
|
1282
|
+
this.internal = void 0;
|
|
1283
|
+
}
|
|
1284
|
+
/** Convert a fetch failure into an actionable error for the given step. */
|
|
1285
|
+
describeFetchFailure(error, step) {
|
|
1286
|
+
if (error instanceof Error && error.name === "TimeoutError") {
|
|
1287
|
+
return new Error(`upstream ${step} timed out after ${String(this.requestTimeoutMs)}ms`);
|
|
1157
1288
|
}
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1289
|
+
return describeUnreachableUpstream(error, this.url) ?? (error instanceof Error ? error : new Error(String(error)));
|
|
1290
|
+
}
|
|
1291
|
+
async initialize() {
|
|
1292
|
+
const headers = mergeUpstreamHeaders(
|
|
1293
|
+
{
|
|
1294
|
+
"content-type": "application/json",
|
|
1295
|
+
accept: "application/json, text/event-stream"
|
|
1296
|
+
},
|
|
1297
|
+
{},
|
|
1298
|
+
this.staticHeaders
|
|
1299
|
+
);
|
|
1300
|
+
const initBody = {
|
|
1301
|
+
jsonrpc: "2.0",
|
|
1302
|
+
id: 0,
|
|
1303
|
+
method: "initialize",
|
|
1304
|
+
params: {
|
|
1305
|
+
protocolVersion: HELIO_MCP_PROTOCOL_VERSION,
|
|
1306
|
+
capabilities: {},
|
|
1307
|
+
clientInfo: { name: "helio-proxy", version: "0" }
|
|
1308
|
+
}
|
|
1309
|
+
};
|
|
1310
|
+
let res;
|
|
1311
|
+
try {
|
|
1312
|
+
res = await fetch(this.url, {
|
|
1313
|
+
method: "POST",
|
|
1314
|
+
headers,
|
|
1315
|
+
body: JSON.stringify(initBody),
|
|
1316
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs)
|
|
1317
|
+
});
|
|
1318
|
+
} catch (error) {
|
|
1319
|
+
throw this.describeFetchFailure(error, "initialize");
|
|
1320
|
+
}
|
|
1321
|
+
if (!res.ok) {
|
|
1322
|
+
throw new Error(`upstream initialize failed: HTTP ${String(res.status)}`);
|
|
1323
|
+
}
|
|
1324
|
+
const sessionId = res.headers.get("mcp-session-id") ?? void 0;
|
|
1325
|
+
const initializeEnvelope = await this.readRequiredJsonRpcEnvelope(
|
|
1326
|
+
res,
|
|
1327
|
+
initBody.id,
|
|
1328
|
+
"initialize"
|
|
1329
|
+
);
|
|
1330
|
+
const initializeError = extractJsonRpcErrorMessage(initializeEnvelope);
|
|
1331
|
+
if (initializeError) {
|
|
1332
|
+
throw new Error(`upstream initialize returned JSON-RPC error: ${initializeError}`);
|
|
1333
|
+
}
|
|
1334
|
+
const negotiatedProtocolVersion = extractNegotiatedProtocolVersion(initializeEnvelope);
|
|
1335
|
+
const notifyHeaders = { ...headers };
|
|
1336
|
+
if (sessionId) notifyHeaders["mcp-session-id"] = sessionId;
|
|
1337
|
+
notifyHeaders["mcp-protocol-version"] = negotiatedProtocolVersion;
|
|
1338
|
+
const notifyRes = await fetch(this.url, {
|
|
1339
|
+
method: "POST",
|
|
1340
|
+
headers: notifyHeaders,
|
|
1341
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
|
|
1342
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs)
|
|
1343
|
+
}).catch((error) => {
|
|
1344
|
+
throw this.describeFetchFailure(error, "notifications/initialized");
|
|
1345
|
+
});
|
|
1346
|
+
if (!notifyRes.ok) {
|
|
1347
|
+
throw new Error(`upstream notifications/initialized failed: HTTP ${String(notifyRes.status)}`);
|
|
1348
|
+
}
|
|
1349
|
+
const notifyError = await this.readOptionalJsonRpcError(notifyRes);
|
|
1350
|
+
if (notifyError) {
|
|
1351
|
+
throw new Error(`upstream notifications/initialized returned JSON-RPC error: ${notifyError}`);
|
|
1352
|
+
}
|
|
1353
|
+
return { sessionId, protocolVersion: negotiatedProtocolVersion };
|
|
1354
|
+
}
|
|
1355
|
+
async readRequiredJsonRpcEnvelope(res, requestId, step) {
|
|
1356
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
1357
|
+
if (contentType.includes("text/event-stream")) {
|
|
1358
|
+
const payload = await readSseJsonRpcResponse(res, requestId);
|
|
1359
|
+
return payload;
|
|
1360
|
+
}
|
|
1361
|
+
const raw = await res.text();
|
|
1362
|
+
if (!raw.trim()) {
|
|
1363
|
+
throw new Error(`upstream ${step} returned an empty body`);
|
|
1364
|
+
}
|
|
1365
|
+
let parsed;
|
|
1366
|
+
try {
|
|
1367
|
+
parsed = JSON.parse(raw);
|
|
1368
|
+
} catch {
|
|
1369
|
+
throw new Error(`upstream ${step} returned non-JSON body`);
|
|
1370
|
+
}
|
|
1371
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
1372
|
+
throw new Error(`upstream ${step} returned non-object JSON`);
|
|
1373
|
+
}
|
|
1374
|
+
return parsed;
|
|
1375
|
+
}
|
|
1376
|
+
async readOptionalJsonRpcError(res) {
|
|
1377
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
1378
|
+
if (contentType.includes("text/event-stream")) {
|
|
1379
|
+
if (!res.body) return void 0;
|
|
1380
|
+
let errorMessage;
|
|
1381
|
+
const reader = res.body.getReader();
|
|
1382
|
+
const decoder = new TextDecoder();
|
|
1383
|
+
let state = { event: "", data: "", remainder: "" };
|
|
1384
|
+
let scannedBytes = 0;
|
|
1385
|
+
const deadline = Date.now() + this.requestTimeoutMs;
|
|
1386
|
+
const onEvent = (event, data) => {
|
|
1387
|
+
if (errorMessage) return;
|
|
1388
|
+
if (event && event !== "message") return;
|
|
1389
|
+
let parsed2;
|
|
1390
|
+
try {
|
|
1391
|
+
parsed2 = JSON.parse(data);
|
|
1392
|
+
} catch {
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
if (typeof parsed2 !== "object" || parsed2 === null) return;
|
|
1396
|
+
errorMessage = extractJsonRpcErrorMessage(parsed2);
|
|
1397
|
+
};
|
|
1398
|
+
for (; ; ) {
|
|
1399
|
+
const remainingMs = deadline - Date.now();
|
|
1400
|
+
if (remainingMs <= 0) {
|
|
1401
|
+
await reader.cancel().catch(() => void 0);
|
|
1402
|
+
throw new Error(
|
|
1403
|
+
`upstream notifications/initialized SSE response timed out after ${String(this.requestTimeoutMs)}ms`
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1406
|
+
let chunk;
|
|
1407
|
+
try {
|
|
1408
|
+
chunk = await readSseChunkWithTimeout(reader, remainingMs);
|
|
1409
|
+
} catch {
|
|
1410
|
+
await reader.cancel().catch(() => void 0);
|
|
1411
|
+
throw new Error(
|
|
1412
|
+
`upstream notifications/initialized SSE response timed out after ${String(this.requestTimeoutMs)}ms`
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
const { done, value } = chunk;
|
|
1416
|
+
if (value !== void 0) {
|
|
1417
|
+
scannedBytes += value.byteLength;
|
|
1418
|
+
if (scannedBytes > MAX_SSE_ERROR_SCAN_BYTES) {
|
|
1419
|
+
await reader.cancel().catch(() => void 0);
|
|
1420
|
+
throw new Error(
|
|
1421
|
+
`upstream notifications/initialized SSE response exceeded ${String(MAX_SSE_ERROR_SCAN_BYTES)} bytes`
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1424
|
+
state = parseSseChunk(decoder.decode(value, { stream: true }), state, onEvent);
|
|
1425
|
+
if (errorMessage) {
|
|
1426
|
+
await reader.cancel().catch(() => void 0);
|
|
1427
|
+
return errorMessage;
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
if (done) {
|
|
1431
|
+
const tail = decoder.decode();
|
|
1432
|
+
if (tail) {
|
|
1433
|
+
state = parseSseChunk(tail, state, onEvent);
|
|
1434
|
+
}
|
|
1435
|
+
return errorMessage;
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
const raw = await res.text();
|
|
1440
|
+
if (!raw.trim()) return void 0;
|
|
1441
|
+
let parsed;
|
|
1442
|
+
try {
|
|
1443
|
+
parsed = JSON.parse(raw);
|
|
1444
|
+
} catch {
|
|
1445
|
+
return void 0;
|
|
1446
|
+
}
|
|
1447
|
+
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
1448
|
+
return extractJsonRpcErrorMessage(parsed);
|
|
1449
|
+
}
|
|
1450
|
+
};
|
|
1451
|
+
async function readSseChunkWithTimeout(reader, timeoutMs) {
|
|
1452
|
+
let timeoutHandle;
|
|
1453
|
+
try {
|
|
1454
|
+
const result = await Promise.race([
|
|
1455
|
+
reader.read(),
|
|
1456
|
+
new Promise((_, reject) => {
|
|
1457
|
+
timeoutHandle = setTimeout(() => {
|
|
1458
|
+
reject(new Error(`sse read timed out after ${String(timeoutMs)}ms`));
|
|
1459
|
+
}, timeoutMs);
|
|
1460
|
+
})
|
|
1461
|
+
]);
|
|
1462
|
+
if (!isSseReadChunk(result)) {
|
|
1463
|
+
throw new Error("upstream notifications/initialized SSE response returned invalid chunk");
|
|
1464
|
+
}
|
|
1465
|
+
return result;
|
|
1466
|
+
} finally {
|
|
1467
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
function isSseReadChunk(value) {
|
|
1471
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1472
|
+
const candidate = value;
|
|
1473
|
+
if (typeof candidate.done !== "boolean") return false;
|
|
1474
|
+
if (candidate.value === void 0) return true;
|
|
1475
|
+
return candidate.value instanceof Uint8Array;
|
|
1476
|
+
}
|
|
1477
|
+
function extractJsonRpcErrorMessage(payload) {
|
|
1478
|
+
const error = payload["error"];
|
|
1479
|
+
if (typeof error === "string") return error;
|
|
1480
|
+
if (typeof error !== "object" || error === null) return void 0;
|
|
1481
|
+
const message = error["message"];
|
|
1482
|
+
if (typeof message === "string" && message.trim()) return message;
|
|
1483
|
+
return "unknown JSON-RPC error";
|
|
1484
|
+
}
|
|
1485
|
+
function extractNegotiatedProtocolVersion(payload) {
|
|
1486
|
+
const result = payload["result"];
|
|
1487
|
+
if (typeof result !== "object" || result === null) {
|
|
1488
|
+
return HELIO_MCP_PROTOCOL_VERSION;
|
|
1489
|
+
}
|
|
1490
|
+
const protocolVersion = result["protocolVersion"];
|
|
1491
|
+
return typeof protocolVersion === "string" && protocolVersion.trim() ? protocolVersion : HELIO_MCP_PROTOCOL_VERSION;
|
|
1163
1492
|
}
|
|
1164
1493
|
|
|
1165
|
-
// src/upstream/forwarder.ts
|
|
1166
|
-
var
|
|
1494
|
+
// src/upstream/streamable-http-forwarder.ts
|
|
1495
|
+
var StreamableHttpForwarder = class {
|
|
1167
1496
|
url;
|
|
1168
1497
|
staticHeaders;
|
|
1169
1498
|
requestTimeoutMs;
|
|
1499
|
+
sessions;
|
|
1170
1500
|
constructor(options) {
|
|
1171
1501
|
this.url = options.url;
|
|
1172
1502
|
this.staticHeaders = options.headers ?? {};
|
|
1173
1503
|
this.requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
|
|
1504
|
+
this.sessions = new UpstreamSessionManager({
|
|
1505
|
+
url: this.url,
|
|
1506
|
+
staticHeaders: this.staticHeaders,
|
|
1507
|
+
requestTimeoutMs: this.requestTimeoutMs
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
/** Lifecycle parity with sse/stdio. No eager connect — sessions are lazy. */
|
|
1511
|
+
connect() {
|
|
1512
|
+
return Promise.resolve();
|
|
1513
|
+
}
|
|
1514
|
+
/** Lifecycle parity with sse/stdio. */
|
|
1515
|
+
close() {
|
|
1516
|
+
this.sessions.invalidateInternalSession();
|
|
1517
|
+
return Promise.resolve();
|
|
1174
1518
|
}
|
|
1175
1519
|
async forward(request) {
|
|
1520
|
+
if (request.method === "initialize") {
|
|
1521
|
+
return this.send(
|
|
1522
|
+
request,
|
|
1523
|
+
request.sessionId,
|
|
1524
|
+
/* protocolVersion */
|
|
1525
|
+
void 0
|
|
1526
|
+
);
|
|
1527
|
+
}
|
|
1528
|
+
return this.send(request, request.sessionId, HELIO_MCP_PROTOCOL_VERSION);
|
|
1529
|
+
}
|
|
1530
|
+
/**
|
|
1531
|
+
* Helio-internal execution path (startup prime / internal maintenance) that
|
|
1532
|
+
* may borrow the proxy-managed internal session.
|
|
1533
|
+
*/
|
|
1534
|
+
async forwardInternal(request) {
|
|
1535
|
+
const session = await this.sessions.ensureInternalSession();
|
|
1536
|
+
try {
|
|
1537
|
+
return await this.send(
|
|
1538
|
+
request,
|
|
1539
|
+
session.sessionId,
|
|
1540
|
+
session.protocolVersion,
|
|
1541
|
+
/* internalManaged */
|
|
1542
|
+
true
|
|
1543
|
+
);
|
|
1544
|
+
} catch (error) {
|
|
1545
|
+
if (error instanceof UpstreamSessionExpiredError) {
|
|
1546
|
+
this.sessions.invalidateInternalSession();
|
|
1547
|
+
const fresh = await this.sessions.ensureInternalSession();
|
|
1548
|
+
return this.send(
|
|
1549
|
+
request,
|
|
1550
|
+
fresh.sessionId,
|
|
1551
|
+
fresh.protocolVersion,
|
|
1552
|
+
/* internalManaged */
|
|
1553
|
+
true
|
|
1554
|
+
);
|
|
1555
|
+
}
|
|
1556
|
+
throw error;
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
async send(request, sessionId, protocolVersion, internalManaged = false) {
|
|
1176
1560
|
const headers = mergeUpstreamHeaders(
|
|
1177
1561
|
{
|
|
1178
1562
|
"content-type": "application/json",
|
|
@@ -1181,8 +1565,9 @@ var UpstreamForwarder = class {
|
|
|
1181
1565
|
request.headers ?? {},
|
|
1182
1566
|
this.staticHeaders
|
|
1183
1567
|
);
|
|
1184
|
-
if (
|
|
1185
|
-
|
|
1568
|
+
if (sessionId) headers["mcp-session-id"] = sessionId;
|
|
1569
|
+
if (protocolVersion && headers["mcp-protocol-version"] === void 0) {
|
|
1570
|
+
headers["mcp-protocol-version"] = protocolVersion;
|
|
1186
1571
|
}
|
|
1187
1572
|
const body = {
|
|
1188
1573
|
jsonrpc: request.jsonrpc,
|
|
@@ -1196,33 +1581,52 @@ var UpstreamForwarder = class {
|
|
|
1196
1581
|
const signal = requestSignal ? AbortSignal.any([requestSignal, timeoutSignal]) : timeoutSignal;
|
|
1197
1582
|
let res;
|
|
1198
1583
|
try {
|
|
1199
|
-
res = await fetch(this.url, {
|
|
1200
|
-
method: "POST",
|
|
1201
|
-
headers,
|
|
1202
|
-
body: JSON.stringify(body),
|
|
1203
|
-
signal
|
|
1204
|
-
});
|
|
1584
|
+
res = await fetch(this.url, { method: "POST", headers, body: JSON.stringify(body), signal });
|
|
1205
1585
|
} catch (error) {
|
|
1206
1586
|
const isTimeout = error instanceof Error && error.name === "TimeoutError";
|
|
1207
|
-
if (requestSignal?.aborted)
|
|
1208
|
-
throw new Error("request aborted by downstream client");
|
|
1209
|
-
}
|
|
1587
|
+
if (requestSignal?.aborted) throw new Error("request aborted by downstream client");
|
|
1210
1588
|
if (isTimeout) {
|
|
1211
1589
|
throw new Error(`upstream request timed out after ${String(this.requestTimeoutMs)}ms`);
|
|
1212
1590
|
}
|
|
1213
1591
|
throw describeUnreachableUpstream(error, this.url) ?? error;
|
|
1214
1592
|
}
|
|
1215
|
-
|
|
1593
|
+
if (internalManaged && res.status === 404 && sessionId) {
|
|
1594
|
+
await res.text().catch(() => void 0);
|
|
1595
|
+
throw new UpstreamSessionExpiredError();
|
|
1596
|
+
}
|
|
1216
1597
|
const contentType = res.headers.get("content-type") ?? "";
|
|
1217
1598
|
if (contentType.includes("text/event-stream")) {
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1599
|
+
const responseHeaders = {};
|
|
1600
|
+
res.headers.forEach((value, key) => {
|
|
1601
|
+
responseHeaders[key] = value;
|
|
1602
|
+
});
|
|
1603
|
+
if (request.id === void 0) {
|
|
1604
|
+
await res.body?.cancel().catch(() => void 0);
|
|
1605
|
+
const response3 = {
|
|
1606
|
+
status: res.status,
|
|
1607
|
+
headers: responseHeaders,
|
|
1608
|
+
body: { jsonrpc: "2.0" }
|
|
1609
|
+
};
|
|
1610
|
+
return { response: response3, durationMs: performance.now() - start };
|
|
1611
|
+
}
|
|
1612
|
+
const jsonRpc = await readSseJsonRpcResponse(res, request.id);
|
|
1613
|
+
const response2 = { status: res.status, headers: responseHeaders, body: jsonRpc };
|
|
1614
|
+
return { response: response2, durationMs: performance.now() - start };
|
|
1221
1615
|
}
|
|
1222
1616
|
const response = await parseUpstreamResponse(res);
|
|
1223
|
-
return { response, durationMs };
|
|
1617
|
+
return { response, durationMs: performance.now() - start };
|
|
1224
1618
|
}
|
|
1225
1619
|
};
|
|
1620
|
+
var UpstreamSessionExpiredError = class extends Error {
|
|
1621
|
+
constructor() {
|
|
1622
|
+
super("upstream session expired (HTTP 404) for Helio-managed internal session");
|
|
1623
|
+
this.name = "UpstreamSessionExpiredError";
|
|
1624
|
+
}
|
|
1625
|
+
};
|
|
1626
|
+
|
|
1627
|
+
// src/upstream/forwarder.ts
|
|
1628
|
+
var UpstreamForwarder = class extends StreamableHttpForwarder {
|
|
1629
|
+
};
|
|
1226
1630
|
|
|
1227
1631
|
// src/mcp/pending-requests.ts
|
|
1228
1632
|
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
@@ -1293,26 +1697,6 @@ function buildRequestSignal(request, timeoutMs) {
|
|
|
1293
1697
|
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
1294
1698
|
return request.signal ? AbortSignal.any([request.signal, timeoutSignal]) : timeoutSignal;
|
|
1295
1699
|
}
|
|
1296
|
-
function parseSseChunk(chunk, state, onEvent) {
|
|
1297
|
-
let { event, data, remainder } = state;
|
|
1298
|
-
const text = remainder + chunk;
|
|
1299
|
-
const lines = text.split("\n");
|
|
1300
|
-
remainder = lines.pop() ?? "";
|
|
1301
|
-
for (const line of lines) {
|
|
1302
|
-
if (line === "") {
|
|
1303
|
-
if (event || data) {
|
|
1304
|
-
onEvent(event, data);
|
|
1305
|
-
event = "";
|
|
1306
|
-
data = "";
|
|
1307
|
-
}
|
|
1308
|
-
} else if (line.startsWith("event: ")) {
|
|
1309
|
-
event = line.slice(7);
|
|
1310
|
-
} else if (line.startsWith("data: ")) {
|
|
1311
|
-
data = data ? data + "\n" + line.slice(6) : line.slice(6);
|
|
1312
|
-
}
|
|
1313
|
-
}
|
|
1314
|
-
return { event, data, remainder };
|
|
1315
|
-
}
|
|
1316
1700
|
var SseUpstreamForwarder = class {
|
|
1317
1701
|
url;
|
|
1318
1702
|
staticHeaders;
|
|
@@ -1800,49 +2184,161 @@ function evaluatePolicy(policy, ctx) {
|
|
|
1800
2184
|
}
|
|
1801
2185
|
|
|
1802
2186
|
// src/policy/annotation-cache.ts
|
|
2187
|
+
var ASPECT_FIELDS = [
|
|
2188
|
+
"annotations",
|
|
2189
|
+
"inputSchema",
|
|
2190
|
+
"description",
|
|
2191
|
+
"outputSchema",
|
|
2192
|
+
"title"
|
|
2193
|
+
];
|
|
1803
2194
|
var ToolAnnotationCache = class {
|
|
1804
|
-
|
|
1805
|
-
|
|
2195
|
+
baselines = /* @__PURE__ */ new Map();
|
|
2196
|
+
present = /* @__PURE__ */ new Set();
|
|
2197
|
+
currentAnnotations = /* @__PURE__ */ new Map();
|
|
2198
|
+
driftedTools = /* @__PURE__ */ new Map();
|
|
2199
|
+
/** Number of tools present in the most recent tools/list. */
|
|
1806
2200
|
get size() {
|
|
1807
|
-
return this.
|
|
2201
|
+
return this.present.size;
|
|
1808
2202
|
}
|
|
1809
|
-
/**
|
|
1810
|
-
* Update the cache from a tools/list JSON-RPC response body.
|
|
1811
|
-
*
|
|
1812
|
-
* Performs a full replacement — tools that existed in the previous cache
|
|
1813
|
-
* but are absent from the new response are removed. This correctly handles
|
|
1814
|
-
* tool list changes (additions, removals, annotation updates).
|
|
1815
|
-
*
|
|
1816
|
-
* @returns `true` if the response body was a valid tools/list response and
|
|
1817
|
-
* the cache was updated, `false` if the body shape was unexpected.
|
|
1818
|
-
*/
|
|
2203
|
+
/** Diff a tools/list JSON-RPC response body against the baselines. */
|
|
1819
2204
|
update(responseBody) {
|
|
1820
2205
|
const tools = extractTools(responseBody);
|
|
1821
|
-
if (!tools) return false;
|
|
1822
|
-
|
|
2206
|
+
if (!tools) return { updated: false, baselined: [], drifted: [], reverted: [] };
|
|
2207
|
+
const baselined = [];
|
|
2208
|
+
const drifted = [];
|
|
2209
|
+
const reverted = [];
|
|
2210
|
+
const present = /* @__PURE__ */ new Set();
|
|
2211
|
+
const currentAnnotations = /* @__PURE__ */ new Map();
|
|
2212
|
+
const entries = [];
|
|
2213
|
+
const nameCounts = /* @__PURE__ */ new Map();
|
|
1823
2214
|
for (const tool of tools) {
|
|
1824
2215
|
if (typeof tool !== "object" || tool === null) continue;
|
|
1825
2216
|
const t = tool;
|
|
1826
2217
|
const name = t["name"];
|
|
1827
2218
|
if (typeof name !== "string") continue;
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
2219
|
+
entries.push({ name, definition: t });
|
|
2220
|
+
nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1);
|
|
2221
|
+
}
|
|
2222
|
+
const duplicateNames = /* @__PURE__ */ new Set();
|
|
2223
|
+
for (const { name, definition: t } of entries) {
|
|
2224
|
+
const isDuplicate = (nameCounts.get(name) ?? 0) > 1;
|
|
2225
|
+
if (isDuplicate) {
|
|
2226
|
+
present.add(name);
|
|
2227
|
+
currentAnnotations.set(name, void 0);
|
|
2228
|
+
if (duplicateNames.has(name)) continue;
|
|
2229
|
+
duplicateNames.add(name);
|
|
2230
|
+
const baseline2 = this.baselines.get(name);
|
|
2231
|
+
const allDefinitions = entries.filter((e) => e.name === name).map((e) => e.definition);
|
|
2232
|
+
const changes2 = [
|
|
2233
|
+
{
|
|
2234
|
+
aspect: "duplicate",
|
|
2235
|
+
baseline: baseline2?.definition,
|
|
2236
|
+
current: allDefinitions
|
|
2237
|
+
}
|
|
2238
|
+
];
|
|
2239
|
+
const event2 = { toolName: name, changes: changes2 };
|
|
2240
|
+
const existing2 = this.driftedTools.get(name);
|
|
2241
|
+
const isNewDrift2 = !existing2 || canonicalize(existing2.changes) !== canonicalize(changes2);
|
|
2242
|
+
this.driftedTools.set(name, event2);
|
|
2243
|
+
if (isNewDrift2) drifted.push(event2);
|
|
2244
|
+
continue;
|
|
2245
|
+
}
|
|
2246
|
+
present.add(name);
|
|
2247
|
+
const annotations = extractAnnotations(t);
|
|
2248
|
+
currentAnnotations.set(name, annotations);
|
|
2249
|
+
const definitionKey = canonicalize(t);
|
|
2250
|
+
const baseline = this.baselines.get(name);
|
|
2251
|
+
if (!baseline) {
|
|
2252
|
+
this.baselines.set(name, { definition: t, definitionKey, annotations });
|
|
2253
|
+
baselined.push(name);
|
|
2254
|
+
if (this.driftedTools.has(name)) {
|
|
2255
|
+
this.driftedTools.delete(name);
|
|
2256
|
+
reverted.push(name);
|
|
2257
|
+
}
|
|
2258
|
+
continue;
|
|
1833
2259
|
}
|
|
2260
|
+
if (definitionKey === baseline.definitionKey) {
|
|
2261
|
+
if (this.driftedTools.has(name)) {
|
|
2262
|
+
this.driftedTools.delete(name);
|
|
2263
|
+
reverted.push(name);
|
|
2264
|
+
}
|
|
2265
|
+
continue;
|
|
2266
|
+
}
|
|
2267
|
+
const changes = [];
|
|
2268
|
+
for (const field of ASPECT_FIELDS) {
|
|
2269
|
+
const baselineValue = baseline.definition[field];
|
|
2270
|
+
const currentValue = t[field];
|
|
2271
|
+
if (canonicalize(baselineValue) !== canonicalize(currentValue)) {
|
|
2272
|
+
changes.push({ aspect: field, baseline: baselineValue, current: currentValue });
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
if (changes.length === 0) {
|
|
2276
|
+
changes.push({ aspect: "other", baseline: baseline.definition, current: t });
|
|
2277
|
+
}
|
|
2278
|
+
const event = { toolName: name, changes };
|
|
2279
|
+
const existing = this.driftedTools.get(name);
|
|
2280
|
+
const isNewDrift = !existing || canonicalize(existing.changes) !== canonicalize(changes);
|
|
2281
|
+
this.driftedTools.set(name, event);
|
|
2282
|
+
if (isNewDrift) drifted.push(event);
|
|
1834
2283
|
}
|
|
1835
|
-
|
|
2284
|
+
this.present = present;
|
|
2285
|
+
this.currentAnnotations = currentAnnotations;
|
|
2286
|
+
return { updated: true, baselined, drifted, reverted };
|
|
1836
2287
|
}
|
|
1837
|
-
/**
|
|
2288
|
+
/**
|
|
2289
|
+
* Get the **baseline** annotations for a tool — the definition first seen,
|
|
2290
|
+
* not the latest upstream claim. Returns `undefined` if the tool has no
|
|
2291
|
+
* annotations or was never seen.
|
|
2292
|
+
*/
|
|
1838
2293
|
get(toolName) {
|
|
1839
|
-
return this.
|
|
2294
|
+
return this.baselines.get(toolName)?.annotations;
|
|
1840
2295
|
}
|
|
1841
|
-
/**
|
|
2296
|
+
/**
|
|
2297
|
+
* Get the annotations from the most recent tools/list. Used for the
|
|
2298
|
+
* stricter-of-both evaluation of drifted tools in on_tool_drift: log mode.
|
|
2299
|
+
* Returns `undefined` for tools absent from the latest list.
|
|
2300
|
+
*/
|
|
2301
|
+
getCurrent(toolName) {
|
|
2302
|
+
return this.currentAnnotations.get(toolName);
|
|
2303
|
+
}
|
|
2304
|
+
/** Whether the tool was present in the most recent tools/list. */
|
|
1842
2305
|
has(toolName) {
|
|
1843
|
-
return this.
|
|
2306
|
+
return this.present.has(toolName);
|
|
2307
|
+
}
|
|
2308
|
+
/** Whether the tool's current definition differs from its baseline. */
|
|
2309
|
+
isDrifted(toolName) {
|
|
2310
|
+
return this.driftedTools.has(toolName);
|
|
2311
|
+
}
|
|
2312
|
+
/** The active drift event for a tool, if any. */
|
|
2313
|
+
getDrift(toolName) {
|
|
2314
|
+
return this.driftedTools.get(toolName);
|
|
1844
2315
|
}
|
|
1845
2316
|
};
|
|
2317
|
+
function extractAnnotations(tool) {
|
|
2318
|
+
const annotations = tool["annotations"];
|
|
2319
|
+
return annotations && typeof annotations === "object" ? annotations : void 0;
|
|
2320
|
+
}
|
|
2321
|
+
function canonicalize(value) {
|
|
2322
|
+
const encoded = JSON.stringify(sortKeysDeep(value));
|
|
2323
|
+
return encoded ?? "";
|
|
2324
|
+
}
|
|
2325
|
+
function sortKeysDeep(value) {
|
|
2326
|
+
if (Array.isArray(value)) return value.map(sortKeysDeep);
|
|
2327
|
+
if (value !== null && typeof value === "object") {
|
|
2328
|
+
const source = value;
|
|
2329
|
+
const out = {};
|
|
2330
|
+
for (const key of Object.keys(source).sort()) {
|
|
2331
|
+
Object.defineProperty(out, key, {
|
|
2332
|
+
value: sortKeysDeep(source[key]),
|
|
2333
|
+
enumerable: true,
|
|
2334
|
+
writable: true,
|
|
2335
|
+
configurable: true
|
|
2336
|
+
});
|
|
2337
|
+
}
|
|
2338
|
+
return out;
|
|
2339
|
+
}
|
|
2340
|
+
return value;
|
|
2341
|
+
}
|
|
1846
2342
|
function extractTools(body) {
|
|
1847
2343
|
if (typeof body !== "object" || body === null) return null;
|
|
1848
2344
|
const b = body;
|
|
@@ -2044,6 +2540,19 @@ function buildRateLimitedFeedback(decision, result) {
|
|
|
2044
2540
|
retry_allowed: true
|
|
2045
2541
|
};
|
|
2046
2542
|
}
|
|
2543
|
+
function buildToolDriftFeedback(drift, action) {
|
|
2544
|
+
const aspects = drift.changes.map((change) => change.aspect);
|
|
2545
|
+
return {
|
|
2546
|
+
blocked: true,
|
|
2547
|
+
reason: "tool_definition_drift",
|
|
2548
|
+
rule: null,
|
|
2549
|
+
ruleIndex: null,
|
|
2550
|
+
action,
|
|
2551
|
+
drifted_aspects: aspects,
|
|
2552
|
+
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.`,
|
|
2553
|
+
retry_allowed: false
|
|
2554
|
+
};
|
|
2555
|
+
}
|
|
2047
2556
|
function buildSpendLimitedFeedback(decision, result, currency) {
|
|
2048
2557
|
const { rule, ruleIndex } = ruleInfo(decision.matchedRule);
|
|
2049
2558
|
const windowSeconds = Math.round(result.windowMs / 1e3);
|
|
@@ -2158,6 +2667,11 @@ var GovernedForwarder = class {
|
|
|
2158
2667
|
*
|
|
2159
2668
|
* This path is intended for startup warm-up and intentionally bypasses policy
|
|
2160
2669
|
* and audit handling. Runtime tools/list requests still flow through forward().
|
|
2670
|
+
*
|
|
2671
|
+
* When the inner forwarder exposes `forwardInternal` (duck-typed), the prime
|
|
2672
|
+
* request is routed through it so session-enforcing servers (e.g. Streamable
|
|
2673
|
+
* HTTP upstreams) receive the request on the managed internal session rather
|
|
2674
|
+
* than as a sessionless call that they would reject with HTTP 400.
|
|
2161
2675
|
*/
|
|
2162
2676
|
async primeAnnotationCache() {
|
|
2163
2677
|
const syntheticToolsList = {
|
|
@@ -2165,14 +2679,22 @@ var GovernedForwarder = class {
|
|
|
2165
2679
|
id: "helio-prime-annotations",
|
|
2166
2680
|
method: "tools/list"
|
|
2167
2681
|
};
|
|
2682
|
+
const internal = this.inner;
|
|
2168
2683
|
try {
|
|
2169
|
-
const result = await this.inner.forward(syntheticToolsList);
|
|
2170
|
-
|
|
2171
|
-
if (!updated) {
|
|
2684
|
+
const result = typeof internal.forwardInternal === "function" ? await internal.forwardInternal(syntheticToolsList) : await this.inner.forward(syntheticToolsList);
|
|
2685
|
+
if (result.response.status >= 400) {
|
|
2172
2686
|
return {
|
|
2173
2687
|
success: false,
|
|
2174
2688
|
toolsCached: this.annotationCache.size,
|
|
2175
|
-
reason:
|
|
2689
|
+
reason: classifyPrimeFailure(result.response)
|
|
2690
|
+
};
|
|
2691
|
+
}
|
|
2692
|
+
const update = this.applyToolDefinitionUpdate(result.response.body, void 0);
|
|
2693
|
+
if (!update.updated) {
|
|
2694
|
+
return {
|
|
2695
|
+
success: false,
|
|
2696
|
+
toolsCached: this.annotationCache.size,
|
|
2697
|
+
reason: classifyPrimeFailure(result.response)
|
|
2176
2698
|
};
|
|
2177
2699
|
}
|
|
2178
2700
|
return { success: true, toolsCached: this.annotationCache.size };
|
|
@@ -2190,10 +2712,62 @@ var GovernedForwarder = class {
|
|
|
2190
2712
|
}
|
|
2191
2713
|
const result = await this.inner.forward(request);
|
|
2192
2714
|
if (request.method === "tools/list") {
|
|
2193
|
-
this.
|
|
2715
|
+
this.applyToolDefinitionUpdate(result.response.body, request.sessionId);
|
|
2194
2716
|
}
|
|
2195
2717
|
return result;
|
|
2196
2718
|
}
|
|
2719
|
+
/**
|
|
2720
|
+
* Apply a tools/list response to the definition cache and surface any
|
|
2721
|
+
* drift: console warning + immediate audit record per event. Single entry
|
|
2722
|
+
* point for both runtime tools/list responses and startup priming, so the
|
|
2723
|
+
* cache is updated exactly once per response.
|
|
2724
|
+
*/
|
|
2725
|
+
applyToolDefinitionUpdate(responseBody, sessionId) {
|
|
2726
|
+
const update = this.annotationCache.update(responseBody);
|
|
2727
|
+
if (!update.updated) return update;
|
|
2728
|
+
for (const drift of update.drifted) {
|
|
2729
|
+
const aspects = drift.changes.map((change) => change.aspect).join(", ");
|
|
2730
|
+
console.error(
|
|
2731
|
+
`[helio] Tool definition drift detected: "${drift.toolName}" changed (${aspects}) after baseline \u2014 calls governed by policies.on_tool_drift (${this.policy.onToolDrift ?? "block"})`
|
|
2732
|
+
);
|
|
2733
|
+
this.writeDriftAuditRecord(drift, sessionId, "tool_drift");
|
|
2734
|
+
}
|
|
2735
|
+
for (const toolName of update.reverted) {
|
|
2736
|
+
console.error(
|
|
2737
|
+
`[helio] Tool definition drift cleared: "${toolName}" returned to its baseline definition`
|
|
2738
|
+
);
|
|
2739
|
+
this.writeDriftAuditRecord({ toolName, changes: [] }, sessionId, "tool_drift_reverted");
|
|
2740
|
+
}
|
|
2741
|
+
return update;
|
|
2742
|
+
}
|
|
2743
|
+
/** Write an immediate audit record for a drift event (not a tool call). */
|
|
2744
|
+
writeDriftAuditRecord(drift, sessionId, decision) {
|
|
2745
|
+
if (!this.auditWriter) return;
|
|
2746
|
+
this.auditWriter.pushImmediate({
|
|
2747
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2748
|
+
session_id: sessionId ?? null,
|
|
2749
|
+
agent_id: null,
|
|
2750
|
+
environment: this.environment ?? null,
|
|
2751
|
+
tool_name: drift.toolName,
|
|
2752
|
+
tool_input: {},
|
|
2753
|
+
policy_decision: decision,
|
|
2754
|
+
block_reason: null,
|
|
2755
|
+
matched_rule: null,
|
|
2756
|
+
matched_rule_index: null,
|
|
2757
|
+
evidence_chain: decision === "tool_drift" ? { tool_drift: { changes: drift.changes } } : null,
|
|
2758
|
+
approval_status: null,
|
|
2759
|
+
approved_by: null,
|
|
2760
|
+
upstream_response: null,
|
|
2761
|
+
upstream_error: null,
|
|
2762
|
+
upstream_http_status: null,
|
|
2763
|
+
upstream_latency_ms: null,
|
|
2764
|
+
total_duration_ms: 0,
|
|
2765
|
+
approval_wait_ms: 0,
|
|
2766
|
+
proxy_compute_ms: 0,
|
|
2767
|
+
flagged_destructive: false,
|
|
2768
|
+
dry_run: false
|
|
2769
|
+
});
|
|
2770
|
+
}
|
|
2197
2771
|
async handleToolsCall(request) {
|
|
2198
2772
|
const startTime = performance.now();
|
|
2199
2773
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -2204,13 +2778,26 @@ var GovernedForwarder = class {
|
|
|
2204
2778
|
}
|
|
2205
2779
|
const toolArguments = params?.["arguments"] && typeof params["arguments"] === "object" ? params["arguments"] : void 0;
|
|
2206
2780
|
const annotations = this.annotationCache.get(toolName);
|
|
2781
|
+
const driftEvent = this.annotationCache.getDrift(toolName);
|
|
2782
|
+
const driftMode = this.policy.onToolDrift ?? "block";
|
|
2207
2783
|
let decision = evaluatePolicy(this.policy, {
|
|
2208
2784
|
toolName,
|
|
2209
2785
|
annotations,
|
|
2210
2786
|
toolArguments,
|
|
2211
2787
|
environment: this.environment
|
|
2212
2788
|
});
|
|
2213
|
-
|
|
2789
|
+
if (driftEvent && driftMode === "log") {
|
|
2790
|
+
const currentDecision = evaluatePolicy(this.policy, {
|
|
2791
|
+
toolName,
|
|
2792
|
+
annotations: this.annotationCache.getCurrent(toolName),
|
|
2793
|
+
toolArguments,
|
|
2794
|
+
environment: this.environment
|
|
2795
|
+
});
|
|
2796
|
+
decision = stricterDecision(decision, currentDecision);
|
|
2797
|
+
}
|
|
2798
|
+
const baselineDestructive = annotations?.destructiveHint ?? true;
|
|
2799
|
+
const currentDestructive = driftEvent && driftMode === "log" ? this.annotationCache.getCurrent(toolName)?.destructiveHint ?? true : false;
|
|
2800
|
+
const isDestructive = baselineDestructive || currentDestructive;
|
|
2214
2801
|
let flaggedDestructive = false;
|
|
2215
2802
|
if (isDestructive && !decision.matchedRule && this.policy.flagDestructive) {
|
|
2216
2803
|
flaggedDestructive = true;
|
|
@@ -2224,6 +2811,15 @@ var GovernedForwarder = class {
|
|
|
2224
2811
|
};
|
|
2225
2812
|
}
|
|
2226
2813
|
}
|
|
2814
|
+
let driftBlocked = false;
|
|
2815
|
+
if (driftEvent && driftMode !== "log") {
|
|
2816
|
+
driftBlocked = driftMode === "block";
|
|
2817
|
+
decision = {
|
|
2818
|
+
action: driftMode === "block" ? "deny" : "require_approval",
|
|
2819
|
+
matchedRule: void 0,
|
|
2820
|
+
reason: `Tool "${toolName}" definition drifted from baseline (${driftEvent.changes.map((change) => change.aspect).join(", ")})`
|
|
2821
|
+
};
|
|
2822
|
+
}
|
|
2227
2823
|
const originalAction = decision.action;
|
|
2228
2824
|
let evidenceResult;
|
|
2229
2825
|
let dependencyResult;
|
|
@@ -2287,6 +2883,8 @@ var GovernedForwarder = class {
|
|
|
2287
2883
|
result = this.makeSessionRequiredBlockResult(request, decision);
|
|
2288
2884
|
} else if (evidenceBlocked) {
|
|
2289
2885
|
result = this.makeEvidenceBlockResult(request, decision, evidenceResult, dependencyResult);
|
|
2886
|
+
} else if (driftBlocked && driftEvent) {
|
|
2887
|
+
result = this.makeDriftBlockResult(request, driftEvent);
|
|
2290
2888
|
} else if (decision.action === "allow") {
|
|
2291
2889
|
result = await this.inner.forward(request);
|
|
2292
2890
|
} else if (decision.action === "deny") {
|
|
@@ -2359,7 +2957,8 @@ var GovernedForwarder = class {
|
|
|
2359
2957
|
rateLimitResult,
|
|
2360
2958
|
spendLimitResult,
|
|
2361
2959
|
isDryRun,
|
|
2362
|
-
forwardingError
|
|
2960
|
+
forwardingError,
|
|
2961
|
+
driftEvent ? { event: driftEvent, mode: driftMode } : void 0
|
|
2363
2962
|
);
|
|
2364
2963
|
return result;
|
|
2365
2964
|
}
|
|
@@ -2576,7 +3175,7 @@ var GovernedForwarder = class {
|
|
|
2576
3175
|
wasForwardedUpstream(decision, approvalOutcome, rateLimitResult, spendLimitResult) {
|
|
2577
3176
|
return decision.action === "allow" || approvalOutcome?.status === "approved" || approvalOutcome?.status === "break_glass" || approvalOutcome?.status === "timeout" && this.approvalRouter?.defaultOnTimeout === "allow" || rateLimitResult?.allowed === true || spendLimitResult?.allowed === true;
|
|
2578
3177
|
}
|
|
2579
|
-
writeAuditRecord(request, timestamp, toolName, toolArguments, decision, result, totalDurationMs, approvalWaitMs, flaggedDestructive, evidenceResult, dependencyResult, evidenceBlocked, approvalOutcome, rateLimitResult, spendLimitResult, isDryRun, forwardingError) {
|
|
3178
|
+
writeAuditRecord(request, timestamp, toolName, toolArguments, decision, result, totalDurationMs, approvalWaitMs, flaggedDestructive, evidenceResult, dependencyResult, evidenceBlocked, approvalOutcome, rateLimitResult, spendLimitResult, isDryRun, forwardingError, drift) {
|
|
2580
3179
|
if (!this.auditWriter) return;
|
|
2581
3180
|
const wasForwarded = this.wasForwardedUpstream(
|
|
2582
3181
|
decision,
|
|
@@ -2636,6 +3235,15 @@ var GovernedForwarder = class {
|
|
|
2636
3235
|
}
|
|
2637
3236
|
};
|
|
2638
3237
|
}
|
|
3238
|
+
if (drift) {
|
|
3239
|
+
evidenceChain = {
|
|
3240
|
+
...evidenceChain ?? {},
|
|
3241
|
+
tool_drift: {
|
|
3242
|
+
mode: drift.mode,
|
|
3243
|
+
changes: drift.event.changes
|
|
3244
|
+
}
|
|
3245
|
+
};
|
|
3246
|
+
}
|
|
2639
3247
|
const blockReason = extractBlockReason(result);
|
|
2640
3248
|
const record = {
|
|
2641
3249
|
timestamp,
|
|
@@ -2668,6 +3276,15 @@ var GovernedForwarder = class {
|
|
|
2668
3276
|
this.auditWriter.push(record);
|
|
2669
3277
|
}
|
|
2670
3278
|
}
|
|
3279
|
+
makeDriftBlockResult(request, drift) {
|
|
3280
|
+
const feedback = buildToolDriftFeedback(drift, "deny");
|
|
3281
|
+
return makeErrorResult(
|
|
3282
|
+
request,
|
|
3283
|
+
POLICY_DENIED,
|
|
3284
|
+
`Tool definition drift: "${drift.toolName}" changed after baseline`,
|
|
3285
|
+
{ ...feedback }
|
|
3286
|
+
);
|
|
3287
|
+
}
|
|
2671
3288
|
makeDenyResult(request, decision) {
|
|
2672
3289
|
const feedback = buildPolicyDeniedFeedback(decision);
|
|
2673
3290
|
const message = decision.matchedRule?.feedback?.message ?? `Policy denied: ${decision.reason}`;
|
|
@@ -2756,6 +3373,17 @@ function collectAllowedEvidenceKeys(policy) {
|
|
|
2756
3373
|
}
|
|
2757
3374
|
return [...keys];
|
|
2758
3375
|
}
|
|
3376
|
+
var ACTION_SEVERITY = {
|
|
3377
|
+
deny: 5,
|
|
3378
|
+
require_approval: 4,
|
|
3379
|
+
dry_run: 3,
|
|
3380
|
+
spend_limit: 2,
|
|
3381
|
+
rate_limit: 1,
|
|
3382
|
+
allow: 0
|
|
3383
|
+
};
|
|
3384
|
+
function stricterDecision(a, b) {
|
|
3385
|
+
return ACTION_SEVERITY[b.action] > ACTION_SEVERITY[a.action] ? b : a;
|
|
3386
|
+
}
|
|
2759
3387
|
function makeErrorResult(request, code, message, data) {
|
|
2760
3388
|
const body = {
|
|
2761
3389
|
jsonrpc: "2.0",
|
|
@@ -2773,6 +3401,27 @@ function hasJsonRpcError(result) {
|
|
|
2773
3401
|
const body = result.response.body;
|
|
2774
3402
|
return body?.["error"] !== void 0;
|
|
2775
3403
|
}
|
|
3404
|
+
function classifyPrimeFailure(response) {
|
|
3405
|
+
if (response.status >= 400) {
|
|
3406
|
+
return `upstream returned HTTP ${String(response.status)} to tools/list (session/initialize may be required)`;
|
|
3407
|
+
}
|
|
3408
|
+
const rawBody = response.body;
|
|
3409
|
+
if (typeof rawBody !== "object" || rawBody === null) {
|
|
3410
|
+
return `upstream tools/list returned a non-JSON body (content-type ${response.headers["content-type"] ?? "unknown"})`;
|
|
3411
|
+
}
|
|
3412
|
+
const body = rawBody;
|
|
3413
|
+
const error = body["error"];
|
|
3414
|
+
if (typeof error === "string") {
|
|
3415
|
+
return `upstream tools/list returned a JSON-RPC error: ${error}`;
|
|
3416
|
+
}
|
|
3417
|
+
if (error !== null && typeof error === "object") {
|
|
3418
|
+
const message = error["message"];
|
|
3419
|
+
if (typeof message === "string") {
|
|
3420
|
+
return `upstream tools/list returned a JSON-RPC error: ${message}`;
|
|
3421
|
+
}
|
|
3422
|
+
}
|
|
3423
|
+
return "upstream tools/list response was missing result.tools";
|
|
3424
|
+
}
|
|
2776
3425
|
function extractBlockReason(result) {
|
|
2777
3426
|
const body = result.response.body;
|
|
2778
3427
|
const error = body?.["error"];
|
|
@@ -3706,6 +4355,7 @@ function clampInt(value, fallback, min, max) {
|
|
|
3706
4355
|
}
|
|
3707
4356
|
|
|
3708
4357
|
// src/audit/store.ts
|
|
4358
|
+
var DRIFT_EVENT_DECISIONS_SQL = "('tool_drift', 'tool_drift_reverted')";
|
|
3709
4359
|
var CREATE_TABLE_DDL = `
|
|
3710
4360
|
CREATE TABLE IF NOT EXISTS audit_records (
|
|
3711
4361
|
id TEXT PRIMARY KEY,
|
|
@@ -4009,7 +4659,7 @@ var AuditStore = class {
|
|
|
4009
4659
|
const totals = this.db.prepare(
|
|
4010
4660
|
`SELECT
|
|
4011
4661
|
COUNT(*) as total,
|
|
4012
|
-
COALESCE(SUM(CASE WHEN block_reason IS NULL THEN 1 ELSE 0 END), 0) as allowed_total,
|
|
4662
|
+
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,
|
|
4013
4663
|
COALESCE(SUM(CASE WHEN block_reason IS NOT NULL THEN 1 ELSE 0 END), 0) as blocked_total,
|
|
4014
4664
|
COALESCE(SUM(CASE WHEN dry_run = 1 THEN 1 ELSE 0 END), 0) as dry_run_total,
|
|
4015
4665
|
COALESCE(SUM(CASE WHEN dry_run = 0 THEN 1 ELSE 0 END), 0) as applied_total
|
|
@@ -4028,9 +4678,10 @@ var AuditStore = class {
|
|
|
4028
4678
|
GROUP BY block_reason
|
|
4029
4679
|
ORDER BY count DESC`
|
|
4030
4680
|
).all(...params);
|
|
4681
|
+
const toolsClause = clause ? `${clause} AND policy_decision NOT IN ${DRIFT_EVENT_DECISIONS_SQL}` : `WHERE policy_decision NOT IN ${DRIFT_EVENT_DECISIONS_SQL}`;
|
|
4031
4682
|
const top_tools = this.db.prepare(
|
|
4032
4683
|
`SELECT tool_name, COUNT(*) as count
|
|
4033
|
-
FROM audit_records ${
|
|
4684
|
+
FROM audit_records ${toolsClause}
|
|
4034
4685
|
GROUP BY tool_name
|
|
4035
4686
|
ORDER BY count DESC
|
|
4036
4687
|
LIMIT 10`
|
|
@@ -5725,6 +6376,7 @@ export {
|
|
|
5725
6376
|
SpendLimiter,
|
|
5726
6377
|
SseUpstreamForwarder,
|
|
5727
6378
|
StdioForwarder,
|
|
6379
|
+
StreamableHttpForwarder,
|
|
5728
6380
|
UpstreamForwarder,
|
|
5729
6381
|
VERSION,
|
|
5730
6382
|
WebhookChannel,
|