@gethelio/proxy 0.4.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,30 @@ 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(),
164
195
  /**
165
196
  * How to treat calls to a tool whose definition (annotations, schemas,
166
197
  * description) has drifted from the baseline Helio captured on first
@@ -221,7 +252,14 @@ var auditSchema = z.object({
221
252
  var sdkSchema = z.object({
222
253
  enabled: z.boolean().default(false),
223
254
  port: z.number().int().min(1).max(65535).default(3200),
224
- 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")
225
263
  });
226
264
  var helioConfigBaseSchema = z.object({
227
265
  version: z.literal("1"),
@@ -284,6 +322,22 @@ var helioConfigSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
284
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.`
285
323
  });
286
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
+ }
287
341
  if (rule.action === "rate_limit") {
288
342
  if (rule.limits?.max_calls === void 0) {
289
343
  ctx.addIssue({
@@ -425,6 +479,7 @@ var PolicyParseError = class extends Error {
425
479
 
426
480
  // src/policy/parser.ts
427
481
  var INPUT_OPERATORS = ["eq", "neq", "gt", "gte", "lt", "lte", "contains", "regex"];
482
+ var METADATA_OPERATORS = ["eq", "neq", "contains", "regex"];
428
483
  function compilePolicies(config) {
429
484
  const warnings = [];
430
485
  const rules = config.rules.map((rule, index) => compileRule(rule, index, warnings));
@@ -433,10 +488,36 @@ function compilePolicies(config) {
433
488
  flagDestructive: config.flag_destructive,
434
489
  ...config.dry_run && { dryRun: true },
435
490
  ...config.on_tool_drift && { onToolDrift: config.on_tool_drift },
436
- rules
491
+ rules,
492
+ ...config.install && { install: compileInstallPolicy(config.install) }
437
493
  };
438
494
  return { policy, warnings };
439
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
+ }
440
521
  function compileRule(rule, index, warnings) {
441
522
  const match = compileMatch(rule.match, index, rule.name);
442
523
  const approval = compileApproval(rule.approval);
@@ -469,7 +550,10 @@ function compileMatch(match, ruleIndex, ruleName) {
469
550
  ...match.input !== void 0 && {
470
551
  input: flattenInputConditions(match.input, ruleIndex, ruleName)
471
552
  },
472
- ...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
+ }
473
557
  };
474
558
  }
475
559
  function compileToolMatcher(pattern, ruleIndex, ruleName) {
@@ -521,6 +605,43 @@ function flattenInputConditions(input, ruleIndex, ruleName) {
521
605
  }
522
606
  return conditions;
523
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
+ }
524
645
  function compileApproval(approval) {
525
646
  if (!approval) return void 0;
526
647
  return {
@@ -2155,12 +2276,31 @@ function matchEnvironment(required, ctx) {
2155
2276
  if (ctx.environment === void 0) return false;
2156
2277
  return ctx.environment === required;
2157
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
+ }
2158
2297
  function matchRule(rule, ctx) {
2159
2298
  const { match } = rule;
2160
2299
  if (match.tool !== void 0 && !matchTool(match.tool, ctx)) return false;
2161
2300
  if (match.annotations !== void 0 && !matchAnnotations(match.annotations, ctx)) return false;
2162
2301
  if (match.input !== void 0 && !matchInput(match.input, ctx)) return false;
2163
2302
  if (match.environment !== void 0 && !matchEnvironment(match.environment, ctx)) return false;
2303
+ if (match.metadata !== void 0 && !matchMetadata(match.metadata, ctx)) return false;
2164
2304
  return true;
2165
2305
  }
2166
2306
 
@@ -2183,6 +2323,197 @@ function evaluatePolicy(policy, ctx) {
2183
2323
  };
2184
2324
  }
2185
2325
 
2326
+ // src/evidence/grounding.ts
2327
+ function checkEvidence(store, sessionId, requirements) {
2328
+ if (requirements.length === 0) {
2329
+ return { satisfied: true, missing: [], expired: [], found: [] };
2330
+ }
2331
+ const found = [];
2332
+ const missing = [];
2333
+ const expired = [];
2334
+ for (const key of requirements) {
2335
+ const valid = store.getEvidence(sessionId, key);
2336
+ if (valid) {
2337
+ found.push(key);
2338
+ } else if (store.hasSeenEvidence(sessionId, key)) {
2339
+ expired.push(key);
2340
+ } else {
2341
+ missing.push(key);
2342
+ }
2343
+ }
2344
+ return {
2345
+ satisfied: missing.length === 0 && expired.length === 0,
2346
+ missing,
2347
+ expired,
2348
+ found
2349
+ };
2350
+ }
2351
+ function checkDependencies(store, sessionId, requirements, options = {}) {
2352
+ if (requirements.length === 0) {
2353
+ return { satisfied: true, missing: [] };
2354
+ }
2355
+ const requireSuccess = options.requireSuccess ?? true;
2356
+ const missing = [];
2357
+ for (const toolName of requirements) {
2358
+ const satisfied = requireSuccess ? store.hasSuccessfulTool(sessionId, toolName) : store.hasCompletedTool(sessionId, toolName);
2359
+ if (!satisfied) {
2360
+ missing.push(toolName);
2361
+ }
2362
+ }
2363
+ return {
2364
+ satisfied: missing.length === 0,
2365
+ missing
2366
+ };
2367
+ }
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
+
2186
2517
  // src/policy/annotation-cache.ts
2187
2518
  var ASPECT_FIELDS = [
2188
2519
  "annotations",
@@ -2285,6 +2616,74 @@ var ToolAnnotationCache = class {
2285
2616
  this.currentAnnotations = currentAnnotations;
2286
2617
  return { updated: true, baselined, drifted, reverted };
2287
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
+ }
2288
2687
  /**
2289
2688
  * Get the **baseline** annotations for a tool — the definition first seen,
2290
2689
  * not the latest upstream claim. Returns `undefined` if the tool has no
@@ -2318,27 +2717,6 @@ function extractAnnotations(tool) {
2318
2717
  const annotations = tool["annotations"];
2319
2718
  return annotations && typeof annotations === "object" ? annotations : void 0;
2320
2719
  }
2321
- function canonicalize(value) {
2322
- const encoded = JSON.stringify(sortKeysDeep(value));
2323
- return encoded ?? "";
2324
- }
2325
- function sortKeysDeep(value) {
2326
- if (Array.isArray(value)) return value.map(sortKeysDeep);
2327
- if (value !== null && typeof value === "object") {
2328
- const source = value;
2329
- const out = {};
2330
- for (const key of Object.keys(source).sort()) {
2331
- Object.defineProperty(out, key, {
2332
- value: sortKeysDeep(source[key]),
2333
- enumerable: true,
2334
- writable: true,
2335
- configurable: true
2336
- });
2337
- }
2338
- return out;
2339
- }
2340
- return value;
2341
- }
2342
2720
  function extractTools(body) {
2343
2721
  if (typeof body !== "object" || body === null) return null;
2344
2722
  const b = body;
@@ -2350,49 +2728,6 @@ function extractTools(body) {
2350
2728
  return tools;
2351
2729
  }
2352
2730
 
2353
- // src/evidence/grounding.ts
2354
- function checkEvidence(store, sessionId, requirements) {
2355
- if (requirements.length === 0) {
2356
- return { satisfied: true, missing: [], expired: [], found: [] };
2357
- }
2358
- const found = [];
2359
- const missing = [];
2360
- const expired = [];
2361
- for (const key of requirements) {
2362
- const valid = store.getEvidence(sessionId, key);
2363
- if (valid) {
2364
- found.push(key);
2365
- } else if (store.hasSeenEvidence(sessionId, key)) {
2366
- expired.push(key);
2367
- } else {
2368
- missing.push(key);
2369
- }
2370
- }
2371
- return {
2372
- satisfied: missing.length === 0 && expired.length === 0,
2373
- missing,
2374
- expired,
2375
- found
2376
- };
2377
- }
2378
- function checkDependencies(store, sessionId, requirements, options = {}) {
2379
- if (requirements.length === 0) {
2380
- return { satisfied: true, missing: [] };
2381
- }
2382
- const requireSuccess = options.requireSuccess ?? true;
2383
- const missing = [];
2384
- for (const toolName of requirements) {
2385
- const satisfied = requireSuccess ? store.hasSuccessfulTool(sessionId, toolName) : store.hasCompletedTool(sessionId, toolName);
2386
- if (!satisfied) {
2387
- missing.push(toolName);
2388
- }
2389
- }
2390
- return {
2391
- satisfied: missing.length === 0,
2392
- missing
2393
- };
2394
- }
2395
-
2396
2731
  // src/feedback/self-repair.ts
2397
2732
  function ruleInfo(rule) {
2398
2733
  return {
@@ -2604,6 +2939,7 @@ var GovernedForwarder = class {
2604
2939
  spendLimiter;
2605
2940
  annotationCache = new ToolAnnotationCache();
2606
2941
  agentKeyWarned = false;
2942
+ senderKeyWarned = false;
2607
2943
  constructor(inner, policy, options) {
2608
2944
  this.inner = inner;
2609
2945
  this.policy = policy;
@@ -2765,7 +3101,10 @@ var GovernedForwarder = class {
2765
3101
  approval_wait_ms: 0,
2766
3102
  proxy_compute_ms: 0,
2767
3103
  flagged_destructive: false,
2768
- dry_run: false
3104
+ dry_run: false,
3105
+ record_kind: "drift_event",
3106
+ origin: "mcp",
3107
+ metadata: null
2769
3108
  });
2770
3109
  }
2771
3110
  async handleToolsCall(request) {
@@ -2777,99 +3116,28 @@ var GovernedForwarder = class {
2777
3116
  return this.inner.forward(request);
2778
3117
  }
2779
3118
  const toolArguments = params?.["arguments"] && typeof params["arguments"] === "object" ? params["arguments"] : void 0;
2780
- const annotations = this.annotationCache.get(toolName);
2781
- const driftEvent = this.annotationCache.getDrift(toolName);
2782
- const driftMode = this.policy.onToolDrift ?? "block";
2783
- let decision = evaluatePolicy(this.policy, {
2784
- toolName,
2785
- annotations,
3119
+ const {
3120
+ decision,
3121
+ driftEvent,
3122
+ driftMode,
3123
+ driftBlocked,
3124
+ flaggedDestructive,
3125
+ evidenceResult,
3126
+ dependencyResult,
3127
+ evidenceBlocked,
3128
+ sessionBlocked,
3129
+ isDryRun
3130
+ } = decide({
3131
+ toolName,
2786
3132
  toolArguments,
2787
- 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)
2788
3140
  });
2789
- if (driftEvent && driftMode === "log") {
2790
- const currentDecision = evaluatePolicy(this.policy, {
2791
- toolName,
2792
- annotations: this.annotationCache.getCurrent(toolName),
2793
- toolArguments,
2794
- environment: this.environment
2795
- });
2796
- decision = stricterDecision(decision, currentDecision);
2797
- }
2798
- const baselineDestructive = annotations?.destructiveHint ?? true;
2799
- const currentDestructive = driftEvent && driftMode === "log" ? this.annotationCache.getCurrent(toolName)?.destructiveHint ?? true : false;
2800
- const isDestructive = baselineDestructive || currentDestructive;
2801
- let flaggedDestructive = false;
2802
- if (isDestructive && !decision.matchedRule && this.policy.flagDestructive) {
2803
- flaggedDestructive = true;
2804
- if (this.policy.flagDestructive === "log") {
2805
- console.error(`[helio] Destructive tool detected: ${toolName} (no matching rule)`);
2806
- } else {
2807
- decision = {
2808
- action: "require_approval",
2809
- matchedRule: void 0,
2810
- reason: `Destructive tool "${toolName}" auto-escalated by flag_destructive policy`
2811
- };
2812
- }
2813
- }
2814
- let driftBlocked = false;
2815
- if (driftEvent && driftMode !== "log") {
2816
- driftBlocked = driftMode === "block";
2817
- decision = {
2818
- action: driftMode === "block" ? "deny" : "require_approval",
2819
- matchedRule: void 0,
2820
- reason: `Tool "${toolName}" definition drifted from baseline (${driftEvent.changes.map((change) => change.aspect).join(", ")})`
2821
- };
2822
- }
2823
- const originalAction = decision.action;
2824
- let evidenceResult;
2825
- let dependencyResult;
2826
- let evidenceBlocked = false;
2827
- let sessionBlocked = false;
2828
- const requiresGroundedSession = decision.action !== "deny" && !!decision.matchedRule && ((decision.matchedRule.evidence?.requires.length ?? 0) > 0 || (decision.matchedRule.requires?.length ?? 0) > 0);
2829
- if (requiresGroundedSession && !request.sessionId) {
2830
- sessionBlocked = true;
2831
- evidenceBlocked = true;
2832
- decision = {
2833
- action: "deny",
2834
- matchedRule: decision.matchedRule,
2835
- reason: "Mcp-Session-Id is required for evidence/dependency-gated policy rules"
2836
- };
2837
- }
2838
- if (decision.action !== "deny" && this.evidenceStore && request.sessionId && decision.matchedRule) {
2839
- const rule = decision.matchedRule;
2840
- if (rule.evidence?.requires.length) {
2841
- evidenceResult = checkEvidence(
2842
- this.evidenceStore,
2843
- request.sessionId,
2844
- rule.evidence.requires
2845
- );
2846
- if (!evidenceResult.satisfied) {
2847
- evidenceBlocked = true;
2848
- const problemKeys = [...evidenceResult.missing, ...evidenceResult.expired];
2849
- decision = {
2850
- action: "deny",
2851
- matchedRule: rule,
2852
- reason: `Required evidence not satisfied: ${problemKeys.join(", ")}`
2853
- };
2854
- }
2855
- }
2856
- if (!evidenceBlocked && rule.requires?.length) {
2857
- dependencyResult = checkDependencies(this.evidenceStore, request.sessionId, rule.requires, {
2858
- requireSuccess: rule.requiresSuccess ?? true
2859
- });
2860
- if (!dependencyResult.satisfied) {
2861
- evidenceBlocked = true;
2862
- decision = {
2863
- action: "deny",
2864
- matchedRule: rule,
2865
- reason: `Required tool calls not completed: ${dependencyResult.missing.join(", ")}`
2866
- };
2867
- }
2868
- }
2869
- }
2870
- const isPerRuleDryRun = originalAction === "dry_run";
2871
- const isGlobalDryRun = this.policy.dryRun === true;
2872
- const isDryRun = (isPerRuleDryRun || isGlobalDryRun) && !sessionBlocked;
2873
3141
  let result;
2874
3142
  let approvalOutcome;
2875
3143
  let approvalWaitMs = 0;
@@ -3166,6 +3434,14 @@ var GovernedForwarder = class {
3166
3434
  );
3167
3435
  }
3168
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}`;
3169
3445
  case "tool":
3170
3446
  default:
3171
3447
  return `tool:${toolName}`;
@@ -3267,7 +3543,10 @@ var GovernedForwarder = class {
3267
3543
  approval_wait_ms: approvalWaitMs,
3268
3544
  proxy_compute_ms: proxyComputeMs,
3269
3545
  flagged_destructive: flaggedDestructive,
3270
- dry_run: isDryRun ?? false
3546
+ dry_run: isDryRun ?? false,
3547
+ record_kind: "tool_call",
3548
+ origin: "mcp",
3549
+ metadata: null
3271
3550
  };
3272
3551
  const isEnforcementDecision = !isDryRun && (!wasForwarded || approvalOutcome !== void 0);
3273
3552
  if (isEnforcementDecision) {
@@ -3373,17 +3652,6 @@ function collectAllowedEvidenceKeys(policy) {
3373
3652
  }
3374
3653
  return [...keys];
3375
3654
  }
3376
- var ACTION_SEVERITY = {
3377
- deny: 5,
3378
- require_approval: 4,
3379
- dry_run: 3,
3380
- spend_limit: 2,
3381
- rate_limit: 1,
3382
- allow: 0
3383
- };
3384
- function stricterDecision(a, b) {
3385
- return ACTION_SEVERITY[b.action] > ACTION_SEVERITY[a.action] ? b : a;
3386
- }
3387
3655
  function makeErrorResult(request, code, message, data) {
3388
3656
  const body = {
3389
3657
  jsonrpc: "2.0",
@@ -3516,6 +3784,46 @@ var RateLimiter = class {
3516
3784
  resetAtMs
3517
3785
  };
3518
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
+ }
3519
3827
  /**
3520
3828
  * Check the rate limit without recording the call (non-destructive).
3521
3829
  *
@@ -3730,6 +4038,57 @@ var SpendLimiter = class {
3730
4038
  resetAtMs
3731
4039
  };
3732
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
+ }
3733
4092
  /**
3734
4093
  * Check the spend limit without recording the spend (non-destructive).
3735
4094
  *
@@ -4175,8 +4534,9 @@ var EvidenceStore = class _EvidenceStore {
4175
4534
  };
4176
4535
 
4177
4536
  // src/evidence/api.ts
4178
- import { Hono as Hono4 } from "hono";
4179
- 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";
4180
4540
 
4181
4541
  // src/auth/bearer.ts
4182
4542
  import { createHash, timingSafeEqual } from "crypto";
@@ -4188,22 +4548,176 @@ function verifyBearer(authHeader, expected) {
4188
4548
  return timingSafeEqual(actualDigest, expectedDigest);
4189
4549
  }
4190
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
+
4191
4703
  // src/evidence/api.ts
4192
- var postEvidenceBody = z4.object({
4193
- session_id: z4.string().min(1),
4194
- tool_name: z4.string().min(1),
4195
- evidence_key: z4.string().min(1),
4196
- evidence_data: z4.unknown().refine((v) => v !== void 0, { message: "Required" }),
4197
- 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()
4198
4711
  });
4199
- var postContextBody = z4.object({
4200
- session_id: z4.string().min(1),
4201
- key: z4.string().min(1),
4202
- 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" })
4203
4716
  });
4204
4717
  function createSidebandApp(store, options = {}) {
4205
- const app = new Hono4();
4206
- 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;
4207
4721
  app.use("*", async (c, next) => {
4208
4722
  const origin = c.req.header("origin");
4209
4723
  if (origin) {
@@ -4214,20 +4728,26 @@ function createSidebandApp(store, options = {}) {
4214
4728
  }
4215
4729
  await next();
4216
4730
  });
4217
- if (token) {
4218
- app.use("*", async (c, next) => {
4219
- if (c.req.path === "/healthz") {
4220
- await next();
4221
- return;
4222
- }
4223
- const authHeader = c.req.header("authorization");
4224
- if (!verifyBearer(authHeader, token)) {
4225
- return c.json({ error: "Unauthorized" }, 401);
4226
- }
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") {
4227
4740
  await next();
4228
- });
4229
- }
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
+ });
4230
4749
  app.get("/healthz", (c) => c.json({ status: "ok" }));
4750
+ app.route("/", createGovernanceApp(options.governance));
4231
4751
  app.post("/evidence", async (c) => {
4232
4752
  let body;
4233
4753
  try {
@@ -4290,9 +4810,805 @@ function createSidebandApp(store, options = {}) {
4290
4810
  return app;
4291
4811
  }
4292
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
+
4293
5609
  // src/audit/store.ts
4294
5610
  import Database from "better-sqlite3";
4295
- import { randomUUID as randomUUID2 } from "crypto";
5611
+ import { randomUUID as randomUUID3 } from "crypto";
4296
5612
  import { chmodSync } from "fs";
4297
5613
 
4298
5614
  // src/upstream/response-summary.ts
@@ -4381,6 +5697,9 @@ CREATE TABLE IF NOT EXISTS audit_records (
4381
5697
  proxy_compute_ms REAL NOT NULL,
4382
5698
  flagged_destructive INTEGER NOT NULL DEFAULT 0,
4383
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,
4384
5703
  created_at TEXT NOT NULL
4385
5704
  );
4386
5705
  `;
@@ -4391,6 +5710,8 @@ CREATE INDEX IF NOT EXISTS idx_audit_policy_decision ON audit_records (policy_d
4391
5710
  CREATE INDEX IF NOT EXISTS idx_audit_session_id ON audit_records (session_id);
4392
5711
  CREATE INDEX IF NOT EXISTS idx_audit_block_reason ON audit_records (block_reason);
4393
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);
4394
5715
  `;
4395
5716
  var INSERT_SQL = `
4396
5717
  INSERT INTO audit_records (
@@ -4399,14 +5720,14 @@ INSERT INTO audit_records (
4399
5720
  approved_by, upstream_response, upstream_error, upstream_latency_ms,
4400
5721
  upstream_http_status,
4401
5722
  total_duration_ms, approval_wait_ms, proxy_compute_ms,
4402
- flagged_destructive, dry_run, created_at
5723
+ flagged_destructive, dry_run, record_kind, origin, metadata, created_at
4403
5724
  ) VALUES (
4404
5725
  @id, @timestamp, @session_id, @agent_id, @environment, @tool_name, @tool_input,
4405
5726
  @policy_decision, @block_reason, @matched_rule, @matched_rule_index, @evidence_chain, @approval_status,
4406
5727
  @approved_by, @upstream_response, @upstream_error, @upstream_latency_ms,
4407
5728
  @upstream_http_status,
4408
5729
  @total_duration_ms, @approval_wait_ms, @proxy_compute_ms,
4409
- @flagged_destructive, @dry_run, @created_at
5730
+ @flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @created_at
4410
5731
  )
4411
5732
  `;
4412
5733
  var REQUIRED_AUDIT_COLUMNS = [
@@ -4416,7 +5737,10 @@ var REQUIRED_AUDIT_COLUMNS = [
4416
5737
  "total_duration_ms",
4417
5738
  "approval_wait_ms",
4418
5739
  "proxy_compute_ms",
4419
- "upstream_http_status"
5740
+ "upstream_http_status",
5741
+ "record_kind",
5742
+ "origin",
5743
+ "metadata"
4420
5744
  ];
4421
5745
  function deserializeRow(row) {
4422
5746
  return {
@@ -4443,6 +5767,9 @@ function deserializeRow(row) {
4443
5767
  proxy_compute_ms: row.proxy_compute_ms,
4444
5768
  flagged_destructive: row.flagged_destructive === 1,
4445
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,
4446
5773
  created_at: row.created_at
4447
5774
  };
4448
5775
  }
@@ -4464,6 +5791,22 @@ function buildWhereClause(filters) {
4464
5791
  if (filters.blocked !== void 0) {
4465
5792
  conditions.push(filters.blocked ? "block_reason IS NOT NULL" : "block_reason IS NULL");
4466
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
+ }
4467
5810
  if (filters.session_id !== void 0) {
4468
5811
  conditions.push("session_id = ?");
4469
5812
  params.push(filters.session_id);
@@ -4564,7 +5907,7 @@ var AuditStore = class {
4564
5907
  * @param id - Optional pre-generated ID (used by AuditWriter to share ID with SSE event bus).
4565
5908
  */
