@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/index.js CHANGED
@@ -105,11 +105,23 @@ var annotationsMatchSchema = z.object({
105
105
  idempotentHint: z.boolean().optional(),
106
106
  openWorldHint: z.boolean().optional()
107
107
  }).strict();
108
+ var metadataConditionSchema = z.union([
109
+ z.string(),
110
+ z.object({
111
+ eq: z.string().optional(),
112
+ neq: z.string().optional(),
113
+ contains: z.string().optional(),
114
+ regex: z.string().optional()
115
+ }).strict().refine((obj) => Object.keys(obj).length > 0, {
116
+ message: "At least one metadata condition operator is required"
117
+ })
118
+ ]);
108
119
  var matchSchema = z.object({
109
120
  tool: z.string().optional(),
110
121
  annotations: annotationsMatchSchema.optional(),
111
122
  input: z.record(z.string(), inputConditionSchema).optional(),
112
- environment: z.string().optional()
123
+ environment: z.string().optional(),
124
+ metadata: z.record(z.string(), metadataConditionSchema).optional()
113
125
  }).strict();
114
126
  var policyActionSchema = z.enum([
115
127
  "allow",
@@ -133,12 +145,12 @@ var spendLimitSchema = z.object({
133
145
  limit: z.number(),
134
146
  currency: z.string(),
135
147
  window: durationSchema,
136
- key: z.enum(["tool", "agent", "session"]).optional()
148
+ key: z.enum(["tool", "agent", "session", "sender_id"]).optional()
137
149
  }).strict();
138
150
  var limitsSchema = z.object({
139
151
  max_calls: z.number().int().positive().optional(),
140
152
  window: durationSchema.optional(),
141
- key: z.enum(["tool", "agent", "session"]).optional(),
153
+ key: z.enum(["tool", "agent", "session", "sender_id"]).optional(),
142
154
  max_spend: spendLimitSchema.optional()
143
155
  }).strict();
144
156
  var feedbackSchema = z.object({
@@ -156,11 +168,43 @@ var policyRuleSchema = z.object({
156
168
  limits: limitsSchema.optional(),
157
169
  feedback: feedbackSchema.optional()
158
170
  }).strict();
171
+ var installMatchSchema = z.object({
172
+ name: z.string().optional(),
173
+ // glob, picomatch (same engine as match.tool)
174
+ source: z.string().optional(),
175
+ // exact ecosystem match (npm | pip | …)
176
+ metadata: z.record(z.string(), metadataConditionSchema).optional()
177
+ }).strict();
178
+ var installRuleSchema = z.object({
179
+ name: z.string().optional(),
180
+ match: installMatchSchema,
181
+ action: z.enum(["deny_install", "allow"]),
182
+ feedback: feedbackSchema.optional()
183
+ }).strict();
184
+ var installSchema = z.object({
185
+ default: z.enum(["allow", "deny"]).default("allow"),
186
+ rules: z.array(installRuleSchema).default([])
187
+ }).strict();
159
188
  var policiesSchema = z.object({
160
189
  default: z.enum(["allow", "deny"]).default("allow"),
161
190
  flag_destructive: z.enum(["log", "require_approval"]).optional(),
162
191
  dry_run: z.boolean().default(false),
163
192
  rules: z.array(policyRuleSchema).default([]),
193
+ /** Install-time policy (issue #13 — deny_install). Optional; absent ⇒ observational. */
194
+ install: installSchema.optional(),
195
+ /**
196
+ * How to treat calls to a tool whose definition (annotations, schemas,
197
+ * description) has drifted from the baseline Helio captured on first
198
+ * sight.
199
+ * - "block": deny the call until the proxy is restarted (re-baselines)
200
+ * or the upstream reverts. Conservative default when omitted.
201
+ * - "require_approval": escalate the call through the approval channel.
202
+ * - "log": audit the drift; rules evaluate against both baseline and
203
+ * current annotations and the stricter decision wins.
204
+ * Kept optional (like hot_reload) so PoliciesConfig literal fixtures
205
+ * don't need the field; undefined is treated as "block".
206
+ */
207
+ on_tool_drift: z.enum(["block", "require_approval", "log"]).optional(),
164
208
  /**
165
209
  * Whether `helio start` should watch the config file for changes and
166
210
  * reconcile policy state on every save. Defaults to `true` when omitted.
@@ -208,7 +252,14 @@ var auditSchema = z.object({
208
252
  var sdkSchema = z.object({
209
253
  enabled: z.boolean().default(false),
210
254
  port: z.number().int().min(1).max(65535).default(3200),
211
- host: z.string().default("127.0.0.1")
255
+ host: z.string().default("127.0.0.1"),
256
+ /**
257
+ * How long a sideband `/evaluate` decision waits for its `/audit` before the
258
+ * proxy finalizes it as `evaluation_expired` (issue #12, D4). Bounds the
259
+ * pending-evaluation registry; an adapter crash cannot silently drop a
260
+ * decided-allowed call from the trail.
261
+ */
262
+ evaluation_ttl: durationSchema.default("10m")
212
263
  });
213
264
  var helioConfigBaseSchema = z.object({
214
265
  version: z.literal("1"),
@@ -223,14 +274,14 @@ var helioConfigBaseSchema = z.object({
223
274
  });
224
275
  var helioConfigSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
225
276
  const hasConfiguredEnvironment = typeof cfg.environment === "string" && cfg.environment.trim().length > 0;
226
- const requiresSecret = cfg.policies.flag_destructive === "require_approval" || cfg.policies.rules.some((rule) => rule.action === "require_approval");
277
+ const requiresSecret = cfg.policies.flag_destructive === "require_approval" || cfg.policies.on_tool_drift === "require_approval" || cfg.policies.rules.some((rule) => rule.action === "require_approval");
227
278
  const hasSecret = hasDashboardApiSecret(cfg.dashboard.api_secret);
228
279
  if (requiresSecret) {
229
280
  if (!hasSecret) {
230
281
  ctx.addIssue({
231
282
  code: "custom",
232
283
  path: ["dashboard", "api_secret"],
233
- message: 'dashboard.api_secret is required when any rule uses require_approval or policies.flag_destructive is "require_approval". Generate one with: `openssl rand -hex 32` and set it under `dashboard.api_secret` in your helio.yaml. (See docs/approvals.md.)'
284
+ message: 'dashboard.api_secret is required when any rule uses require_approval or policies.flag_destructive or policies.on_tool_drift is "require_approval". Generate one with: `openssl rand -hex 32` and set it under `dashboard.api_secret` in your helio.yaml. (See docs/approvals.md.)'
234
285
  });
235
286
  }
236
287
  }
@@ -271,6 +322,22 @@ var helioConfigSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
271
322
  message: `Rule sets match.environment="${rule.match.environment}" but top-level \`environment\` is not configured. Set top-level environment to enable env-scoped rules.`
272
323
  });
273
324
  }
325
+ if (!cfg.sdk.enabled) {
326
+ if (rule.limits?.key === "sender_id") {
327
+ ctx.addIssue({
328
+ code: "custom",
329
+ path: ["policies", "rules", ruleIndex, "limits", "key"],
330
+ 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.'
331
+ });
332
+ }
333
+ if (rule.limits?.max_spend?.key === "sender_id") {
334
+ ctx.addIssue({
335
+ code: "custom",
336
+ path: ["policies", "rules", ruleIndex, "limits", "max_spend", "key"],
337
+ 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.'
338
+ });
339
+ }
340
+ }
274
341
  if (rule.action === "rate_limit") {
275
342
  if (rule.limits?.max_calls === void 0) {
276
343
  ctx.addIssue({
@@ -412,6 +479,7 @@ var PolicyParseError = class extends Error {
412
479
 
413
480
  // src/policy/parser.ts
414
481
  var INPUT_OPERATORS = ["eq", "neq", "gt", "gte", "lt", "lte", "contains", "regex"];
482
+ var METADATA_OPERATORS = ["eq", "neq", "contains", "regex"];
415
483
  function compilePolicies(config) {
416
484
  const warnings = [];
417
485
  const rules = config.rules.map((rule, index) => compileRule(rule, index, warnings));
@@ -419,10 +487,37 @@ function compilePolicies(config) {
419
487
  defaultAction: config.default,
420
488
  flagDestructive: config.flag_destructive,
421
489
  ...config.dry_run && { dryRun: true },
422
- rules
490
+ ...config.on_tool_drift && { onToolDrift: config.on_tool_drift },
491
+ rules,
492
+ ...config.install && { install: compileInstallPolicy(config.install) }
423
493
  };
424
494
  return { policy, warnings };
425
495
  }
496
+ function compileInstallPolicy(install) {
497
+ return {
498
+ defaultAction: install.default,
499
+ rules: install.rules.map((rule, index) => {
500
+ const name = rule.match.name !== void 0 ? compileToolMatcher(rule.match.name, index, rule.name) : void 0;
501
+ const metadata = rule.match.metadata !== void 0 ? flattenMetadataConditions(rule.match.metadata, index, rule.name) : void 0;
502
+ return {
503
+ index,
504
+ ...rule.name !== void 0 && { name: rule.name },
505
+ match: {
506
+ ...name !== void 0 && { name },
507
+ ...rule.match.source !== void 0 && { source: rule.match.source },
508
+ ...metadata !== void 0 && { metadata }
509
+ },
510
+ action: rule.action,
511
+ ...rule.feedback !== void 0 && {
512
+ feedback: {
513
+ message: rule.feedback.message,
514
+ ...rule.feedback.suggestion !== void 0 && { suggestion: rule.feedback.suggestion }
515
+ }
516
+ }
517
+ };
518
+ })
519
+ };
520
+ }
426
521
  function compileRule(rule, index, warnings) {
427
522
  const match = compileMatch(rule.match, index, rule.name);
428
523
  const approval = compileApproval(rule.approval);
@@ -455,7 +550,10 @@ function compileMatch(match, ruleIndex, ruleName) {
455
550
  ...match.input !== void 0 && {
456
551
  input: flattenInputConditions(match.input, ruleIndex, ruleName)
457
552
  },
458
- ...match.environment !== void 0 && { environment: match.environment }
553
+ ...match.environment !== void 0 && { environment: match.environment },
554
+ ...match.metadata !== void 0 && {
555
+ metadata: flattenMetadataConditions(match.metadata, ruleIndex, ruleName)
556
+ }
459
557
  };
460
558
  }
461
559
  function compileToolMatcher(pattern, ruleIndex, ruleName) {
@@ -507,6 +605,43 @@ function flattenInputConditions(input, ruleIndex, ruleName) {
507
605
  }
508
606
  return conditions;
509
607
  }
608
+ function flattenMetadataConditions(metadata, ruleIndex, ruleName) {
609
+ const conditions = [];
610
+ for (const [key, raw] of Object.entries(metadata)) {
611
+ if (typeof raw === "string") {
612
+ conditions.push({ key, operator: "eq", value: raw });
613
+ continue;
614
+ }
615
+ for (const op of METADATA_OPERATORS) {
616
+ const value = raw[op];
617
+ if (value === void 0) continue;
618
+ if (op === "regex") {
619
+ if (!safeRegex(value)) {
620
+ throw new PolicyParseError(
621
+ `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.`,
622
+ ruleIndex,
623
+ ruleName
624
+ );
625
+ }
626
+ let compiledRegex;
627
+ try {
628
+ compiledRegex = new RegExp(value);
629
+ } catch (err) {
630
+ const msg = err instanceof Error ? err.message : String(err);
631
+ throw new PolicyParseError(
632
+ `invalid regex "${value}" for metadata key "${key}": ${msg}`,
633
+ ruleIndex,
634
+ ruleName
635
+ );
636
+ }
637
+ conditions.push({ key, operator: op, value, regex: compiledRegex });
638
+ } else {
639
+ conditions.push({ key, operator: op, value });
640
+ }
641
+ }
642
+ }
643
+ return conditions;
644
+ }
510
645
  function compileApproval(approval) {
511
646
  if (!approval) return void 0;
512
647
  return {
@@ -2141,12 +2276,31 @@ function matchEnvironment(required, ctx) {
2141
2276
  if (ctx.environment === void 0) return false;
2142
2277
  return ctx.environment === required;
2143
2278
  }
2279
+ function matchMetadata(conditions, ctx) {
2280
+ if (conditions.length === 0) return true;
2281
+ if (ctx.metadata === void 0) return false;
2282
+ for (const condition of conditions) {
2283
+ const value = ctx.metadata[condition.key];
2284
+ const matched = evaluateCondition(
2285
+ {
2286
+ path: condition.key,
2287
+ operator: condition.operator,
2288
+ value: condition.value,
2289
+ regex: condition.regex
2290
+ },
2291
+ value
2292
+ );
2293
+ if (!matched) return false;
2294
+ }
2295
+ return true;
2296
+ }
2144
2297
  function matchRule(rule, ctx) {
2145
2298
  const { match } = rule;
2146
2299
  if (match.tool !== void 0 && !matchTool(match.tool, ctx)) return false;
2147
2300
  if (match.annotations !== void 0 && !matchAnnotations(match.annotations, ctx)) return false;
2148
2301
  if (match.input !== void 0 && !matchInput(match.input, ctx)) return false;
2149
2302
  if (match.environment !== void 0 && !matchEnvironment(match.environment, ctx)) return false;
2303
+ if (match.metadata !== void 0 && !matchMetadata(match.metadata, ctx)) return false;
2150
2304
  return true;
2151
2305
  }
2152
2306
 
@@ -2169,61 +2323,6 @@ function evaluatePolicy(policy, ctx) {
2169
2323
  };
2170
2324
  }
2171
2325
 
2172
- // src/policy/annotation-cache.ts
2173
- var ToolAnnotationCache = class {
2174
- cache = /* @__PURE__ */ new Map();
2175
- /** Number of tools currently cached. */
2176
- get size() {
2177
- return this.cache.size;
2178
- }
2179
- /**
2180
- * Update the cache from a tools/list JSON-RPC response body.
2181
- *
2182
- * Performs a full replacement — tools that existed in the previous cache
2183
- * but are absent from the new response are removed. This correctly handles
2184
- * tool list changes (additions, removals, annotation updates).
2185
- *
2186
- * @returns `true` if the response body was a valid tools/list response and
2187
- * the cache was updated, `false` if the body shape was unexpected.
2188
- */
2189
- update(responseBody) {
2190
- const tools = extractTools(responseBody);
2191
- if (!tools) return false;
2192
- this.cache.clear();
2193
- for (const tool of tools) {
2194
- if (typeof tool !== "object" || tool === null) continue;
2195
- const t = tool;
2196
- const name = t["name"];
2197
- if (typeof name !== "string") continue;
2198
- const annotations = t["annotations"];
2199
- if (annotations && typeof annotations === "object") {
2200
- this.cache.set(name, annotations);
2201
- } else {
2202
- this.cache.set(name, void 0);
2203
- }
2204
- }
2205
- return true;
2206
- }
2207
- /** Get cached annotations for a tool. Returns `undefined` if the tool is not in the cache. */
2208
- get(toolName) {
2209
- return this.cache.get(toolName);
2210
- }
2211
- /** Check whether a tool exists in the cache (regardless of whether it has annotations). */
2212
- has(toolName) {
2213
- return this.cache.has(toolName);
2214
- }
2215
- };
2216
- function extractTools(body) {
2217
- if (typeof body !== "object" || body === null) return null;
2218
- const b = body;
2219
- const result = b["result"];
2220
- if (typeof result !== "object" || result === null) return null;
2221
- const r = result;
2222
- const tools = r["tools"];
2223
- if (!Array.isArray(tools)) return null;
2224
- return tools;
2225
- }
2226
-
2227
2326
  // src/evidence/grounding.ts
2228
2327
  function checkEvidence(store, sessionId, requirements) {
2229
2328
  if (requirements.length === 0) {
@@ -2267,6 +2366,368 @@ function checkDependencies(store, sessionId, requirements, options = {}) {
2267
2366
  };
2268
2367
  }
2269
2368
 
2369
+ // src/policy/decision-pipeline.ts
2370
+ function decide(input) {
2371
+ const { toolName, toolArguments, sessionId, policy, environment, evidenceStore } = input;
2372
+ const annotations = input.baselineAnnotations;
2373
+ const driftEvent = input.driftEvent;
2374
+ const driftMode = policy.onToolDrift ?? "block";
2375
+ const metadata = buildMetadataView(input.metadata, input.agentId);
2376
+ let decision = evaluatePolicy(policy, {
2377
+ toolName,
2378
+ annotations,
2379
+ toolArguments,
2380
+ environment,
2381
+ metadata
2382
+ });
2383
+ if (driftEvent && driftMode === "log") {
2384
+ const currentDecision = evaluatePolicy(policy, {
2385
+ toolName,
2386
+ annotations: input.currentAnnotations,
2387
+ toolArguments,
2388
+ environment,
2389
+ metadata
2390
+ });
2391
+ decision = stricterDecision(decision, currentDecision);
2392
+ }
2393
+ const baselineDestructive = annotations?.destructiveHint ?? true;
2394
+ const currentDestructive = driftEvent && driftMode === "log" ? input.currentAnnotations?.destructiveHint ?? true : false;
2395
+ const isDestructive = baselineDestructive || currentDestructive;
2396
+ let flaggedDestructive = false;
2397
+ if (isDestructive && !decision.matchedRule && policy.flagDestructive) {
2398
+ flaggedDestructive = true;
2399
+ if (policy.flagDestructive === "log") {
2400
+ console.error(`[helio] Destructive tool detected: ${toolName} (no matching rule)`);
2401
+ } else {
2402
+ decision = {
2403
+ action: "require_approval",
2404
+ matchedRule: void 0,
2405
+ reason: `Destructive tool "${toolName}" auto-escalated by flag_destructive policy`
2406
+ };
2407
+ }
2408
+ }
2409
+ let driftBlocked = false;
2410
+ if (driftEvent && driftMode !== "log") {
2411
+ driftBlocked = driftMode === "block";
2412
+ decision = {
2413
+ action: driftMode === "block" ? "deny" : "require_approval",
2414
+ matchedRule: void 0,
2415
+ reason: `Tool "${toolName}" definition drifted from baseline (${driftEvent.changes.map((change) => change.aspect).join(", ")})`
2416
+ };
2417
+ }
2418
+ const originalAction = decision.action;
2419
+ let evidenceResult;
2420
+ let dependencyResult;
2421
+ let evidenceBlocked = false;
2422
+ let sessionBlocked = false;
2423
+ const requiresGroundedSession = decision.action !== "deny" && !!decision.matchedRule && ((decision.matchedRule.evidence?.requires.length ?? 0) > 0 || (decision.matchedRule.requires?.length ?? 0) > 0);
2424
+ if (requiresGroundedSession && !sessionId) {
2425
+ sessionBlocked = true;
2426
+ evidenceBlocked = true;
2427
+ decision = {
2428
+ action: "deny",
2429
+ matchedRule: decision.matchedRule,
2430
+ reason: "Mcp-Session-Id is required for evidence/dependency-gated policy rules"
2431
+ };
2432
+ }
2433
+ if (decision.action !== "deny" && evidenceStore && sessionId && decision.matchedRule) {
2434
+ const rule = decision.matchedRule;
2435
+ if (rule.evidence?.requires.length) {
2436
+ evidenceResult = checkEvidence(evidenceStore, sessionId, rule.evidence.requires);
2437
+ if (!evidenceResult.satisfied) {
2438
+ evidenceBlocked = true;
2439
+ const problemKeys = [...evidenceResult.missing, ...evidenceResult.expired];
2440
+ decision = {
2441
+ action: "deny",
2442
+ matchedRule: rule,
2443
+ reason: `Required evidence not satisfied: ${problemKeys.join(", ")}`
2444
+ };
2445
+ }
2446
+ }
2447
+ if (!evidenceBlocked && rule.requires?.length) {
2448
+ dependencyResult = checkDependencies(evidenceStore, sessionId, rule.requires, {
2449
+ requireSuccess: rule.requiresSuccess ?? true
2450
+ });
2451
+ if (!dependencyResult.satisfied) {
2452
+ evidenceBlocked = true;
2453
+ decision = {
2454
+ action: "deny",
2455
+ matchedRule: rule,
2456
+ reason: `Required tool calls not completed: ${dependencyResult.missing.join(", ")}`
2457
+ };
2458
+ }
2459
+ }
2460
+ }
2461
+ const isPerRuleDryRun = originalAction === "dry_run";
2462
+ const isGlobalDryRun = policy.dryRun === true;
2463
+ const isDryRun = (isPerRuleDryRun || isGlobalDryRun) && !sessionBlocked;
2464
+ return {
2465
+ decision,
2466
+ originalAction,
2467
+ driftEvent,
2468
+ driftMode,
2469
+ driftBlocked,
2470
+ flaggedDestructive,
2471
+ evidenceResult,
2472
+ dependencyResult,
2473
+ evidenceBlocked,
2474
+ sessionBlocked,
2475
+ isDryRun
2476
+ };
2477
+ }
2478
+ var ACTION_SEVERITY = {
2479
+ deny: 5,
2480
+ require_approval: 4,
2481
+ dry_run: 3,
2482
+ spend_limit: 2,
2483
+ rate_limit: 1,
2484
+ allow: 0
2485
+ };
2486
+ function stricterDecision(a, b) {
2487
+ return ACTION_SEVERITY[b.action] > ACTION_SEVERITY[a.action] ? b : a;
2488
+ }
2489
+ function buildMetadataView(metadata, agentId) {
2490
+ if (agentId === void 0) return metadata;
2491
+ return { ...metadata ?? {}, agent_id: agentId };
2492
+ }
2493
+
2494
+ // src/util/canonical-json.ts
2495
+ function canonicalize(value) {
2496
+ const encoded = JSON.stringify(sortKeysDeep(value));
2497
+ return encoded ?? "";
2498
+ }
2499
+ function sortKeysDeep(value) {
2500
+ if (Array.isArray(value)) return value.map(sortKeysDeep);
2501
+ if (value !== null && typeof value === "object") {
2502
+ const source = value;
2503
+ const out = {};
2504
+ for (const key of Object.keys(source).sort()) {
2505
+ Object.defineProperty(out, key, {
2506
+ value: sortKeysDeep(source[key]),
2507
+ enumerable: true,
2508
+ writable: true,
2509
+ configurable: true
2510
+ });
2511
+ }
2512
+ return out;
2513
+ }
2514
+ return value;
2515
+ }
2516
+
2517
+ // src/policy/annotation-cache.ts
2518
+ var ASPECT_FIELDS = [
2519
+ "annotations",
2520
+ "inputSchema",
2521
+ "description",
2522
+ "outputSchema",
2523
+ "title"
2524
+ ];
2525
+ var ToolAnnotationCache = class {
2526
+ baselines = /* @__PURE__ */ new Map();
2527
+ present = /* @__PURE__ */ new Set();
2528
+ currentAnnotations = /* @__PURE__ */ new Map();
2529
+ driftedTools = /* @__PURE__ */ new Map();
2530
+ /** Number of tools present in the most recent tools/list. */
2531
+ get size() {
2532
+ return this.present.size;
2533
+ }
2534
+ /** Diff a tools/list JSON-RPC response body against the baselines. */
2535
+ update(responseBody) {
2536
+ const tools = extractTools(responseBody);
2537
+ if (!tools) return { updated: false, baselined: [], drifted: [], reverted: [] };
2538
+ const baselined = [];
2539
+ const drifted = [];
2540
+ const reverted = [];
2541
+ const present = /* @__PURE__ */ new Set();
2542
+ const currentAnnotations = /* @__PURE__ */ new Map();
2543
+ const entries = [];
2544
+ const nameCounts = /* @__PURE__ */ new Map();
2545
+ for (const tool of tools) {
2546
+ if (typeof tool !== "object" || tool === null) continue;
2547
+ const t = tool;
2548
+ const name = t["name"];
2549
+ if (typeof name !== "string") continue;
2550
+ entries.push({ name, definition: t });
2551
+ nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1);
2552
+ }
2553
+ const duplicateNames = /* @__PURE__ */ new Set();
2554
+ for (const { name, definition: t } of entries) {
2555
+ const isDuplicate = (nameCounts.get(name) ?? 0) > 1;
2556
+ if (isDuplicate) {
2557
+ present.add(name);
2558
+ currentAnnotations.set(name, void 0);
2559
+ if (duplicateNames.has(name)) continue;
2560
+ duplicateNames.add(name);
2561
+ const baseline2 = this.baselines.get(name);
2562
+ const allDefinitions = entries.filter((e) => e.name === name).map((e) => e.definition);
2563
+ const changes2 = [
2564
+ {
2565
+ aspect: "duplicate",
2566
+ baseline: baseline2?.definition,
2567
+ current: allDefinitions
2568
+ }
2569
+ ];
2570
+ const event2 = { toolName: name, changes: changes2 };
2571
+ const existing2 = this.driftedTools.get(name);
2572
+ const isNewDrift2 = !existing2 || canonicalize(existing2.changes) !== canonicalize(changes2);
2573
+ this.driftedTools.set(name, event2);
2574
+ if (isNewDrift2) drifted.push(event2);
2575
+ continue;
2576
+ }
2577
+ present.add(name);
2578
+ const annotations = extractAnnotations(t);
2579
+ currentAnnotations.set(name, annotations);
2580
+ const definitionKey = canonicalize(t);
2581
+ const baseline = this.baselines.get(name);
2582
+ if (!baseline) {
2583
+ this.baselines.set(name, { definition: t, definitionKey, annotations });
2584
+ baselined.push(name);
2585
+ if (this.driftedTools.has(name)) {
2586
+ this.driftedTools.delete(name);
2587
+ reverted.push(name);
2588
+ }
2589
+ continue;
2590
+ }
2591
+ if (definitionKey === baseline.definitionKey) {
2592
+ if (this.driftedTools.has(name)) {
2593
+ this.driftedTools.delete(name);
2594
+ reverted.push(name);
2595
+ }
2596
+ continue;
2597
+ }
2598
+ const changes = [];
2599
+ for (const field of ASPECT_FIELDS) {
2600
+ const baselineValue = baseline.definition[field];
2601
+ const currentValue = t[field];
2602
+ if (canonicalize(baselineValue) !== canonicalize(currentValue)) {
2603
+ changes.push({ aspect: field, baseline: baselineValue, current: currentValue });
2604
+ }
2605
+ }
2606
+ if (changes.length === 0) {
2607
+ changes.push({ aspect: "other", baseline: baseline.definition, current: t });
2608
+ }
2609
+ const event = { toolName: name, changes };
2610
+ const existing = this.driftedTools.get(name);
2611
+ const isNewDrift = !existing || canonicalize(existing.changes) !== canonicalize(changes);
2612
+ this.driftedTools.set(name, event);
2613
+ if (isNewDrift) drifted.push(event);
2614
+ }
2615
+ this.present = present;
2616
+ this.currentAnnotations = currentAnnotations;
2617
+ return { updated: true, baselined, drifted, reverted };
2618
+ }
2619
+ /**
2620
+ * Incrementally merge a single tool definition into the cache (issue #12, D6).
2621
+ *
2622
+ * Unlike {@link update}, this touches only the named tool: it adds to (never
2623
+ * rebuilds) the `present` set and `currentAnnotations` map. The sideband
2624
+ * governance path feeds adapter-origin tools one definition at a time (each
2625
+ * `/evaluate` carries at most one), so routing them through the whole-list
2626
+ * `update()` would wipe every other tool's current-annotation snapshot on
2627
+ * each call and silently degrade the stricter-of-both log-mode drift
2628
+ * evaluation. The MCP whole-list path is unaffected — it keeps calling
2629
+ * `update()`. Each origin owns its own cache instance, so the accumulate
2630
+ * semantics here never mix with update()'s replace semantics.
2631
+ *
2632
+ * `toolDefinition` must already be in MCP shape (`inputSchema`/`outputSchema`
2633
+ * camelCase); the governance service maps the wire `tool` object before
2634
+ * calling. Returns the same result shape as `update()` (for one tool).
2635
+ */
2636
+ updateSingle(toolDefinition) {
2637
+ if (typeof toolDefinition !== "object" || toolDefinition === null) {
2638
+ return { updated: false, baselined: [], drifted: [], reverted: [] };
2639
+ }
2640
+ const t = toolDefinition;
2641
+ const name = t["name"];
2642
+ if (typeof name !== "string") {
2643
+ return { updated: false, baselined: [], drifted: [], reverted: [] };
2644
+ }
2645
+ const baselined = [];
2646
+ const drifted = [];
2647
+ const reverted = [];
2648
+ this.present.add(name);
2649
+ const annotations = extractAnnotations(t);
2650
+ this.currentAnnotations.set(name, annotations);
2651
+ const definitionKey = canonicalize(t);
2652
+ const baseline = this.baselines.get(name);
2653
+ if (!baseline) {
2654
+ this.baselines.set(name, { definition: t, definitionKey, annotations });
2655
+ baselined.push(name);
2656
+ if (this.driftedTools.has(name)) {
2657
+ this.driftedTools.delete(name);
2658
+ reverted.push(name);
2659
+ }
2660
+ return { updated: true, baselined, drifted, reverted };
2661
+ }
2662
+ if (definitionKey === baseline.definitionKey) {
2663
+ if (this.driftedTools.has(name)) {
2664
+ this.driftedTools.delete(name);
2665
+ reverted.push(name);
2666
+ }
2667
+ return { updated: true, baselined, drifted, reverted };
2668
+ }
2669
+ const changes = [];
2670
+ for (const field of ASPECT_FIELDS) {
2671
+ const baselineValue = baseline.definition[field];
2672
+ const currentValue = t[field];
2673
+ if (canonicalize(baselineValue) !== canonicalize(currentValue)) {
2674
+ changes.push({ aspect: field, baseline: baselineValue, current: currentValue });
2675
+ }
2676
+ }
2677
+ if (changes.length === 0) {
2678
+ changes.push({ aspect: "other", baseline: baseline.definition, current: t });
2679
+ }
2680
+ const event = { toolName: name, changes };
2681
+ const existing = this.driftedTools.get(name);
2682
+ const isNewDrift = !existing || canonicalize(existing.changes) !== canonicalize(changes);
2683
+ this.driftedTools.set(name, event);
2684
+ if (isNewDrift) drifted.push(event);
2685
+ return { updated: true, baselined, drifted, reverted };
2686
+ }
2687
+ /**
2688
+ * Get the **baseline** annotations for a tool — the definition first seen,
2689
+ * not the latest upstream claim. Returns `undefined` if the tool has no
2690
+ * annotations or was never seen.
2691
+ */
2692
+ get(toolName) {
2693
+ return this.baselines.get(toolName)?.annotations;
2694
+ }
2695
+ /**
2696
+ * Get the annotations from the most recent tools/list. Used for the
2697
+ * stricter-of-both evaluation of drifted tools in on_tool_drift: log mode.
2698
+ * Returns `undefined` for tools absent from the latest list.
2699
+ */
2700
+ getCurrent(toolName) {
2701
+ return this.currentAnnotations.get(toolName);
2702
+ }
2703
+ /** Whether the tool was present in the most recent tools/list. */
2704
+ has(toolName) {
2705
+ return this.present.has(toolName);
2706
+ }
2707
+ /** Whether the tool's current definition differs from its baseline. */
2708
+ isDrifted(toolName) {
2709
+ return this.driftedTools.has(toolName);
2710
+ }
2711
+ /** The active drift event for a tool, if any. */
2712
+ getDrift(toolName) {
2713
+ return this.driftedTools.get(toolName);
2714
+ }
2715
+ };
2716
+ function extractAnnotations(tool) {
2717
+ const annotations = tool["annotations"];
2718
+ return annotations && typeof annotations === "object" ? annotations : void 0;
2719
+ }
2720
+ function extractTools(body) {
2721
+ if (typeof body !== "object" || body === null) return null;
2722
+ const b = body;
2723
+ const result = b["result"];
2724
+ if (typeof result !== "object" || result === null) return null;
2725
+ const r = result;
2726
+ const tools = r["tools"];
2727
+ if (!Array.isArray(tools)) return null;
2728
+ return tools;
2729
+ }
2730
+
2270
2731
  // src/feedback/self-repair.ts
2271
2732
  function ruleInfo(rule) {
2272
2733
  return {
@@ -2414,6 +2875,19 @@ function buildRateLimitedFeedback(decision, result) {
2414
2875
  retry_allowed: true
2415
2876
  };
2416
2877
  }
2878
+ function buildToolDriftFeedback(drift, action) {
2879
+ const aspects = drift.changes.map((change) => change.aspect);
2880
+ return {
2881
+ blocked: true,
2882
+ reason: "tool_definition_drift",
2883
+ rule: null,
2884
+ ruleIndex: null,
2885
+ action,
2886
+ drifted_aspects: aspects,
2887
+ 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.`,
2888
+ retry_allowed: false
2889
+ };
2890
+ }
2417
2891
  function buildSpendLimitedFeedback(decision, result, currency) {
2418
2892
  const { rule, ruleIndex } = ruleInfo(decision.matchedRule);
2419
2893
  const windowSeconds = Math.round(result.windowMs / 1e3);
@@ -2465,6 +2939,7 @@ var GovernedForwarder = class {
2465
2939
  spendLimiter;
2466
2940
  annotationCache = new ToolAnnotationCache();
2467
2941
  agentKeyWarned = false;
2942
+ senderKeyWarned = false;
2468
2943
  constructor(inner, policy, options) {
2469
2944
  this.inner = inner;
2470
2945
  this.policy = policy;
@@ -2550,8 +3025,8 @@ var GovernedForwarder = class {
2550
3025
  reason: classifyPrimeFailure(result.response)
2551
3026
  };
2552
3027
  }
2553
- const updated = this.annotationCache.update(result.response.body);
2554
- if (!updated) {
3028
+ const update = this.applyToolDefinitionUpdate(result.response.body, void 0);
3029
+ if (!update.updated) {
2555
3030
  return {
2556
3031
  success: false,
2557
3032
  toolsCached: this.annotationCache.size,
@@ -2573,10 +3048,65 @@ var GovernedForwarder = class {
2573
3048
  }
2574
3049
  const result = await this.inner.forward(request);
2575
3050
  if (request.method === "tools/list") {
2576
- this.annotationCache.update(result.response.body);
3051
+ this.applyToolDefinitionUpdate(result.response.body, request.sessionId);
2577
3052
  }
2578
3053
  return result;
2579
3054
  }
3055
+ /**
3056
+ * Apply a tools/list response to the definition cache and surface any
3057
+ * drift: console warning + immediate audit record per event. Single entry
3058
+ * point for both runtime tools/list responses and startup priming, so the
3059
+ * cache is updated exactly once per response.
3060
+ */
3061
+ applyToolDefinitionUpdate(responseBody, sessionId) {
3062
+ const update = this.annotationCache.update(responseBody);
3063
+ if (!update.updated) return update;
3064
+ for (const drift of update.drifted) {
3065
+ const aspects = drift.changes.map((change) => change.aspect).join(", ");
3066
+ console.error(
3067
+ `[helio] Tool definition drift detected: "${drift.toolName}" changed (${aspects}) after baseline \u2014 calls governed by policies.on_tool_drift (${this.policy.onToolDrift ?? "block"})`
3068
+ );
3069
+ this.writeDriftAuditRecord(drift, sessionId, "tool_drift");
3070
+ }
3071
+ for (const toolName of update.reverted) {
3072
+ console.error(
3073
+ `[helio] Tool definition drift cleared: "${toolName}" returned to its baseline definition`
3074
+ );
3075
+ this.writeDriftAuditRecord({ toolName, changes: [] }, sessionId, "tool_drift_reverted");
3076
+ }
3077
+ return update;
3078
+ }
3079
+ /** Write an immediate audit record for a drift event (not a tool call). */
3080
+ writeDriftAuditRecord(drift, sessionId, decision) {
3081
+ if (!this.auditWriter) return;
3082
+ this.auditWriter.pushImmediate({
3083
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3084
+ session_id: sessionId ?? null,
3085
+ agent_id: null,
3086
+ environment: this.environment ?? null,
3087
+ tool_name: drift.toolName,
3088
+ tool_input: {},
3089
+ policy_decision: decision,
3090
+ block_reason: null,
3091
+ matched_rule: null,
3092
+ matched_rule_index: null,
3093
+ evidence_chain: decision === "tool_drift" ? { tool_drift: { changes: drift.changes } } : null,
3094
+ approval_status: null,
3095
+ approved_by: null,
3096
+ upstream_response: null,
3097
+ upstream_error: null,
3098
+ upstream_http_status: null,
3099
+ upstream_latency_ms: null,
3100
+ total_duration_ms: 0,
3101
+ approval_wait_ms: 0,
3102
+ proxy_compute_ms: 0,
3103
+ flagged_destructive: false,
3104
+ dry_run: false,
3105
+ record_kind: "drift_event",
3106
+ origin: "mcp",
3107
+ metadata: null
3108
+ });
3109
+ }
2580
3110
  async handleToolsCall(request) {
2581
3111
  const startTime = performance.now();
2582
3112
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
@@ -2586,77 +3116,28 @@ var GovernedForwarder = class {
2586
3116
  return this.inner.forward(request);
2587
3117
  }
2588
3118
  const toolArguments = params?.["arguments"] && typeof params["arguments"] === "object" ? params["arguments"] : void 0;
2589
- const annotations = this.annotationCache.get(toolName);
2590
- let decision = evaluatePolicy(this.policy, {
3119
+ const {
3120
+ decision,
3121
+ driftEvent,
3122
+ driftMode,
3123
+ driftBlocked,
3124
+ flaggedDestructive,
3125
+ evidenceResult,
3126
+ dependencyResult,
3127
+ evidenceBlocked,
3128
+ sessionBlocked,
3129
+ isDryRun
3130
+ } = decide({
2591
3131
  toolName,
2592
- annotations,
2593
3132
  toolArguments,
2594
- environment: this.environment
3133
+ sessionId: request.sessionId,
3134
+ policy: this.policy,
3135
+ environment: this.environment,
3136
+ evidenceStore: this.evidenceStore,
3137
+ baselineAnnotations: this.annotationCache.get(toolName),
3138
+ currentAnnotations: this.annotationCache.getCurrent(toolName),
3139
+ driftEvent: this.annotationCache.getDrift(toolName)
2595
3140
  });
2596
- const isDestructive = annotations?.destructiveHint ?? true;
2597
- let flaggedDestructive = false;
2598
- if (isDestructive && !decision.matchedRule && this.policy.flagDestructive) {
2599
- flaggedDestructive = true;
2600
- if (this.policy.flagDestructive === "log") {
2601
- console.error(`[helio] Destructive tool detected: ${toolName} (no matching rule)`);
2602
- } else {
2603
- decision = {
2604
- action: "require_approval",
2605
- matchedRule: void 0,
2606
- reason: `Destructive tool "${toolName}" auto-escalated by flag_destructive policy`
2607
- };
2608
- }
2609
- }
2610
- const originalAction = decision.action;
2611
- let evidenceResult;
2612
- let dependencyResult;
2613
- let evidenceBlocked = false;
2614
- let sessionBlocked = false;
2615
- const requiresGroundedSession = decision.action !== "deny" && !!decision.matchedRule && ((decision.matchedRule.evidence?.requires.length ?? 0) > 0 || (decision.matchedRule.requires?.length ?? 0) > 0);
2616
- if (requiresGroundedSession && !request.sessionId) {
2617
- sessionBlocked = true;
2618
- evidenceBlocked = true;
2619
- decision = {
2620
- action: "deny",
2621
- matchedRule: decision.matchedRule,
2622
- reason: "Mcp-Session-Id is required for evidence/dependency-gated policy rules"
2623
- };
2624
- }
2625
- if (decision.action !== "deny" && this.evidenceStore && request.sessionId && decision.matchedRule) {
2626
- const rule = decision.matchedRule;
2627
- if (rule.evidence?.requires.length) {
2628
- evidenceResult = checkEvidence(
2629
- this.evidenceStore,
2630
- request.sessionId,
2631
- rule.evidence.requires
2632
- );
2633
- if (!evidenceResult.satisfied) {
2634
- evidenceBlocked = true;
2635
- const problemKeys = [...evidenceResult.missing, ...evidenceResult.expired];
2636
- decision = {
2637
- action: "deny",
2638
- matchedRule: rule,
2639
- reason: `Required evidence not satisfied: ${problemKeys.join(", ")}`
2640
- };
2641
- }
2642
- }
2643
- if (!evidenceBlocked && rule.requires?.length) {
2644
- dependencyResult = checkDependencies(this.evidenceStore, request.sessionId, rule.requires, {
2645
- requireSuccess: rule.requiresSuccess ?? true
2646
- });
2647
- if (!dependencyResult.satisfied) {
2648
- evidenceBlocked = true;
2649
- decision = {
2650
- action: "deny",
2651
- matchedRule: rule,
2652
- reason: `Required tool calls not completed: ${dependencyResult.missing.join(", ")}`
2653
- };
2654
- }
2655
- }
2656
- }
2657
- const isPerRuleDryRun = originalAction === "dry_run";
2658
- const isGlobalDryRun = this.policy.dryRun === true;
2659
- const isDryRun = (isPerRuleDryRun || isGlobalDryRun) && !sessionBlocked;
2660
3141
  let result;
2661
3142
  let approvalOutcome;
2662
3143
  let approvalWaitMs = 0;
@@ -2670,6 +3151,8 @@ var GovernedForwarder = class {
2670
3151
  result = this.makeSessionRequiredBlockResult(request, decision);
2671
3152
  } else if (evidenceBlocked) {
2672
3153
  result = this.makeEvidenceBlockResult(request, decision, evidenceResult, dependencyResult);
3154
+ } else if (driftBlocked && driftEvent) {
3155
+ result = this.makeDriftBlockResult(request, driftEvent);
2673
3156
  } else if (decision.action === "allow") {
2674
3157
  result = await this.inner.forward(request);
2675
3158
  } else if (decision.action === "deny") {
@@ -2742,7 +3225,8 @@ var GovernedForwarder = class {
2742
3225
  rateLimitResult,
2743
3226
  spendLimitResult,
2744
3227
  isDryRun,
2745
- forwardingError
3228
+ forwardingError,
3229
+ driftEvent ? { event: driftEvent, mode: driftMode } : void 0
2746
3230
  );
2747
3231
  return result;
2748
3232
  }
@@ -2950,6 +3434,14 @@ var GovernedForwarder = class {
2950
3434
  );
2951
3435
  }
2952
3436
  return `tool:${toolName}`;
3437
+ case "sender_id":
3438
+ if (!this.senderKeyWarned) {
3439
+ this.senderKeyWarned = true;
3440
+ console.error(
3441
+ '[helio] Warning: limits.key "sender_id" has no sender on the MCP path, falling back to "tool"'
3442
+ );
3443
+ }
3444
+ return `tool:${toolName}`;
2953
3445
  case "tool":
2954
3446
  default:
2955
3447
  return `tool:${toolName}`;
@@ -2959,7 +3451,7 @@ var GovernedForwarder = class {
2959
3451
  wasForwardedUpstream(decision, approvalOutcome, rateLimitResult, spendLimitResult) {
2960
3452
  return decision.action === "allow" || approvalOutcome?.status === "approved" || approvalOutcome?.status === "break_glass" || approvalOutcome?.status === "timeout" && this.approvalRouter?.defaultOnTimeout === "allow" || rateLimitResult?.allowed === true || spendLimitResult?.allowed === true;
2961
3453
  }
2962
- writeAuditRecord(request, timestamp, toolName, toolArguments, decision, result, totalDurationMs, approvalWaitMs, flaggedDestructive, evidenceResult, dependencyResult, evidenceBlocked, approvalOutcome, rateLimitResult, spendLimitResult, isDryRun, forwardingError) {
3454
+ writeAuditRecord(request, timestamp, toolName, toolArguments, decision, result, totalDurationMs, approvalWaitMs, flaggedDestructive, evidenceResult, dependencyResult, evidenceBlocked, approvalOutcome, rateLimitResult, spendLimitResult, isDryRun, forwardingError, drift) {
2963
3455
  if (!this.auditWriter) return;
2964
3456
  const wasForwarded = this.wasForwardedUpstream(
2965
3457
  decision,
@@ -3019,10 +3511,19 @@ var GovernedForwarder = class {
3019
3511
  }
3020
3512
  };
3021
3513
  }
3022
- const blockReason = extractBlockReason(result);
3023
- const record = {
3024
- timestamp,
3025
- session_id: request.sessionId ?? null,
3514
+ if (drift) {
3515
+ evidenceChain = {
3516
+ ...evidenceChain ?? {},
3517
+ tool_drift: {
3518
+ mode: drift.mode,
3519
+ changes: drift.event.changes
3520
+ }
3521
+ };
3522
+ }
3523
+ const blockReason = extractBlockReason(result);
3524
+ const record = {
3525
+ timestamp,
3526
+ session_id: request.sessionId ?? null,
3026
3527
  agent_id: null,
3027
3528
  environment: this.environment ?? null,
3028
3529
  tool_name: toolName,
@@ -3042,7 +3543,10 @@ var GovernedForwarder = class {
3042
3543
  approval_wait_ms: approvalWaitMs,
3043
3544
  proxy_compute_ms: proxyComputeMs,
3044
3545
  flagged_destructive: flaggedDestructive,
3045
- dry_run: isDryRun ?? false
3546
+ dry_run: isDryRun ?? false,
3547
+ record_kind: "tool_call",
3548
+ origin: "mcp",
3549
+ metadata: null
3046
3550
  };
3047
3551
  const isEnforcementDecision = !isDryRun && (!wasForwarded || approvalOutcome !== void 0);
3048
3552
  if (isEnforcementDecision) {
@@ -3051,6 +3555,15 @@ var GovernedForwarder = class {
3051
3555
  this.auditWriter.push(record);
3052
3556
  }
3053
3557
  }
3558
+ makeDriftBlockResult(request, drift) {
3559
+ const feedback = buildToolDriftFeedback(drift, "deny");
3560
+ return makeErrorResult(
3561
+ request,
3562
+ POLICY_DENIED,
3563
+ `Tool definition drift: "${drift.toolName}" changed after baseline`,
3564
+ { ...feedback }
3565
+ );
3566
+ }
3054
3567
  makeDenyResult(request, decision) {
3055
3568
  const feedback = buildPolicyDeniedFeedback(decision);
3056
3569
  const message = decision.matchedRule?.feedback?.message ?? `Policy denied: ${decision.reason}`;
@@ -3271,6 +3784,46 @@ var RateLimiter = class {
3271
3784
  resetAtMs
3272
3785
  };
3273
3786
  }
3787
+ /**
3788
+ * Unconditionally record a call against the rate limit.
3789
+ *
3790
+ * Unlike check(), this always appends the timestamp — even when the bucket
3791
+ * is already at/over the limit — because the call it represents has already
3792
+ * executed. The sideband splits decision from execution: /evaluate peeks
3793
+ * (non-destructive), and /audit calls record() once the external call ran,
3794
+ * so refusing to record at the limit (as check() does) would let real calls
3795
+ * escape accounting and under-count subsequent peeks. (issue #12, D3.)
3796
+ *
3797
+ * Warnings fire only while the post-append count stays within the limit —
3798
+ * exact parity with check(), which never warns on its over-limit path — so a
3799
+ * burst of over-limit audits cannot flood the dashboard's limit_warning feed.
3800
+ */
3801
+ record(params) {
3802
+ const { key, maxCalls, windowMs } = params;
3803
+ const now = this.now();
3804
+ const windowStart = now - windowMs;
3805
+ let bucket = this.buckets.get(key);
3806
+ if (!bucket) {
3807
+ bucket = { timestamps: [], maxCalls, windowMs };
3808
+ this.buckets.set(key, bucket);
3809
+ }
3810
+ bucket.maxCalls = maxCalls;
3811
+ bucket.windowMs = windowMs;
3812
+ bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
3813
+ bucket.timestamps.push(now);
3814
+ const current = bucket.timestamps.length;
3815
+ const resetAtMs = (bucket.timestamps[0] ?? now) + windowMs;
3816
+ if (this.onWarning && current <= maxCalls && current / maxCalls >= this.warningThreshold) {
3817
+ this.onWarning({ key, current, limit: maxCalls, window_ms: windowMs, reset_at_ms: resetAtMs });
3818
+ }
3819
+ return {
3820
+ allowed: current <= maxCalls,
3821
+ current,
3822
+ limit: maxCalls,
3823
+ windowMs,
3824
+ resetAtMs
3825
+ };
3826
+ }
3274
3827
  /**
3275
3828
  * Check the rate limit without recording the call (non-destructive).
3276
3829
  *
@@ -3485,6 +4038,57 @@ var SpendLimiter = class {
3485
4038
  resetAtMs
3486
4039
  };
3487
4040
  }
4041
+ /**
4042
+ * Unconditionally record a spend against the limit.
4043
+ *
4044
+ * Unlike check(), this always appends the amount — even when it pushes the
4045
+ * window past the limit — because the spend it represents has already been
4046
+ * incurred. The sideband peeks at /evaluate and commits here at /audit once
4047
+ * the external call ran (issue #12, D3).
4048
+ *
4049
+ * Throws on a negative or non-finite amount: such amounts are rejected at
4050
+ * /evaluate, so one reaching record() is a logic bug we surface loudly rather
4051
+ * than silently corrupt the sliding-window sum. Warnings fire only while the
4052
+ * post-append spend stays within the limit (parity with check()).
4053
+ */
4054
+ record(params) {
4055
+ const { key, amount, limit, windowMs } = params;
4056
+ if (!Number.isFinite(amount) || amount < 0) {
4057
+ throw new RangeError(
4058
+ `SpendLimiter.record() received an invalid amount (${String(amount)}); invalid amounts must be rejected at /evaluate, never committed`
4059
+ );
4060
+ }
4061
+ const now = this.now();
4062
+ const windowStart = now - windowMs;
4063
+ let bucket = this.buckets.get(key);
4064
+ if (!bucket) {
4065
+ bucket = { entries: [], limit, currency: "", windowMs };
4066
+ this.buckets.set(key, bucket);
4067
+ }
4068
+ bucket.limit = limit;
4069
+ bucket.windowMs = windowMs;
4070
+ bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
4071
+ bucket.entries.push({ timestamp: now, amount });
4072
+ const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
4073
+ const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
4074
+ if (this.onWarning && currentSpend <= limit && currentSpend / limit >= this.warningThreshold) {
4075
+ this.onWarning({
4076
+ key,
4077
+ current_spend: currentSpend,
4078
+ limit,
4079
+ currency: bucket.currency,
4080
+ window_ms: windowMs,
4081
+ reset_at_ms: resetAtMs
4082
+ });
4083
+ }
4084
+ return {
4085
+ allowed: currentSpend <= limit,
4086
+ currentSpend,
4087
+ limit,
4088
+ windowMs,
4089
+ resetAtMs
4090
+ };
4091
+ }
3488
4092
  /**
3489
4093
  * Check the spend limit without recording the spend (non-destructive).
3490
4094
  *
@@ -3930,8 +4534,9 @@ var EvidenceStore = class _EvidenceStore {
3930
4534
  };
3931
4535
 
3932
4536
  // src/evidence/api.ts
3933
- import { Hono as Hono4 } from "hono";
3934
- import { z as z4 } from "zod";
4537
+ import { Hono as Hono5 } from "hono";
4538
+ import { bodyLimit } from "hono/body-limit";
4539
+ import { z as z5 } from "zod";
3935
4540
 
3936
4541
  // src/auth/bearer.ts
3937
4542
  import { createHash, timingSafeEqual } from "crypto";
@@ -3943,22 +4548,176 @@ function verifyBearer(authHeader, expected) {
3943
4548
  return timingSafeEqual(actualDigest, expectedDigest);
3944
4549
  }
3945
4550
 
4551
+ // src/sideband/governance-api.ts
4552
+ import { Hono as Hono4 } from "hono";
4553
+ import { z as z4 } from "zod";
4554
+ import { createHash as createHash2 } from "crypto";
4555
+ var originSchema = z4.string().regex(/^[a-z0-9_-]{1,64}$/, "origin must match ^[a-z0-9_-]{1,64}$").default("sideband");
4556
+ var metadataSchema = z4.record(z4.string(), z4.unknown()).nullish();
4557
+ var toolDefinitionSchema = z4.object({
4558
+ name: z4.string().min(1),
4559
+ description: z4.string().optional(),
4560
+ input_schema: z4.unknown().optional(),
4561
+ output_schema: z4.unknown().optional(),
4562
+ title: z4.string().optional(),
4563
+ annotations: z4.record(z4.string(), z4.unknown()).optional()
4564
+ });
4565
+ var evaluateBody = z4.object({
4566
+ origin: originSchema,
4567
+ adapter_version: z4.string().max(64).optional(),
4568
+ agent_id: z4.string().nullish(),
4569
+ session_id: z4.string().nullish(),
4570
+ tool: toolDefinitionSchema,
4571
+ arguments: z4.record(z4.string(), z4.unknown()).optional(),
4572
+ metadata: metadataSchema
4573
+ });
4574
+ var installScanBody = z4.object({
4575
+ origin: originSchema,
4576
+ agent_id: z4.string().nullish(),
4577
+ session_id: z4.string().nullish(),
4578
+ package: z4.object({
4579
+ name: z4.string().min(1),
4580
+ version: z4.string().optional(),
4581
+ source: z4.string().max(64).optional(),
4582
+ spec: z4.string().optional(),
4583
+ url: z4.string().optional()
4584
+ }),
4585
+ metadata: metadataSchema
4586
+ });
4587
+ var auditBody = z4.object({
4588
+ evaluation_id: z4.string().min(1),
4589
+ status: z4.enum(["success", "error", "not_executed"]),
4590
+ error: z4.string().optional(),
4591
+ duration_ms: z4.number().optional(),
4592
+ result: z4.unknown().optional(),
4593
+ actual_amount: z4.number().optional()
4594
+ });
4595
+ var resolveBody = z4.object({
4596
+ resolution: z4.enum(["approved", "denied", "timeout", "cancelled"]),
4597
+ resolved_by: z4.string().optional(),
4598
+ reason: z4.string().optional(),
4599
+ scope: z4.enum(["once", "always"]).optional()
4600
+ });
4601
+ var MAX_METADATA_BYTES = 4 * 1024;
4602
+ function createGovernanceApp(service) {
4603
+ const app = new Hono4();
4604
+ const unavailable = () => ({ error: "governance_unavailable" });
4605
+ app.post("/evaluate", async (c) => {
4606
+ if (!service) return c.json(unavailable(), 503);
4607
+ const parsed = await parseJson(c);
4608
+ if ("error" in parsed) return c.json(parsed.error, 400);
4609
+ const result = evaluateBody.safeParse(parsed.body);
4610
+ if (!result.success) {
4611
+ return c.json({ error: "Validation error", details: formatZodErrors(result.error) }, 400);
4612
+ }
4613
+ if (metadataTooLarge(result.data.metadata)) {
4614
+ return c.json({ error: "metadata_too_large" }, 413);
4615
+ }
4616
+ const r = service.evaluate({
4617
+ origin: result.data.origin,
4618
+ adapter_version: result.data.adapter_version,
4619
+ agent_id: result.data.agent_id ?? null,
4620
+ session_id: result.data.session_id ?? null,
4621
+ tool: result.data.tool,
4622
+ arguments: result.data.arguments,
4623
+ metadata: result.data.metadata ?? null
4624
+ });
4625
+ return c.json(r.body, asStatus(r.status));
4626
+ });
4627
+ app.post("/audit", async (c) => {
4628
+ if (!service) return c.json(unavailable(), 503);
4629
+ const parsed = await parseJson(c);
4630
+ if ("error" in parsed) return c.json(parsed.error, 400);
4631
+ const result = auditBody.safeParse(parsed.body);
4632
+ if (!result.success) {
4633
+ return c.json({ error: "Validation error", details: formatZodErrors(result.error) }, 400);
4634
+ }
4635
+ const hash = auditPayloadHash(result.data);
4636
+ const r = service.audit(result.data, hash);
4637
+ return c.json(r.body, asStatus(r.status));
4638
+ });
4639
+ app.post("/install-scan", async (c) => {
4640
+ if (!service) return c.json(unavailable(), 503);
4641
+ const parsed = await parseJson(c);
4642
+ if ("error" in parsed) return c.json(parsed.error, 400);
4643
+ const result = installScanBody.safeParse(parsed.body);
4644
+ if (!result.success) {
4645
+ return c.json({ error: "Validation error", details: formatZodErrors(result.error) }, 400);
4646
+ }
4647
+ if (metadataTooLarge(result.data.metadata)) {
4648
+ return c.json({ error: "metadata_too_large" }, 413);
4649
+ }
4650
+ const r = service.installScan({
4651
+ origin: result.data.origin,
4652
+ agent_id: result.data.agent_id ?? null,
4653
+ session_id: result.data.session_id ?? null,
4654
+ package: result.data.package,
4655
+ metadata: result.data.metadata ?? null
4656
+ });
4657
+ return c.json(r.body, asStatus(r.status));
4658
+ });
4659
+ app.post("/approval/:id/resolve", async (c) => {
4660
+ if (!service) return c.json(unavailable(), 503);
4661
+ const parsed = await parseJson(c);
4662
+ if ("error" in parsed) return c.json(parsed.error, 400);
4663
+ const result = resolveBody.safeParse(parsed.body);
4664
+ if (!result.success) {
4665
+ return c.json({ error: "Validation error", details: formatZodErrors(result.error) }, 400);
4666
+ }
4667
+ if ((result.data.resolution === "approved" || result.data.resolution === "denied") && !result.data.resolved_by) {
4668
+ return c.json({ error: "resolved_by is required for approved/denied" }, 400);
4669
+ }
4670
+ const r = service.resolveApproval(c.req.param("id"), result.data);
4671
+ return c.json(r.body, asStatus(r.status));
4672
+ });
4673
+ return app;
4674
+ }
4675
+ function isGovernancePath(path) {
4676
+ return path === "/evaluate" || path === "/audit" || path === "/install-scan" || path.startsWith("/approval/");
4677
+ }
4678
+ async function parseJson(c) {
4679
+ try {
4680
+ return { body: await c.req.json() };
4681
+ } catch {
4682
+ return { error: { error: "Invalid JSON" } };
4683
+ }
4684
+ }
4685
+ function metadataTooLarge(metadata) {
4686
+ if (metadata == null) return false;
4687
+ return Buffer.byteLength(canonicalize(metadata), "utf8") > MAX_METADATA_BYTES;
4688
+ }
4689
+ function auditPayloadHash(data) {
4690
+ const semantic = {
4691
+ status: data.status,
4692
+ error: data.error ?? null,
4693
+ duration_ms: data.duration_ms ?? null,
4694
+ result: data.result ?? null,
4695
+ actual_amount: data.actual_amount ?? null
4696
+ };
4697
+ return createHash2("sha256").update(canonicalize(semantic)).digest("hex");
4698
+ }
4699
+ function asStatus(status) {
4700
+ return status;
4701
+ }
4702
+
3946
4703
  // src/evidence/api.ts
3947
- var postEvidenceBody = z4.object({
3948
- session_id: z4.string().min(1),
3949
- tool_name: z4.string().min(1),
3950
- evidence_key: z4.string().min(1),
3951
- evidence_data: z4.unknown().refine((v) => v !== void 0, { message: "Required" }),
3952
- ttl_seconds: z4.number().int().positive().optional()
4704
+ var SIDEBAND_BODY_LIMIT_BYTES = 1 * 1024 * 1024;
4705
+ var postEvidenceBody = z5.object({
4706
+ session_id: z5.string().min(1),
4707
+ tool_name: z5.string().min(1),
4708
+ evidence_key: z5.string().min(1),
4709
+ evidence_data: z5.unknown().refine((v) => v !== void 0, { message: "Required" }),
4710
+ ttl_seconds: z5.number().int().positive().optional()
3953
4711
  });
3954
- var postContextBody = z4.object({
3955
- session_id: z4.string().min(1),
3956
- key: z4.string().min(1),
3957
- value: z4.unknown().refine((v) => v !== void 0, { message: "Required" })
4712
+ var postContextBody = z5.object({
4713
+ session_id: z5.string().min(1),
4714
+ key: z5.string().min(1),
4715
+ value: z5.unknown().refine((v) => v !== void 0, { message: "Required" })
3958
4716
  });
3959
4717
  function createSidebandApp(store, options = {}) {
3960
- const app = new Hono4();
3961
- const token = options.token && options.token.length > 0 ? options.token : void 0;
4718
+ const app = new Hono5();
4719
+ const sdkToken = options.token && options.token.length > 0 ? options.token : void 0;
4720
+ const adapterToken = options.adapterToken && options.adapterToken.length > 0 ? options.adapterToken : void 0;
3962
4721
  app.use("*", async (c, next) => {
3963
4722
  const origin = c.req.header("origin");
3964
4723
  if (origin) {
@@ -3969,20 +4728,26 @@ function createSidebandApp(store, options = {}) {
3969
4728
  }
3970
4729
  await next();
3971
4730
  });
3972
- if (token) {
3973
- app.use("*", async (c, next) => {
3974
- if (c.req.path === "/healthz") {
3975
- await next();
3976
- return;
3977
- }
3978
- const authHeader = c.req.header("authorization");
3979
- if (!verifyBearer(authHeader, token)) {
3980
- return c.json({ error: "Unauthorized" }, 401);
3981
- }
4731
+ app.use(
4732
+ "*",
4733
+ bodyLimit({
4734
+ maxSize: SIDEBAND_BODY_LIMIT_BYTES,
4735
+ onError: (c) => c.json({ error: "request_body_too_large" }, 413)
4736
+ })
4737
+ );
4738
+ app.use("*", async (c, next) => {
4739
+ if (c.req.path === "/healthz") {
3982
4740
  await next();
3983
- });
3984
- }
4741
+ return;
4742
+ }
4743
+ const expected = isGovernancePath(c.req.path) ? adapterToken : sdkToken;
4744
+ if (expected && !verifyBearer(c.req.header("authorization"), expected)) {
4745
+ return c.json({ error: "Unauthorized" }, 401);
4746
+ }
4747
+ await next();
4748
+ });
3985
4749
  app.get("/healthz", (c) => c.json({ status: "ok" }));
4750
+ app.route("/", createGovernanceApp(options.governance));
3986
4751
  app.post("/evidence", async (c) => {
3987
4752
  let body;
3988
4753
  try {
@@ -4045,9 +4810,805 @@ function createSidebandApp(store, options = {}) {
4045
4810
  return app;
4046
4811
  }
4047
4812
 
4813
+ // src/sideband/governance-service.ts
4814
+ import { randomUUID as randomUUID2 } from "crypto";
4815
+
4816
+ // src/sideband/errors.ts
4817
+ var GovernanceConfigError = class extends Error {
4818
+ constructor(message) {
4819
+ super(message);
4820
+ this.name = "GovernanceConfigError";
4821
+ }
4822
+ };
4823
+
4824
+ // src/sideband/governance-service.ts
4825
+ var MAX_ORIGINS = 32;
4826
+ var MAX_TOOLS_PER_ORIGIN = 1024;
4827
+ var MAX_TOOL_INPUT_BYTES = 64 * 1024;
4828
+ var MAX_PENDING_COUNT = 1e4;
4829
+ var MAX_PENDING_BYTES = 64 * 1024 * 1024;
4830
+ var MAX_SENDER_KEYS = 5e4;
4831
+ var SWEEP_INTERVAL_MS2 = 3e4;
4832
+ var GovernanceService = class {
4833
+ policy;
4834
+ environment;
4835
+ evidenceStore;
4836
+ approvalRouter;
4837
+ rateLimiter;
4838
+ spendLimiter;
4839
+ auditWriter;
4840
+ approvalTimeoutMs;
4841
+ ttlMs;
4842
+ now;
4843
+ maxPending;
4844
+ maxPendingBytes;
4845
+ maxSenderKeys;
4846
+ /** Distinct sender_id limit keys with live state (reservation registry, issue #13). */
4847
+ senderKeys = /* @__PURE__ */ new Set();
4848
+ pending = /* @__PURE__ */ new Map();
4849
+ tombstones = /* @__PURE__ */ new Map();
4850
+ caches = /* @__PURE__ */ new Map();
4851
+ /** Native approval ticket id → its pending evaluation id, for on-access
4852
+ * deadline enforcement on the resolve path. */
4853
+ ticketToEvaluation = /* @__PURE__ */ new Map();
4854
+ pendingBytes = 0;
4855
+ sweepTimer = null;
4856
+ closed = false;
4857
+ constructor(options) {
4858
+ this.policy = options.policy;
4859
+ this.environment = options.environment;
4860
+ this.evidenceStore = options.evidenceStore;
4861
+ this.approvalRouter = options.approvalRouter;
4862
+ this.rateLimiter = options.rateLimiter;
4863
+ this.spendLimiter = options.spendLimiter;
4864
+ this.auditWriter = options.auditWriter;
4865
+ this.approvalTimeoutMs = options.approvalTimeoutMs ?? 3e5;
4866
+ this.ttlMs = options.ttlMs ?? 6e5;
4867
+ this.now = options.now ?? Date.now;
4868
+ this.maxPending = options.maxPending ?? MAX_PENDING_COUNT;
4869
+ this.maxPendingBytes = options.maxPendingBytes ?? MAX_PENDING_BYTES;
4870
+ this.maxSenderKeys = options.maxSenderKeys ?? MAX_SENDER_KEYS;
4871
+ this.assertApprovalRouter(this.policy);
4872
+ const sweepMs = options.sweepIntervalMs ?? SWEEP_INTERVAL_MS2;
4873
+ if (sweepMs > 0) {
4874
+ this.sweepTimer = setInterval(() => {
4875
+ this.sweep();
4876
+ }, sweepMs);
4877
+ this.sweepTimer.unref();
4878
+ }
4879
+ }
4880
+ /** Swap the compiled policy on hot-reload (mirrors GovernedForwarder). */
4881
+ updatePolicy(policy) {
4882
+ this.assertApprovalRouter(policy);
4883
+ this.policy = policy;
4884
+ }
4885
+ // -------------------------------------------------------------------------
4886
+ // POST /evaluate
4887
+ // -------------------------------------------------------------------------
4888
+ evaluate(req) {
4889
+ const reserved = reservedMetadataKey(req.metadata);
4890
+ if (reserved) {
4891
+ return { status: 400, body: { error: "reserved_metadata_key", key: reserved } };
4892
+ }
4893
+ const inputBytes = byteLength(req.arguments ?? {});
4894
+ if (inputBytes > MAX_TOOL_INPUT_BYTES) {
4895
+ return { status: 413, body: { error: "tool_input_too_large" } };
4896
+ }
4897
+ const entryBytes = inputBytes + byteLength(req.metadata ?? {});
4898
+ if (!this.caches.has(req.origin) && this.caches.size >= MAX_ORIGINS) {
4899
+ return { status: 400, body: { error: "origin_limit_exceeded" } };
4900
+ }
4901
+ if (this.pending.size >= this.maxPending || this.pendingBytes + entryBytes > this.maxPendingBytes) {
4902
+ return { status: 503, body: { error: "evaluation_backlog_full" } };
4903
+ }
4904
+ const cache = this.cacheFor(req.origin);
4905
+ const toolName = req.tool.name;
4906
+ const hasDefinition = definitionProvided(req.tool);
4907
+ if (hasDefinition) {
4908
+ if (!cache.has(toolName) && cache.size >= MAX_TOOLS_PER_ORIGIN) {
4909
+ return { status: 400, body: { error: "tool_baseline_limit" } };
4910
+ }
4911
+ cache.updateSingle(toMcpToolDef(req.tool));
4912
+ }
4913
+ const pipeline = decide({
4914
+ toolName,
4915
+ toolArguments: req.arguments,
4916
+ sessionId: req.session_id ?? void 0,
4917
+ policy: this.policy,
4918
+ environment: this.environment,
4919
+ evidenceStore: this.evidenceStore,
4920
+ baselineAnnotations: cache.get(toolName),
4921
+ currentAnnotations: cache.getCurrent(toolName),
4922
+ driftEvent: cache.getDrift(toolName),
4923
+ metadata: req.metadata ?? void 0,
4924
+ agentId: req.agent_id ?? void 0
4925
+ });
4926
+ const { decision } = pipeline;
4927
+ const evaluationId = randomUUID2();
4928
+ const timestampIso = new Date(this.now()).toISOString();
4929
+ let wire;
4930
+ let limitPlan;
4931
+ let limitsBlock;
4932
+ const senderId = senderIdOf(req.metadata);
4933
+ if (pipeline.isDryRun) {
4934
+ wire = "dry_run";
4935
+ } else if (decision.action === "deny") {
4936
+ wire = "deny";
4937
+ } else if (decision.action === "require_approval") {
4938
+ wire = "require_approval";
4939
+ } else if (decision.action === "rate_limit") {
4940
+ const planned = this.planRate(decision, toolName, req.session_id, senderId);
4941
+ if (planned?.plan && !this.reserveSenderKey(planned.plan.key)) {
4942
+ return { status: 503, body: { error: "limit_capacity_exhausted" } };
4943
+ }
4944
+ limitPlan = planned?.plan;
4945
+ limitsBlock = planned?.block ? { rate: planned.block } : void 0;
4946
+ wire = planned?.allowed ? "allow" : "rate_limited";
4947
+ } else if (decision.action === "spend_limit") {
4948
+ const planned = this.planSpend(decision, toolName, req.session_id, req.arguments, senderId);
4949
+ if (planned?.plan && !this.reserveSenderKey(planned.plan.key)) {
4950
+ return { status: 503, body: { error: "limit_capacity_exhausted" } };
4951
+ }
4952
+ limitPlan = planned?.plan;
4953
+ limitsBlock = planned?.block ? { spend: planned.block } : void 0;
4954
+ wire = planned?.allowed ? "allow" : "spend_limited";
4955
+ } else {
4956
+ wire = "allow";
4957
+ }
4958
+ const matchedRuleName = decision.matchedRule?.name ?? null;
4959
+ const matchedRuleIndex = decision.matchedRule?.index ?? null;
4960
+ const responseBody = {
4961
+ evaluation_id: evaluationId,
4962
+ decision: wire,
4963
+ reason: decision.reason,
4964
+ matched_rule: matchedRuleName,
4965
+ matched_rule_index: matchedRuleIndex
4966
+ };
4967
+ if (isBlocking(wire)) {
4968
+ responseBody["feedback"] = buildFeedback(decision.matchedRule, decision.reason);
4969
+ }
4970
+ if (limitsBlock) responseBody["limits"] = limitsBlock;
4971
+ if (wire === "dry_run") {
4972
+ responseBody["dry_run"] = {
4973
+ would_forward: decision.action === "allow" && !pipeline.evidenceBlocked,
4974
+ evidence_satisfied: !pipeline.evidenceBlocked,
4975
+ limits_ok: true
4976
+ };
4977
+ }
4978
+ if (pipeline.driftEvent) {
4979
+ responseBody["tool_drift"] = { changes: pipeline.driftEvent.changes };
4980
+ }
4981
+ if (isTerminalAtEvaluate(wire)) {
4982
+ const auditId = this.writeAudit({
4983
+ timestampIso,
4984
+ origin: req.origin,
4985
+ agentId: req.agent_id,
4986
+ sessionId: req.session_id,
4987
+ toolName,
4988
+ toolInput: req.arguments ?? {},
4989
+ metadata: req.metadata,
4990
+ action: decision.action,
4991
+ wire,
4992
+ matchedRuleName,
4993
+ matchedRuleIndex,
4994
+ flaggedDestructive: pipeline.flaggedDestructive,
4995
+ dryRun: wire === "dry_run",
4996
+ recordKind: "tool_call",
4997
+ limitsChain: limitsBlock
4998
+ });
4999
+ this.tombstones.set(evaluationId, {
5000
+ auditRecordId: auditId,
5001
+ payloadHash: null,
5002
+ finalizedBy: "evaluate",
5003
+ expiresAtMs: this.now() + this.ttlMs
5004
+ });
5005
+ return { status: 200, body: responseBody };
5006
+ }
5007
+ let approvalTicketId;
5008
+ let ticketTimeoutAtMs;
5009
+ if (wire === "require_approval") {
5010
+ const router = this.approvalRouter;
5011
+ if (!router) {
5012
+ throw new GovernanceConfigError(
5013
+ "[helio] invariant violation: require_approval decision without an approvalRouter"
5014
+ );
5015
+ }
5016
+ const timeoutMs = decision.matchedRule?.approval?.timeoutMs ?? this.approvalTimeoutMs;
5017
+ const ticket = router.createNativeTicket({
5018
+ tool_name: toolName,
5019
+ tool_input: req.arguments ?? {},
5020
+ matched_rule: decision.matchedRule,
5021
+ session_id: req.session_id,
5022
+ origin: req.origin,
5023
+ timeout_ms: timeoutMs
5024
+ });
5025
+ approvalTicketId = ticket.id;
5026
+ ticketTimeoutAtMs = this.now() + timeoutMs;
5027
+ responseBody["approval"] = {
5028
+ id: ticket.id,
5029
+ timeout_ms: timeoutMs,
5030
+ resolve_path: `/approval/${ticket.id}/resolve`
5031
+ };
5032
+ }
5033
+ const entry = {
5034
+ evaluationId,
5035
+ origin: req.origin,
5036
+ agentId: req.agent_id,
5037
+ sessionId: req.session_id,
5038
+ toolName,
5039
+ toolInput: req.arguments ?? {},
5040
+ metadata: req.metadata,
5041
+ action: decision.action,
5042
+ matchedRuleName,
5043
+ matchedRuleIndex,
5044
+ flaggedDestructive: pipeline.flaggedDestructive,
5045
+ limitPlan,
5046
+ approvalTicketId,
5047
+ timestampIso,
5048
+ createdAtMs: this.now(),
5049
+ evaluationExpiresAtMs: this.now() + this.ttlMs,
5050
+ ticketTimeoutAtMs,
5051
+ bytes: entryBytes
5052
+ };
5053
+ this.pending.set(evaluationId, entry);
5054
+ this.pendingBytes += entryBytes;
5055
+ if (approvalTicketId) this.ticketToEvaluation.set(approvalTicketId, evaluationId);
5056
+ return { status: 200, body: responseBody };
5057
+ }
5058
+ // -------------------------------------------------------------------------
5059
+ // POST /audit
5060
+ // -------------------------------------------------------------------------
5061
+ audit(req, payloadHash) {
5062
+ const id = req.evaluation_id;
5063
+ const tomb = this.tombstones.get(id);
5064
+ if (tomb) {
5065
+ if (tomb.finalizedBy === "expired") {
5066
+ return { status: 404, body: { error: "evaluation_expired" } };
5067
+ }
5068
+ if (tomb.finalizedBy === "evaluate") {
5069
+ return {
5070
+ status: 200,
5071
+ body: {
5072
+ ok: true,
5073
+ audit_record_id: tomb.auditRecordId,
5074
+ already_finalized: true,
5075
+ finalized_by: "evaluate"
5076
+ }
5077
+ };
5078
+ }
5079
+ if (tomb.payloadHash === payloadHash) {
5080
+ return {
5081
+ status: 200,
5082
+ body: { ok: true, audit_record_id: tomb.auditRecordId, already_finalized: true }
5083
+ };
5084
+ }
5085
+ return { status: 409, body: { error: "evaluation_conflict" } };
5086
+ }
5087
+ const entry = this.pending.get(id);
5088
+ if (!entry) {
5089
+ return { status: 404, body: { error: "evaluation_unknown" } };
5090
+ }
5091
+ if (this.enforceDeadlines(entry) === "expired") {
5092
+ return { status: 404, body: { error: "evaluation_expired" } };
5093
+ }
5094
+ let approvalStatus = null;
5095
+ let approvedBy = null;
5096
+ if (entry.approvalTicketId) {
5097
+ const ticket = this.getTicketStatus(entry.approvalTicketId);
5098
+ const status = ticket?.status;
5099
+ if (!status || status === "pending") {
5100
+ return { status: 409, body: { error: "approval_unresolved" } };
5101
+ }
5102
+ approvalStatus = status;
5103
+ approvedBy = ticket.resolved_by ?? null;
5104
+ }
5105
+ if (req.actual_amount !== void 0) {
5106
+ if (!Number.isFinite(req.actual_amount) || req.actual_amount < 0) {
5107
+ return { status: 400, body: { error: "invalid_actual_amount" } };
5108
+ }
5109
+ if (entry.limitPlan?.kind !== "spend") {
5110
+ return { status: 400, body: { error: "no_spend_rule" } };
5111
+ }
5112
+ }
5113
+ const callHappened = req.status === "success" || req.status === "error";
5114
+ let limitsChain;
5115
+ if (callHappened && entry.limitPlan) {
5116
+ limitsChain = this.commitLimit(entry.limitPlan, req.actual_amount);
5117
+ }
5118
+ if (callHappened && this.evidenceStore && entry.sessionId) {
5119
+ this.evidenceStore.recordToolCall(entry.sessionId, entry.toolName, req.status === "success");
5120
+ }
5121
+ const auditId = this.writeAudit({
5122
+ timestampIso: entry.timestampIso,
5123
+ origin: entry.origin,
5124
+ agentId: entry.agentId,
5125
+ sessionId: entry.sessionId,
5126
+ toolName: entry.toolName,
5127
+ toolInput: entry.toolInput,
5128
+ metadata: entry.metadata,
5129
+ action: entry.action,
5130
+ wire: entry.action === "require_approval" ? "require_approval" : "allow",
5131
+ matchedRuleName: entry.matchedRuleName,
5132
+ matchedRuleIndex: entry.matchedRuleIndex,
5133
+ flaggedDestructive: entry.flaggedDestructive,
5134
+ dryRun: false,
5135
+ recordKind: "tool_call",
5136
+ limitsChain,
5137
+ approvalStatus,
5138
+ approvedBy,
5139
+ upstreamError: req.status === "error" ? req.error ?? "tool call failed" : null,
5140
+ upstreamResponse: req.result ?? null,
5141
+ upstreamLatencyMs: req.duration_ms ?? null
5142
+ });
5143
+ this.discardPending(entry);
5144
+ this.tombstones.set(id, {
5145
+ auditRecordId: auditId,
5146
+ payloadHash,
5147
+ finalizedBy: "audit",
5148
+ expiresAtMs: this.now() + this.ttlMs
5149
+ });
5150
+ return { status: 201, body: { ok: true, audit_record_id: auditId } };
5151
+ }
5152
+ // -------------------------------------------------------------------------
5153
+ // POST /install-scan — evaluates install-time policy (issue #13)
5154
+ // -------------------------------------------------------------------------
5155
+ installScan(req) {
5156
+ const reserved = reservedMetadataKey(req.metadata);
5157
+ if (reserved) {
5158
+ return { status: 400, body: { error: "reserved_metadata_key", key: reserved } };
5159
+ }
5160
+ const evaluationId = randomUUID2();
5161
+ const toolName = `install:${req.package.source ?? "pkg"}:${req.package.name}`;
5162
+ const verdict = this.evaluateInstall(req);
5163
+ const denied = verdict.decision === "deny";
5164
+ const auditId = this.writeAudit({
5165
+ timestampIso: new Date(this.now()).toISOString(),
5166
+ origin: req.origin,
5167
+ agentId: req.agent_id,
5168
+ sessionId: req.session_id,
5169
+ toolName,
5170
+ toolInput: { ...req.package },
5171
+ metadata: req.metadata,
5172
+ // policy_decision is 'deny' (NOT 'deny_install') so the dashboard renders a
5173
+ // blocked install as a block, not an allow. The install context lives in
5174
+ // record_kind + block_reason.
5175
+ action: denied ? "deny" : "allow",
5176
+ wire: denied ? "deny" : "allow",
5177
+ matchedRuleName: verdict.matchedRule?.name ?? null,
5178
+ matchedRuleIndex: verdict.matchedRule?.index ?? null,
5179
+ flaggedDestructive: false,
5180
+ dryRun: false,
5181
+ recordKind: "install_scan"
5182
+ });
5183
+ this.tombstones.set(evaluationId, {
5184
+ auditRecordId: auditId,
5185
+ payloadHash: null,
5186
+ finalizedBy: "evaluate",
5187
+ expiresAtMs: this.now() + this.ttlMs
5188
+ });
5189
+ const body = {
5190
+ evaluation_id: evaluationId,
5191
+ decision: verdict.decision,
5192
+ reason: verdict.reason,
5193
+ matched_rule: verdict.matchedRule?.name ?? null,
5194
+ matched_rule_index: verdict.matchedRule?.index ?? null
5195
+ };
5196
+ if (denied) {
5197
+ body["feedback"] = buildFeedback(verdict.matchedRule, verdict.reason);
5198
+ }
5199
+ return { status: 200, body };
5200
+ }
5201
+ /** First-match-wins evaluation of the compiled install policy (issue #13). */
5202
+ evaluateInstall(req) {
5203
+ const install = this.policy.install;
5204
+ if (!install) {
5205
+ return { decision: "allow", reason: "no install-time rules defined" };
5206
+ }
5207
+ const metadataView = req.agent_id != null ? { ...req.metadata ?? {}, agent_id: req.agent_id } : req.metadata ?? void 0;
5208
+ for (const rule of install.rules) {
5209
+ if (matchInstallRule(rule, req.package, metadataView)) {
5210
+ const label = rule.name ? `"${rule.name}"` : `install_rule[${String(rule.index)}]`;
5211
+ return {
5212
+ decision: rule.action === "deny_install" ? "deny" : "allow",
5213
+ matchedRule: rule,
5214
+ reason: `Matched ${label} \u2192 ${rule.action}`
5215
+ };
5216
+ }
5217
+ }
5218
+ return {
5219
+ decision: install.defaultAction,
5220
+ reason: `No matching install rule; default ${install.defaultAction}`
5221
+ };
5222
+ }
5223
+ // -------------------------------------------------------------------------
5224
+ // POST /approval/:id/resolve
5225
+ // -------------------------------------------------------------------------
5226
+ resolveApproval(ticketId, req) {
5227
+ if (!this.approvalRouter) {
5228
+ return { status: 503, body: { error: "governance_unavailable" } };
5229
+ }
5230
+ const ticket = this.getTicketStatus(ticketId);
5231
+ if (!ticket) {
5232
+ return { status: 404, body: { error: "ticket_not_found" } };
5233
+ }
5234
+ if (!ticket.channel_name.startsWith("native:")) {
5235
+ return { status: 409, body: { error: "not_a_native_ticket" } };
5236
+ }
5237
+ const evaluationId = this.ticketToEvaluation.get(ticketId);
5238
+ const entry = evaluationId ? this.pending.get(evaluationId) : void 0;
5239
+ if (entry) this.enforceDeadlines(entry);
5240
+ const current = this.getTicketStatus(ticketId);
5241
+ if (!current || current.status !== "pending") {
5242
+ return { status: 409, body: { error: "already_resolved", status: current?.status } };
5243
+ }
5244
+ const resolved = this.approvalRouter.resolveNativeTicket(
5245
+ ticketId,
5246
+ req.resolution,
5247
+ req.resolved_by,
5248
+ { denial_reason: req.resolution === "denied" ? req.reason : void 0 }
5249
+ );
5250
+ if (!resolved) {
5251
+ return { status: 409, body: { error: "already_resolved" } };
5252
+ }
5253
+ return { status: 200, body: { ok: true } };
5254
+ }
5255
+ // -------------------------------------------------------------------------
5256
+ // Sweep — GC backstop for callers that never return
5257
+ // -------------------------------------------------------------------------
5258
+ sweep() {
5259
+ for (const entry of [...this.pending.values()]) {
5260
+ this.enforceDeadlines(entry);
5261
+ }
5262
+ const now = this.now();
5263
+ for (const [id, tomb] of this.tombstones) {
5264
+ if (tomb.expiresAtMs <= now) this.tombstones.delete(id);
5265
+ }
5266
+ this.pruneSenderKeys();
5267
+ }
5268
+ /**
5269
+ * Reserve a cardinality slot for a sender-keyed limit (issue #13).
5270
+ *
5271
+ * Only `sender:*` keys are gated — tool/session families are bounded by upstream
5272
+ * cardinality, and the MCP path never reaches here, so structural traffic cannot
5273
+ * be starved. A key already backed by live state (registry or a live limiter
5274
+ * bucket) costs no new slot. At capacity we lazily prune dead keys before failing
5275
+ * closed, so an emptied bucket frees its slot without waiting for the sweep.
5276
+ */
5277
+ reserveSenderKey(key) {
5278
+ if (!key.startsWith("sender:")) return true;
5279
+ if (this.senderKeys.has(key)) return true;
5280
+ if (this.hasLiveBucket(key)) {
5281
+ this.senderKeys.add(key);
5282
+ return true;
5283
+ }
5284
+ if (this.senderKeys.size >= this.maxSenderKeys) {
5285
+ this.pruneSenderKeys();
5286
+ if (this.senderKeys.size >= this.maxSenderKeys) return false;
5287
+ }
5288
+ this.senderKeys.add(key);
5289
+ return true;
5290
+ }
5291
+ /** Drop registry keys with no pending evaluation AND no live limiter bucket. */
5292
+ pruneSenderKeys() {
5293
+ if (this.senderKeys.size === 0) return;
5294
+ const inUse = /* @__PURE__ */ new Set();
5295
+ for (const entry of this.pending.values()) {
5296
+ if (entry.limitPlan && entry.limitPlan.key.startsWith("sender:")) {
5297
+ inUse.add(entry.limitPlan.key);
5298
+ }
5299
+ }
5300
+ for (const key of this.senderKeys) {
5301
+ if (inUse.has(key)) continue;
5302
+ if (this.hasLiveBucket(key)) continue;
5303
+ this.senderKeys.delete(key);
5304
+ }
5305
+ }
5306
+ /**
5307
+ * Whether either limiter still holds a live bucket for `key`. Uses the public
5308
+ * `getKeyState()` — never the limiters' private maps — and its lazy eviction of
5309
+ * an emptied bucket IS the prune-on-touch mechanism.
5310
+ */
5311
+ hasLiveBucket(key) {
5312
+ return this.rateLimiter?.getKeyState(key) !== void 0 || this.spendLimiter?.getKeyState(key) !== void 0;
5313
+ }
5314
+ close() {
5315
+ if (this.closed) return;
5316
+ this.closed = true;
5317
+ if (this.sweepTimer) {
5318
+ clearInterval(this.sweepTimer);
5319
+ this.sweepTimer = null;
5320
+ }
5321
+ this.pending.clear();
5322
+ this.tombstones.clear();
5323
+ this.caches.clear();
5324
+ this.senderKeys.clear();
5325
+ this.pendingBytes = 0;
5326
+ }
5327
+ // -------------------------------------------------------------------------
5328
+ // Internals
5329
+ // -------------------------------------------------------------------------
5330
+ /** Apply crossed deadlines to one pending entry. Returns its post-state. */
5331
+ enforceDeadlines(entry) {
5332
+ const now = this.now();
5333
+ if (now >= entry.evaluationExpiresAtMs) {
5334
+ if (entry.approvalTicketId) {
5335
+ this.approvalRouter?.resolveNativeTicket(entry.approvalTicketId, "timeout");
5336
+ }
5337
+ const auditId = this.writeAudit({
5338
+ timestampIso: entry.timestampIso,
5339
+ origin: entry.origin,
5340
+ agentId: entry.agentId,
5341
+ sessionId: entry.sessionId,
5342
+ toolName: entry.toolName,
5343
+ toolInput: entry.toolInput,
5344
+ metadata: entry.metadata,
5345
+ action: entry.action,
5346
+ wire: entry.action === "require_approval" ? "require_approval" : "allow",
5347
+ matchedRuleName: entry.matchedRuleName,
5348
+ matchedRuleIndex: entry.matchedRuleIndex,
5349
+ flaggedDestructive: entry.flaggedDestructive,
5350
+ dryRun: false,
5351
+ recordKind: "evaluation_expired",
5352
+ sidebandUnreported: true
5353
+ });
5354
+ this.discardPending(entry);
5355
+ this.tombstones.set(entry.evaluationId, {
5356
+ auditRecordId: auditId,
5357
+ payloadHash: null,
5358
+ finalizedBy: "expired",
5359
+ expiresAtMs: now + this.ttlMs
5360
+ });
5361
+ console.error(
5362
+ `[helio] Sideband evaluation ${entry.evaluationId} expired without /audit (origin=${entry.origin}, tool=${entry.toolName}) \u2014 recorded as evaluation_expired`
5363
+ );
5364
+ return "expired";
5365
+ }
5366
+ if (entry.approvalTicketId && entry.ticketTimeoutAtMs !== void 0 && now >= entry.ticketTimeoutAtMs) {
5367
+ this.approvalRouter?.resolveNativeTicket(entry.approvalTicketId, "timeout");
5368
+ }
5369
+ return "active";
5370
+ }
5371
+ cacheFor(origin) {
5372
+ let cache = this.caches.get(origin);
5373
+ if (!cache) {
5374
+ cache = new ToolAnnotationCache();
5375
+ this.caches.set(origin, cache);
5376
+ }
5377
+ return cache;
5378
+ }
5379
+ discardPending(entry) {
5380
+ if (this.pending.delete(entry.evaluationId)) {
5381
+ this.pendingBytes -= entry.bytes;
5382
+ }
5383
+ if (entry.approvalTicketId) this.ticketToEvaluation.delete(entry.approvalTicketId);
5384
+ }
5385
+ getTicketStatus(ticketId) {
5386
+ return this.approvalRouter?.getTicket(ticketId);
5387
+ }
5388
+ planRate(decision, toolName, sessionId, senderId) {
5389
+ const limits = decision.matchedRule?.limits;
5390
+ if (!this.rateLimiter || !limits?.maxCalls || !limits.windowMs) {
5391
+ return { allowed: true };
5392
+ }
5393
+ const key = buildLimitKey(limits.key, toolName, sessionId, senderId);
5394
+ const peek = this.rateLimiter.peek({
5395
+ key,
5396
+ maxCalls: limits.maxCalls,
5397
+ windowMs: limits.windowMs
5398
+ });
5399
+ return {
5400
+ plan: { kind: "rate", key, limits },
5401
+ block: {
5402
+ current: peek.current,
5403
+ limit: peek.limit,
5404
+ window_ms: peek.windowMs,
5405
+ reset_at_ms: peek.resetAtMs
5406
+ },
5407
+ allowed: peek.allowed
5408
+ };
5409
+ }
5410
+ planSpend(decision, toolName, sessionId, args, senderId) {
5411
+ const maxSpend = decision.matchedRule?.limits?.maxSpend;
5412
+ if (!this.spendLimiter || !maxSpend) return { allowed: true };
5413
+ const key = buildLimitKey(maxSpend.key, toolName, sessionId, senderId);
5414
+ const rawAmount = resolvePath(maxSpend.field, args ?? {});
5415
+ if (typeof rawAmount !== "number" || !Number.isFinite(rawAmount) || rawAmount < 0) {
5416
+ return { allowed: false, block: { reason: "invalid_amount", limit: maxSpend.limit } };
5417
+ }
5418
+ const peek = this.spendLimiter.peek({
5419
+ key,
5420
+ amount: rawAmount,
5421
+ limit: maxSpend.limit,
5422
+ windowMs: maxSpend.windowMs
5423
+ });
5424
+ return {
5425
+ plan: {
5426
+ kind: "spend",
5427
+ key,
5428
+ limits: decision.matchedRule.limits,
5429
+ amount: rawAmount,
5430
+ currency: maxSpend.currency
5431
+ },
5432
+ block: {
5433
+ current_spend: peek.currentSpend,
5434
+ limit: peek.limit,
5435
+ currency: maxSpend.currency,
5436
+ window_ms: peek.windowMs,
5437
+ reset_at_ms: peek.resetAtMs
5438
+ },
5439
+ allowed: peek.allowed
5440
+ };
5441
+ }
5442
+ /** Commit a limit plan at /audit time and return the evidence_chain block. */
5443
+ commitLimit(plan, actualAmount) {
5444
+ if (plan.kind === "rate" && this.rateLimiter && plan.limits.maxCalls && plan.limits.windowMs) {
5445
+ const r = this.rateLimiter.record({
5446
+ key: plan.key,
5447
+ maxCalls: plan.limits.maxCalls,
5448
+ windowMs: plan.limits.windowMs
5449
+ });
5450
+ return {
5451
+ rate_limit: {
5452
+ allowed: r.allowed,
5453
+ current: r.current,
5454
+ limit: r.limit,
5455
+ window_ms: r.windowMs,
5456
+ reset_at_ms: r.resetAtMs
5457
+ }
5458
+ };
5459
+ }
5460
+ if (plan.kind === "spend" && this.spendLimiter && plan.limits.maxSpend) {
5461
+ const amount = actualAmount ?? plan.amount ?? 0;
5462
+ const r = this.spendLimiter.record({
5463
+ key: plan.key,
5464
+ amount,
5465
+ limit: plan.limits.maxSpend.limit,
5466
+ windowMs: plan.limits.maxSpend.windowMs
5467
+ });
5468
+ this.spendLimiter.setCurrency(plan.key, plan.limits.maxSpend.currency);
5469
+ return {
5470
+ spend_limit: {
5471
+ allowed: r.allowed,
5472
+ current_spend: r.currentSpend,
5473
+ limit: r.limit,
5474
+ window_ms: r.windowMs,
5475
+ reset_at_ms: r.resetAtMs
5476
+ }
5477
+ };
5478
+ }
5479
+ return void 0;
5480
+ }
5481
+ writeAudit(args) {
5482
+ const id = randomUUID2();
5483
+ if (!this.auditWriter) return id;
5484
+ const blockReason = deriveBlockReason(args);
5485
+ let evidenceChain = args.limitsChain ?? null;
5486
+ if (args.sidebandUnreported) {
5487
+ evidenceChain = { ...evidenceChain ?? {}, sideband: { unreported: true } };
5488
+ }
5489
+ const record = {
5490
+ timestamp: args.timestampIso,
5491
+ session_id: args.sessionId,
5492
+ agent_id: args.agentId,
5493
+ environment: this.environment ?? null,
5494
+ tool_name: args.toolName,
5495
+ tool_input: args.toolInput,
5496
+ policy_decision: args.action,
5497
+ block_reason: blockReason,
5498
+ matched_rule: args.matchedRuleName,
5499
+ matched_rule_index: args.matchedRuleIndex,
5500
+ evidence_chain: evidenceChain,
5501
+ approval_status: args.approvalStatus ?? null,
5502
+ approved_by: args.approvedBy ?? null,
5503
+ upstream_response: args.upstreamResponse ?? null,
5504
+ upstream_error: args.upstreamError ?? null,
5505
+ upstream_http_status: null,
5506
+ upstream_latency_ms: args.upstreamLatencyMs ?? null,
5507
+ total_duration_ms: 0,
5508
+ approval_wait_ms: 0,
5509
+ proxy_compute_ms: 0,
5510
+ flagged_destructive: args.flaggedDestructive,
5511
+ dry_run: args.dryRun,
5512
+ record_kind: args.recordKind,
5513
+ origin: args.origin,
5514
+ metadata: args.metadata
5515
+ };
5516
+ const isEnforcement = args.recordKind === "evaluation_expired" || blockReason !== null || args.approvalStatus != null;
5517
+ if (isEnforcement) this.auditWriter.pushImmediate(record, id);
5518
+ else this.auditWriter.push(record, id);
5519
+ return id;
5520
+ }
5521
+ assertApprovalRouter(policy) {
5522
+ if (!policyCanRequireApproval(policy) || this.approvalRouter) return;
5523
+ throw new GovernanceConfigError(
5524
+ "[helio] GovernanceService misconfiguration: approval-capable policy (a require_approval rule, or flag_destructive/on_tool_drift set to require_approval) requires an approvalRouter"
5525
+ );
5526
+ }
5527
+ };
5528
+ function deriveBlockReason(args) {
5529
+ if (args.recordKind === "evaluation_expired") return null;
5530
+ if (args.recordKind === "install_scan") return args.wire === "deny" ? "install_denied" : null;
5531
+ if (args.dryRun) return null;
5532
+ if (args.approvalStatus === "denied") return "approval_denied";
5533
+ if (args.approvalStatus === "timeout") return "approval_timeout";
5534
+ if (args.approvalStatus === "cancelled") return "cancelled";
5535
+ switch (args.wire) {
5536
+ case "deny":
5537
+ return "policy_denied";
5538
+ case "rate_limited":
5539
+ return "rate_limited";
5540
+ case "spend_limited":
5541
+ return "spend_limited";
5542
+ default:
5543
+ return null;
5544
+ }
5545
+ }
5546
+ function buildFeedback(rule, reason) {
5547
+ const message = rule?.feedback?.message ?? reason;
5548
+ const suggestion = rule?.feedback?.suggestion;
5549
+ return suggestion ? { message, suggestion } : { message };
5550
+ }
5551
+ function isBlocking(wire) {
5552
+ return wire === "deny" || wire === "rate_limited" || wire === "spend_limited";
5553
+ }
5554
+ function isTerminalAtEvaluate(wire) {
5555
+ return wire === "deny" || wire === "rate_limited" || wire === "spend_limited" || wire === "dry_run";
5556
+ }
5557
+ function policyCanRequireApproval(policy) {
5558
+ if (policy.flagDestructive === "require_approval" || policy.onToolDrift === "require_approval") {
5559
+ return true;
5560
+ }
5561
+ return policy.rules.some((rule) => rule.action === "require_approval");
5562
+ }
5563
+ function buildLimitKey(keyType, toolName, sessionId, senderId) {
5564
+ switch (keyType) {
5565
+ case "session":
5566
+ return `session:${sessionId ?? "unknown"}`;
5567
+ case "sender_id":
5568
+ return `sender:${senderId ?? "unknown"}`;
5569
+ case "agent":
5570
+ case "tool":
5571
+ default:
5572
+ return `tool:${toolName}`;
5573
+ }
5574
+ }
5575
+ function senderIdOf(metadata) {
5576
+ const v = metadata?.["sender_id"];
5577
+ return typeof v === "string" ? v : null;
5578
+ }
5579
+ function matchInstallRule(rule, pkg2, metadataView) {
5580
+ if (rule.match.name && !rule.match.name.test(pkg2.name)) return false;
5581
+ if (rule.match.source !== void 0 && rule.match.source !== pkg2.source) return false;
5582
+ if (rule.match.metadata && !matchMetadata(rule.match.metadata, { metadata: metadataView })) {
5583
+ return false;
5584
+ }
5585
+ return true;
5586
+ }
5587
+ function reservedMetadataKey(metadata) {
5588
+ if (metadata && Object.prototype.hasOwnProperty.call(metadata, "agent_id")) {
5589
+ return "agent_id";
5590
+ }
5591
+ return null;
5592
+ }
5593
+ function definitionProvided(tool) {
5594
+ return tool.description !== void 0 || tool.input_schema !== void 0 || tool.output_schema !== void 0 || tool.title !== void 0 || tool.annotations !== void 0;
5595
+ }
5596
+ function toMcpToolDef(tool) {
5597
+ const def = { name: tool.name };
5598
+ if (tool.description !== void 0) def["description"] = tool.description;
5599
+ if (tool.input_schema !== void 0) def["inputSchema"] = tool.input_schema;
5600
+ if (tool.output_schema !== void 0) def["outputSchema"] = tool.output_schema;
5601
+ if (tool.title !== void 0) def["title"] = tool.title;
5602
+ if (tool.annotations !== void 0) def["annotations"] = tool.annotations;
5603
+ return def;
5604
+ }
5605
+ function byteLength(value) {
5606
+ return Buffer.byteLength(canonicalize(value), "utf8");
5607
+ }
5608
+
4048
5609
  // src/audit/store.ts
4049
5610
  import Database from "better-sqlite3";
4050
- import { randomUUID as randomUUID2 } from "crypto";
5611
+ import { randomUUID as randomUUID3 } from "crypto";
4051
5612
  import { chmodSync } from "fs";
4052
5613
 
4053
5614
  // src/upstream/response-summary.ts
@@ -4110,6 +5671,7 @@ function clampInt(value, fallback, min, max) {
4110
5671
  }
4111
5672
 
4112
5673
  // src/audit/store.ts
5674
+ var DRIFT_EVENT_DECISIONS_SQL = "('tool_drift', 'tool_drift_reverted')";
4113
5675
  var CREATE_TABLE_DDL = `
4114
5676
  CREATE TABLE IF NOT EXISTS audit_records (
4115
5677
  id TEXT PRIMARY KEY,
@@ -4135,6 +5697,9 @@ CREATE TABLE IF NOT EXISTS audit_records (
4135
5697
  proxy_compute_ms REAL NOT NULL,
4136
5698
  flagged_destructive INTEGER NOT NULL DEFAULT 0,
4137
5699
  dry_run INTEGER NOT NULL DEFAULT 0,
5700
+ record_kind TEXT NOT NULL DEFAULT 'tool_call',
5701
+ origin TEXT NOT NULL DEFAULT 'mcp',
5702
+ metadata TEXT,
4138
5703
  created_at TEXT NOT NULL
4139
5704
  );
4140
5705
  `;
@@ -4145,6 +5710,8 @@ CREATE INDEX IF NOT EXISTS idx_audit_policy_decision ON audit_records (policy_d
4145
5710
  CREATE INDEX IF NOT EXISTS idx_audit_session_id ON audit_records (session_id);
4146
5711
  CREATE INDEX IF NOT EXISTS idx_audit_block_reason ON audit_records (block_reason);
4147
5712
  CREATE INDEX IF NOT EXISTS idx_audit_upstream_status_created_at ON audit_records (upstream_http_status, created_at);
5713
+ CREATE INDEX IF NOT EXISTS idx_audit_record_kind ON audit_records (record_kind);
5714
+ CREATE INDEX IF NOT EXISTS idx_audit_origin ON audit_records (origin);
4148
5715
  `;
4149
5716
  var INSERT_SQL = `
4150
5717
  INSERT INTO audit_records (
@@ -4153,14 +5720,14 @@ INSERT INTO audit_records (
4153
5720
  approved_by, upstream_response, upstream_error, upstream_latency_ms,
4154
5721
  upstream_http_status,
4155
5722
  total_duration_ms, approval_wait_ms, proxy_compute_ms,
4156
- flagged_destructive, dry_run, created_at
5723
+ flagged_destructive, dry_run, record_kind, origin, metadata, created_at
4157
5724
  ) VALUES (
4158
5725
  @id, @timestamp, @session_id, @agent_id, @environment, @tool_name, @tool_input,
4159
5726
  @policy_decision, @block_reason, @matched_rule, @matched_rule_index, @evidence_chain, @approval_status,
4160
5727
  @approved_by, @upstream_response, @upstream_error, @upstream_latency_ms,
4161
5728
  @upstream_http_status,
4162
5729
  @total_duration_ms, @approval_wait_ms, @proxy_compute_ms,
4163
- @flagged_destructive, @dry_run, @created_at
5730
+ @flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @created_at
4164
5731
  )
4165
5732
  `;
4166
5733
  var REQUIRED_AUDIT_COLUMNS = [
@@ -4170,7 +5737,10 @@ var REQUIRED_AUDIT_COLUMNS = [
4170
5737
  "total_duration_ms",
4171
5738
  "approval_wait_ms",
4172
5739
  "proxy_compute_ms",
4173
- "upstream_http_status"
5740
+ "upstream_http_status",
5741
+ "record_kind",
5742
+ "origin",
5743
+ "metadata"
4174
5744
  ];
4175
5745
  function deserializeRow(row) {
4176
5746
  return {
@@ -4197,6 +5767,9 @@ function deserializeRow(row) {
4197
5767
  proxy_compute_ms: row.proxy_compute_ms,
4198
5768
  flagged_destructive: row.flagged_destructive === 1,
4199
5769
  dry_run: row.dry_run === 1,
5770
+ record_kind: row.record_kind,
5771
+ origin: row.origin,
5772
+ metadata: row.metadata ? JSON.parse(row.metadata) : null,
4200
5773
  created_at: row.created_at
4201
5774
  };
4202
5775
  }
@@ -4218,6 +5791,22 @@ function buildWhereClause(filters) {
4218
5791
  if (filters.blocked !== void 0) {
4219
5792
  conditions.push(filters.blocked ? "block_reason IS NOT NULL" : "block_reason IS NULL");
4220
5793
  }
5794
+ if (filters.record_kind !== void 0) {
5795
+ conditions.push("record_kind = ?");
5796
+ params.push(filters.record_kind);
5797
+ }
5798
+ if (filters.origin !== void 0) {
5799
+ conditions.push("origin LIKE ?");
5800
+ params.push(`%${filters.origin}%`);
5801
+ }
5802
+ if (filters.channel_id !== void 0) {
5803
+ conditions.push("json_extract(metadata, '$.channel_id') LIKE ?");
5804
+ params.push(`%${filters.channel_id}%`);
5805
+ }
5806
+ if (filters.sender_id !== void 0) {
5807
+ conditions.push("json_extract(metadata, '$.sender_id') LIKE ?");
5808
+ params.push(`%${filters.sender_id}%`);
5809
+ }
4221
5810
  if (filters.session_id !== void 0) {
4222
5811
  conditions.push("session_id = ?");
4223
5812
  params.push(filters.session_id);
@@ -4318,7 +5907,7 @@ var AuditStore = class {
4318
5907
  * @param id - Optional pre-generated ID (used by AuditWriter to share ID with SSE event bus).
4319
5908
  */
4320
5909
  insert(record, createdAt, id) {
4321
- const resolvedId = id ?? randomUUID2();
5910
+ const resolvedId = id ?? randomUUID3();
4322
5911
  const now = createdAt ?? (/* @__PURE__ */ new Date()).toISOString();
4323
5912
  this.insertStmt.run({
4324
5913
  id: resolvedId,
@@ -4344,6 +5933,9 @@ var AuditStore = class {
4344
5933
  proxy_compute_ms: record.proxy_compute_ms,
4345
5934
  flagged_destructive: record.flagged_destructive ? 1 : 0,
4346
5935
  dry_run: record.dry_run ? 1 : 0,
5936
+ record_kind: record.record_kind,
5937
+ origin: record.origin,
5938
+ metadata: record.metadata ? JSON.stringify(record.metadata) : null,
4347
5939
  created_at: now
4348
5940
  });
4349
5941
  return resolvedId;
@@ -4413,7 +6005,7 @@ var AuditStore = class {
4413
6005
  const totals = this.db.prepare(
4414
6006
  `SELECT
4415
6007
  COUNT(*) as total,
4416
- COALESCE(SUM(CASE WHEN block_reason IS NULL THEN 1 ELSE 0 END), 0) as allowed_total,
6008
+ COALESCE(SUM(CASE WHEN block_reason IS NULL AND policy_decision NOT IN ${DRIFT_EVENT_DECISIONS_SQL} THEN 1 ELSE 0 END), 0) as allowed_total,
4417
6009
  COALESCE(SUM(CASE WHEN block_reason IS NOT NULL THEN 1 ELSE 0 END), 0) as blocked_total,
4418
6010
  COALESCE(SUM(CASE WHEN dry_run = 1 THEN 1 ELSE 0 END), 0) as dry_run_total,
4419
6011
  COALESCE(SUM(CASE WHEN dry_run = 0 THEN 1 ELSE 0 END), 0) as applied_total
@@ -4432,9 +6024,10 @@ var AuditStore = class {
4432
6024
  GROUP BY block_reason
4433
6025
  ORDER BY count DESC`
4434
6026
  ).all(...params);
6027
+ const toolsClause = clause ? `${clause} AND policy_decision NOT IN ${DRIFT_EVENT_DECISIONS_SQL}` : `WHERE policy_decision NOT IN ${DRIFT_EVENT_DECISIONS_SQL}`;
4435
6028
  const top_tools = this.db.prepare(
4436
6029
  `SELECT tool_name, COUNT(*) as count
4437
- FROM audit_records ${clause}
6030
+ FROM audit_records ${toolsClause}
4438
6031
  GROUP BY tool_name
4439
6032
  ORDER BY count DESC
4440
6033
  LIMIT 10`
@@ -4489,7 +6082,7 @@ var AuditStore = class {
4489
6082
  };
4490
6083
 
4491
6084
  // src/audit/writer.ts
4492
- import { randomUUID as randomUUID3 } from "crypto";
6085
+ import { randomUUID as randomUUID4 } from "crypto";
4493
6086
  var AuditWriter = class {
4494
6087
  store;
4495
6088
  bufferSize;
@@ -4524,9 +6117,8 @@ var AuditWriter = class {
4524
6117
  * is scheduled. This keeps request-path latency bounded even under bursty
4525
6118
  * write load.
4526
6119
  */
4527
- push(record) {
6120
+ push(record, id = randomUUID4()) {
4528
6121
  if (this.closed) return;
4529
- const id = randomUUID3();
4530
6122
  this.buffer.push({ id, record });
4531
6123
  this.onPush?.(record, id);
4532
6124
  if (this.buffer.length >= this.bufferSize) {
@@ -4541,9 +6133,8 @@ var AuditWriter = class {
4541
6133
  * A fatal-process crash still invokes the crash-drain hook, which calls
4542
6134
  * `flush()` synchronously before exit.
4543
6135
  */
4544
- pushImmediate(record) {
6136
+ pushImmediate(record, id = randomUUID4()) {
4545
6137
  if (this.closed) return;
4546
- const id = randomUUID3();
4547
6138
  this.buffer.push({ id, record });
4548
6139
  this.onPush?.(record, id);
4549
6140
  this.scheduleFlushSoon();
@@ -4599,7 +6190,7 @@ var AuditWriter = class {
4599
6190
  };
4600
6191
 
4601
6192
  // src/approval/queue.ts
4602
- import { randomUUID as randomUUID4 } from "crypto";
6193
+ import { randomUUID as randomUUID5 } from "crypto";
4603
6194
  var ApprovalQueue = class {
4604
6195
  tickets = /* @__PURE__ */ new Map();
4605
6196
  now;
@@ -4629,7 +6220,7 @@ var ApprovalQueue = class {
4629
6220
  if (this.closed) throw new Error("ApprovalQueue is closed");
4630
6221
  const now = this.now();
4631
6222
  const ticket = {
4632
- id: randomUUID4(),
6223
+ id: randomUUID5(),
4633
6224
  tool_name: params.tool_name,
4634
6225
  tool_input: params.tool_input,
4635
6226
  matched_rule: params.matched_rule,
@@ -4708,6 +6299,7 @@ var ApprovalQueue = class {
4708
6299
  };
4709
6300
 
4710
6301
  // src/approval/router.ts
6302
+ var NATIVE_CHANNEL_PREFIX = "native:";
4711
6303
  var ApprovalRouter = class {
4712
6304
  defaultTimeoutMs;
4713
6305
  defaultOnTimeout;
@@ -4820,6 +6412,59 @@ var ApprovalRouter = class {
4820
6412
  });
4821
6413
  return outcome;
4822
6414
  }
6415
+ /**
6416
+ * Create a native (adapter-owned) approval ticket without holding a Promise.
6417
+ *
6418
+ * Used by the sideband governance path (issue #12, D10): when `/evaluate`
6419
+ * yields `require_approval`, the adapter runs the approval in its own UI
6420
+ * (e.g. OpenClaw's Telegram dialog), so Helio must NOT block, start
6421
+ * timeout/escalation timers, or notify a channel — doing so would
6422
+ * double-notify. We still create the queue ticket and fire `onSubmit` so the
6423
+ * dashboard's `approval_requested` SSE event flows and the ticket is visible.
6424
+ *
6425
+ * The ticket's `channel_name` is `native:<origin>`, which marks it as
6426
+ * adapter-owned: the dashboard approve/deny endpoints refuse it (it can only
6427
+ * be resolved through the adapter), and {@link resolveNativeTicket} is the
6428
+ * resolution path.
6429
+ */
6430
+ createNativeTicket(params) {
6431
+ const rule = params.matched_rule;
6432
+ const timeoutMs = params.timeout_ms ?? rule?.approval?.timeoutMs ?? this.defaultTimeoutMs;
6433
+ const ticket = this.queue.add({
6434
+ tool_name: params.tool_name,
6435
+ tool_input: params.tool_input,
6436
+ matched_rule: rule?.name ?? null,
6437
+ rule_index: rule?.index ?? null,
6438
+ channel_name: `${NATIVE_CHANNEL_PREFIX}${params.origin}`,
6439
+ session_id: params.session_id,
6440
+ timeout_ms: timeoutMs
6441
+ });
6442
+ this.onSubmit?.(ticket);
6443
+ return ticket;
6444
+ }
6445
+ /**
6446
+ * Resolve a native ticket created by {@link createNativeTicket}.
6447
+ *
6448
+ * Resolves the queue ticket and fires `onResolve` (→ `approval_resolved`
6449
+ * SSE), with no held Promise to settle. Refuses tickets that have a pending
6450
+ * router Promise (those are MCP-path tickets; resolving them here would leave
6451
+ * the held request hanging) and tickets that are not `native:`-prefixed.
6452
+ *
6453
+ * @returns `true` if resolved, `false` if not found, already resolved, not a
6454
+ * native ticket, or router-managed.
6455
+ */
6456
+ resolveNativeTicket(ticketId, status, resolvedBy, options) {
6457
+ if (this.pending.has(ticketId)) return false;
6458
+ const ticket = this.queue.get(ticketId);
6459
+ if (!ticket || !ticket.channel_name.startsWith(NATIVE_CHANNEL_PREFIX)) return false;
6460
+ const resolved = this.queue.resolve(ticketId, status, resolvedBy, {
6461
+ denial_reason: options?.denial_reason
6462
+ });
6463
+ if (!resolved) return false;
6464
+ const updated = this.queue.get(ticketId);
6465
+ if (updated) this.onResolve?.(updated);
6466
+ return true;
6467
+ }
4823
6468
  /**
4824
6469
  * Approve a pending ticket. Resolves the held Promise so the governed
4825
6470
  * forwarder can forward the request upstream.
@@ -4864,6 +6509,10 @@ var ApprovalRouter = class {
4864
6509
  ticketId
4865
6510
  });
4866
6511
  }
6512
+ /** Look up a ticket by id (delegates to the queue). */
6513
+ getTicket(ticketId) {
6514
+ return this.queue.get(ticketId);
6515
+ }
4867
6516
  /** Clean up all pending timers and resolve all pending promises. */
4868
6517
  close() {
4869
6518
  this.closed = true;
@@ -5126,8 +6775,8 @@ function createChannels(channels) {
5126
6775
 
5127
6776
  // src/approval/slack-actions.ts
5128
6777
  import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
5129
- import { Hono as Hono5 } from "hono";
5130
- import { z as z5 } from "zod";
6778
+ import { Hono as Hono6 } from "hono";
6779
+ import { z as z6 } from "zod";
5131
6780
  var MAX_TIMESTAMP_AGE_S = 300;
5132
6781
  var REJECTION_LOG_WINDOW_MS = 6e4;
5133
6782
  var REJECTION_LOG_SAMPLE_EVERY = 25;
@@ -5193,12 +6842,12 @@ function verifySlackSignature(secrets, timestamp, rawBody, signature) {
5193
6842
  }
5194
6843
  return false;
5195
6844
  }
5196
- var slackActionPayloadSchema = z5.object({
5197
- type: z5.string(),
5198
- user: z5.object({ id: z5.string(), username: z5.string() }),
5199
- actions: z5.array(z5.object({ action_id: z5.string() })),
5200
- channel: z5.object({ id: z5.string() }),
5201
- message: z5.object({ ts: z5.string() })
6845
+ var slackActionPayloadSchema = z6.object({
6846
+ type: z6.string(),
6847
+ user: z6.object({ id: z6.string(), username: z6.string() }),
6848
+ actions: z6.array(z6.object({ action_id: z6.string() })),
6849
+ channel: z6.object({ id: z6.string() }),
6850
+ message: z6.object({ ts: z6.string() })
5202
6851
  });
5203
6852
  function parseActionPayload(rawBody) {
5204
6853
  try {
@@ -5228,7 +6877,7 @@ Ticket \`${ticketId}\``
5228
6877
  }
5229
6878
  function createSlackActionApp(options) {
5230
6879
  const { router, channels } = options;
5231
- const app = new Hono5();
6880
+ const app = new Hono6();
5232
6881
  const rejectionLogBuckets = /* @__PURE__ */ new Map();
5233
6882
  const rejectUnauthorized = (c, reason, context) => {
5234
6883
  logRejectedSlackCallback(rejectionLogBuckets, {
@@ -5327,18 +6976,18 @@ function createSlackActionApp(options) {
5327
6976
  }
5328
6977
 
5329
6978
  // src/approval/api.ts
5330
- import { Hono as Hono6 } from "hono";
5331
- import { z as z6 } from "zod";
5332
- var approveBody = z6.object({
5333
- approved_by: z6.string().min(1)
6979
+ import { Hono as Hono7 } from "hono";
6980
+ import { z as z7 } from "zod";
6981
+ var approveBody = z7.object({
6982
+ approved_by: z7.string().min(1)
5334
6983
  });
5335
- var denyBody = z6.object({
5336
- denied_by: z6.string().min(1),
5337
- reason: z6.string().optional()
6984
+ var denyBody = z7.object({
6985
+ denied_by: z7.string().min(1),
6986
+ reason: z7.string().optional()
5338
6987
  });
5339
- var breakGlassBody = z6.object({
5340
- approved_by: z6.string().min(1),
5341
- reason: z6.string().min(1)
6988
+ var breakGlassBody = z7.object({
6989
+ approved_by: z7.string().min(1),
6990
+ reason: z7.string().min(1)
5342
6991
  });
5343
6992
  var APPROVAL_STATUSES = [
5344
6993
  "pending",
@@ -5347,25 +6996,26 @@ var APPROVAL_STATUSES = [
5347
6996
  "timeout",
5348
6997
  "break_glass",
5349
6998
  "client_disconnected",
5350
- "shutdown_cancelled"
6999
+ "shutdown_cancelled",
7000
+ "cancelled"
5351
7001
  ];
5352
7002
  var approvalStatusSet = new Set(APPROVAL_STATUSES);
5353
- var listApprovalsQuery = z6.object({
5354
- status: z6.preprocess(
7003
+ var listApprovalsQuery = z7.object({
7004
+ status: z7.preprocess(
5355
7005
  (value) => typeof value === "string" && approvalStatusSet.has(value) ? value : void 0,
5356
- z6.enum(APPROVAL_STATUSES).optional()
7006
+ z7.enum(APPROVAL_STATUSES).optional()
5357
7007
  ),
5358
- limit: z6.preprocess(
7008
+ limit: z7.preprocess(
5359
7009
  (value) => clampInt(typeof value === "string" ? value : void 0, 50, 1, 1e3),
5360
- z6.number().int()
7010
+ z7.number().int()
5361
7011
  ),
5362
- offset: z6.preprocess(
7012
+ offset: z7.preprocess(
5363
7013
  (value) => clampInt(typeof value === "string" ? value : void 0, 0, 0, Number.MAX_SAFE_INTEGER),
5364
- z6.number().int()
7014
+ z7.number().int()
5365
7015
  )
5366
7016
  });
5367
7017
  function createApprovalApp(router, queue, options) {
5368
- const app = new Hono6();
7018
+ const app = new Hono7();
5369
7019
  const apiSecret = options?.apiSecret;
5370
7020
  if (apiSecret) {
5371
7021
  app.use("*", async (c, next) => {
@@ -5411,6 +7061,15 @@ function createApprovalApp(router, queue, options) {
5411
7061
  if (!ticket) {
5412
7062
  return c.json({ error: "Ticket not found" }, 404);
5413
7063
  }
7064
+ if (ticket.channel_name.startsWith(NATIVE_CHANNEL_PREFIX)) {
7065
+ return c.json(
7066
+ {
7067
+ error: "native_ticket",
7068
+ resolve_in: ticket.channel_name.slice(NATIVE_CHANNEL_PREFIX.length)
7069
+ },
7070
+ 409
7071
+ );
7072
+ }
5414
7073
  if (ticket.status !== "pending") {
5415
7074
  return c.json({ error: "Ticket already resolved", status: ticket.status }, 409);
5416
7075
  }
@@ -5436,6 +7095,15 @@ function createApprovalApp(router, queue, options) {
5436
7095
  if (!ticket) {
5437
7096
  return c.json({ error: "Ticket not found" }, 404);
5438
7097
  }
7098
+ if (ticket.channel_name.startsWith(NATIVE_CHANNEL_PREFIX)) {
7099
+ return c.json(
7100
+ {
7101
+ error: "native_ticket",
7102
+ resolve_in: ticket.channel_name.slice(NATIVE_CHANNEL_PREFIX.length)
7103
+ },
7104
+ 409
7105
+ );
7106
+ }
5439
7107
  if (ticket.status !== "pending") {
5440
7108
  return c.json({ error: "Ticket already resolved", status: ticket.status }, 409);
5441
7109
  }
@@ -5461,6 +7129,15 @@ function createApprovalApp(router, queue, options) {
5461
7129
  if (!ticket) {
5462
7130
  return c.json({ error: "Ticket not found" }, 404);
5463
7131
  }
7132
+ if (ticket.channel_name.startsWith(NATIVE_CHANNEL_PREFIX)) {
7133
+ return c.json(
7134
+ {
7135
+ error: "native_ticket",
7136
+ resolve_in: ticket.channel_name.slice(NATIVE_CHANNEL_PREFIX.length)
7137
+ },
7138
+ 409
7139
+ );
7140
+ }
5464
7141
  if (ticket.status !== "pending") {
5465
7142
  return c.json({ error: "Ticket already resolved", status: ticket.status }, 409);
5466
7143
  }
@@ -5476,9 +7153,9 @@ function createApprovalApp(router, queue, options) {
5476
7153
  // src/dashboard/api.ts
5477
7154
  import { readFileSync } from "fs";
5478
7155
  import { join } from "path";
5479
- import { randomUUID as randomUUID5 } from "crypto";
5480
- import { Hono as Hono7 } from "hono";
5481
- import { z as z7 } from "zod";
7156
+ import { randomUUID as randomUUID6 } from "crypto";
7157
+ import { Hono as Hono8 } from "hono";
7158
+ import { z as z8 } from "zod";
5482
7159
  import { cors } from "hono/cors";
5483
7160
  import { serveStatic } from "@hono/node-server/serve-static";
5484
7161
  import { streamSSE } from "hono/streaming";
@@ -5539,7 +7216,7 @@ function recordsToCsv(records) {
5539
7216
  }
5540
7217
 
5541
7218
  // src/dashboard/session.ts
5542
- import { createHash as createHash2, createHmac as createHmac3, randomBytes, timingSafeEqual as timingSafeEqual3 } from "crypto";
7219
+ import { createHash as createHash3, createHmac as createHmac3, randomBytes, timingSafeEqual as timingSafeEqual3 } from "crypto";
5543
7220
  var DashboardSessionStore = class {
5544
7221
  secret;
5545
7222
  ttlMs;
@@ -5620,8 +7297,8 @@ var DashboardSessionStore = class {
5620
7297
  const id = token.slice(0, dot);
5621
7298
  const signature = token.slice(dot + 1);
5622
7299
  const expected = this.sign(id);
5623
- const actualDigest = createHash2("sha256").update(signature).digest();
5624
- const expectedDigest = createHash2("sha256").update(expected).digest();
7300
+ const actualDigest = createHash3("sha256").update(signature).digest();
7301
+ const expectedDigest = createHash3("sha256").update(expected).digest();
5625
7302
  if (!timingSafeEqual3(actualDigest, expectedDigest)) return void 0;
5626
7303
  return id;
5627
7304
  }
@@ -5631,29 +7308,29 @@ var DashboardSessionStore = class {
5631
7308
  };
5632
7309
 
5633
7310
  // src/dashboard/api.ts
5634
- var optionalQueryString = z7.preprocess(
7311
+ var optionalQueryString = z8.preprocess(
5635
7312
  (value) => typeof value === "string" && value.length > 0 ? value : void 0,
5636
- z7.string().optional()
7313
+ z8.string().optional()
5637
7314
  );
5638
- var optionalQueryInt = z7.preprocess((value) => {
7315
+ var optionalQueryInt = z8.preprocess((value) => {
5639
7316
  if (typeof value !== "string" || value.length === 0) return void 0;
5640
7317
  const parsed = Number.parseInt(value, 10);
5641
7318
  return Number.isFinite(parsed) ? parsed : void 0;
5642
- }, z7.number().int().optional());
5643
- var queryBoolean = z7.preprocess(
7319
+ }, z8.number().int().optional());
7320
+ var queryBoolean = z8.preprocess(
5644
7321
  (value) => value === "true" ? true : value === "false" ? false : void 0,
5645
- z7.boolean().optional()
7322
+ z8.boolean().optional()
5646
7323
  );
5647
- var clampedQueryInt = (fallback, min, max) => z7.preprocess(
7324
+ var clampedQueryInt = (fallback, min, max) => z8.preprocess(
5648
7325
  (value) => clampInt(typeof value === "string" ? value : void 0, fallback, min, max),
5649
- z7.number().int()
7326
+ z8.number().int()
5650
7327
  );
5651
- var feedQuerySchema = z7.object({
7328
+ var feedQuerySchema = z8.object({
5652
7329
  limit: clampedQueryInt(50, 1, 200),
5653
7330
  offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER)
5654
7331
  });
5655
- var auditExportQuerySchema = z7.object({
5656
- format: z7.preprocess((value) => value === "csv" ? "csv" : "json", z7.enum(["json", "csv"])),
7332
+ var auditExportQuerySchema = z8.object({
7333
+ format: z8.preprocess((value) => value === "csv" ? "csv" : "json", z8.enum(["json", "csv"])),
5657
7334
  limit: clampedQueryInt(1e4, 1, 1e4),
5658
7335
  tool: optionalQueryString,
5659
7336
  decision: optionalQueryString,
@@ -5665,9 +7342,13 @@ var auditExportQuerySchema = z7.object({
5665
7342
  from: optionalQueryString,
5666
7343
  to: optionalQueryString,
5667
7344
  upstream_status_min: optionalQueryInt,
5668
- upstream_status_max: optionalQueryInt
7345
+ upstream_status_max: optionalQueryInt,
7346
+ origin: optionalQueryString,
7347
+ record_kind: optionalQueryString,
7348
+ channel_id: optionalQueryString,
7349
+ sender_id: optionalQueryString
5669
7350
  });
5670
- var auditQuerySchema = z7.object({
7351
+ var auditQuerySchema = z8.object({
5671
7352
  limit: clampedQueryInt(50, 1, 1e3),
5672
7353
  offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER),
5673
7354
  tool: optionalQueryString,
@@ -5681,14 +7362,18 @@ var auditQuerySchema = z7.object({
5681
7362
  destructive: queryBoolean,
5682
7363
  dry_run: queryBoolean,
5683
7364
  upstream_status_min: optionalQueryInt,
5684
- upstream_status_max: optionalQueryInt
7365
+ upstream_status_max: optionalQueryInt,
7366
+ origin: optionalQueryString,
7367
+ record_kind: optionalQueryString,
7368
+ channel_id: optionalQueryString,
7369
+ sender_id: optionalQueryString
5685
7370
  });
5686
- var analyticsQuerySchema = z7.object({
7371
+ var analyticsQuerySchema = z8.object({
5687
7372
  from: optionalQueryString,
5688
7373
  to: optionalQueryString
5689
7374
  });
5690
- var authSessionBodySchema = z7.object({
5691
- secret: z7.string()
7375
+ var authSessionBodySchema = z8.object({
7376
+ secret: z8.string()
5692
7377
  });
5693
7378
  var SESSION_COOKIE = "helio_session";
5694
7379
  var SESSION_TTL_MS = 8 * 60 * 60 * 1e3;
@@ -5748,7 +7433,7 @@ function createDashboardAppWithLifecycle(deps, options) {
5748
7433
  } = deps;
5749
7434
  const apiSecret = options?.apiSecret;
5750
7435
  const sessionStore = apiSecret ? new DashboardSessionStore({ secret: apiSecret, ttlMs: SESSION_TTL_MS }) : void 0;
5751
- const app = new Hono7();
7436
+ const app = new Hono8();
5752
7437
  app.use(
5753
7438
  "*",
5754
7439
  cors({
@@ -5888,7 +7573,11 @@ function createDashboardAppWithLifecycle(deps, options) {
5888
7573
  from: query.from,
5889
7574
  to: query.to,
5890
7575
  upstream_status_min: query.upstream_status_min,
5891
- upstream_status_max: query.upstream_status_max
7576
+ upstream_status_max: query.upstream_status_max,
7577
+ origin: query.origin,
7578
+ record_kind: query.record_kind,
7579
+ channel_id: query.channel_id,
7580
+ sender_id: query.sender_id
5892
7581
  };
5893
7582
  const result = auditStore.list(filters, { limit, order: "asc" });
5894
7583
  if (format === "csv") {
@@ -5930,7 +7619,11 @@ function createDashboardAppWithLifecycle(deps, options) {
5930
7619
  flagged_destructive: query.destructive,
5931
7620
  dry_run: query.dry_run,
5932
7621
  upstream_status_min: query.upstream_status_min,
5933
- upstream_status_max: query.upstream_status_max
7622
+ upstream_status_max: query.upstream_status_max,
7623
+ origin: query.origin,
7624
+ record_kind: query.record_kind,
7625
+ channel_id: query.channel_id,
7626
+ sender_id: query.sender_id
5934
7627
  };
5935
7628
  const result = auditStore.list(filters, { limit, offset, order: "desc" });
5936
7629
  return c.json({
@@ -5991,7 +7684,7 @@ function createDashboardAppWithLifecycle(deps, options) {
5991
7684
  app.get("/api/events", (c) => {
5992
7685
  return streamSSE(c, async (stream) => {
5993
7686
  if (closed) return;
5994
- const connId = randomUUID5();
7687
+ const connId = randomUUID6();
5995
7688
  let streamClosed = false;
5996
7689
  let stopHeartbeat = () => {
5997
7690
  };
@@ -6020,7 +7713,7 @@ function createDashboardAppWithLifecycle(deps, options) {
6020
7713
  void stream.writeSSE({
6021
7714
  event: eventType,
6022
7715
  data: JSON.stringify(data),
6023
- id: randomUUID5()
7716
+ id: randomUUID6()
6024
7717
  }).then(() => {
6025
7718
  const conn = activeConnections.get(connId);
6026
7719
  if (conn) conn.lastWrite = Date.now();
@@ -6121,6 +7814,8 @@ export {
6121
7814
  ConfigError,
6122
7815
  DashboardEventBus,
6123
7816
  EvidenceStore,
7817
+ GovernanceConfigError,
7818
+ GovernanceService,
6124
7819
  GovernedForwarder,
6125
7820
  PolicyParseError,
6126
7821
  QueueChannel,