@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/cli.js CHANGED
@@ -115,11 +115,23 @@ var annotationsMatchSchema = z.object({
115
115
  idempotentHint: z.boolean().optional(),
116
116
  openWorldHint: z.boolean().optional()
117
117
  }).strict();
118
+ var metadataConditionSchema = z.union([
119
+ z.string(),
120
+ z.object({
121
+ eq: z.string().optional(),
122
+ neq: z.string().optional(),
123
+ contains: z.string().optional(),
124
+ regex: z.string().optional()
125
+ }).strict().refine((obj) => Object.keys(obj).length > 0, {
126
+ message: "At least one metadata condition operator is required"
127
+ })
128
+ ]);
118
129
  var matchSchema = z.object({
119
130
  tool: z.string().optional(),
120
131
  annotations: annotationsMatchSchema.optional(),
121
132
  input: z.record(z.string(), inputConditionSchema).optional(),
122
- environment: z.string().optional()
133
+ environment: z.string().optional(),
134
+ metadata: z.record(z.string(), metadataConditionSchema).optional()
123
135
  }).strict();
124
136
  var policyActionSchema = z.enum([
125
137
  "allow",
@@ -143,12 +155,12 @@ var spendLimitSchema = z.object({
143
155
  limit: z.number(),
144
156
  currency: z.string(),
145
157
  window: durationSchema,
146
- key: z.enum(["tool", "agent", "session"]).optional()
158
+ key: z.enum(["tool", "agent", "session", "sender_id"]).optional()
147
159
  }).strict();
148
160
  var limitsSchema = z.object({
149
161
  max_calls: z.number().int().positive().optional(),
150
162
  window: durationSchema.optional(),
151
- key: z.enum(["tool", "agent", "session"]).optional(),
163
+ key: z.enum(["tool", "agent", "session", "sender_id"]).optional(),
152
164
  max_spend: spendLimitSchema.optional()
153
165
  }).strict();
154
166
  var feedbackSchema = z.object({
@@ -166,11 +178,30 @@ var policyRuleSchema = z.object({
166
178
  limits: limitsSchema.optional(),
167
179
  feedback: feedbackSchema.optional()
168
180
  }).strict();
181
+ var installMatchSchema = z.object({
182
+ name: z.string().optional(),
183
+ // glob, picomatch (same engine as match.tool)
184
+ source: z.string().optional(),
185
+ // exact ecosystem match (npm | pip | …)
186
+ metadata: z.record(z.string(), metadataConditionSchema).optional()
187
+ }).strict();
188
+ var installRuleSchema = z.object({
189
+ name: z.string().optional(),
190
+ match: installMatchSchema,
191
+ action: z.enum(["deny_install", "allow"]),
192
+ feedback: feedbackSchema.optional()
193
+ }).strict();
194
+ var installSchema = z.object({
195
+ default: z.enum(["allow", "deny"]).default("allow"),
196
+ rules: z.array(installRuleSchema).default([])
197
+ }).strict();
169
198
  var policiesSchema = z.object({
170
199
  default: z.enum(["allow", "deny"]).default("allow"),
171
200
  flag_destructive: z.enum(["log", "require_approval"]).optional(),
172
201
  dry_run: z.boolean().default(false),
173
202
  rules: z.array(policyRuleSchema).default([]),
203
+ /** Install-time policy (issue #13 — deny_install). Optional; absent ⇒ observational. */
204
+ install: installSchema.optional(),
174
205
  /**
175
206
  * How to treat calls to a tool whose definition (annotations, schemas,
176
207
  * description) has drifted from the baseline Helio captured on first
@@ -231,7 +262,14 @@ var auditSchema = z.object({
231
262
  var sdkSchema = z.object({
232
263
  enabled: z.boolean().default(false),
233
264
  port: z.number().int().min(1).max(65535).default(3200),
234
- host: z.string().default("127.0.0.1")
265
+ host: z.string().default("127.0.0.1"),
266
+ /**
267
+ * How long a sideband `/evaluate` decision waits for its `/audit` before the
268
+ * proxy finalizes it as `evaluation_expired` (issue #12, D4). Bounds the
269
+ * pending-evaluation registry; an adapter crash cannot silently drop a
270
+ * decided-allowed call from the trail.
271
+ */
272
+ evaluation_ttl: durationSchema.default("10m")
235
273
  });
236
274
  var helioConfigBaseSchema = z.object({
237
275
  version: z.literal("1"),
@@ -294,6 +332,22 @@ var helioConfigSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
294
332
  message: `Rule sets match.environment="${rule.match.environment}" but top-level \`environment\` is not configured. Set top-level environment to enable env-scoped rules.`
295
333
  });
296
334
  }
335
+ if (!cfg.sdk.enabled) {
336
+ if (rule.limits?.key === "sender_id") {
337
+ ctx.addIssue({
338
+ code: "custom",
339
+ path: ["policies", "rules", ruleIndex, "limits", "key"],
340
+ message: 'limits.key "sender_id" requires the SDK sideband (sdk.enabled: true) \u2014 sender_id is supplied by hook adapters and is absent on the MCP path.'
341
+ });
342
+ }
343
+ if (rule.limits?.max_spend?.key === "sender_id") {
344
+ ctx.addIssue({
345
+ code: "custom",
346
+ path: ["policies", "rules", ruleIndex, "limits", "max_spend", "key"],
347
+ message: 'limits.max_spend.key "sender_id" requires the SDK sideband (sdk.enabled: true) \u2014 sender_id is supplied by hook adapters and is absent on the MCP path.'
348
+ });
349
+ }
350
+ }
297
351
  if (rule.action === "rate_limit") {
298
352
  if (rule.limits?.max_calls === void 0) {
299
353
  ctx.addIssue({
@@ -465,6 +519,7 @@ var PolicyParseError = class extends Error {
465
519
 
466
520
  // src/policy/parser.ts
467
521
  var INPUT_OPERATORS = ["eq", "neq", "gt", "gte", "lt", "lte", "contains", "regex"];
522
+ var METADATA_OPERATORS = ["eq", "neq", "contains", "regex"];
468
523
  function compilePolicies(config) {
469
524
  const warnings = [];
470
525
  const rules = config.rules.map((rule, index) => compileRule(rule, index, warnings));
@@ -473,10 +528,36 @@ function compilePolicies(config) {
473
528
  flagDestructive: config.flag_destructive,
474
529
  ...config.dry_run && { dryRun: true },
475
530
  ...config.on_tool_drift && { onToolDrift: config.on_tool_drift },
476
- rules
531
+ rules,
532
+ ...config.install && { install: compileInstallPolicy(config.install) }
477
533
  };
478
534
  return { policy, warnings };
479
535
  }
536
+ function compileInstallPolicy(install) {
537
+ return {
538
+ defaultAction: install.default,
539
+ rules: install.rules.map((rule, index) => {
540
+ const name = rule.match.name !== void 0 ? compileToolMatcher(rule.match.name, index, rule.name) : void 0;
541
+ const metadata = rule.match.metadata !== void 0 ? flattenMetadataConditions(rule.match.metadata, index, rule.name) : void 0;
542
+ return {
543
+ index,
544
+ ...rule.name !== void 0 && { name: rule.name },
545
+ match: {
546
+ ...name !== void 0 && { name },
547
+ ...rule.match.source !== void 0 && { source: rule.match.source },
548
+ ...metadata !== void 0 && { metadata }
549
+ },
550
+ action: rule.action,
551
+ ...rule.feedback !== void 0 && {
552
+ feedback: {
553
+ message: rule.feedback.message,
554
+ ...rule.feedback.suggestion !== void 0 && { suggestion: rule.feedback.suggestion }
555
+ }
556
+ }
557
+ };
558
+ })
559
+ };
560
+ }
480
561
  function compileRule(rule, index, warnings) {
481
562
  const match = compileMatch(rule.match, index, rule.name);
482
563
  const approval = compileApproval(rule.approval);
@@ -509,7 +590,10 @@ function compileMatch(match, ruleIndex, ruleName) {
509
590
  ...match.input !== void 0 && {
510
591
  input: flattenInputConditions(match.input, ruleIndex, ruleName)
511
592
  },
512
- ...match.environment !== void 0 && { environment: match.environment }
593
+ ...match.environment !== void 0 && { environment: match.environment },
594
+ ...match.metadata !== void 0 && {
595
+ metadata: flattenMetadataConditions(match.metadata, ruleIndex, ruleName)
596
+ }
513
597
  };
514
598
  }
515
599
  function compileToolMatcher(pattern, ruleIndex, ruleName) {
@@ -561,6 +645,43 @@ function flattenInputConditions(input, ruleIndex, ruleName) {
561
645
  }
562
646
  return conditions;
563
647
  }
648
+ function flattenMetadataConditions(metadata, ruleIndex, ruleName) {
649
+ const conditions = [];
650
+ for (const [key, raw] of Object.entries(metadata)) {
651
+ if (typeof raw === "string") {
652
+ conditions.push({ key, operator: "eq", value: raw });
653
+ continue;
654
+ }
655
+ for (const op of METADATA_OPERATORS) {
656
+ const value = raw[op];
657
+ if (value === void 0) continue;
658
+ if (op === "regex") {
659
+ if (!safeRegex(value)) {
660
+ throw new PolicyParseError(
661
+ `catastrophic regex "${value}" for metadata key "${key}": pattern is vulnerable to ReDoS and has been rejected. Rewrite with bounded quantifiers (e.g. {1,100}) or split into simpler rules.`,
662
+ ruleIndex,
663
+ ruleName
664
+ );
665
+ }
666
+ let compiledRegex;
667
+ try {
668
+ compiledRegex = new RegExp(value);
669
+ } catch (err) {
670
+ const msg = err instanceof Error ? err.message : String(err);
671
+ throw new PolicyParseError(
672
+ `invalid regex "${value}" for metadata key "${key}": ${msg}`,
673
+ ruleIndex,
674
+ ruleName
675
+ );
676
+ }
677
+ conditions.push({ key, operator: op, value, regex: compiledRegex });
678
+ } else {
679
+ conditions.push({ key, operator: op, value });
680
+ }
681
+ }
682
+ }
683
+ return conditions;
684
+ }
564
685
  function compileApproval(approval) {
565
686
  if (!approval) return void 0;
566
687
  return {
@@ -2291,12 +2412,31 @@ function matchEnvironment(required, ctx) {
2291
2412
  if (ctx.environment === void 0) return false;
2292
2413
  return ctx.environment === required;
2293
2414
  }
2415
+ function matchMetadata(conditions, ctx) {
2416
+ if (conditions.length === 0) return true;
2417
+ if (ctx.metadata === void 0) return false;
2418
+ for (const condition of conditions) {
2419
+ const value = ctx.metadata[condition.key];
2420
+ const matched = evaluateCondition(
2421
+ {
2422
+ path: condition.key,
2423
+ operator: condition.operator,
2424
+ value: condition.value,
2425
+ regex: condition.regex
2426
+ },
2427
+ value
2428
+ );
2429
+ if (!matched) return false;
2430
+ }
2431
+ return true;
2432
+ }
2294
2433
  function matchRule(rule, ctx) {
2295
2434
  const { match } = rule;
2296
2435
  if (match.tool !== void 0 && !matchTool(match.tool, ctx)) return false;
2297
2436
  if (match.annotations !== void 0 && !matchAnnotations(match.annotations, ctx)) return false;
2298
2437
  if (match.input !== void 0 && !matchInput(match.input, ctx)) return false;
2299
2438
  if (match.environment !== void 0 && !matchEnvironment(match.environment, ctx)) return false;
2439
+ if (match.metadata !== void 0 && !matchMetadata(match.metadata, ctx)) return false;
2300
2440
  return true;
2301
2441
  }
2302
2442
 
@@ -2319,6 +2459,197 @@ function evaluatePolicy(policy, ctx) {
2319
2459
  };
2320
2460
  }
2321
2461
 
2462
+ // src/evidence/grounding.ts
2463
+ function checkEvidence(store, sessionId, requirements) {
2464
+ if (requirements.length === 0) {
2465
+ return { satisfied: true, missing: [], expired: [], found: [] };
2466
+ }
2467
+ const found = [];
2468
+ const missing = [];
2469
+ const expired = [];
2470
+ for (const key of requirements) {
2471
+ const valid = store.getEvidence(sessionId, key);
2472
+ if (valid) {
2473
+ found.push(key);
2474
+ } else if (store.hasSeenEvidence(sessionId, key)) {
2475
+ expired.push(key);
2476
+ } else {
2477
+ missing.push(key);
2478
+ }
2479
+ }
2480
+ return {
2481
+ satisfied: missing.length === 0 && expired.length === 0,
2482
+ missing,
2483
+ expired,
2484
+ found
2485
+ };
2486
+ }
2487
+ function checkDependencies(store, sessionId, requirements, options = {}) {
2488
+ if (requirements.length === 0) {
2489
+ return { satisfied: true, missing: [] };
2490
+ }
2491
+ const requireSuccess = options.requireSuccess ?? true;
2492
+ const missing = [];
2493
+ for (const toolName of requirements) {
2494
+ const satisfied = requireSuccess ? store.hasSuccessfulTool(sessionId, toolName) : store.hasCompletedTool(sessionId, toolName);
2495
+ if (!satisfied) {
2496
+ missing.push(toolName);
2497
+ }
2498
+ }
2499
+ return {
2500
+ satisfied: missing.length === 0,
2501
+ missing
2502
+ };
2503
+ }
2504
+
2505
+ // src/policy/decision-pipeline.ts
2506
+ function decide(input) {
2507
+ const { toolName, toolArguments, sessionId, policy, environment, evidenceStore } = input;
2508
+ const annotations = input.baselineAnnotations;
2509
+ const driftEvent = input.driftEvent;
2510
+ const driftMode = policy.onToolDrift ?? "block";
2511
+ const metadata = buildMetadataView(input.metadata, input.agentId);
2512
+ let decision = evaluatePolicy(policy, {
2513
+ toolName,
2514
+ annotations,
2515
+ toolArguments,
2516
+ environment,
2517
+ metadata
2518
+ });
2519
+ if (driftEvent && driftMode === "log") {
2520
+ const currentDecision = evaluatePolicy(policy, {
2521
+ toolName,
2522
+ annotations: input.currentAnnotations,
2523
+ toolArguments,
2524
+ environment,
2525
+ metadata
2526
+ });
2527
+ decision = stricterDecision(decision, currentDecision);
2528
+ }
2529
+ const baselineDestructive = annotations?.destructiveHint ?? true;
2530
+ const currentDestructive = driftEvent && driftMode === "log" ? input.currentAnnotations?.destructiveHint ?? true : false;
2531
+ const isDestructive = baselineDestructive || currentDestructive;
2532
+ let flaggedDestructive = false;
2533
+ if (isDestructive && !decision.matchedRule && policy.flagDestructive) {
2534
+ flaggedDestructive = true;
2535
+ if (policy.flagDestructive === "log") {
2536
+ console.error(`[helio] Destructive tool detected: ${toolName} (no matching rule)`);
2537
+ } else {
2538
+ decision = {
2539
+ action: "require_approval",
2540
+ matchedRule: void 0,
2541
+ reason: `Destructive tool "${toolName}" auto-escalated by flag_destructive policy`
2542
+ };
2543
+ }
2544
+ }
2545
+ let driftBlocked = false;
2546
+ if (driftEvent && driftMode !== "log") {
2547
+ driftBlocked = driftMode === "block";
2548
+ decision = {
2549
+ action: driftMode === "block" ? "deny" : "require_approval",
2550
+ matchedRule: void 0,
2551
+ reason: `Tool "${toolName}" definition drifted from baseline (${driftEvent.changes.map((change) => change.aspect).join(", ")})`
2552
+ };
2553
+ }
2554
+ const originalAction = decision.action;
2555
+ let evidenceResult;
2556
+ let dependencyResult;
2557
+ let evidenceBlocked = false;
2558
+ let sessionBlocked = false;
2559
+ const requiresGroundedSession = decision.action !== "deny" && !!decision.matchedRule && ((decision.matchedRule.evidence?.requires.length ?? 0) > 0 || (decision.matchedRule.requires?.length ?? 0) > 0);
2560
+ if (requiresGroundedSession && !sessionId) {
2561
+ sessionBlocked = true;
2562
+ evidenceBlocked = true;
2563
+ decision = {
2564
+ action: "deny",
2565
+ matchedRule: decision.matchedRule,
2566
+ reason: "Mcp-Session-Id is required for evidence/dependency-gated policy rules"
2567
+ };
2568
+ }
2569
+ if (decision.action !== "deny" && evidenceStore && sessionId && decision.matchedRule) {
2570
+ const rule = decision.matchedRule;
2571
+ if (rule.evidence?.requires.length) {
2572
+ evidenceResult = checkEvidence(evidenceStore, sessionId, rule.evidence.requires);
2573
+ if (!evidenceResult.satisfied) {
2574
+ evidenceBlocked = true;
2575
+ const problemKeys = [...evidenceResult.missing, ...evidenceResult.expired];
2576
+ decision = {
2577
+ action: "deny",
2578
+ matchedRule: rule,
2579
+ reason: `Required evidence not satisfied: ${problemKeys.join(", ")}`
2580
+ };
2581
+ }
2582
+ }
2583
+ if (!evidenceBlocked && rule.requires?.length) {
2584
+ dependencyResult = checkDependencies(evidenceStore, sessionId, rule.requires, {
2585
+ requireSuccess: rule.requiresSuccess ?? true
2586
+ });
2587
+ if (!dependencyResult.satisfied) {
2588
+ evidenceBlocked = true;
2589
+ decision = {
2590
+ action: "deny",
2591
+ matchedRule: rule,
2592
+ reason: `Required tool calls not completed: ${dependencyResult.missing.join(", ")}`
2593
+ };
2594
+ }
2595
+ }
2596
+ }
2597
+ const isPerRuleDryRun = originalAction === "dry_run";
2598
+ const isGlobalDryRun = policy.dryRun === true;
2599
+ const isDryRun = (isPerRuleDryRun || isGlobalDryRun) && !sessionBlocked;
2600
+ return {
2601
+ decision,
2602
+ originalAction,
2603
+ driftEvent,
2604
+ driftMode,
2605
+ driftBlocked,
2606
+ flaggedDestructive,
2607
+ evidenceResult,
2608
+ dependencyResult,
2609
+ evidenceBlocked,
2610
+ sessionBlocked,
2611
+ isDryRun
2612
+ };
2613
+ }
2614
+ var ACTION_SEVERITY = {
2615
+ deny: 5,
2616
+ require_approval: 4,
2617
+ dry_run: 3,
2618
+ spend_limit: 2,
2619
+ rate_limit: 1,
2620
+ allow: 0
2621
+ };
2622
+ function stricterDecision(a, b) {
2623
+ return ACTION_SEVERITY[b.action] > ACTION_SEVERITY[a.action] ? b : a;
2624
+ }
2625
+ function buildMetadataView(metadata, agentId) {
2626
+ if (agentId === void 0) return metadata;
2627
+ return { ...metadata ?? {}, agent_id: agentId };
2628
+ }
2629
+
2630
+ // src/util/canonical-json.ts
2631
+ function canonicalize(value) {
2632
+ const encoded = JSON.stringify(sortKeysDeep(value));
2633
+ return encoded ?? "";
2634
+ }
2635
+ function sortKeysDeep(value) {
2636
+ if (Array.isArray(value)) return value.map(sortKeysDeep);
2637
+ if (value !== null && typeof value === "object") {
2638
+ const source = value;
2639
+ const out = {};
2640
+ for (const key of Object.keys(source).sort()) {
2641
+ Object.defineProperty(out, key, {
2642
+ value: sortKeysDeep(source[key]),
2643
+ enumerable: true,
2644
+ writable: true,
2645
+ configurable: true
2646
+ });
2647
+ }
2648
+ return out;
2649
+ }
2650
+ return value;
2651
+ }
2652
+
2322
2653
  // src/policy/annotation-cache.ts
2323
2654
  var ASPECT_FIELDS = [
2324
2655
  "annotations",
@@ -2421,6 +2752,74 @@ var ToolAnnotationCache = class {
2421
2752
  this.currentAnnotations = currentAnnotations;
2422
2753
  return { updated: true, baselined, drifted, reverted };
2423
2754
  }
2755
+ /**
2756
+ * Incrementally merge a single tool definition into the cache (issue #12, D6).
2757
+ *
2758
+ * Unlike {@link update}, this touches only the named tool: it adds to (never
2759
+ * rebuilds) the `present` set and `currentAnnotations` map. The sideband
2760
+ * governance path feeds adapter-origin tools one definition at a time (each
2761
+ * `/evaluate` carries at most one), so routing them through the whole-list
2762
+ * `update()` would wipe every other tool's current-annotation snapshot on
2763
+ * each call and silently degrade the stricter-of-both log-mode drift
2764
+ * evaluation. The MCP whole-list path is unaffected — it keeps calling
2765
+ * `update()`. Each origin owns its own cache instance, so the accumulate
2766
+ * semantics here never mix with update()'s replace semantics.
2767
+ *
2768
+ * `toolDefinition` must already be in MCP shape (`inputSchema`/`outputSchema`
2769
+ * camelCase); the governance service maps the wire `tool` object before
2770
+ * calling. Returns the same result shape as `update()` (for one tool).
2771
+ */
2772
+ updateSingle(toolDefinition) {
2773
+ if (typeof toolDefinition !== "object" || toolDefinition === null) {
2774
+ return { updated: false, baselined: [], drifted: [], reverted: [] };
2775
+ }
2776
+ const t = toolDefinition;
2777
+ const name = t["name"];
2778
+ if (typeof name !== "string") {
2779
+ return { updated: false, baselined: [], drifted: [], reverted: [] };
2780
+ }
2781
+ const baselined = [];
2782
+ const drifted = [];
2783
+ const reverted = [];
2784
+ this.present.add(name);
2785
+ const annotations = extractAnnotations(t);
2786
+ this.currentAnnotations.set(name, annotations);
2787
+ const definitionKey = canonicalize(t);
2788
+ const baseline = this.baselines.get(name);
2789
+ if (!baseline) {
2790
+ this.baselines.set(name, { definition: t, definitionKey, annotations });
2791
+ baselined.push(name);
2792
+ if (this.driftedTools.has(name)) {
2793
+ this.driftedTools.delete(name);
2794
+ reverted.push(name);
2795
+ }
2796
+ return { updated: true, baselined, drifted, reverted };
2797
+ }
2798
+ if (definitionKey === baseline.definitionKey) {
2799
+ if (this.driftedTools.has(name)) {
2800
+ this.driftedTools.delete(name);
2801
+ reverted.push(name);
2802
+ }
2803
+ return { updated: true, baselined, drifted, reverted };
2804
+ }
2805
+ const changes = [];
2806
+ for (const field of ASPECT_FIELDS) {
2807
+ const baselineValue = baseline.definition[field];
2808
+ const currentValue = t[field];
2809
+ if (canonicalize(baselineValue) !== canonicalize(currentValue)) {
2810
+ changes.push({ aspect: field, baseline: baselineValue, current: currentValue });
2811
+ }
2812
+ }
2813
+ if (changes.length === 0) {
2814
+ changes.push({ aspect: "other", baseline: baseline.definition, current: t });
2815
+ }
2816
+ const event = { toolName: name, changes };
2817
+ const existing = this.driftedTools.get(name);
2818
+ const isNewDrift = !existing || canonicalize(existing.changes) !== canonicalize(changes);
2819
+ this.driftedTools.set(name, event);
2820
+ if (isNewDrift) drifted.push(event);
2821
+ return { updated: true, baselined, drifted, reverted };
2822
+ }
2424
2823
  /**
2425
2824
  * Get the **baseline** annotations for a tool — the definition first seen,
2426
2825
  * not the latest upstream claim. Returns `undefined` if the tool has no
@@ -2454,27 +2853,6 @@ function extractAnnotations(tool) {
2454
2853
  const annotations = tool["annotations"];
2455
2854
  return annotations && typeof annotations === "object" ? annotations : void 0;
2456
2855
  }
2457
- function canonicalize(value) {
2458
- const encoded = JSON.stringify(sortKeysDeep(value));
2459
- return encoded ?? "";
2460
- }
2461
- function sortKeysDeep(value) {
2462
- if (Array.isArray(value)) return value.map(sortKeysDeep);
2463
- if (value !== null && typeof value === "object") {
2464
- const source = value;
2465
- const out = {};
2466
- for (const key of Object.keys(source).sort()) {
2467
- Object.defineProperty(out, key, {
2468
- value: sortKeysDeep(source[key]),
2469
- enumerable: true,
2470
- writable: true,
2471
- configurable: true
2472
- });
2473
- }
2474
- return out;
2475
- }
2476
- return value;
2477
- }
2478
2856
  function extractTools(body) {
2479
2857
  if (typeof body !== "object" || body === null) return null;
2480
2858
  const b = body;
@@ -2486,49 +2864,6 @@ function extractTools(body) {
2486
2864
  return tools;
2487
2865
  }
2488
2866
 
2489
- // src/evidence/grounding.ts
2490
- function checkEvidence(store, sessionId, requirements) {
2491
- if (requirements.length === 0) {
2492
- return { satisfied: true, missing: [], expired: [], found: [] };
2493
- }
2494
- const found = [];
2495
- const missing = [];
2496
- const expired = [];
2497
- for (const key of requirements) {
2498
- const valid = store.getEvidence(sessionId, key);
2499
- if (valid) {
2500
- found.push(key);
2501
- } else if (store.hasSeenEvidence(sessionId, key)) {
2502
- expired.push(key);
2503
- } else {
2504
- missing.push(key);
2505
- }
2506
- }
2507
- return {
2508
- satisfied: missing.length === 0 && expired.length === 0,
2509
- missing,
2510
- expired,
2511
- found
2512
- };
2513
- }
2514
- function checkDependencies(store, sessionId, requirements, options = {}) {
2515
- if (requirements.length === 0) {
2516
- return { satisfied: true, missing: [] };
2517
- }
2518
- const requireSuccess = options.requireSuccess ?? true;
2519
- const missing = [];
2520
- for (const toolName of requirements) {
2521
- const satisfied = requireSuccess ? store.hasSuccessfulTool(sessionId, toolName) : store.hasCompletedTool(sessionId, toolName);
2522
- if (!satisfied) {
2523
- missing.push(toolName);
2524
- }
2525
- }
2526
- return {
2527
- satisfied: missing.length === 0,
2528
- missing
2529
- };
2530
- }
2531
-
2532
2867
  // src/feedback/self-repair.ts
2533
2868
  function ruleInfo(rule) {
2534
2869
  return {
@@ -2740,6 +3075,7 @@ var GovernedForwarder = class {
2740
3075
  spendLimiter;
2741
3076
  annotationCache = new ToolAnnotationCache();
2742
3077
  agentKeyWarned = false;
3078
+ senderKeyWarned = false;
2743
3079
  constructor(inner, policy, options) {
2744
3080
  this.inner = inner;
2745
3081
  this.policy = policy;
@@ -2901,7 +3237,10 @@ var GovernedForwarder = class {
2901
3237
  approval_wait_ms: 0,
2902
3238
  proxy_compute_ms: 0,
2903
3239
  flagged_destructive: false,
2904
- dry_run: false
3240
+ dry_run: false,
3241
+ record_kind: "drift_event",
3242
+ origin: "mcp",
3243
+ metadata: null
2905
3244
  });
2906
3245
  }
2907
3246
  async handleToolsCall(request) {
@@ -2913,99 +3252,28 @@ var GovernedForwarder = class {
2913
3252
  return this.inner.forward(request);
2914
3253
  }
2915
3254
  const toolArguments = params?.["arguments"] && typeof params["arguments"] === "object" ? params["arguments"] : void 0;
2916
- const annotations = this.annotationCache.get(toolName);
2917
- const driftEvent = this.annotationCache.getDrift(toolName);
2918
- const driftMode = this.policy.onToolDrift ?? "block";
2919
- let decision = evaluatePolicy(this.policy, {
2920
- toolName,
2921
- annotations,
3255
+ const {
3256
+ decision,
3257
+ driftEvent,
3258
+ driftMode,
3259
+ driftBlocked,
3260
+ flaggedDestructive,
3261
+ evidenceResult,
3262
+ dependencyResult,
3263
+ evidenceBlocked,
3264
+ sessionBlocked,
3265
+ isDryRun
3266
+ } = decide({
3267
+ toolName,
2922
3268
  toolArguments,
2923
- environment: this.environment
3269
+ sessionId: request.sessionId,
3270
+ policy: this.policy,
3271
+ environment: this.environment,
3272
+ evidenceStore: this.evidenceStore,
3273
+ baselineAnnotations: this.annotationCache.get(toolName),
3274
+ currentAnnotations: this.annotationCache.getCurrent(toolName),
3275
+ driftEvent: this.annotationCache.getDrift(toolName)
2924
3276
  });
2925
- if (driftEvent && driftMode === "log") {
2926
- const currentDecision = evaluatePolicy(this.policy, {
2927
- toolName,
2928
- annotations: this.annotationCache.getCurrent(toolName),
2929
- toolArguments,
2930
- environment: this.environment
2931
- });
2932
- decision = stricterDecision(decision, currentDecision);
2933
- }
2934
- const baselineDestructive = annotations?.destructiveHint ?? true;
2935
- const currentDestructive = driftEvent && driftMode === "log" ? this.annotationCache.getCurrent(toolName)?.destructiveHint ?? true : false;
2936
- const isDestructive = baselineDestructive || currentDestructive;
2937
- let flaggedDestructive = false;
2938
- if (isDestructive && !decision.matchedRule && this.policy.flagDestructive) {
2939
- flaggedDestructive = true;
2940
- if (this.policy.flagDestructive === "log") {
2941
- console.error(`[helio] Destructive tool detected: ${toolName} (no matching rule)`);
2942
- } else {
2943
- decision = {
2944
- action: "require_approval",
2945
- matchedRule: void 0,
2946
- reason: `Destructive tool "${toolName}" auto-escalated by flag_destructive policy`
2947
- };
2948
- }
2949
- }
2950
- let driftBlocked = false;
2951
- if (driftEvent && driftMode !== "log") {
2952
- driftBlocked = driftMode === "block";
2953
- decision = {
2954
- action: driftMode === "block" ? "deny" : "require_approval",
2955
- matchedRule: void 0,
2956
- reason: `Tool "${toolName}" definition drifted from baseline (${driftEvent.changes.map((change) => change.aspect).join(", ")})`
2957
- };
2958
- }
2959
- const originalAction = decision.action;
2960
- let evidenceResult;
2961
- let dependencyResult;
2962
- let evidenceBlocked = false;
2963
- let sessionBlocked = false;
2964
- const requiresGroundedSession = decision.action !== "deny" && !!decision.matchedRule && ((decision.matchedRule.evidence?.requires.length ?? 0) > 0 || (decision.matchedRule.requires?.length ?? 0) > 0);
2965
- if (requiresGroundedSession && !request.sessionId) {
2966
- sessionBlocked = true;
2967
- evidenceBlocked = true;
2968
- decision = {
2969
- action: "deny",
2970
- matchedRule: decision.matchedRule,
2971
- reason: "Mcp-Session-Id is required for evidence/dependency-gated policy rules"
2972
- };
2973
- }
2974
- if (decision.action !== "deny" && this.evidenceStore && request.sessionId && decision.matchedRule) {
2975
- const rule = decision.matchedRule;
2976
- if (rule.evidence?.requires.length) {
2977
- evidenceResult = checkEvidence(
2978
- this.evidenceStore,
2979
- request.sessionId,
2980
- rule.evidence.requires
2981
- );
2982
- if (!evidenceResult.satisfied) {
2983
- evidenceBlocked = true;
2984
- const problemKeys = [...evidenceResult.missing, ...evidenceResult.expired];
2985
- decision = {
2986
- action: "deny",
2987
- matchedRule: rule,
2988
- reason: `Required evidence not satisfied: ${problemKeys.join(", ")}`
2989
- };
2990
- }
2991
- }
2992
- if (!evidenceBlocked && rule.requires?.length) {
2993
- dependencyResult = checkDependencies(this.evidenceStore, request.sessionId, rule.requires, {
2994
- requireSuccess: rule.requiresSuccess ?? true
2995
- });
2996
- if (!dependencyResult.satisfied) {
2997
- evidenceBlocked = true;
2998
- decision = {
2999
- action: "deny",
3000
- matchedRule: rule,
3001
- reason: `Required tool calls not completed: ${dependencyResult.missing.join(", ")}`
3002
- };
3003
- }
3004
- }
3005
- }
3006
- const isPerRuleDryRun = originalAction === "dry_run";
3007
- const isGlobalDryRun = this.policy.dryRun === true;
3008
- const isDryRun = (isPerRuleDryRun || isGlobalDryRun) && !sessionBlocked;
3009
3277
  let result;
3010
3278
  let approvalOutcome;
3011
3279
  let approvalWaitMs = 0;
@@ -3302,6 +3570,14 @@ var GovernedForwarder = class {
3302
3570
  );
3303
3571
  }
3304
3572
  return `tool:${toolName}`;
3573
+ case "sender_id":
3574
+ if (!this.senderKeyWarned) {
3575
+ this.senderKeyWarned = true;
3576
+ console.error(
3577
+ '[helio] Warning: limits.key "sender_id" has no sender on the MCP path, falling back to "tool"'
3578
+ );
3579
+ }
3580
+ return `tool:${toolName}`;
3305
3581
  case "tool":
3306
3582
  default:
3307
3583
  return `tool:${toolName}`;
@@ -3403,7 +3679,10 @@ var GovernedForwarder = class {
3403
3679
  approval_wait_ms: approvalWaitMs,
3404
3680
  proxy_compute_ms: proxyComputeMs,
3405
3681
  flagged_destructive: flaggedDestructive,
3406
- dry_run: isDryRun ?? false
3682
+ dry_run: isDryRun ?? false,
3683
+ record_kind: "tool_call",
3684
+ origin: "mcp",
3685
+ metadata: null
3407
3686
  };
3408
3687
  const isEnforcementDecision = !isDryRun && (!wasForwarded || approvalOutcome !== void 0);
3409
3688
  if (isEnforcementDecision) {
@@ -3509,17 +3788,6 @@ function collectAllowedEvidenceKeys(policy) {
3509
3788
  }
3510
3789
  return [...keys];
3511
3790
  }
3512
- var ACTION_SEVERITY = {
3513
- deny: 5,
3514
- require_approval: 4,
3515
- dry_run: 3,
3516
- spend_limit: 2,
3517
- rate_limit: 1,
3518
- allow: 0
3519
- };
3520
- function stricterDecision(a, b) {
3521
- return ACTION_SEVERITY[b.action] > ACTION_SEVERITY[a.action] ? b : a;
3522
- }
3523
3791
  function makeErrorResult(request, code, message, data) {
3524
3792
  const body = {
3525
3793
  jsonrpc: "2.0",
@@ -3652,6 +3920,46 @@ var RateLimiter = class {
3652
3920
  resetAtMs
3653
3921
  };
3654
3922
  }
3923
+ /**
3924
+ * Unconditionally record a call against the rate limit.
3925
+ *
3926
+ * Unlike check(), this always appends the timestamp — even when the bucket
3927
+ * is already at/over the limit — because the call it represents has already
3928
+ * executed. The sideband splits decision from execution: /evaluate peeks
3929
+ * (non-destructive), and /audit calls record() once the external call ran,
3930
+ * so refusing to record at the limit (as check() does) would let real calls
3931
+ * escape accounting and under-count subsequent peeks. (issue #12, D3.)
3932
+ *
3933
+ * Warnings fire only while the post-append count stays within the limit —
3934
+ * exact parity with check(), which never warns on its over-limit path — so a
3935
+ * burst of over-limit audits cannot flood the dashboard's limit_warning feed.
3936
+ */
3937
+ record(params) {
3938
+ const { key, maxCalls, windowMs } = params;
3939
+ const now = this.now();
3940
+ const windowStart = now - windowMs;
3941
+ let bucket = this.buckets.get(key);
3942
+ if (!bucket) {
3943
+ bucket = { timestamps: [], maxCalls, windowMs };
3944
+ this.buckets.set(key, bucket);
3945
+ }
3946
+ bucket.maxCalls = maxCalls;
3947
+ bucket.windowMs = windowMs;
3948
+ bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
3949
+ bucket.timestamps.push(now);
3950
+ const current = bucket.timestamps.length;
3951
+ const resetAtMs = (bucket.timestamps[0] ?? now) + windowMs;
3952
+ if (this.onWarning && current <= maxCalls && current / maxCalls >= this.warningThreshold) {
3953
+ this.onWarning({ key, current, limit: maxCalls, window_ms: windowMs, reset_at_ms: resetAtMs });
3954
+ }
3955
+ return {
3956
+ allowed: current <= maxCalls,
3957
+ current,
3958
+ limit: maxCalls,
3959
+ windowMs,
3960
+ resetAtMs
3961
+ };
3962
+ }
3655
3963
  /**
3656
3964
  * Check the rate limit without recording the call (non-destructive).
3657
3965
  *
@@ -3866,6 +4174,57 @@ var SpendLimiter = class {
3866
4174
  resetAtMs
3867
4175
  };
3868
4176
  }
4177
+ /**
4178
+ * Unconditionally record a spend against the limit.
4179
+ *
4180
+ * Unlike check(), this always appends the amount — even when it pushes the
4181
+ * window past the limit — because the spend it represents has already been
4182
+ * incurred. The sideband peeks at /evaluate and commits here at /audit once
4183
+ * the external call ran (issue #12, D3).
4184
+ *
4185
+ * Throws on a negative or non-finite amount: such amounts are rejected at
4186
+ * /evaluate, so one reaching record() is a logic bug we surface loudly rather
4187
+ * than silently corrupt the sliding-window sum. Warnings fire only while the
4188
+ * post-append spend stays within the limit (parity with check()).
4189
+ */
4190
+ record(params) {
4191
+ const { key, amount, limit, windowMs } = params;
4192
+ if (!Number.isFinite(amount) || amount < 0) {
4193
+ throw new RangeError(
4194
+ `SpendLimiter.record() received an invalid amount (${String(amount)}); invalid amounts must be rejected at /evaluate, never committed`
4195
+ );
4196
+ }
4197
+ const now = this.now();
4198
+ const windowStart = now - windowMs;
4199
+ let bucket = this.buckets.get(key);
4200
+ if (!bucket) {
4201
+ bucket = { entries: [], limit, currency: "", windowMs };
4202
+ this.buckets.set(key, bucket);
4203
+ }
4204
+ bucket.limit = limit;
4205
+ bucket.windowMs = windowMs;
4206
+ bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
4207
+ bucket.entries.push({ timestamp: now, amount });
4208
+ const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
4209
+ const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
4210
+ if (this.onWarning && currentSpend <= limit && currentSpend / limit >= this.warningThreshold) {
4211
+ this.onWarning({
4212
+ key,
4213
+ current_spend: currentSpend,
4214
+ limit,
4215
+ currency: bucket.currency,
4216
+ window_ms: windowMs,
4217
+ reset_at_ms: resetAtMs
4218
+ });
4219
+ }
4220
+ return {
4221
+ allowed: currentSpend <= limit,
4222
+ currentSpend,
4223
+ limit,
4224
+ windowMs,
4225
+ resetAtMs
4226
+ };
4227
+ }
3869
4228
  /**
3870
4229
  * Check the spend limit without recording the spend (non-destructive).
3871
4230
  *
@@ -4111,6 +4470,9 @@ CREATE TABLE IF NOT EXISTS audit_records (
4111
4470
  proxy_compute_ms REAL NOT NULL,
4112
4471
  flagged_destructive INTEGER NOT NULL DEFAULT 0,
4113
4472
  dry_run INTEGER NOT NULL DEFAULT 0,
4473
+ record_kind TEXT NOT NULL DEFAULT 'tool_call',
4474
+ origin TEXT NOT NULL DEFAULT 'mcp',
4475
+ metadata TEXT,
4114
4476
  created_at TEXT NOT NULL
4115
4477
  );
4116
4478
  `;
@@ -4121,6 +4483,8 @@ CREATE INDEX IF NOT EXISTS idx_audit_policy_decision ON audit_records (policy_d
4121
4483
  CREATE INDEX IF NOT EXISTS idx_audit_session_id ON audit_records (session_id);
4122
4484
  CREATE INDEX IF NOT EXISTS idx_audit_block_reason ON audit_records (block_reason);
4123
4485
  CREATE INDEX IF NOT EXISTS idx_audit_upstream_status_created_at ON audit_records (upstream_http_status, created_at);
4486
+ CREATE INDEX IF NOT EXISTS idx_audit_record_kind ON audit_records (record_kind);
4487
+ CREATE INDEX IF NOT EXISTS idx_audit_origin ON audit_records (origin);
4124
4488
  `;
4125
4489
  var INSERT_SQL = `
4126
4490
  INSERT INTO audit_records (
@@ -4129,14 +4493,14 @@ INSERT INTO audit_records (
4129
4493
  approved_by, upstream_response, upstream_error, upstream_latency_ms,
4130
4494
  upstream_http_status,
4131
4495
  total_duration_ms, approval_wait_ms, proxy_compute_ms,
4132
- flagged_destructive, dry_run, created_at
4496
+ flagged_destructive, dry_run, record_kind, origin, metadata, created_at
4133
4497
  ) VALUES (
4134
4498
  @id, @timestamp, @session_id, @agent_id, @environment, @tool_name, @tool_input,
4135
4499
  @policy_decision, @block_reason, @matched_rule, @matched_rule_index, @evidence_chain, @approval_status,
4136
4500
  @approved_by, @upstream_response, @upstream_error, @upstream_latency_ms,
4137
4501
  @upstream_http_status,
4138
4502
  @total_duration_ms, @approval_wait_ms, @proxy_compute_ms,
4139
- @flagged_destructive, @dry_run, @created_at
4503
+ @flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @created_at
4140
4504
  )
4141
4505
  `;
4142
4506
  var REQUIRED_AUDIT_COLUMNS = [
@@ -4146,7 +4510,10 @@ var REQUIRED_AUDIT_COLUMNS = [
4146
4510
  "total_duration_ms",
4147
4511
  "approval_wait_ms",
4148
4512
  "proxy_compute_ms",
4149
- "upstream_http_status"
4513
+ "upstream_http_status",
4514
+ "record_kind",
4515
+ "origin",
4516
+ "metadata"
4150
4517
  ];
4151
4518
  function deserializeRow(row) {
4152
4519
  return {
@@ -4173,6 +4540,9 @@ function deserializeRow(row) {
4173
4540
  proxy_compute_ms: row.proxy_compute_ms,
4174
4541
  flagged_destructive: row.flagged_destructive === 1,
4175
4542
  dry_run: row.dry_run === 1,
4543
+ record_kind: row.record_kind,
4544
+ origin: row.origin,
4545
+ metadata: row.metadata ? JSON.parse(row.metadata) : null,
4176
4546
  created_at: row.created_at
4177
4547
  };
4178
4548
  }
@@ -4194,6 +4564,22 @@ function buildWhereClause(filters) {
4194
4564
  if (filters.blocked !== void 0) {
4195
4565
  conditions.push(filters.blocked ? "block_reason IS NOT NULL" : "block_reason IS NULL");
4196
4566
  }
4567
+ if (filters.record_kind !== void 0) {
4568
+ conditions.push("record_kind = ?");
4569
+ params.push(filters.record_kind);
4570
+ }
4571
+ if (filters.origin !== void 0) {
4572
+ conditions.push("origin LIKE ?");
4573
+ params.push(`%${filters.origin}%`);
4574
+ }
4575
+ if (filters.channel_id !== void 0) {
4576
+ conditions.push("json_extract(metadata, '$.channel_id') LIKE ?");
4577
+ params.push(`%${filters.channel_id}%`);
4578
+ }
4579
+ if (filters.sender_id !== void 0) {
4580
+ conditions.push("json_extract(metadata, '$.sender_id') LIKE ?");
4581
+ params.push(`%${filters.sender_id}%`);
4582
+ }
4197
4583
  if (filters.session_id !== void 0) {
4198
4584
  conditions.push("session_id = ?");
4199
4585
  params.push(filters.session_id);
@@ -4320,6 +4706,9 @@ var AuditStore = class {
4320
4706
  proxy_compute_ms: record.proxy_compute_ms,
4321
4707
  flagged_destructive: record.flagged_destructive ? 1 : 0,
4322
4708
  dry_run: record.dry_run ? 1 : 0,
4709
+ record_kind: record.record_kind,
4710
+ origin: record.origin,
4711
+ metadata: record.metadata ? JSON.stringify(record.metadata) : null,
4323
4712
  created_at: now
4324
4713
  });
4325
4714
  return resolvedId;
@@ -4501,9 +4890,8 @@ var AuditWriter = class {
4501
4890
  * is scheduled. This keeps request-path latency bounded even under bursty
4502
4891
  * write load.
4503
4892
  */
4504
- push(record) {
4893
+ push(record, id = randomUUID3()) {
4505
4894
  if (this.closed) return;
4506
- const id = randomUUID3();
4507
4895
  this.buffer.push({ id, record });
4508
4896
  this.onPush?.(record, id);
4509
4897
  if (this.buffer.length >= this.bufferSize) {
@@ -4518,9 +4906,8 @@ var AuditWriter = class {
4518
4906
  * A fatal-process crash still invokes the crash-drain hook, which calls
4519
4907
  * `flush()` synchronously before exit.
4520
4908
  */
4521
- pushImmediate(record) {
4909
+ pushImmediate(record, id = randomUUID3()) {
4522
4910
  if (this.closed) return;
4523
- const id = randomUUID3();
4524
4911
  this.buffer.push({ id, record });
4525
4912
  this.onPush?.(record, id);
4526
4913
  this.scheduleFlushSoon();
@@ -4866,8 +5253,9 @@ var EvidenceStore = class _EvidenceStore {
4866
5253
  };
4867
5254
 
4868
5255
  // src/evidence/api.ts
4869
- import { Hono as Hono4 } from "hono";
4870
- import { z as z4 } from "zod";
5256
+ import { Hono as Hono5 } from "hono";
5257
+ import { bodyLimit } from "hono/body-limit";
5258
+ import { z as z5 } from "zod";
4871
5259
 
4872
5260
  // src/auth/bearer.ts
4873
5261
  import { createHash, timingSafeEqual } from "crypto";
@@ -4879,22 +5267,176 @@ function verifyBearer(authHeader, expected) {
4879
5267
  return timingSafeEqual(actualDigest, expectedDigest);
4880
5268
  }
4881
5269
 
5270
+ // src/sideband/governance-api.ts
5271
+ import { Hono as Hono4 } from "hono";
5272
+ import { z as z4 } from "zod";
5273
+ import { createHash as createHash2 } from "crypto";
5274
+ var originSchema = z4.string().regex(/^[a-z0-9_-]{1,64}$/, "origin must match ^[a-z0-9_-]{1,64}$").default("sideband");
5275
+ var metadataSchema = z4.record(z4.string(), z4.unknown()).nullish();
5276
+ var toolDefinitionSchema = z4.object({
5277
+ name: z4.string().min(1),
5278
+ description: z4.string().optional(),
5279
+ input_schema: z4.unknown().optional(),
5280
+ output_schema: z4.unknown().optional(),
5281
+ title: z4.string().optional(),
5282
+ annotations: z4.record(z4.string(), z4.unknown()).optional()
5283
+ });
5284
+ var evaluateBody = z4.object({
5285
+ origin: originSchema,
5286
+ adapter_version: z4.string().max(64).optional(),
5287
+ agent_id: z4.string().nullish(),
5288
+ session_id: z4.string().nullish(),
5289
+ tool: toolDefinitionSchema,
5290
+ arguments: z4.record(z4.string(), z4.unknown()).optional(),
5291
+ metadata: metadataSchema
5292
+ });
5293
+ var installScanBody = z4.object({
5294
+ origin: originSchema,
5295
+ agent_id: z4.string().nullish(),
5296
+ session_id: z4.string().nullish(),
5297
+ package: z4.object({
5298
+ name: z4.string().min(1),
5299
+ version: z4.string().optional(),
5300
+ source: z4.string().max(64).optional(),
5301
+ spec: z4.string().optional(),
5302
+ url: z4.string().optional()
5303
+ }),
5304
+ metadata: metadataSchema
5305
+ });
5306
+ var auditBody = z4.object({
5307
+ evaluation_id: z4.string().min(1),
5308
+ status: z4.enum(["success", "error", "not_executed"]),
5309
+ error: z4.string().optional(),
5310
+ duration_ms: z4.number().optional(),
5311
+ result: z4.unknown().optional(),
5312
+ actual_amount: z4.number().optional()
5313
+ });
5314
+ var resolveBody = z4.object({
5315
+ resolution: z4.enum(["approved", "denied", "timeout", "cancelled"]),
5316
+ resolved_by: z4.string().optional(),
5317
+ reason: z4.string().optional(),
5318
+ scope: z4.enum(["once", "always"]).optional()
5319
+ });
5320
+ var MAX_METADATA_BYTES = 4 * 1024;
5321
+ function createGovernanceApp(service) {
5322
+ const app = new Hono4();
5323
+ const unavailable = () => ({ error: "governance_unavailable" });
5324
+ app.post("/evaluate", async (c) => {
5325
+ if (!service) return c.json(unavailable(), 503);
5326
+ const parsed = await parseJson(c);
5327
+ if ("error" in parsed) return c.json(parsed.error, 400);
5328
+ const result = evaluateBody.safeParse(parsed.body);
5329
+ if (!result.success) {
5330
+ return c.json({ error: "Validation error", details: formatZodErrors(result.error) }, 400);
5331
+ }
5332
+ if (metadataTooLarge(result.data.metadata)) {
5333
+ return c.json({ error: "metadata_too_large" }, 413);
5334
+ }
5335
+ const r = service.evaluate({
5336
+ origin: result.data.origin,
5337
+ adapter_version: result.data.adapter_version,
5338
+ agent_id: result.data.agent_id ?? null,
5339
+ session_id: result.data.session_id ?? null,
5340
+ tool: result.data.tool,
5341
+ arguments: result.data.arguments,
5342
+ metadata: result.data.metadata ?? null
5343
+ });
5344
+ return c.json(r.body, asStatus(r.status));
5345
+ });
5346
+ app.post("/audit", async (c) => {
5347
+ if (!service) return c.json(unavailable(), 503);
5348
+ const parsed = await parseJson(c);
5349
+ if ("error" in parsed) return c.json(parsed.error, 400);
5350
+ const result = auditBody.safeParse(parsed.body);
5351
+ if (!result.success) {
5352
+ return c.json({ error: "Validation error", details: formatZodErrors(result.error) }, 400);
5353
+ }
5354
+ const hash = auditPayloadHash(result.data);
5355
+ const r = service.audit(result.data, hash);
5356
+ return c.json(r.body, asStatus(r.status));
5357
+ });
5358
+ app.post("/install-scan", async (c) => {
5359
+ if (!service) return c.json(unavailable(), 503);
5360
+ const parsed = await parseJson(c);
5361
+ if ("error" in parsed) return c.json(parsed.error, 400);
5362
+ const result = installScanBody.safeParse(parsed.body);
5363
+ if (!result.success) {
5364
+ return c.json({ error: "Validation error", details: formatZodErrors(result.error) }, 400);
5365
+ }
5366
+ if (metadataTooLarge(result.data.metadata)) {
5367
+ return c.json({ error: "metadata_too_large" }, 413);
5368
+ }
5369
+ const r = service.installScan({
5370
+ origin: result.data.origin,
5371
+ agent_id: result.data.agent_id ?? null,
5372
+ session_id: result.data.session_id ?? null,
5373
+ package: result.data.package,
5374
+ metadata: result.data.metadata ?? null
5375
+ });
5376
+ return c.json(r.body, asStatus(r.status));
5377
+ });
5378
+ app.post("/approval/:id/resolve", async (c) => {
5379
+ if (!service) return c.json(unavailable(), 503);
5380
+ const parsed = await parseJson(c);
5381
+ if ("error" in parsed) return c.json(parsed.error, 400);
5382
+ const result = resolveBody.safeParse(parsed.body);
5383
+ if (!result.success) {
5384
+ return c.json({ error: "Validation error", details: formatZodErrors(result.error) }, 400);
5385
+ }
5386
+ if ((result.data.resolution === "approved" || result.data.resolution === "denied") && !result.data.resolved_by) {
5387
+ return c.json({ error: "resolved_by is required for approved/denied" }, 400);
5388
+ }
5389
+ const r = service.resolveApproval(c.req.param("id"), result.data);
5390
+ return c.json(r.body, asStatus(r.status));
5391
+ });
5392
+ return app;
5393
+ }
5394
+ function isGovernancePath(path) {
5395
+ return path === "/evaluate" || path === "/audit" || path === "/install-scan" || path.startsWith("/approval/");
5396
+ }
5397
+ async function parseJson(c) {
5398
+ try {
5399
+ return { body: await c.req.json() };
5400
+ } catch {
5401
+ return { error: { error: "Invalid JSON" } };
5402
+ }
5403
+ }
5404
+ function metadataTooLarge(metadata) {
5405
+ if (metadata == null) return false;
5406
+ return Buffer.byteLength(canonicalize(metadata), "utf8") > MAX_METADATA_BYTES;
5407
+ }
5408
+ function auditPayloadHash(data) {
5409
+ const semantic = {
5410
+ status: data.status,
5411
+ error: data.error ?? null,
5412
+ duration_ms: data.duration_ms ?? null,
5413
+ result: data.result ?? null,
5414
+ actual_amount: data.actual_amount ?? null
5415
+ };
5416
+ return createHash2("sha256").update(canonicalize(semantic)).digest("hex");
5417
+ }
5418
+ function asStatus(status) {
5419
+ return status;
5420
+ }
5421
+
4882
5422
  // src/evidence/api.ts
4883
- var postEvidenceBody = z4.object({
4884
- session_id: z4.string().min(1),
4885
- tool_name: z4.string().min(1),
4886
- evidence_key: z4.string().min(1),
4887
- evidence_data: z4.unknown().refine((v) => v !== void 0, { message: "Required" }),
4888
- ttl_seconds: z4.number().int().positive().optional()
5423
+ var SIDEBAND_BODY_LIMIT_BYTES = 1 * 1024 * 1024;
5424
+ var postEvidenceBody = z5.object({
5425
+ session_id: z5.string().min(1),
5426
+ tool_name: z5.string().min(1),
5427
+ evidence_key: z5.string().min(1),
5428
+ evidence_data: z5.unknown().refine((v) => v !== void 0, { message: "Required" }),
5429
+ ttl_seconds: z5.number().int().positive().optional()
4889
5430
  });
4890
- var postContextBody = z4.object({
4891
- session_id: z4.string().min(1),
4892
- key: z4.string().min(1),
4893
- value: z4.unknown().refine((v) => v !== void 0, { message: "Required" })
5431
+ var postContextBody = z5.object({
5432
+ session_id: z5.string().min(1),
5433
+ key: z5.string().min(1),
5434
+ value: z5.unknown().refine((v) => v !== void 0, { message: "Required" })
4894
5435
  });
4895
5436
  function createSidebandApp(store, options = {}) {
4896
- const app = new Hono4();
4897
- const token = options.token && options.token.length > 0 ? options.token : void 0;
5437
+ const app = new Hono5();
5438
+ const sdkToken = options.token && options.token.length > 0 ? options.token : void 0;
5439
+ const adapterToken = options.adapterToken && options.adapterToken.length > 0 ? options.adapterToken : void 0;
4898
5440
  app.use("*", async (c, next) => {
4899
5441
  const origin = c.req.header("origin");
4900
5442
  if (origin) {
@@ -4905,20 +5447,26 @@ function createSidebandApp(store, options = {}) {
4905
5447
  }
4906
5448
  await next();
4907
5449
  });
4908
- if (token) {
4909
- app.use("*", async (c, next) => {
4910
- if (c.req.path === "/healthz") {
4911
- await next();
4912
- return;
4913
- }
4914
- const authHeader = c.req.header("authorization");
4915
- if (!verifyBearer(authHeader, token)) {
4916
- return c.json({ error: "Unauthorized" }, 401);
4917
- }
5450
+ app.use(
5451
+ "*",
5452
+ bodyLimit({
5453
+ maxSize: SIDEBAND_BODY_LIMIT_BYTES,
5454
+ onError: (c) => c.json({ error: "request_body_too_large" }, 413)
5455
+ })
5456
+ );
5457
+ app.use("*", async (c, next) => {
5458
+ if (c.req.path === "/healthz") {
4918
5459
  await next();
4919
- });
4920
- }
5460
+ return;
5461
+ }
5462
+ const expected = isGovernancePath(c.req.path) ? adapterToken : sdkToken;
5463
+ if (expected && !verifyBearer(c.req.header("authorization"), expected)) {
5464
+ return c.json({ error: "Unauthorized" }, 401);
5465
+ }
5466
+ await next();
5467
+ });
4921
5468
  app.get("/healthz", (c) => c.json({ status: "ok" }));
5469
+ app.route("/", createGovernanceApp(options.governance));
4922
5470
  app.post("/evidence", async (c) => {
4923
5471
  let body;
4924
5472
  try {
@@ -4981,8 +5529,804 @@ function createSidebandApp(store, options = {}) {
4981
5529
  return app;
4982
5530
  }
4983
5531
 
4984
- // src/approval/queue.ts
5532
+ // src/sideband/governance-service.ts
4985
5533
  import { randomUUID as randomUUID4 } from "crypto";
5534
+
5535
+ // src/sideband/errors.ts
5536
+ var GovernanceConfigError = class extends Error {
5537
+ constructor(message) {
5538
+ super(message);
5539
+ this.name = "GovernanceConfigError";
5540
+ }
5541
+ };
5542
+
5543
+ // src/sideband/governance-service.ts
5544
+ var MAX_ORIGINS = 32;
5545
+ var MAX_TOOLS_PER_ORIGIN = 1024;
5546
+ var MAX_TOOL_INPUT_BYTES = 64 * 1024;
5547
+ var MAX_PENDING_COUNT = 1e4;
5548
+ var MAX_PENDING_BYTES = 64 * 1024 * 1024;
5549
+ var MAX_SENDER_KEYS = 5e4;
5550
+ var SWEEP_INTERVAL_MS2 = 3e4;
5551
+ var GovernanceService = class {
5552
+ policy;
5553
+ environment;
5554
+ evidenceStore;
5555
+ approvalRouter;
5556
+ rateLimiter;
5557
+ spendLimiter;
5558
+ auditWriter;
5559
+ approvalTimeoutMs;
5560
+ ttlMs;
5561
+ now;
5562
+ maxPending;
5563
+ maxPendingBytes;
5564
+ maxSenderKeys;
5565
+ /** Distinct sender_id limit keys with live state (reservation registry, issue #13). */
5566
+ senderKeys = /* @__PURE__ */ new Set();
5567
+ pending = /* @__PURE__ */ new Map();
5568
+ tombstones = /* @__PURE__ */ new Map();
5569
+ caches = /* @__PURE__ */ new Map();
5570
+ /** Native approval ticket id → its pending evaluation id, for on-access
5571
+ * deadline enforcement on the resolve path. */
5572
+ ticketToEvaluation = /* @__PURE__ */ new Map();
5573
+ pendingBytes = 0;
5574
+ sweepTimer = null;
5575
+ closed = false;
5576
+ constructor(options) {
5577
+ this.policy = options.policy;
5578
+ this.environment = options.environment;
5579
+ this.evidenceStore = options.evidenceStore;
5580
+ this.approvalRouter = options.approvalRouter;
5581
+ this.rateLimiter = options.rateLimiter;
5582
+ this.spendLimiter = options.spendLimiter;
5583
+ this.auditWriter = options.auditWriter;
5584
+ this.approvalTimeoutMs = options.approvalTimeoutMs ?? 3e5;
5585
+ this.ttlMs = options.ttlMs ?? 6e5;
5586
+ this.now = options.now ?? Date.now;
5587
+ this.maxPending = options.maxPending ?? MAX_PENDING_COUNT;
5588
+ this.maxPendingBytes = options.maxPendingBytes ?? MAX_PENDING_BYTES;
5589
+ this.maxSenderKeys = options.maxSenderKeys ?? MAX_SENDER_KEYS;
5590
+ this.assertApprovalRouter(this.policy);
5591
+ const sweepMs = options.sweepIntervalMs ?? SWEEP_INTERVAL_MS2;
5592
+ if (sweepMs > 0) {
5593
+ this.sweepTimer = setInterval(() => {
5594
+ this.sweep();
5595
+ }, sweepMs);
5596
+ this.sweepTimer.unref();
5597
+ }
5598
+ }
5599
+ /** Swap the compiled policy on hot-reload (mirrors GovernedForwarder). */
5600
+ updatePolicy(policy) {
5601
+ this.assertApprovalRouter(policy);
5602
+ this.policy = policy;
5603
+ }
5604
+ // -------------------------------------------------------------------------
5605
+ // POST /evaluate
5606
+ // -------------------------------------------------------------------------
5607
+ evaluate(req) {
5608
+ const reserved = reservedMetadataKey(req.metadata);
5609
+ if (reserved) {
5610
+ return { status: 400, body: { error: "reserved_metadata_key", key: reserved } };
5611
+ }
5612
+ const inputBytes = byteLength(req.arguments ?? {});
5613
+ if (inputBytes > MAX_TOOL_INPUT_BYTES) {
5614
+ return { status: 413, body: { error: "tool_input_too_large" } };
5615
+ }
5616
+ const entryBytes = inputBytes + byteLength(req.metadata ?? {});
5617
+ if (!this.caches.has(req.origin) && this.caches.size >= MAX_ORIGINS) {
5618
+ return { status: 400, body: { error: "origin_limit_exceeded" } };
5619
+ }
5620
+ if (this.pending.size >= this.maxPending || this.pendingBytes + entryBytes > this.maxPendingBytes) {
5621
+ return { status: 503, body: { error: "evaluation_backlog_full" } };
5622
+ }
5623
+ const cache = this.cacheFor(req.origin);
5624
+ const toolName = req.tool.name;
5625
+ const hasDefinition = definitionProvided(req.tool);
5626
+ if (hasDefinition) {
5627
+ if (!cache.has(toolName) && cache.size >= MAX_TOOLS_PER_ORIGIN) {
5628
+ return { status: 400, body: { error: "tool_baseline_limit" } };
5629
+ }
5630
+ cache.updateSingle(toMcpToolDef(req.tool));
5631
+ }
5632
+ const pipeline = decide({
5633
+ toolName,
5634
+ toolArguments: req.arguments,
5635
+ sessionId: req.session_id ?? void 0,
5636
+ policy: this.policy,
5637
+ environment: this.environment,
5638
+ evidenceStore: this.evidenceStore,
5639
+ baselineAnnotations: cache.get(toolName),
5640
+ currentAnnotations: cache.getCurrent(toolName),
5641
+ driftEvent: cache.getDrift(toolName),
5642
+ metadata: req.metadata ?? void 0,
5643
+ agentId: req.agent_id ?? void 0
5644
+ });
5645
+ const { decision } = pipeline;
5646
+ const evaluationId = randomUUID4();
5647
+ const timestampIso = new Date(this.now()).toISOString();
5648
+ let wire;
5649
+ let limitPlan;
5650
+ let limitsBlock;
5651
+ const senderId = senderIdOf(req.metadata);
5652
+ if (pipeline.isDryRun) {
5653
+ wire = "dry_run";
5654
+ } else if (decision.action === "deny") {
5655
+ wire = "deny";
5656
+ } else if (decision.action === "require_approval") {
5657
+ wire = "require_approval";
5658
+ } else if (decision.action === "rate_limit") {
5659
+ const planned = this.planRate(decision, toolName, req.session_id, senderId);
5660
+ if (planned?.plan && !this.reserveSenderKey(planned.plan.key)) {
5661
+ return { status: 503, body: { error: "limit_capacity_exhausted" } };
5662
+ }
5663
+ limitPlan = planned?.plan;
5664
+ limitsBlock = planned?.block ? { rate: planned.block } : void 0;
5665
+ wire = planned?.allowed ? "allow" : "rate_limited";
5666
+ } else if (decision.action === "spend_limit") {
5667
+ const planned = this.planSpend(decision, toolName, req.session_id, req.arguments, senderId);
5668
+ if (planned?.plan && !this.reserveSenderKey(planned.plan.key)) {
5669
+ return { status: 503, body: { error: "limit_capacity_exhausted" } };
5670
+ }
5671
+ limitPlan = planned?.plan;
5672
+ limitsBlock = planned?.block ? { spend: planned.block } : void 0;
5673
+ wire = planned?.allowed ? "allow" : "spend_limited";
5674
+ } else {
5675
+ wire = "allow";
5676
+ }
5677
+ const matchedRuleName = decision.matchedRule?.name ?? null;
5678
+ const matchedRuleIndex = decision.matchedRule?.index ?? null;
5679
+ const responseBody = {
5680
+ evaluation_id: evaluationId,
5681
+ decision: wire,
5682
+ reason: decision.reason,
5683
+ matched_rule: matchedRuleName,
5684
+ matched_rule_index: matchedRuleIndex
5685
+ };
5686
+ if (isBlocking(wire)) {
5687
+ responseBody["feedback"] = buildFeedback(decision.matchedRule, decision.reason);
5688
+ }
5689
+ if (limitsBlock) responseBody["limits"] = limitsBlock;
5690
+ if (wire === "dry_run") {
5691
+ responseBody["dry_run"] = {
5692
+ would_forward: decision.action === "allow" && !pipeline.evidenceBlocked,
5693
+ evidence_satisfied: !pipeline.evidenceBlocked,
5694
+ limits_ok: true
5695
+ };
5696
+ }
5697
+ if (pipeline.driftEvent) {
5698
+ responseBody["tool_drift"] = { changes: pipeline.driftEvent.changes };
5699
+ }
5700
+ if (isTerminalAtEvaluate(wire)) {
5701
+ const auditId = this.writeAudit({
5702
+ timestampIso,
5703
+ origin: req.origin,
5704
+ agentId: req.agent_id,
5705
+ sessionId: req.session_id,
5706
+ toolName,
5707
+ toolInput: req.arguments ?? {},
5708
+ metadata: req.metadata,
5709
+ action: decision.action,
5710
+ wire,
5711
+ matchedRuleName,
5712
+ matchedRuleIndex,
5713
+ flaggedDestructive: pipeline.flaggedDestructive,
5714
+ dryRun: wire === "dry_run",
5715
+ recordKind: "tool_call",
5716
+ limitsChain: limitsBlock
5717
+ });
5718
+ this.tombstones.set(evaluationId, {
5719
+ auditRecordId: auditId,
5720
+ payloadHash: null,
5721
+ finalizedBy: "evaluate",
5722
+ expiresAtMs: this.now() + this.ttlMs
5723
+ });
5724
+ return { status: 200, body: responseBody };
5725
+ }
5726
+ let approvalTicketId;
5727
+ let ticketTimeoutAtMs;
5728
+ if (wire === "require_approval") {
5729
+ const router = this.approvalRouter;
5730
+ if (!router) {
5731
+ throw new GovernanceConfigError(
5732
+ "[helio] invariant violation: require_approval decision without an approvalRouter"
5733
+ );
5734
+ }
5735
+ const timeoutMs = decision.matchedRule?.approval?.timeoutMs ?? this.approvalTimeoutMs;
5736
+ const ticket = router.createNativeTicket({
5737
+ tool_name: toolName,
5738
+ tool_input: req.arguments ?? {},
5739
+ matched_rule: decision.matchedRule,
5740
+ session_id: req.session_id,
5741
+ origin: req.origin,
5742
+ timeout_ms: timeoutMs
5743
+ });
5744
+ approvalTicketId = ticket.id;
5745
+ ticketTimeoutAtMs = this.now() + timeoutMs;
5746
+ responseBody["approval"] = {
5747
+ id: ticket.id,
5748
+ timeout_ms: timeoutMs,
5749
+ resolve_path: `/approval/${ticket.id}/resolve`
5750
+ };
5751
+ }
5752
+ const entry = {
5753
+ evaluationId,
5754
+ origin: req.origin,
5755
+ agentId: req.agent_id,
5756
+ sessionId: req.session_id,
5757
+ toolName,
5758
+ toolInput: req.arguments ?? {},
5759
+ metadata: req.metadata,
5760
+ action: decision.action,
5761
+ matchedRuleName,
5762
+ matchedRuleIndex,
5763
+ flaggedDestructive: pipeline.flaggedDestructive,
5764
+ limitPlan,
5765
+ approvalTicketId,
5766
+ timestampIso,
5767
+ createdAtMs: this.now(),
5768
+ evaluationExpiresAtMs: this.now() + this.ttlMs,
5769
+ ticketTimeoutAtMs,
5770
+ bytes: entryBytes
5771
+ };
5772
+ this.pending.set(evaluationId, entry);
5773
+ this.pendingBytes += entryBytes;
5774
+ if (approvalTicketId) this.ticketToEvaluation.set(approvalTicketId, evaluationId);
5775
+ return { status: 200, body: responseBody };
5776
+ }
5777
+ // -------------------------------------------------------------------------
5778
+ // POST /audit
5779
+ // -------------------------------------------------------------------------
5780
+ audit(req, payloadHash) {
5781
+ const id = req.evaluation_id;
5782
+ const tomb = this.tombstones.get(id);
5783
+ if (tomb) {
5784
+ if (tomb.finalizedBy === "expired") {
5785
+ return { status: 404, body: { error: "evaluation_expired" } };
5786
+ }
5787
+ if (tomb.finalizedBy === "evaluate") {
5788
+ return {
5789
+ status: 200,
5790
+ body: {
5791
+ ok: true,
5792
+ audit_record_id: tomb.auditRecordId,
5793
+ already_finalized: true,
5794
+ finalized_by: "evaluate"
5795
+ }
5796
+ };
5797
+ }
5798
+ if (tomb.payloadHash === payloadHash) {
5799
+ return {
5800
+ status: 200,
5801
+ body: { ok: true, audit_record_id: tomb.auditRecordId, already_finalized: true }
5802
+ };
5803
+ }
5804
+ return { status: 409, body: { error: "evaluation_conflict" } };
5805
+ }
5806
+ const entry = this.pending.get(id);
5807
+ if (!entry) {
5808
+ return { status: 404, body: { error: "evaluation_unknown" } };
5809
+ }
5810
+ if (this.enforceDeadlines(entry) === "expired") {
5811
+ return { status: 404, body: { error: "evaluation_expired" } };
5812
+ }
5813
+ let approvalStatus = null;
5814
+ let approvedBy = null;
5815
+ if (entry.approvalTicketId) {
5816
+ const ticket = this.getTicketStatus(entry.approvalTicketId);
5817
+ const status = ticket?.status;
5818
+ if (!status || status === "pending") {
5819
+ return { status: 409, body: { error: "approval_unresolved" } };
5820
+ }
5821
+ approvalStatus = status;
5822
+ approvedBy = ticket.resolved_by ?? null;
5823
+ }
5824
+ if (req.actual_amount !== void 0) {
5825
+ if (!Number.isFinite(req.actual_amount) || req.actual_amount < 0) {
5826
+ return { status: 400, body: { error: "invalid_actual_amount" } };
5827
+ }
5828
+ if (entry.limitPlan?.kind !== "spend") {
5829
+ return { status: 400, body: { error: "no_spend_rule" } };
5830
+ }
5831
+ }
5832
+ const callHappened = req.status === "success" || req.status === "error";
5833
+ let limitsChain;
5834
+ if (callHappened && entry.limitPlan) {
5835
+ limitsChain = this.commitLimit(entry.limitPlan, req.actual_amount);
5836
+ }
5837
+ if (callHappened && this.evidenceStore && entry.sessionId) {
5838
+ this.evidenceStore.recordToolCall(entry.sessionId, entry.toolName, req.status === "success");
5839
+ }
5840
+ const auditId = this.writeAudit({
5841
+ timestampIso: entry.timestampIso,
5842
+ origin: entry.origin,
5843
+ agentId: entry.agentId,
5844
+ sessionId: entry.sessionId,
5845
+ toolName: entry.toolName,
5846
+ toolInput: entry.toolInput,
5847
+ metadata: entry.metadata,
5848
+ action: entry.action,
5849
+ wire: entry.action === "require_approval" ? "require_approval" : "allow",
5850
+ matchedRuleName: entry.matchedRuleName,
5851
+ matchedRuleIndex: entry.matchedRuleIndex,
5852
+ flaggedDestructive: entry.flaggedDestructive,
5853
+ dryRun: false,
5854
+ recordKind: "tool_call",
5855
+ limitsChain,
5856
+ approvalStatus,
5857
+ approvedBy,
5858
+ upstreamError: req.status === "error" ? req.error ?? "tool call failed" : null,
5859
+ upstreamResponse: req.result ?? null,
5860
+ upstreamLatencyMs: req.duration_ms ?? null
5861
+ });
5862
+ this.discardPending(entry);
5863
+ this.tombstones.set(id, {
5864
+ auditRecordId: auditId,
5865
+ payloadHash,
5866
+ finalizedBy: "audit",
5867
+ expiresAtMs: this.now() + this.ttlMs
5868
+ });
5869
+ return { status: 201, body: { ok: true, audit_record_id: auditId } };
5870
+ }
5871
+ // -------------------------------------------------------------------------
5872
+ // POST /install-scan — evaluates install-time policy (issue #13)
5873
+ // -------------------------------------------------------------------------
5874
+ installScan(req) {
5875
+ const reserved = reservedMetadataKey(req.metadata);
5876
+ if (reserved) {
5877
+ return { status: 400, body: { error: "reserved_metadata_key", key: reserved } };
5878
+ }
5879
+ const evaluationId = randomUUID4();
5880
+ const toolName = `install:${req.package.source ?? "pkg"}:${req.package.name}`;
5881
+ const verdict = this.evaluateInstall(req);
5882
+ const denied = verdict.decision === "deny";
5883
+ const auditId = this.writeAudit({
5884
+ timestampIso: new Date(this.now()).toISOString(),
5885
+ origin: req.origin,
5886
+ agentId: req.agent_id,
5887
+ sessionId: req.session_id,
5888
+ toolName,
5889
+ toolInput: { ...req.package },
5890
+ metadata: req.metadata,
5891
+ // policy_decision is 'deny' (NOT 'deny_install') so the dashboard renders a
5892
+ // blocked install as a block, not an allow. The install context lives in
5893
+ // record_kind + block_reason.
5894
+ action: denied ? "deny" : "allow",
5895
+ wire: denied ? "deny" : "allow",
5896
+ matchedRuleName: verdict.matchedRule?.name ?? null,
5897
+ matchedRuleIndex: verdict.matchedRule?.index ?? null,
5898
+ flaggedDestructive: false,
5899
+ dryRun: false,
5900
+ recordKind: "install_scan"
5901
+ });
5902
+ this.tombstones.set(evaluationId, {
5903
+ auditRecordId: auditId,
5904
+ payloadHash: null,
5905
+ finalizedBy: "evaluate",
5906
+ expiresAtMs: this.now() + this.ttlMs
5907
+ });
5908
+ const body = {
5909
+ evaluation_id: evaluationId,
5910
+ decision: verdict.decision,
5911
+ reason: verdict.reason,
5912
+ matched_rule: verdict.matchedRule?.name ?? null,
5913
+ matched_rule_index: verdict.matchedRule?.index ?? null
5914
+ };
5915
+ if (denied) {
5916
+ body["feedback"] = buildFeedback(verdict.matchedRule, verdict.reason);
5917
+ }
5918
+ return { status: 200, body };
5919
+ }
5920
+ /** First-match-wins evaluation of the compiled install policy (issue #13). */
5921
+ evaluateInstall(req) {
5922
+ const install = this.policy.install;
5923
+ if (!install) {
5924
+ return { decision: "allow", reason: "no install-time rules defined" };
5925
+ }
5926
+ const metadataView = req.agent_id != null ? { ...req.metadata ?? {}, agent_id: req.agent_id } : req.metadata ?? void 0;
5927
+ for (const rule of install.rules) {
5928
+ if (matchInstallRule(rule, req.package, metadataView)) {
5929
+ const label = rule.name ? `"${rule.name}"` : `install_rule[${String(rule.index)}]`;
5930
+ return {
5931
+ decision: rule.action === "deny_install" ? "deny" : "allow",
5932
+ matchedRule: rule,
5933
+ reason: `Matched ${label} \u2192 ${rule.action}`
5934
+ };
5935
+ }
5936
+ }
5937
+ return {
5938
+ decision: install.defaultAction,
5939
+ reason: `No matching install rule; default ${install.defaultAction}`
5940
+ };
5941
+ }
5942
+ // -------------------------------------------------------------------------
5943
+ // POST /approval/:id/resolve
5944
+ // -------------------------------------------------------------------------
5945
+ resolveApproval(ticketId, req) {
5946
+ if (!this.approvalRouter) {
5947
+ return { status: 503, body: { error: "governance_unavailable" } };
5948
+ }
5949
+ const ticket = this.getTicketStatus(ticketId);
5950
+ if (!ticket) {
5951
+ return { status: 404, body: { error: "ticket_not_found" } };
5952
+ }
5953
+ if (!ticket.channel_name.startsWith("native:")) {
5954
+ return { status: 409, body: { error: "not_a_native_ticket" } };
5955
+ }
5956
+ const evaluationId = this.ticketToEvaluation.get(ticketId);
5957
+ const entry = evaluationId ? this.pending.get(evaluationId) : void 0;
5958
+ if (entry) this.enforceDeadlines(entry);
5959
+ const current = this.getTicketStatus(ticketId);
5960
+ if (!current || current.status !== "pending") {
5961
+ return { status: 409, body: { error: "already_resolved", status: current?.status } };
5962
+ }
5963
+ const resolved = this.approvalRouter.resolveNativeTicket(
5964
+ ticketId,
5965
+ req.resolution,
5966
+ req.resolved_by,
5967
+ { denial_reason: req.resolution === "denied" ? req.reason : void 0 }
5968
+ );
5969
+ if (!resolved) {
5970
+ return { status: 409, body: { error: "already_resolved" } };
5971
+ }
5972
+ return { status: 200, body: { ok: true } };
5973
+ }
5974
+ // -------------------------------------------------------------------------
5975
+ // Sweep — GC backstop for callers that never return
5976
+ // -------------------------------------------------------------------------
5977
+ sweep() {
5978
+ for (const entry of [...this.pending.values()]) {
5979
+ this.enforceDeadlines(entry);
5980
+ }
5981
+ const now = this.now();
5982
+ for (const [id, tomb] of this.tombstones) {
5983
+ if (tomb.expiresAtMs <= now) this.tombstones.delete(id);
5984
+ }
5985
+ this.pruneSenderKeys();
5986
+ }
5987
+ /**
5988
+ * Reserve a cardinality slot for a sender-keyed limit (issue #13).
5989
+ *
5990
+ * Only `sender:*` keys are gated — tool/session families are bounded by upstream
5991
+ * cardinality, and the MCP path never reaches here, so structural traffic cannot
5992
+ * be starved. A key already backed by live state (registry or a live limiter
5993
+ * bucket) costs no new slot. At capacity we lazily prune dead keys before failing
5994
+ * closed, so an emptied bucket frees its slot without waiting for the sweep.
5995
+ */
5996
+ reserveSenderKey(key) {
5997
+ if (!key.startsWith("sender:")) return true;
5998
+ if (this.senderKeys.has(key)) return true;
5999
+ if (this.hasLiveBucket(key)) {
6000
+ this.senderKeys.add(key);
6001
+ return true;
6002
+ }
6003
+ if (this.senderKeys.size >= this.maxSenderKeys) {
6004
+ this.pruneSenderKeys();
6005
+ if (this.senderKeys.size >= this.maxSenderKeys) return false;
6006
+ }
6007
+ this.senderKeys.add(key);
6008
+ return true;
6009
+ }
6010
+ /** Drop registry keys with no pending evaluation AND no live limiter bucket. */
6011
+ pruneSenderKeys() {
6012
+ if (this.senderKeys.size === 0) return;
6013
+ const inUse = /* @__PURE__ */ new Set();
6014
+ for (const entry of this.pending.values()) {
6015
+ if (entry.limitPlan && entry.limitPlan.key.startsWith("sender:")) {
6016
+ inUse.add(entry.limitPlan.key);
6017
+ }
6018
+ }
6019
+ for (const key of this.senderKeys) {
6020
+ if (inUse.has(key)) continue;
6021
+ if (this.hasLiveBucket(key)) continue;
6022
+ this.senderKeys.delete(key);
6023
+ }
6024
+ }
6025
+ /**
6026
+ * Whether either limiter still holds a live bucket for `key`. Uses the public
6027
+ * `getKeyState()` — never the limiters' private maps — and its lazy eviction of
6028
+ * an emptied bucket IS the prune-on-touch mechanism.
6029
+ */
6030
+ hasLiveBucket(key) {
6031
+ return this.rateLimiter?.getKeyState(key) !== void 0 || this.spendLimiter?.getKeyState(key) !== void 0;
6032
+ }
6033
+ close() {
6034
+ if (this.closed) return;
6035
+ this.closed = true;
6036
+ if (this.sweepTimer) {
6037
+ clearInterval(this.sweepTimer);
6038
+ this.sweepTimer = null;
6039
+ }
6040
+ this.pending.clear();
6041
+ this.tombstones.clear();
6042
+ this.caches.clear();
6043
+ this.senderKeys.clear();
6044
+ this.pendingBytes = 0;
6045
+ }
6046
+ // -------------------------------------------------------------------------
6047
+ // Internals
6048
+ // -------------------------------------------------------------------------
6049
+ /** Apply crossed deadlines to one pending entry. Returns its post-state. */
6050
+ enforceDeadlines(entry) {
6051
+ const now = this.now();
6052
+ if (now >= entry.evaluationExpiresAtMs) {
6053
+ if (entry.approvalTicketId) {
6054
+ this.approvalRouter?.resolveNativeTicket(entry.approvalTicketId, "timeout");
6055
+ }
6056
+ const auditId = this.writeAudit({
6057
+ timestampIso: entry.timestampIso,
6058
+ origin: entry.origin,
6059
+ agentId: entry.agentId,
6060
+ sessionId: entry.sessionId,
6061
+ toolName: entry.toolName,
6062
+ toolInput: entry.toolInput,
6063
+ metadata: entry.metadata,
6064
+ action: entry.action,
6065
+ wire: entry.action === "require_approval" ? "require_approval" : "allow",
6066
+ matchedRuleName: entry.matchedRuleName,
6067
+ matchedRuleIndex: entry.matchedRuleIndex,
6068
+ flaggedDestructive: entry.flaggedDestructive,
6069
+ dryRun: false,
6070
+ recordKind: "evaluation_expired",
6071
+ sidebandUnreported: true
6072
+ });
6073
+ this.discardPending(entry);
6074
+ this.tombstones.set(entry.evaluationId, {
6075
+ auditRecordId: auditId,
6076
+ payloadHash: null,
6077
+ finalizedBy: "expired",
6078
+ expiresAtMs: now + this.ttlMs
6079
+ });
6080
+ console.error(
6081
+ `[helio] Sideband evaluation ${entry.evaluationId} expired without /audit (origin=${entry.origin}, tool=${entry.toolName}) \u2014 recorded as evaluation_expired`
6082
+ );
6083
+ return "expired";
6084
+ }
6085
+ if (entry.approvalTicketId && entry.ticketTimeoutAtMs !== void 0 && now >= entry.ticketTimeoutAtMs) {
6086
+ this.approvalRouter?.resolveNativeTicket(entry.approvalTicketId, "timeout");
6087
+ }
6088
+ return "active";
6089
+ }
6090
+ cacheFor(origin) {
6091
+ let cache = this.caches.get(origin);
6092
+ if (!cache) {
6093
+ cache = new ToolAnnotationCache();
6094
+ this.caches.set(origin, cache);
6095
+ }
6096
+ return cache;
6097
+ }
6098
+ discardPending(entry) {
6099
+ if (this.pending.delete(entry.evaluationId)) {
6100
+ this.pendingBytes -= entry.bytes;
6101
+ }
6102
+ if (entry.approvalTicketId) this.ticketToEvaluation.delete(entry.approvalTicketId);
6103
+ }
6104
+ getTicketStatus(ticketId) {
6105
+ return this.approvalRouter?.getTicket(ticketId);
6106
+ }
6107
+ planRate(decision, toolName, sessionId, senderId) {
6108
+ const limits = decision.matchedRule?.limits;
6109
+ if (!this.rateLimiter || !limits?.maxCalls || !limits.windowMs) {
6110
+ return { allowed: true };
6111
+ }
6112
+ const key = buildLimitKey(limits.key, toolName, sessionId, senderId);
6113
+ const peek = this.rateLimiter.peek({
6114
+ key,
6115
+ maxCalls: limits.maxCalls,
6116
+ windowMs: limits.windowMs
6117
+ });
6118
+ return {
6119
+ plan: { kind: "rate", key, limits },
6120
+ block: {
6121
+ current: peek.current,
6122
+ limit: peek.limit,
6123
+ window_ms: peek.windowMs,
6124
+ reset_at_ms: peek.resetAtMs
6125
+ },
6126
+ allowed: peek.allowed
6127
+ };
6128
+ }
6129
+ planSpend(decision, toolName, sessionId, args, senderId) {
6130
+ const maxSpend = decision.matchedRule?.limits?.maxSpend;
6131
+ if (!this.spendLimiter || !maxSpend) return { allowed: true };
6132
+ const key = buildLimitKey(maxSpend.key, toolName, sessionId, senderId);
6133
+ const rawAmount = resolvePath(maxSpend.field, args ?? {});
6134
+ if (typeof rawAmount !== "number" || !Number.isFinite(rawAmount) || rawAmount < 0) {
6135
+ return { allowed: false, block: { reason: "invalid_amount", limit: maxSpend.limit } };
6136
+ }
6137
+ const peek = this.spendLimiter.peek({
6138
+ key,
6139
+ amount: rawAmount,
6140
+ limit: maxSpend.limit,
6141
+ windowMs: maxSpend.windowMs
6142
+ });
6143
+ return {
6144
+ plan: {
6145
+ kind: "spend",
6146
+ key,
6147
+ limits: decision.matchedRule.limits,
6148
+ amount: rawAmount,
6149
+ currency: maxSpend.currency
6150
+ },
6151
+ block: {
6152
+ current_spend: peek.currentSpend,
6153
+ limit: peek.limit,
6154
+ currency: maxSpend.currency,
6155
+ window_ms: peek.windowMs,
6156
+ reset_at_ms: peek.resetAtMs
6157
+ },
6158
+ allowed: peek.allowed
6159
+ };
6160
+ }
6161
+ /** Commit a limit plan at /audit time and return the evidence_chain block. */
6162
+ commitLimit(plan, actualAmount) {
6163
+ if (plan.kind === "rate" && this.rateLimiter && plan.limits.maxCalls && plan.limits.windowMs) {
6164
+ const r = this.rateLimiter.record({
6165
+ key: plan.key,
6166
+ maxCalls: plan.limits.maxCalls,
6167
+ windowMs: plan.limits.windowMs
6168
+ });
6169
+ return {
6170
+ rate_limit: {
6171
+ allowed: r.allowed,
6172
+ current: r.current,
6173
+ limit: r.limit,
6174
+ window_ms: r.windowMs,
6175
+ reset_at_ms: r.resetAtMs
6176
+ }
6177
+ };
6178
+ }
6179
+ if (plan.kind === "spend" && this.spendLimiter && plan.limits.maxSpend) {
6180
+ const amount = actualAmount ?? plan.amount ?? 0;
6181
+ const r = this.spendLimiter.record({
6182
+ key: plan.key,
6183
+ amount,
6184
+ limit: plan.limits.maxSpend.limit,
6185
+ windowMs: plan.limits.maxSpend.windowMs
6186
+ });
6187
+ this.spendLimiter.setCurrency(plan.key, plan.limits.maxSpend.currency);
6188
+ return {
6189
+ spend_limit: {
6190
+ allowed: r.allowed,
6191
+ current_spend: r.currentSpend,
6192
+ limit: r.limit,
6193
+ window_ms: r.windowMs,
6194
+ reset_at_ms: r.resetAtMs
6195
+ }
6196
+ };
6197
+ }
6198
+ return void 0;
6199
+ }
6200
+ writeAudit(args) {
6201
+ const id = randomUUID4();
6202
+ if (!this.auditWriter) return id;
6203
+ const blockReason = deriveBlockReason(args);
6204
+ let evidenceChain = args.limitsChain ?? null;
6205
+ if (args.sidebandUnreported) {
6206
+ evidenceChain = { ...evidenceChain ?? {}, sideband: { unreported: true } };
6207
+ }
6208
+ const record = {
6209
+ timestamp: args.timestampIso,
6210
+ session_id: args.sessionId,
6211
+ agent_id: args.agentId,
6212
+ environment: this.environment ?? null,
6213
+ tool_name: args.toolName,
6214
+ tool_input: args.toolInput,
6215
+ policy_decision: args.action,
6216
+ block_reason: blockReason,
6217
+ matched_rule: args.matchedRuleName,
6218
+ matched_rule_index: args.matchedRuleIndex,
6219
+ evidence_chain: evidenceChain,
6220
+ approval_status: args.approvalStatus ?? null,
6221
+ approved_by: args.approvedBy ?? null,
6222
+ upstream_response: args.upstreamResponse ?? null,
6223
+ upstream_error: args.upstreamError ?? null,
6224
+ upstream_http_status: null,
6225
+ upstream_latency_ms: args.upstreamLatencyMs ?? null,
6226
+ total_duration_ms: 0,
6227
+ approval_wait_ms: 0,
6228
+ proxy_compute_ms: 0,
6229
+ flagged_destructive: args.flaggedDestructive,
6230
+ dry_run: args.dryRun,
6231
+ record_kind: args.recordKind,
6232
+ origin: args.origin,
6233
+ metadata: args.metadata
6234
+ };
6235
+ const isEnforcement = args.recordKind === "evaluation_expired" || blockReason !== null || args.approvalStatus != null;
6236
+ if (isEnforcement) this.auditWriter.pushImmediate(record, id);
6237
+ else this.auditWriter.push(record, id);
6238
+ return id;
6239
+ }
6240
+ assertApprovalRouter(policy) {
6241
+ if (!policyCanRequireApproval(policy) || this.approvalRouter) return;
6242
+ throw new GovernanceConfigError(
6243
+ "[helio] GovernanceService misconfiguration: approval-capable policy (a require_approval rule, or flag_destructive/on_tool_drift set to require_approval) requires an approvalRouter"
6244
+ );
6245
+ }
6246
+ };
6247
+ function deriveBlockReason(args) {
6248
+ if (args.recordKind === "evaluation_expired") return null;
6249
+ if (args.recordKind === "install_scan") return args.wire === "deny" ? "install_denied" : null;
6250
+ if (args.dryRun) return null;
6251
+ if (args.approvalStatus === "denied") return "approval_denied";
6252
+ if (args.approvalStatus === "timeout") return "approval_timeout";
6253
+ if (args.approvalStatus === "cancelled") return "cancelled";
6254
+ switch (args.wire) {
6255
+ case "deny":
6256
+ return "policy_denied";
6257
+ case "rate_limited":
6258
+ return "rate_limited";
6259
+ case "spend_limited":
6260
+ return "spend_limited";
6261
+ default:
6262
+ return null;
6263
+ }
6264
+ }
6265
+ function buildFeedback(rule, reason) {
6266
+ const message = rule?.feedback?.message ?? reason;
6267
+ const suggestion = rule?.feedback?.suggestion;
6268
+ return suggestion ? { message, suggestion } : { message };
6269
+ }
6270
+ function isBlocking(wire) {
6271
+ return wire === "deny" || wire === "rate_limited" || wire === "spend_limited";
6272
+ }
6273
+ function isTerminalAtEvaluate(wire) {
6274
+ return wire === "deny" || wire === "rate_limited" || wire === "spend_limited" || wire === "dry_run";
6275
+ }
6276
+ function policyCanRequireApproval(policy) {
6277
+ if (policy.flagDestructive === "require_approval" || policy.onToolDrift === "require_approval") {
6278
+ return true;
6279
+ }
6280
+ return policy.rules.some((rule) => rule.action === "require_approval");
6281
+ }
6282
+ function buildLimitKey(keyType, toolName, sessionId, senderId) {
6283
+ switch (keyType) {
6284
+ case "session":
6285
+ return `session:${sessionId ?? "unknown"}`;
6286
+ case "sender_id":
6287
+ return `sender:${senderId ?? "unknown"}`;
6288
+ case "agent":
6289
+ case "tool":
6290
+ default:
6291
+ return `tool:${toolName}`;
6292
+ }
6293
+ }
6294
+ function senderIdOf(metadata) {
6295
+ const v = metadata?.["sender_id"];
6296
+ return typeof v === "string" ? v : null;
6297
+ }
6298
+ function matchInstallRule(rule, pkg2, metadataView) {
6299
+ if (rule.match.name && !rule.match.name.test(pkg2.name)) return false;
6300
+ if (rule.match.source !== void 0 && rule.match.source !== pkg2.source) return false;
6301
+ if (rule.match.metadata && !matchMetadata(rule.match.metadata, { metadata: metadataView })) {
6302
+ return false;
6303
+ }
6304
+ return true;
6305
+ }
6306
+ function reservedMetadataKey(metadata) {
6307
+ if (metadata && Object.prototype.hasOwnProperty.call(metadata, "agent_id")) {
6308
+ return "agent_id";
6309
+ }
6310
+ return null;
6311
+ }
6312
+ function definitionProvided(tool) {
6313
+ return tool.description !== void 0 || tool.input_schema !== void 0 || tool.output_schema !== void 0 || tool.title !== void 0 || tool.annotations !== void 0;
6314
+ }
6315
+ function toMcpToolDef(tool) {
6316
+ const def = { name: tool.name };
6317
+ if (tool.description !== void 0) def["description"] = tool.description;
6318
+ if (tool.input_schema !== void 0) def["inputSchema"] = tool.input_schema;
6319
+ if (tool.output_schema !== void 0) def["outputSchema"] = tool.output_schema;
6320
+ if (tool.title !== void 0) def["title"] = tool.title;
6321
+ if (tool.annotations !== void 0) def["annotations"] = tool.annotations;
6322
+ return def;
6323
+ }
6324
+ function byteLength(value) {
6325
+ return Buffer.byteLength(canonicalize(value), "utf8");
6326
+ }
6327
+
6328
+ // src/approval/queue.ts
6329
+ import { randomUUID as randomUUID5 } from "crypto";
4986
6330
  var ApprovalQueue = class {
4987
6331
  tickets = /* @__PURE__ */ new Map();
4988
6332
  now;
@@ -5012,7 +6356,7 @@ var ApprovalQueue = class {
5012
6356
  if (this.closed) throw new Error("ApprovalQueue is closed");
5013
6357
  const now = this.now();
5014
6358
  const ticket = {
5015
- id: randomUUID4(),
6359
+ id: randomUUID5(),
5016
6360
  tool_name: params.tool_name,
5017
6361
  tool_input: params.tool_input,
5018
6362
  matched_rule: params.matched_rule,
@@ -5091,6 +6435,7 @@ var ApprovalQueue = class {
5091
6435
  };
5092
6436
 
5093
6437
  // src/approval/router.ts
6438
+ var NATIVE_CHANNEL_PREFIX = "native:";
5094
6439
  var ApprovalRouter = class {
5095
6440
  defaultTimeoutMs;
5096
6441
  defaultOnTimeout;
@@ -5203,6 +6548,59 @@ var ApprovalRouter = class {
5203
6548
  });
5204
6549
  return outcome;
5205
6550
  }
6551
+ /**
6552
+ * Create a native (adapter-owned) approval ticket without holding a Promise.
6553
+ *
6554
+ * Used by the sideband governance path (issue #12, D10): when `/evaluate`
6555
+ * yields `require_approval`, the adapter runs the approval in its own UI
6556
+ * (e.g. OpenClaw's Telegram dialog), so Helio must NOT block, start
6557
+ * timeout/escalation timers, or notify a channel — doing so would
6558
+ * double-notify. We still create the queue ticket and fire `onSubmit` so the
6559
+ * dashboard's `approval_requested` SSE event flows and the ticket is visible.
6560
+ *
6561
+ * The ticket's `channel_name` is `native:<origin>`, which marks it as
6562
+ * adapter-owned: the dashboard approve/deny endpoints refuse it (it can only
6563
+ * be resolved through the adapter), and {@link resolveNativeTicket} is the
6564
+ * resolution path.
6565
+ */
6566
+ createNativeTicket(params) {
6567
+ const rule = params.matched_rule;
6568
+ const timeoutMs = params.timeout_ms ?? rule?.approval?.timeoutMs ?? this.defaultTimeoutMs;
6569
+ const ticket = this.queue.add({
6570
+ tool_name: params.tool_name,
6571
+ tool_input: params.tool_input,
6572
+ matched_rule: rule?.name ?? null,
6573
+ rule_index: rule?.index ?? null,
6574
+ channel_name: `${NATIVE_CHANNEL_PREFIX}${params.origin}`,
6575
+ session_id: params.session_id,
6576
+ timeout_ms: timeoutMs
6577
+ });
6578
+ this.onSubmit?.(ticket);
6579
+ return ticket;
6580
+ }
6581
+ /**
6582
+ * Resolve a native ticket created by {@link createNativeTicket}.
6583
+ *
6584
+ * Resolves the queue ticket and fires `onResolve` (→ `approval_resolved`
6585
+ * SSE), with no held Promise to settle. Refuses tickets that have a pending
6586
+ * router Promise (those are MCP-path tickets; resolving them here would leave
6587
+ * the held request hanging) and tickets that are not `native:`-prefixed.
6588
+ *
6589
+ * @returns `true` if resolved, `false` if not found, already resolved, not a
6590
+ * native ticket, or router-managed.
6591
+ */
6592
+ resolveNativeTicket(ticketId, status, resolvedBy, options) {
6593
+ if (this.pending.has(ticketId)) return false;
6594
+ const ticket = this.queue.get(ticketId);
6595
+ if (!ticket || !ticket.channel_name.startsWith(NATIVE_CHANNEL_PREFIX)) return false;
6596
+ const resolved = this.queue.resolve(ticketId, status, resolvedBy, {
6597
+ denial_reason: options?.denial_reason
6598
+ });
6599
+ if (!resolved) return false;
6600
+ const updated = this.queue.get(ticketId);
6601
+ if (updated) this.onResolve?.(updated);
6602
+ return true;
6603
+ }
5206
6604
  /**
5207
6605
  * Approve a pending ticket. Resolves the held Promise so the governed
5208
6606
  * forwarder can forward the request upstream.
@@ -5247,6 +6645,10 @@ var ApprovalRouter = class {
5247
6645
  ticketId
5248
6646
  });
5249
6647
  }
6648
+ /** Look up a ticket by id (delegates to the queue). */
6649
+ getTicket(ticketId) {
6650
+ return this.queue.get(ticketId);
6651
+ }
5250
6652
  /** Clean up all pending timers and resolve all pending promises. */
5251
6653
  close() {
5252
6654
  this.closed = true;
@@ -5509,8 +6911,8 @@ function createChannels(channels) {
5509
6911
 
5510
6912
  // src/approval/slack-actions.ts
5511
6913
  import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
5512
- import { Hono as Hono5 } from "hono";
5513
- import { z as z5 } from "zod";
6914
+ import { Hono as Hono6 } from "hono";
6915
+ import { z as z6 } from "zod";
5514
6916
  var MAX_TIMESTAMP_AGE_S = 300;
5515
6917
  var REJECTION_LOG_WINDOW_MS = 6e4;
5516
6918
  var REJECTION_LOG_SAMPLE_EVERY = 25;
@@ -5576,12 +6978,12 @@ function verifySlackSignature(secrets, timestamp, rawBody, signature) {
5576
6978
  }
5577
6979
  return false;
5578
6980
  }
5579
- var slackActionPayloadSchema = z5.object({
5580
- type: z5.string(),
5581
- user: z5.object({ id: z5.string(), username: z5.string() }),
5582
- actions: z5.array(z5.object({ action_id: z5.string() })),
5583
- channel: z5.object({ id: z5.string() }),
5584
- message: z5.object({ ts: z5.string() })
6981
+ var slackActionPayloadSchema = z6.object({
6982
+ type: z6.string(),
6983
+ user: z6.object({ id: z6.string(), username: z6.string() }),
6984
+ actions: z6.array(z6.object({ action_id: z6.string() })),
6985
+ channel: z6.object({ id: z6.string() }),
6986
+ message: z6.object({ ts: z6.string() })
5585
6987
  });
5586
6988
  function parseActionPayload(rawBody) {
5587
6989
  try {
@@ -5611,7 +7013,7 @@ Ticket \`${ticketId}\``
5611
7013
  }
5612
7014
  function createSlackActionApp(options) {
5613
7015
  const { router, channels } = options;
5614
- const app = new Hono5();
7016
+ const app = new Hono6();
5615
7017
  const rejectionLogBuckets = /* @__PURE__ */ new Map();
5616
7018
  const rejectUnauthorized = (c, reason, context) => {
5617
7019
  logRejectedSlackCallback(rejectionLogBuckets, {
@@ -5710,18 +7112,18 @@ function createSlackActionApp(options) {
5710
7112
  }
5711
7113
 
5712
7114
  // src/approval/api.ts
5713
- import { Hono as Hono6 } from "hono";
5714
- import { z as z6 } from "zod";
5715
- var approveBody = z6.object({
5716
- approved_by: z6.string().min(1)
7115
+ import { Hono as Hono7 } from "hono";
7116
+ import { z as z7 } from "zod";
7117
+ var approveBody = z7.object({
7118
+ approved_by: z7.string().min(1)
5717
7119
  });
5718
- var denyBody = z6.object({
5719
- denied_by: z6.string().min(1),
5720
- reason: z6.string().optional()
7120
+ var denyBody = z7.object({
7121
+ denied_by: z7.string().min(1),
7122
+ reason: z7.string().optional()
5721
7123
  });
5722
- var breakGlassBody = z6.object({
5723
- approved_by: z6.string().min(1),
5724
- reason: z6.string().min(1)
7124
+ var breakGlassBody = z7.object({
7125
+ approved_by: z7.string().min(1),
7126
+ reason: z7.string().min(1)
5725
7127
  });
5726
7128
  var APPROVAL_STATUSES = [
5727
7129
  "pending",
@@ -5730,25 +7132,26 @@ var APPROVAL_STATUSES = [
5730
7132
  "timeout",
5731
7133
  "break_glass",
5732
7134
  "client_disconnected",
5733
- "shutdown_cancelled"
7135
+ "shutdown_cancelled",
7136
+ "cancelled"
5734
7137
  ];
5735
7138
  var approvalStatusSet = new Set(APPROVAL_STATUSES);
5736
- var listApprovalsQuery = z6.object({
5737
- status: z6.preprocess(
7139
+ var listApprovalsQuery = z7.object({
7140
+ status: z7.preprocess(
5738
7141
  (value) => typeof value === "string" && approvalStatusSet.has(value) ? value : void 0,
5739
- z6.enum(APPROVAL_STATUSES).optional()
7142
+ z7.enum(APPROVAL_STATUSES).optional()
5740
7143
  ),
5741
- limit: z6.preprocess(
7144
+ limit: z7.preprocess(
5742
7145
  (value) => clampInt(typeof value === "string" ? value : void 0, 50, 1, 1e3),
5743
- z6.number().int()
7146
+ z7.number().int()
5744
7147
  ),
5745
- offset: z6.preprocess(
7148
+ offset: z7.preprocess(
5746
7149
  (value) => clampInt(typeof value === "string" ? value : void 0, 0, 0, Number.MAX_SAFE_INTEGER),
5747
- z6.number().int()
7150
+ z7.number().int()
5748
7151
  )
5749
7152
  });
5750
7153
  function createApprovalApp(router, queue, options) {
5751
- const app = new Hono6();
7154
+ const app = new Hono7();
5752
7155
  const apiSecret = options?.apiSecret;
5753
7156
  if (apiSecret) {
5754
7157
  app.use("*", async (c, next) => {
@@ -5794,6 +7197,15 @@ function createApprovalApp(router, queue, options) {
5794
7197
  if (!ticket) {
5795
7198
  return c.json({ error: "Ticket not found" }, 404);
5796
7199
  }
7200
+ if (ticket.channel_name.startsWith(NATIVE_CHANNEL_PREFIX)) {
7201
+ return c.json(
7202
+ {
7203
+ error: "native_ticket",
7204
+ resolve_in: ticket.channel_name.slice(NATIVE_CHANNEL_PREFIX.length)
7205
+ },
7206
+ 409
7207
+ );
7208
+ }
5797
7209
  if (ticket.status !== "pending") {
5798
7210
  return c.json({ error: "Ticket already resolved", status: ticket.status }, 409);
5799
7211
  }
@@ -5819,6 +7231,15 @@ function createApprovalApp(router, queue, options) {
5819
7231
  if (!ticket) {
5820
7232
  return c.json({ error: "Ticket not found" }, 404);
5821
7233
  }
7234
+ if (ticket.channel_name.startsWith(NATIVE_CHANNEL_PREFIX)) {
7235
+ return c.json(
7236
+ {
7237
+ error: "native_ticket",
7238
+ resolve_in: ticket.channel_name.slice(NATIVE_CHANNEL_PREFIX.length)
7239
+ },
7240
+ 409
7241
+ );
7242
+ }
5822
7243
  if (ticket.status !== "pending") {
5823
7244
  return c.json({ error: "Ticket already resolved", status: ticket.status }, 409);
5824
7245
  }
@@ -5844,6 +7265,15 @@ function createApprovalApp(router, queue, options) {
5844
7265
  if (!ticket) {
5845
7266
  return c.json({ error: "Ticket not found" }, 404);
5846
7267
  }
7268
+ if (ticket.channel_name.startsWith(NATIVE_CHANNEL_PREFIX)) {
7269
+ return c.json(
7270
+ {
7271
+ error: "native_ticket",
7272
+ resolve_in: ticket.channel_name.slice(NATIVE_CHANNEL_PREFIX.length)
7273
+ },
7274
+ 409
7275
+ );
7276
+ }
5847
7277
  if (ticket.status !== "pending") {
5848
7278
  return c.json({ error: "Ticket already resolved", status: ticket.status }, 409);
5849
7279
  }
@@ -5859,9 +7289,9 @@ function createApprovalApp(router, queue, options) {
5859
7289
  // src/dashboard/api.ts
5860
7290
  import { readFileSync } from "fs";
5861
7291
  import { join } from "path";
5862
- import { randomUUID as randomUUID5 } from "crypto";
5863
- import { Hono as Hono7 } from "hono";
5864
- import { z as z7 } from "zod";
7292
+ import { randomUUID as randomUUID6 } from "crypto";
7293
+ import { Hono as Hono8 } from "hono";
7294
+ import { z as z8 } from "zod";
5865
7295
  import { cors } from "hono/cors";
5866
7296
  import { serveStatic } from "@hono/node-server/serve-static";
5867
7297
  import { streamSSE } from "hono/streaming";
@@ -5922,7 +7352,7 @@ function recordsToCsv(records) {
5922
7352
  }
5923
7353
 
5924
7354
  // src/dashboard/session.ts
5925
- import { createHash as createHash2, createHmac as createHmac3, randomBytes, timingSafeEqual as timingSafeEqual3 } from "crypto";
7355
+ import { createHash as createHash3, createHmac as createHmac3, randomBytes, timingSafeEqual as timingSafeEqual3 } from "crypto";
5926
7356
  var DashboardSessionStore = class {
5927
7357
  secret;
5928
7358
  ttlMs;
@@ -6003,8 +7433,8 @@ var DashboardSessionStore = class {
6003
7433
  const id = token.slice(0, dot);
6004
7434
  const signature = token.slice(dot + 1);
6005
7435
  const expected = this.sign(id);
6006
- const actualDigest = createHash2("sha256").update(signature).digest();
6007
- const expectedDigest = createHash2("sha256").update(expected).digest();
7436
+ const actualDigest = createHash3("sha256").update(signature).digest();
7437
+ const expectedDigest = createHash3("sha256").update(expected).digest();
6008
7438
  if (!timingSafeEqual3(actualDigest, expectedDigest)) return void 0;
6009
7439
  return id;
6010
7440
  }
@@ -6014,29 +7444,29 @@ var DashboardSessionStore = class {
6014
7444
  };
6015
7445
 
6016
7446
  // src/dashboard/api.ts
6017
- var optionalQueryString = z7.preprocess(
7447
+ var optionalQueryString = z8.preprocess(
6018
7448
  (value) => typeof value === "string" && value.length > 0 ? value : void 0,
6019
- z7.string().optional()
7449
+ z8.string().optional()
6020
7450
  );
6021
- var optionalQueryInt = z7.preprocess((value) => {
7451
+ var optionalQueryInt = z8.preprocess((value) => {
6022
7452
  if (typeof value !== "string" || value.length === 0) return void 0;
6023
7453
  const parsed = Number.parseInt(value, 10);
6024
7454
  return Number.isFinite(parsed) ? parsed : void 0;
6025
- }, z7.number().int().optional());
6026
- var queryBoolean = z7.preprocess(
7455
+ }, z8.number().int().optional());
7456
+ var queryBoolean = z8.preprocess(
6027
7457
  (value) => value === "true" ? true : value === "false" ? false : void 0,
6028
- z7.boolean().optional()
7458
+ z8.boolean().optional()
6029
7459
  );
6030
- var clampedQueryInt = (fallback, min, max) => z7.preprocess(
7460
+ var clampedQueryInt = (fallback, min, max) => z8.preprocess(
6031
7461
  (value) => clampInt(typeof value === "string" ? value : void 0, fallback, min, max),
6032
- z7.number().int()
7462
+ z8.number().int()
6033
7463
  );
6034
- var feedQuerySchema = z7.object({
7464
+ var feedQuerySchema = z8.object({
6035
7465
  limit: clampedQueryInt(50, 1, 200),
6036
7466
  offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER)
6037
7467
  });
6038
- var auditExportQuerySchema = z7.object({
6039
- format: z7.preprocess((value) => value === "csv" ? "csv" : "json", z7.enum(["json", "csv"])),
7468
+ var auditExportQuerySchema = z8.object({
7469
+ format: z8.preprocess((value) => value === "csv" ? "csv" : "json", z8.enum(["json", "csv"])),
6040
7470
  limit: clampedQueryInt(1e4, 1, 1e4),
6041
7471
  tool: optionalQueryString,
6042
7472
  decision: optionalQueryString,
@@ -6048,9 +7478,13 @@ var auditExportQuerySchema = z7.object({
6048
7478
  from: optionalQueryString,
6049
7479
  to: optionalQueryString,
6050
7480
  upstream_status_min: optionalQueryInt,
6051
- upstream_status_max: optionalQueryInt
7481
+ upstream_status_max: optionalQueryInt,
7482
+ origin: optionalQueryString,
7483
+ record_kind: optionalQueryString,
7484
+ channel_id: optionalQueryString,
7485
+ sender_id: optionalQueryString
6052
7486
  });
6053
- var auditQuerySchema = z7.object({
7487
+ var auditQuerySchema = z8.object({
6054
7488
  limit: clampedQueryInt(50, 1, 1e3),
6055
7489
  offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER),
6056
7490
  tool: optionalQueryString,
@@ -6064,14 +7498,18 @@ var auditQuerySchema = z7.object({
6064
7498
  destructive: queryBoolean,
6065
7499
  dry_run: queryBoolean,
6066
7500
  upstream_status_min: optionalQueryInt,
6067
- upstream_status_max: optionalQueryInt
7501
+ upstream_status_max: optionalQueryInt,
7502
+ origin: optionalQueryString,
7503
+ record_kind: optionalQueryString,
7504
+ channel_id: optionalQueryString,
7505
+ sender_id: optionalQueryString
6068
7506
  });
6069
- var analyticsQuerySchema = z7.object({
7507
+ var analyticsQuerySchema = z8.object({
6070
7508
  from: optionalQueryString,
6071
7509
  to: optionalQueryString
6072
7510
  });
6073
- var authSessionBodySchema = z7.object({
6074
- secret: z7.string()
7511
+ var authSessionBodySchema = z8.object({
7512
+ secret: z8.string()
6075
7513
  });
6076
7514
  var SESSION_COOKIE = "helio_session";
6077
7515
  var SESSION_TTL_MS = 8 * 60 * 60 * 1e3;
@@ -6131,7 +7569,7 @@ function createDashboardAppWithLifecycle(deps, options) {
6131
7569
  } = deps;
6132
7570
  const apiSecret = options?.apiSecret;
6133
7571
  const sessionStore = apiSecret ? new DashboardSessionStore({ secret: apiSecret, ttlMs: SESSION_TTL_MS }) : void 0;
6134
- const app = new Hono7();
7572
+ const app = new Hono8();
6135
7573
  app.use(
6136
7574
  "*",
6137
7575
  cors({
@@ -6271,7 +7709,11 @@ function createDashboardAppWithLifecycle(deps, options) {
6271
7709
  from: query.from,
6272
7710
  to: query.to,
6273
7711
  upstream_status_min: query.upstream_status_min,
6274
- upstream_status_max: query.upstream_status_max
7712
+ upstream_status_max: query.upstream_status_max,
7713
+ origin: query.origin,
7714
+ record_kind: query.record_kind,
7715
+ channel_id: query.channel_id,
7716
+ sender_id: query.sender_id
6275
7717
  };
6276
7718
  const result = auditStore.list(filters, { limit, order: "asc" });
6277
7719
  if (format === "csv") {
@@ -6313,7 +7755,11 @@ function createDashboardAppWithLifecycle(deps, options) {
6313
7755
  flagged_destructive: query.destructive,
6314
7756
  dry_run: query.dry_run,
6315
7757
  upstream_status_min: query.upstream_status_min,
6316
- upstream_status_max: query.upstream_status_max
7758
+ upstream_status_max: query.upstream_status_max,
7759
+ origin: query.origin,
7760
+ record_kind: query.record_kind,
7761
+ channel_id: query.channel_id,
7762
+ sender_id: query.sender_id
6317
7763
  };
6318
7764
  const result = auditStore.list(filters, { limit, offset, order: "desc" });
6319
7765
  return c.json({
@@ -6374,7 +7820,7 @@ function createDashboardAppWithLifecycle(deps, options) {
6374
7820
  app.get("/api/events", (c) => {
6375
7821
  return streamSSE(c, async (stream) => {
6376
7822
  if (closed) return;
6377
- const connId = randomUUID5();
7823
+ const connId = randomUUID6();
6378
7824
  let streamClosed = false;
6379
7825
  let stopHeartbeat = () => {
6380
7826
  };
@@ -6403,7 +7849,7 @@ function createDashboardAppWithLifecycle(deps, options) {
6403
7849
  void stream.writeSSE({
6404
7850
  event: eventType,
6405
7851
  data: JSON.stringify(data),
6406
- id: randomUUID5()
7852
+ id: randomUUID6()
6407
7853
  }).then(() => {
6408
7854
  const conn = activeConnections.get(connId);
6409
7855
  if (conn) conn.lastWrite = Date.now();
@@ -6774,7 +8220,9 @@ async function startCommand(configPath, options) {
6774
8220
  flagged_destructive: record.flagged_destructive,
6775
8221
  dry_run: record.dry_run,
6776
8222
  matched_rule: record.matched_rule,
6777
- matched_rule_index: record.matched_rule_index
8223
+ matched_rule_index: record.matched_rule_index,
8224
+ record_kind: record.record_kind,
8225
+ origin: record.origin
6778
8226
  });
6779
8227
  }
6780
8228
  });
@@ -6853,6 +8301,8 @@ async function startCommand(configPath, options) {
6853
8301
  let sidebandHandle;
6854
8302
  let sidebandToken;
6855
8303
  let sidebandTokenSource;
8304
+ let adapterToken;
8305
+ let governanceService;
6856
8306
  if (config.sdk.enabled) {
6857
8307
  sidebandToken = process.env["HELIO_SDK_TOKEN"];
6858
8308
  if (!sidebandToken || sidebandToken.length === 0) {
@@ -6862,7 +8312,27 @@ async function startCommand(configPath, options) {
6862
8312
  } else {
6863
8313
  sidebandTokenSource = "env";
6864
8314
  }
6865
- const sidebandApp = createSidebandApp(evidenceStore, { token: sidebandToken });
8315
+ adapterToken = process.env["HELIO_ADAPTER_TOKEN"];
8316
+ if (!adapterToken || adapterToken.length === 0) {
8317
+ adapterToken = randomBytes2(32).toString("hex");
8318
+ process.env["HELIO_ADAPTER_TOKEN"] = adapterToken;
8319
+ }
8320
+ governanceService = new GovernanceService({
8321
+ policy,
8322
+ environment: config.environment,
8323
+ evidenceStore,
8324
+ approvalRouter,
8325
+ rateLimiter,
8326
+ spendLimiter,
8327
+ auditWriter,
8328
+ approvalTimeoutMs: parseDuration(config.approval.timeout),
8329
+ ttlMs: parseDuration(config.sdk.evaluation_ttl)
8330
+ });
8331
+ const sidebandApp = createSidebandApp(evidenceStore, {
8332
+ token: sidebandToken,
8333
+ adapterToken,
8334
+ governance: governanceService
8335
+ });
6866
8336
  sidebandHandle = startSidebandServer(sidebandApp, config.sdk.port, config.sdk.host);
6867
8337
  }
6868
8338
  let dashboardHandle;
@@ -6913,6 +8383,12 @@ async function startCommand(configPath, options) {
6913
8383
  ${sidebandToken}`
6914
8384
  );
6915
8385
  }
8386
+ if (adapterToken) {
8387
+ console.error(
8388
+ `Adapter token (governance routes; pass as HELIO_ADAPTER_TOKEN to your adapter):
8389
+ ${adapterToken}`
8390
+ );
8391
+ }
6916
8392
  }
6917
8393
  if (dashboardHandle) {
6918
8394
  console.error(
@@ -6940,6 +8416,7 @@ async function startCommand(configPath, options) {
6940
8416
  initialConfig: config,
6941
8417
  onPolicyReload: (newPolicy, reloadWarnings, restartRequiredPaths) => {
6942
8418
  governedForwarder.updatePolicy(newPolicy);
8419
+ governanceService?.updatePolicy(newPolicy);
6943
8420
  const count = newPolicy.rules.length;
6944
8421
  console.error(
6945
8422
  `[helio] Policy reloaded: ${String(count)} rule${count !== 1 ? "s" : ""} (default: ${newPolicy.defaultAction})`
@@ -6983,7 +8460,8 @@ async function startCommand(configPath, options) {
6983
8460
  spendLimiter,
6984
8461
  closeDashboardApp,
6985
8462
  dashboardHandle,
6986
- eventBus
8463
+ eventBus,
8464
+ governanceService
6987
8465
  );
6988
8466
  }
6989
8467
  async function initCommand(outputPath, force) {
@@ -7091,7 +8569,7 @@ function writeCsv(records) {
7091
8569
  console.log(values.join(","));
7092
8570
  }
7093
8571
  }
7094
- function registerShutdown(handle, annotationPrime, closeForwarder, auditWriter, configWatcher, sidebandHandle, evidenceStore, approvalRouter, approvalQueue, rateLimiter, spendLimiter, closeDashboardApp, dashboardHandle, eventBus) {
8572
+ function registerShutdown(handle, annotationPrime, closeForwarder, auditWriter, configWatcher, sidebandHandle, evidenceStore, approvalRouter, approvalQueue, rateLimiter, spendLimiter, closeDashboardApp, dashboardHandle, eventBus, governanceService) {
7095
8573
  let isShuttingDown = false;
7096
8574
  const shutdown = () => {
7097
8575
  if (isShuttingDown) return;
@@ -7107,6 +8585,7 @@ function registerShutdown(handle, annotationPrime, closeForwarder, auditWriter,
7107
8585
  if (configWatcher) configWatcher.close();
7108
8586
  if (closeDashboardApp) closeDashboardApp();
7109
8587
  if (eventBus) eventBus.close();
8588
+ if (governanceService) governanceService.close();
7110
8589
  if (rateLimiter) rateLimiter.close();
7111
8590
  if (spendLimiter) spendLimiter.close();
7112
8591
  if (approvalRouter) approvalRouter.close();