@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/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/merge-headers.ts
1152
- function mergeUpstreamHeaders(base, forwarded, staticHeaders) {
1153
- const out = {};
1154
- const apply = (headers) => {
1155
- for (const [name, value] of Object.entries(headers)) {
1156
- out[name.toLowerCase()] = value;
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
- apply(base);
1160
- apply(forwarded);
1161
- apply(staticHeaders);
1162
- return out;
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 UpstreamForwarder = class {
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 (request.sessionId) {
1185
- headers["mcp-session-id"] = request.sessionId;
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
- const durationMs = performance.now() - start;
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
- throw new Error(
1219
- "upstream returned text/event-stream; streamable-http passthrough is not supported in v0.1"
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
- cache = /* @__PURE__ */ new Map();
1805
- /** Number of tools currently cached. */
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.cache.size;
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
- this.cache.clear();
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
- const annotations = t["annotations"];
1829
- if (annotations && typeof annotations === "object") {
1830
- this.cache.set(name, annotations);
1831
- } else {
1832
- this.cache.set(name, void 0);
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
- return true;
2284
+ this.present = present;
2285
+ this.currentAnnotations = currentAnnotations;
2286
+ return { updated: true, baselined, drifted, reverted };
1836
2287
  }
1837
- /** Get cached annotations for a tool. Returns `undefined` if the tool is not in the cache. */
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.cache.get(toolName);
2294
+ return this.baselines.get(toolName)?.annotations;
1840
2295
  }
1841
- /** Check whether a tool exists in the cache (regardless of whether it has annotations). */
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.cache.has(toolName);
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
- const updated = this.annotationCache.update(result.response.body);
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: "upstream tools/list response had unexpected shape"
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.annotationCache.update(result.response.body);
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
- const isDestructive = annotations?.destructiveHint ?? true;
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 ${clause}
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,