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