@gethelio/proxy 0.3.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 };
@@ -2306,49 +2320,161 @@ function evaluatePolicy(policy, ctx) {
2306
2320
  }
2307
2321
 
2308
2322
  // src/policy/annotation-cache.ts
2323
+ var ASPECT_FIELDS = [
2324
+ "annotations",
2325
+ "inputSchema",
2326
+ "description",
2327
+ "outputSchema",
2328
+ "title"
2329
+ ];
2309
2330
  var ToolAnnotationCache = class {
2310
- cache = /* @__PURE__ */ new Map();
2311
- /** 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. */
2312
2336
  get size() {
2313
- return this.cache.size;
2337
+ return this.present.size;
2314
2338
  }
2315
- /**
2316
- * Update the cache from a tools/list JSON-RPC response body.
2317
- *
2318
- * Performs a full replacement — tools that existed in the previous cache
2319
- * but are absent from the new response are removed. This correctly handles
2320
- * tool list changes (additions, removals, annotation updates).
2321
- *
2322
- * @returns `true` if the response body was a valid tools/list response and
2323
- * the cache was updated, `false` if the body shape was unexpected.
2324
- */
2339
+ /** Diff a tools/list JSON-RPC response body against the baselines. */
2325
2340
  update(responseBody) {
2326
2341
  const tools = extractTools(responseBody);
2327
- if (!tools) return false;
2328
- 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();
2329
2350
  for (const tool of tools) {
2330
2351
  if (typeof tool !== "object" || tool === null) continue;
2331
2352
  const t = tool;
2332
2353
  const name = t["name"];
2333
2354
  if (typeof name !== "string") continue;
2334
- const annotations = t["annotations"];
2335
- if (annotations && typeof annotations === "object") {
2336
- this.cache.set(name, annotations);
2337
- } else {
2338
- 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;
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
+ }
2339
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);
2340
2419
  }
2341
- return true;
2420
+ this.present = present;
2421
+ this.currentAnnotations = currentAnnotations;
2422
+ return { updated: true, baselined, drifted, reverted };
2342
2423
  }
2343
- /** 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
+ */
2344
2429
  get(toolName) {
2345
- return this.cache.get(toolName);
2430
+ return this.baselines.get(toolName)?.annotations;
2431
+ }
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);
2346
2439
  }
2347
- /** Check whether a tool exists in the cache (regardless of whether it has annotations). */
2440
+ /** Whether the tool was present in the most recent tools/list. */
2348
2441
  has(toolName) {
2349
- 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);
2350
2451
  }
2351
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
+ }
2352
2478
  function extractTools(body) {
2353
2479
  if (typeof body !== "object" || body === null) return null;
2354
2480
  const b = body;
@@ -2550,6 +2676,19 @@ function buildRateLimitedFeedback(decision, result) {
2550
2676
  retry_allowed: true
2551
2677
  };
2552
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
+ }
2553
2692
  function buildSpendLimitedFeedback(decision, result, currency) {
2554
2693
  const { rule, ruleIndex } = ruleInfo(decision.matchedRule);
2555
2694
  const windowSeconds = Math.round(result.windowMs / 1e3);
@@ -2686,8 +2825,8 @@ var GovernedForwarder = class {
2686
2825
  reason: classifyPrimeFailure(result.response)
2687
2826
  };
2688
2827
  }