4566
5909
  insert(record, createdAt, id) {
4567
- const resolvedId = id ?? randomUUID2();
5910
+ const resolvedId = id ?? randomUUID3();
4568
5911
  const now = createdAt ?? (/* @__PURE__ */ new Date()).toISOString();
4569
5912
  this.insertStmt.run({
4570
5913
  id: resolvedId,
@@ -4590,6 +5933,9 @@ var AuditStore = class {
4590
5933
  proxy_compute_ms: record.proxy_compute_ms,
4591
5934
  flagged_destructive: record.flagged_destructive ? 1 : 0,
4592
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,
4593
5939
  created_at: now
4594
5940
  });
4595
5941
  return resolvedId;
@@ -4736,7 +6082,7 @@ var AuditStore = class {
4736
6082
  };
4737
6083
 
4738
6084
  // src/audit/writer.ts
4739
- import { randomUUID as randomUUID3 } from "crypto";
6085
+ import { randomUUID as randomUUID4 } from "crypto";
4740
6086
  var AuditWriter = class {
4741
6087
  store;
4742
6088
  bufferSize;
@@ -4771,9 +6117,8 @@ var AuditWriter = class {
4771
6117
  * is scheduled. This keeps request-path latency bounded even under bursty
4772
6118
  * write load.
4773
6119
  */
4774
- push(record) {
6120
+ push(record, id = randomUUID4()) {
4775
6121
  if (this.closed) return;
4776
- const id = randomUUID3();
4777
6122
  this.buffer.push({ id, record });
4778
6123
  this.onPush?.(record, id);
4779
6124
  if (this.buffer.length >= this.bufferSize) {
@@ -4788,9 +6133,8 @@ var AuditWriter = class {
4788
6133
  * A fatal-process crash still invokes the crash-drain hook, which calls
4789
6134
  * `flush()` synchronously before exit.
4790
6135
  */
4791
- pushImmediate(record) {
6136
+ pushImmediate(record, id = randomUUID4()) {
4792
6137
  if (this.closed) return;
4793
- const id = randomUUID3();
4794
6138
  this.buffer.push({ id, record });
4795
6139
  this.onPush?.(record, id);
4796
6140
  this.scheduleFlushSoon();
@@ -4846,7 +6190,7 @@ var AuditWriter = class {
4846
6190
  };
4847
6191
 
4848
6192
  // src/approval/queue.ts
4849
- import { randomUUID as randomUUID4 } from "crypto";
6193
+ import { randomUUID as randomUUID5 } from "crypto";
4850
6194
  var ApprovalQueue = class {
4851
6195
  tickets = /* @__PURE__ */ new Map();
4852
6196
  now;
@@ -4876,7 +6220,7 @@ var ApprovalQueue = class {
4876
6220
  if (this.closed) throw new Error("ApprovalQueue is closed");
4877
6221
  const now = this.now();
4878
6222
  const ticket = {
4879
- id: randomUUID4(),
6223
+ id: randomUUID5(),
4880
6224
  tool_name: params.tool_name,
4881
6225
  tool_input: params.tool_input,
4882
6226
  matched_rule: params.matched_rule,
@@ -4955,6 +6299,7 @@ var ApprovalQueue = class {
4955
6299
  };
4956
6300
 
4957
6301
  // src/approval/router.ts
6302
+ var NATIVE_CHANNEL_PREFIX = "native:";
4958
6303
  var ApprovalRouter = class {
4959
6304
  defaultTimeoutMs;
4960
6305
  defaultOnTimeout;
@@ -5067,6 +6412,59 @@ var ApprovalRouter = class {
5067
6412
  });
5068
6413
  return outcome;
5069
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
+ }
5070
6468
  /**
5071
6469
  * Approve a pending ticket. Resolves the held Promise so the governed
5072
6470
  * forwarder can forward the request upstream.
@@ -5111,6 +6509,10 @@ var ApprovalRouter = class {
5111
6509
  ticketId
5112
6510
  });
5113
6511
  }
6512
+ /** Look up a ticket by id (delegates to the queue). */
6513
+ getTicket(ticketId) {
6514
+ return this.queue.get(ticketId);
6515
+ }
5114
6516
  /** Clean up all pending timers and resolve all pending promises. */
5115
6517
  close() {
5116
6518
  this.closed = true;
@@ -5373,8 +6775,8 @@ function createChannels(channels) {
5373
6775
 
5374
6776
  // src/approval/slack-actions.ts
5375
6777
  import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
5376
- import { Hono as Hono5 } from "hono";
5377
- import { z as z5 } from "zod";
6778
+ import { Hono as Hono6 } from "hono";
6779
+ import { z as z6 } from "zod";
5378
6780
  var MAX_TIMESTAMP_AGE_S = 300;
5379
6781
  var REJECTION_LOG_WINDOW_MS = 6e4;
5380
6782
  var REJECTION_LOG_SAMPLE_EVERY = 25;
@@ -5440,12 +6842,12 @@ function verifySlackSignature(secrets, timestamp, rawBody, signature) {
5440
6842
  }
5441
6843
  return false;
5442
6844
  }
5443
- var slackActionPayloadSchema = z5.object({
5444
- type: z5.string(),
5445
- user: z5.object({ id: z5.string(), username: z5.string() }),
5446
- actions: z5.array(z5.object({ action_id: z5.string() })),
5447
- channel: z5.object({ id: z5.string() }),
5448
- 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() })
5449
6851
  });
5450
6852
  function parseActionPayload(rawBody) {
5451
6853
  try {
@@ -5475,7 +6877,7 @@ Ticket \`${ticketId}\``
5475
6877
  }
5476
6878
  function createSlackActionApp(options) {
5477
6879
  const { router, channels } = options;
5478
- const app = new Hono5();
6880
+ const app = new Hono6();
5479
6881
  const rejectionLogBuckets = /* @__PURE__ */ new Map();
5480
6882
  const rejectUnauthorized = (c, reason, context) => {
5481
6883
  logRejectedSlackCallback(rejectionLogBuckets, {
@@ -5574,18 +6976,18 @@ function createSlackActionApp(options) {
5574
6976
  }
5575
6977
 
5576
6978
  // src/approval/api.ts
5577
- import { Hono as Hono6 } from "hono";
5578
- import { z as z6 } from "zod";
5579
- var approveBody = z6.object({
5580
- 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)
5581
6983
  });
5582
- var denyBody = z6.object({
5583
- denied_by: z6.string().min(1),
5584
- reason: z6.string().optional()
6984
+ var denyBody = z7.object({
6985
+ denied_by: z7.string().min(1),
6986
+ reason: z7.string().optional()
5585
6987
  });
5586
- var breakGlassBody = z6.object({
5587
- approved_by: z6.string().min(1),
5588
- reason: z6.string().min(1)
6988
+ var breakGlassBody = z7.object({
6989
+ approved_by: z7.string().min(1),
6990
+ reason: z7.string().min(1)
5589
6991
  });
5590
6992
  var APPROVAL_STATUSES = [
5591
6993
  "pending",
@@ -5594,25 +6996,26 @@ var APPROVAL_STATUSES = [
5594
6996
  "timeout",
5595
6997
  "break_glass",
5596
6998
  "client_disconnected",
5597
- "shutdown_cancelled"
6999
+ "shutdown_cancelled",
7000
+ "cancelled"
5598
7001
  ];
5599
7002
  var approvalStatusSet = new Set(APPROVAL_STATUSES);
5600
- var listApprovalsQuery = z6.object({
5601
- status: z6.preprocess(
7003
+ var listApprovalsQuery = z7.object({
7004
+ status: z7.preprocess(
5602
7005
  (value) => typeof value === "string" && approvalStatusSet.has(value) ? value : void 0,
5603
- z6.enum(APPROVAL_STATUSES).optional()
7006
+ z7.enum(APPROVAL_STATUSES).optional()
5604
7007
  ),
5605
- limit: z6.preprocess(
7008
+ limit: z7.preprocess(
5606
7009
  (value) => clampInt(typeof value === "string" ? value : void 0, 50, 1, 1e3),
5607
- z6.number().int()
7010
+ z7.number().int()
5608
7011
  ),
5609
- offset: z6.preprocess(
7012
+ offset: z7.preprocess(
5610
7013
  (value) => clampInt(typeof value === "string" ? value : void 0, 0, 0, Number.MAX_SAFE_INTEGER),
5611
- z6.number().int()
7014
+ z7.number().int()
5612
7015
  )
5613
7016
  });
5614
7017
  function createApprovalApp(router, queue, options) {
5615
- const app = new Hono6();
7018
+ const app = new Hono7();
5616
7019
  const apiSecret = options?.apiSecret;
5617
7020
  if (apiSecret) {
5618
7021
  app.use("*", async (c, next) => {
@@ -5658,6 +7061,15 @@ function createApprovalApp(router, queue, options) {
5658
7061
  if (!ticket) {
5659
7062
  return c.json({ error: "Ticket not found" }, 404);
5660
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
+ }
5661
7073
  if (ticket.status !== "pending") {
5662
7074
  return c.json({ error: "Ticket already resolved", status: ticket.status }, 409);
5663
7075
  }
@@ -5683,6 +7095,15 @@ function createApprovalApp(router, queue, options) {
5683
7095
  if (!ticket) {
5684
7096
  return c.json({ error: "Ticket not found" }, 404);
5685
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
+ }
5686
7107
  if (ticket.status !== "pending") {
5687
7108
  return c.json({ error: "Ticket already resolved", status: ticket.status }, 409);
5688
7109
  }
@@ -5708,6 +7129,15 @@ function createApprovalApp(router, queue, options) {
5708
7129
  if (!ticket) {
5709
7130
  return c.json({ error: "Ticket not found" }, 404);
5710
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
+ }
5711
7141
  if (ticket.status !== "pending") {
5712
7142
  return c.json({ error: "Ticket already resolved", status: ticket.status }, 409);
5713
7143
  }
@@ -5723,9 +7153,9 @@ function createApprovalApp(router, queue, options) {
5723
7153
  // src/dashboard/api.ts
5724
7154
  import { readFileSync } from "fs";
5725
7155
  import { join } from "path";
5726
- import { randomUUID as randomUUID5 } from "crypto";
5727
- import { Hono as Hono7 } from "hono";
5728
- 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";
5729
7159
  import { cors } from "hono/cors";
5730
7160
  import { serveStatic } from "@hono/node-server/serve-static";
5731
7161
  import { streamSSE } from "hono/streaming";
@@ -5786,7 +7216,7 @@ function recordsToCsv(records) {
5786
7216
  }
5787
7217
 
5788
7218
  // src/dashboard/session.ts
5789
- 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";
5790
7220
  var DashboardSessionStore = class {
5791
7221
  secret;
5792
7222
  ttlMs;
@@ -5867,8 +7297,8 @@ var DashboardSessionStore = class {
5867
7297
  const id = token.slice(0, dot);
5868
7298
  const signature = token.slice(dot + 1);
5869
7299
  const expected = this.sign(id);
5870
- const actualDigest = createHash2("sha256").update(signature).digest();
5871
- const expectedDigest = createHash2("sha256").update(expected).digest();
7300
+ const actualDigest = createHash3("sha256").update(signature).digest();
7301
+ const expectedDigest = createHash3("sha256").update(expected).digest();
5872
7302
  if (!timingSafeEqual3(actualDigest, expectedDigest)) return void 0;
5873
7303
  return id;
5874
7304
  }
@@ -5878,29 +7308,29 @@ var DashboardSessionStore = class {
5878
7308
  };
5879
7309
 
5880
7310
  // src/dashboard/api.ts
5881
- var optionalQueryString = z7.preprocess(
7311
+ var optionalQueryString = z8.preprocess(
5882
7312
  (value) => typeof value === "string" && value.length > 0 ? value : void 0,
5883
- z7.string().optional()
7313
+ z8.string().optional()
5884
7314
  );
5885
- var optionalQueryInt = z7.preprocess((value) => {
7315
+ var optionalQueryInt = z8.preprocess((value) => {
5886
7316
  if (typeof value !== "string" || value.length === 0) return void 0;
5887
7317
  const parsed = Number.parseInt(value, 10);
5888
7318
  return Number.isFinite(parsed) ? parsed : void 0;
5889
- }, z7.number().int().optional());
5890
- var queryBoolean = z7.preprocess(
7319
+ }, z8.number().int().optional());
7320
+ var queryBoolean = z8.preprocess(
5891
7321
  (value) => value === "true" ? true : value === "false" ? false : void 0,
5892
- z7.boolean().optional()
7322
+ z8.boolean().optional()
5893
7323
  );
5894
- var clampedQueryInt = (fallback, min, max) => z7.preprocess(
7324
+ var clampedQueryInt = (fallback, min, max) => z8.preprocess(
5895
7325
  (value) => clampInt(typeof value === "string" ? value : void 0, fallback, min, max),
5896
- z7.number().int()
7326
+ z8.number().int()
5897
7327
  );
5898
- var feedQuerySchema = z7.object({
7328
+ var feedQuerySchema = z8.object({
5899
7329
  limit: clampedQueryInt(50, 1, 200),
5900
7330
  offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER)
5901
7331
  });
5902
- var auditExportQuerySchema = z7.object({
5903
- 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"])),
5904
7334
  limit: clampedQueryInt(1e4, 1, 1e4),
5905
7335
  tool: optionalQueryString,
5906
7336
  decision: optionalQueryString,
@@ -5912,9 +7342,13 @@ var auditExportQuerySchema = z7.object({
5912
7342
  from: optionalQueryString,
5913
7343
  to: optionalQueryString,
5914
7344
  upstream_status_min: optionalQueryInt,
5915
- upstream_status_max: optionalQueryInt
7345
+ upstream_status_max: optionalQueryInt,
7346
+ origin: optionalQueryString,
7347
+ record_kind: optionalQueryString,
7348
+ channel_id: optionalQueryString,
7349
+ sender_id: optionalQueryString
5916
7350
  });
5917
- var auditQuerySchema = z7.object({
7351
+ var auditQuerySchema = z8.object({
5918
7352
  limit: clampedQueryInt(50, 1, 1e3),
5919
7353
  offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER),
5920
7354
  tool: optionalQueryString,
@@ -5928,14 +7362,18 @@ var auditQuerySchema = z7.object({
5928
7362
  destructive: queryBoolean,
5929
7363
  dry_run: queryBoolean,
5930
7364
  upstream_status_min: optionalQueryInt,
5931
- upstream_status_max: optionalQueryInt
7365
+ upstream_status_max: optionalQueryInt,
7366
+ origin: optionalQueryString,
7367
+ record_kind: optionalQueryString,
7368
+ channel_id: optionalQueryString,
7369
+ sender_id: optionalQueryString
5932
7370
  });
5933
- var analyticsQuerySchema = z7.object({
7371
+ var analyticsQuerySchema = z8.object({
5934
7372
  from: optionalQueryString,
5935
7373
  to: optionalQueryString
5936
7374
  });
5937
- var authSessionBodySchema = z7.object({
5938
- secret: z7.string()
7375
+ var authSessionBodySchema = z8.object({
7376
+ secret: z8.string()
5939
7377
  });
5940
7378
  var SESSION_COOKIE = "helio_session";
5941
7379
  var SESSION_TTL_MS = 8 * 60 * 60 * 1e3;
@@ -5995,7 +7433,7 @@ function createDashboardAppWithLifecycle(deps, options) {
5995
7433
  } = deps;
5996
7434
  const apiSecret = options?.apiSecret;
5997
7435
  const sessionStore = apiSecret ? new DashboardSessionStore({ secret: apiSecret, ttlMs: SESSION_TTL_MS }) : void 0;
5998
- const app = new Hono7();
7436
+ const app = new Hono8();
5999
7437
  app.use(
6000
7438
  "*",
6001
7439
  cors({
@@ -6135,7 +7573,11 @@ function createDashboardAppWithLifecycle(deps, options) {
6135
7573
  from: query.from,
6136
7574
  to: query.to,
6137
7575
  upstream_status_min: query.upstream_status_min,
6138
- 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
6139
7581
  };
6140
7582
  const result = auditStore.list(filters, { limit, order: "asc" });
6141
7583
  if (format === "csv") {
@@ -6177,7 +7619,11 @@ function createDashboardAppWithLifecycle(deps, options) {
6177
7619
  flagged_destructive: query.destructive,
6178
7620
  dry_run: query.dry_run,
6179
7621
  upstream_status_min: query.upstream_status_min,
6180
- 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
6181
7627
  };
6182
7628
  const result = auditStore.list(filters, { limit, offset, order: "desc" });
6183
7629
  return c.json({
@@ -6238,7 +7684,7 @@ function createDashboardAppWithLifecycle(deps, options) {
6238
7684
  app.get("/api/events", (c) => {
6239
7685
  return streamSSE(c, async (stream) => {
6240
7686
  if (closed) return;
6241
- const connId = randomUUID5();
7687
+ const connId = randomUUID6();
6242
7688
  let streamClosed = false;
6243
7689
  let stopHeartbeat = () => {
6244
7690
  };
@@ -6267,7 +7713,7 @@ function createDashboardAppWithLifecycle(deps, options) {
6267
7713
  void stream.writeSSE({
6268
7714
  event: eventType,
6269
7715
  data: JSON.stringify(data),
6270
- id: randomUUID5()
7716
+ id: randomUUID6()
6271
7717
  }).then(() => {
6272
7718
  const conn = activeConnections.get(connId);
6273
7719
  if (conn) conn.lastWrite = Date.now();
@@ -6368,6 +7814,8 @@ export {
6368
7814
  ConfigError,
6369
7815
  DashboardEventBus,
6370
7816
  EvidenceStore,
7817
+ GovernanceConfigError,
7818
+ GovernanceService,
6371
7819
  GovernedForwarder,
6372
7820
  PolicyParseError,
6373
7821
  QueueChannel,