@gethelio/proxy 0.3.0 → 0.5.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
@@ -115,11 +115,23 @@ var annotationsMatchSchema = z.object({
115
115
  idempotentHint: z.boolean().optional(),
116
116
  openWorldHint: z.boolean().optional()
117
117
  }).strict();
118
+ var metadataConditionSchema = z.union([
119
+ z.string(),
120
+ z.object({
121
+ eq: z.string().optional(),
122
+ neq: z.string().optional(),
123
+ contains: z.string().optional(),
124
+ regex: z.string().optional()
125
+ }).strict().refine((obj) => Object.keys(obj).length > 0, {
126
+ message: "At least one metadata condition operator is required"
127
+ })
128
+ ]);
118
129
  var matchSchema = z.object({
119
130
  tool: z.string().optional(),
120
131
  annotations: annotationsMatchSchema.optional(),
121
132
  input: z.record(z.string(), inputConditionSchema).optional(),
122
- environment: z.string().optional()
133
+ environment: z.string().optional(),
134
+ metadata: z.record(z.string(), metadataConditionSchema).optional()
123
135
  }).strict();
124
136
  var policyActionSchema = z.enum([
125
137
  "allow",
@@ -143,12 +155,12 @@ var spendLimitSchema = z.object({
143
155
  limit: z.number(),
144
156
  currency: z.string(),
145
157
  window: durationSchema,
146
- key: z.enum(["tool", "agent", "session"]).optional()
158
+ key: z.enum(["tool", "agent", "session", "sender_id"]).optional()
147
159
  }).strict();
148
160
  var limitsSchema = z.object({
149
161
  max_calls: z.number().int().positive().optional(),
150
162
  window: durationSchema.optional(),
151
- key: z.enum(["tool", "agent", "session"]).optional(),
163
+ key: z.enum(["tool", "agent", "session", "sender_id"]).optional(),
152
164
  max_spend: spendLimitSchema.optional()
153
165
  }).strict();
154
166
  var feedbackSchema = z.object({
@@ -166,11 +178,43 @@ var policyRuleSchema = z.object({
166
178
  limits: limitsSchema.optional(),
167
179
  feedback: feedbackSchema.optional()
168
180
  }).strict();
181
+ var installMatchSchema = z.object({
182
+ name: z.string().optional(),
183
+ // glob, picomatch (same engine as match.tool)
184
+ source: z.string().optional(),
185
+ // exact ecosystem match (npm | pip | …)
186
+ metadata: z.record(z.string(), metadataConditionSchema).optional()
187
+ }).strict();
188
+ var installRuleSchema = z.object({
189
+ name: z.string().optional(),
190
+ match: installMatchSchema,
191
+ action: z.enum(["deny_install", "allow"]),
192
+ feedback: feedbackSchema.optional()
193
+ }).strict();
194
+ var installSchema = z.object({
195
+ default: z.enum(["allow", "deny"]).default("allow"),
196
+ rules: z.array(installRuleSchema).default([])
197
+ }).strict();
169
198
  var policiesSchema = z.object({
170
199
  default: z.enum(["allow", "deny"]).default("allow"),
171
200
  flag_destructive: z.enum(["log", "require_approval"]).optional(),
172
201
  dry_run: z.boolean().default(false),
173
202
  rules: z.array(policyRuleSchema).default([]),
203
+ /** Install-time policy (issue #13 — deny_install). Optional; absent ⇒ observational. */
204
+ install: installSchema.optional(),
205
+ /**
206
+ * How to treat calls to a tool whose definition (annotations, schemas,
207
+ * description) has drifted from the baseline Helio captured on first
208
+ * sight.
209
+ * - "block": deny the call until the proxy is restarted (re-baselines)
210
+ * or the upstream reverts. Conservative default when omitted.
211
+ * - "require_approval": escalate the call through the approval channel.
212
+ * - "log": audit the drift; rules evaluate against both baseline and
213
+ * current annotations and the stricter decision wins.
214
+ * Kept optional (like hot_reload) so PoliciesConfig literal fixtures
215
+ * don't need the field; undefined is treated as "block".
216
+ */
217
+ on_tool_drift: z.enum(["block", "require_approval", "log"]).optional(),
174
218
  /**
175
219
  * Whether `helio start` should watch the config file for changes and
176
220
  * reconcile policy state on every save. Defaults to `true` when omitted.
@@ -218,7 +262,14 @@ var auditSchema = z.object({
218
262
  var sdkSchema = z.object({
219
263
  enabled: z.boolean().default(false),
220
264
  port: z.number().int().min(1).max(65535).default(3200),
221
- host: z.string().default("127.0.0.1")
265
+ host: z.string().default("127.0.0.1"),
266
+ /**
267
+ * How long a sideband `/evaluate` decision waits for its `/audit` before the
268
+ * proxy finalizes it as `evaluation_expired` (issue #12, D4). Bounds the
269
+ * pending-evaluation registry; an adapter crash cannot silently drop a
270
+ * decided-allowed call from the trail.
271
+ */
272
+ evaluation_ttl: durationSchema.default("10m")
222
273
  });
223
274
  var helioConfigBaseSchema = z.object({
224
275
  version: z.literal("1"),
@@ -233,14 +284,14 @@ var helioConfigBaseSchema = z.object({
233
284
  });
234
285
  var helioConfigSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
235
286
  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");
287
+ 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
288
  const hasSecret = hasDashboardApiSecret(cfg.dashboard.api_secret);
238
289
  if (requiresSecret) {
239
290
  if (!hasSecret) {
240
291
  ctx.addIssue({
241
292
  code: "custom",
242
293
  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.)'
294
+ 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
295
  });
245
296
  }
246
297
  }
@@ -281,6 +332,22 @@ var helioConfigSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
281
332
  message: `Rule sets match.environment="${rule.match.environment}" but top-level \`environment\` is not configured. Set top-level environment to enable env-scoped rules.`
282
333
  });
283
334
  }
335
+ if (!cfg.sdk.enabled) {
336
+ if (rule.limits?.key === "sender_id") {
337
+ ctx.addIssue({
338
+ code: "custom",
339
+ path: ["policies", "rules", ruleIndex, "limits", "key"],
340
+ message: 'limits.key "sender_id" requires the SDK sideband (sdk.enabled: true) \u2014 sender_id is supplied by hook adapters and is absent on the MCP path.'
341
+ });
342
+ }
343
+ if (rule.limits?.max_spend?.key === "sender_id") {
344
+ ctx.addIssue({
345
+ code: "custom",
346
+ path: ["policies", "rules", ruleIndex, "limits", "max_spend", "key"],
347
+ message: 'limits.max_spend.key "sender_id" requires the SDK sideband (sdk.enabled: true) \u2014 sender_id is supplied by hook adapters and is absent on the MCP path.'
348
+ });
349
+ }
350
+ }
284
351
  if (rule.action === "rate_limit") {
285
352
  if (rule.limits?.max_calls === void 0) {
286
353
  ctx.addIssue({
@@ -452,6 +519,7 @@ var PolicyParseError = class extends Error {
452
519
 
453
520
  // src/policy/parser.ts
454
521
  var INPUT_OPERATORS = ["eq", "neq", "gt", "gte", "lt", "lte", "contains", "regex"];
522
+ var METADATA_OPERATORS = ["eq", "neq", "contains", "regex"];
455
523
  function compilePolicies(config) {
456
524
  const warnings = [];
457
525
  const rules = config.rules.map((rule, index) => compileRule(rule, index, warnings));
@@ -459,10 +527,37 @@ function compilePolicies(config) {
459
527
  defaultAction: config.default,
460
528
  flagDestructive: config.flag_destructive,
461
529
  ...config.dry_run && { dryRun: true },
462
- rules
530
+ ...config.on_tool_drift && { onToolDrift: config.on_tool_drift },
531
+ rules,
532
+ ...config.install && { install: compileInstallPolicy(config.install) }
463
533
  };
464
534
  return { policy, warnings };
465
535
  }
536
+ function compileInstallPolicy(install) {
537
+ return {
538
+ defaultAction: install.default,
539
+ rules: install.rules.map((rule, index) => {
540
+ const name = rule.match.name !== void 0 ? compileToolMatcher(rule.match.name, index, rule.name) : void 0;
541
+ const metadata = rule.match.metadata !== void 0 ? flattenMetadataConditions(rule.match.metadata, index, rule.name) : void 0;
542
+ return {
543
+ index,
544
+ ...rule.name !== void 0 && { name: rule.name },
545
+ match: {
546
+ ...name !== void 0 && { name },
547
+ ...rule.match.source !== void 0 && { source: rule.match.source },
548
+ ...metadata !== void 0 && { metadata }
549
+ },
550
+ action: rule.action,
551
+ ...rule.feedback !== void 0 && {
552
+ feedback: {
553
+ message: rule.feedback.message,
554
+ ...rule.feedback.suggestion !== void 0 && { suggestion: rule.feedback.suggestion }
555
+ }
556
+ }
557
+ };
558
+ })
559
+ };
560
+ }
466
561
  function compileRule(rule, index, warnings) {
467
562
  const match = compileMatch(rule.match, index, rule.name);
468
563
  const approval = compileApproval(rule.approval);
@@ -495,7 +590,10 @@ function compileMatch(match, ruleIndex, ruleName) {
495
590
  ...match.input !== void 0 && {
496
591
  input: flattenInputConditions(match.input, ruleIndex, ruleName)
497
592
  },
498
- ...match.environment !== void 0 && { environment: match.environment }
593
+ ...match.environment !== void 0 && { environment: match.environment },
594
+ ...match.metadata !== void 0 && {
595
+ metadata: flattenMetadataConditions(match.metadata, ruleIndex, ruleName)
596
+ }
499
597
  };
500
598
  }
501
599
  function compileToolMatcher(pattern, ruleIndex, ruleName) {
@@ -547,6 +645,43 @@ function flattenInputConditions(input, ruleIndex, ruleName) {
547
645
  }
548
646
  return conditions;
549
647
  }
648
+ function flattenMetadataConditions(metadata, ruleIndex, ruleName) {
649
+ const conditions = [];
650
+ for (const [key, raw] of Object.entries(metadata)) {
651
+ if (typeof raw === "string") {
652
+ conditions.push({ key, operator: "eq", value: raw });
653
+ continue;
654
+ }
655
+ for (const op of METADATA_OPERATORS) {
656
+ const value = raw[op];
657
+ if (value === void 0) continue;
658
+ if (op === "regex") {
659
+ if (!safeRegex(value)) {
660
+ throw new PolicyParseError(
661
+ `catastrophic regex "${value}" for metadata key "${key}": pattern is vulnerable to ReDoS and has been rejected. Rewrite with bounded quantifiers (e.g. {1,100}) or split into simpler rules.`,
662
+ ruleIndex,
663
+ ruleName
664
+ );
665
+ }
666
+ let compiledRegex;
667
+ try {
668
+ compiledRegex = new RegExp(value);
669
+ } catch (err) {
670
+ const msg = err instanceof Error ? err.message : String(err);
671
+ throw new PolicyParseError(
672
+ `invalid regex "${value}" for metadata key "${key}": ${msg}`,
673
+ ruleIndex,
674
+ ruleName
675
+ );
676
+ }
677
+ conditions.push({ key, operator: op, value, regex: compiledRegex });
678
+ } else {
679
+ conditions.push({ key, operator: op, value });
680
+ }
681
+ }
682
+ }
683
+ return conditions;
684
+ }
550
685
  function compileApproval(approval) {
551
686
  if (!approval) return void 0;
552
687
  return {
@@ -2277,12 +2412,31 @@ function matchEnvironment(required, ctx) {
2277
2412
  if (ctx.environment === void 0) return false;
2278
2413
  return ctx.environment === required;
2279
2414
  }
2415
+ function matchMetadata(conditions, ctx) {
2416
+ if (conditions.length === 0) return true;
2417
+ if (ctx.metadata === void 0) return false;
2418
+ for (const condition of conditions) {
2419
+ const value = ctx.metadata[condition.key];
2420
+ const matched = evaluateCondition(
2421
+ {
2422
+ path: condition.key,
2423
+ operator: condition.operator,
2424
+ value: condition.value,
2425
+ regex: condition.regex
2426
+ },
2427
+ value
2428
+ );
2429
+ if (!matched) return false;
2430
+ }
2431
+ return true;
2432
+ }
2280
2433
  function matchRule(rule, ctx) {
2281
2434
  const { match } = rule;
2282
2435
  if (match.tool !== void 0 && !matchTool(match.tool, ctx)) return false;
2283
2436
  if (match.annotations !== void 0 && !matchAnnotations(match.annotations, ctx)) return false;
2284
2437
  if (match.input !== void 0 && !matchInput(match.input, ctx)) return false;
2285
2438
  if (match.environment !== void 0 && !matchEnvironment(match.environment, ctx)) return false;
2439
+ if (match.metadata !== void 0 && !matchMetadata(match.metadata, ctx)) return false;
2286
2440
  return true;
2287
2441
  }
2288
2442
 
@@ -2305,61 +2459,6 @@ function evaluatePolicy(policy, ctx) {
2305
2459
  };
2306
2460
  }
2307
2461
 
2308
- // src/policy/annotation-cache.ts
2309
- var ToolAnnotationCache = class {
2310
- cache = /* @__PURE__ */ new Map();
2311
- /** Number of tools currently cached. */
2312
- get size() {
2313
- return this.cache.size;
2314
- }
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
- */
2325
- update(responseBody) {
2326
- const tools = extractTools(responseBody);
2327
- if (!tools) return false;
2328
- this.cache.clear();
2329
- for (const tool of tools) {
2330
- if (typeof tool !== "object" || tool === null) continue;
2331
- const t = tool;
2332
- const name = t["name"];
2333
- 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);
2339
- }
2340
- }
2341
- return true;
2342
- }
2343
- /** Get cached annotations for a tool. Returns `undefined` if the tool is not in the cache. */
2344
- get(toolName) {
2345
- return this.cache.get(toolName);
2346
- }
2347
- /** Check whether a tool exists in the cache (regardless of whether it has annotations). */
2348
- has(toolName) {
2349
- return this.cache.has(toolName);
2350
- }
2351
- };
2352
- function extractTools(body) {
2353
- if (typeof body !== "object" || body === null) return null;
2354
- const b = body;
2355
- const result = b["result"];
2356
- if (typeof result !== "object" || result === null) return null;
2357
- const r = result;
2358
- const tools = r["tools"];
2359
- if (!Array.isArray(tools)) return null;
2360
- return tools;
2361
- }
2362
-
2363
2462
  // src/evidence/grounding.ts
2364
2463
  function checkEvidence(store, sessionId, requirements) {
2365
2464
  if (requirements.length === 0) {
@@ -2403,6 +2502,368 @@ function checkDependencies(store, sessionId, requirements, options = {}) {
2403
2502
  };
2404
2503
  }
2405
2504
 
2505
+ // src/policy/decision-pipeline.ts
2506
+ function decide(input) {
2507
+ const { toolName, toolArguments, sessionId, policy, environment, evidenceStore } = input;
2508
+ const annotations = input.baselineAnnotations;
2509
+ const driftEvent = input.driftEvent;
2510
+ const driftMode = policy.onToolDrift ?? "block";
2511
+ const metadata = buildMetadataView(input.metadata, input.agentId);
2512
+ let decision = evaluatePolicy(policy, {
2513
+ toolName,
2514
+ annotations,
2515
+ toolArguments,
2516
+ environment,
2517
+ metadata
2518
+ });
2519
+ if (driftEvent && driftMode === "log") {
2520
+ const currentDecision = evaluatePolicy(policy, {
2521
+ toolName,
2522
+ annotations: input.currentAnnotations,
2523
+ toolArguments,
2524
+ environment,
2525
+ metadata
2526
+ });
2527
+ decision = stricterDecision(decision, currentDecision);
2528
+ }
2529
+ const baselineDestructive = annotations?.destructiveHint ?? true;
2530
+ const currentDestructive = driftEvent && driftMode === "log" ? input.currentAnnotations?.destructiveHint ?? true : false;
2531
+ const isDestructive = baselineDestructive || currentDestructive;
2532
+ let flaggedDestructive = false;
2533
+ if (isDestructive && !decision.matchedRule && policy.flagDestructive) {
2534
+ flaggedDestructive = true;
2535
+ if (policy.flagDestructive === "log") {
2536
+ console.error(`[helio] Destructive tool detected: ${toolName} (no matching rule)`);
2537
+ } else {
2538
+ decision = {
2539
+ action: "require_approval",
2540
+ matchedRule: void 0,
2541
+ reason: `Destructive tool "${toolName}" auto-escalated by flag_destructive policy`
2542
+ };
2543
+ }
2544
+ }
2545
+ let driftBlocked = false;
2546
+ if (driftEvent && driftMode !== "log") {
2547
+ driftBlocked = driftMode === "block";
2548
+ decision = {
2549
+ action: driftMode === "block" ? "deny" : "require_approval",
2550
+ matchedRule: void 0,
2551
+ reason: `Tool "${toolName}" definition drifted from baseline (${driftEvent.changes.map((change) => change.aspect).join(", ")})`
2552
+ };
2553
+ }
2554
+ const originalAction = decision.action;
2555
+ let evidenceResult;
2556
+ let dependencyResult;
2557
+ let evidenceBlocked = false;
2558
+ let sessionBlocked = false;
2559
+ const requiresGroundedSession = decision.action !== "deny" && !!decision.matchedRule && ((decision.matchedRule.evidence?.requires.length ?? 0) > 0 || (decision.matchedRule.requires?.length ?? 0) > 0);
2560
+ if (requiresGroundedSession && !sessionId) {
2561
+ sessionBlocked = true;
2562
+ evidenceBlocked = true;
2563
+ decision = {
2564
+ action: "deny",
2565
+ matchedRule: decision.matchedRule,
2566
+ reason: "Mcp-Session-Id is required for evidence/dependency-gated policy rules"
2567
+ };
2568
+ }
2569
+ if (decision.action !== "deny" && evidenceStore && sessionId && decision.matchedRule) {
2570
+ const rule = decision.matchedRule;
2571
+ if (rule.evidence?.requires.length) {
2572
+ evidenceResult = checkEvidence(evidenceStore, sessionId, rule.evidence.requires);
2573
+ if (!evidenceResult.satisfied) {
2574
+ evidenceBlocked = true;
2575
+ const problemKeys = [...evidenceResult.missing, ...evidenceResult.expired];
2576
+ decision = {
2577
+ action: "deny",
2578
+ matchedRule: rule,
2579
+ reason: `Required evidence not satisfied: ${problemKeys.join(", ")}`
2580
+ };
2581
+ }
2582
+ }
2583
+ if (!evidenceBlocked && rule.requires?.length) {
2584
+ dependencyResult = checkDependencies(evidenceStore, sessionId, rule.requires, {
2585
+ requireSuccess: rule.requiresSuccess ?? true
2586
+ });
2587
+ if (!dependencyResult.satisfied) {
2588
+ evidenceBlocked = true;
2589
+ decision = {
2590
+ action: "deny",
2591
+ matchedRule: rule,
2592
+ reason: `Required tool calls not completed: ${dependencyResult.missing.join(", ")}`
2593
+ };
2594
+ }
2595
+ }
2596
+ }
2597
+ const isPerRuleDryRun = originalAction === "dry_run";
2598
+ const isGlobalDryRun = policy.dryRun === true;
2599
+ const isDryRun = (isPerRuleDryRun || isGlobalDryRun) && !sessionBlocked;
2600
+ return {
2601
+ decision,
2602
+ originalAction,
2603
+ driftEvent,
2604
+ driftMode,
2605
+ driftBlocked,
2606
+ flaggedDestructive,
2607
+ evidenceResult,
2608
+ dependencyResult,
2609
+ evidenceBlocked,
2610
+ sessionBlocked,
2611
+ isDryRun
2612
+ };
2613
+ }
2614
+ var ACTION_SEVERITY = {
2615
+ deny: 5,
2616
+ require_approval: 4,
2617
+ dry_run: 3,
2618
+ spend_limit: 2,
2619
+ rate_limit: 1,
2620
+ allow: 0
2621
+ };
2622
+ function stricterDecision(a, b) {
2623
+ return ACTION_SEVERITY[b.action] > ACTION_SEVERITY[a.action] ? b : a;
2624
+ }
2625
+ function buildMetadataView(metadata, agentId) {
2626
+ if (agentId === void 0) return metadata;
2627
+ return { ...metadata ?? {}, agent_id: agentId };
2628
+ }
2629
+
2630
+ // src/util/canonical-json.ts
2631
+ function canonicalize(value) {
2632
+ const encoded = JSON.stringify(sortKeysDeep(value));
2633
+ return encoded ?? "";
2634
+ }
2635
+ function sortKeysDeep(value) {
2636
+ if (Array.isArray(value)) return value.map(sortKeysDeep);
2637
+ if (value !== null && typeof value === "object") {
2638
+ const source = value;
2639
+ const out = {};
2640
+ for (const key of Object.keys(source).sort()) {
2641
+ Object.defineProperty(out, key, {
2642
+ value: sortKeysDeep(source[key]),
2643
+ enumerable: true,
2644
+ writable: true,
2645
+ configurable: true
2646
+ });
2647
+ }
2648
+ return out;
2649
+ }
2650
+ return value;
2651
+ }
2652
+
2653
+ // src/policy/annotation-cache.ts
2654
+ var ASPECT_FIELDS = [
2655
+ "annotations",
2656
+ "inputSchema",
2657
+ "description",
2658
+ "outputSchema",
2659
+ "title"
2660
+ ];
2661
+ var ToolAnnotationCache = class {
2662
+ baselines = /* @__PURE__ */ new Map();
2663
+ present = /* @__PURE__ */ new Set();
2664
+ currentAnnotations = /* @__PURE__ */ new Map();
2665
+ driftedTools = /* @__PURE__ */ new Map();
2666
+ /** Number of tools present in the most recent tools/list. */
2667
+ get size() {
2668
+ return this.present.size;
2669
+ }
2670
+ /** Diff a tools/list JSON-RPC response body against the baselines. */
2671
+ update(responseBody) {
2672
+ const tools = extractTools(responseBody);
2673
+ if (!tools) return { updated: false, baselined: [], drifted: [], reverted: [] };
2674
+ const baselined = [];
2675
+ const drifted = [];
2676
+ const reverted = [];
2677
+ const present = /* @__PURE__ */ new Set();
2678
+ const currentAnnotations = /* @__PURE__ */ new Map();
2679
+ const entries = [];
2680
+ const nameCounts = /* @__PURE__ */ new Map();
2681
+ for (const tool of tools) {
2682
+ if (typeof tool !== "object" || tool === null) continue;
2683
+ const t = tool;
2684
+ const name = t["name"];
2685
+ if (typeof name !== "string") continue;
2686
+ entries.push({ name, definition: t });
2687
+ nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1);
2688
+ }
2689
+ const duplicateNames = /* @__PURE__ */ new Set();
2690
+ for (const { name, definition: t } of entries) {
2691
+ const isDuplicate = (nameCounts.get(name) ?? 0) > 1;
2692
+ if (isDuplicate) {
2693
+ present.add(name);
2694
+ currentAnnotations.set(name, void 0);
2695
+ if (duplicateNames.has(name)) continue;
2696
+ duplicateNames.add(name);
2697
+ const baseline2 = this.baselines.get(name);
2698
+ const allDefinitions = entries.filter((e) => e.name === name).map((e) => e.definition);
2699
+ const changes2 = [
2700
+ {
2701
+ aspect: "duplicate",
2702
+ baseline: baseline2?.definition,
2703
+ current: allDefinitions
2704
+ }
2705
+ ];
2706
+ const event2 = { toolName: name, changes: changes2 };
2707
+ const existing2 = this.driftedTools.get(name);
2708
+ const isNewDrift2 = !existing2 || canonicalize(existing2.changes) !== canonicalize(changes2);
2709
+ this.driftedTools.set(name, event2);
2710
+ if (isNewDrift2) drifted.push(event2);
2711
+ continue;
2712
+ }
2713
+ present.add(name);
2714
+ const annotations = extractAnnotations(t);
2715
+ currentAnnotations.set(name, annotations);
2716
+ const definitionKey = canonicalize(t);
2717
+ const baseline = this.baselines.get(name);
2718
+ if (!baseline) {
2719
+ this.baselines.set(name, { definition: t, definitionKey, annotations });
2720
+ baselined.push(name);
2721
+ if (this.driftedTools.has(name)) {
2722
+ this.driftedTools.delete(name);
2723
+ reverted.push(name);
2724
+ }
2725
+ continue;
2726
+ }
2727
+ if (definitionKey === baseline.definitionKey) {
2728
+ if (this.driftedTools.has(name)) {
2729
+ this.driftedTools.delete(name);
2730
+ reverted.push(name);
2731
+ }
2732
+ continue;
2733
+ }
2734
+ const changes = [];
2735
+ for (const field of ASPECT_FIELDS) {
2736
+ const baselineValue = baseline.definition[field];
2737
+ const currentValue = t[field];
2738
+ if (canonicalize(baselineValue) !== canonicalize(currentValue)) {
2739
+ changes.push({ aspect: field, baseline: baselineValue, current: currentValue });
2740
+ }
2741
+ }
2742
+ if (changes.length === 0) {
2743
+ changes.push({ aspect: "other", baseline: baseline.definition, current: t });
2744
+ }
2745
+ const event = { toolName: name, changes };
2746
+ const existing = this.driftedTools.get(name);
2747
+ const isNewDrift = !existing || canonicalize(existing.changes) !== canonicalize(changes);
2748
+ this.driftedTools.set(name, event);
2749
+ if (isNewDrift) drifted.push(event);
2750
+ }
2751
+ this.present = present;
2752
+ this.currentAnnotations = currentAnnotations;
2753
+ return { updated: true, baselined, drifted, reverted };
2754
+ }
2755
+ /**
2756
+ * Incrementally merge a single tool definition into the cache (issue #12, D6).
2757
+ *
2758
+ * Unlike {@link update}, this touches only the named tool: it adds to (never
2759
+ * rebuilds) the `present` set and `currentAnnotations` map. The sideband
2760
+ * governance path feeds adapter-origin tools one definition at a time (each
2761
+ * `/evaluate` carries at most one), so routing them through the whole-list
2762
+ * `update()` would wipe every other tool's current-annotation snapshot on
2763
+ * each call and silently degrade the stricter-of-both log-mode drift
2764
+ * evaluation. The MCP whole-list path is unaffected — it keeps calling
2765
+ * `update()`. Each origin owns its own cache instance, so the accumulate
2766
+ * semantics here never mix with update()'s replace semantics.
2767
+ *
2768
+ * `toolDefinition` must already be in MCP shape (`inputSchema`/`outputSchema`
2769
+ * camelCase); the governance service maps the wire `tool` object before
2770
+ * calling. Returns the same result shape as `update()` (for one tool).
2771
+ */
2772
+ updateSingle(toolDefinition) {
2773
+ if (typeof toolDefinition !== "object" || toolDefinition === null) {
2774
+ return { updated: false, baselined: [], drifted: [], reverted: [] };
2775
+ }
2776
+ const t = toolDefinition;
2777
+ const name = t["name"];
2778
+ if (typeof name !== "string") {
2779
+ return { updated: false, baselined: [], drifted: [], reverted: [] };
2780
+ }
2781
+ const baselined = [];
2782
+ const drifted = [];
2783
+ const reverted = [];
2784
+ this.present.add(name);
2785
+ const annotations = extractAnnotations(t);
2786
+ this.currentAnnotations.set(name, annotations);
2787
+ const definitionKey = canonicalize(t);
2788
+ const baseline = this.baselines.get(name);
2789
+ if (!baseline) {
2790
+ this.baselines.set(name, { definition: t, definitionKey, annotations });
2791
+ baselined.push(name);
2792
+ if (this.driftedTools.has(name)) {
2793
+ this.driftedTools.delete(name);
2794
+ reverted.push(name);
2795
+ }
2796
+ return { updated: true, baselined, drifted, reverted };
2797
+ }
2798
+ if (definitionKey === baseline.definitionKey) {
2799
+ if (this.driftedTools.has(name)) {
2800
+ this.driftedTools.delete(name);
2801
+ reverted.push(name);
2802
+ }
2803
+ return { updated: true, baselined, drifted, reverted };
2804
+ }
2805
+ const changes = [];
2806
+ for (const field of ASPECT_FIELDS) {
2807
+ const baselineValue = baseline.definition[field];
2808
+ const currentValue = t[field];
2809
+ if (canonicalize(baselineValue) !== canonicalize(currentValue)) {
2810
+ changes.push({ aspect: field, baseline: baselineValue, current: currentValue });
2811
+ }
2812
+ }
2813
+ if (changes.length === 0) {
2814
+ changes.push({ aspect: "other", baseline: baseline.definition, current: t });
2815
+ }
2816
+ const event = { toolName: name, changes };
2817
+ const existing = this.driftedTools.get(name);
2818
+ const isNewDrift = !existing || canonicalize(existing.changes) !== canonicalize(changes);
2819
+ this.driftedTools.set(name, event);
2820
+ if (isNewDrift) drifted.push(event);
2821
+ return { updated: true, baselined, drifted, reverted };
2822
+ }
2823
+ /**
2824
+ * Get the **baseline** annotations for a tool — the definition first seen,
2825
+ * not the latest upstream claim. Returns `undefined` if the tool has no
2826
+ * annotations or was never seen.
2827
+ */
2828
+ get(toolName) {
2829
+ return this.baselines.get(toolName)?.annotations;
2830
+ }
2831
+ /**
2832
+ * Get the annotations from the most recent tools/list. Used for the
2833
+ * stricter-of-both evaluation of drifted tools in on_tool_drift: log mode.
2834
+ * Returns `undefined` for tools absent from the latest list.
2835
+ */
2836
+ getCurrent(toolName) {
2837
+ return this.currentAnnotations.get(toolName);
2838
+ }
2839
+ /** Whether the tool was present in the most recent tools/list. */
2840
+ has(toolName) {
2841
+ return this.present.has(toolName);
2842
+ }
2843
+ /** Whether the tool's current definition differs from its baseline. */
2844
+ isDrifted(toolName) {
2845
+ return this.driftedTools.has(toolName);
2846
+ }
2847
+ /** The active drift event for a tool, if any. */
2848
+ getDrift(toolName) {
2849
+ return this.driftedTools.get(toolName);
2850
+ }
2851
+ };
2852
+ function extractAnnotations(tool) {
2853
+ const annotations = tool["annotations"];
2854
+ return annotations && typeof annotations === "object" ? annotations : void 0;
2855
+ }
2856
+ function extractTools(body) {
2857
+ if (typeof body !== "object" || body === null) return null;
2858
+ const b = body;
2859
+ const result = b["result"];
2860
+ if (typeof result !== "object" || result === null) return null;
2861
+ const r = result;
2862
+ const tools = r["tools"];
2863
+ if (!Array.isArray(tools)) return null;
2864
+ return tools;
2865
+ }
2866
+
2406
2867
  // src/feedback/self-repair.ts
2407
2868
  function ruleInfo(rule) {
2408
2869
  return {
@@ -2550,6 +3011,19 @@ function buildRateLimitedFeedback(decision, result) {
2550
3011
  retry_allowed: true
2551
3012
  };
2552
3013
  }
3014
+ function buildToolDriftFeedback(drift, action) {
3015
+ const aspects = drift.changes.map((change) => change.aspect);
3016
+ return {
3017
+ blocked: true,
3018
+ reason: "tool_definition_drift",
3019
+ rule: null,
3020
+ ruleIndex: null,
3021
+ action,
3022
+ drifted_aspects: aspects,
3023
+ 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.`,
3024
+ retry_allowed: false
3025
+ };
3026
+ }
2553
3027
  function buildSpendLimitedFeedback(decision, result, currency) {
2554
3028
  const { rule, ruleIndex } = ruleInfo(decision.matchedRule);
2555
3029
  const windowSeconds = Math.round(result.windowMs / 1e3);
@@ -2601,6 +3075,7 @@ var GovernedForwarder = class {
2601
3075
  spendLimiter;
2602
3076
  annotationCache = new ToolAnnotationCache();
2603
3077
  agentKeyWarned = false;
3078
+ senderKeyWarned = false;
2604
3079
  constructor(inner, policy, options) {
2605
3080
  this.inner = inner;
2606
3081
  this.policy = policy;
@@ -2686,8 +3161,8 @@ var GovernedForwarder = class {
2686
3161
  reason: classifyPrimeFailure(result.response)
2687
3162
  };
2688
3163
  }
2689
- const updated = this.annotationCache.update(result.response.body);
2690
- if (!updated) {
3164
+ const update = this.applyToolDefinitionUpdate(result.response.body, void 0);
3165
+ if (!update.updated) {
2691
3166
  return {
2692
3167
  success: false,
2693
3168
  toolsCached: this.annotationCache.size,
@@ -2709,10 +3184,65 @@ var GovernedForwarder = class {
2709
3184
  }
2710
3185
  const result = await this.inner.forward(request);
2711
3186
  if (request.method === "tools/list") {
2712
- this.annotationCache.update(result.response.body);
3187
+ this.applyToolDefinitionUpdate(result.response.body, request.sessionId);
2713
3188
  }
2714
3189
  return result;
2715
3190
  }
3191
+ /**
3192
+ * Apply a tools/list response to the definition cache and surface any
3193
+ * drift: console warning + immediate audit record per event. Single entry
3194
+ * point for both runtime tools/list responses and startup priming, so the
3195
+ * cache is updated exactly once per response.
3196
+ */
3197
+ applyToolDefinitionUpdate(responseBody, sessionId) {
3198
+ const update = this.annotationCache.update(responseBody);
3199
+ if (!update.updated) return update;
3200
+ for (const drift of update.drifted) {
3201
+ const aspects = drift.changes.map((change) => change.aspect).join(", ");
3202
+ console.error(
3203
+ `[helio] Tool definition drift detected: "${drift.toolName}" changed (${aspects}) after baseline \u2014 calls governed by policies.on_tool_drift (${this.policy.onToolDrift ?? "block"})`
3204
+ );
3205
+ this.writeDriftAuditRecord(drift, sessionId, "tool_drift");
3206
+ }
3207
+ for (const toolName of update.reverted) {
3208
+ console.error(
3209
+ `[helio] Tool definition drift cleared: "${toolName}" returned to its baseline definition`
3210
+ );
3211
+ this.writeDriftAuditRecord({ toolName, changes: [] }, sessionId, "tool_drift_reverted");
3212
+ }
3213
+ return update;
3214
+ }
3215
+ /** Write an immediate audit record for a drift event (not a tool call). */
3216
+ writeDriftAuditRecord(drift, sessionId, decision) {
3217
+ if (!this.auditWriter) return;
3218
+ this.auditWriter.pushImmediate({
3219
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3220
+ session_id: sessionId ?? null,
3221
+ agent_id: null,
3222
+ environment: this.environment ?? null,
3223
+ tool_name: drift.toolName,
3224
+ tool_input: {},
3225
+ policy_decision: decision,
3226
+ block_reason: null,
3227
+ matched_rule: null,
3228
+ matched_rule_index: null,
3229
+ evidence_chain: decision === "tool_drift" ? { tool_drift: { changes: drift.changes } } : null,
3230
+ approval_status: null,
3231
+ approved_by: null,
3232
+ upstream_response: null,
3233
+ upstream_error: null,
3234
+ upstream_http_status: null,
3235
+ upstream_latency_ms: null,
3236
+ total_duration_ms: 0,
3237
+ approval_wait_ms: 0,
3238
+ proxy_compute_ms: 0,
3239
+ flagged_destructive: false,
3240
+ dry_run: false,
3241
+ record_kind: "drift_event",
3242
+ origin: "mcp",
3243
+ metadata: null
3244
+ });
3245
+ }
2716
3246
  async handleToolsCall(request) {
2717
3247
  const startTime = performance.now();
2718
3248
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
@@ -2722,77 +3252,28 @@ var GovernedForwarder = class {
2722
3252
  return this.inner.forward(request);
2723
3253
  }
2724
3254
  const toolArguments = params?.["arguments"] && typeof params["arguments"] === "object" ? params["arguments"] : void 0;
2725
- const annotations = this.annotationCache.get(toolName);
2726
- let decision = evaluatePolicy(this.policy, {
3255
+ const {
3256
+ decision,
3257
+ driftEvent,
3258
+ driftMode,
3259
+ driftBlocked,
3260
+ flaggedDestructive,
3261
+ evidenceResult,
3262
+ dependencyResult,
3263
+ evidenceBlocked,
3264
+ sessionBlocked,
3265
+ isDryRun
3266
+ } = decide({
2727
3267
  toolName,
2728
- annotations,
2729
3268
  toolArguments,
2730
- environment: this.environment
3269
+ sessionId: request.sessionId,
3270
+ policy: this.policy,
3271
+ environment: this.environment,
3272
+ evidenceStore: this.evidenceStore,
3273
+ baselineAnnotations: this.annotationCache.get(toolName),
3274
+ currentAnnotations: this.annotationCache.getCurrent(toolName),
3275
+ driftEvent: this.annotationCache.getDrift(toolName)
2731
3276
  });
2732
- const isDestructive = annotations?.destructiveHint ?? true;
2733
- let flaggedDestructive = false;
2734
- if (isDestructive && !decision.matchedRule && this.policy.flagDestructive) {
2735
- flaggedDestructive = true;
2736
- if (this.policy.flagDestructive === "log") {
2737
- console.error(`[helio] Destructive tool detected: ${toolName} (no matching rule)`);
2738
- } else {
2739
- decision = {
2740
- action: "require_approval",
2741
- matchedRule: void 0,
2742
- reason: `Destructive tool "${toolName}" auto-escalated by flag_destructive policy`
2743
- };
2744
- }
2745
- }
2746
- const originalAction = decision.action;
2747
- let evidenceResult;
2748
- let dependencyResult;
2749
- let evidenceBlocked = false;
2750
- let sessionBlocked = false;
2751
- const requiresGroundedSession = decision.action !== "deny" && !!decision.matchedRule && ((decision.matchedRule.evidence?.requires.length ?? 0) > 0 || (decision.matchedRule.requires?.length ?? 0) > 0);
2752
- if (requiresGroundedSession && !request.sessionId) {
2753
- sessionBlocked = true;
2754
- evidenceBlocked = true;
2755
- decision = {
2756
- action: "deny",
2757
- matchedRule: decision.matchedRule,
2758
- reason: "Mcp-Session-Id is required for evidence/dependency-gated policy rules"
2759
- };
2760
- }
2761
- if (decision.action !== "deny" && this.evidenceStore && request.sessionId && decision.matchedRule) {
2762
- const rule = decision.matchedRule;
2763
- if (rule.evidence?.requires.length) {
2764
- evidenceResult = checkEvidence(
2765
- this.evidenceStore,
2766
- request.sessionId,
2767
- rule.evidence.requires
2768
- );
2769
- if (!evidenceResult.satisfied) {
2770
- evidenceBlocked = true;
2771
- const problemKeys = [...evidenceResult.missing, ...evidenceResult.expired];
2772
- decision = {
2773
- action: "deny",
2774
- matchedRule: rule,
2775
- reason: `Required evidence not satisfied: ${problemKeys.join(", ")}`
2776
- };
2777
- }
2778
- }
2779
- if (!evidenceBlocked && rule.requires?.length) {
2780
- dependencyResult = checkDependencies(this.evidenceStore, request.sessionId, rule.requires, {
2781
- requireSuccess: rule.requiresSuccess ?? true
2782
- });
2783
- if (!dependencyResult.satisfied) {
2784
- evidenceBlocked = true;
2785
- decision = {
2786
- action: "deny",
2787
- matchedRule: rule,
2788
- reason: `Required tool calls not completed: ${dependencyResult.missing.join(", ")}`
2789
- };
2790
- }
2791
- }
2792
- }
2793
- const isPerRuleDryRun = originalAction === "dry_run";
2794
- const isGlobalDryRun = this.policy.dryRun === true;
2795
- const isDryRun = (isPerRuleDryRun || isGlobalDryRun) && !sessionBlocked;
2796
3277
  let result;
2797
3278
  let approvalOutcome;
2798
3279
  let approvalWaitMs = 0;
@@ -2806,6 +3287,8 @@ var GovernedForwarder = class {
2806
3287
  result = this.makeSessionRequiredBlockResult(request, decision);
2807
3288
  } else if (evidenceBlocked) {
2808
3289
  result = this.makeEvidenceBlockResult(request, decision, evidenceResult, dependencyResult);
3290
+ } else if (driftBlocked && driftEvent) {
3291
+ result = this.makeDriftBlockResult(request, driftEvent);
2809
3292
  } else if (decision.action === "allow") {
2810
3293
  result = await this.inner.forward(request);
2811
3294
  } else if (decision.action === "deny") {
@@ -2878,7 +3361,8 @@ var GovernedForwarder = class {
2878
3361
  rateLimitResult,
2879
3362
  spendLimitResult,
2880
3363
  isDryRun,
2881
- forwardingError
3364
+ forwardingError,
3365
+ driftEvent ? { event: driftEvent, mode: driftMode } : void 0
2882
3366
  );
2883
3367
  return result;
2884
3368
  }
@@ -3086,6 +3570,14 @@ var GovernedForwarder = class {
3086
3570
  );
3087
3571
  }
3088
3572
  return `tool:${toolName}`;
3573
+ case "sender_id":
3574
+ if (!this.senderKeyWarned) {
3575
+ this.senderKeyWarned = true;
3576
+ console.error(
3577
+ '[helio] Warning: limits.key "sender_id" has no sender on the MCP path, falling back to "tool"'
3578
+ );
3579
+ }
3580
+ return `tool:${toolName}`;
3089
3581
  case "tool":
3090
3582
  default:
3091
3583
  return `tool:${toolName}`;
@@ -3095,7 +3587,7 @@ var GovernedForwarder = class {
3095
3587
  wasForwardedUpstream(decision, approvalOutcome, rateLimitResult, spendLimitResult) {
3096
3588
  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
3589
  }
3098
- writeAuditRecord(request, timestamp, toolName, toolArguments, decision, result, totalDurationMs, approvalWaitMs, flaggedDestructive, evidenceResult, dependencyResult, evidenceBlocked, approvalOutcome, rateLimitResult, spendLimitResult, isDryRun, forwardingError) {
3590
+ writeAuditRecord(request, timestamp, toolName, toolArguments, decision, result, totalDurationMs, approvalWaitMs, flaggedDestructive, evidenceResult, dependencyResult, evidenceBlocked, approvalOutcome, rateLimitResult, spendLimitResult, isDryRun, forwardingError, drift) {
3099
3591
  if (!this.auditWriter) return;
3100
3592
  const wasForwarded = this.wasForwardedUpstream(
3101
3593
  decision,
@@ -3155,10 +3647,19 @@ var GovernedForwarder = class {
3155
3647
  }
3156
3648
  };
3157
3649
  }
3158
- const blockReason = extractBlockReason(result);
3159
- const record = {
3160
- timestamp,
3161
- session_id: request.sessionId ?? null,
3650
+ if (drift) {
3651
+ evidenceChain = {
3652
+ ...evidenceChain ?? {},
3653
+ tool_drift: {
3654
+ mode: drift.mode,
3655
+ changes: drift.event.changes
3656
+ }
3657
+ };
3658
+ }
3659
+ const blockReason = extractBlockReason(result);
3660
+ const record = {
3661
+ timestamp,
3662
+ session_id: request.sessionId ?? null,
3162
3663
  agent_id: null,
3163
3664
  environment: this.environment ?? null,
3164
3665
  tool_name: toolName,
@@ -3178,7 +3679,10 @@ var GovernedForwarder = class {
3178
3679
  approval_wait_ms: approvalWaitMs,
3179
3680
  proxy_compute_ms: proxyComputeMs,
3180
3681
  flagged_destructive: flaggedDestructive,
3181
- dry_run: isDryRun ?? false
3682
+ dry_run: isDryRun ?? false,
3683
+ record_kind: "tool_call",
3684
+ origin: "mcp",
3685
+ metadata: null
3182
3686
  };
3183
3687
  const isEnforcementDecision = !isDryRun && (!wasForwarded || approvalOutcome !== void 0);
3184
3688
  if (isEnforcementDecision) {
@@ -3187,6 +3691,15 @@ var GovernedForwarder = class {
3187
3691
  this.auditWriter.push(record);
3188
3692
  }
3189
3693
  }
3694
+ makeDriftBlockResult(request, drift) {
3695
+ const feedback = buildToolDriftFeedback(drift, "deny");
3696
+ return makeErrorResult(
3697
+ request,
3698
+ POLICY_DENIED,
3699
+ `Tool definition drift: "${drift.toolName}" changed after baseline`,
3700
+ { ...feedback }
3701
+ );
3702
+ }
3190
3703
  makeDenyResult(request, decision) {
3191
3704
  const feedback = buildPolicyDeniedFeedback(decision);
3192
3705
  const message = decision.matchedRule?.feedback?.message ?? `Policy denied: ${decision.reason}`;
@@ -3407,6 +3920,46 @@ var RateLimiter = class {
3407
3920
  resetAtMs
3408
3921
  };
3409
3922
  }
3923
+ /**
3924
+ * Unconditionally record a call against the rate limit.
3925
+ *
3926
+ * Unlike check(), this always appends the timestamp — even when the bucket
3927
+ * is already at/over the limit — because the call it represents has already
3928
+ * executed. The sideband splits decision from execution: /evaluate peeks
3929
+ * (non-destructive), and /audit calls record() once the external call ran,
3930
+ * so refusing to record at the limit (as check() does) would let real calls
3931
+ * escape accounting and under-count subsequent peeks. (issue #12, D3.)
3932
+ *
3933
+ * Warnings fire only while the post-append count stays within the limit —
3934
+ * exact parity with check(), which never warns on its over-limit path — so a
3935
+ * burst of over-limit audits cannot flood the dashboard's limit_warning feed.
3936
+ */
3937
+ record(params) {
3938
+ const { key, maxCalls, windowMs } = params;
3939
+ const now = this.now();
3940
+ const windowStart = now - windowMs;
3941
+ let bucket = this.buckets.get(key);
3942
+ if (!bucket) {
3943
+ bucket = { timestamps: [], maxCalls, windowMs };
3944
+ this.buckets.set(key, bucket);
3945
+ }
3946
+ bucket.maxCalls = maxCalls;
3947
+ bucket.windowMs = windowMs;
3948
+ bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
3949
+ bucket.timestamps.push(now);
3950
+ const current = bucket.timestamps.length;
3951
+ const resetAtMs = (bucket.timestamps[0] ?? now) + windowMs;
3952
+ if (this.onWarning && current <= maxCalls && current / maxCalls >= this.warningThreshold) {
3953
+ this.onWarning({ key, current, limit: maxCalls, window_ms: windowMs, reset_at_ms: resetAtMs });
3954
+ }
3955
+ return {
3956
+ allowed: current <= maxCalls,
3957
+ current,
3958
+ limit: maxCalls,
3959
+ windowMs,
3960
+ resetAtMs
3961
+ };
3962
+ }
3410
3963
  /**
3411
3964
  * Check the rate limit without recording the call (non-destructive).
3412
3965
  *
@@ -3621,6 +4174,57 @@ var SpendLimiter = class {
3621
4174
  resetAtMs
3622
4175
  };
3623
4176
  }
4177
+ /**
4178
+ * Unconditionally record a spend against the limit.
4179
+ *
4180
+ * Unlike check(), this always appends the amount — even when it pushes the
4181
+ * window past the limit — because the spend it represents has already been
4182
+ * incurred. The sideband peeks at /evaluate and commits here at /audit once
4183
+ * the external call ran (issue #12, D3).
4184
+ *
4185
+ * Throws on a negative or non-finite amount: such amounts are rejected at
4186
+ * /evaluate, so one reaching record() is a logic bug we surface loudly rather
4187
+ * than silently corrupt the sliding-window sum. Warnings fire only while the
4188
+ * post-append spend stays within the limit (parity with check()).
4189
+ */
4190
+ record(params) {
4191
+ const { key, amount, limit, windowMs } = params;
4192
+ if (!Number.isFinite(amount) || amount < 0) {
4193
+ throw new RangeError(
4194
+ `SpendLimiter.record() received an invalid amount (${String(amount)}); invalid amounts must be rejected at /evaluate, never committed`
4195
+ );
4196
+ }
4197
+ const now = this.now();
4198
+ const windowStart = now - windowMs;
4199
+ let bucket = this.buckets.get(key);
4200
+ if (!bucket) {
4201
+ bucket = { entries: [], limit, currency: "", windowMs };
4202
+ this.buckets.set(key, bucket);
4203
+ }
4204
+ bucket.limit = limit;
4205
+ bucket.windowMs = windowMs;
4206
+ bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
4207
+ bucket.entries.push({ timestamp: now, amount });
4208
+ const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
4209
+ const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
4210
+ if (this.onWarning && currentSpend <= limit && currentSpend / limit >= this.warningThreshold) {
4211
+ this.onWarning({
4212
+ key,
4213
+ current_spend: currentSpend,
4214
+ limit,
4215
+ currency: bucket.currency,
4216
+ window_ms: windowMs,
4217
+ reset_at_ms: resetAtMs
4218
+ });
4219
+ }
4220
+ return {
4221
+ allowed: currentSpend <= limit,
4222
+ currentSpend,
4223
+ limit,
4224
+ windowMs,
4225
+ resetAtMs
4226
+ };
4227
+ }
3624
4228
  /**
3625
4229
  * Check the spend limit without recording the spend (non-destructive).
3626
4230
  *
@@ -3840,6 +4444,7 @@ function clampInt(value, fallback, min, max) {
3840
4444
  }
3841
4445
 
3842
4446
  // src/audit/store.ts
4447
+ var DRIFT_EVENT_DECISIONS_SQL = "('tool_drift', 'tool_drift_reverted')";
3843
4448
  var CREATE_TABLE_DDL = `
3844
4449
  CREATE TABLE IF NOT EXISTS audit_records (
3845
4450
  id TEXT PRIMARY KEY,
@@ -3865,6 +4470,9 @@ CREATE TABLE IF NOT EXISTS audit_records (
3865
4470
  proxy_compute_ms REAL NOT NULL,
3866
4471
  flagged_destructive INTEGER NOT NULL DEFAULT 0,
3867
4472
  dry_run INTEGER NOT NULL DEFAULT 0,
4473
+ record_kind TEXT NOT NULL DEFAULT 'tool_call',
4474
+ origin TEXT NOT NULL DEFAULT 'mcp',
4475
+ metadata TEXT,
3868
4476
  created_at TEXT NOT NULL
3869
4477
  );
3870
4478
  `;
@@ -3875,6 +4483,8 @@ CREATE INDEX IF NOT EXISTS idx_audit_policy_decision ON audit_records (policy_d
3875
4483
  CREATE INDEX IF NOT EXISTS idx_audit_session_id ON audit_records (session_id);
3876
4484
  CREATE INDEX IF NOT EXISTS idx_audit_block_reason ON audit_records (block_reason);
3877
4485
  CREATE INDEX IF NOT EXISTS idx_audit_upstream_status_created_at ON audit_records (upstream_http_status, created_at);
4486
+ CREATE INDEX IF NOT EXISTS idx_audit_record_kind ON audit_records (record_kind);
4487
+ CREATE INDEX IF NOT EXISTS idx_audit_origin ON audit_records (origin);
3878
4488
  `;
3879
4489
  var INSERT_SQL = `
3880
4490
  INSERT INTO audit_records (
@@ -3883,14 +4493,14 @@ INSERT INTO audit_records (
3883
4493
  approved_by, upstream_response, upstream_error, upstream_latency_ms,
3884
4494
  upstream_http_status,
3885
4495
  total_duration_ms, approval_wait_ms, proxy_compute_ms,
3886
- flagged_destructive, dry_run, created_at
4496
+ flagged_destructive, dry_run, record_kind, origin, metadata, created_at
3887
4497
  ) VALUES (
3888
4498
  @id, @timestamp, @session_id, @agent_id, @environment, @tool_name, @tool_input,
3889
4499
  @policy_decision, @block_reason, @matched_rule, @matched_rule_index, @evidence_chain, @approval_status,
3890
4500
  @approved_by, @upstream_response, @upstream_error, @upstream_latency_ms,
3891
4501
  @upstream_http_status,
3892
4502
  @total_duration_ms, @approval_wait_ms, @proxy_compute_ms,
3893
- @flagged_destructive, @dry_run, @created_at
4503
+ @flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @created_at
3894
4504
  )
3895
4505
  `;
3896
4506
  var REQUIRED_AUDIT_COLUMNS = [
@@ -3900,7 +4510,10 @@ var REQUIRED_AUDIT_COLUMNS = [
3900
4510
  "total_duration_ms",
3901
4511
  "approval_wait_ms",
3902
4512
  "proxy_compute_ms",
3903
- "upstream_http_status"
4513
+ "upstream_http_status",
4514
+ "record_kind",
4515
+ "origin",
4516
+ "metadata"
3904
4517
  ];
3905
4518
  function deserializeRow(row) {
3906
4519
  return {
@@ -3927,6 +4540,9 @@ function deserializeRow(row) {
3927
4540
  proxy_compute_ms: row.proxy_compute_ms,
3928
4541
  flagged_destructive: row.flagged_destructive === 1,
3929
4542
  dry_run: row.dry_run === 1,
4543
+ record_kind: row.record_kind,
4544
+ origin: row.origin,
4545
+ metadata: row.metadata ? JSON.parse(row.metadata) : null,
3930
4546
  created_at: row.created_at
3931
4547
  };
3932
4548
  }
@@ -3948,6 +4564,22 @@ function buildWhereClause(filters) {
3948
4564
  if (filters.blocked !== void 0) {
3949
4565
  conditions.push(filters.blocked ? "block_reason IS NOT NULL" : "block_reason IS NULL");
3950
4566
  }
4567
+ if (filters.record_kind !== void 0) {
4568
+ conditions.push("record_kind = ?");
4569
+ params.push(filters.record_kind);
4570
+ }
4571
+ if (filters.origin !== void 0) {
4572
+ conditions.push("origin LIKE ?");
4573
+ params.push(`%${filters.origin}%`);
4574
+ }
4575
+ if (filters.channel_id !== void 0) {
4576
+ conditions.push("json_extract(metadata, '$.channel_id') LIKE ?");
4577
+ params.push(`%${filters.channel_id}%`);
4578
+ }
4579
+ if (filters.sender_id !== void 0) {
4580
+ conditions.push("json_extract(metadata, '$.sender_id') LIKE ?");
4581
+ params.push(`%${filters.sender_id}%`);
4582
+ }
3951
4583
  if (filters.session_id !== void 0) {
3952
4584
  conditions.push("session_id = ?");
3953
4585
  params.push(filters.session_id);
@@ -4074,6 +4706,9 @@ var AuditStore = class {
4074
4706
  proxy_compute_ms: record.proxy_compute_ms,
4075
4707
  flagged_destructive: record.flagged_destructive ? 1 : 0,
4076
4708
  dry_run: record.dry_run ? 1 : 0,
4709
+ record_kind: record.record_kind,
4710
+ origin: record.origin,
4711
+ metadata: record.metadata ? JSON.stringify(record.metadata) : null,
4077
4712
  created_at: now
4078
4713
  });
4079
4714
  return resolvedId;
@@ -4143,7 +4778,7 @@ var AuditStore = class {
4143
4778
  const totals = this.db.prepare(
4144
4779
  `SELECT
4145
4780
  COUNT(*) as total,
4146
- COALESCE(SUM(CASE WHEN block_reason IS NULL THEN 1 ELSE 0 END), 0) as allowed_total,
4781
+ 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
4782
  COALESCE(SUM(CASE WHEN block_reason IS NOT NULL THEN 1 ELSE 0 END), 0) as blocked_total,
4148
4783
  COALESCE(SUM(CASE WHEN dry_run = 1 THEN 1 ELSE 0 END), 0) as dry_run_total,
4149
4784
  COALESCE(SUM(CASE WHEN dry_run = 0 THEN 1 ELSE 0 END), 0) as applied_total
@@ -4162,9 +4797,10 @@ var AuditStore = class {
4162
4797
  GROUP BY block_reason
4163
4798
  ORDER BY count DESC`
4164
4799
  ).all(...params);
4800
+ const toolsClause = clause ? `${clause} AND policy_decision NOT IN ${DRIFT_EVENT_DECISIONS_SQL}` : `WHERE policy_decision NOT IN ${DRIFT_EVENT_DECISIONS_SQL}`;
4165
4801
  const top_tools = this.db.prepare(
4166
4802
  `SELECT tool_name, COUNT(*) as count
4167
- FROM audit_records ${clause}
4803
+ FROM audit_records ${toolsClause}
4168
4804
  GROUP BY tool_name
4169
4805
  ORDER BY count DESC
4170
4806
  LIMIT 10`
@@ -4254,9 +4890,8 @@ var AuditWriter = class {
4254
4890
  * is scheduled. This keeps request-path latency bounded even under bursty
4255
4891
  * write load.
4256
4892
  */
4257
- push(record) {
4893
+ push(record, id = randomUUID3()) {
4258
4894
  if (this.closed) return;
4259
- const id = randomUUID3();
4260
4895
  this.buffer.push({ id, record });
4261
4896
  this.onPush?.(record, id);
4262
4897
  if (this.buffer.length >= this.bufferSize) {
@@ -4271,9 +4906,8 @@ var AuditWriter = class {
4271
4906
  * A fatal-process crash still invokes the crash-drain hook, which calls
4272
4907
  * `flush()` synchronously before exit.
4273
4908
  */
4274
- pushImmediate(record) {
4909
+ pushImmediate(record, id = randomUUID3()) {
4275
4910
  if (this.closed) return;
4276
- const id = randomUUID3();
4277
4911
  this.buffer.push({ id, record });
4278
4912
  this.onPush?.(record, id);
4279
4913
  this.scheduleFlushSoon();
@@ -4619,8 +5253,9 @@ var EvidenceStore = class _EvidenceStore {
4619
5253
  };
4620
5254
 
4621
5255
  // src/evidence/api.ts
4622
- import { Hono as Hono4 } from "hono";
4623
- import { z as z4 } from "zod";
5256
+ import { Hono as Hono5 } from "hono";
5257
+ import { bodyLimit } from "hono/body-limit";
5258
+ import { z as z5 } from "zod";
4624
5259
 
4625
5260
  // src/auth/bearer.ts
4626
5261
  import { createHash, timingSafeEqual } from "crypto";
@@ -4632,22 +5267,176 @@ function verifyBearer(authHeader, expected) {
4632
5267
  return timingSafeEqual(actualDigest, expectedDigest);
4633
5268
  }
4634
5269
 
5270
+ // src/sideband/governance-api.ts
5271
+ import { Hono as Hono4 } from "hono";
5272
+ import { z as z4 } from "zod";
5273
+ import { createHash as createHash2 } from "crypto";
5274
+ var originSchema = z4.string().regex(/^[a-z0-9_-]{1,64}$/, "origin must match ^[a-z0-9_-]{1,64}$").default("sideband");
5275
+ var metadataSchema = z4.record(z4.string(), z4.unknown()).nullish();
5276
+ var toolDefinitionSchema = z4.object({
5277
+ name: z4.string().min(1),
5278
+ description: z4.string().optional(),
5279
+ input_schema: z4.unknown().optional(),
5280
+ output_schema: z4.unknown().optional(),
5281
+ title: z4.string().optional(),
5282
+ annotations: z4.record(z4.string(), z4.unknown()).optional()
5283
+ });
5284
+ var evaluateBody = z4.object({
5285
+ origin: originSchema,
5286
+ adapter_version: z4.string().max(64).optional(),
5287
+ agent_id: z4.string().nullish(),
5288
+ session_id: z4.string().nullish(),
5289
+ tool: toolDefinitionSchema,
5290
+ arguments: z4.record(z4.string(), z4.unknown()).optional(),
5291
+ metadata: metadataSchema
5292
+ });
5293
+ var installScanBody = z4.object({
5294
+ origin: originSchema,
5295
+ agent_id: z4.string().nullish(),
5296
+ session_id: z4.string().nullish(),
5297
+ package: z4.object({
5298
+ name: z4.string().min(1),
5299
+ version: z4.string().optional(),
5300
+ source: z4.string().max(64).optional(),
5301
+ spec: z4.string().optional(),
5302
+ url: z4.string().optional()
5303
+ }),
5304
+ metadata: metadataSchema
5305
+ });
5306
+ var auditBody = z4.object({
5307
+ evaluation_id: z4.string().min(1),
5308
+ status: z4.enum(["success", "error", "not_executed"]),
5309
+ error: z4.string().optional(),
5310
+ duration_ms: z4.number().optional(),
5311
+ result: z4.unknown().optional(),
5312
+ actual_amount: z4.number().optional()
5313
+ });
5314
+ var resolveBody = z4.object({
5315
+ resolution: z4.enum(["approved", "denied", "timeout", "cancelled"]),
5316
+ resolved_by: z4.string().optional(),
5317
+ reason: z4.string().optional(),
5318
+ scope: z4.enum(["once", "always"]).optional()
5319
+ });
5320
+ var MAX_METADATA_BYTES = 4 * 1024;
5321
+ function createGovernanceApp(service) {
5322
+ const app = new Hono4();
5323
+ const unavailable = () => ({ error: "governance_unavailable" });
5324
+ app.post("/evaluate", async (c) => {
5325
+ if (!service) return c.json(unavailable(), 503);
5326
+ const parsed = await parseJson(c);
5327
+ if ("error" in parsed) return c.json(parsed.error, 400);
5328
+ const result = evaluateBody.safeParse(parsed.body);
5329
+ if (!result.success) {
5330
+ return c.json({ error: "Validation error", details: formatZodErrors(result.error) }, 400);
5331
+ }
5332
+ if (metadataTooLarge(result.data.metadata)) {
5333
+ return c.json({ error: "metadata_too_large" }, 413);
5334
+ }
5335
+ const r = service.evaluate({
5336
+ origin: result.data.origin,
5337
+ adapter_version: result.data.adapter_version,
5338
+ agent_id: result.data.agent_id ?? null,
5339
+ session_id: result.data.session_id ?? null,
5340
+ tool: result.data.tool,
5341
+ arguments: result.data.arguments,
5342
+ metadata: result.data.metadata ?? null
5343
+ });
5344
+ return c.json(r.body, asStatus(r.status));
5345
+ });
5346
+ app.post("/audit", async (c) => {
5347
+ if (!service) return c.json(unavailable(), 503);
5348
+ const parsed = await parseJson(c);
5349
+ if ("error" in parsed) return c.json(parsed.error, 400);
5350
+ const result = auditBody.safeParse(parsed.body);
5351
+ if (!result.success) {
5352
+ return c.json({ error: "Validation error", details: formatZodErrors(result.error) }, 400);
5353
+ }
5354
+ const hash = auditPayloadHash(result.data);
5355
+ const r = service.audit(result.data, hash);
5356
+ return c.json(r.body, asStatus(r.status));
5357
+ });
5358
+ app.post("/install-scan", async (c) => {
5359
+ if (!service) return c.json(unavailable(), 503);
5360
+ const parsed = await parseJson(c);
5361
+ if ("error" in parsed) return c.json(parsed.error, 400);
5362
+ const result = installScanBody.safeParse(parsed.body);
5363
+ if (!result.success) {
5364
+ return c.json({ error: "Validation error", details: formatZodErrors(result.error) }, 400);
5365
+ }
5366
+ if (metadataTooLarge(result.data.metadata)) {
5367
+ return c.json({ error: "metadata_too_large" }, 413);
5368
+ }
5369
+ const r = service.installScan({
5370
+ origin: result.data.origin,
5371
+ agent_id: result.data.agent_id ?? null,
5372
+ session_id: result.data.session_id ?? null,
5373
+ package: result.data.package,
5374
+ metadata: result.data.metadata ?? null
5375
+ });
5376
+ return c.json(r.body, asStatus(r.status));
5377
+ });
5378
+ app.post("/approval/:id/resolve", async (c) => {
5379
+ if (!service) return c.json(unavailable(), 503);
5380
+ const parsed = await parseJson(c);
5381
+ if ("error" in parsed) return c.json(parsed.error, 400);
5382
+ const result = resolveBody.safeParse(parsed.body);
5383
+ if (!result.success) {
5384
+ return c.json({ error: "Validation error", details: formatZodErrors(result.error) }, 400);
5385
+ }
5386
+ if ((result.data.resolution === "approved" || result.data.resolution === "denied") && !result.data.resolved_by) {
5387
+ return c.json({ error: "resolved_by is required for approved/denied" }, 400);
5388
+ }
5389
+ const r = service.resolveApproval(c.req.param("id"), result.data);
5390
+ return c.json(r.body, asStatus(r.status));
5391
+ });
5392
+ return app;
5393
+ }
5394
+ function isGovernancePath(path) {
5395
+ return path === "/evaluate" || path === "/audit" || path === "/install-scan" || path.startsWith("/approval/");
5396
+ }
5397
+ async function parseJson(c) {
5398
+ try {
5399
+ return { body: await c.req.json() };
5400
+ } catch {
5401
+ return { error: { error: "Invalid JSON" } };
5402
+ }
5403
+ }
5404
+ function metadataTooLarge(metadata) {
5405
+ if (metadata == null) return false;
5406
+ return Buffer.byteLength(canonicalize(metadata), "utf8") > MAX_METADATA_BYTES;
5407
+ }
5408
+ function auditPayloadHash(data) {
5409
+ const semantic = {
5410
+ status: data.status,
5411
+ error: data.error ?? null,
5412
+ duration_ms: data.duration_ms ?? null,
5413
+ result: data.result ?? null,
5414
+ actual_amount: data.actual_amount ?? null
5415
+ };
5416
+ return createHash2("sha256").update(canonicalize(semantic)).digest("hex");
5417
+ }
5418
+ function asStatus(status) {
5419
+ return status;
5420
+ }
5421
+
4635
5422
  // src/evidence/api.ts
4636
- var postEvidenceBody = z4.object({
4637
- session_id: z4.string().min(1),
4638
- tool_name: z4.string().min(1),
4639
- evidence_key: z4.string().min(1),
4640
- evidence_data: z4.unknown().refine((v) => v !== void 0, { message: "Required" }),
4641
- ttl_seconds: z4.number().int().positive().optional()
5423
+ var SIDEBAND_BODY_LIMIT_BYTES = 1 * 1024 * 1024;
5424
+ var postEvidenceBody = z5.object({
5425
+ session_id: z5.string().min(1),
5426
+ tool_name: z5.string().min(1),
5427
+ evidence_key: z5.string().min(1),
5428
+ evidence_data: z5.unknown().refine((v) => v !== void 0, { message: "Required" }),
5429
+ ttl_seconds: z5.number().int().positive().optional()
4642
5430
  });
4643
- var postContextBody = z4.object({
4644
- session_id: z4.string().min(1),
4645
- key: z4.string().min(1),
4646
- value: z4.unknown().refine((v) => v !== void 0, { message: "Required" })
5431
+ var postContextBody = z5.object({
5432
+ session_id: z5.string().min(1),
5433
+ key: z5.string().min(1),
5434
+ value: z5.unknown().refine((v) => v !== void 0, { message: "Required" })
4647
5435
  });
4648
5436
  function createSidebandApp(store, options = {}) {
4649
- const app = new Hono4();
4650
- const token = options.token && options.token.length > 0 ? options.token : void 0;
5437
+ const app = new Hono5();
5438
+ const sdkToken = options.token && options.token.length > 0 ? options.token : void 0;
5439
+ const adapterToken = options.adapterToken && options.adapterToken.length > 0 ? options.adapterToken : void 0;
4651
5440
  app.use("*", async (c, next) => {
4652
5441
  const origin = c.req.header("origin");
4653
5442
  if (origin) {
@@ -4658,20 +5447,26 @@ function createSidebandApp(store, options = {}) {
4658
5447
  }
4659
5448
  await next();
4660
5449
  });
4661
- if (token) {
4662
- app.use("*", async (c, next) => {
4663
- if (c.req.path === "/healthz") {
4664
- await next();
4665
- return;
4666
- }
4667
- const authHeader = c.req.header("authorization");
4668
- if (!verifyBearer(authHeader, token)) {
4669
- return c.json({ error: "Unauthorized" }, 401);
4670
- }
5450
+ app.use(
5451
+ "*",
5452
+ bodyLimit({
5453
+ maxSize: SIDEBAND_BODY_LIMIT_BYTES,
5454
+ onError: (c) => c.json({ error: "request_body_too_large" }, 413)
5455
+ })
5456
+ );
5457
+ app.use("*", async (c, next) => {
5458
+ if (c.req.path === "/healthz") {
4671
5459
  await next();
4672
- });
4673
- }
5460
+ return;
5461
+ }
5462
+ const expected = isGovernancePath(c.req.path) ? adapterToken : sdkToken;
5463
+ if (expected && !verifyBearer(c.req.header("authorization"), expected)) {
5464
+ return c.json({ error: "Unauthorized" }, 401);
5465
+ }
5466
+ await next();
5467
+ });
4674
5468
  app.get("/healthz", (c) => c.json({ status: "ok" }));
5469
+ app.route("/", createGovernanceApp(options.governance));
4675
5470
  app.post("/evidence", async (c) => {
4676
5471
  let body;
4677
5472
  try {
@@ -4734,8 +5529,804 @@ function createSidebandApp(store, options = {}) {
4734
5529
  return app;
4735
5530
  }
4736
5531
 
4737
- // src/approval/queue.ts
5532
+ // src/sideband/governance-service.ts
4738
5533
  import { randomUUID as randomUUID4 } from "crypto";
5534
+
5535
+ // src/sideband/errors.ts
5536
+ var GovernanceConfigError = class extends Error {
5537
+ constructor(message) {
5538
+ super(message);
5539
+ this.name = "GovernanceConfigError";
5540
+ }
5541
+ };
5542
+
5543
+ // src/sideband/governance-service.ts
5544
+ var MAX_ORIGINS = 32;
5545
+ var MAX_TOOLS_PER_ORIGIN = 1024;
5546
+ var MAX_TOOL_INPUT_BYTES = 64 * 1024;
5547
+ var MAX_PENDING_COUNT = 1e4;
5548
+ var MAX_PENDING_BYTES = 64 * 1024 * 1024;
5549
+ var MAX_SENDER_KEYS = 5e4;
5550
+ var SWEEP_INTERVAL_MS2 = 3e4;
5551
+ var GovernanceService = class {
5552
+ policy;
5553
+ environment;
5554
+ evidenceStore;
5555
+ approvalRouter;
5556
+ rateLimiter;
5557
+ spendLimiter;
5558
+ auditWriter;
5559
+ approvalTimeoutMs;
5560
+ ttlMs;
5561
+ now;
5562
+ maxPending;
5563
+ maxPendingBytes;
5564
+ maxSenderKeys;
5565
+ /** Distinct sender_id limit keys with live state (reservation registry, issue #13). */
5566
+ senderKeys = /* @__PURE__ */ new Set();
5567
+ pending = /* @__PURE__ */ new Map();
5568
+ tombstones = /* @__PURE__ */ new Map();
5569
+ caches = /* @__PURE__ */ new Map();
5570
+ /** Native approval ticket id → its pending evaluation id, for on-access
5571
+ * deadline enforcement on the resolve path. */
5572
+ ticketToEvaluation = /* @__PURE__ */ new Map();
5573
+ pendingBytes = 0;
5574
+ sweepTimer = null;
5575
+ closed = false;
5576
+ constructor(options) {
5577
+ this.policy = options.policy;
5578
+ this.environment = options.environment;
5579
+ this.evidenceStore = options.evidenceStore;
5580
+ this.approvalRouter = options.approvalRouter;
5581
+ this.rateLimiter = options.rateLimiter;
5582
+ this.spendLimiter = options.spendLimiter;
5583
+ this.auditWriter = options.auditWriter;
5584
+ this.approvalTimeoutMs = options.approvalTimeoutMs ?? 3e5;
5585
+ this.ttlMs = options.ttlMs ?? 6e5;
5586
+ this.now = options.now ?? Date.now;
5587
+ this.maxPending = options.maxPending ?? MAX_PENDING_COUNT;
5588
+ this.maxPendingBytes = options.maxPendingBytes ?? MAX_PENDING_BYTES;
5589
+ this.maxSenderKeys = options.maxSenderKeys ?? MAX_SENDER_KEYS;
5590
+ this.assertApprovalRouter(this.policy);
5591
+ const sweepMs = options.sweepIntervalMs ?? SWEEP_INTERVAL_MS2;
5592
+ if (sweepMs > 0) {
5593
+ this.sweepTimer = setInterval(() => {
5594
+ this.sweep();
5595
+ }, sweepMs);
5596
+ this.sweepTimer.unref();
5597
+ }
5598
+ }
5599
+ /** Swap the compiled policy on hot-reload (mirrors GovernedForwarder). */
5600
+ updatePolicy(policy) {
5601
+ this.assertApprovalRouter(policy);
5602
+ this.policy = policy;
5603
+ }
5604
+ // -------------------------------------------------------------------------
5605
+ // POST /evaluate
5606
+ // -------------------------------------------------------------------------
5607
+ evaluate(req) {
5608
+ const reserved = reservedMetadataKey(req.metadata);
5609
+ if (reserved) {
5610
+ return { status: 400, body: { error: "reserved_metadata_key", key: reserved } };
5611
+ }
5612
+ const inputBytes = byteLength(req.arguments ?? {});
5613
+ if (inputBytes > MAX_TOOL_INPUT_BYTES) {
5614
+ return { status: 413, body: { error: "tool_input_too_large" } };
5615
+ }
5616
+ const entryBytes = inputBytes + byteLength(req.metadata ?? {});
5617
+ if (!this.caches.has(req.origin) && this.caches.size >= MAX_ORIGINS) {
5618
+ return { status: 400, body: { error: "origin_limit_exceeded" } };
5619
+ }
5620
+ if (this.pending.size >= this.maxPending || this.pendingBytes + entryBytes > this.maxPendingBytes) {
5621
+ return { status: 503, body: { error: "evaluation_backlog_full" } };
5622
+ }
5623
+ const cache = this.cacheFor(req.origin);
5624
+ const toolName = req.tool.name;
5625
+ const hasDefinition = definitionProvided(req.tool);
5626
+ if (hasDefinition) {
5627
+ if (!cache.has(toolName) && cache.size >= MAX_TOOLS_PER_ORIGIN) {
5628
+ return { status: 400, body: { error: "tool_baseline_limit" } };
5629
+ }
5630
+ cache.updateSingle(toMcpToolDef(req.tool));
5631
+ }
5632
+ const pipeline = decide({
5633
+ toolName,
5634
+ toolArguments: req.arguments,
5635
+ sessionId: req.session_id ?? void 0,
5636
+ policy: this.policy,
5637
+ environment: this.environment,
5638
+ evidenceStore: this.evidenceStore,
5639
+ baselineAnnotations: cache.get(toolName),
5640
+ currentAnnotations: cache.getCurrent(toolName),
5641
+ driftEvent: cache.getDrift(toolName),
5642
+ metadata: req.metadata ?? void 0,
5643
+ agentId: req.agent_id ?? void 0
5644
+ });
5645
+ const { decision } = pipeline;
5646
+ const evaluationId = randomUUID4();
5647
+ const timestampIso = new Date(this.now()).toISOString();
5648
+ let wire;
5649
+ let limitPlan;
5650
+ let limitsBlock;
5651
+ const senderId = senderIdOf(req.metadata);
5652
+ if (pipeline.isDryRun) {
5653
+ wire = "dry_run";
5654
+ } else if (decision.action === "deny") {
5655
+ wire = "deny";
5656
+ } else if (decision.action === "require_approval") {
5657
+ wire = "require_approval";
5658
+ } else if (decision.action === "rate_limit") {
5659
+ const planned = this.planRate(decision, toolName, req.session_id, senderId);
5660
+ if (planned?.plan && !this.reserveSenderKey(planned.plan.key)) {
5661
+ return { status: 503, body: { error: "limit_capacity_exhausted" } };
5662
+ }
5663
+ limitPlan = planned?.plan;
5664
+ limitsBlock = planned?.block ? { rate: planned.block } : void 0;
5665
+ wire = planned?.allowed ? "allow" : "rate_limited";
5666
+ } else if (decision.action === "spend_limit") {
5667
+ const planned = this.planSpend(decision, toolName, req.session_id, req.arguments, senderId);
5668
+ if (planned?.plan && !this.reserveSenderKey(planned.plan.key)) {
5669
+ return { status: 503, body: { error: "limit_capacity_exhausted" } };
5670
+ }
5671
+ limitPlan = planned?.plan;
5672
+ limitsBlock = planned?.block ? { spend: planned.block } : void 0;
5673
+ wire = planned?.allowed ? "allow" : "spend_limited";
5674
+ } else {
5675
+ wire = "allow";
5676
+ }
5677
+ const matchedRuleName = decision.matchedRule?.name ?? null;
5678
+ const matchedRuleIndex = decision.matchedRule?.index ?? null;
5679
+ const responseBody = {
5680
+ evaluation_id: evaluationId,
5681
+ decision: wire,
5682
+ reason: decision.reason,
5683
+ matched_rule: matchedRuleName,
5684
+ matched_rule_index: matchedRuleIndex
5685
+ };
5686
+ if (isBlocking(wire)) {
5687
+ responseBody["feedback"] = buildFeedback(decision.matchedRule, decision.reason);
5688
+ }
5689
+ if (limitsBlock) responseBody["limits"] = limitsBlock;
5690
+ if (wire === "dry_run") {
5691
+ responseBody["dry_run"] = {
5692
+ would_forward: decision.action === "allow" && !pipeline.evidenceBlocked,
5693
+ evidence_satisfied: !pipeline.evidenceBlocked,
5694
+ limits_ok: true
5695
+ };
5696
+ }
5697
+ if (pipeline.driftEvent) {
5698
+ responseBody["tool_drift"] = { changes: pipeline.driftEvent.changes };
5699
+ }
5700
+ if (isTerminalAtEvaluate(wire)) {
5701
+ const auditId = this.writeAudit({
5702
+ timestampIso,
5703
+ origin: req.origin,
5704
+ agentId: req.agent_id,
5705
+ sessionId: req.session_id,
5706
+ toolName,
5707
+ toolInput: req.arguments ?? {},
5708
+ metadata: req.metadata,
5709
+ action: decision.action,
5710
+ wire,
5711
+ matchedRuleName,
5712
+ matchedRuleIndex,
5713
+ flaggedDestructive: pipeline.flaggedDestructive,
5714
+ dryRun: wire === "dry_run",
5715
+ recordKind: "tool_call",
5716
+ limitsChain: limitsBlock
5717
+ });
5718
+ this.tombstones.set(evaluationId, {
5719
+ auditRecordId: auditId,
5720
+ payloadHash: null,
5721
+ finalizedBy: "evaluate",
5722
+ expiresAtMs: this.now() + this.ttlMs
5723
+ });
5724
+ return { status: 200, body: responseBody };
5725
+ }
5726
+ let approvalTicketId;
5727
+ let ticketTimeoutAtMs;
5728
+ if (wire === "require_approval") {
5729
+ const router = this.approvalRouter;
5730
+ if (!router) {
5731
+ throw new GovernanceConfigError(
5732
+ "[helio] invariant violation: require_approval decision without an approvalRouter"
5733
+ );
5734
+ }
5735
+ const timeoutMs = decision.matchedRule?.approval?.timeoutMs ?? this.approvalTimeoutMs;
5736
+ const ticket = router.createNativeTicket({
5737
+ tool_name: toolName,
5738
+ tool_input: req.arguments ?? {},
5739
+ matched_rule: decision.matchedRule,
5740
+ session_id: req.session_id,
5741
+ origin: req.origin,
5742
+ timeout_ms: timeoutMs
5743
+ });
5744
+ approvalTicketId = ticket.id;
5745
+ ticketTimeoutAtMs = this.now() + timeoutMs;
5746
+ responseBody["approval"] = {
5747
+ id: ticket.id,
5748
+ timeout_ms: timeoutMs,
5749
+ resolve_path: `/approval/${ticket.id}/resolve`
5750
+ };
5751
+ }
5752
+ const entry = {
5753
+ evaluationId,
5754
+ origin: req.origin,
5755
+ agentId: req.agent_id,
5756
+ sessionId: req.session_id,
5757
+ toolName,
5758
+ toolInput: req.arguments ?? {},
5759
+ metadata: req.metadata,
5760
+ action: decision.action,
5761
+ matchedRuleName,
5762
+ matchedRuleIndex,
5763
+ flaggedDestructive: pipeline.flaggedDestructive,
5764
+ limitPlan,
5765
+ approvalTicketId,
5766
+ timestampIso,
5767
+ createdAtMs: this.now(),
5768
+ evaluationExpiresAtMs: this.now() + this.ttlMs,
5769
+ ticketTimeoutAtMs,
5770
+ bytes: entryBytes
5771
+ };
5772
+ this.pending.set(evaluationId, entry);
5773
+ this.pendingBytes += entryBytes;
5774
+ if (approvalTicketId) this.ticketToEvaluation.set(approvalTicketId, evaluationId);
5775
+ return { status: 200, body: responseBody };
5776
+ }
5777
+ // -------------------------------------------------------------------------
5778
+ // POST /audit
5779
+ // -------------------------------------------------------------------------
5780
+ audit(req, payloadHash) {
5781
+ const id = req.evaluation_id;
5782
+ const tomb = this.tombstones.get(id);
5783
+ if (tomb) {
5784
+ if (tomb.finalizedBy === "expired") {
5785
+ return { status: 404, body: { error: "evaluation_expired" } };
5786
+ }
5787
+ if (tomb.finalizedBy === "evaluate") {
5788
+ return {
5789
+ status: 200,
5790
+ body: {
5791
+ ok: true,
5792
+ audit_record_id: tomb.auditRecordId,
5793
+ already_finalized: true,
5794
+ finalized_by: "evaluate"
5795
+ }
5796
+ };
5797
+ }
5798
+ if (tomb.payloadHash === payloadHash) {
5799
+ return {
5800
+ status: 200,
5801
+ body: { ok: true, audit_record_id: tomb.auditRecordId, already_finalized: true }
5802
+ };
5803
+ }
5804
+ return { status: 409, body: { error: "evaluation_conflict" } };
5805
+ }
5806
+ const entry = this.pending.get(id);
5807
+ if (!entry) {
5808
+ return { status: 404, body: { error: "evaluation_unknown" } };
5809
+ }
5810
+ if (this.enforceDeadlines(entry) === "expired") {
5811
+ return { status: 404, body: { error: "evaluation_expired" } };
5812
+ }
5813
+ let approvalStatus = null;
5814
+ let approvedBy = null;
5815
+ if (entry.approvalTicketId) {
5816
+ const ticket = this.getTicketStatus(entry.approvalTicketId);
5817
+ const status = ticket?.status;
5818
+ if (!status || status === "pending") {
5819
+ return { status: 409, body: { error: "approval_unresolved" } };
5820
+ }
5821
+ approvalStatus = status;
5822
+ approvedBy = ticket.resolved_by ?? null;
5823
+ }
5824
+ if (req.actual_amount !== void 0) {
5825
+ if (!Number.isFinite(req.actual_amount) || req.actual_amount < 0) {
5826
+ return { status: 400, body: { error: "invalid_actual_amount" } };
5827
+ }
5828
+ if (entry.limitPlan?.kind !== "spend") {
5829
+ return { status: 400, body: { error: "no_spend_rule" } };
5830
+ }
5831
+ }
5832
+ const callHappened = req.status === "success" || req.status === "error";
5833
+ let limitsChain;
5834
+ if (callHappened && entry.limitPlan) {
5835
+ limitsChain = this.commitLimit(entry.limitPlan, req.actual_amount);
5836
+ }
5837
+ if (callHappened && this.evidenceStore && entry.sessionId) {
5838
+ this.evidenceStore.recordToolCall(entry.sessionId, entry.toolName, req.status === "success");
5839
+ }
5840
+ const auditId = this.writeAudit({
5841
+ timestampIso: entry.timestampIso,
5842
+ origin: entry.origin,
5843
+ agentId: entry.agentId,
5844
+ sessionId: entry.sessionId,
5845
+ toolName: entry.toolName,
5846
+ toolInput: entry.toolInput,
5847
+ metadata: entry.metadata,
5848
+ action: entry.action,
5849
+ wire: entry.action === "require_approval" ? "require_approval" : "allow",
5850
+ matchedRuleName: entry.matchedRuleName,
5851
+ matchedRuleIndex: entry.matchedRuleIndex,
5852
+ flaggedDestructive: entry.flaggedDestructive,
5853
+ dryRun: false,
5854
+ recordKind: "tool_call",
5855
+ limitsChain,
5856
+ approvalStatus,
5857
+ approvedBy,
5858
+ upstreamError: req.status === "error" ? req.error ?? "tool call failed" : null,
5859
+ upstreamResponse: req.result ?? null,
5860
+ upstreamLatencyMs: req.duration_ms ?? null
5861
+ });
5862
+ this.discardPending(entry);
5863
+ this.tombstones.set(id, {
5864
+ auditRecordId: auditId,
5865
+ payloadHash,
5866
+ finalizedBy: "audit",
5867
+ expiresAtMs: this.now() + this.ttlMs
5868
+ });
5869
+ return { status: 201, body: { ok: true, audit_record_id: auditId } };
5870
+ }
5871
+ // -------------------------------------------------------------------------
5872
+ // POST /install-scan — evaluates install-time policy (issue #13)
5873
+ // -------------------------------------------------------------------------
5874
+ installScan(req) {
5875
+ const reserved = reservedMetadataKey(req.metadata);
5876
+ if (reserved) {
5877
+ return { status: 400, body: { error: "reserved_metadata_key", key: reserved } };
5878
+ }
5879
+ const evaluationId = randomUUID4();
5880
+ const toolName = `install:${req.package.source ?? "pkg"}:${req.package.name}`;
5881
+ const verdict = this.evaluateInstall(req);
5882
+ const denied = verdict.decision === "deny";
5883
+ const auditId = this.writeAudit({
5884
+ timestampIso: new Date(this.now()).toISOString(),
5885
+ origin: req.origin,
5886
+ agentId: req.agent_id,
5887
+ sessionId: req.session_id,
5888
+ toolName,
5889
+ toolInput: { ...req.package },
5890
+ metadata: req.metadata,
5891
+ // policy_decision is 'deny' (NOT 'deny_install') so the dashboard renders a
5892
+ // blocked install as a block, not an allow. The install context lives in
5893
+ // record_kind + block_reason.
5894
+ action: denied ? "deny" : "allow",
5895
+ wire: denied ? "deny" : "allow",
5896
+ matchedRuleName: verdict.matchedRule?.name ?? null,
5897
+ matchedRuleIndex: verdict.matchedRule?.index ?? null,
5898
+ flaggedDestructive: false,
5899
+ dryRun: false,
5900
+ recordKind: "install_scan"
5901
+ });
5902
+ this.tombstones.set(evaluationId, {
5903
+ auditRecordId: auditId,
5904
+ payloadHash: null,
5905
+ finalizedBy: "evaluate",
5906
+ expiresAtMs: this.now() + this.ttlMs
5907
+ });
5908
+ const body = {
5909
+ evaluation_id: evaluationId,
5910
+ decision: verdict.decision,
5911
+ reason: verdict.reason,
5912
+ matched_rule: verdict.matchedRule?.name ?? null,
5913
+ matched_rule_index: verdict.matchedRule?.index ?? null
5914
+ };
5915
+ if (denied) {
5916
+ body["feedback"] = buildFeedback(verdict.matchedRule, verdict.reason);
5917
+ }
5918
+ return { status: 200, body };
5919
+ }
5920
+ /** First-match-wins evaluation of the compiled install policy (issue #13). */
5921
+ evaluateInstall(req) {
5922
+ const install = this.policy.install;
5923
+ if (!install) {
5924
+ return { decision: "allow", reason: "no install-time rules defined" };
5925
+ }
5926
+ const metadataView = req.agent_id != null ? { ...req.metadata ?? {}, agent_id: req.agent_id } : req.metadata ?? void 0;
5927
+ for (const rule of install.rules) {
5928
+ if (matchInstallRule(rule, req.package, metadataView)) {
5929
+ const label = rule.name ? `"${rule.name}"` : `install_rule[${String(rule.index)}]`;
5930
+ return {
5931
+ decision: rule.action === "deny_install" ? "deny" : "allow",
5932
+ matchedRule: rule,
5933
+ reason: `Matched ${label} \u2192 ${rule.action}`
5934
+ };
5935
+ }
5936
+ }
5937
+ return {
5938
+ decision: install.defaultAction,
5939
+ reason: `No matching install rule; default ${install.defaultAction}`
5940
+ };
5941
+ }
5942
+ // -------------------------------------------------------------------------
5943
+ // POST /approval/:id/resolve
5944
+ // -------------------------------------------------------------------------
5945
+ resolveApproval(ticketId, req) {
5946
+ if (!this.approvalRouter) {
5947
+ return { status: 503, body: { error: "governance_unavailable" } };
5948
+ }
5949
+ const ticket = this.getTicketStatus(ticketId);
5950
+ if (!ticket) {
5951
+ return { status: 404, body: { error: "ticket_not_found" } };
5952
+ }
5953
+ if (!ticket.channel_name.startsWith("native:")) {
5954
+ return { status: 409, body: { error: "not_a_native_ticket" } };
5955
+ }
5956
+ const evaluationId = this.ticketToEvaluation.get(ticketId);
5957
+ const entry = evaluationId ? this.pending.get(evaluationId) : void 0;
5958
+ if (entry) this.enforceDeadlines(entry);
5959
+ const current = this.getTicketStatus(ticketId);
5960
+ if (!current || current.status !== "pending") {
5961
+ return { status: 409, body: { error: "already_resolved", status: current?.status } };
5962
+ }
5963
+ const resolved = this.approvalRouter.resolveNativeTicket(
5964
+ ticketId,
5965
+ req.resolution,
5966
+ req.resolved_by,
5967
+ { denial_reason: req.resolution === "denied" ? req.reason : void 0 }
5968
+ );
5969
+ if (!resolved) {
5970
+ return { status: 409, body: { error: "already_resolved" } };
5971
+ }
5972
+ return { status: 200, body: { ok: true } };
5973
+ }
5974
+ // -------------------------------------------------------------------------
5975
+ // Sweep — GC backstop for callers that never return
5976
+ // -------------------------------------------------------------------------
5977
+ sweep() {
5978
+ for (const entry of [...this.pending.values()]) {
5979
+ this.enforceDeadlines(entry);
5980
+ }
5981
+ const now = this.now();
5982
+ for (const [id, tomb] of this.tombstones) {
5983
+ if (tomb.expiresAtMs <= now) this.tombstones.delete(id);
5984
+ }
5985
+ this.pruneSenderKeys();
5986
+ }
5987
+ /**
5988
+ * Reserve a cardinality slot for a sender-keyed limit (issue #13).
5989
+ *
5990
+ * Only `sender:*` keys are gated — tool/session families are bounded by upstream
5991
+ * cardinality, and the MCP path never reaches here, so structural traffic cannot
5992
+ * be starved. A key already backed by live state (registry or a live limiter
5993
+ * bucket) costs no new slot. At capacity we lazily prune dead keys before failing
5994
+ * closed, so an emptied bucket frees its slot without waiting for the sweep.
5995
+ */
5996
+ reserveSenderKey(key) {
5997
+ if (!key.startsWith("sender:")) return true;
5998
+ if (this.senderKeys.has(key)) return true;
5999
+ if (this.hasLiveBucket(key)) {
6000
+ this.senderKeys.add(key);
6001
+ return true;
6002
+ }
6003
+ if (this.senderKeys.size >= this.maxSenderKeys) {
6004
+ this.pruneSenderKeys();
6005
+ if (this.senderKeys.size >= this.maxSenderKeys) return false;
6006
+ }
6007
+ this.senderKeys.add(key);
6008
+ return true;
6009
+ }
6010
+ /** Drop registry keys with no pending evaluation AND no live limiter bucket. */
6011
+ pruneSenderKeys() {
6012
+ if (this.senderKeys.size === 0) return;
6013
+ const inUse = /* @__PURE__ */ new Set();
6014
+ for (const entry of this.pending.values()) {
6015
+ if (entry.limitPlan && entry.limitPlan.key.startsWith("sender:")) {
6016
+ inUse.add(entry.limitPlan.key);
6017
+ }
6018
+ }
6019
+ for (const key of this.senderKeys) {
6020
+ if (inUse.has(key)) continue;
6021
+ if (this.hasLiveBucket(key)) continue;
6022
+ this.senderKeys.delete(key);
6023
+ }
6024
+ }
6025
+ /**
6026
+ * Whether either limiter still holds a live bucket for `key`. Uses the public
6027
+ * `getKeyState()` — never the limiters' private maps — and its lazy eviction of
6028
+ * an emptied bucket IS the prune-on-touch mechanism.
6029
+ */
6030
+ hasLiveBucket(key) {
6031
+ return this.rateLimiter?.getKeyState(key) !== void 0 || this.spendLimiter?.getKeyState(key) !== void 0;
6032
+ }
6033
+ close() {
6034
+ if (this.closed) return;
6035
+ this.closed = true;
6036
+ if (this.sweepTimer) {
6037
+ clearInterval(this.sweepTimer);
6038
+ this.sweepTimer = null;
6039
+ }
6040
+ this.pending.clear();
6041
+ this.tombstones.clear();
6042
+ this.caches.clear();
6043
+ this.senderKeys.clear();
6044
+ this.pendingBytes = 0;
6045
+ }
6046
+ // -------------------------------------------------------------------------
6047
+ // Internals
6048
+ // -------------------------------------------------------------------------
6049
+ /** Apply crossed deadlines to one pending entry. Returns its post-state. */
6050
+ enforceDeadlines(entry) {
6051
+ const now = this.now();
6052
+ if (now >= entry.evaluationExpiresAtMs) {
6053
+ if (entry.approvalTicketId) {
6054
+ this.approvalRouter?.resolveNativeTicket(entry.approvalTicketId, "timeout");
6055
+ }
6056
+ const auditId = this.writeAudit({
6057
+ timestampIso: entry.timestampIso,
6058
+ origin: entry.origin,
6059
+ agentId: entry.agentId,
6060
+ sessionId: entry.sessionId,
6061
+ toolName: entry.toolName,
6062
+ toolInput: entry.toolInput,
6063
+ metadata: entry.metadata,
6064
+ action: entry.action,
6065
+ wire: entry.action === "require_approval" ? "require_approval" : "allow",
6066
+ matchedRuleName: entry.matchedRuleName,
6067
+ matchedRuleIndex: entry.matchedRuleIndex,
6068
+ flaggedDestructive: entry.flaggedDestructive,
6069
+ dryRun: false,
6070
+ recordKind: "evaluation_expired",
6071
+ sidebandUnreported: true
6072
+ });
6073
+ this.discardPending(entry);
6074
+ this.tombstones.set(entry.evaluationId, {
6075
+ auditRecordId: auditId,
6076
+ payloadHash: null,
6077
+ finalizedBy: "expired",
6078
+ expiresAtMs: now + this.ttlMs
6079
+ });
6080
+ console.error(
6081
+ `[helio] Sideband evaluation ${entry.evaluationId} expired without /audit (origin=${entry.origin}, tool=${entry.toolName}) \u2014 recorded as evaluation_expired`
6082
+ );
6083
+ return "expired";
6084
+ }
6085
+ if (entry.approvalTicketId && entry.ticketTimeoutAtMs !== void 0 && now >= entry.ticketTimeoutAtMs) {
6086
+ this.approvalRouter?.resolveNativeTicket(entry.approvalTicketId, "timeout");
6087
+ }
6088
+ return "active";
6089
+ }
6090
+ cacheFor(origin) {
6091
+ let cache = this.caches.get(origin);
6092
+ if (!cache) {
6093
+ cache = new ToolAnnotationCache();
6094
+ this.caches.set(origin, cache);
6095
+ }
6096
+ return cache;
6097
+ }
6098
+ discardPending(entry) {
6099
+ if (this.pending.delete(entry.evaluationId)) {
6100
+ this.pendingBytes -= entry.bytes;
6101
+ }
6102
+ if (entry.approvalTicketId) this.ticketToEvaluation.delete(entry.approvalTicketId);
6103
+ }
6104
+ getTicketStatus(ticketId) {
6105
+ return this.approvalRouter?.getTicket(ticketId);
6106
+ }
6107
+ planRate(decision, toolName, sessionId, senderId) {
6108
+ const limits = decision.matchedRule?.limits;
6109
+ if (!this.rateLimiter || !limits?.maxCalls || !limits.windowMs) {
6110
+ return { allowed: true };
6111
+ }
6112
+ const key = buildLimitKey(limits.key, toolName, sessionId, senderId);
6113
+ const peek = this.rateLimiter.peek({
6114
+ key,
6115
+ maxCalls: limits.maxCalls,
6116
+ windowMs: limits.windowMs
6117
+ });
6118
+ return {
6119
+ plan: { kind: "rate", key, limits },
6120
+ block: {
6121
+ current: peek.current,
6122
+ limit: peek.limit,
6123
+ window_ms: peek.windowMs,
6124
+ reset_at_ms: peek.resetAtMs
6125
+ },
6126
+ allowed: peek.allowed
6127
+ };
6128
+ }
6129
+ planSpend(decision, toolName, sessionId, args, senderId) {
6130
+ const maxSpend = decision.matchedRule?.limits?.maxSpend;
6131
+ if (!this.spendLimiter || !maxSpend) return { allowed: true };
6132
+ const key = buildLimitKey(maxSpend.key, toolName, sessionId, senderId);
6133
+ const rawAmount = resolvePath(maxSpend.field, args ?? {});
6134
+ if (typeof rawAmount !== "number" || !Number.isFinite(rawAmount) || rawAmount < 0) {
6135
+ return { allowed: false, block: { reason: "invalid_amount", limit: maxSpend.limit } };
6136
+ }
6137
+ const peek = this.spendLimiter.peek({
6138
+ key,
6139
+ amount: rawAmount,
6140
+ limit: maxSpend.limit,
6141
+ windowMs: maxSpend.windowMs
6142
+ });
6143
+ return {
6144
+ plan: {
6145
+ kind: "spend",
6146
+ key,
6147
+ limits: decision.matchedRule.limits,
6148
+ amount: rawAmount,
6149
+ currency: maxSpend.currency
6150
+ },
6151
+ block: {
6152
+ current_spend: peek.currentSpend,
6153
+ limit: peek.limit,
6154
+ currency: maxSpend.currency,
6155
+ window_ms: peek.windowMs,
6156
+ reset_at_ms: peek.resetAtMs
6157
+ },
6158
+ allowed: peek.allowed
6159
+ };
6160
+ }
6161
+ /** Commit a limit plan at /audit time and return the evidence_chain block. */
6162
+ commitLimit(plan, actualAmount) {
6163
+ if (plan.kind === "rate" && this.rateLimiter && plan.limits.maxCalls && plan.limits.windowMs) {
6164
+ const r = this.rateLimiter.record({
6165
+ key: plan.key,
6166
+ maxCalls: plan.limits.maxCalls,
6167
+ windowMs: plan.limits.windowMs
6168
+ });
6169
+ return {
6170
+ rate_limit: {
6171
+ allowed: r.allowed,
6172
+ current: r.current,
6173
+ limit: r.limit,
6174
+ window_ms: r.windowMs,
6175
+ reset_at_ms: r.resetAtMs
6176
+ }
6177
+ };
6178
+ }
6179
+ if (plan.kind === "spend" && this.spendLimiter && plan.limits.maxSpend) {
6180
+ const amount = actualAmount ?? plan.amount ?? 0;
6181
+ const r = this.spendLimiter.record({
6182
+ key: plan.key,
6183
+ amount,
6184
+ limit: plan.limits.maxSpend.limit,
6185
+ windowMs: plan.limits.maxSpend.windowMs
6186
+ });
6187
+ this.spendLimiter.setCurrency(plan.key, plan.limits.maxSpend.currency);
6188
+ return {
6189
+ spend_limit: {
6190
+ allowed: r.allowed,
6191
+ current_spend: r.currentSpend,
6192
+ limit: r.limit,
6193
+ window_ms: r.windowMs,
6194
+ reset_at_ms: r.resetAtMs
6195
+ }
6196
+ };
6197
+ }
6198
+ return void 0;
6199
+ }
6200
+ writeAudit(args) {
6201
+ const id = randomUUID4();
6202
+ if (!this.auditWriter) return id;
6203
+ const blockReason = deriveBlockReason(args);
6204
+ let evidenceChain = args.limitsChain ?? null;
6205
+ if (args.sidebandUnreported) {
6206
+ evidenceChain = { ...evidenceChain ?? {}, sideband: { unreported: true } };
6207
+ }
6208
+ const record = {
6209
+ timestamp: args.timestampIso,
6210
+ session_id: args.sessionId,
6211
+ agent_id: args.agentId,
6212
+ environment: this.environment ?? null,
6213
+ tool_name: args.toolName,
6214
+ tool_input: args.toolInput,
6215
+ policy_decision: args.action,
6216
+ block_reason: blockReason,
6217
+ matched_rule: args.matchedRuleName,
6218
+ matched_rule_index: args.matchedRuleIndex,
6219
+ evidence_chain: evidenceChain,
6220
+ approval_status: args.approvalStatus ?? null,
6221
+ approved_by: args.approvedBy ?? null,
6222
+ upstream_response: args.upstreamResponse ?? null,
6223
+ upstream_error: args.upstreamError ?? null,
6224
+ upstream_http_status: null,
6225
+ upstream_latency_ms: args.upstreamLatencyMs ?? null,
6226
+ total_duration_ms: 0,
6227
+ approval_wait_ms: 0,
6228
+ proxy_compute_ms: 0,
6229
+ flagged_destructive: args.flaggedDestructive,
6230
+ dry_run: args.dryRun,
6231
+ record_kind: args.recordKind,
6232
+ origin: args.origin,
6233
+ metadata: args.metadata
6234
+ };
6235
+ const isEnforcement = args.recordKind === "evaluation_expired" || blockReason !== null || args.approvalStatus != null;
6236
+ if (isEnforcement) this.auditWriter.pushImmediate(record, id);
6237
+ else this.auditWriter.push(record, id);
6238
+ return id;
6239
+ }
6240
+ assertApprovalRouter(policy) {
6241
+ if (!policyCanRequireApproval(policy) || this.approvalRouter) return;
6242
+ throw new GovernanceConfigError(
6243
+ "[helio] GovernanceService misconfiguration: approval-capable policy (a require_approval rule, or flag_destructive/on_tool_drift set to require_approval) requires an approvalRouter"
6244
+ );
6245
+ }
6246
+ };
6247
+ function deriveBlockReason(args) {
6248
+ if (args.recordKind === "evaluation_expired") return null;
6249
+ if (args.recordKind === "install_scan") return args.wire === "deny" ? "install_denied" : null;
6250
+ if (args.dryRun) return null;
6251
+ if (args.approvalStatus === "denied") return "approval_denied";
6252
+ if (args.approvalStatus === "timeout") return "approval_timeout";
6253
+ if (args.approvalStatus === "cancelled") return "cancelled";
6254
+ switch (args.wire) {
6255
+ case "deny":
6256
+ return "policy_denied";
6257
+ case "rate_limited":
6258
+ return "rate_limited";
6259
+ case "spend_limited":
6260
+ return "spend_limited";
6261
+ default:
6262
+ return null;
6263
+ }
6264
+ }
6265
+ function buildFeedback(rule, reason) {
6266
+ const message = rule?.feedback?.message ?? reason;
6267
+ const suggestion = rule?.feedback?.suggestion;
6268
+ return suggestion ? { message, suggestion } : { message };
6269
+ }
6270
+ function isBlocking(wire) {
6271
+ return wire === "deny" || wire === "rate_limited" || wire === "spend_limited";
6272
+ }
6273
+ function isTerminalAtEvaluate(wire) {
6274
+ return wire === "deny" || wire === "rate_limited" || wire === "spend_limited" || wire === "dry_run";
6275
+ }
6276
+ function policyCanRequireApproval(policy) {
6277
+ if (policy.flagDestructive === "require_approval" || policy.onToolDrift === "require_approval") {
6278
+ return true;
6279
+ }
6280
+ return policy.rules.some((rule) => rule.action === "require_approval");
6281
+ }
6282
+ function buildLimitKey(keyType, toolName, sessionId, senderId) {
6283
+ switch (keyType) {
6284
+ case "session":
6285
+ return `session:${sessionId ?? "unknown"}`;
6286
+ case "sender_id":
6287
+ return `sender:${senderId ?? "unknown"}`;
6288
+ case "agent":
6289
+ case "tool":
6290
+ default:
6291
+ return `tool:${toolName}`;
6292
+ }
6293
+ }
6294
+ function senderIdOf(metadata) {
6295
+ const v = metadata?.["sender_id"];
6296
+ return typeof v === "string" ? v : null;
6297
+ }
6298
+ function matchInstallRule(rule, pkg2, metadataView) {
6299
+ if (rule.match.name && !rule.match.name.test(pkg2.name)) return false;
6300
+ if (rule.match.source !== void 0 && rule.match.source !== pkg2.source) return false;
6301
+ if (rule.match.metadata && !matchMetadata(rule.match.metadata, { metadata: metadataView })) {
6302
+ return false;
6303
+ }
6304
+ return true;
6305
+ }
6306
+ function reservedMetadataKey(metadata) {
6307
+ if (metadata && Object.prototype.hasOwnProperty.call(metadata, "agent_id")) {
6308
+ return "agent_id";
6309
+ }
6310
+ return null;
6311
+ }
6312
+ function definitionProvided(tool) {
6313
+ return tool.description !== void 0 || tool.input_schema !== void 0 || tool.output_schema !== void 0 || tool.title !== void 0 || tool.annotations !== void 0;
6314
+ }
6315
+ function toMcpToolDef(tool) {
6316
+ const def = { name: tool.name };
6317
+ if (tool.description !== void 0) def["description"] = tool.description;
6318
+ if (tool.input_schema !== void 0) def["inputSchema"] = tool.input_schema;
6319
+ if (tool.output_schema !== void 0) def["outputSchema"] = tool.output_schema;
6320
+ if (tool.title !== void 0) def["title"] = tool.title;
6321
+ if (tool.annotations !== void 0) def["annotations"] = tool.annotations;
6322
+ return def;
6323
+ }
6324
+ function byteLength(value) {
6325
+ return Buffer.byteLength(canonicalize(value), "utf8");
6326
+ }
6327
+
6328
+ // src/approval/queue.ts
6329
+ import { randomUUID as randomUUID5 } from "crypto";
4739
6330
  var ApprovalQueue = class {
4740
6331
  tickets = /* @__PURE__ */ new Map();
4741
6332
  now;
@@ -4765,7 +6356,7 @@ var ApprovalQueue = class {
4765
6356
  if (this.closed) throw new Error("ApprovalQueue is closed");
4766
6357
  const now = this.now();
4767
6358
  const ticket = {
4768
- id: randomUUID4(),
6359
+ id: randomUUID5(),
4769
6360
  tool_name: params.tool_name,
4770
6361
  tool_input: params.tool_input,
4771
6362
  matched_rule: params.matched_rule,
@@ -4844,6 +6435,7 @@ var ApprovalQueue = class {
4844
6435
  };
4845
6436
 
4846
6437
  // src/approval/router.ts
6438
+ var NATIVE_CHANNEL_PREFIX = "native:";
4847
6439
  var ApprovalRouter = class {
4848
6440
  defaultTimeoutMs;
4849
6441
  defaultOnTimeout;
@@ -4956,6 +6548,59 @@ var ApprovalRouter = class {
4956
6548
  });
4957
6549
  return outcome;
4958
6550
  }
6551
+ /**
6552
+ * Create a native (adapter-owned) approval ticket without holding a Promise.
6553
+ *
6554
+ * Used by the sideband governance path (issue #12, D10): when `/evaluate`
6555
+ * yields `require_approval`, the adapter runs the approval in its own UI
6556
+ * (e.g. OpenClaw's Telegram dialog), so Helio must NOT block, start
6557
+ * timeout/escalation timers, or notify a channel — doing so would
6558
+ * double-notify. We still create the queue ticket and fire `onSubmit` so the
6559
+ * dashboard's `approval_requested` SSE event flows and the ticket is visible.
6560
+ *
6561
+ * The ticket's `channel_name` is `native:<origin>`, which marks it as
6562
+ * adapter-owned: the dashboard approve/deny endpoints refuse it (it can only
6563
+ * be resolved through the adapter), and {@link resolveNativeTicket} is the
6564
+ * resolution path.
6565
+ */
6566
+ createNativeTicket(params) {
6567
+ const rule = params.matched_rule;
6568
+ const timeoutMs = params.timeout_ms ?? rule?.approval?.timeoutMs ?? this.defaultTimeoutMs;
6569
+ const ticket = this.queue.add({
6570
+ tool_name: params.tool_name,
6571
+ tool_input: params.tool_input,
6572
+ matched_rule: rule?.name ?? null,
6573
+ rule_index: rule?.index ?? null,
6574
+ channel_name: `${NATIVE_CHANNEL_PREFIX}${params.origin}`,
6575
+ session_id: params.session_id,
6576
+ timeout_ms: timeoutMs
6577
+ });
6578
+ this.onSubmit?.(ticket);
6579
+ return ticket;
6580
+ }
6581
+ /**
6582
+ * Resolve a native ticket created by {@link createNativeTicket}.
6583
+ *
6584
+ * Resolves the queue ticket and fires `onResolve` (→ `approval_resolved`
6585
+ * SSE), with no held Promise to settle. Refuses tickets that have a pending
6586
+ * router Promise (those are MCP-path tickets; resolving them here would leave
6587
+ * the held request hanging) and tickets that are not `native:`-prefixed.
6588
+ *
6589
+ * @returns `true` if resolved, `false` if not found, already resolved, not a
6590
+ * native ticket, or router-managed.
6591
+ */
6592
+ resolveNativeTicket(ticketId, status, resolvedBy, options) {
6593
+ if (this.pending.has(ticketId)) return false;
6594
+ const ticket = this.queue.get(ticketId);
6595
+ if (!ticket || !ticket.channel_name.startsWith(NATIVE_CHANNEL_PREFIX)) return false;
6596
+ const resolved = this.queue.resolve(ticketId, status, resolvedBy, {
6597
+ denial_reason: options?.denial_reason
6598
+ });
6599
+ if (!resolved) return false;
6600
+ const updated = this.queue.get(ticketId);
6601
+ if (updated) this.onResolve?.(updated);
6602
+ return true;
6603
+ }
4959
6604
  /**
4960
6605
  * Approve a pending ticket. Resolves the held Promise so the governed
4961
6606
  * forwarder can forward the request upstream.
@@ -5000,6 +6645,10 @@ var ApprovalRouter = class {
5000
6645
  ticketId
5001
6646
  });
5002
6647
  }
6648
+ /** Look up a ticket by id (delegates to the queue). */
6649
+ getTicket(ticketId) {
6650
+ return this.queue.get(ticketId);
6651
+ }
5003
6652
  /** Clean up all pending timers and resolve all pending promises. */
5004
6653
  close() {
5005
6654
  this.closed = true;
@@ -5262,8 +6911,8 @@ function createChannels(channels) {
5262
6911
 
5263
6912
  // src/approval/slack-actions.ts
5264
6913
  import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
5265
- import { Hono as Hono5 } from "hono";
5266
- import { z as z5 } from "zod";
6914
+ import { Hono as Hono6 } from "hono";
6915
+ import { z as z6 } from "zod";
5267
6916
  var MAX_TIMESTAMP_AGE_S = 300;
5268
6917
  var REJECTION_LOG_WINDOW_MS = 6e4;
5269
6918
  var REJECTION_LOG_SAMPLE_EVERY = 25;
@@ -5329,12 +6978,12 @@ function verifySlackSignature(secrets, timestamp, rawBody, signature) {
5329
6978
  }
5330
6979
  return false;
5331
6980
  }
5332
- var slackActionPayloadSchema = z5.object({
5333
- type: z5.string(),
5334
- user: z5.object({ id: z5.string(), username: z5.string() }),
5335
- actions: z5.array(z5.object({ action_id: z5.string() })),
5336
- channel: z5.object({ id: z5.string() }),
5337
- message: z5.object({ ts: z5.string() })
6981
+ var slackActionPayloadSchema = z6.object({
6982
+ type: z6.string(),
6983
+ user: z6.object({ id: z6.string(), username: z6.string() }),
6984
+ actions: z6.array(z6.object({ action_id: z6.string() })),
6985
+ channel: z6.object({ id: z6.string() }),
6986
+ message: z6.object({ ts: z6.string() })
5338
6987
  });
5339
6988
  function parseActionPayload(rawBody) {
5340
6989
  try {
@@ -5364,7 +7013,7 @@ Ticket \`${ticketId}\``
5364
7013
  }
5365
7014
  function createSlackActionApp(options) {
5366
7015
  const { router, channels } = options;
5367
- const app = new Hono5();
7016
+ const app = new Hono6();
5368
7017
  const rejectionLogBuckets = /* @__PURE__ */ new Map();
5369
7018
  const rejectUnauthorized = (c, reason, context) => {
5370
7019
  logRejectedSlackCallback(rejectionLogBuckets, {
@@ -5463,18 +7112,18 @@ function createSlackActionApp(options) {
5463
7112
  }
5464
7113
 
5465
7114
  // src/approval/api.ts
5466
- import { Hono as Hono6 } from "hono";
5467
- import { z as z6 } from "zod";
5468
- var approveBody = z6.object({
5469
- approved_by: z6.string().min(1)
7115
+ import { Hono as Hono7 } from "hono";
7116
+ import { z as z7 } from "zod";
7117
+ var approveBody = z7.object({
7118
+ approved_by: z7.string().min(1)
5470
7119
  });
5471
- var denyBody = z6.object({
5472
- denied_by: z6.string().min(1),
5473
- reason: z6.string().optional()
7120
+ var denyBody = z7.object({
7121
+ denied_by: z7.string().min(1),
7122
+ reason: z7.string().optional()
5474
7123
  });
5475
- var breakGlassBody = z6.object({
5476
- approved_by: z6.string().min(1),
5477
- reason: z6.string().min(1)
7124
+ var breakGlassBody = z7.object({
7125
+ approved_by: z7.string().min(1),
7126
+ reason: z7.string().min(1)
5478
7127
  });
5479
7128
  var APPROVAL_STATUSES = [
5480
7129
  "pending",
@@ -5483,25 +7132,26 @@ var APPROVAL_STATUSES = [
5483
7132
  "timeout",
5484
7133
  "break_glass",
5485
7134
  "client_disconnected",
5486
- "shutdown_cancelled"
7135
+ "shutdown_cancelled",
7136
+ "cancelled"
5487
7137
  ];
5488
7138
  var approvalStatusSet = new Set(APPROVAL_STATUSES);
5489
- var listApprovalsQuery = z6.object({
5490
- status: z6.preprocess(
7139
+ var listApprovalsQuery = z7.object({
7140
+ status: z7.preprocess(
5491
7141
  (value) => typeof value === "string" && approvalStatusSet.has(value) ? value : void 0,
5492
- z6.enum(APPROVAL_STATUSES).optional()
7142
+ z7.enum(APPROVAL_STATUSES).optional()
5493
7143
  ),
5494
- limit: z6.preprocess(
7144
+ limit: z7.preprocess(
5495
7145
  (value) => clampInt(typeof value === "string" ? value : void 0, 50, 1, 1e3),
5496
- z6.number().int()
7146
+ z7.number().int()
5497
7147
  ),
5498
- offset: z6.preprocess(
7148
+ offset: z7.preprocess(
5499
7149
  (value) => clampInt(typeof value === "string" ? value : void 0, 0, 0, Number.MAX_SAFE_INTEGER),
5500
- z6.number().int()
7150
+ z7.number().int()
5501
7151
  )
5502
7152
  });
5503
7153
  function createApprovalApp(router, queue, options) {
5504
- const app = new Hono6();
7154
+ const app = new Hono7();
5505
7155
  const apiSecret = options?.apiSecret;
5506
7156
  if (apiSecret) {
5507
7157
  app.use("*", async (c, next) => {
@@ -5547,6 +7197,15 @@ function createApprovalApp(router, queue, options) {
5547
7197
  if (!ticket) {
5548
7198
  return c.json({ error: "Ticket not found" }, 404);
5549
7199
  }
7200
+ if (ticket.channel_name.startsWith(NATIVE_CHANNEL_PREFIX)) {
7201
+ return c.json(
7202
+ {
7203
+ error: "native_ticket",
7204
+ resolve_in: ticket.channel_name.slice(NATIVE_CHANNEL_PREFIX.length)
7205
+ },
7206
+ 409
7207
+ );
7208
+ }
5550
7209
  if (ticket.status !== "pending") {
5551
7210
  return c.json({ error: "Ticket already resolved", status: ticket.status }, 409);
5552
7211
  }
@@ -5572,6 +7231,15 @@ function createApprovalApp(router, queue, options) {
5572
7231
  if (!ticket) {
5573
7232
  return c.json({ error: "Ticket not found" }, 404);
5574
7233
  }
7234
+ if (ticket.channel_name.startsWith(NATIVE_CHANNEL_PREFIX)) {
7235
+ return c.json(
7236
+ {
7237
+ error: "native_ticket",
7238
+ resolve_in: ticket.channel_name.slice(NATIVE_CHANNEL_PREFIX.length)
7239
+ },
7240
+ 409
7241
+ );
7242
+ }
5575
7243
  if (ticket.status !== "pending") {
5576
7244
  return c.json({ error: "Ticket already resolved", status: ticket.status }, 409);
5577
7245
  }
@@ -5597,6 +7265,15 @@ function createApprovalApp(router, queue, options) {
5597
7265
  if (!ticket) {
5598
7266
  return c.json({ error: "Ticket not found" }, 404);
5599
7267
  }
7268
+ if (ticket.channel_name.startsWith(NATIVE_CHANNEL_PREFIX)) {
7269
+ return c.json(
7270
+ {
7271
+ error: "native_ticket",
7272
+ resolve_in: ticket.channel_name.slice(NATIVE_CHANNEL_PREFIX.length)
7273
+ },
7274
+ 409
7275
+ );
7276
+ }
5600
7277
  if (ticket.status !== "pending") {
5601
7278
  return c.json({ error: "Ticket already resolved", status: ticket.status }, 409);
5602
7279
  }
@@ -5612,9 +7289,9 @@ function createApprovalApp(router, queue, options) {
5612
7289
  // src/dashboard/api.ts
5613
7290
  import { readFileSync } from "fs";
5614
7291
  import { join } from "path";
5615
- import { randomUUID as randomUUID5 } from "crypto";
5616
- import { Hono as Hono7 } from "hono";
5617
- import { z as z7 } from "zod";
7292
+ import { randomUUID as randomUUID6 } from "crypto";
7293
+ import { Hono as Hono8 } from "hono";
7294
+ import { z as z8 } from "zod";
5618
7295
  import { cors } from "hono/cors";
5619
7296
  import { serveStatic } from "@hono/node-server/serve-static";
5620
7297
  import { streamSSE } from "hono/streaming";
@@ -5675,7 +7352,7 @@ function recordsToCsv(records) {
5675
7352
  }
5676
7353
 
5677
7354
  // src/dashboard/session.ts
5678
- import { createHash as createHash2, createHmac as createHmac3, randomBytes, timingSafeEqual as timingSafeEqual3 } from "crypto";
7355
+ import { createHash as createHash3, createHmac as createHmac3, randomBytes, timingSafeEqual as timingSafeEqual3 } from "crypto";
5679
7356
  var DashboardSessionStore = class {
5680
7357
  secret;
5681
7358
  ttlMs;
@@ -5756,8 +7433,8 @@ var DashboardSessionStore = class {
5756
7433
  const id = token.slice(0, dot);
5757
7434
  const signature = token.slice(dot + 1);
5758
7435
  const expected = this.sign(id);
5759
- const actualDigest = createHash2("sha256").update(signature).digest();
5760
- const expectedDigest = createHash2("sha256").update(expected).digest();
7436
+ const actualDigest = createHash3("sha256").update(signature).digest();
7437
+ const expectedDigest = createHash3("sha256").update(expected).digest();
5761
7438
  if (!timingSafeEqual3(actualDigest, expectedDigest)) return void 0;
5762
7439
  return id;
5763
7440
  }
@@ -5767,29 +7444,29 @@ var DashboardSessionStore = class {
5767
7444
  };
5768
7445
 
5769
7446
  // src/dashboard/api.ts
5770
- var optionalQueryString = z7.preprocess(
7447
+ var optionalQueryString = z8.preprocess(
5771
7448
  (value) => typeof value === "string" && value.length > 0 ? value : void 0,
5772
- z7.string().optional()
7449
+ z8.string().optional()
5773
7450
  );
5774
- var optionalQueryInt = z7.preprocess((value) => {
7451
+ var optionalQueryInt = z8.preprocess((value) => {
5775
7452
  if (typeof value !== "string" || value.length === 0) return void 0;
5776
7453
  const parsed = Number.parseInt(value, 10);
5777
7454
  return Number.isFinite(parsed) ? parsed : void 0;
5778
- }, z7.number().int().optional());
5779
- var queryBoolean = z7.preprocess(
7455
+ }, z8.number().int().optional());
7456
+ var queryBoolean = z8.preprocess(
5780
7457
  (value) => value === "true" ? true : value === "false" ? false : void 0,
5781
- z7.boolean().optional()
7458
+ z8.boolean().optional()
5782
7459
  );
5783
- var clampedQueryInt = (fallback, min, max) => z7.preprocess(
7460
+ var clampedQueryInt = (fallback, min, max) => z8.preprocess(
5784
7461
  (value) => clampInt(typeof value === "string" ? value : void 0, fallback, min, max),
5785
- z7.number().int()
7462
+ z8.number().int()
5786
7463
  );
5787
- var feedQuerySchema = z7.object({
7464
+ var feedQuerySchema = z8.object({
5788
7465
  limit: clampedQueryInt(50, 1, 200),
5789
7466
  offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER)
5790
7467
  });
5791
- var auditExportQuerySchema = z7.object({
5792
- format: z7.preprocess((value) => value === "csv" ? "csv" : "json", z7.enum(["json", "csv"])),
7468
+ var auditExportQuerySchema = z8.object({
7469
+ format: z8.preprocess((value) => value === "csv" ? "csv" : "json", z8.enum(["json", "csv"])),
5793
7470
  limit: clampedQueryInt(1e4, 1, 1e4),
5794
7471
  tool: optionalQueryString,
5795
7472
  decision: optionalQueryString,
@@ -5801,9 +7478,13 @@ var auditExportQuerySchema = z7.object({
5801
7478
  from: optionalQueryString,
5802
7479
  to: optionalQueryString,
5803
7480
  upstream_status_min: optionalQueryInt,
5804
- upstream_status_max: optionalQueryInt
7481
+ upstream_status_max: optionalQueryInt,
7482
+ origin: optionalQueryString,
7483
+ record_kind: optionalQueryString,
7484
+ channel_id: optionalQueryString,
7485
+ sender_id: optionalQueryString
5805
7486
  });
5806
- var auditQuerySchema = z7.object({
7487
+ var auditQuerySchema = z8.object({
5807
7488
  limit: clampedQueryInt(50, 1, 1e3),
5808
7489
  offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER),
5809
7490
  tool: optionalQueryString,
@@ -5817,14 +7498,18 @@ var auditQuerySchema = z7.object({
5817
7498
  destructive: queryBoolean,
5818
7499
  dry_run: queryBoolean,
5819
7500
  upstream_status_min: optionalQueryInt,
5820
- upstream_status_max: optionalQueryInt
7501
+ upstream_status_max: optionalQueryInt,
7502
+ origin: optionalQueryString,
7503
+ record_kind: optionalQueryString,
7504
+ channel_id: optionalQueryString,
7505
+ sender_id: optionalQueryString
5821
7506
  });
5822
- var analyticsQuerySchema = z7.object({
7507
+ var analyticsQuerySchema = z8.object({
5823
7508
  from: optionalQueryString,
5824
7509
  to: optionalQueryString
5825
7510
  });
5826
- var authSessionBodySchema = z7.object({
5827
- secret: z7.string()
7511
+ var authSessionBodySchema = z8.object({
7512
+ secret: z8.string()
5828
7513
  });
5829
7514
  var SESSION_COOKIE = "helio_session";
5830
7515
  var SESSION_TTL_MS = 8 * 60 * 60 * 1e3;
@@ -5884,7 +7569,7 @@ function createDashboardAppWithLifecycle(deps, options) {
5884
7569
  } = deps;
5885
7570
  const apiSecret = options?.apiSecret;
5886
7571
  const sessionStore = apiSecret ? new DashboardSessionStore({ secret: apiSecret, ttlMs: SESSION_TTL_MS }) : void 0;
5887
- const app = new Hono7();
7572
+ const app = new Hono8();
5888
7573
  app.use(
5889
7574
  "*",
5890
7575
  cors({
@@ -6024,7 +7709,11 @@ function createDashboardAppWithLifecycle(deps, options) {
6024
7709
  from: query.from,
6025
7710
  to: query.to,
6026
7711
  upstream_status_min: query.upstream_status_min,
6027
- upstream_status_max: query.upstream_status_max
7712
+ upstream_status_max: query.upstream_status_max,
7713
+ origin: query.origin,
7714
+ record_kind: query.record_kind,
7715
+ channel_id: query.channel_id,
7716
+ sender_id: query.sender_id
6028
7717
  };
6029
7718
  const result = auditStore.list(filters, { limit, order: "asc" });
6030
7719
  if (format === "csv") {
@@ -6066,7 +7755,11 @@ function createDashboardAppWithLifecycle(deps, options) {
6066
7755
  flagged_destructive: query.destructive,
6067
7756
  dry_run: query.dry_run,
6068
7757
  upstream_status_min: query.upstream_status_min,
6069
- upstream_status_max: query.upstream_status_max
7758
+ upstream_status_max: query.upstream_status_max,
7759
+ origin: query.origin,
7760
+ record_kind: query.record_kind,
7761
+ channel_id: query.channel_id,
7762
+ sender_id: query.sender_id
6070
7763
  };
6071
7764
  const result = auditStore.list(filters, { limit, offset, order: "desc" });
6072
7765
  return c.json({
@@ -6127,7 +7820,7 @@ function createDashboardAppWithLifecycle(deps, options) {
6127
7820
  app.get("/api/events", (c) => {
6128
7821
  return streamSSE(c, async (stream) => {
6129
7822
  if (closed) return;
6130
- const connId = randomUUID5();
7823
+ const connId = randomUUID6();
6131
7824
  let streamClosed = false;
6132
7825
  let stopHeartbeat = () => {
6133
7826
  };
@@ -6156,7 +7849,7 @@ function createDashboardAppWithLifecycle(deps, options) {
6156
7849
  void stream.writeSSE({
6157
7850
  event: eventType,
6158
7851
  data: JSON.stringify(data),
6159
- id: randomUUID5()
7852
+ id: randomUUID6()
6160
7853
  }).then(() => {
6161
7854
  const conn = activeConnections.get(connId);
6162
7855
  if (conn) conn.lastWrite = Date.now();
@@ -6435,7 +8128,9 @@ async function startAnnotationPrimeLoop(governedForwarder) {
6435
8128
  primed = true;
6436
8129
  clearRetryTimer();
6437
8130
  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`);
8131
+ console.error(
8132
+ `${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)`
8133
+ );
6439
8134
  return;
6440
8135
  }
6441
8136
  const reason = result.reason ?? "unknown reason";
@@ -6525,7 +8220,9 @@ async function startCommand(configPath, options) {
6525
8220
  flagged_destructive: record.flagged_destructive,
6526
8221
  dry_run: record.dry_run,
6527
8222
  matched_rule: record.matched_rule,
6528
- matched_rule_index: record.matched_rule_index
8223
+ matched_rule_index: record.matched_rule_index,
8224
+ record_kind: record.record_kind,
8225
+ origin: record.origin
6529
8226
  });
6530
8227
  }
6531
8228
  });
@@ -6604,6 +8301,8 @@ async function startCommand(configPath, options) {
6604
8301
  let sidebandHandle;
6605
8302
  let sidebandToken;
6606
8303
  let sidebandTokenSource;
8304
+ let adapterToken;
8305
+ let governanceService;
6607
8306
  if (config.sdk.enabled) {
6608
8307
  sidebandToken = process.env["HELIO_SDK_TOKEN"];
6609
8308
  if (!sidebandToken || sidebandToken.length === 0) {
@@ -6613,7 +8312,27 @@ async function startCommand(configPath, options) {
6613
8312
  } else {
6614
8313
  sidebandTokenSource = "env";
6615
8314
  }
6616
- const sidebandApp = createSidebandApp(evidenceStore, { token: sidebandToken });
8315
+ adapterToken = process.env["HELIO_ADAPTER_TOKEN"];
8316
+ if (!adapterToken || adapterToken.length === 0) {
8317
+ adapterToken = randomBytes2(32).toString("hex");
8318
+ process.env["HELIO_ADAPTER_TOKEN"] = adapterToken;
8319
+ }
8320
+ governanceService = new GovernanceService({
8321
+ policy,
8322
+ environment: config.environment,
8323
+ evidenceStore,
8324
+ approvalRouter,
8325
+ rateLimiter,
8326
+ spendLimiter,
8327
+ auditWriter,
8328
+ approvalTimeoutMs: parseDuration(config.approval.timeout),
8329
+ ttlMs: parseDuration(config.sdk.evaluation_ttl)
8330
+ });
8331
+ const sidebandApp = createSidebandApp(evidenceStore, {
8332
+ token: sidebandToken,
8333
+ adapterToken,
8334
+ governance: governanceService
8335
+ });
6617
8336
  sidebandHandle = startSidebandServer(sidebandApp, config.sdk.port, config.sdk.host);
6618
8337
  }
6619
8338
  let dashboardHandle;
@@ -6664,6 +8383,12 @@ async function startCommand(configPath, options) {
6664
8383
  ${sidebandToken}`
6665
8384
  );
6666
8385
  }
8386
+ if (adapterToken) {
8387
+ console.error(
8388
+ `Adapter token (governance routes; pass as HELIO_ADAPTER_TOKEN to your adapter):
8389
+ ${adapterToken}`
8390
+ );
8391
+ }
6667
8392
  }
6668
8393
  if (dashboardHandle) {
6669
8394
  console.error(
@@ -6691,6 +8416,7 @@ async function startCommand(configPath, options) {
6691
8416
  initialConfig: config,
6692
8417
  onPolicyReload: (newPolicy, reloadWarnings, restartRequiredPaths) => {
6693
8418
  governedForwarder.updatePolicy(newPolicy);
8419
+ governanceService?.updatePolicy(newPolicy);
6694
8420
  const count = newPolicy.rules.length;
6695
8421
  console.error(
6696
8422
  `[helio] Policy reloaded: ${String(count)} rule${count !== 1 ? "s" : ""} (default: ${newPolicy.defaultAction})`
@@ -6734,7 +8460,8 @@ async function startCommand(configPath, options) {
6734
8460
  spendLimiter,
6735
8461
  closeDashboardApp,
6736
8462
  dashboardHandle,
6737
- eventBus
8463
+ eventBus,
8464
+ governanceService
6738
8465
  );
6739
8466
  }
6740
8467
  async function initCommand(outputPath, force) {
@@ -6842,7 +8569,7 @@ function writeCsv(records) {
6842
8569
  console.log(values.join(","));
6843
8570
  }
6844
8571
  }
6845
- function registerShutdown(handle, annotationPrime, closeForwarder, auditWriter, configWatcher, sidebandHandle, evidenceStore, approvalRouter, approvalQueue, rateLimiter, spendLimiter, closeDashboardApp, dashboardHandle, eventBus) {
8572
+ function registerShutdown(handle, annotationPrime, closeForwarder, auditWriter, configWatcher, sidebandHandle, evidenceStore, approvalRouter, approvalQueue, rateLimiter, spendLimiter, closeDashboardApp, dashboardHandle, eventBus, governanceService) {
6846
8573
  let isShuttingDown = false;
6847
8574
  const shutdown = () => {
6848
8575
  if (isShuttingDown) return;
@@ -6858,6 +8585,7 @@ function registerShutdown(handle, annotationPrime, closeForwarder, auditWriter,
6858
8585
  if (configWatcher) configWatcher.close();
6859
8586
  if (closeDashboardApp) closeDashboardApp();
6860
8587
  if (eventBus) eventBus.close();
8588
+ if (governanceService) governanceService.close();
6861
8589
  if (rateLimiter) rateLimiter.close();
6862
8590
  if (spendLimiter) spendLimiter.close();
6863
8591
  if (approvalRouter) approvalRouter.close();