2689
- const updated = this.annotationCache.update(result.response.body);
2690
- if (!updated) {
2828
+ const update = this.applyToolDefinitionUpdate(result.response.body, void 0);
2829
+ if (!update.updated) {
2691
2830
  return {
2692
2831
  success: false,
2693
2832
  toolsCached: this.annotationCache.size,
@@ -2709,10 +2848,62 @@ var GovernedForwarder = class {
2709
2848
  }
2710
2849
  const result = await this.inner.forward(request);
2711
2850
  if (request.method === "tools/list") {
2712
- this.annotationCache.update(result.response.body);
2851
+ this.applyToolDefinitionUpdate(result.response.body, request.sessionId);
2713
2852
  }
2714
2853
  return result;
2715
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
+ }
2716
2907
  async handleToolsCall(request) {
2717
2908
  const startTime = performance.now();
2718
2909
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
@@ -2723,13 +2914,26 @@ var GovernedForwarder = class {
2723
2914
  }
2724
2915
  const toolArguments = params?.["arguments"] && typeof params["arguments"] === "object" ? params["arguments"] : void 0;
2725
2916
  const annotations = this.annotationCache.get(toolName);
2917
+ const driftEvent = this.annotationCache.getDrift(toolName);
2918
+ const driftMode = this.policy.onToolDrift ?? "block";
2726
2919
  let decision = evaluatePolicy(this.policy, {
2727
2920
  toolName,
2728
2921
  annotations,
2729
2922
  toolArguments,
2730
2923
  environment: this.environment
2731
2924
  });
2732
- 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;
2733
2937
  let flaggedDestructive = false;
2734
2938
  if (isDestructive && !decision.matchedRule && this.policy.flagDestructive) {
2735
2939
  flaggedDestructive = true;
@@ -2743,6 +2947,15 @@ var GovernedForwarder = class {
2743
2947
  };
2744
2948
  }
2745
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
+ }
2746
2959
  const originalAction = decision.action;
2747
2960
  let evidenceResult;
2748
2961
  let dependencyResult;
@@ -2806,6 +3019,8 @@ var GovernedForwarder = class {
2806
3019
  result = this.makeSessionRequiredBlockResult(request, decision);
2807
3020
  } else if (evidenceBlocked) {
2808
3021
  result = this.makeEvidenceBlockResult(request, decision, evidenceResult, dependencyResult);
3022
+ } else if (driftBlocked && driftEvent) {
3023
+ result = this.makeDriftBlockResult(request, driftEvent);
2809
3024
  } else if (decision.action === "allow") {
2810
3025
  result = await this.inner.forward(request);
2811
3026
  } else if (decision.action === "deny") {
@@ -2878,7 +3093,8 @@ var GovernedForwarder = class {
2878
3093
  rateLimitResult,
2879
3094
  spendLimitResult,
2880
3095
  isDryRun,
2881
- forwardingError
3096
+ forwardingError,
3097
+ driftEvent ? { event: driftEvent, mode: driftMode } : void 0
2882
3098
  );
2883
3099
  return result;
2884
3100
  }
@@ -3095,7 +3311,7 @@ var GovernedForwarder = class {
3095
3311
  wasForwardedUpstream(decision, approvalOutcome, rateLimitResult, spendLimitResult) {
3096
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;
3097
3313
  }
3098
- 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) {
3099
3315
  if (!this.auditWriter) return;
3100
3316
  const wasForwarded = this.wasForwardedUpstream(
3101
3317
  decision,
@@ -3155,6 +3371,15 @@ var GovernedForwarder = class {
3155
3371
  }
3156
3372
  };
3157
3373
  }
3374
+ if (drift) {
3375
+ evidenceChain = {
3376
+ ...evidenceChain ?? {},
3377
+ tool_drift: {
3378
+ mode: drift.mode,
3379
+ changes: drift.event.changes
3380
+ }
3381
+ };
3382
+ }
3158
3383
  const blockReason = extractBlockReason(result);
3159
3384
  const record = {
3160
3385
  timestamp,
@@ -3187,6 +3412,15 @@ var GovernedForwarder = class {
3187
3412
  this.auditWriter.push(record);
3188
3413
  }
3189
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
+ }
3190
3424
  makeDenyResult(request, decision) {
3191
3425
  const feedback = buildPolicyDeniedFeedback(decision);
3192
3426
  const message = decision.matchedRule?.feedback?.message ?? `Policy denied: ${decision.reason}`;
@@ -3275,6 +3509,17 @@ function collectAllowedEvidenceKeys(policy) {
3275
3509
  }
3276
3510
  return [...keys];
3277
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
+ }
3278
3523
  function makeErrorResult(request, code, message, data) {
3279
3524
  const body = {
3280
3525
  jsonrpc: "2.0",
@@ -3840,6 +4085,7 @@ function clampInt(value, fallback, min, max) {
3840
4085
  }
3841
4086
 
3842
4087
  // src/audit/store.ts
4088
+ var DRIFT_EVENT_DECISIONS_SQL = "('tool_drift', 'tool_drift_reverted')";
3843
4089
  var CREATE_TABLE_DDL = `
3844
4090
  CREATE TABLE IF NOT EXISTS audit_records (
3845
4091
  id TEXT PRIMARY KEY,
@@ -4143,7 +4389,7 @@ var AuditStore = class {
4143
4389
  const totals = this.db.prepare(
4144
4390
  `SELECT
4145
4391
  COUNT(*) as total,
4146
- 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,
4147
4393
  COALESCE(SUM(CASE WHEN block_reason IS NOT NULL THEN 1 ELSE 0 END), 0) as blocked_total,
4148
4394
  COALESCE(SUM(CASE WHEN dry_run = 1 THEN 1 ELSE 0 END), 0) as dry_run_total,
4149
4395
  COALESCE(SUM(CASE WHEN dry_run = 0 THEN 1 ELSE 0 END), 0) as applied_total
@@ -4162,9 +4408,10 @@ var AuditStore = class {
4162
4408
  GROUP BY block_reason
4163
4409
  ORDER BY count DESC`
4164
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}`;
4165
4412
  const top_tools = this.db.prepare(
4166
4413
  `SELECT tool_name, COUNT(*) as count
4167
- FROM audit_records ${clause}
4414
+ FROM audit_records ${toolsClause}
4168
4415
  GROUP BY tool_name
4169
4416
  ORDER BY count DESC
4170
4417
  LIMIT 10`
@@ -6435,7 +6682,9 @@ async function startAnnotationPrimeLoop(governedForwarder) {
6435
6682
  primed = true;
6436
6683
  clearRetryTimer();
6437
6684
  const prefix = phase === "initial" ? "[helio] Annotation cache primed" : `[helio] Annotation cache primed after retry ${String(retryAttempt)}`;
6438
- 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
+ );
6439
6688
  return;
6440
6689
  }
6441
6690
  const reason = result.reason ?? "unknown reason";
package/dist/index.d.ts CHANGED
@@ -81,6 +81,11 @@ declare const policiesSchema: z.ZodObject<{
81
81
  suggestion: z.ZodOptional<z.ZodString>;
82
82
  }, z.core.$strict>>;
83
83
  }, z.core.$strict>>>;
84
+ on_tool_drift: z.ZodOptional<z.ZodEnum<{
85
+ require_approval: "require_approval";
86
+ log: "log";
87
+ block: "block";
88
+ }>>;
84
89
  hot_reload: z.ZodOptional<z.ZodBoolean>;
85
90
  }, z.core.$strict>;
86
91
  declare const approvalChannelSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
@@ -204,6 +209,11 @@ declare const helioConfigSchema: z.ZodObject<{
204
209
  suggestion: z.ZodOptional<z.ZodString>;
205
210
  }, z.core.$strict>>;
206
211
  }, z.core.$strict>>>;
212
+ on_tool_drift: z.ZodOptional<z.ZodEnum<{
213
+ require_approval: "require_approval";
214
+ log: "log";
215
+ block: "block";
216
+ }>>;
207
217
  hot_reload: z.ZodOptional<z.ZodBoolean>;
208
218
  }, z.core.$strict>>;
209
219
  approval: z.ZodPrefault<z.ZodObject<{
@@ -366,6 +376,11 @@ interface CompiledPolicy {
366
376
  readonly defaultAction: 'allow' | 'deny';
367
377
  readonly flagDestructive?: 'log' | 'require_approval';
368
378
  readonly dryRun?: boolean;
379
+ /**
380
+ * Response to tool definition drift (issue #25). Undefined means "block"
381
+ * at the use site — conservative by default.
382
+ */
383
+ readonly onToolDrift?: 'block' | 'require_approval' | 'log';
369
384
  readonly rules: readonly CompiledPolicyRule[];
370
385
  }
371
386
  /** A non-fatal warning produced during policy compilation. */
@@ -1617,6 +1632,15 @@ declare class GovernedForwarder implements McpForwarder {
1617
1632
  */
1618
1633
  primeAnnotationCache(): Promise<AnnotationCachePrimeResult>;
1619
1634
  forward(request: McpRequest): Promise<ForwardResult>;
1635
+ /**
1636
+ * Apply a tools/list response to the definition cache and surface any
1637
+ * drift: console warning + immediate audit record per event. Single entry
1638
+ * point for both runtime tools/list responses and startup priming, so the
1639
+ * cache is updated exactly once per response.
1640
+ */
1641
+ private applyToolDefinitionUpdate;
1642
+ /** Write an immediate audit record for a drift event (not a tool call). */
1643
+ private writeDriftAuditRecord;
1620
1644
  private handleToolsCall;
1621
1645
  private handleApproval;
1622
1646
  private handleRateLimit;
@@ -1631,6 +1655,7 @@ declare class GovernedForwarder implements McpForwarder {
1631
1655
  /** Determine if the request was actually forwarded to the upstream MCP server. */
1632
1656
  private wasForwardedUpstream;
1633
1657
  private writeAuditRecord;
1658
+ private makeDriftBlockResult;
1634
1659
  private makeDenyResult;
1635
1660
  private makePolicyMisconfiguredResult;
1636
1661
  private makeUnsupportedResult;
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 };
@@ -2170,49 +2184,161 @@ function evaluatePolicy(policy, ctx) {
2170
2184
  }
2171
2185
 
2172
2186
  // src/policy/annotation-cache.ts
2187
+ var ASPECT_FIELDS = [
2188
+ "annotations",
2189
+ "inputSchema",
2190
+ "description",
2191
+ "outputSchema",
2192
+ "title"
2193
+ ];
2173
2194
  var ToolAnnotationCache = class {
2174
- cache = /* @__PURE__ */ new Map();
2175
- /** 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. */
2176
2200
  get size() {
2177
- return this.cache.size;
2201
+ return this.present.size;
2178
2202
  }
2179
- /**
2180
- * Update the cache from a tools/list JSON-RPC response body.
2181
- *
2182
- * Performs a full replacement — tools that existed in the previous cache
2183
- * but are absent from the new response are removed. This correctly handles
2184
- * tool list changes (additions, removals, annotation updates).
2185
- *
2186
- * @returns `true` if the response body was a valid tools/list response and
2187
- * the cache was updated, `false` if the body shape was unexpected.
2188
- */
2203
+ /** Diff a tools/list JSON-RPC response body against the baselines. */
2189
2204
  update(responseBody) {
2190
2205
  const tools = extractTools(responseBody);
2191
- if (!tools) return false;
2192
- 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();
2193
2214
  for (const tool of tools) {
2194
2215
  if (typeof tool !== "object" || tool === null) continue;
2195
2216
  const t = tool;
2196
2217
  const name = t["name"];
2197
2218
  if (typeof name !== "string") continue;
2198
- const annotations = t["annotations"];
2199
- if (annotations && typeof annotations === "object") {
2200
- this.cache.set(name, annotations);
2201
- } else {
2202
- 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;
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
+ }
2203
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);
2204
2283
  }
2205
- return true;
2284
+ this.present = present;
2285
+ this.currentAnnotations = currentAnnotations;
2286
+ return { updated: true, baselined, drifted, reverted };
2206
2287
  }
2207
- /** 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
+ */
2208
2293
  get(toolName) {
2209
- return this.cache.get(toolName);
2294
+ return this.baselines.get(toolName)?.annotations;
2210
2295
  }
2211
- /** 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. */
2212
2305
  has(toolName) {
2213
- 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);
2214
2315
  }
2215
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
+ }
2216
2342
  function extractTools(body) {
2217
2343
  if (typeof body !== "object" || body === null) return null;
2218
2344
  const b = body;
@@ -2414,6 +2540,19 @@ function buildRateLimitedFeedback(decision, result) {
2414
2540
  retry_allowed: true
2415
2541
  };
2416
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
+ }
2417
2556
  function buildSpendLimitedFeedback(decision, result, currency) {
2418
2557
  const { rule, ruleIndex } = ruleInfo(decision.matchedRule);
2419
2558
  const windowSeconds = Math.round(result.windowMs / 1e3);
@@ -2550,8 +2689,8 @@ var GovernedForwarder = class {
2550
2689
  reason: classifyPrimeFailure(result.response)
2551
2690
  };
2552
2691
  }
2553
- const updated = this.annotationCache.update(result.response.body);
2554
- if (!updated) {
2692
+ const update = this.applyToolDefinitionUpdate(result.response.body, void 0);
2693
+ if (!update.updated) {
2555
2694
  return {
2556
2695
  success: false,
2557
2696
  toolsCached: this.annotationCache.size,
@@ -2573,10 +2712,62 @@ var GovernedForwarder = class {
2573
2712
  }
2574
2713
  const result = await this.inner.forward(request);
2575
2714
  if (request.method === "tools/list") {
2576
- this.annotationCache.update(result.response.body);
2715
+ this.applyToolDefinitionUpdate(result.response.body, request.sessionId);
2577
2716
  }
2578
2717
  return result;
2579
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
+ }
2580
2771
  async handleToolsCall(request) {
2581
2772
  const startTime = performance.now();
2582
2773
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
@@ -2587,13 +2778,26 @@ var GovernedForwarder = class {
2587
2778
  }
2588
2779
  const toolArguments = params?.["arguments"] && typeof params["arguments"] === "object" ? params["arguments"] : void 0;
2589
2780
  const annotations = this.annotationCache.get(toolName);
2781
+ const driftEvent = this.annotationCache.getDrift(toolName);
2782
+ const driftMode = this.policy.onToolDrift ?? "block";
2590
2783
  let decision = evaluatePolicy(this.policy, {
2591
2784
  toolName,
2592
2785
  annotations,
2593
2786
  toolArguments,
2594
2787
  environment: this.environment
2595
2788
  });
2596
- 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;
2597
2801
  let flaggedDestructive = false;
2598
2802
  if (isDestructive && !decision.matchedRule && this.policy.flagDestructive) {
2599
2803
  flaggedDestructive = true;
@@ -2607,6 +2811,15 @@ var GovernedForwarder = class {
2607
2811
  };
2608
2812
  }
2609
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
+ }
2610
2823
  const originalAction = decision.action;
2611
2824
  let evidenceResult;
2612
2825
  let dependencyResult;
@@ -2670,6 +2883,8 @@ var GovernedForwarder = class {
2670
2883
  result = this.makeSessionRequiredBlockResult(request, decision);
2671
2884
  } else if (evidenceBlocked) {
2672
2885
  result = this.makeEvidenceBlockResult(request, decision, evidenceResult, dependencyResult);
2886
+ } else if (driftBlocked && driftEvent) {
2887
+ result = this.makeDriftBlockResult(request, driftEvent);
2673
2888
  } else if (decision.action === "allow") {
2674
2889
  result = await this.inner.forward(request);
2675
2890
  } else if (decision.action === "deny") {
@@ -2742,7 +2957,8 @@ var GovernedForwarder = class {
2742
2957
  rateLimitResult,
2743
2958
  spendLimitResult,
2744
2959
  isDryRun,
2745
- forwardingError
2960
+ forwardingError,
2961
+ driftEvent ? { event: driftEvent, mode: driftMode } : void 0
2746
2962
  );
2747
2963
  return result;
2748
2964
  }
@@ -2959,7 +3175,7 @@ var GovernedForwarder = class {
2959
3175
  wasForwardedUpstream(decision, approvalOutcome, rateLimitResult, spendLimitResult) {
2960
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;
2961
3177
  }
2962
- 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) {
2963
3179
  if (!this.auditWriter) return;
2964
3180
  const wasForwarded = this.wasForwardedUpstream(
2965
3181
  decision,
@@ -3019,6 +3235,15 @@ var GovernedForwarder = class {
3019
3235
  }
3020
3236
  };
3021
3237
  }
3238
+ if (drift) {
3239
+ evidenceChain = {
3240
+ ...evidenceChain ?? {},
3241
+ tool_drift: {
3242
+ mode: drift.mode,
3243
+ changes: drift.event.changes
3244
+ }
3245
+ };
3246
+ }
3022
3247
  const blockReason = extractBlockReason(result);
3023
3248
  const record = {
3024
3249
  timestamp,
@@ -3051,6 +3276,15 @@ var GovernedForwarder = class {
3051
3276
  this.auditWriter.push(record);
3052
3277
  }
3053
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
+ }
3054
3288
  makeDenyResult(request, decision) {
3055
3289
  const feedback = buildPolicyDeniedFeedback(decision);
3056
3290
  const message = decision.matchedRule?.feedback?.message ?? `Policy denied: ${decision.reason}`;
@@ -3139,6 +3373,17 @@ function collectAllowedEvidenceKeys(policy) {
3139
3373
  }
3140
3374
  return [...keys];
3141
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
+ }
3142
3387
  function makeErrorResult(request, code, message, data) {
3143
3388
  const body = {
3144
3389
  jsonrpc: "2.0",
@@ -4110,6 +4355,7 @@ function clampInt(value, fallback, min, max) {
4110
4355
  }
4111
4356
 
4112
4357
  // src/audit/store.ts
4358
+ var DRIFT_EVENT_DECISIONS_SQL = "('tool_drift', 'tool_drift_reverted')";
4113
4359
  var CREATE_TABLE_DDL = `
4114
4360
  CREATE TABLE IF NOT EXISTS audit_records (
4115
4361
  id TEXT PRIMARY KEY,
@@ -4413,7 +4659,7 @@ var AuditStore = class {
4413
4659
  const totals = this.db.prepare(
4414
4660
  `SELECT
4415
4661
  COUNT(*) as total,
4416
- 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,
4417
4663
  COALESCE(SUM(CASE WHEN block_reason IS NOT NULL THEN 1 ELSE 0 END), 0) as blocked_total,
4418
4664
  COALESCE(SUM(CASE WHEN dry_run = 1 THEN 1 ELSE 0 END), 0) as dry_run_total,
4419
4665
  COALESCE(SUM(CASE WHEN dry_run = 0 THEN 1 ELSE 0 END), 0) as applied_total
@@ -4432,9 +4678,10 @@ var AuditStore = class {
4432
4678
  GROUP BY block_reason
4433
4679
  ORDER BY count DESC`
4434
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}`;
4435
4682
  const top_tools = this.db.prepare(
4436
4683
  `SELECT tool_name, COUNT(*) as count
4437
- FROM audit_records ${clause}
4684
+ FROM audit_records ${toolsClause}
4438
4685
  GROUP BY tool_name
4439
4686
  ORDER BY count DESC
4440
4687
  LIMIT 10`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethelio/proxy",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Open-source MCP governance proxy for AI agents",
6
6
  "license": "Apache-2.0",