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