@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 +285 -36
- package/dist/index.d.ts +25 -0
- package/dist/index.js +282 -35
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -171,6 +171,19 @@ var policiesSchema = z.object({
|
|
|
171
171
|
flag_destructive: z.enum(["log", "require_approval"]).optional(),
|
|
172
172
|
dry_run: z.boolean().default(false),
|
|
173
173
|
rules: z.array(policyRuleSchema).default([]),
|
|
174
|
+
/**
|
|
175
|
+
* How to treat calls to a tool whose definition (annotations, schemas,
|
|
176
|
+
* description) has drifted from the baseline Helio captured on first
|
|
177
|
+
* sight.
|
|
178
|
+
* - "block": deny the call until the proxy is restarted (re-baselines)
|
|
179
|
+
* or the upstream reverts. Conservative default when omitted.
|
|
180
|
+
* - "require_approval": escalate the call through the approval channel.
|
|
181
|
+
* - "log": audit the drift; rules evaluate against both baseline and
|
|
182
|
+
* current annotations and the stricter decision wins.
|
|
183
|
+
* Kept optional (like hot_reload) so PoliciesConfig literal fixtures
|
|
184
|
+
* don't need the field; undefined is treated as "block".
|
|
185
|
+
*/
|
|
186
|
+
on_tool_drift: z.enum(["block", "require_approval", "log"]).optional(),
|
|
174
187
|
/**
|
|
175
188
|
* Whether `helio start` should watch the config file for changes and
|
|
176
189
|
* reconcile policy state on every save. Defaults to `true` when omitted.
|
|
@@ -233,14 +246,14 @@ var helioConfigBaseSchema = z.object({
|
|
|
233
246
|
});
|
|
234
247
|
var helioConfigSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
|
|
235
248
|
const hasConfiguredEnvironment = typeof cfg.environment === "string" && cfg.environment.trim().length > 0;
|
|
236
|
-
const requiresSecret = cfg.policies.flag_destructive === "require_approval" || cfg.policies.rules.some((rule) => rule.action === "require_approval");
|
|
249
|
+
const requiresSecret = cfg.policies.flag_destructive === "require_approval" || cfg.policies.on_tool_drift === "require_approval" || cfg.policies.rules.some((rule) => rule.action === "require_approval");
|
|
237
250
|
const hasSecret = hasDashboardApiSecret(cfg.dashboard.api_secret);
|
|
238
251
|
if (requiresSecret) {
|
|
239
252
|
if (!hasSecret) {
|
|
240
253
|
ctx.addIssue({
|
|
241
254
|
code: "custom",
|
|
242
255
|
path: ["dashboard", "api_secret"],
|
|
243
|
-
message: 'dashboard.api_secret is required when any rule uses require_approval or policies.flag_destructive is "require_approval". Generate one with: `openssl rand -hex 32` and set it under `dashboard.api_secret` in your helio.yaml. (See docs/approvals.md.)'
|
|
256
|
+
message: 'dashboard.api_secret is required when any rule uses require_approval or policies.flag_destructive or policies.on_tool_drift is "require_approval". Generate one with: `openssl rand -hex 32` and set it under `dashboard.api_secret` in your helio.yaml. (See docs/approvals.md.)'
|
|
244
257
|
});
|
|
245
258
|
}
|
|
246
259
|
}
|
|
@@ -459,6 +472,7 @@ function compilePolicies(config) {
|
|
|
459
472
|
defaultAction: config.default,
|
|
460
473
|
flagDestructive: config.flag_destructive,
|
|
461
474
|
...config.dry_run && { dryRun: true },
|
|
475
|
+
...config.on_tool_drift && { onToolDrift: config.on_tool_drift },
|
|
462
476
|
rules
|
|
463
477
|
};
|
|
464
478
|
return { policy, warnings };
|
|
@@ -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
|
-
|
|
2311
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
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
|
-
|
|
2420
|
+
this.present = present;
|
|
2421
|
+
this.currentAnnotations = currentAnnotations;
|
|
2422
|
+
return { updated: true, baselined, drifted, reverted };
|
|
2342
2423
|
}
|
|
2343
|
-
/**
|
|
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.
|
|
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
|
-
/**
|
|
2440
|
+
/** Whether the tool was present in the most recent tools/list. */
|
|
2348
2441
|
has(toolName) {
|
|
2349
|
-
return this.
|
|
2442
|
+
return this.present.has(toolName);
|
|
2443
|
+
}
|
|
2444
|
+
/** Whether the tool's current definition differs from its baseline. */
|
|
2445
|
+
isDrifted(toolName) {
|
|
2446
|
+
return this.driftedTools.has(toolName);
|
|
2447
|
+
}
|
|
2448
|
+
/** The active drift event for a tool, if any. */
|
|
2449
|
+
getDrift(toolName) {
|
|
2450
|
+
return this.driftedTools.get(toolName);
|
|
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
|
|
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.
|
|
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
|
-
|
|
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 ${
|
|
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(
|
|
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
|
-
|
|
2175
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
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
|
-
|
|
2284
|
+
this.present = present;
|
|
2285
|
+
this.currentAnnotations = currentAnnotations;
|
|
2286
|
+
return { updated: true, baselined, drifted, reverted };
|
|
2206
2287
|
}
|
|
2207
|
-
/**
|
|
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.
|
|
2294
|
+
return this.baselines.get(toolName)?.annotations;
|
|
2210
2295
|
}
|
|
2211
|
-
/**
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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 ${
|
|
4684
|
+
FROM audit_records ${toolsClause}
|
|
4438
4685
|
GROUP BY tool_name
|
|
4439
4686
|
ORDER BY count DESC
|
|
4440
4687
|
LIMIT 10`
|