@gethelio/proxy 0.9.0 → 0.10.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/README.md +16 -15
- package/dist/cli.js +2871 -743
- package/dist/dashboard-assets/assets/index-Ba99PYDi.js +128 -0
- package/dist/dashboard-assets/assets/index-DNjpdKac.css +1 -0
- package/dist/dashboard-assets/index.html +2 -2
- package/dist/index.d.ts +618 -6
- package/dist/index.js +4183 -2498
- package/package.json +1 -1
- package/dist/dashboard-assets/assets/index-0ylAcvX3.js +0 -128
- package/dist/dashboard-assets/assets/index-DZKoV0Vx.css +0 -1
package/dist/cli.js
CHANGED
|
@@ -142,9 +142,9 @@ var policyActionSchema = z.enum([
|
|
|
142
142
|
"dry_run"
|
|
143
143
|
]);
|
|
144
144
|
var ruleApprovalSchema = z.object({
|
|
145
|
-
channel: z.string(),
|
|
145
|
+
channel: z.string().min(1),
|
|
146
146
|
timeout: durationSchema.optional(),
|
|
147
|
-
delegates: z.array(z.string()).optional(),
|
|
147
|
+
delegates: z.array(z.string().min(1)).optional(),
|
|
148
148
|
escalation_after: durationSchema.optional()
|
|
149
149
|
}).strict();
|
|
150
150
|
var evidenceSchema = z.object({
|
|
@@ -226,22 +226,80 @@ var policiesSchema = z.object({
|
|
|
226
226
|
*/
|
|
227
227
|
hot_reload: z.boolean().optional()
|
|
228
228
|
}).strict();
|
|
229
|
+
var budgetContributorSchema = z.object({
|
|
230
|
+
tool: z.string().min(1),
|
|
231
|
+
// picomatch glob, same engine as match.tool
|
|
232
|
+
field: z.string().min(1)
|
|
233
|
+
// dot-path into tool arguments, e.g. "$.amount"
|
|
234
|
+
}).strict();
|
|
235
|
+
var budgetSchema = z.object({
|
|
236
|
+
// The name is embedded in bucket keys (`budget:<name>:<scope>`) and, later,
|
|
237
|
+
// ledger rows — constrain it so keys stay parseable and scope classification
|
|
238
|
+
// (e.g. the sender-key cardinality guard) cannot be confused by delimiters.
|
|
239
|
+
name: z.string().min(1).max(64).regex(/^[a-zA-Z0-9_-]+$/, {
|
|
240
|
+
message: 'Budget names may only contain letters, digits, "_" and "-"'
|
|
241
|
+
}),
|
|
242
|
+
limit: z.number().positive(),
|
|
243
|
+
currency: z.string().min(1),
|
|
244
|
+
/** A sliding duration ("1h", "7d") or "session" (a depleting pot per session key). */
|
|
245
|
+
window: z.union([durationSchema, z.literal("session")]),
|
|
246
|
+
key: z.enum(["global", "session", "sender_id"]).default("global"),
|
|
247
|
+
/**
|
|
248
|
+
* What a breach does: `deny` blocks the call outright; `require_approval`
|
|
249
|
+
* raises one composite break-glass ticket per call listing every breached
|
|
250
|
+
* budget, and the call proceeds only on an explicit approval.
|
|
251
|
+
*/
|
|
252
|
+
on_exceed: z.enum(["deny", "require_approval"]).default("deny"),
|
|
253
|
+
/**
|
|
254
|
+
* Break-glass ticket routing (same shape as rule-level `approval`). Only
|
|
255
|
+
* valid with `on_exceed: require_approval`; when omitted, tickets fall
|
|
256
|
+
* back to the dashboard channel and the global `approval.timeout`. Note
|
|
257
|
+
* that `default_on_timeout` never applies to budget tickets — they fail
|
|
258
|
+
* closed on timeout regardless (money gates do not fail open).
|
|
259
|
+
*/
|
|
260
|
+
approval: ruleApprovalSchema.optional(),
|
|
261
|
+
/** Session windows only: idle time before a session pot is collected. Default 24h. */
|
|
262
|
+
idle_ttl: durationSchema.optional(),
|
|
263
|
+
contributors: z.array(budgetContributorSchema).min(1)
|
|
264
|
+
}).strict().superRefine((budget, ctx) => {
|
|
265
|
+
if (budget.approval !== void 0 && budget.on_exceed !== "require_approval") {
|
|
266
|
+
ctx.addIssue({
|
|
267
|
+
code: "custom",
|
|
268
|
+
path: ["approval"],
|
|
269
|
+
message: 'budget approval config only applies with on_exceed: "require_approval" \u2014 with on_exceed: "deny" it is dead config. Remove it or switch on_exceed.'
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
if (budget.window === "session" && budget.key === "global") {
|
|
273
|
+
ctx.addIssue({
|
|
274
|
+
code: "custom",
|
|
275
|
+
path: ["key"],
|
|
276
|
+
message: 'window: "session" requires key: "session" or "sender_id" \u2014 a global bucket with session lifetime never replenishes and never ends. Pick a per-session or per-sender scope, or use a duration window.'
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
if (budget.window !== "session" && budget.idle_ttl !== void 0) {
|
|
280
|
+
ctx.addIssue({
|
|
281
|
+
code: "custom",
|
|
282
|
+
path: ["idle_ttl"],
|
|
283
|
+
message: 'idle_ttl only applies to window: "session" budgets. Duration windows expire entries on their own; remove idle_ttl.'
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
});
|
|
229
287
|
var slackChannelSchema = z.object({
|
|
230
288
|
type: z.literal("slack"),
|
|
231
|
-
name: z.string().optional(),
|
|
289
|
+
name: z.string().min(1).optional(),
|
|
232
290
|
bot_token: z.string(),
|
|
233
291
|
signing_secret: z.string(),
|
|
234
292
|
channel: z.string()
|
|
235
293
|
});
|
|
236
294
|
var webhookChannelSchema = z.object({
|
|
237
295
|
type: z.literal("webhook"),
|
|
238
|
-
name: z.string().optional(),
|
|
296
|
+
name: z.string().min(1).optional(),
|
|
239
297
|
url: z.string(),
|
|
240
298
|
secret: z.string().optional()
|
|
241
299
|
});
|
|
242
300
|
var dashboardChannelSchema = z.object({
|
|
243
301
|
type: z.literal("dashboard"),
|
|
244
|
-
name: z.string().optional()
|
|
302
|
+
name: z.string().min(1).optional()
|
|
245
303
|
});
|
|
246
304
|
var approvalChannelSchema = z.discriminatedUnion("type", [
|
|
247
305
|
slackChannelSchema,
|
|
@@ -278,20 +336,23 @@ var helioConfigBaseSchema = z.object({
|
|
|
278
336
|
dashboard: dashboardSchema.prefault({}),
|
|
279
337
|
environment: z.string().optional(),
|
|
280
338
|
policies: policiesSchema.prefault({}),
|
|
339
|
+
// Budgets sit beside policies deliberately: they are the second half of the
|
|
340
|
+
// governance declaration (policy decision → budget gate), not plumbing.
|
|
341
|
+
budgets: z.array(budgetSchema).default([]),
|
|
281
342
|
approval: approvalSchema.prefault({}),
|
|
282
343
|
audit: auditSchema.prefault({}),
|
|
283
344
|
sdk: sdkSchema.prefault({})
|
|
284
345
|
});
|
|
285
346
|
var helioConfigSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
|
|
286
347
|
const hasConfiguredEnvironment = typeof cfg.environment === "string" && cfg.environment.trim().length > 0;
|
|
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");
|
|
348
|
+
const requiresSecret = cfg.policies.flag_destructive === "require_approval" || cfg.policies.on_tool_drift === "require_approval" || cfg.policies.rules.some((rule) => rule.action === "require_approval") || cfg.budgets.some((budget) => budget.on_exceed === "require_approval");
|
|
288
349
|
const hasSecret = hasDashboardApiSecret(cfg.dashboard.api_secret);
|
|
289
350
|
if (requiresSecret) {
|
|
290
351
|
if (!hasSecret) {
|
|
291
352
|
ctx.addIssue({
|
|
292
353
|
code: "custom",
|
|
293
354
|
path: ["dashboard", "api_secret"],
|
|
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.)'
|
|
355
|
+
message: 'dashboard.api_secret is required when any rule uses require_approval, any budget uses on_exceed: 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.)'
|
|
295
356
|
});
|
|
296
357
|
}
|
|
297
358
|
}
|
|
@@ -309,6 +370,86 @@ var helioConfigSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
|
|
|
309
370
|
message: "dashboard.host must be a loopback address (127.0.0.1, localhost, or ::1) when dashboard.allow_open_mode is true and dashboard.api_secret is unset."
|
|
310
371
|
});
|
|
311
372
|
}
|
|
373
|
+
const channelTypeByKey = /* @__PURE__ */ new Map([["dashboard", "dashboard"]]);
|
|
374
|
+
for (const [channelIndex, channel] of cfg.approval.channels.entries()) {
|
|
375
|
+
const key = channel.name ?? channel.type;
|
|
376
|
+
if (key === "dashboard" && channel.type !== "dashboard") {
|
|
377
|
+
ctx.addIssue({
|
|
378
|
+
code: "custom",
|
|
379
|
+
path: ["approval", "channels", channelIndex, channel.name ? "name" : "type"],
|
|
380
|
+
message: 'The channel key "dashboard" is reserved for the built-in dashboard channel. Pick a different name.'
|
|
381
|
+
});
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (channelTypeByKey.has(key) && !(key === "dashboard" && channel.type === "dashboard")) {
|
|
385
|
+
ctx.addIssue({
|
|
386
|
+
code: "custom",
|
|
387
|
+
path: ["approval", "channels", channelIndex, channel.name ? "name" : "type"],
|
|
388
|
+
message: `Duplicate approval channel key "${key}". Channels register under name ?? type \u2014 give each channel a unique name.`
|
|
389
|
+
});
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
channelTypeByKey.set(key, channel.type);
|
|
393
|
+
}
|
|
394
|
+
const knownChannelKeys = new Set(channelTypeByKey.keys());
|
|
395
|
+
const resolvesToDashboard = (key) => channelTypeByKey.get(key) === "dashboard";
|
|
396
|
+
const seenBudgetNames = /* @__PURE__ */ new Set();
|
|
397
|
+
for (const [budgetIndex, budget] of cfg.budgets.entries()) {
|
|
398
|
+
if (seenBudgetNames.has(budget.name)) {
|
|
399
|
+
ctx.addIssue({
|
|
400
|
+
code: "custom",
|
|
401
|
+
path: ["budgets", budgetIndex, "name"],
|
|
402
|
+
message: `Duplicate budget name "${budget.name}". Budget names are the identity that preserves accrued spend across config edits \u2014 each budget needs its own.`
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
seenBudgetNames.add(budget.name);
|
|
406
|
+
if (!cfg.sdk.enabled && budget.key === "sender_id") {
|
|
407
|
+
ctx.addIssue({
|
|
408
|
+
code: "custom",
|
|
409
|
+
path: ["budgets", budgetIndex, "key"],
|
|
410
|
+
message: 'budget 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.'
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
const budgetChannel = budget.approval?.channel;
|
|
414
|
+
if (budgetChannel && !knownChannelKeys.has(budgetChannel)) {
|
|
415
|
+
ctx.addIssue({
|
|
416
|
+
code: "custom",
|
|
417
|
+
path: ["budgets", budgetIndex, "approval", "channel"],
|
|
418
|
+
message: `Unknown approval channel "${budgetChannel}". Add it to approval.channels (type or name), or use "dashboard".`
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
for (const [delegateIndex, delegate] of (budget.approval?.delegates ?? []).entries()) {
|
|
422
|
+
if (!knownChannelKeys.has(delegate)) {
|
|
423
|
+
ctx.addIssue({
|
|
424
|
+
code: "custom",
|
|
425
|
+
path: ["budgets", budgetIndex, "approval", "delegates", delegateIndex],
|
|
426
|
+
message: `Unknown delegate channel "${delegate}". Delegates must reference configured approval channel names.`
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if (budget.on_exceed === "require_approval" && !cfg.dashboard.enabled) {
|
|
431
|
+
const effectiveChannel = budget.approval?.channel ?? "dashboard";
|
|
432
|
+
if (resolvesToDashboard(effectiveChannel)) {
|
|
433
|
+
ctx.addIssue({
|
|
434
|
+
code: "custom",
|
|
435
|
+
path: budget.approval?.channel !== void 0 ? ["budgets", budgetIndex, "approval", "channel"] : ["budgets", budgetIndex, "on_exceed"],
|
|
436
|
+
message: "This budget routes break-glass tickets to the dashboard channel, but dashboard.enabled is false \u2014 the ticket could never be resolved and would always time out. Enable the dashboard or route approval.channel to a Slack channel."
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
const escalationAfterMs = budget.approval?.escalation_after !== void 0 ? parseDuration(budget.approval.escalation_after) : void 0;
|
|
440
|
+
const effectiveTimeoutMs = parseDuration(budget.approval?.timeout ?? cfg.approval.timeout);
|
|
441
|
+
const escalationCanFire = escalationAfterMs !== void 0 && escalationAfterMs > 0 && escalationAfterMs < effectiveTimeoutMs;
|
|
442
|
+
for (const [delegateIndex, delegate] of (budget.approval?.delegates ?? []).entries()) {
|
|
443
|
+
if (escalationCanFire && knownChannelKeys.has(delegate) && resolvesToDashboard(delegate)) {
|
|
444
|
+
ctx.addIssue({
|
|
445
|
+
code: "custom",
|
|
446
|
+
path: ["budgets", budgetIndex, "approval", "delegates", delegateIndex],
|
|
447
|
+
message: "This budget escalates break-glass tickets to a dashboard channel, but dashboard.enabled is false \u2014 the delegate could never resolve the ticket. Enable the dashboard or delegate to a Slack channel."
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
312
453
|
const hasWebhookChannel = cfg.approval.channels.some((channel) => channel.type === "webhook");
|
|
313
454
|
if (hasWebhookChannel && !cfg.dashboard.enabled) {
|
|
314
455
|
ctx.addIssue({
|
|
@@ -317,13 +458,6 @@ var helioConfigSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
|
|
|
317
458
|
message: "dashboard.enabled must be true when approval.channels includes a webhook channel. Webhook notifications require the dashboard sideband approval API."
|
|
318
459
|
});
|
|
319
460
|
}
|
|
320
|
-
const knownChannelKeys = /* @__PURE__ */ new Set(["dashboard"]);
|
|
321
|
-
for (const channel of cfg.approval.channels) {
|
|
322
|
-
knownChannelKeys.add(channel.type);
|
|
323
|
-
if (channel.name) {
|
|
324
|
-
knownChannelKeys.add(channel.name);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
461
|
for (const [ruleIndex, rule] of cfg.policies.rules.entries()) {
|
|
328
462
|
if (rule.match.environment !== void 0 && !hasConfiguredEnvironment) {
|
|
329
463
|
ctx.addIssue({
|
|
@@ -499,6 +633,75 @@ function diffReloadBoundary(previous, next) {
|
|
|
499
633
|
}
|
|
500
634
|
return { restartRequiredPaths };
|
|
501
635
|
}
|
|
636
|
+
function findUnroutableApprovalReferences(policy, budgets, surface) {
|
|
637
|
+
const problems = [];
|
|
638
|
+
const known = (key) => surface.channelTypes.has(key);
|
|
639
|
+
const isDashboardKey = (key) => surface.channelTypes.get(key) === "dashboard";
|
|
640
|
+
const escalationCanFire = (approval) => {
|
|
641
|
+
const timeoutMs = approval.timeoutMs ?? surface.defaultApprovalTimeoutMs;
|
|
642
|
+
return approval.escalationAfterMs !== void 0 && approval.escalationAfterMs > 0 && approval.escalationAfterMs < timeoutMs;
|
|
643
|
+
};
|
|
644
|
+
const needsRunningDashboard = (key, label, via) => {
|
|
645
|
+
if (isDashboardKey(key) && !surface.dashboardEnabled) {
|
|
646
|
+
problems.push(
|
|
647
|
+
`${label} ${via} the dashboard channel, but the running process has no dashboard server`
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
for (const rule of policy.rules) {
|
|
652
|
+
if (rule.action !== "require_approval") {
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
if (rule.match.metadata !== void 0) continue;
|
|
656
|
+
const label = rule.name ? `rule "${rule.name}"` : `rule[${String(rule.index)}]`;
|
|
657
|
+
const approval = rule.approval;
|
|
658
|
+
const effectiveChannel = approval?.channel ?? "dashboard";
|
|
659
|
+
if (!known(effectiveChannel)) {
|
|
660
|
+
problems.push(`${label} references approval channel "${effectiveChannel}"`);
|
|
661
|
+
} else {
|
|
662
|
+
needsRunningDashboard(effectiveChannel, label, "routes approvals to");
|
|
663
|
+
}
|
|
664
|
+
if (approval && escalationCanFire(approval)) {
|
|
665
|
+
for (const delegate of approval.delegates ?? []) {
|
|
666
|
+
if (!known(delegate)) {
|
|
667
|
+
problems.push(`${label} references delegate channel "${delegate}"`);
|
|
668
|
+
} else {
|
|
669
|
+
needsRunningDashboard(delegate, label, "escalates approvals to");
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (policy.flagDestructive === "require_approval" && !surface.dashboardEnabled) {
|
|
675
|
+
problems.push(
|
|
676
|
+
"policies.flag_destructive: require_approval routes approvals to the dashboard channel, but the running process has no dashboard server"
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
if (policy.onToolDrift === "require_approval" && !surface.dashboardEnabled) {
|
|
680
|
+
problems.push(
|
|
681
|
+
"policies.on_tool_drift: require_approval routes approvals to the dashboard channel, but the running process has no dashboard server"
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
for (const budget of budgets) {
|
|
685
|
+
if (budget.onExceed !== "require_approval") continue;
|
|
686
|
+
const label = `budget "${budget.name}"`;
|
|
687
|
+
const effectiveChannel = budget.approval?.channel ?? "dashboard";
|
|
688
|
+
if (!known(effectiveChannel)) {
|
|
689
|
+
problems.push(`${label} references approval channel "${effectiveChannel}"`);
|
|
690
|
+
} else {
|
|
691
|
+
needsRunningDashboard(effectiveChannel, label, "routes break-glass tickets to");
|
|
692
|
+
}
|
|
693
|
+
if (budget.approval && escalationCanFire(budget.approval)) {
|
|
694
|
+
for (const delegate of budget.approval.delegates ?? []) {
|
|
695
|
+
if (!known(delegate)) {
|
|
696
|
+
problems.push(`${label} references delegate channel "${delegate}"`);
|
|
697
|
+
} else {
|
|
698
|
+
needsRunningDashboard(delegate, label, "escalates break-glass tickets to");
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return problems;
|
|
704
|
+
}
|
|
502
705
|
|
|
503
706
|
// src/policy/parser.ts
|
|
504
707
|
import picomatch from "picomatch";
|
|
@@ -753,10 +956,52 @@ function checkSemanticWarnings(rule, index, warnings) {
|
|
|
753
956
|
}
|
|
754
957
|
}
|
|
755
958
|
|
|
959
|
+
// src/budget/parser.ts
|
|
960
|
+
import picomatch2 from "picomatch";
|
|
961
|
+
var DEFAULT_IDLE_TTL_MS = 864e5;
|
|
962
|
+
var BudgetParseError = class extends Error {
|
|
963
|
+
budgetName;
|
|
964
|
+
constructor(message, budgetName) {
|
|
965
|
+
super(`Budget "${budgetName}": ${message}`);
|
|
966
|
+
this.name = "BudgetParseError";
|
|
967
|
+
this.budgetName = budgetName;
|
|
968
|
+
}
|
|
969
|
+
};
|
|
970
|
+
function compileBudgets(budgets) {
|
|
971
|
+
return budgets.map((budget) => ({
|
|
972
|
+
name: budget.name,
|
|
973
|
+
limit: budget.limit,
|
|
974
|
+
currency: budget.currency,
|
|
975
|
+
window: budget.window === "session" ? {
|
|
976
|
+
kind: "session",
|
|
977
|
+
idleTtlMs: budget.idle_ttl ? parseDuration(budget.idle_ttl) : DEFAULT_IDLE_TTL_MS
|
|
978
|
+
} : { kind: "duration", windowMs: parseDuration(budget.window) },
|
|
979
|
+
windowRaw: budget.window,
|
|
980
|
+
key: budget.key,
|
|
981
|
+
onExceed: budget.on_exceed,
|
|
982
|
+
...budget.approval !== void 0 && { approval: compileApproval(budget.approval) },
|
|
983
|
+
contributors: budget.contributors.map(
|
|
984
|
+
(contributor) => compileContributor(contributor, budget.name)
|
|
985
|
+
)
|
|
986
|
+
}));
|
|
987
|
+
}
|
|
988
|
+
function compileContributor(contributor, budgetName) {
|
|
989
|
+
try {
|
|
990
|
+
const test = picomatch2(contributor.tool, { dot: true });
|
|
991
|
+
return { tool: { pattern: contributor.tool, test }, field: contributor.field };
|
|
992
|
+
} catch (err) {
|
|
993
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
994
|
+
throw new BudgetParseError(
|
|
995
|
+
`invalid contributor glob "${contributor.tool}": ${message}`,
|
|
996
|
+
budgetName
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
756
1001
|
// src/config/watcher.ts
|
|
757
1002
|
var ConfigWatcher = class {
|
|
758
1003
|
configPath;
|
|
759
|
-
|
|
1004
|
+
onReload;
|
|
760
1005
|
onError;
|
|
761
1006
|
initialConfig;
|
|
762
1007
|
env;
|
|
@@ -765,7 +1010,7 @@ var ConfigWatcher = class {
|
|
|
765
1010
|
debounceTimer = null;
|
|
766
1011
|
constructor(options) {
|
|
767
1012
|
this.configPath = options.configPath;
|
|
768
|
-
this.
|
|
1013
|
+
this.onReload = options.onReload;
|
|
769
1014
|
this.onError = options.onError;
|
|
770
1015
|
this.initialConfig = options.initialConfig;
|
|
771
1016
|
this.env = options.env;
|
|
@@ -807,8 +1052,9 @@ var ConfigWatcher = class {
|
|
|
807
1052
|
try {
|
|
808
1053
|
const config = await loadConfig(this.configPath, this.env);
|
|
809
1054
|
const { policy, warnings } = compilePolicies(config.policies);
|
|
1055
|
+
const budgets = compileBudgets(config.budgets);
|
|
810
1056
|
const restartRequiredPaths = this.initialConfig !== void 0 ? diffReloadBoundary(this.initialConfig, config).restartRequiredPaths : [];
|
|
811
|
-
this.
|
|
1057
|
+
this.onReload(policy, warnings, restartRequiredPaths, budgets);
|
|
812
1058
|
} catch (err) {
|
|
813
1059
|
if (err instanceof Error) {
|
|
814
1060
|
this.onError(err);
|
|
@@ -829,6 +1075,7 @@ import { Hono } from "hono";
|
|
|
829
1075
|
// src/mcp/types.ts
|
|
830
1076
|
var PARSE_ERROR = -32700;
|
|
831
1077
|
var INVALID_REQUEST = -32600;
|
|
1078
|
+
var INVALID_PARAMS = -32602;
|
|
832
1079
|
var INTERNAL_ERROR = -32603;
|
|
833
1080
|
function makeJsonRpcError(id, code, message) {
|
|
834
1081
|
return {
|
|
@@ -2459,6 +2706,9 @@ function evaluatePolicy(policy, ctx) {
|
|
|
2459
2706
|
};
|
|
2460
2707
|
}
|
|
2461
2708
|
|
|
2709
|
+
// src/policy/governed-forwarder.ts
|
|
2710
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
2711
|
+
|
|
2462
2712
|
// src/evidence/grounding.ts
|
|
2463
2713
|
function checkEvidence(store, sessionId, requirements) {
|
|
2464
2714
|
if (requirements.length === 0) {
|
|
@@ -2866,19 +3116,20 @@ function extractTools(body) {
|
|
|
2866
3116
|
|
|
2867
3117
|
// src/feedback/self-repair.ts
|
|
2868
3118
|
function ruleInfo(rule) {
|
|
3119
|
+
const index = rule?.index ?? null;
|
|
2869
3120
|
return {
|
|
2870
3121
|
rule: rule?.name ?? null,
|
|
2871
|
-
ruleIndex:
|
|
3122
|
+
ruleIndex: index,
|
|
3123
|
+
rule_index: index
|
|
2872
3124
|
};
|
|
2873
3125
|
}
|
|
2874
3126
|
function buildPolicyDeniedFeedback(decision) {
|
|
2875
|
-
const
|
|
2876
|
-
const suggestion = decision.matchedRule?.feedback?.suggestion ?? decision.matchedRule?.feedback?.message ?? `This action was denied by policy${rule ? ` (rule: "${rule}")` : ""}. Review the policy configuration or use an allowed tool instead.`;
|
|
3127
|
+
const info = ruleInfo(decision.matchedRule);
|
|
3128
|
+
const suggestion = decision.matchedRule?.feedback?.suggestion ?? decision.matchedRule?.feedback?.message ?? `This action was denied by policy${info.rule ? ` (rule: "${info.rule}")` : ""}. Review the policy configuration or use an allowed tool instead.`;
|
|
2877
3129
|
return {
|
|
2878
3130
|
blocked: true,
|
|
2879
3131
|
reason: "policy_denied",
|
|
2880
|
-
|
|
2881
|
-
ruleIndex,
|
|
3132
|
+
...info,
|
|
2882
3133
|
action: "deny",
|
|
2883
3134
|
policy_reason: decision.reason,
|
|
2884
3135
|
suggestion,
|
|
@@ -2886,14 +3137,13 @@ function buildPolicyDeniedFeedback(decision) {
|
|
|
2886
3137
|
};
|
|
2887
3138
|
}
|
|
2888
3139
|
function buildEvidenceMissingFeedback(decision, evidenceResult, dependencyResult) {
|
|
2889
|
-
const
|
|
3140
|
+
const info = ruleInfo(decision.matchedRule);
|
|
2890
3141
|
const missing = evidenceResult?.missing ?? [];
|
|
2891
3142
|
const suggestion = missing.length === 1 ? `Call the ${String(missing[0])} tool first to provide the required evidence, then retry this action.` : `Call the following tools first to provide the required evidence: ${missing.join(", ")}. Then retry this action.`;
|
|
2892
3143
|
return {
|
|
2893
3144
|
blocked: true,
|
|
2894
3145
|
reason: "evidence_missing",
|
|
2895
|
-
|
|
2896
|
-
ruleIndex,
|
|
3146
|
+
...info,
|
|
2897
3147
|
action: "deny",
|
|
2898
3148
|
missing_evidence: missing,
|
|
2899
3149
|
expired_evidence: evidenceResult?.expired ?? [],
|
|
@@ -2903,14 +3153,13 @@ function buildEvidenceMissingFeedback(decision, evidenceResult, dependencyResult
|
|
|
2903
3153
|
};
|
|
2904
3154
|
}
|
|
2905
3155
|
function buildEvidenceExpiredFeedback(decision, evidenceResult, dependencyResult) {
|
|
2906
|
-
const
|
|
3156
|
+
const info = ruleInfo(decision.matchedRule);
|
|
2907
3157
|
const expired = evidenceResult?.expired ?? [];
|
|
2908
3158
|
const suggestion = expired.length === 1 ? `Evidence from ${String(expired[0])} has expired. Call it again to refresh the evidence, then retry this action.` : `Evidence from the following tools has expired: ${expired.join(", ")}. Call them again to refresh, then retry this action.`;
|
|
2909
3159
|
return {
|
|
2910
3160
|
blocked: true,
|
|
2911
3161
|
reason: "evidence_expired",
|
|
2912
|
-
|
|
2913
|
-
ruleIndex,
|
|
3162
|
+
...info,
|
|
2914
3163
|
action: "deny",
|
|
2915
3164
|
missing_evidence: evidenceResult?.missing ?? [],
|
|
2916
3165
|
expired_evidence: expired,
|
|
@@ -2920,14 +3169,13 @@ function buildEvidenceExpiredFeedback(decision, evidenceResult, dependencyResult
|
|
|
2920
3169
|
};
|
|
2921
3170
|
}
|
|
2922
3171
|
function buildDependencyMissingFeedback(decision, evidenceResult, dependencyResult) {
|
|
2923
|
-
const
|
|
3172
|
+
const info = ruleInfo(decision.matchedRule);
|
|
2924
3173
|
const missing = dependencyResult?.missing ?? [];
|
|
2925
3174
|
const suggestion = missing.length === 1 ? `Call the ${String(missing[0])} tool first before attempting this action.` : `Call the following tools first: ${missing.join(", ")}. Then retry this action.`;
|
|
2926
3175
|
return {
|
|
2927
3176
|
blocked: true,
|
|
2928
3177
|
reason: "dependency_missing",
|
|
2929
|
-
|
|
2930
|
-
ruleIndex,
|
|
3178
|
+
...info,
|
|
2931
3179
|
action: "deny",
|
|
2932
3180
|
missing_evidence: evidenceResult?.missing ?? [],
|
|
2933
3181
|
expired_evidence: evidenceResult?.expired ?? [],
|
|
@@ -2937,13 +3185,12 @@ function buildDependencyMissingFeedback(decision, evidenceResult, dependencyResu
|
|
|
2937
3185
|
};
|
|
2938
3186
|
}
|
|
2939
3187
|
function buildApprovalDeniedFeedback(decision, deniedBy, denialReason) {
|
|
2940
|
-
const
|
|
3188
|
+
const info = ruleInfo(decision.matchedRule);
|
|
2941
3189
|
const suggestion = decision.matchedRule?.feedback?.suggestion ?? decision.matchedRule?.feedback?.message ?? `This action was denied by ${deniedBy}.${denialReason ? ` Reason: ${denialReason}.` : ""} Contact them for details or use an alternative approach.`;
|
|
2942
3190
|
return {
|
|
2943
3191
|
blocked: true,
|
|
2944
3192
|
reason: "approval_denied",
|
|
2945
|
-
|
|
2946
|
-
ruleIndex,
|
|
3193
|
+
...info,
|
|
2947
3194
|
action: "require_approval",
|
|
2948
3195
|
denied_by: deniedBy,
|
|
2949
3196
|
denial_reason: denialReason ?? null,
|
|
@@ -2952,14 +3199,13 @@ function buildApprovalDeniedFeedback(decision, deniedBy, denialReason) {
|
|
|
2952
3199
|
};
|
|
2953
3200
|
}
|
|
2954
3201
|
function buildApprovalTimeoutFeedback(decision, timeoutMs) {
|
|
2955
|
-
const
|
|
3202
|
+
const info = ruleInfo(decision.matchedRule);
|
|
2956
3203
|
const timeoutSeconds = Math.round(timeoutMs / 1e3);
|
|
2957
3204
|
const suggestion = decision.matchedRule?.feedback?.suggestion ?? decision.matchedRule?.feedback?.message ?? `Approval request timed out after ${String(timeoutSeconds)}s. Try again or contact an approver directly.`;
|
|
2958
3205
|
return {
|
|
2959
3206
|
blocked: true,
|
|
2960
3207
|
reason: "approval_timeout",
|
|
2961
|
-
|
|
2962
|
-
ruleIndex,
|
|
3208
|
+
...info,
|
|
2963
3209
|
action: "require_approval",
|
|
2964
3210
|
timeout_seconds: timeoutSeconds,
|
|
2965
3211
|
suggestion,
|
|
@@ -2967,41 +3213,38 @@ function buildApprovalTimeoutFeedback(decision, timeoutMs) {
|
|
|
2967
3213
|
};
|
|
2968
3214
|
}
|
|
2969
3215
|
function buildClientDisconnectedFeedback(decision) {
|
|
2970
|
-
const
|
|
3216
|
+
const info = ruleInfo(decision.matchedRule);
|
|
2971
3217
|
const suggestion = decision.matchedRule?.feedback?.suggestion ?? decision.matchedRule?.feedback?.message ?? "The client disconnected before this request completed. Retry with a stable connection.";
|
|
2972
3218
|
return {
|
|
2973
3219
|
blocked: true,
|
|
2974
3220
|
reason: "client_disconnected",
|
|
2975
|
-
|
|
2976
|
-
ruleIndex,
|
|
3221
|
+
...info,
|
|
2977
3222
|
action: "require_approval",
|
|
2978
3223
|
suggestion,
|
|
2979
3224
|
retry_allowed: true
|
|
2980
3225
|
};
|
|
2981
3226
|
}
|
|
2982
3227
|
function buildShutdownCancelledFeedback(decision) {
|
|
2983
|
-
const
|
|
3228
|
+
const info = ruleInfo(decision.matchedRule);
|
|
2984
3229
|
const suggestion = decision.matchedRule?.feedback?.suggestion ?? decision.matchedRule?.feedback?.message ?? "The proxy was shut down while this request was awaiting approval (for example during deploy/restart). Retry once the proxy is healthy.";
|
|
2985
3230
|
return {
|
|
2986
3231
|
blocked: true,
|
|
2987
3232
|
reason: "shutdown_cancelled",
|
|
2988
|
-
|
|
2989
|
-
ruleIndex,
|
|
3233
|
+
...info,
|
|
2990
3234
|
action: "require_approval",
|
|
2991
3235
|
suggestion,
|
|
2992
3236
|
retry_allowed: true
|
|
2993
3237
|
};
|
|
2994
3238
|
}
|
|
2995
3239
|
function buildRateLimitedFeedback(decision, result) {
|
|
2996
|
-
const
|
|
3240
|
+
const info = ruleInfo(decision.matchedRule);
|
|
2997
3241
|
const windowSeconds = Math.round(result.windowMs / 1e3);
|
|
2998
3242
|
const resetAt = new Date(result.resetAtMs).toISOString();
|
|
2999
3243
|
const suggestion = decision.matchedRule?.feedback?.suggestion ?? decision.matchedRule?.feedback?.message ?? `Rate limit exceeded (${String(result.current)}/${String(result.limit)} calls in ${String(windowSeconds)}s window). Retry after ${resetAt} or reduce call frequency.`;
|
|
3000
3244
|
return {
|
|
3001
3245
|
blocked: true,
|
|
3002
3246
|
reason: "rate_limited",
|
|
3003
|
-
|
|
3004
|
-
ruleIndex,
|
|
3247
|
+
...info,
|
|
3005
3248
|
action: "rate_limit",
|
|
3006
3249
|
current_calls: result.current,
|
|
3007
3250
|
max_calls: result.limit,
|
|
@@ -3016,8 +3259,7 @@ function buildToolDriftFeedback(drift, action) {
|
|
|
3016
3259
|
return {
|
|
3017
3260
|
blocked: true,
|
|
3018
3261
|
reason: "tool_definition_drift",
|
|
3019
|
-
|
|
3020
|
-
ruleIndex: null,
|
|
3262
|
+
...ruleInfo(void 0),
|
|
3021
3263
|
action,
|
|
3022
3264
|
drifted_aspects: aspects,
|
|
3023
3265
|
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.`,
|
|
@@ -3025,15 +3267,14 @@ function buildToolDriftFeedback(drift, action) {
|
|
|
3025
3267
|
};
|
|
3026
3268
|
}
|
|
3027
3269
|
function buildSpendLimitedFeedback(decision, result, currency) {
|
|
3028
|
-
const
|
|
3270
|
+
const info = ruleInfo(decision.matchedRule);
|
|
3029
3271
|
const windowSeconds = Math.round(result.windowMs / 1e3);
|
|
3030
3272
|
if (result.reason === "invalid_amount") {
|
|
3031
3273
|
const field = decision.matchedRule?.limits?.maxSpend?.field ?? "amount";
|
|
3032
3274
|
return {
|
|
3033
3275
|
blocked: true,
|
|
3034
3276
|
reason: "spend_limited",
|
|
3035
|
-
|
|
3036
|
-
ruleIndex,
|
|
3277
|
+
...info,
|
|
3037
3278
|
action: "spend_limit",
|
|
3038
3279
|
current_spend: result.currentSpend,
|
|
3039
3280
|
max_spend: result.limit,
|
|
@@ -3049,8 +3290,7 @@ function buildSpendLimitedFeedback(decision, result, currency) {
|
|
|
3049
3290
|
return {
|
|
3050
3291
|
blocked: true,
|
|
3051
3292
|
reason: "spend_limited",
|
|
3052
|
-
|
|
3053
|
-
ruleIndex,
|
|
3293
|
+
...info,
|
|
3054
3294
|
action: "spend_limit",
|
|
3055
3295
|
current_spend: result.currentSpend,
|
|
3056
3296
|
max_spend: result.limit,
|
|
@@ -3061,86 +3301,506 @@ function buildSpendLimitedFeedback(decision, result, currency) {
|
|
|
3061
3301
|
retry_allowed: true
|
|
3062
3302
|
};
|
|
3063
3303
|
}
|
|
3304
|
+
function breachBlock(entry) {
|
|
3305
|
+
return {
|
|
3306
|
+
name: entry.budget.name,
|
|
3307
|
+
limit: entry.budget.limit,
|
|
3308
|
+
spent: entry.spent,
|
|
3309
|
+
remaining: entry.remaining,
|
|
3310
|
+
attempted_amount: entry.amount,
|
|
3311
|
+
currency: entry.budget.currency,
|
|
3312
|
+
window: entry.budget.windowRaw,
|
|
3313
|
+
on_exceed: entry.budget.onExceed,
|
|
3314
|
+
reset_at: entry.resetAtMs === null ? null : new Date(entry.resetAtMs).toISOString()
|
|
3315
|
+
};
|
|
3316
|
+
}
|
|
3317
|
+
function buildBudgetExceededFeedback(decision, breaches, failures) {
|
|
3318
|
+
const info = ruleInfo(decision.matchedRule);
|
|
3319
|
+
const budgets = [
|
|
3320
|
+
...breaches.map(breachBlock),
|
|
3321
|
+
...failures.map((failure) => ({
|
|
3322
|
+
name: failure.budget.name,
|
|
3323
|
+
limit: failure.budget.limit,
|
|
3324
|
+
spent: failure.spent,
|
|
3325
|
+
remaining: failure.remaining,
|
|
3326
|
+
attempted_amount: null,
|
|
3327
|
+
currency: failure.budget.currency,
|
|
3328
|
+
window: failure.budget.windowRaw,
|
|
3329
|
+
on_exceed: failure.budget.onExceed,
|
|
3330
|
+
reset_at: failure.resetAtMs === null ? null : new Date(failure.resetAtMs).toISOString(),
|
|
3331
|
+
reason: "invalid_amount"
|
|
3332
|
+
}))
|
|
3333
|
+
];
|
|
3334
|
+
const suggestion = failures.length > 0 ? `Budget ${failures.map((f) => `"${f.budget.name}"`).join(", ")} could not read a valid spend amount from this call. Retry with a non-negative finite amount in the expected field.` : `Budget ${breaches.map((b) => `"${b.budget.name}"`).join(", ")} would be exceeded by this call. Wait for the window to reset or reduce the amount.`;
|
|
3335
|
+
const retryAllowed = breaches.every((entry) => entry.budget.window.kind === "duration");
|
|
3336
|
+
return {
|
|
3337
|
+
blocked: true,
|
|
3338
|
+
reason: "budget_exceeded",
|
|
3339
|
+
...info,
|
|
3340
|
+
action: "budget",
|
|
3341
|
+
budgets,
|
|
3342
|
+
suggestion,
|
|
3343
|
+
retry_allowed: retryAllowed
|
|
3344
|
+
};
|
|
3345
|
+
}
|
|
3346
|
+
function buildBudgetApprovalDeniedFeedback(decision, breaches, deniedBy, denialReason) {
|
|
3347
|
+
const info = ruleInfo(decision.matchedRule);
|
|
3348
|
+
const names = breaches.map((b) => `"${b.budget.name}"`).join(", ");
|
|
3349
|
+
return {
|
|
3350
|
+
blocked: true,
|
|
3351
|
+
reason: "budget_exceeded",
|
|
3352
|
+
...info,
|
|
3353
|
+
action: "budget",
|
|
3354
|
+
denied_by: deniedBy,
|
|
3355
|
+
denial_reason: denialReason ?? null,
|
|
3356
|
+
budgets: breaches.map(breachBlock),
|
|
3357
|
+
suggestion: `The budget overage on ${names} was denied by ${deniedBy}.${denialReason ? ` Reason: ${denialReason}.` : ""} Wait for the window to reset, reduce the amount, or contact them for details.`,
|
|
3358
|
+
retry_allowed: false
|
|
3359
|
+
};
|
|
3360
|
+
}
|
|
3361
|
+
function buildBudgetApprovalTimeoutFeedback(decision, breaches, timeoutMs) {
|
|
3362
|
+
const info = ruleInfo(decision.matchedRule);
|
|
3363
|
+
const timeoutSeconds = Math.round(timeoutMs / 1e3);
|
|
3364
|
+
const names = breaches.map((b) => `"${b.budget.name}"`).join(", ");
|
|
3365
|
+
return {
|
|
3366
|
+
blocked: true,
|
|
3367
|
+
reason: "budget_exceeded",
|
|
3368
|
+
...info,
|
|
3369
|
+
action: "budget",
|
|
3370
|
+
timeout_seconds: timeoutSeconds,
|
|
3371
|
+
budgets: breaches.map(breachBlock),
|
|
3372
|
+
suggestion: `The break-glass request for ${names} timed out after ${String(timeoutSeconds)}s (budget approvals never fail open). Try again or contact an approver directly.`,
|
|
3373
|
+
retry_allowed: true
|
|
3374
|
+
};
|
|
3375
|
+
}
|
|
3064
3376
|
|
|
3065
|
-
// src/policy/
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
this.
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
this.spendLimiter = options?.spendLimiter;
|
|
3088
|
-
if (this.evidenceStore) {
|
|
3089
|
-
this.evidenceStore.setAllowedEvidenceKeys(collectAllowedEvidenceKeys(policy));
|
|
3377
|
+
// src/policy/spend-limiter.ts
|
|
3378
|
+
function spendBucketKey(baseKey, ruleIndex) {
|
|
3379
|
+
return `${baseKey}:rule:${String(ruleIndex)}`;
|
|
3380
|
+
}
|
|
3381
|
+
var RULE_SUFFIX_RE = /:rule:(\d+)$/;
|
|
3382
|
+
var SpendLimiter = class {
|
|
3383
|
+
buckets = /* @__PURE__ */ new Map();
|
|
3384
|
+
now;
|
|
3385
|
+
onWarning;
|
|
3386
|
+
warningThreshold;
|
|
3387
|
+
timer = null;
|
|
3388
|
+
closed = false;
|
|
3389
|
+
constructor(options = {}) {
|
|
3390
|
+
this.now = options.now ?? Date.now;
|
|
3391
|
+
this.onWarning = options.onWarning;
|
|
3392
|
+
this.warningThreshold = options.warningThreshold ?? 0.8;
|
|
3393
|
+
const intervalMs = options.cleanupIntervalMs ?? 6e4;
|
|
3394
|
+
if (intervalMs > 0) {
|
|
3395
|
+
this.timer = setInterval(() => {
|
|
3396
|
+
this.cleanup();
|
|
3397
|
+
}, intervalMs);
|
|
3398
|
+
this.timer.unref();
|
|
3090
3399
|
}
|
|
3091
3400
|
}
|
|
3401
|
+
// -------------------------------------------------------------------------
|
|
3402
|
+
// Core operations
|
|
3403
|
+
// -------------------------------------------------------------------------
|
|
3092
3404
|
/**
|
|
3093
|
-
*
|
|
3094
|
-
* against the new configuration.
|
|
3095
|
-
*
|
|
3096
|
-
* Rate and spend limit buckets are preserved when their underlying rule
|
|
3097
|
-
* config is unchanged — this is what makes a benign hot-reload (e.g. a
|
|
3098
|
-
* `vim :w` with no real edits, or a whitespace-only config change) safe:
|
|
3099
|
-
* operators do not get a surprise zero of their live rate/spend state
|
|
3100
|
-
* mid-window. Buckets whose config changed or whose rule was removed are
|
|
3101
|
-
* evicted by the limiters' `reconcile()` methods, so the next check
|
|
3102
|
-
* lazy-creates a fresh bucket under the new config.
|
|
3405
|
+
* Check and optionally record a spend against the limit.
|
|
3103
3406
|
*
|
|
3104
|
-
*
|
|
3105
|
-
*
|
|
3407
|
+
* Evicts expired entries, sums remaining amounts, then checks:
|
|
3408
|
+
* - Under limit (currentSpend + amount <= limit): records and returns `allowed: true`
|
|
3409
|
+
* - Would exceed: does NOT record (rejected spends don't consume budget)
|
|
3106
3410
|
*/
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3411
|
+
check(params) {
|
|
3412
|
+
const { key, amount, limit, windowMs } = params;
|
|
3413
|
+
const now = this.now();
|
|
3414
|
+
const windowStart = now - windowMs;
|
|
3415
|
+
if (!Number.isFinite(amount) || amount < 0) {
|
|
3416
|
+
const existing = this.buckets.get(key);
|
|
3417
|
+
const activeEntries = existing ? existing.entries.filter((e) => e.timestamp > windowStart) : [];
|
|
3418
|
+
const currentSpend2 = activeEntries.reduce((sum, e) => sum + e.amount, 0);
|
|
3419
|
+
const oldest = activeEntries[0];
|
|
3420
|
+
return {
|
|
3421
|
+
allowed: false,
|
|
3422
|
+
currentSpend: currentSpend2,
|
|
3423
|
+
limit,
|
|
3424
|
+
windowMs,
|
|
3425
|
+
resetAtMs: oldest ? oldest.timestamp + windowMs : 0,
|
|
3426
|
+
reason: "invalid_amount"
|
|
3427
|
+
};
|
|
3111
3428
|
}
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
if (limits?.maxCalls !== void 0 && limits.windowMs !== void 0) {
|
|
3117
|
-
rateConfigs.push({ maxCalls: limits.maxCalls, windowMs: limits.windowMs });
|
|
3118
|
-
}
|
|
3119
|
-
}
|
|
3120
|
-
this.rateLimiter.reconcile(rateConfigs);
|
|
3429
|
+
let bucket = this.buckets.get(key);
|
|
3430
|
+
if (!bucket) {
|
|
3431
|
+
bucket = { entries: [], limit, currency: "", windowMs };
|
|
3432
|
+
this.buckets.set(key, bucket);
|
|
3121
3433
|
}
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3434
|
+
bucket.limit = limit;
|
|
3435
|
+
bucket.windowMs = windowMs;
|
|
3436
|
+
bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
|
|
3437
|
+
const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
|
|
3438
|
+
if (currentSpend + amount > limit) {
|
|
3439
|
+
const oldest = bucket.entries[0];
|
|
3440
|
+
return {
|
|
3441
|
+
allowed: false,
|
|
3442
|
+
currentSpend,
|
|
3443
|
+
limit,
|
|
3444
|
+
windowMs,
|
|
3445
|
+
resetAtMs: oldest ? oldest.timestamp + windowMs : 0
|
|
3446
|
+
};
|
|
3135
3447
|
}
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3448
|
+
bucket.entries.push({ timestamp: now, amount });
|
|
3449
|
+
const newSpend = currentSpend + amount;
|
|
3450
|
+
const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
|
|
3451
|
+
if (this.onWarning && newSpend / limit >= this.warningThreshold) {
|
|
3452
|
+
this.safeWarn({
|
|
3453
|
+
key,
|
|
3454
|
+
current_spend: newSpend,
|
|
3455
|
+
limit,
|
|
3456
|
+
currency: bucket.currency,
|
|
3457
|
+
window_ms: windowMs,
|
|
3458
|
+
reset_at_ms: resetAtMs
|
|
3459
|
+
});
|
|
3460
|
+
}
|
|
3461
|
+
return {
|
|
3462
|
+
allowed: true,
|
|
3463
|
+
currentSpend: newSpend,
|
|
3464
|
+
limit,
|
|
3465
|
+
windowMs,
|
|
3466
|
+
resetAtMs
|
|
3467
|
+
};
|
|
3468
|
+
}
|
|
3469
|
+
/**
|
|
3470
|
+
* Unconditionally record a spend against the limit.
|
|
3471
|
+
*
|
|
3472
|
+
* Unlike check(), this always appends the amount — even when it pushes the
|
|
3473
|
+
* window past the limit — because the spend it represents has already been
|
|
3474
|
+
* incurred. The sideband peeks at /evaluate and commits here at /audit once
|
|
3475
|
+
* the external call ran (issue #12, D3).
|
|
3476
|
+
*
|
|
3477
|
+
* Throws on a negative or non-finite amount: such amounts are rejected at
|
|
3478
|
+
* /evaluate, so one reaching record() is a logic bug we surface loudly rather
|
|
3479
|
+
* than silently corrupt the sliding-window sum. Warnings fire only while the
|
|
3480
|
+
* post-append spend stays within the limit (parity with check()).
|
|
3481
|
+
*/
|
|
3482
|
+
record(params) {
|
|
3483
|
+
const { key, amount, limit, windowMs } = params;
|
|
3484
|
+
if (!Number.isFinite(amount) || amount < 0) {
|
|
3485
|
+
throw new RangeError(
|
|
3486
|
+
`SpendLimiter.record() received an invalid amount (${String(amount)}); invalid amounts must be rejected at /evaluate, never committed`
|
|
3487
|
+
);
|
|
3488
|
+
}
|
|
3489
|
+
const now = this.now();
|
|
3490
|
+
const windowStart = now - windowMs;
|
|
3491
|
+
let bucket = this.buckets.get(key);
|
|
3492
|
+
if (!bucket) {
|
|
3493
|
+
bucket = { entries: [], limit, currency: "", windowMs };
|
|
3494
|
+
this.buckets.set(key, bucket);
|
|
3495
|
+
}
|
|
3496
|
+
bucket.limit = limit;
|
|
3497
|
+
bucket.windowMs = windowMs;
|
|
3498
|
+
bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
|
|
3499
|
+
bucket.entries.push({ timestamp: now, amount });
|
|
3500
|
+
const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
|
|
3501
|
+
const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
|
|
3502
|
+
if (this.onWarning && currentSpend <= limit && currentSpend / limit >= this.warningThreshold) {
|
|
3503
|
+
this.safeWarn({
|
|
3504
|
+
key,
|
|
3505
|
+
current_spend: currentSpend,
|
|
3506
|
+
limit,
|
|
3507
|
+
currency: bucket.currency,
|
|
3508
|
+
window_ms: windowMs,
|
|
3509
|
+
reset_at_ms: resetAtMs
|
|
3510
|
+
});
|
|
3511
|
+
}
|
|
3512
|
+
return {
|
|
3513
|
+
allowed: currentSpend <= limit,
|
|
3514
|
+
currentSpend,
|
|
3515
|
+
limit,
|
|
3516
|
+
windowMs,
|
|
3517
|
+
resetAtMs
|
|
3518
|
+
};
|
|
3519
|
+
}
|
|
3520
|
+
/**
|
|
3521
|
+
* Check the spend limit without recording the spend (non-destructive).
|
|
3522
|
+
*
|
|
3523
|
+
* Used by dry-run mode to determine what would happen without consuming
|
|
3524
|
+
* budget in the bucket.
|
|
3525
|
+
*/
|
|
3526
|
+
peek(params) {
|
|
3527
|
+
const { key, amount, limit, windowMs } = params;
|
|
3528
|
+
const now = this.now();
|
|
3529
|
+
const windowStart = now - windowMs;
|
|
3530
|
+
const bucket = this.buckets.get(key);
|
|
3531
|
+
if (!Number.isFinite(amount) || amount < 0) {
|
|
3532
|
+
const activeEntries2 = bucket ? bucket.entries.filter((e) => e.timestamp > windowStart) : [];
|
|
3533
|
+
const currentSpend2 = activeEntries2.reduce((sum, e) => sum + e.amount, 0);
|
|
3534
|
+
const oldest2 = activeEntries2[0];
|
|
3535
|
+
return {
|
|
3536
|
+
allowed: false,
|
|
3537
|
+
currentSpend: currentSpend2,
|
|
3538
|
+
limit,
|
|
3539
|
+
windowMs,
|
|
3540
|
+
resetAtMs: oldest2 ? oldest2.timestamp + windowMs : 0,
|
|
3541
|
+
reason: "invalid_amount"
|
|
3542
|
+
};
|
|
3543
|
+
}
|
|
3544
|
+
if (!bucket) {
|
|
3545
|
+
const wouldExceed = amount > limit;
|
|
3546
|
+
return {
|
|
3547
|
+
allowed: !wouldExceed,
|
|
3548
|
+
currentSpend: wouldExceed ? 0 : amount,
|
|
3549
|
+
limit,
|
|
3550
|
+
windowMs,
|
|
3551
|
+
resetAtMs: now + windowMs
|
|
3552
|
+
};
|
|
3553
|
+
}
|
|
3554
|
+
const activeEntries = bucket.entries.filter((e) => e.timestamp > windowStart);
|
|
3555
|
+
const currentSpend = activeEntries.reduce((sum, e) => sum + e.amount, 0);
|
|
3556
|
+
if (currentSpend + amount > limit) {
|
|
3557
|
+
const oldest2 = activeEntries[0];
|
|
3558
|
+
return {
|
|
3559
|
+
allowed: false,
|
|
3560
|
+
currentSpend,
|
|
3561
|
+
limit,
|
|
3562
|
+
windowMs,
|
|
3563
|
+
resetAtMs: oldest2 ? oldest2.timestamp + windowMs : 0
|
|
3564
|
+
};
|
|
3565
|
+
}
|
|
3566
|
+
const newSpend = currentSpend + amount;
|
|
3567
|
+
const oldest = activeEntries[0];
|
|
3568
|
+
return {
|
|
3569
|
+
allowed: true,
|
|
3570
|
+
currentSpend: newSpend,
|
|
3571
|
+
limit,
|
|
3572
|
+
windowMs,
|
|
3573
|
+
resetAtMs: oldest ? oldest.timestamp + windowMs : now + windowMs
|
|
3574
|
+
};
|
|
3575
|
+
}
|
|
3576
|
+
/**
|
|
3577
|
+
* Set the display currency for a key. Called by the governed forwarder
|
|
3578
|
+
* after check() so dashboard reads include the currency label.
|
|
3579
|
+
*/
|
|
3580
|
+
setCurrency(key, currency) {
|
|
3581
|
+
const bucket = this.buckets.get(key);
|
|
3582
|
+
if (bucket) bucket.currency = currency;
|
|
3583
|
+
}
|
|
3584
|
+
// -------------------------------------------------------------------------
|
|
3585
|
+
// Read operations (for dashboard API)
|
|
3586
|
+
// -------------------------------------------------------------------------
|
|
3587
|
+
/** Get the current state of a single key. Returns undefined if not tracked. */
|
|
3588
|
+
getKeyState(key) {
|
|
3589
|
+
const bucket = this.buckets.get(key);
|
|
3590
|
+
if (!bucket) return void 0;
|
|
3591
|
+
const windowStart = this.now() - bucket.windowMs;
|
|
3592
|
+
bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
|
|
3593
|
+
if (bucket.entries.length === 0) {
|
|
3594
|
+
this.buckets.delete(key);
|
|
3595
|
+
return void 0;
|
|
3596
|
+
}
|
|
3597
|
+
const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
|
|
3598
|
+
return {
|
|
3599
|
+
key,
|
|
3600
|
+
current_spend: currentSpend,
|
|
3601
|
+
limit: bucket.limit,
|
|
3602
|
+
currency: bucket.currency,
|
|
3603
|
+
window_ms: bucket.windowMs,
|
|
3604
|
+
reset_at_ms: (bucket.entries[0]?.timestamp ?? 0) + bucket.windowMs
|
|
3605
|
+
};
|
|
3606
|
+
}
|
|
3607
|
+
/** List all tracked keys with their current state. */
|
|
3608
|
+
listKeyStates() {
|
|
3609
|
+
const states = [];
|
|
3610
|
+
for (const key of [...this.buckets.keys()]) {
|
|
3611
|
+
const state = this.getKeyState(key);
|
|
3612
|
+
if (state) states.push(state);
|
|
3613
|
+
}
|
|
3614
|
+
return states;
|
|
3615
|
+
}
|
|
3616
|
+
// -------------------------------------------------------------------------
|
|
3617
|
+
// Maintenance
|
|
3618
|
+
// -------------------------------------------------------------------------
|
|
3619
|
+
/** Sweep all buckets: remove expired entries, delete empty buckets. */
|
|
3620
|
+
cleanup() {
|
|
3621
|
+
const now = this.now();
|
|
3622
|
+
for (const [key, bucket] of this.buckets) {
|
|
3623
|
+
const windowStart = now - bucket.windowMs;
|
|
3624
|
+
bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
|
|
3625
|
+
if (bucket.entries.length === 0) {
|
|
3626
|
+
this.buckets.delete(key);
|
|
3627
|
+
}
|
|
3628
|
+
}
|
|
3629
|
+
}
|
|
3630
|
+
/** Clear all spend limit state. Called on policy hot-reload. */
|
|
3631
|
+
reset() {
|
|
3632
|
+
this.buckets.clear();
|
|
3633
|
+
}
|
|
3634
|
+
/**
|
|
3635
|
+
* Reconcile bucket state against a new policy's spend configuration.
|
|
3636
|
+
*
|
|
3637
|
+
* Walks every existing bucket and checks whether its last-seen
|
|
3638
|
+
* `{ limit, currency, windowMs }` tuple still appears in `validConfigs`.
|
|
3639
|
+
* Buckets whose config is unchanged are left untouched — cumulative spend
|
|
3640
|
+
* and elapsed-window progress are preserved across hot-reloads. Buckets
|
|
3641
|
+
* whose config is gone (rule changed or removed) are evicted so the next
|
|
3642
|
+
* check lazy-creates a fresh bucket under the new config.
|
|
3643
|
+
*
|
|
3644
|
+
* Keys built by {@link spendBucketKey} carry the owning rule's index, and
|
|
3645
|
+
* for those the tuple must match at THAT index (`config.ruleIndex`): a
|
|
3646
|
+
* reorder that shifts a spend rule's index evicts its old-index bucket
|
|
3647
|
+
* instead of leaving an orphan no rule reads again — or worse, letting
|
|
3648
|
+
* whatever rule now sits at that index adopt another rule's accrued spend.
|
|
3649
|
+
* Un-suffixed keys keep the tuple-anywhere match.
|
|
3650
|
+
*
|
|
3651
|
+
* Currency is part of the tuple because a USD→EUR switch is a meaningful
|
|
3652
|
+
* policy change — the same numeric limit buys a different amount of real
|
|
3653
|
+
* spend, so the bucket must reset. This replaces the old `reset()` call
|
|
3654
|
+
* on every hot-reload, which wiped all state even when the matching rule
|
|
3655
|
+
* was unchanged.
|
|
3656
|
+
*/
|
|
3657
|
+
reconcile(validConfigs) {
|
|
3658
|
+
const valid = /* @__PURE__ */ new Set();
|
|
3659
|
+
const byIndex = /* @__PURE__ */ new Map();
|
|
3660
|
+
for (const config of validConfigs) {
|
|
3661
|
+
const tuple = `${String(config.limit)}|${config.currency}|${String(config.windowMs)}`;
|
|
3662
|
+
if (config.ruleIndex === void 0) {
|
|
3663
|
+
valid.add(tuple);
|
|
3664
|
+
} else {
|
|
3665
|
+
byIndex.set(config.ruleIndex, tuple);
|
|
3666
|
+
}
|
|
3667
|
+
}
|
|
3668
|
+
for (const [key, bucket] of this.buckets) {
|
|
3669
|
+
const tuple = `${String(bucket.limit)}|${bucket.currency}|${String(bucket.windowMs)}`;
|
|
3670
|
+
const suffix = RULE_SUFFIX_RE.exec(key);
|
|
3671
|
+
const survives = suffix ? byIndex.get(Number(suffix[1])) === tuple : valid.has(tuple);
|
|
3672
|
+
if (!survives) {
|
|
3673
|
+
this.buckets.delete(key);
|
|
3674
|
+
}
|
|
3675
|
+
}
|
|
3676
|
+
}
|
|
3677
|
+
/** Stop the cleanup timer and mark as closed. */
|
|
3678
|
+
/**
|
|
3679
|
+
* Invoke the warning callback without letting a subscriber throw into the
|
|
3680
|
+
* limiter's caller: a warning fires after state has already mutated, and a
|
|
3681
|
+
* governed call must not be blocked (or double-charged on retry) by an
|
|
3682
|
+
* observability bug.
|
|
3683
|
+
*/
|
|
3684
|
+
safeWarn(state) {
|
|
3685
|
+
if (!this.onWarning) return;
|
|
3686
|
+
try {
|
|
3687
|
+
this.onWarning(state);
|
|
3688
|
+
} catch (err) {
|
|
3689
|
+
console.error("[helio] limit warning subscriber threw:", err);
|
|
3690
|
+
}
|
|
3691
|
+
}
|
|
3692
|
+
close() {
|
|
3693
|
+
if (this.closed) return;
|
|
3694
|
+
this.closed = true;
|
|
3695
|
+
if (this.timer) {
|
|
3696
|
+
clearInterval(this.timer);
|
|
3697
|
+
this.timer = null;
|
|
3698
|
+
}
|
|
3699
|
+
this.buckets.clear();
|
|
3700
|
+
}
|
|
3701
|
+
};
|
|
3702
|
+
|
|
3703
|
+
// src/policy/governed-forwarder.ts
|
|
3704
|
+
var POLICY_DENIED = -32001;
|
|
3705
|
+
function blocked(result) {
|
|
3706
|
+
return { proceed: false, result, approvalWaitMs: 0 };
|
|
3707
|
+
}
|
|
3708
|
+
function budgetChainBlock(entry, kind) {
|
|
3709
|
+
return {
|
|
3710
|
+
name: entry.budget.name,
|
|
3711
|
+
bucket_key: entry.bucketKey,
|
|
3712
|
+
allowed: entry.allowed,
|
|
3713
|
+
amount: entry.amount,
|
|
3714
|
+
spent: entry.spent,
|
|
3715
|
+
limit: entry.budget.limit,
|
|
3716
|
+
remaining: entry.remaining,
|
|
3717
|
+
currency: entry.budget.currency,
|
|
3718
|
+
...kind ? { kind } : {},
|
|
3719
|
+
...entry.stale ? { stale: true } : {}
|
|
3720
|
+
};
|
|
3721
|
+
}
|
|
3722
|
+
var GovernedForwarder = class {
|
|
3723
|
+
inner;
|
|
3724
|
+
policy;
|
|
3725
|
+
environment;
|
|
3726
|
+
auditWriter;
|
|
3727
|
+
evidenceStore;
|
|
3728
|
+
approvalRouter;
|
|
3729
|
+
rateLimiter;
|
|
3730
|
+
spendLimiter;
|
|
3731
|
+
budgetEngine;
|
|
3732
|
+
annotationCache = new ToolAnnotationCache();
|
|
3733
|
+
agentKeyWarned = false;
|
|
3734
|
+
senderKeyWarned = false;
|
|
3735
|
+
constructor(inner, policy, options) {
|
|
3736
|
+
this.inner = inner;
|
|
3737
|
+
this.policy = policy;
|
|
3738
|
+
this.environment = options?.environment;
|
|
3739
|
+
this.auditWriter = options?.auditWriter;
|
|
3740
|
+
this.evidenceStore = options?.evidenceStore;
|
|
3741
|
+
this.approvalRouter = options?.approvalRouter;
|
|
3742
|
+
this.rateLimiter = options?.rateLimiter;
|
|
3743
|
+
this.spendLimiter = options?.spendLimiter;
|
|
3744
|
+
this.budgetEngine = options?.budgetEngine;
|
|
3745
|
+
if (this.evidenceStore) {
|
|
3746
|
+
this.evidenceStore.setAllowedEvidenceKeys(collectAllowedEvidenceKeys(policy));
|
|
3747
|
+
}
|
|
3748
|
+
}
|
|
3749
|
+
/**
|
|
3750
|
+
* Swap the compiled policy atomically and reconcile limit bucket state
|
|
3751
|
+
* against the new configuration.
|
|
3752
|
+
*
|
|
3753
|
+
* Rate and spend limit buckets are preserved when their underlying rule
|
|
3754
|
+
* config is unchanged — this is what makes a benign hot-reload (e.g. a
|
|
3755
|
+
* `vim :w` with no real edits, or a whitespace-only config change) safe:
|
|
3756
|
+
* operators do not get a surprise zero of their live rate/spend state
|
|
3757
|
+
* mid-window. Buckets whose config changed or whose rule was removed are
|
|
3758
|
+
* evicted by the limiters' `reconcile()` methods, so the next check
|
|
3759
|
+
* lazy-creates a fresh bucket under the new config.
|
|
3760
|
+
*
|
|
3761
|
+
* See `packages/proxy/src/policy/rate-limiter.ts` and `spend-limiter.ts`
|
|
3762
|
+
* for the per-bucket compare-and-evict semantics.
|
|
3763
|
+
*/
|
|
3764
|
+
updatePolicy(policy) {
|
|
3765
|
+
this.policy = policy;
|
|
3766
|
+
if (this.evidenceStore) {
|
|
3767
|
+
this.evidenceStore.setAllowedEvidenceKeys(collectAllowedEvidenceKeys(policy));
|
|
3768
|
+
}
|
|
3769
|
+
if (this.rateLimiter) {
|
|
3770
|
+
const rateConfigs = [];
|
|
3771
|
+
for (const rule of policy.rules) {
|
|
3772
|
+
const limits = rule.limits;
|
|
3773
|
+
if (limits?.maxCalls !== void 0 && limits.windowMs !== void 0) {
|
|
3774
|
+
rateConfigs.push({ maxCalls: limits.maxCalls, windowMs: limits.windowMs });
|
|
3775
|
+
}
|
|
3776
|
+
}
|
|
3777
|
+
this.rateLimiter.reconcile(rateConfigs);
|
|
3778
|
+
}
|
|
3779
|
+
if (this.spendLimiter) {
|
|
3780
|
+
const spendConfigs = [];
|
|
3781
|
+
for (const rule of policy.rules) {
|
|
3782
|
+
const maxSpend = rule.limits?.maxSpend;
|
|
3783
|
+
if (maxSpend) {
|
|
3784
|
+
spendConfigs.push({
|
|
3785
|
+
limit: maxSpend.limit,
|
|
3786
|
+
currency: maxSpend.currency,
|
|
3787
|
+
windowMs: maxSpend.windowMs,
|
|
3788
|
+
// Spend bucket keys are rule-discriminated (spendBucketKey), so
|
|
3789
|
+
// reconcile must match tuples at the owning rule's index.
|
|
3790
|
+
ruleIndex: rule.index
|
|
3791
|
+
});
|
|
3792
|
+
}
|
|
3793
|
+
}
|
|
3794
|
+
this.spendLimiter.reconcile(spendConfigs);
|
|
3795
|
+
}
|
|
3796
|
+
}
|
|
3797
|
+
/**
|
|
3798
|
+
* Prime the annotation cache by fetching tools/list directly from upstream.
|
|
3799
|
+
*
|
|
3800
|
+
* This path is intended for startup warm-up and intentionally bypasses policy
|
|
3801
|
+
* and audit handling. Runtime tools/list requests still flow through forward().
|
|
3802
|
+
*
|
|
3803
|
+
* When the inner forwarder exposes `forwardInternal` (duck-typed), the prime
|
|
3144
3804
|
* request is routed through it so session-enforcing servers (e.g. Streamable
|
|
3145
3805
|
* HTTP upstreams) receive the request on the managed internal session rather
|
|
3146
3806
|
* than as a sessionless call that they would reject with HTTP 400.
|
|
@@ -3243,13 +3903,24 @@ var GovernedForwarder = class {
|
|
|
3243
3903
|
metadata: null
|
|
3244
3904
|
});
|
|
3245
3905
|
}
|
|
3246
|
-
async handleToolsCall(
|
|
3906
|
+
async handleToolsCall(original) {
|
|
3247
3907
|
const startTime = performance.now();
|
|
3248
3908
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
3909
|
+
let request;
|
|
3910
|
+
try {
|
|
3911
|
+
request = original.params === void 0 ? original : { ...original, params: structuredClone(original.params) };
|
|
3912
|
+
} catch {
|
|
3913
|
+
return makeErrorResult(
|
|
3914
|
+
original,
|
|
3915
|
+
INVALID_PARAMS,
|
|
3916
|
+
"tools/call params must be JSON-serializable",
|
|
3917
|
+
{ blocked: true, reason: "invalid_params" }
|
|
3918
|
+
);
|
|
3919
|
+
}
|
|
3249
3920
|
const params = request.params;
|
|
3250
3921
|
const toolName = typeof params?.["name"] === "string" ? params["name"] : void 0;
|
|
3251
3922
|
if (!toolName) {
|
|
3252
|
-
return this.
|
|
3923
|
+
return this.rejectNamelessToolsCall(request, params, timestamp, startTime);
|
|
3253
3924
|
}
|
|
3254
3925
|
const toolArguments = params?.["arguments"] && typeof params["arguments"] === "object" ? params["arguments"] : void 0;
|
|
3255
3926
|
const {
|
|
@@ -3274,6 +3945,7 @@ var GovernedForwarder = class {
|
|
|
3274
3945
|
currentAnnotations: this.annotationCache.getCurrent(toolName),
|
|
3275
3946
|
driftEvent: this.annotationCache.getDrift(toolName)
|
|
3276
3947
|
});
|
|
3948
|
+
const auditRecordId = randomUUID2();
|
|
3277
3949
|
let result;
|
|
3278
3950
|
let approvalOutcome;
|
|
3279
3951
|
let approvalWaitMs = 0;
|
|
@@ -3281,52 +3953,74 @@ var GovernedForwarder = class {
|
|
|
3281
3953
|
let rateLimitResult;
|
|
3282
3954
|
let spendLimitResult;
|
|
3283
3955
|
let forwardingError;
|
|
3956
|
+
let forwarded = false;
|
|
3957
|
+
let budgetsChain;
|
|
3958
|
+
let budgetApproval;
|
|
3284
3959
|
try {
|
|
3285
3960
|
if (isDryRun) {
|
|
3286
3961
|
result = this.handleDryRun(request, decision, toolName, toolArguments, evidenceBlocked);
|
|
3287
|
-
} else
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
decision,
|
|
3304
|
-
toolName,
|
|
3305
|
-
toolArguments
|
|
3306
|
-
);
|
|
3307
|
-
result = approvalResult.result;
|
|
3308
|
-
approvalOutcome = approvalResult.outcome;
|
|
3309
|
-
approvalWaitMs = approvalResult.approvalWaitMs;
|
|
3310
|
-
approvalContext = approvalResult.approvalContext;
|
|
3311
|
-
}
|
|
3312
|
-
} else if (decision.action === "rate_limit") {
|
|
3313
|
-
if (!this.rateLimiter) {
|
|
3314
|
-
result = this.makeUnsupportedResult(request, decision, toolName);
|
|
3315
|
-
} else {
|
|
3316
|
-
const rlResult = await this.handleRateLimit(request, decision, toolName);
|
|
3317
|
-
result = rlResult.result;
|
|
3318
|
-
rateLimitResult = rlResult.rateLimitResult;
|
|
3319
|
-
}
|
|
3320
|
-
} else if (decision.action === "spend_limit") {
|
|
3321
|
-
if (!this.spendLimiter) {
|
|
3322
|
-
result = this.makeUnsupportedResult(request, decision, toolName);
|
|
3962
|
+
} else {
|
|
3963
|
+
const gate = decision.action === "require_approval" && this.approvalRouter ? await this.handleApproval(request, decision, toolName, toolArguments) : this.resolveActionGate(request, decision, toolName, toolArguments, {
|
|
3964
|
+
sessionBlocked,
|
|
3965
|
+
evidenceBlocked,
|
|
3966
|
+
driftBlocked,
|
|
3967
|
+
driftEvent,
|
|
3968
|
+
evidenceResult,
|
|
3969
|
+
dependencyResult
|
|
3970
|
+
});
|
|
3971
|
+
approvalOutcome = gate.approvalOutcome;
|
|
3972
|
+
approvalWaitMs = gate.approvalWaitMs;
|
|
3973
|
+
approvalContext = gate.approvalContext;
|
|
3974
|
+
rateLimitResult = gate.rateLimitResult;
|
|
3975
|
+
spendLimitResult = gate.spendLimitResult;
|
|
3976
|
+
if (!gate.proceed) {
|
|
3977
|
+
result = gate.result;
|
|
3323
3978
|
} else {
|
|
3324
|
-
const
|
|
3325
|
-
|
|
3326
|
-
|
|
3979
|
+
const budgetGate = this.gateBudgets(request, decision, toolName, toolArguments);
|
|
3980
|
+
budgetsChain = budgetGate.chain;
|
|
3981
|
+
let budgetBlock;
|
|
3982
|
+
if (budgetGate.kind === "approval") {
|
|
3983
|
+
const held = await this.handleBudgetApproval(
|
|
3984
|
+
request,
|
|
3985
|
+
decision,
|
|
3986
|
+
toolName,
|
|
3987
|
+
toolArguments,
|
|
3988
|
+
budgetGate
|
|
3989
|
+
);
|
|
3990
|
+
budgetApproval = held.audit;
|
|
3991
|
+
approvalWaitMs += held.waitMs;
|
|
3992
|
+
if (!held.proceed) budgetBlock = held.result;
|
|
3993
|
+
}
|
|
3994
|
+
if (budgetGate.kind === "blocked") {
|
|
3995
|
+
result = budgetGate.result;
|
|
3996
|
+
} else if (budgetBlock) {
|
|
3997
|
+
result = budgetBlock;
|
|
3998
|
+
} else {
|
|
3999
|
+
let ledgerBlock;
|
|
4000
|
+
try {
|
|
4001
|
+
budgetsChain = budgetGate.commit?.(auditRecordId) ?? budgetsChain;
|
|
4002
|
+
} catch (ledgerError) {
|
|
4003
|
+
const reason = ledgerError instanceof Error ? ledgerError.message : String(ledgerError);
|
|
4004
|
+
console.error(
|
|
4005
|
+
`[helio] Budget ledger write failed for tool "${toolName}"; blocking the call: ${reason}`
|
|
4006
|
+
);
|
|
4007
|
+
ledgerBlock = makeErrorResult(request, INTERNAL_ERROR, "budget ledger write failed", {
|
|
4008
|
+
blocked: true,
|
|
4009
|
+
reason: "budget_ledger_write_failed",
|
|
4010
|
+
failure_class: "budget_ledger_write_failed",
|
|
4011
|
+
failure_reason: reason
|
|
4012
|
+
});
|
|
4013
|
+
budgetsChain = [{ ledger_write_failed: true, reason }];
|
|
4014
|
+
}
|
|
4015
|
+
if (ledgerBlock) {
|
|
4016
|
+
result = ledgerBlock;
|
|
4017
|
+
} else {
|
|
4018
|
+
gate.commitRuleLimit?.();
|
|
4019
|
+
forwarded = true;
|
|
4020
|
+
result = await this.inner.forward(request);
|
|
4021
|
+
}
|
|
4022
|
+
}
|
|
3327
4023
|
}
|
|
3328
|
-
} else {
|
|
3329
|
-
result = this.makeDenyResult(request, decision);
|
|
3330
4024
|
}
|
|
3331
4025
|
} catch (error) {
|
|
3332
4026
|
forwardingError = error instanceof Error ? error : new Error(String(error));
|
|
@@ -3335,19 +4029,18 @@ var GovernedForwarder = class {
|
|
|
3335
4029
|
failure_reason: forwardingError.message
|
|
3336
4030
|
});
|
|
3337
4031
|
}
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
)
|
|
3344
|
-
|
|
3345
|
-
const succeeded = !hasJsonRpcError(result);
|
|
3346
|
-
this.evidenceStore.recordToolCall(request.sessionId, toolName, succeeded);
|
|
4032
|
+
try {
|
|
4033
|
+
if (forwarded && !isDryRun && this.evidenceStore && request.sessionId && toolName) {
|
|
4034
|
+
const succeeded = !hasJsonRpcError(result);
|
|
4035
|
+
this.evidenceStore.recordToolCall(request.sessionId, toolName, succeeded);
|
|
4036
|
+
}
|
|
4037
|
+
} catch (err) {
|
|
4038
|
+
console.error("[helio] dependency tracking failed after forward:", err);
|
|
3347
4039
|
}
|
|
3348
4040
|
const totalDurationMs = performance.now() - startTime;
|
|
3349
|
-
this.
|
|
4041
|
+
this.writeAuditRecordSafely(
|
|
3350
4042
|
request,
|
|
4043
|
+
auditRecordId,
|
|
3351
4044
|
timestamp,
|
|
3352
4045
|
toolName,
|
|
3353
4046
|
toolArguments,
|
|
@@ -3356,6 +4049,7 @@ var GovernedForwarder = class {
|
|
|
3356
4049
|
totalDurationMs,
|
|
3357
4050
|
approvalWaitMs,
|
|
3358
4051
|
flaggedDestructive,
|
|
4052
|
+
forwarded,
|
|
3359
4053
|
evidenceResult,
|
|
3360
4054
|
dependencyResult,
|
|
3361
4055
|
evidenceBlocked,
|
|
@@ -3363,12 +4057,320 @@ var GovernedForwarder = class {
|
|
|
3363
4057
|
approvalContext,
|
|
3364
4058
|
rateLimitResult,
|
|
3365
4059
|
spendLimitResult,
|
|
4060
|
+
budgetsChain,
|
|
4061
|
+
budgetApproval,
|
|
3366
4062
|
isDryRun,
|
|
3367
4063
|
forwardingError,
|
|
3368
4064
|
driftEvent ? { event: driftEvent, mode: driftMode } : void 0
|
|
3369
4065
|
);
|
|
3370
4066
|
return result;
|
|
3371
4067
|
}
|
|
4068
|
+
/**
|
|
4069
|
+
* Await the composite break-glass ticket for a budget overage (issue #14).
|
|
4070
|
+
*
|
|
4071
|
+
* The ticket is routed by the BUDGET's approval config (first breached
|
|
4072
|
+
* budget in config order), never the matched rule's. Deviation from rule
|
|
4073
|
+
* approvals, by design: timeout ALWAYS fails closed — `default_on_timeout:
|
|
4074
|
+
* allow` would forward an unapproved overage, and recording it as
|
|
4075
|
+
* `approved_overage` would be a lie while not recording it would corrupt
|
|
4076
|
+
* the pot. Money gates do not fail open.
|
|
4077
|
+
*/
|
|
4078
|
+
async handleBudgetApproval(request, decision, toolName, toolArguments, gate) {
|
|
4079
|
+
const router = this.approvalRouter;
|
|
4080
|
+
const approvalStart = performance.now();
|
|
4081
|
+
const outcome = await router.submit(
|
|
4082
|
+
{
|
|
4083
|
+
tool_name: toolName,
|
|
4084
|
+
tool_input: toolArguments ?? {},
|
|
4085
|
+
matched_rule: decision.matchedRule,
|
|
4086
|
+
session_id: request.sessionId ?? null,
|
|
4087
|
+
breached_budgets: gate.breachContexts,
|
|
4088
|
+
approval: gate.approval
|
|
4089
|
+
},
|
|
4090
|
+
request.signal
|
|
4091
|
+
);
|
|
4092
|
+
const waitMs = performance.now() - approvalStart;
|
|
4093
|
+
const ticket = outcome.ticketId ? router.getTicket(outcome.ticketId) : void 0;
|
|
4094
|
+
const denialReason = outcome.status === "denied" && outcome.reason ? outcome.reason : void 0;
|
|
4095
|
+
const audit = {
|
|
4096
|
+
outcome,
|
|
4097
|
+
...denialReason ? { denialReason } : {},
|
|
4098
|
+
...ticket?.escalated_at ? { escalatedAt: ticket.escalated_at, escalatedTo: [...ticket.escalated_to ?? []] } : {}
|
|
4099
|
+
};
|
|
4100
|
+
if (outcome.status === "approved" || outcome.status === "break_glass") {
|
|
4101
|
+
if (request.signal?.aborted) {
|
|
4102
|
+
return {
|
|
4103
|
+
proceed: false,
|
|
4104
|
+
result: this.makeClientDisconnectedBlockResult(request, decision),
|
|
4105
|
+
audit,
|
|
4106
|
+
waitMs
|
|
4107
|
+
};
|
|
4108
|
+
}
|
|
4109
|
+
return { proceed: true, audit, waitMs };
|
|
4110
|
+
}
|
|
4111
|
+
if (outcome.status === "denied") {
|
|
4112
|
+
const feedback2 = buildBudgetApprovalDeniedFeedback(
|
|
4113
|
+
decision,
|
|
4114
|
+
gate.breaches,
|
|
4115
|
+
outcome.resolvedBy,
|
|
4116
|
+
outcome.reason
|
|
4117
|
+
);
|
|
4118
|
+
return {
|
|
4119
|
+
proceed: false,
|
|
4120
|
+
result: makeErrorResult(
|
|
4121
|
+
request,
|
|
4122
|
+
POLICY_DENIED,
|
|
4123
|
+
`Budget overage denied by ${outcome.resolvedBy}`,
|
|
4124
|
+
{ ...feedback2 }
|
|
4125
|
+
),
|
|
4126
|
+
audit,
|
|
4127
|
+
waitMs
|
|
4128
|
+
};
|
|
4129
|
+
}
|
|
4130
|
+
if (outcome.status === "client_disconnected" || request.signal?.aborted) {
|
|
4131
|
+
return {
|
|
4132
|
+
proceed: false,
|
|
4133
|
+
result: this.makeClientDisconnectedBlockResult(request, decision),
|
|
4134
|
+
audit,
|
|
4135
|
+
waitMs
|
|
4136
|
+
};
|
|
4137
|
+
}
|
|
4138
|
+
if (outcome.status === "shutdown_cancelled") {
|
|
4139
|
+
const feedback2 = buildShutdownCancelledFeedback(decision);
|
|
4140
|
+
return {
|
|
4141
|
+
proceed: false,
|
|
4142
|
+
result: makeErrorResult(
|
|
4143
|
+
request,
|
|
4144
|
+
POLICY_DENIED,
|
|
4145
|
+
"Budget approval cancelled by proxy shutdown",
|
|
4146
|
+
{ ...feedback2 }
|
|
4147
|
+
),
|
|
4148
|
+
audit,
|
|
4149
|
+
waitMs
|
|
4150
|
+
};
|
|
4151
|
+
}
|
|
4152
|
+
const feedback = buildBudgetApprovalTimeoutFeedback(decision, gate.breaches, outcome.timeoutMs);
|
|
4153
|
+
return {
|
|
4154
|
+
proceed: false,
|
|
4155
|
+
result: makeErrorResult(request, POLICY_DENIED, "Budget approval timed out", {
|
|
4156
|
+
...feedback
|
|
4157
|
+
}),
|
|
4158
|
+
audit,
|
|
4159
|
+
waitMs
|
|
4160
|
+
};
|
|
4161
|
+
}
|
|
4162
|
+
/** writeAuditRecord, isolated: an audit-writer bug must not reject the response. */
|
|
4163
|
+
writeAuditRecordSafely(...args) {
|
|
4164
|
+
try {
|
|
4165
|
+
this.writeAuditRecord(...args);
|
|
4166
|
+
} catch (err) {
|
|
4167
|
+
console.error("[helio] audit record write failed:", err);
|
|
4168
|
+
}
|
|
4169
|
+
}
|
|
4170
|
+
/**
|
|
4171
|
+
* Everything the non-approval action branches decide, minus the forward
|
|
4172
|
+
* itself. Deliberately SYNCHRONOUS: the caller must reach the phase-3
|
|
4173
|
+
* commits without yielding to the microtask queue, or concurrent calls
|
|
4174
|
+
* could double-spend a peeked limiter slot. The approval branch (the only
|
|
4175
|
+
* one that genuinely waits) is dispatched by the caller directly.
|
|
4176
|
+
*/
|
|
4177
|
+
resolveActionGate(request, decision, toolName, toolArguments, pipeline) {
|
|
4178
|
+
if (pipeline.sessionBlocked) {
|
|
4179
|
+
return blocked(this.makeSessionRequiredBlockResult(request, decision));
|
|
4180
|
+
}
|
|
4181
|
+
if (pipeline.evidenceBlocked) {
|
|
4182
|
+
return blocked(
|
|
4183
|
+
this.makeEvidenceBlockResult(
|
|
4184
|
+
request,
|
|
4185
|
+
decision,
|
|
4186
|
+
pipeline.evidenceResult,
|
|
4187
|
+
pipeline.dependencyResult
|
|
4188
|
+
)
|
|
4189
|
+
);
|
|
4190
|
+
}
|
|
4191
|
+
if (pipeline.driftBlocked && pipeline.driftEvent) {
|
|
4192
|
+
return blocked(this.makeDriftBlockResult(request, pipeline.driftEvent));
|
|
4193
|
+
}
|
|
4194
|
+
switch (decision.action) {
|
|
4195
|
+
case "allow":
|
|
4196
|
+
return { proceed: true, approvalWaitMs: 0 };
|
|
4197
|
+
case "deny":
|
|
4198
|
+
return blocked(this.makeDenyResult(request, decision));
|
|
4199
|
+
case "require_approval":
|
|
4200
|
+
return blocked(this.makeUnsupportedResult(request, decision, toolName));
|
|
4201
|
+
case "rate_limit":
|
|
4202
|
+
if (!this.rateLimiter) {
|
|
4203
|
+
return blocked(this.makeUnsupportedResult(request, decision, toolName));
|
|
4204
|
+
}
|
|
4205
|
+
return this.handleRateLimit(request, decision, toolName);
|
|
4206
|
+
case "spend_limit":
|
|
4207
|
+
if (!this.spendLimiter) {
|
|
4208
|
+
return blocked(this.makeUnsupportedResult(request, decision, toolName));
|
|
4209
|
+
}
|
|
4210
|
+
return this.handleSpendLimit(request, decision, toolName, toolArguments);
|
|
4211
|
+
default:
|
|
4212
|
+
return blocked(this.makeDenyResult(request, decision));
|
|
4213
|
+
}
|
|
4214
|
+
}
|
|
4215
|
+
/**
|
|
4216
|
+
* Phase 2: check every budget the call feeds, all-or-nothing (issue #14).
|
|
4217
|
+
*
|
|
4218
|
+
* Any `on_exceed: deny` breach (or invalid amount) denies and records
|
|
4219
|
+
* NOTHING on any budget — rejected calls never consume budget anywhere.
|
|
4220
|
+
* Breaches that are all `on_exceed: require_approval` yield the `approval`
|
|
4221
|
+
* variant: one composite break-glass ticket per call, and only an explicit
|
|
4222
|
+
* approval commits (breached budgets as `approved_overage`). On proceed,
|
|
4223
|
+
* the returned `commit` records every charge together (ledger rows first,
|
|
4224
|
+
* atomically, referencing the pre-generated audit id).
|
|
4225
|
+
*/
|
|
4226
|
+
gateBudgets(request, decision, toolName, toolArguments) {
|
|
4227
|
+
const engine = this.budgetEngine;
|
|
4228
|
+
if (!engine) return { kind: "proceed" };
|
|
4229
|
+
const { charges, failures } = engine.resolveCharges({
|
|
4230
|
+
toolName,
|
|
4231
|
+
toolArguments,
|
|
4232
|
+
sessionId: request.sessionId ?? null,
|
|
4233
|
+
senderId: null
|
|
4234
|
+
// adapter context; absent on the MCP path
|
|
4235
|
+
});
|
|
4236
|
+
if (charges.length === 0 && failures.length === 0) return { kind: "proceed" };
|
|
4237
|
+
const peek = charges.length > 0 ? engine.peekAll(charges) : { allowed: true, entries: [] };
|
|
4238
|
+
const breaches = peek.entries.filter((entry) => !entry.allowed);
|
|
4239
|
+
const anyHardDeny = failures.length > 0 || breaches.some((entry) => entry.budget.onExceed === "deny");
|
|
4240
|
+
if (breaches.length > 0) engine.reportBreaches(breaches);
|
|
4241
|
+
if (anyHardDeny) {
|
|
4242
|
+
if (failures.length > 0) {
|
|
4243
|
+
console.error(
|
|
4244
|
+
`[helio] Warning: budget ${failures.map((f) => `"${f.budget.name}"`).join(", ")} could not resolve a valid amount for tool "${toolName}", denying request`
|
|
4245
|
+
);
|
|
4246
|
+
}
|
|
4247
|
+
const feedback = buildBudgetExceededFeedback(decision, breaches, failures);
|
|
4248
|
+
const message = failures.length > 0 ? `Budget denied: invalid amount for ${failures.map((f) => `"${f.budget.name}"`).join(", ")}` : `Budget exceeded: ${breaches.map((entry) => `"${entry.budget.name}"`).join(", ")}`;
|
|
4249
|
+
return {
|
|
4250
|
+
kind: "blocked",
|
|
4251
|
+
result: makeErrorResult(request, POLICY_DENIED, message, { ...feedback }),
|
|
4252
|
+
chain: [
|
|
4253
|
+
...peek.entries.map((entry) => budgetChainBlock(entry)),
|
|
4254
|
+
...failures.map((failure) => ({
|
|
4255
|
+
name: failure.budget.name,
|
|
4256
|
+
bucket_key: failure.bucketKey,
|
|
4257
|
+
allowed: false,
|
|
4258
|
+
reason: failure.reason,
|
|
4259
|
+
spent: failure.spent,
|
|
4260
|
+
limit: failure.budget.limit,
|
|
4261
|
+
remaining: failure.remaining,
|
|
4262
|
+
currency: failure.budget.currency
|
|
4263
|
+
}))
|
|
4264
|
+
]
|
|
4265
|
+
};
|
|
4266
|
+
}
|
|
4267
|
+
const peekBlockByName = new Map(
|
|
4268
|
+
peek.entries.map((entry) => [entry.budget.name, budgetChainBlock(entry)])
|
|
4269
|
+
);
|
|
4270
|
+
const commit = (auditRecordId, kinds) => engine.recordAll(charges, {
|
|
4271
|
+
kind: "spend",
|
|
4272
|
+
...kinds ? { kinds } : {},
|
|
4273
|
+
auditRecordId,
|
|
4274
|
+
origin: "mcp",
|
|
4275
|
+
toolName,
|
|
4276
|
+
timestampIso: (/* @__PURE__ */ new Date()).toISOString()
|
|
4277
|
+
}).map((entry) => {
|
|
4278
|
+
const kind = kinds?.get(entry.budget.name) ?? "spend";
|
|
4279
|
+
const frozen = peekBlockByName.get(entry.budget.name);
|
|
4280
|
+
return entry.stale && frozen ? { ...frozen, kind, stale: true } : budgetChainBlock(entry, kind);
|
|
4281
|
+
});
|
|
4282
|
+
if (breaches.length > 0) {
|
|
4283
|
+
if (!this.approvalRouter) {
|
|
4284
|
+
console.error(
|
|
4285
|
+
`[helio] Budget ${breaches.map((b) => `"${b.budget.name}"`).join(", ")} requires break-glass approval but no approval router is configured; denying request`
|
|
4286
|
+
);
|
|
4287
|
+
const feedback = buildBudgetExceededFeedback(decision, breaches, []);
|
|
4288
|
+
return {
|
|
4289
|
+
kind: "blocked",
|
|
4290
|
+
result: makeErrorResult(
|
|
4291
|
+
request,
|
|
4292
|
+
POLICY_DENIED,
|
|
4293
|
+
`Budget exceeded: ${breaches.map((entry) => `"${entry.budget.name}"`).join(", ")}`,
|
|
4294
|
+
{ ...feedback }
|
|
4295
|
+
),
|
|
4296
|
+
chain: peek.entries.map((entry) => budgetChainBlock(entry))
|
|
4297
|
+
};
|
|
4298
|
+
}
|
|
4299
|
+
const kinds = new Map(
|
|
4300
|
+
breaches.map((entry) => [entry.budget.name, "approved_overage"])
|
|
4301
|
+
);
|
|
4302
|
+
return {
|
|
4303
|
+
kind: "approval",
|
|
4304
|
+
chain: peek.entries.map((entry) => budgetChainBlock(entry)),
|
|
4305
|
+
commit: (auditRecordId) => commit(auditRecordId, kinds),
|
|
4306
|
+
breaches,
|
|
4307
|
+
breachContexts: breaches.map((entry) => ({
|
|
4308
|
+
name: entry.budget.name,
|
|
4309
|
+
limit: entry.budget.limit,
|
|
4310
|
+
spent: entry.spent,
|
|
4311
|
+
attempted_amount: entry.amount,
|
|
4312
|
+
currency: entry.budget.currency,
|
|
4313
|
+
window: entry.budget.windowRaw
|
|
4314
|
+
})),
|
|
4315
|
+
approval: breaches[0]?.budget.approval ?? { channel: "dashboard" }
|
|
4316
|
+
};
|
|
4317
|
+
}
|
|
4318
|
+
return {
|
|
4319
|
+
kind: "proceed",
|
|
4320
|
+
chain: peek.entries.map((entry) => budgetChainBlock(entry)),
|
|
4321
|
+
commit: (auditRecordId) => commit(auditRecordId)
|
|
4322
|
+
};
|
|
4323
|
+
}
|
|
4324
|
+
/**
|
|
4325
|
+
* Reject a `tools/call` that carries no usable tool name and record it.
|
|
4326
|
+
*
|
|
4327
|
+
* The rejection is its own audit shape, not a governed decision: no rule was
|
|
4328
|
+
* evaluated, so it is written directly (like {@link writeDriftAuditRecord})
|
|
4329
|
+
* rather than threaded through {@link writeAuditRecord}, whose
|
|
4330
|
+
* `PolicyDecision.action` union has no `rejected` member and whose
|
|
4331
|
+
* forwarded-upstream logic does not apply. The raw `params` are preserved in
|
|
4332
|
+
* `tool_input` so an investigator can see exactly what a lenient upstream
|
|
4333
|
+
* could have keyed off.
|
|
4334
|
+
*/
|
|
4335
|
+
rejectNamelessToolsCall(request, params, timestamp, startTime) {
|
|
4336
|
+
const result = makeErrorResult(
|
|
4337
|
+
request,
|
|
4338
|
+
INVALID_PARAMS,
|
|
4339
|
+
"tools/call requires a string params.name",
|
|
4340
|
+
{ blocked: true, reason: "missing_tool_name" }
|
|
4341
|
+
);
|
|
4342
|
+
if (this.auditWriter) {
|
|
4343
|
+
const toolInput = { raw_params: params ?? null };
|
|
4344
|
+
this.auditWriter.pushImmediate({
|
|
4345
|
+
timestamp,
|
|
4346
|
+
session_id: request.sessionId ?? null,
|
|
4347
|
+
agent_id: null,
|
|
4348
|
+
environment: this.environment ?? null,
|
|
4349
|
+
tool_name: "<nameless>",
|
|
4350
|
+
tool_input: toolInput,
|
|
4351
|
+
policy_decision: "rejected",
|
|
4352
|
+
block_reason: "missing_tool_name",
|
|
4353
|
+
matched_rule: null,
|
|
4354
|
+
matched_rule_index: null,
|
|
4355
|
+
evidence_chain: null,
|
|
4356
|
+
approval_status: null,
|
|
4357
|
+
approved_by: null,
|
|
4358
|
+
upstream_response: null,
|
|
4359
|
+
upstream_error: null,
|
|
4360
|
+
upstream_http_status: null,
|
|
4361
|
+
upstream_latency_ms: null,
|
|
4362
|
+
total_duration_ms: performance.now() - startTime,
|
|
4363
|
+
approval_wait_ms: 0,
|
|
4364
|
+
proxy_compute_ms: performance.now() - startTime,
|
|
4365
|
+
flagged_destructive: false,
|
|
4366
|
+
dry_run: false,
|
|
4367
|
+
record_kind: "tool_call",
|
|
4368
|
+
origin: "mcp",
|
|
4369
|
+
metadata: null
|
|
4370
|
+
});
|
|
4371
|
+
}
|
|
4372
|
+
return result;
|
|
4373
|
+
}
|
|
3372
4374
|
async handleApproval(request, decision, toolName, toolArguments) {
|
|
3373
4375
|
const router = this.approvalRouter;
|
|
3374
4376
|
const approvalStart = performance.now();
|
|
@@ -3392,81 +4394,126 @@ var GovernedForwarder = class {
|
|
|
3392
4394
|
escalated_to: [...ticket.escalated_to ?? []]
|
|
3393
4395
|
} : {}
|
|
3394
4396
|
} : void 0;
|
|
3395
|
-
let result;
|
|
3396
4397
|
if (outcome.status === "approved" || outcome.status === "break_glass") {
|
|
3397
4398
|
if (request.signal?.aborted) {
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
result = makeErrorResult(request, POLICY_DENIED, message, { ...feedback });
|
|
3406
|
-
} else if (outcome.status === "client_disconnected") {
|
|
3407
|
-
result = this.makeClientDisconnectedBlockResult(request, decision);
|
|
3408
|
-
} else if (outcome.status === "shutdown_cancelled") {
|
|
3409
|
-
const feedback = buildShutdownCancelledFeedback(decision);
|
|
3410
|
-
const message = decision.matchedRule?.feedback?.message ?? "Approval cancelled by proxy shutdown";
|
|
3411
|
-
result = makeErrorResult(request, POLICY_DENIED, message, { ...feedback });
|
|
3412
|
-
} else {
|
|
3413
|
-
if (router.defaultOnTimeout === "allow" && !request.signal?.aborted) {
|
|
3414
|
-
result = await this.inner.forward(request);
|
|
3415
|
-
} else if (request.signal?.aborted) {
|
|
3416
|
-
result = this.makeClientDisconnectedBlockResult(request, decision);
|
|
3417
|
-
} else {
|
|
3418
|
-
const feedback = buildApprovalTimeoutFeedback(decision, outcome.timeoutMs);
|
|
3419
|
-
const message = decision.matchedRule?.feedback?.message ?? `Approval timed out`;
|
|
3420
|
-
result = makeErrorResult(request, POLICY_DENIED, message, { ...feedback });
|
|
4399
|
+
return {
|
|
4400
|
+
proceed: false,
|
|
4401
|
+
result: this.makeClientDisconnectedBlockResult(request, decision),
|
|
4402
|
+
approvalOutcome: outcome,
|
|
4403
|
+
approvalWaitMs,
|
|
4404
|
+
approvalContext
|
|
4405
|
+
};
|
|
3421
4406
|
}
|
|
4407
|
+
return { proceed: true, approvalOutcome: outcome, approvalWaitMs, approvalContext };
|
|
4408
|
+
}
|
|
4409
|
+
if (outcome.status === "denied") {
|
|
4410
|
+
const feedback2 = buildApprovalDeniedFeedback(decision, outcome.resolvedBy, outcome.reason);
|
|
4411
|
+
const message2 = decision.matchedRule?.feedback?.message ?? `Approval denied by ${outcome.resolvedBy}`;
|
|
4412
|
+
return {
|
|
4413
|
+
proceed: false,
|
|
4414
|
+
result: makeErrorResult(request, POLICY_DENIED, message2, { ...feedback2 }),
|
|
4415
|
+
approvalOutcome: outcome,
|
|
4416
|
+
approvalWaitMs,
|
|
4417
|
+
approvalContext
|
|
4418
|
+
};
|
|
3422
4419
|
}
|
|
3423
|
-
|
|
4420
|
+
if (outcome.status === "client_disconnected") {
|
|
4421
|
+
return {
|
|
4422
|
+
proceed: false,
|
|
4423
|
+
result: this.makeClientDisconnectedBlockResult(request, decision),
|
|
4424
|
+
approvalOutcome: outcome,
|
|
4425
|
+
approvalWaitMs,
|
|
4426
|
+
approvalContext
|
|
4427
|
+
};
|
|
4428
|
+
}
|
|
4429
|
+
if (outcome.status === "shutdown_cancelled") {
|
|
4430
|
+
const feedback2 = buildShutdownCancelledFeedback(decision);
|
|
4431
|
+
const message2 = decision.matchedRule?.feedback?.message ?? "Approval cancelled by proxy shutdown";
|
|
4432
|
+
return {
|
|
4433
|
+
proceed: false,
|
|
4434
|
+
result: makeErrorResult(request, POLICY_DENIED, message2, { ...feedback2 }),
|
|
4435
|
+
approvalOutcome: outcome,
|
|
4436
|
+
approvalWaitMs,
|
|
4437
|
+
approvalContext
|
|
4438
|
+
};
|
|
4439
|
+
}
|
|
4440
|
+
if (router.defaultOnTimeout === "allow" && !request.signal?.aborted) {
|
|
4441
|
+
return { proceed: true, approvalOutcome: outcome, approvalWaitMs, approvalContext };
|
|
4442
|
+
}
|
|
4443
|
+
if (request.signal?.aborted) {
|
|
4444
|
+
return {
|
|
4445
|
+
proceed: false,
|
|
4446
|
+
result: this.makeClientDisconnectedBlockResult(request, decision),
|
|
4447
|
+
approvalOutcome: outcome,
|
|
4448
|
+
approvalWaitMs,
|
|
4449
|
+
approvalContext
|
|
4450
|
+
};
|
|
4451
|
+
}
|
|
4452
|
+
const feedback = buildApprovalTimeoutFeedback(decision, outcome.timeoutMs);
|
|
4453
|
+
const message = decision.matchedRule?.feedback?.message ?? `Approval timed out`;
|
|
4454
|
+
return {
|
|
4455
|
+
proceed: false,
|
|
4456
|
+
result: makeErrorResult(request, POLICY_DENIED, message, { ...feedback }),
|
|
4457
|
+
approvalOutcome: outcome,
|
|
4458
|
+
approvalWaitMs,
|
|
4459
|
+
approvalContext
|
|
4460
|
+
};
|
|
3424
4461
|
}
|
|
3425
|
-
|
|
4462
|
+
handleRateLimit(request, decision, toolName) {
|
|
3426
4463
|
const limiter = this.rateLimiter;
|
|
3427
4464
|
const limits = decision.matchedRule?.limits;
|
|
3428
4465
|
if (!limits?.maxCalls || !limits.windowMs) {
|
|
3429
|
-
const
|
|
4466
|
+
const result = this.makePolicyMisconfiguredResult(
|
|
3430
4467
|
request,
|
|
3431
4468
|
decision,
|
|
3432
4469
|
`Policy misconfigured: rate_limit rule for "${toolName}" requires limits.max_calls and limits.window`
|
|
3433
4470
|
);
|
|
3434
4471
|
return {
|
|
3435
|
-
|
|
4472
|
+
proceed: false,
|
|
4473
|
+
result,
|
|
4474
|
+
approvalWaitMs: 0,
|
|
3436
4475
|
rateLimitResult: { allowed: false, current: 0, limit: 0, windowMs: 0, resetAtMs: 0 }
|
|
3437
4476
|
};
|
|
3438
4477
|
}
|
|
3439
4478
|
const key = this.buildLimitKey(limits.key, toolName, request);
|
|
3440
|
-
const
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
windowMs: limits.windowMs
|
|
3444
|
-
});
|
|
3445
|
-
let result;
|
|
3446
|
-
if (rateLimitResult.allowed) {
|
|
3447
|
-
result = await this.inner.forward(request);
|
|
3448
|
-
} else {
|
|
4479
|
+
const params = { key, maxCalls: limits.maxCalls, windowMs: limits.windowMs };
|
|
4480
|
+
const rateLimitResult = limiter.peek(params);
|
|
4481
|
+
if (!rateLimitResult.allowed) {
|
|
3449
4482
|
const feedback = buildRateLimitedFeedback(decision, rateLimitResult);
|
|
3450
4483
|
const message = decision.matchedRule?.feedback?.message ?? `Rate limit exceeded for ${key}`;
|
|
3451
|
-
|
|
4484
|
+
return {
|
|
4485
|
+
proceed: false,
|
|
4486
|
+
result: makeErrorResult(request, POLICY_DENIED, message, { ...feedback }),
|
|
4487
|
+
approvalWaitMs: 0,
|
|
4488
|
+
rateLimitResult
|
|
4489
|
+
};
|
|
3452
4490
|
}
|
|
3453
|
-
return {
|
|
4491
|
+
return {
|
|
4492
|
+
proceed: true,
|
|
4493
|
+
approvalWaitMs: 0,
|
|
4494
|
+
rateLimitResult,
|
|
4495
|
+
commitRuleLimit: () => {
|
|
4496
|
+
limiter.record(params);
|
|
4497
|
+
}
|
|
4498
|
+
};
|
|
3454
4499
|
}
|
|
3455
|
-
|
|
4500
|
+
handleSpendLimit(request, decision, toolName, toolArguments) {
|
|
3456
4501
|
const limiter = this.spendLimiter;
|
|
3457
4502
|
const maxSpend = decision.matchedRule?.limits?.maxSpend;
|
|
3458
4503
|
if (!maxSpend) {
|
|
3459
|
-
const
|
|
4504
|
+
const result = this.makePolicyMisconfiguredResult(
|
|
3460
4505
|
request,
|
|
3461
4506
|
decision,
|
|
3462
4507
|
`Policy misconfigured: spend_limit rule for "${toolName}" requires limits.max_spend`
|
|
3463
4508
|
);
|
|
3464
4509
|
return {
|
|
3465
|
-
|
|
4510
|
+
proceed: false,
|
|
4511
|
+
result,
|
|
4512
|
+
approvalWaitMs: 0,
|
|
3466
4513
|
spendLimitResult: { allowed: false, currentSpend: 0, limit: 0, windowMs: 0, resetAtMs: 0 }
|
|
3467
4514
|
};
|
|
3468
4515
|
}
|
|
3469
|
-
const key = this.
|
|
4516
|
+
const key = this.buildSpendLimitKey(maxSpend.key, toolName, request, decision.matchedRule.index);
|
|
3470
4517
|
const rawAmount = resolvePath(maxSpend.field, toolArguments ?? {});
|
|
3471
4518
|
if (typeof rawAmount !== "number") {
|
|
3472
4519
|
console.error(
|
|
@@ -3482,39 +4529,45 @@ var GovernedForwarder = class {
|
|
|
3482
4529
|
reason: "invalid_amount"
|
|
3483
4530
|
};
|
|
3484
4531
|
const feedback = buildSpendLimitedFeedback(decision, invalidAmount, maxSpend.currency);
|
|
3485
|
-
const
|
|
4532
|
+
const result = makeErrorResult(
|
|
3486
4533
|
request,
|
|
3487
4534
|
POLICY_DENIED,
|
|
3488
4535
|
`Spend limit denied: invalid amount for field "${maxSpend.field}"`,
|
|
3489
4536
|
{ ...feedback }
|
|
3490
4537
|
);
|
|
3491
4538
|
return {
|
|
3492
|
-
|
|
4539
|
+
proceed: false,
|
|
4540
|
+
result,
|
|
4541
|
+
approvalWaitMs: 0,
|
|
3493
4542
|
spendLimitResult: invalidAmount
|
|
3494
4543
|
};
|
|
3495
4544
|
}
|
|
3496
|
-
const
|
|
3497
|
-
|
|
3498
|
-
amount: rawAmount,
|
|
3499
|
-
limit: maxSpend.limit,
|
|
3500
|
-
windowMs: maxSpend.windowMs
|
|
3501
|
-
});
|
|
4545
|
+
const params = { key, amount: rawAmount, limit: maxSpend.limit, windowMs: maxSpend.windowMs };
|
|
4546
|
+
const spendLimitResult = limiter.peek(params);
|
|
3502
4547
|
if (spendLimitResult.reason === "invalid_amount") {
|
|
3503
4548
|
console.error(
|
|
3504
4549
|
`[helio] Warning: spend limit field "${maxSpend.field}" resolved to invalid amount (${String(rawAmount)}) for tool "${toolName}" (rule: ${decision.matchedRule.name ?? "unnamed"}), denying request`
|
|
3505
4550
|
);
|
|
3506
|
-
} else {
|
|
3507
|
-
limiter.setCurrency(key, maxSpend.currency);
|
|
3508
4551
|
}
|
|
3509
|
-
|
|
3510
|
-
if (spendLimitResult.allowed) {
|
|
3511
|
-
result = await this.inner.forward(request);
|
|
3512
|
-
} else {
|
|
4552
|
+
if (!spendLimitResult.allowed) {
|
|
3513
4553
|
const feedback = buildSpendLimitedFeedback(decision, spendLimitResult, maxSpend.currency);
|
|
3514
4554
|
const message = spendLimitResult.reason === "invalid_amount" ? `Spend limit denied: invalid amount for field "${maxSpend.field}"` : decision.matchedRule.feedback?.message ?? `Spend limit exceeded for ${key} (${maxSpend.currency})`;
|
|
3515
|
-
|
|
4555
|
+
return {
|
|
4556
|
+
proceed: false,
|
|
4557
|
+
result: makeErrorResult(request, POLICY_DENIED, message, { ...feedback }),
|
|
4558
|
+
approvalWaitMs: 0,
|
|
4559
|
+
spendLimitResult
|
|
4560
|
+
};
|
|
3516
4561
|
}
|
|
3517
|
-
return {
|
|
4562
|
+
return {
|
|
4563
|
+
proceed: true,
|
|
4564
|
+
approvalWaitMs: 0,
|
|
4565
|
+
spendLimitResult,
|
|
4566
|
+
commitRuleLimit: () => {
|
|
4567
|
+
limiter.record(params);
|
|
4568
|
+
limiter.setCurrency(key, maxSpend.currency);
|
|
4569
|
+
}
|
|
4570
|
+
};
|
|
3518
4571
|
}
|
|
3519
4572
|
/**
|
|
3520
4573
|
* Handle dry-run mode: compute what would have happened without forwarding
|
|
@@ -3553,7 +4606,12 @@ var GovernedForwarder = class {
|
|
|
3553
4606
|
wouldForward = false;
|
|
3554
4607
|
limitsOk = false;
|
|
3555
4608
|
} else {
|
|
3556
|
-
const key = this.
|
|
4609
|
+
const key = this.buildSpendLimitKey(
|
|
4610
|
+
maxSpend.key,
|
|
4611
|
+
toolName,
|
|
4612
|
+
request,
|
|
4613
|
+
decision.matchedRule.index
|
|
4614
|
+
);
|
|
3557
4615
|
const peekResult = this.spendLimiter.peek({
|
|
3558
4616
|
key,
|
|
3559
4617
|
amount: rawAmount,
|
|
@@ -3568,7 +4626,42 @@ var GovernedForwarder = class {
|
|
|
3568
4626
|
break;
|
|
3569
4627
|
}
|
|
3570
4628
|
}
|
|
3571
|
-
|
|
4629
|
+
let budgets;
|
|
4630
|
+
if (wouldForward && this.budgetEngine) {
|
|
4631
|
+
const { charges, failures } = this.budgetEngine.resolveCharges({
|
|
4632
|
+
toolName,
|
|
4633
|
+
toolArguments,
|
|
4634
|
+
sessionId: request.sessionId ?? null,
|
|
4635
|
+
senderId: null
|
|
4636
|
+
});
|
|
4637
|
+
if (failures.length > 0 || charges.length > 0) {
|
|
4638
|
+
const peek = charges.length > 0 ? this.budgetEngine.peekAll(charges) : { allowed: true, entries: [] };
|
|
4639
|
+
const ok = failures.length === 0 && peek.allowed;
|
|
4640
|
+
wouldForward &&= ok;
|
|
4641
|
+
limitsOk &&= ok;
|
|
4642
|
+
budgets = [
|
|
4643
|
+
...peek.entries.map((entry) => budgetChainBlock(entry)),
|
|
4644
|
+
...failures.map((failure) => ({
|
|
4645
|
+
name: failure.budget.name,
|
|
4646
|
+
bucket_key: failure.bucketKey,
|
|
4647
|
+
allowed: false,
|
|
4648
|
+
reason: failure.reason,
|
|
4649
|
+
spent: failure.spent,
|
|
4650
|
+
limit: failure.budget.limit,
|
|
4651
|
+
remaining: failure.remaining,
|
|
4652
|
+
currency: failure.budget.currency
|
|
4653
|
+
}))
|
|
4654
|
+
];
|
|
4655
|
+
}
|
|
4656
|
+
}
|
|
4657
|
+
return this.makeDryRunResult(
|
|
4658
|
+
request,
|
|
4659
|
+
decision,
|
|
4660
|
+
wouldForward,
|
|
4661
|
+
evidenceSatisfied,
|
|
4662
|
+
limitsOk,
|
|
4663
|
+
budgets
|
|
4664
|
+
);
|
|
3572
4665
|
}
|
|
3573
4666
|
/** Construct a limit bucket key based on the configured key type. */
|
|
3574
4667
|
buildLimitKey(keyType, toolName, request) {
|
|
@@ -3596,19 +4689,17 @@ var GovernedForwarder = class {
|
|
|
3596
4689
|
return `tool:${toolName}`;
|
|
3597
4690
|
}
|
|
3598
4691
|
}
|
|
3599
|
-
/**
|
|
3600
|
-
|
|
3601
|
-
|
|
4692
|
+
/**
|
|
4693
|
+
* Construct a spend bucket key via the shared {@link spendBucketKey}
|
|
4694
|
+
* composer — see its doc for why spend buckets are rule-discriminated.
|
|
4695
|
+
* Rate buckets keep the undiscriminated keys.
|
|
4696
|
+
*/
|
|
4697
|
+
buildSpendLimitKey(keyType, toolName, request, ruleIndex) {
|
|
4698
|
+
return spendBucketKey(this.buildLimitKey(keyType, toolName, request), ruleIndex);
|
|
3602
4699
|
}
|
|
3603
|
-
writeAuditRecord(request, timestamp, toolName, toolArguments, decision, result, totalDurationMs, approvalWaitMs, flaggedDestructive, evidenceResult, dependencyResult, evidenceBlocked, approvalOutcome, approvalContext, rateLimitResult, spendLimitResult, isDryRun, forwardingError, drift) {
|
|
4700
|
+
writeAuditRecord(request, auditRecordId, timestamp, toolName, toolArguments, decision, result, totalDurationMs, approvalWaitMs, flaggedDestructive, forwarded, evidenceResult, dependencyResult, evidenceBlocked, approvalOutcome, approvalContext, rateLimitResult, spendLimitResult, budgetsChain, budgetApproval, isDryRun, forwardingError, drift) {
|
|
3604
4701
|
if (!this.auditWriter) return;
|
|
3605
|
-
const
|
|
3606
|
-
decision,
|
|
3607
|
-
approvalOutcome,
|
|
3608
|
-
rateLimitResult,
|
|
3609
|
-
spendLimitResult
|
|
3610
|
-
);
|
|
3611
|
-
const actuallyForwarded = wasForwarded && !isDryRun;
|
|
4702
|
+
const actuallyForwarded = forwarded && !isDryRun;
|
|
3612
4703
|
const hadForwardingError = forwardingError !== void 0;
|
|
3613
4704
|
let upstreamError = null;
|
|
3614
4705
|
const upstreamHttpStatus = actuallyForwarded && !hadForwardingError ? result.response.status : null;
|
|
@@ -3626,12 +4717,13 @@ var GovernedForwarder = class {
|
|
|
3626
4717
|
upstreamError = "invalid spend amount";
|
|
3627
4718
|
}
|
|
3628
4719
|
let evidenceChain = buildEvidenceChain(evidenceResult, dependencyResult, evidenceBlocked);
|
|
3629
|
-
|
|
4720
|
+
const breakGlassOutcome = approvalOutcome?.status === "break_glass" ? approvalOutcome : budgetApproval?.outcome.status === "break_glass" ? budgetApproval.outcome : void 0;
|
|
4721
|
+
if (breakGlassOutcome && "reason" in breakGlassOutcome) {
|
|
3630
4722
|
evidenceChain = {
|
|
3631
4723
|
...evidenceChain ?? {},
|
|
3632
4724
|
break_glass: {
|
|
3633
|
-
reason:
|
|
3634
|
-
invoked_by:
|
|
4725
|
+
reason: breakGlassOutcome.reason,
|
|
4726
|
+
invoked_by: breakGlassOutcome.resolvedBy
|
|
3635
4727
|
}
|
|
3636
4728
|
};
|
|
3637
4729
|
}
|
|
@@ -3641,6 +4733,22 @@ var GovernedForwarder = class {
|
|
|
3641
4733
|
approval: { ...approvalContext }
|
|
3642
4734
|
};
|
|
3643
4735
|
}
|
|
4736
|
+
if (budgetApproval && budgetApproval.outcome.ticketId) {
|
|
4737
|
+
const outcome = budgetApproval.outcome;
|
|
4738
|
+
evidenceChain = {
|
|
4739
|
+
...evidenceChain ?? {},
|
|
4740
|
+
budget_approval: {
|
|
4741
|
+
ticket_id: outcome.ticketId,
|
|
4742
|
+
status: outcome.status,
|
|
4743
|
+
..."resolvedBy" in outcome ? { resolved_by: outcome.resolvedBy } : {},
|
|
4744
|
+
...budgetApproval.denialReason ? { denial_reason: budgetApproval.denialReason } : {},
|
|
4745
|
+
...budgetApproval.escalatedAt ? {
|
|
4746
|
+
escalated_at: budgetApproval.escalatedAt,
|
|
4747
|
+
escalated_to: budgetApproval.escalatedTo ?? []
|
|
4748
|
+
} : {}
|
|
4749
|
+
}
|
|
4750
|
+
};
|
|
4751
|
+
}
|
|
3644
4752
|
if (rateLimitResult) {
|
|
3645
4753
|
evidenceChain = {
|
|
3646
4754
|
...evidenceChain ?? {},
|
|
@@ -3666,6 +4774,12 @@ var GovernedForwarder = class {
|
|
|
3666
4774
|
}
|
|
3667
4775
|
};
|
|
3668
4776
|
}
|
|
4777
|
+
if (budgetsChain && budgetsChain.length > 0) {
|
|
4778
|
+
evidenceChain = {
|
|
4779
|
+
...evidenceChain ?? {},
|
|
4780
|
+
budgets: budgetsChain
|
|
4781
|
+
};
|
|
4782
|
+
}
|
|
3669
4783
|
if (drift) {
|
|
3670
4784
|
evidenceChain = {
|
|
3671
4785
|
...evidenceChain ?? {},
|
|
@@ -3688,8 +4802,11 @@ var GovernedForwarder = class {
|
|
|
3688
4802
|
matched_rule: decision.matchedRule?.name ?? null,
|
|
3689
4803
|
matched_rule_index: decision.matchedRule?.index ?? null,
|
|
3690
4804
|
evidence_chain: evidenceChain,
|
|
3691
|
-
|
|
3692
|
-
|
|
4805
|
+
// The approval columns describe the rule gate when one ran; on a
|
|
4806
|
+
// budget-only ticket (no rule approval) they carry the break-glass
|
|
4807
|
+
// outcome so a human-denied overage reads as denied here too.
|
|
4808
|
+
approval_status: (approvalOutcome ?? budgetApproval?.outcome)?.status ?? null,
|
|
4809
|
+
approved_by: approvedByOf(approvalOutcome ?? budgetApproval?.outcome),
|
|
3693
4810
|
upstream_response: actuallyForwarded && !hadForwardingError ? result.response.body : null,
|
|
3694
4811
|
upstream_error: upstreamError,
|
|
3695
4812
|
upstream_http_status: upstreamHttpStatus,
|
|
@@ -3703,11 +4820,11 @@ var GovernedForwarder = class {
|
|
|
3703
4820
|
origin: "mcp",
|
|
3704
4821
|
metadata: null
|
|
3705
4822
|
};
|
|
3706
|
-
const isEnforcementDecision = !isDryRun && (!
|
|
4823
|
+
const isEnforcementDecision = !isDryRun && (!forwarded || approvalOutcome !== void 0 || budgetApproval !== void 0);
|
|
3707
4824
|
if (isEnforcementDecision) {
|
|
3708
|
-
this.auditWriter.pushImmediate(record);
|
|
4825
|
+
this.auditWriter.pushImmediate(record, auditRecordId);
|
|
3709
4826
|
} else {
|
|
3710
|
-
this.auditWriter.push(record);
|
|
4827
|
+
this.auditWriter.push(record, auditRecordId);
|
|
3711
4828
|
}
|
|
3712
4829
|
}
|
|
3713
4830
|
makeDriftBlockResult(request, drift) {
|
|
@@ -3738,21 +4855,21 @@ var GovernedForwarder = class {
|
|
|
3738
4855
|
const message = decision.matchedRule ? `Action "${action}" matched by ${decision.matchedRule.name ? `"${decision.matchedRule.name}"` : `rule[${String(decision.matchedRule.index)}]`} is not yet supported` : `Destructive tool "${toolName ?? "unknown"}" requires approval (flag_destructive policy)`;
|
|
3739
4856
|
return makeErrorResult(request, POLICY_DENIED, message, {
|
|
3740
4857
|
blocked: true,
|
|
3741
|
-
|
|
3742
|
-
ruleIndex: decision.matchedRule?.index ?? null,
|
|
4858
|
+
...ruleInfo(decision.matchedRule),
|
|
3743
4859
|
action,
|
|
3744
4860
|
reason: decision.reason,
|
|
3745
4861
|
unsupported: true
|
|
3746
4862
|
});
|
|
3747
4863
|
}
|
|
3748
|
-
makeDryRunResult(request, decision, wouldForward, evidenceSatisfied, limitsOk) {
|
|
4864
|
+
makeDryRunResult(request, decision, wouldForward, evidenceSatisfied, limitsOk, budgets) {
|
|
3749
4865
|
const payload = {
|
|
3750
4866
|
dry_run: true,
|
|
3751
4867
|
would_forward: wouldForward,
|
|
3752
4868
|
policy_decision: decision.action,
|
|
3753
4869
|
matched_rule: decision.matchedRule?.name ?? null,
|
|
3754
4870
|
evidence_satisfied: evidenceSatisfied,
|
|
3755
|
-
limits_ok: limitsOk
|
|
4871
|
+
limits_ok: limitsOk,
|
|
4872
|
+
...budgets ? { budgets } : {}
|
|
3756
4873
|
};
|
|
3757
4874
|
const body = {
|
|
3758
4875
|
jsonrpc: "2.0",
|
|
@@ -3820,6 +4937,9 @@ function makeErrorResult(request, code, message, data) {
|
|
|
3820
4937
|
};
|
|
3821
4938
|
return { response, durationMs: 0 };
|
|
3822
4939
|
}
|
|
4940
|
+
function approvedByOf(outcome) {
|
|
4941
|
+
return outcome && "resolvedBy" in outcome ? outcome.resolvedBy : null;
|
|
4942
|
+
}
|
|
3823
4943
|
function hasJsonRpcError(result) {
|
|
3824
4944
|
const body = result.response.body;
|
|
3825
4945
|
return body?.["error"] !== void 0;
|
|
@@ -3853,9 +4973,9 @@ function extractBlockReason(result) {
|
|
|
3853
4973
|
if (!data || data["blocked"] !== true) return null;
|
|
3854
4974
|
return typeof data["reason"] === "string" ? data["reason"] : null;
|
|
3855
4975
|
}
|
|
3856
|
-
function buildEvidenceChain(evidenceResult, dependencyResult,
|
|
4976
|
+
function buildEvidenceChain(evidenceResult, dependencyResult, blocked2) {
|
|
3857
4977
|
if (!evidenceResult && !dependencyResult) return null;
|
|
3858
|
-
const chain = { blocked:
|
|
4978
|
+
const chain = { blocked: blocked2 ?? false };
|
|
3859
4979
|
if (evidenceResult) {
|
|
3860
4980
|
chain["evidence"] = {
|
|
3861
4981
|
required: [...evidenceResult.found, ...evidenceResult.missing, ...evidenceResult.expired],
|
|
@@ -3929,7 +5049,7 @@ var RateLimiter = class {
|
|
|
3929
5049
|
const current = bucket.timestamps.length;
|
|
3930
5050
|
const resetAtMs = (bucket.timestamps[0] ?? now) + windowMs;
|
|
3931
5051
|
if (this.onWarning && current / maxCalls >= this.warningThreshold) {
|
|
3932
|
-
this.
|
|
5052
|
+
this.safeWarn({ key, current, limit: maxCalls, window_ms: windowMs, reset_at_ms: resetAtMs });
|
|
3933
5053
|
}
|
|
3934
5054
|
return {
|
|
3935
5055
|
allowed: true,
|
|
@@ -3942,372 +5062,83 @@ var RateLimiter = class {
|
|
|
3942
5062
|
/**
|
|
3943
5063
|
* Unconditionally record a call against the rate limit.
|
|
3944
5064
|
*
|
|
3945
|
-
* Unlike check(), this always appends the timestamp — even when the bucket
|
|
3946
|
-
* is already at/over the limit — because the call it represents has already
|
|
3947
|
-
* executed. The sideband splits decision from execution: /evaluate peeks
|
|
3948
|
-
* (non-destructive), and /audit calls record() once the external call ran,
|
|
3949
|
-
* so refusing to record at the limit (as check() does) would let real calls
|
|
3950
|
-
* escape accounting and under-count subsequent peeks. (issue #12, D3.)
|
|
3951
|
-
*
|
|
3952
|
-
* Warnings fire only while the post-append count stays within the limit —
|
|
3953
|
-
* exact parity with check(), which never warns on its over-limit path — so a
|
|
3954
|
-
* burst of over-limit audits cannot flood the dashboard's limit_warning feed.
|
|
3955
|
-
*/
|
|
3956
|
-
record(params) {
|
|
3957
|
-
const { key, maxCalls, windowMs } = params;
|
|
3958
|
-
const now = this.now();
|
|
3959
|
-
const windowStart = now - windowMs;
|
|
3960
|
-
let bucket = this.buckets.get(key);
|
|
3961
|
-
if (!bucket) {
|
|
3962
|
-
bucket = { timestamps: [], maxCalls, windowMs };
|
|
3963
|
-
this.buckets.set(key, bucket);
|
|
3964
|
-
}
|
|
3965
|
-
bucket.maxCalls = maxCalls;
|
|
3966
|
-
bucket.windowMs = windowMs;
|
|
3967
|
-
bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
|
|
3968
|
-
bucket.timestamps.push(now);
|
|
3969
|
-
const current = bucket.timestamps.length;
|
|
3970
|
-
const resetAtMs = (bucket.timestamps[0] ?? now) + windowMs;
|
|
3971
|
-
if (this.onWarning && current <= maxCalls && current / maxCalls >= this.warningThreshold) {
|
|
3972
|
-
this.onWarning({ key, current, limit: maxCalls, window_ms: windowMs, reset_at_ms: resetAtMs });
|
|
3973
|
-
}
|
|
3974
|
-
return {
|
|
3975
|
-
allowed: current <= maxCalls,
|
|
3976
|
-
current,
|
|
3977
|
-
limit: maxCalls,
|
|
3978
|
-
windowMs,
|
|
3979
|
-
resetAtMs
|
|
3980
|
-
};
|
|
3981
|
-
}
|
|
3982
|
-
/**
|
|
3983
|
-
* Check the rate limit without recording the call (non-destructive).
|
|
3984
|
-
*
|
|
3985
|
-
* Used by dry-run mode to determine what would happen without consuming
|
|
3986
|
-
* a slot in the bucket.
|
|
3987
|
-
*/
|
|
3988
|
-
peek(params) {
|
|
3989
|
-
const { key, maxCalls, windowMs } = params;
|
|
3990
|
-
const now = this.now();
|
|
3991
|
-
const windowStart = now - windowMs;
|
|
3992
|
-
const bucket = this.buckets.get(key);
|
|
3993
|
-
if (!bucket) {
|
|
3994
|
-
return {
|
|
3995
|
-
allowed: true,
|
|
3996
|
-
current: 1,
|
|
3997
|
-
limit: maxCalls,
|
|
3998
|
-
windowMs,
|
|
3999
|
-
resetAtMs: now + windowMs
|
|
4000
|
-
};
|
|
4001
|
-
}
|
|
4002
|
-
const activeCount = bucket.timestamps.filter((ts) => ts > windowStart).length;
|
|
4003
|
-
if (activeCount >= maxCalls) {
|
|
4004
|
-
const oldest2 = bucket.timestamps.find((ts) => ts > windowStart) ?? 0;
|
|
4005
|
-
return {
|
|
4006
|
-
allowed: false,
|
|
4007
|
-
current: activeCount,
|
|
4008
|
-
limit: maxCalls,
|
|
4009
|
-
windowMs,
|
|
4010
|
-
resetAtMs: oldest2 + windowMs
|
|
4011
|
-
};
|
|
4012
|
-
}
|
|
4013
|
-
const oldest = bucket.timestamps.find((ts) => ts > windowStart) ?? now;
|
|
4014
|
-
return {
|
|
4015
|
-
allowed: true,
|
|
4016
|
-
current: activeCount + 1,
|
|
4017
|
-
limit: maxCalls,
|
|
4018
|
-
windowMs,
|
|
4019
|
-
resetAtMs: oldest + windowMs
|
|
4020
|
-
};
|
|
4021
|
-
}
|
|
4022
|
-
// -------------------------------------------------------------------------
|
|
4023
|
-
// Read operations (for dashboard API)
|
|
4024
|
-
// -------------------------------------------------------------------------
|
|
4025
|
-
/** Get the current state of a single key. Returns undefined if not tracked. */
|
|
4026
|
-
getKeyState(key) {
|
|
4027
|
-
const bucket = this.buckets.get(key);
|
|
4028
|
-
if (!bucket) return void 0;
|
|
4029
|
-
const windowStart = this.now() - bucket.windowMs;
|
|
4030
|
-
bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
|
|
4031
|
-
if (bucket.timestamps.length === 0) {
|
|
4032
|
-
this.buckets.delete(key);
|
|
4033
|
-
return void 0;
|
|
4034
|
-
}
|
|
4035
|
-
return {
|
|
4036
|
-
key,
|
|
4037
|
-
current: bucket.timestamps.length,
|
|
4038
|
-
limit: bucket.maxCalls,
|
|
4039
|
-
window_ms: bucket.windowMs,
|
|
4040
|
-
reset_at_ms: (bucket.timestamps[0] ?? 0) + bucket.windowMs
|
|
4041
|
-
};
|
|
4042
|
-
}
|
|
4043
|
-
/** List all tracked keys with their current state. */
|
|
4044
|
-
listKeyStates() {
|
|
4045
|
-
const states = [];
|
|
4046
|
-
for (const key of [...this.buckets.keys()]) {
|
|
4047
|
-
const state = this.getKeyState(key);
|
|
4048
|
-
if (state) states.push(state);
|
|
4049
|
-
}
|
|
4050
|
-
return states;
|
|
4051
|
-
}
|
|
4052
|
-
// -------------------------------------------------------------------------
|
|
4053
|
-
// Maintenance
|
|
4054
|
-
// -------------------------------------------------------------------------
|
|
4055
|
-
/** Sweep all buckets: remove expired timestamps, delete empty buckets. */
|
|
4056
|
-
cleanup() {
|
|
4057
|
-
const now = this.now();
|
|
4058
|
-
for (const [key, bucket] of this.buckets) {
|
|
4059
|
-
const windowStart = now - bucket.windowMs;
|
|
4060
|
-
bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
|
|
4061
|
-
if (bucket.timestamps.length === 0) {
|
|
4062
|
-
this.buckets.delete(key);
|
|
4063
|
-
}
|
|
4064
|
-
}
|
|
4065
|
-
}
|
|
4066
|
-
/** Clear all rate limit state. Called on policy hot-reload. */
|
|
4067
|
-
reset() {
|
|
4068
|
-
this.buckets.clear();
|
|
4069
|
-
}
|
|
4070
|
-
/**
|
|
4071
|
-
* Reconcile bucket state against a new policy's limit configuration.
|
|
4072
|
-
*
|
|
4073
|
-
* Walks every existing bucket and checks whether its last-seen
|
|
4074
|
-
* `{ maxCalls, windowMs }` tuple still appears anywhere in `validConfigs`.
|
|
4075
|
-
* Buckets whose config is still present are left untouched — counters and
|
|
4076
|
-
* elapsed-window progress are preserved across hot-reloads. Buckets whose
|
|
4077
|
-
* config is gone (rule changed or removed) are evicted so the next check
|
|
4078
|
-
* lazy-creates a fresh bucket under the new config.
|
|
4079
|
-
*
|
|
4080
|
-
* This is the compare-and-evict semantic that replaces the old `reset()`
|
|
4081
|
-
* call on every hot-reload, which wiped all state even when the matching
|
|
4082
|
-
* rule was unchanged.
|
|
4083
|
-
*/
|
|
4084
|
-
reconcile(validConfigs) {
|
|
4085
|
-
const valid = /* @__PURE__ */ new Set();
|
|
4086
|
-
for (const config of validConfigs) {
|
|
4087
|
-
valid.add(`${String(config.maxCalls)}|${String(config.windowMs)}`);
|
|
4088
|
-
}
|
|
4089
|
-
for (const [key, bucket] of this.buckets) {
|
|
4090
|
-
const tuple = `${String(bucket.maxCalls)}|${String(bucket.windowMs)}`;
|
|
4091
|
-
if (!valid.has(tuple)) {
|
|
4092
|
-
this.buckets.delete(key);
|
|
4093
|
-
}
|
|
4094
|
-
}
|
|
4095
|
-
}
|
|
4096
|
-
/** Stop the cleanup timer and mark as closed. */
|
|
4097
|
-
close() {
|
|
4098
|
-
if (this.closed) return;
|
|
4099
|
-
this.closed = true;
|
|
4100
|
-
if (this.timer) {
|
|
4101
|
-
clearInterval(this.timer);
|
|
4102
|
-
this.timer = null;
|
|
4103
|
-
}
|
|
4104
|
-
this.buckets.clear();
|
|
4105
|
-
}
|
|
4106
|
-
};
|
|
4107
|
-
|
|
4108
|
-
// src/policy/spend-limiter.ts
|
|
4109
|
-
var SpendLimiter = class {
|
|
4110
|
-
buckets = /* @__PURE__ */ new Map();
|
|
4111
|
-
now;
|
|
4112
|
-
onWarning;
|
|
4113
|
-
warningThreshold;
|
|
4114
|
-
timer = null;
|
|
4115
|
-
closed = false;
|
|
4116
|
-
constructor(options = {}) {
|
|
4117
|
-
this.now = options.now ?? Date.now;
|
|
4118
|
-
this.onWarning = options.onWarning;
|
|
4119
|
-
this.warningThreshold = options.warningThreshold ?? 0.8;
|
|
4120
|
-
const intervalMs = options.cleanupIntervalMs ?? 6e4;
|
|
4121
|
-
if (intervalMs > 0) {
|
|
4122
|
-
this.timer = setInterval(() => {
|
|
4123
|
-
this.cleanup();
|
|
4124
|
-
}, intervalMs);
|
|
4125
|
-
this.timer.unref();
|
|
4126
|
-
}
|
|
4127
|
-
}
|
|
4128
|
-
// -------------------------------------------------------------------------
|
|
4129
|
-
// Core operations
|
|
4130
|
-
// -------------------------------------------------------------------------
|
|
4131
|
-
/**
|
|
4132
|
-
* Check and optionally record a spend against the limit.
|
|
4133
|
-
*
|
|
4134
|
-
* Evicts expired entries, sums remaining amounts, then checks:
|
|
4135
|
-
* - Under limit (currentSpend + amount <= limit): records and returns `allowed: true`
|
|
4136
|
-
* - Would exceed: does NOT record (rejected spends don't consume budget)
|
|
4137
|
-
*/
|
|
4138
|
-
check(params) {
|
|
4139
|
-
const { key, amount, limit, windowMs } = params;
|
|
4140
|
-
const now = this.now();
|
|
4141
|
-
const windowStart = now - windowMs;
|
|
4142
|
-
if (!Number.isFinite(amount) || amount < 0) {
|
|
4143
|
-
const existing = this.buckets.get(key);
|
|
4144
|
-
const activeEntries = existing ? existing.entries.filter((e) => e.timestamp > windowStart) : [];
|
|
4145
|
-
const currentSpend2 = activeEntries.reduce((sum, e) => sum + e.amount, 0);
|
|
4146
|
-
const oldest = activeEntries[0];
|
|
4147
|
-
return {
|
|
4148
|
-
allowed: false,
|
|
4149
|
-
currentSpend: currentSpend2,
|
|
4150
|
-
limit,
|
|
4151
|
-
windowMs,
|
|
4152
|
-
resetAtMs: oldest ? oldest.timestamp + windowMs : 0,
|
|
4153
|
-
reason: "invalid_amount"
|
|
4154
|
-
};
|
|
4155
|
-
}
|
|
4156
|
-
let bucket = this.buckets.get(key);
|
|
4157
|
-
if (!bucket) {
|
|
4158
|
-
bucket = { entries: [], limit, currency: "", windowMs };
|
|
4159
|
-
this.buckets.set(key, bucket);
|
|
4160
|
-
}
|
|
4161
|
-
bucket.limit = limit;
|
|
4162
|
-
bucket.windowMs = windowMs;
|
|
4163
|
-
bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
|
|
4164
|
-
const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
|
|
4165
|
-
if (currentSpend + amount > limit) {
|
|
4166
|
-
const oldest = bucket.entries[0];
|
|
4167
|
-
return {
|
|
4168
|
-
allowed: false,
|
|
4169
|
-
currentSpend,
|
|
4170
|
-
limit,
|
|
4171
|
-
windowMs,
|
|
4172
|
-
resetAtMs: oldest ? oldest.timestamp + windowMs : 0
|
|
4173
|
-
};
|
|
4174
|
-
}
|
|
4175
|
-
bucket.entries.push({ timestamp: now, amount });
|
|
4176
|
-
const newSpend = currentSpend + amount;
|
|
4177
|
-
const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
|
|
4178
|
-
if (this.onWarning && newSpend / limit >= this.warningThreshold) {
|
|
4179
|
-
this.onWarning({
|
|
4180
|
-
key,
|
|
4181
|
-
current_spend: newSpend,
|
|
4182
|
-
limit,
|
|
4183
|
-
currency: bucket.currency,
|
|
4184
|
-
window_ms: windowMs,
|
|
4185
|
-
reset_at_ms: resetAtMs
|
|
4186
|
-
});
|
|
4187
|
-
}
|
|
4188
|
-
return {
|
|
4189
|
-
allowed: true,
|
|
4190
|
-
currentSpend: newSpend,
|
|
4191
|
-
limit,
|
|
4192
|
-
windowMs,
|
|
4193
|
-
resetAtMs
|
|
4194
|
-
};
|
|
4195
|
-
}
|
|
4196
|
-
/**
|
|
4197
|
-
* Unconditionally record a spend against the limit.
|
|
4198
|
-
*
|
|
4199
|
-
* Unlike check(), this always appends the amount — even when it pushes the
|
|
4200
|
-
* window past the limit — because the spend it represents has already been
|
|
4201
|
-
* incurred. The sideband peeks at /evaluate and commits here at /audit once
|
|
4202
|
-
* the external call ran (issue #12, D3).
|
|
5065
|
+
* Unlike check(), this always appends the timestamp — even when the bucket
|
|
5066
|
+
* is already at/over the limit — because the call it represents has already
|
|
5067
|
+
* executed. The sideband splits decision from execution: /evaluate peeks
|
|
5068
|
+
* (non-destructive), and /audit calls record() once the external call ran,
|
|
5069
|
+
* so refusing to record at the limit (as check() does) would let real calls
|
|
5070
|
+
* escape accounting and under-count subsequent peeks. (issue #12, D3.)
|
|
4203
5071
|
*
|
|
4204
|
-
*
|
|
4205
|
-
*
|
|
4206
|
-
*
|
|
4207
|
-
* post-append spend stays within the limit (parity with check()).
|
|
5072
|
+
* Warnings fire only while the post-append count stays within the limit —
|
|
5073
|
+
* exact parity with check(), which never warns on its over-limit path — so a
|
|
5074
|
+
* burst of over-limit audits cannot flood the dashboard's limit_warning feed.
|
|
4208
5075
|
*/
|
|
4209
5076
|
record(params) {
|
|
4210
|
-
const { key,
|
|
4211
|
-
if (!Number.isFinite(amount) || amount < 0) {
|
|
4212
|
-
throw new RangeError(
|
|
4213
|
-
`SpendLimiter.record() received an invalid amount (${String(amount)}); invalid amounts must be rejected at /evaluate, never committed`
|
|
4214
|
-
);
|
|
4215
|
-
}
|
|
5077
|
+
const { key, maxCalls, windowMs } = params;
|
|
4216
5078
|
const now = this.now();
|
|
4217
5079
|
const windowStart = now - windowMs;
|
|
4218
5080
|
let bucket = this.buckets.get(key);
|
|
4219
5081
|
if (!bucket) {
|
|
4220
|
-
bucket = {
|
|
5082
|
+
bucket = { timestamps: [], maxCalls, windowMs };
|
|
4221
5083
|
this.buckets.set(key, bucket);
|
|
4222
5084
|
}
|
|
4223
|
-
bucket.
|
|
5085
|
+
bucket.maxCalls = maxCalls;
|
|
4224
5086
|
bucket.windowMs = windowMs;
|
|
4225
|
-
bucket.
|
|
4226
|
-
bucket.
|
|
4227
|
-
const
|
|
4228
|
-
const resetAtMs = (bucket.
|
|
4229
|
-
if (this.onWarning &&
|
|
4230
|
-
this.
|
|
4231
|
-
key,
|
|
4232
|
-
current_spend: currentSpend,
|
|
4233
|
-
limit,
|
|
4234
|
-
currency: bucket.currency,
|
|
4235
|
-
window_ms: windowMs,
|
|
4236
|
-
reset_at_ms: resetAtMs
|
|
4237
|
-
});
|
|
5087
|
+
bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
|
|
5088
|
+
bucket.timestamps.push(now);
|
|
5089
|
+
const current = bucket.timestamps.length;
|
|
5090
|
+
const resetAtMs = (bucket.timestamps[0] ?? now) + windowMs;
|
|
5091
|
+
if (this.onWarning && current <= maxCalls && current / maxCalls >= this.warningThreshold) {
|
|
5092
|
+
this.safeWarn({ key, current, limit: maxCalls, window_ms: windowMs, reset_at_ms: resetAtMs });
|
|
4238
5093
|
}
|
|
4239
5094
|
return {
|
|
4240
|
-
allowed:
|
|
4241
|
-
|
|
4242
|
-
limit,
|
|
5095
|
+
allowed: current <= maxCalls,
|
|
5096
|
+
current,
|
|
5097
|
+
limit: maxCalls,
|
|
4243
5098
|
windowMs,
|
|
4244
5099
|
resetAtMs
|
|
4245
5100
|
};
|
|
4246
5101
|
}
|
|
4247
5102
|
/**
|
|
4248
|
-
* Check the
|
|
5103
|
+
* Check the rate limit without recording the call (non-destructive).
|
|
4249
5104
|
*
|
|
4250
5105
|
* Used by dry-run mode to determine what would happen without consuming
|
|
4251
|
-
*
|
|
5106
|
+
* a slot in the bucket.
|
|
4252
5107
|
*/
|
|
4253
5108
|
peek(params) {
|
|
4254
|
-
const { key,
|
|
5109
|
+
const { key, maxCalls, windowMs } = params;
|
|
4255
5110
|
const now = this.now();
|
|
4256
5111
|
const windowStart = now - windowMs;
|
|
4257
5112
|
const bucket = this.buckets.get(key);
|
|
4258
|
-
if (!Number.isFinite(amount) || amount < 0) {
|
|
4259
|
-
const activeEntries2 = bucket ? bucket.entries.filter((e) => e.timestamp > windowStart) : [];
|
|
4260
|
-
const currentSpend2 = activeEntries2.reduce((sum, e) => sum + e.amount, 0);
|
|
4261
|
-
const oldest2 = activeEntries2[0];
|
|
4262
|
-
return {
|
|
4263
|
-
allowed: false,
|
|
4264
|
-
currentSpend: currentSpend2,
|
|
4265
|
-
limit,
|
|
4266
|
-
windowMs,
|
|
4267
|
-
resetAtMs: oldest2 ? oldest2.timestamp + windowMs : 0,
|
|
4268
|
-
reason: "invalid_amount"
|
|
4269
|
-
};
|
|
4270
|
-
}
|
|
4271
5113
|
if (!bucket) {
|
|
4272
|
-
const wouldExceed = amount > limit;
|
|
4273
5114
|
return {
|
|
4274
|
-
allowed:
|
|
4275
|
-
|
|
4276
|
-
limit,
|
|
5115
|
+
allowed: true,
|
|
5116
|
+
current: 1,
|
|
5117
|
+
limit: maxCalls,
|
|
4277
5118
|
windowMs,
|
|
4278
5119
|
resetAtMs: now + windowMs
|
|
4279
5120
|
};
|
|
4280
5121
|
}
|
|
4281
|
-
const
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
const oldest2 = activeEntries[0];
|
|
5122
|
+
const activeCount = bucket.timestamps.filter((ts) => ts > windowStart).length;
|
|
5123
|
+
if (activeCount >= maxCalls) {
|
|
5124
|
+
const oldest2 = bucket.timestamps.find((ts) => ts > windowStart) ?? 0;
|
|
4285
5125
|
return {
|
|
4286
5126
|
allowed: false,
|
|
4287
|
-
|
|
4288
|
-
limit,
|
|
5127
|
+
current: activeCount,
|
|
5128
|
+
limit: maxCalls,
|
|
4289
5129
|
windowMs,
|
|
4290
|
-
resetAtMs: oldest2
|
|
5130
|
+
resetAtMs: oldest2 + windowMs
|
|
4291
5131
|
};
|
|
4292
5132
|
}
|
|
4293
|
-
const
|
|
4294
|
-
const oldest = activeEntries[0];
|
|
5133
|
+
const oldest = bucket.timestamps.find((ts) => ts > windowStart) ?? now;
|
|
4295
5134
|
return {
|
|
4296
5135
|
allowed: true,
|
|
4297
|
-
|
|
4298
|
-
limit,
|
|
5136
|
+
current: activeCount + 1,
|
|
5137
|
+
limit: maxCalls,
|
|
4299
5138
|
windowMs,
|
|
4300
|
-
resetAtMs: oldest
|
|
5139
|
+
resetAtMs: oldest + windowMs
|
|
4301
5140
|
};
|
|
4302
5141
|
}
|
|
4303
|
-
/**
|
|
4304
|
-
* Set the display currency for a key. Called by the governed forwarder
|
|
4305
|
-
* after check() so dashboard reads include the currency label.
|
|
4306
|
-
*/
|
|
4307
|
-
setCurrency(key, currency) {
|
|
4308
|
-
const bucket = this.buckets.get(key);
|
|
4309
|
-
if (bucket) bucket.currency = currency;
|
|
4310
|
-
}
|
|
4311
5142
|
// -------------------------------------------------------------------------
|
|
4312
5143
|
// Read operations (for dashboard API)
|
|
4313
5144
|
// -------------------------------------------------------------------------
|
|
@@ -4316,19 +5147,17 @@ var SpendLimiter = class {
|
|
|
4316
5147
|
const bucket = this.buckets.get(key);
|
|
4317
5148
|
if (!bucket) return void 0;
|
|
4318
5149
|
const windowStart = this.now() - bucket.windowMs;
|
|
4319
|
-
bucket.
|
|
4320
|
-
if (bucket.
|
|
5150
|
+
bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
|
|
5151
|
+
if (bucket.timestamps.length === 0) {
|
|
4321
5152
|
this.buckets.delete(key);
|
|
4322
5153
|
return void 0;
|
|
4323
5154
|
}
|
|
4324
|
-
const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
|
|
4325
5155
|
return {
|
|
4326
5156
|
key,
|
|
4327
|
-
|
|
4328
|
-
limit: bucket.
|
|
4329
|
-
currency: bucket.currency,
|
|
5157
|
+
current: bucket.timestamps.length,
|
|
5158
|
+
limit: bucket.maxCalls,
|
|
4330
5159
|
window_ms: bucket.windowMs,
|
|
4331
|
-
reset_at_ms: (bucket.
|
|
5160
|
+
reset_at_ms: (bucket.timestamps[0] ?? 0) + bucket.windowMs
|
|
4332
5161
|
};
|
|
4333
5162
|
}
|
|
4334
5163
|
/** List all tracked keys with their current state. */
|
|
@@ -4343,50 +5172,62 @@ var SpendLimiter = class {
|
|
|
4343
5172
|
// -------------------------------------------------------------------------
|
|
4344
5173
|
// Maintenance
|
|
4345
5174
|
// -------------------------------------------------------------------------
|
|
4346
|
-
/** Sweep all buckets: remove expired
|
|
5175
|
+
/** Sweep all buckets: remove expired timestamps, delete empty buckets. */
|
|
4347
5176
|
cleanup() {
|
|
4348
5177
|
const now = this.now();
|
|
4349
5178
|
for (const [key, bucket] of this.buckets) {
|
|
4350
5179
|
const windowStart = now - bucket.windowMs;
|
|
4351
|
-
bucket.
|
|
4352
|
-
if (bucket.
|
|
5180
|
+
bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
|
|
5181
|
+
if (bucket.timestamps.length === 0) {
|
|
4353
5182
|
this.buckets.delete(key);
|
|
4354
5183
|
}
|
|
4355
5184
|
}
|
|
4356
5185
|
}
|
|
4357
|
-
/** Clear all
|
|
5186
|
+
/** Clear all rate limit state. Called on policy hot-reload. */
|
|
4358
5187
|
reset() {
|
|
4359
5188
|
this.buckets.clear();
|
|
4360
5189
|
}
|
|
4361
5190
|
/**
|
|
4362
|
-
* Reconcile bucket state against a new policy's
|
|
5191
|
+
* Reconcile bucket state against a new policy's limit configuration.
|
|
4363
5192
|
*
|
|
4364
5193
|
* Walks every existing bucket and checks whether its last-seen
|
|
4365
|
-
* `{
|
|
4366
|
-
* Buckets whose config is
|
|
4367
|
-
*
|
|
4368
|
-
*
|
|
4369
|
-
*
|
|
5194
|
+
* `{ maxCalls, windowMs }` tuple still appears anywhere in `validConfigs`.
|
|
5195
|
+
* Buckets whose config is still present are left untouched — counters and
|
|
5196
|
+
* elapsed-window progress are preserved across hot-reloads. Buckets whose
|
|
5197
|
+
* config is gone (rule changed or removed) are evicted so the next check
|
|
5198
|
+
* lazy-creates a fresh bucket under the new config.
|
|
4370
5199
|
*
|
|
4371
|
-
*
|
|
4372
|
-
*
|
|
4373
|
-
*
|
|
4374
|
-
* on every hot-reload, which wiped all state even when the matching rule
|
|
4375
|
-
* was unchanged.
|
|
5200
|
+
* This is the compare-and-evict semantic that replaces the old `reset()`
|
|
5201
|
+
* call on every hot-reload, which wiped all state even when the matching
|
|
5202
|
+
* rule was unchanged.
|
|
4376
5203
|
*/
|
|
4377
5204
|
reconcile(validConfigs) {
|
|
4378
5205
|
const valid = /* @__PURE__ */ new Set();
|
|
4379
5206
|
for (const config of validConfigs) {
|
|
4380
|
-
valid.add(`${String(config.
|
|
5207
|
+
valid.add(`${String(config.maxCalls)}|${String(config.windowMs)}`);
|
|
4381
5208
|
}
|
|
4382
5209
|
for (const [key, bucket] of this.buckets) {
|
|
4383
|
-
const tuple = `${String(bucket.
|
|
5210
|
+
const tuple = `${String(bucket.maxCalls)}|${String(bucket.windowMs)}`;
|
|
4384
5211
|
if (!valid.has(tuple)) {
|
|
4385
5212
|
this.buckets.delete(key);
|
|
4386
5213
|
}
|
|
4387
5214
|
}
|
|
4388
5215
|
}
|
|
4389
5216
|
/** Stop the cleanup timer and mark as closed. */
|
|
5217
|
+
/**
|
|
5218
|
+
* Invoke the warning callback without letting a subscriber throw into the
|
|
5219
|
+
* limiter's caller: a warning fires after state has already mutated, and a
|
|
5220
|
+
* governed call must not be blocked (or double-charged on retry) by an
|
|
5221
|
+
* observability bug.
|
|
5222
|
+
*/
|
|
5223
|
+
safeWarn(state) {
|
|
5224
|
+
if (!this.onWarning) return;
|
|
5225
|
+
try {
|
|
5226
|
+
this.onWarning(state);
|
|
5227
|
+
} catch (err) {
|
|
5228
|
+
console.error("[helio] limit warning subscriber threw:", err);
|
|
5229
|
+
}
|
|
5230
|
+
}
|
|
4390
5231
|
close() {
|
|
4391
5232
|
if (this.closed) return;
|
|
4392
5233
|
this.closed = true;
|
|
@@ -4400,7 +5241,7 @@ var SpendLimiter = class {
|
|
|
4400
5241
|
|
|
4401
5242
|
// src/audit/store.ts
|
|
4402
5243
|
import Database from "better-sqlite3";
|
|
4403
|
-
import { randomUUID as
|
|
5244
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
4404
5245
|
import { chmodSync } from "fs";
|
|
4405
5246
|
|
|
4406
5247
|
// src/upstream/response-summary.ts
|
|
@@ -4464,6 +5305,7 @@ function clampInt(value, fallback, min, max) {
|
|
|
4464
5305
|
|
|
4465
5306
|
// src/audit/store.ts
|
|
4466
5307
|
var DRIFT_EVENT_DECISIONS_SQL = "('tool_drift', 'tool_drift_reverted')";
|
|
5308
|
+
var NON_TOOL_DECISIONS_SQL = "('tool_drift', 'tool_drift_reverted', 'rejected')";
|
|
4467
5309
|
var EXPORT_MAX_RECORDS = 1e4;
|
|
4468
5310
|
var LIST_MAX_PAGE_SIZE = 1e3;
|
|
4469
5311
|
var CREATE_TABLE_DDL = `
|
|
@@ -4654,6 +5496,7 @@ var AuditStore = class {
|
|
|
4654
5496
|
insertStmt;
|
|
4655
5497
|
retentionMs;
|
|
4656
5498
|
includeResponses;
|
|
5499
|
+
retentionSweepHooks = [];
|
|
4657
5500
|
cleanupTimer = null;
|
|
4658
5501
|
constructor(options) {
|
|
4659
5502
|
this.db = new Database(options.path);
|
|
@@ -4671,12 +5514,51 @@ var AuditStore = class {
|
|
|
4671
5514
|
const intervalMs = options.cleanupIntervalMs ?? 864e5;
|
|
4672
5515
|
if (intervalMs > 0) {
|
|
4673
5516
|
this.cleanupTimer = setInterval(() => {
|
|
4674
|
-
this.
|
|
5517
|
+
this.runRetentionSweep();
|
|
4675
5518
|
restrictAuditFilePerms(options.path);
|
|
4676
5519
|
}, intervalMs);
|
|
4677
5520
|
this.cleanupTimer.unref();
|
|
4678
5521
|
}
|
|
4679
5522
|
}
|
|
5523
|
+
/**
|
|
5524
|
+
* Register a hook to run on every retention sweep, receiving the sweep's
|
|
5525
|
+
* cutoff. This is how co-resident tables (the budget ledger) join the
|
|
5526
|
+
* store's single sweep schedule instead of running their own timers.
|
|
5527
|
+
* Hooks registered after construction miss the constructor's initial
|
|
5528
|
+
* purge — call {@link runRetentionSweep} once after registering to cover
|
|
5529
|
+
* rows that aged out while the process was down.
|
|
5530
|
+
*/
|
|
5531
|
+
onRetentionSweep(fn) {
|
|
5532
|
+
this.retentionSweepHooks.push(fn);
|
|
5533
|
+
}
|
|
5534
|
+
/**
|
|
5535
|
+
* One full retention sweep: purge expired audit records, then fire every
|
|
5536
|
+
* registered hook with the sweep's cutoff. Hook failures degrade to a
|
|
5537
|
+
* logged error — a broken co-resident purge must not stop the audit
|
|
5538
|
+
* table's own retention.
|
|
5539
|
+
*/
|
|
5540
|
+
runRetentionSweep() {
|
|
5541
|
+
const ms = Date.now() - this.retentionMs;
|
|
5542
|
+
const cutoff = { iso: new Date(ms).toISOString(), ms };
|
|
5543
|
+
this.purgeBefore(cutoff.iso);
|
|
5544
|
+
for (const hook of this.retentionSweepHooks) {
|
|
5545
|
+
try {
|
|
5546
|
+
hook(cutoff);
|
|
5547
|
+
} catch (err) {
|
|
5548
|
+
console.error("[helio] retention sweep hook failed:", err);
|
|
5549
|
+
}
|
|
5550
|
+
}
|
|
5551
|
+
}
|
|
5552
|
+
/**
|
|
5553
|
+
* Package-internal: the store's open database handle, for components that
|
|
5554
|
+
* co-locate their tables in the audit db (the budget ledger). Sharing the
|
|
5555
|
+
* handle keeps one connection, one WAL domain, and one file-permission
|
|
5556
|
+
* hardening pass. Not part of the public embedding API — do not re-export
|
|
5557
|
+
* anything built on this from the package root.
|
|
5558
|
+
*/
|
|
5559
|
+
get database() {
|
|
5560
|
+
return this.db;
|
|
5561
|
+
}
|
|
4680
5562
|
/**
|
|
4681
5563
|
* Validate that the on-disk audit schema contains all required canonical columns.
|
|
4682
5564
|
*
|
|
@@ -4701,7 +5583,7 @@ var AuditStore = class {
|
|
|
4701
5583
|
* @param id - Optional pre-generated ID (used by AuditWriter to share ID with SSE event bus).
|
|
4702
5584
|
*/
|
|
4703
5585
|
insert(record, createdAt, id) {
|
|
4704
|
-
const resolvedId = id ??
|
|
5586
|
+
const resolvedId = id ?? randomUUID3();
|
|
4705
5587
|
const now = createdAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
4706
5588
|
this.insertStmt.run({
|
|
4707
5589
|
id: resolvedId,
|
|
@@ -4831,7 +5713,7 @@ var AuditStore = class {
|
|
|
4831
5713
|
GROUP BY block_reason
|
|
4832
5714
|
ORDER BY count DESC`
|
|
4833
5715
|
).all(...params);
|
|
4834
|
-
const toolsClause = clause ? `${clause} AND policy_decision NOT IN ${
|
|
5716
|
+
const toolsClause = clause ? `${clause} AND policy_decision NOT IN ${NON_TOOL_DECISIONS_SQL}` : `WHERE policy_decision NOT IN ${NON_TOOL_DECISIONS_SQL}`;
|
|
4835
5717
|
const top_tools = this.db.prepare(
|
|
4836
5718
|
`SELECT tool_name, COUNT(*) as count
|
|
4837
5719
|
FROM audit_records ${toolsClause}
|
|
@@ -4874,8 +5756,10 @@ var AuditStore = class {
|
|
|
4874
5756
|
}
|
|
4875
5757
|
/** Delete records older than the retention period. Returns the count of deleted records. */
|
|
4876
5758
|
purgeExpired() {
|
|
4877
|
-
|
|
4878
|
-
|
|
5759
|
+
return this.purgeBefore(new Date(Date.now() - this.retentionMs).toISOString());
|
|
5760
|
+
}
|
|
5761
|
+
purgeBefore(cutoffIso) {
|
|
5762
|
+
const result = this.db.prepare("DELETE FROM audit_records WHERE created_at < ?").run(cutoffIso);
|
|
4879
5763
|
return result.changes;
|
|
4880
5764
|
}
|
|
4881
5765
|
/** Close the database and stop the cleanup timer. */
|
|
@@ -4889,7 +5773,7 @@ var AuditStore = class {
|
|
|
4889
5773
|
};
|
|
4890
5774
|
|
|
4891
5775
|
// src/audit/writer.ts
|
|
4892
|
-
import { randomUUID as
|
|
5776
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
4893
5777
|
var AuditWriter = class {
|
|
4894
5778
|
store;
|
|
4895
5779
|
bufferSize;
|
|
@@ -4924,7 +5808,7 @@ var AuditWriter = class {
|
|
|
4924
5808
|
* is scheduled. This keeps request-path latency bounded even under bursty
|
|
4925
5809
|
* write load.
|
|
4926
5810
|
*/
|
|
4927
|
-
push(record, id =
|
|
5811
|
+
push(record, id = randomUUID4()) {
|
|
4928
5812
|
if (this.closed) return;
|
|
4929
5813
|
this.buffer.push({ id, record });
|
|
4930
5814
|
this.onPush?.(record, id);
|
|
@@ -4940,7 +5824,7 @@ var AuditWriter = class {
|
|
|
4940
5824
|
* A fatal-process crash still invokes the crash-drain hook, which calls
|
|
4941
5825
|
* `flush()` synchronously before exit.
|
|
4942
5826
|
*/
|
|
4943
|
-
pushImmediate(record, id =
|
|
5827
|
+
pushImmediate(record, id = randomUUID4()) {
|
|
4944
5828
|
if (this.closed) return;
|
|
4945
5829
|
this.buffer.push({ id, record });
|
|
4946
5830
|
this.onPush?.(record, id);
|
|
@@ -5309,7 +6193,9 @@ import { createHash as createHash2 } from "crypto";
|
|
|
5309
6193
|
var originSchema = z4.string().regex(/^[a-z0-9_-]{1,64}$/, "origin must match ^[a-z0-9_-]{1,64}$").default("sideband");
|
|
5310
6194
|
var metadataSchema = z4.record(z4.string(), z4.unknown()).nullish();
|
|
5311
6195
|
var toolDefinitionSchema = z4.object({
|
|
5312
|
-
|
|
6196
|
+
// Stored verbatim in pending entries and audit rows; capped so a
|
|
6197
|
+
// caller-minted name cannot inflate the pending-entry footprint.
|
|
6198
|
+
name: z4.string().min(1).max(256),
|
|
5313
6199
|
description: z4.string().optional(),
|
|
5314
6200
|
input_schema: z4.unknown().optional(),
|
|
5315
6201
|
output_schema: z4.unknown().optional(),
|
|
@@ -5319,16 +6205,18 @@ var toolDefinitionSchema = z4.object({
|
|
|
5319
6205
|
var evaluateBody = z4.object({
|
|
5320
6206
|
origin: originSchema,
|
|
5321
6207
|
adapter_version: z4.string().max(64).optional(),
|
|
5322
|
-
|
|
5323
|
-
|
|
6208
|
+
// Stored in pending entries and limit bucket keys; capped (like origin and
|
|
6209
|
+
// adapter_version) so caller-minted ids cannot inflate memory unaccounted.
|
|
6210
|
+
agent_id: z4.string().max(128).nullish(),
|
|
6211
|
+
session_id: z4.string().max(256).nullish(),
|
|
5324
6212
|
tool: toolDefinitionSchema,
|
|
5325
6213
|
arguments: z4.record(z4.string(), z4.unknown()).optional(),
|
|
5326
6214
|
metadata: metadataSchema
|
|
5327
6215
|
});
|
|
5328
6216
|
var installScanBody = z4.object({
|
|
5329
6217
|
origin: originSchema,
|
|
5330
|
-
agent_id: z4.string().nullish(),
|
|
5331
|
-
session_id: z4.string().nullish(),
|
|
6218
|
+
agent_id: z4.string().max(128).nullish(),
|
|
6219
|
+
session_id: z4.string().max(256).nullish(),
|
|
5332
6220
|
package: z4.object({
|
|
5333
6221
|
name: z4.string().min(1),
|
|
5334
6222
|
version: z4.string().optional(),
|
|
@@ -5588,7 +6476,7 @@ function createSidebandApp(store, options = {}) {
|
|
|
5588
6476
|
}
|
|
5589
6477
|
|
|
5590
6478
|
// src/sideband/governance-service.ts
|
|
5591
|
-
import { randomUUID as
|
|
6479
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
5592
6480
|
|
|
5593
6481
|
// src/sideband/errors.ts
|
|
5594
6482
|
var GovernanceConfigError = class extends Error {
|
|
@@ -5616,6 +6504,7 @@ var GovernanceService = class {
|
|
|
5616
6504
|
approvalRouter;
|
|
5617
6505
|
rateLimiter;
|
|
5618
6506
|
spendLimiter;
|
|
6507
|
+
budgetEngine;
|
|
5619
6508
|
auditWriter;
|
|
5620
6509
|
approvalTimeoutMs;
|
|
5621
6510
|
ttlMs;
|
|
@@ -5648,6 +6537,7 @@ var GovernanceService = class {
|
|
|
5648
6537
|
this.approvalRouter = options.approvalRouter;
|
|
5649
6538
|
this.rateLimiter = options.rateLimiter;
|
|
5650
6539
|
this.spendLimiter = options.spendLimiter;
|
|
6540
|
+
this.budgetEngine = options.budgetEngine;
|
|
5651
6541
|
this.auditWriter = options.auditWriter;
|
|
5652
6542
|
this.approvalTimeoutMs = options.approvalTimeoutMs ?? 3e5;
|
|
5653
6543
|
this.ttlMs = options.ttlMs ?? 6e5;
|
|
@@ -5681,7 +6571,12 @@ var GovernanceService = class {
|
|
|
5681
6571
|
if (inputBytes > MAX_TOOL_INPUT_BYTES) {
|
|
5682
6572
|
return { status: 413, body: { error: "tool_input_too_large" } };
|
|
5683
6573
|
}
|
|
5684
|
-
const entryBytes = inputBytes + byteLength(req.metadata ?? {})
|
|
6574
|
+
const entryBytes = inputBytes + byteLength(req.metadata ?? {}) + byteLength({
|
|
6575
|
+
tool: req.tool.name,
|
|
6576
|
+
agent_id: req.agent_id,
|
|
6577
|
+
session_id: req.session_id,
|
|
6578
|
+
origin: req.origin
|
|
6579
|
+
});
|
|
5685
6580
|
if (!this.caches.has(req.origin) && this.caches.size >= MAX_ORIGINS) {
|
|
5686
6581
|
return { status: 400, body: { error: "origin_limit_exceeded" } };
|
|
5687
6582
|
}
|
|
@@ -5712,11 +6607,21 @@ var GovernanceService = class {
|
|
|
5712
6607
|
agentId: req.agent_id ?? void 0
|
|
5713
6608
|
});
|
|
5714
6609
|
const { decision } = pipeline;
|
|
5715
|
-
const evaluationId =
|
|
6610
|
+
const evaluationId = randomUUID5();
|
|
5716
6611
|
const timestampIso = new Date(this.now()).toISOString();
|
|
5717
6612
|
let wire;
|
|
5718
|
-
|
|
6613
|
+
const plans = [];
|
|
5719
6614
|
let limitsBlock;
|
|
6615
|
+
const reservedThisCall = [];
|
|
6616
|
+
const reserve = (key) => {
|
|
6617
|
+
const preexisting = this.senderKeys.has(key);
|
|
6618
|
+
if (!this.reserveSenderKey(key)) return false;
|
|
6619
|
+
if (!preexisting && this.senderKeys.has(key)) reservedThisCall.push(key);
|
|
6620
|
+
return true;
|
|
6621
|
+
};
|
|
6622
|
+
const releaseReservations = () => {
|
|
6623
|
+
for (const key of reservedThisCall) this.senderKeys.delete(key);
|
|
6624
|
+
};
|
|
5720
6625
|
const senderId = senderIdOf(req.metadata);
|
|
5721
6626
|
if (pipeline.isDryRun) {
|
|
5722
6627
|
wire = "dry_run";
|
|
@@ -5726,23 +6631,97 @@ var GovernanceService = class {
|
|
|
5726
6631
|
wire = "require_approval";
|
|
5727
6632
|
} else if (decision.action === "rate_limit") {
|
|
5728
6633
|
const planned = this.planRate(decision, toolName, req.session_id, senderId);
|
|
5729
|
-
if (planned?.plan && !
|
|
6634
|
+
if (planned?.plan && !reserve(planned.plan.key)) {
|
|
5730
6635
|
return { status: 503, body: { error: "limit_capacity_exhausted" } };
|
|
5731
6636
|
}
|
|
5732
|
-
|
|
6637
|
+
if (planned?.plan) plans.push(planned.plan);
|
|
5733
6638
|
limitsBlock = planned?.block ? { rate: planned.block } : void 0;
|
|
5734
6639
|
wire = planned?.allowed ? "allow" : "rate_limited";
|
|
5735
6640
|
} else if (decision.action === "spend_limit") {
|
|
5736
6641
|
const planned = this.planSpend(decision, toolName, req.session_id, req.arguments, senderId);
|
|
5737
|
-
if (planned?.plan && !
|
|
6642
|
+
if (planned?.plan && !reserve(planned.plan.key)) {
|
|
5738
6643
|
return { status: 503, body: { error: "limit_capacity_exhausted" } };
|
|
5739
6644
|
}
|
|
5740
|
-
|
|
6645
|
+
if (planned?.plan) plans.push(planned.plan);
|
|
5741
6646
|
limitsBlock = planned?.block ? { spend: planned.block } : void 0;
|
|
5742
6647
|
wire = planned?.allowed ? "allow" : "spend_limited";
|
|
5743
6648
|
} else {
|
|
5744
6649
|
wire = "allow";
|
|
5745
6650
|
}
|
|
6651
|
+
let budgetsBlock;
|
|
6652
|
+
let budgetDryRunOk = true;
|
|
6653
|
+
let budgetDenial;
|
|
6654
|
+
let budgetBreachContexts;
|
|
6655
|
+
let budgetBreachEntries;
|
|
6656
|
+
let budgetTicketTimeoutMs;
|
|
6657
|
+
let budgetTriggeredApproval = false;
|
|
6658
|
+
if (this.budgetEngine && (wire === "allow" || wire === "require_approval" || wire === "dry_run")) {
|
|
6659
|
+
const { charges, failures } = this.budgetEngine.resolveCharges({
|
|
6660
|
+
toolName,
|
|
6661
|
+
toolArguments: req.arguments,
|
|
6662
|
+
sessionId: req.session_id,
|
|
6663
|
+
senderId
|
|
6664
|
+
});
|
|
6665
|
+
if (charges.length > 0 || failures.length > 0) {
|
|
6666
|
+
const peek = charges.length > 0 ? this.budgetEngine.peekAll(charges) : { allowed: true, entries: [] };
|
|
6667
|
+
budgetsBlock = [
|
|
6668
|
+
...peek.entries.map((entry2) => budgetWireBlock(entry2)),
|
|
6669
|
+
...failures.map((failure) => budgetFailureBlock(failure))
|
|
6670
|
+
];
|
|
6671
|
+
const breaches = peek.entries.filter((entry2) => !entry2.allowed);
|
|
6672
|
+
const canBreakGlass = this.approvalRouter !== void 0 && breaches.every((entry2) => entry2.budget.onExceed === "require_approval");
|
|
6673
|
+
if (failures.length > 0 || breaches.length > 0 && !canBreakGlass) {
|
|
6674
|
+
budgetDryRunOk = false;
|
|
6675
|
+
if (wire !== "dry_run") {
|
|
6676
|
+
releaseReservations();
|
|
6677
|
+
plans.length = 0;
|
|
6678
|
+
wire = "budget_exceeded";
|
|
6679
|
+
budgetDenial = {
|
|
6680
|
+
breached: breaches.map((entry2) => entry2.budget.name),
|
|
6681
|
+
invalid: failures.map((failure) => failure.budget.name)
|
|
6682
|
+
};
|
|
6683
|
+
if (breaches.length > 0) this.budgetEngine.reportBreaches(breaches);
|
|
6684
|
+
}
|
|
6685
|
+
} else {
|
|
6686
|
+
if (breaches.length > 0) budgetDryRunOk = false;
|
|
6687
|
+
if (wire !== "dry_run") {
|
|
6688
|
+
for (const [index, charge] of charges.entries()) {
|
|
6689
|
+
if (!reserve(charge.bucketKey)) {
|
|
6690
|
+
releaseReservations();
|
|
6691
|
+
return { status: 503, body: { error: "limit_capacity_exhausted" } };
|
|
6692
|
+
}
|
|
6693
|
+
plans.push({
|
|
6694
|
+
kind: "budget",
|
|
6695
|
+
budget: charge.budget,
|
|
6696
|
+
bucketKey: charge.bucketKey,
|
|
6697
|
+
amount: charge.amount,
|
|
6698
|
+
generation: charge.generation,
|
|
6699
|
+
breached: peek.entries[index]?.allowed === false
|
|
6700
|
+
});
|
|
6701
|
+
}
|
|
6702
|
+
if (breaches.length > 0) {
|
|
6703
|
+
budgetBreachEntries = breaches;
|
|
6704
|
+
budgetBreachContexts = breaches.map((entry2) => ({
|
|
6705
|
+
name: entry2.budget.name,
|
|
6706
|
+
limit: entry2.budget.limit,
|
|
6707
|
+
spent: entry2.spent,
|
|
6708
|
+
attempted_amount: entry2.amount,
|
|
6709
|
+
currency: entry2.budget.currency,
|
|
6710
|
+
window: entry2.budget.windowRaw
|
|
6711
|
+
}));
|
|
6712
|
+
budgetTicketTimeoutMs = breaches[0]?.budget.approval?.timeoutMs;
|
|
6713
|
+
if (wire === "allow") {
|
|
6714
|
+
budgetTriggeredApproval = true;
|
|
6715
|
+
wire = "require_approval";
|
|
6716
|
+
}
|
|
6717
|
+
}
|
|
6718
|
+
}
|
|
6719
|
+
}
|
|
6720
|
+
}
|
|
6721
|
+
}
|
|
6722
|
+
if (budgetsBlock) {
|
|
6723
|
+
limitsBlock = { ...limitsBlock ?? {}, budgets: budgetsBlock };
|
|
6724
|
+
}
|
|
5746
6725
|
const matchedRuleName = decision.matchedRule?.name ?? null;
|
|
5747
6726
|
const matchedRuleIndex = decision.matchedRule?.index ?? null;
|
|
5748
6727
|
const responseBody = {
|
|
@@ -5752,15 +6731,27 @@ var GovernanceService = class {
|
|
|
5752
6731
|
matched_rule: matchedRuleName,
|
|
5753
6732
|
matched_rule_index: matchedRuleIndex
|
|
5754
6733
|
};
|
|
5755
|
-
if (
|
|
6734
|
+
if (shouldAttachFeedback(wire, decision)) {
|
|
5756
6735
|
responseBody["feedback"] = buildFeedback(decision.matchedRule, decision.reason);
|
|
5757
6736
|
}
|
|
6737
|
+
if (wire === "require_approval" && budgetTriggeredApproval && budgetBreachContexts) {
|
|
6738
|
+
responseBody["feedback"] = {
|
|
6739
|
+
message: `Budget ${budgetBreachContexts.map((b) => `"${b.name}"`).join(", ")} would be exceeded by this call; break-glass approval required`,
|
|
6740
|
+
suggestion: "Await the approval decision, reduce the amount, or wait for the window to reset."
|
|
6741
|
+
};
|
|
6742
|
+
}
|
|
6743
|
+
if (wire === "budget_exceeded" && budgetDenial) {
|
|
6744
|
+
responseBody["feedback"] = {
|
|
6745
|
+
message: budgetDenial.invalid.length > 0 ? `Budget ${budgetDenial.invalid.map((n) => `"${n}"`).join(", ")} could not read a valid spend amount from this call` : `Budget ${budgetDenial.breached.map((n) => `"${n}"`).join(", ")} would be exceeded by this call`,
|
|
6746
|
+
suggestion: budgetDenial.invalid.length > 0 ? "Retry with a non-negative finite amount in the expected field." : "Wait for the window to reset or reduce the amount."
|
|
6747
|
+
};
|
|
6748
|
+
}
|
|
5758
6749
|
if (limitsBlock) responseBody["limits"] = limitsBlock;
|
|
5759
6750
|
if (wire === "dry_run") {
|
|
5760
6751
|
responseBody["dry_run"] = {
|
|
5761
|
-
would_forward: decision.action === "allow" && !pipeline.evidenceBlocked,
|
|
6752
|
+
would_forward: decision.action === "allow" && !pipeline.evidenceBlocked && budgetDryRunOk,
|
|
5762
6753
|
evidence_satisfied: !pipeline.evidenceBlocked,
|
|
5763
|
-
limits_ok:
|
|
6754
|
+
limits_ok: budgetDryRunOk
|
|
5764
6755
|
};
|
|
5765
6756
|
}
|
|
5766
6757
|
if (pipeline.driftEvent) {
|
|
@@ -5792,6 +6783,12 @@ var GovernanceService = class {
|
|
|
5792
6783
|
});
|
|
5793
6784
|
return { status: 200, body: responseBody };
|
|
5794
6785
|
}
|
|
6786
|
+
const budgetsAtEvaluate = plans.some((plan) => plan.kind === "budget") ? structuredClone(budgetsBlock) : void 0;
|
|
6787
|
+
const totalBytes = entryBytes + planBytes(plans) + (budgetsAtEvaluate ? byteLength(budgetsAtEvaluate) : 0);
|
|
6788
|
+
if (this.pendingBytes + totalBytes > this.maxPendingBytes) {
|
|
6789
|
+
releaseReservations();
|
|
6790
|
+
return { status: 503, body: { error: "evaluation_backlog_full" } };
|
|
6791
|
+
}
|
|
5795
6792
|
let approvalTicketId;
|
|
5796
6793
|
let ticketTimeoutAtMs;
|
|
5797
6794
|
if (wire === "require_approval") {
|
|
@@ -5801,14 +6798,18 @@ var GovernanceService = class {
|
|
|
5801
6798
|
"[helio] invariant violation: require_approval decision without an approvalRouter"
|
|
5802
6799
|
);
|
|
5803
6800
|
}
|
|
5804
|
-
const timeoutMs = decision.matchedRule?.approval?.timeoutMs ?? this.approvalTimeoutMs;
|
|
6801
|
+
const timeoutMs = (decision.action === "require_approval" ? decision.matchedRule?.approval?.timeoutMs : budgetTicketTimeoutMs) ?? this.approvalTimeoutMs;
|
|
5805
6802
|
const ticket = router.createNativeTicket({
|
|
5806
6803
|
tool_name: toolName,
|
|
5807
|
-
|
|
6804
|
+
// Cloned: the ticket is what the APPROVER sees, and a direct
|
|
6805
|
+
// embedder mutating its arguments object after /evaluate must not
|
|
6806
|
+
// rewrite it (same guard as the pending entry's evidence below).
|
|
6807
|
+
tool_input: structuredClone(req.arguments ?? {}),
|
|
5808
6808
|
matched_rule: decision.matchedRule,
|
|
5809
6809
|
session_id: req.session_id,
|
|
5810
6810
|
origin: req.origin,
|
|
5811
|
-
timeout_ms: timeoutMs
|
|
6811
|
+
timeout_ms: timeoutMs,
|
|
6812
|
+
breached_budgets: budgetBreachContexts
|
|
5812
6813
|
});
|
|
5813
6814
|
approvalTicketId = ticket.id;
|
|
5814
6815
|
ticketTimeoutAtMs = this.now() + timeoutMs;
|
|
@@ -5817,6 +6818,7 @@ var GovernanceService = class {
|
|
|
5817
6818
|
timeout_ms: timeoutMs,
|
|
5818
6819
|
resolve_path: `/approval/${ticket.id}/resolve`
|
|
5819
6820
|
};
|
|
6821
|
+
if (budgetBreachEntries) this.budgetEngine?.reportBreaches(budgetBreachEntries);
|
|
5820
6822
|
}
|
|
5821
6823
|
const entry = {
|
|
5822
6824
|
evaluationId,
|
|
@@ -5824,22 +6826,27 @@ var GovernanceService = class {
|
|
|
5824
6826
|
agentId: req.agent_id,
|
|
5825
6827
|
sessionId: req.session_id,
|
|
5826
6828
|
toolName,
|
|
5827
|
-
|
|
5828
|
-
|
|
6829
|
+
// Cloned: direct embedders share these references and could otherwise
|
|
6830
|
+
// mutate the audit evidence (and desync the byte accounting) after
|
|
6831
|
+
// admission. The HTTP route always builds fresh objects; this guards
|
|
6832
|
+
// the library surface.
|
|
6833
|
+
toolInput: structuredClone(req.arguments ?? {}),
|
|
6834
|
+
metadata: req.metadata === null ? null : structuredClone(req.metadata),
|
|
5829
6835
|
action: decision.action,
|
|
5830
6836
|
matchedRuleName,
|
|
5831
6837
|
matchedRuleIndex,
|
|
5832
6838
|
flaggedDestructive: pipeline.flaggedDestructive,
|
|
5833
|
-
|
|
6839
|
+
plans,
|
|
6840
|
+
budgetsAtEvaluate,
|
|
5834
6841
|
approvalTicketId,
|
|
5835
6842
|
timestampIso,
|
|
5836
6843
|
createdAtMs: this.now(),
|
|
5837
6844
|
evaluationExpiresAtMs: this.now() + this.ttlMs,
|
|
5838
6845
|
ticketTimeoutAtMs,
|
|
5839
|
-
bytes:
|
|
6846
|
+
bytes: totalBytes
|
|
5840
6847
|
};
|
|
5841
6848
|
this.pending.set(evaluationId, entry);
|
|
5842
|
-
this.pendingBytes +=
|
|
6849
|
+
this.pendingBytes += totalBytes;
|
|
5843
6850
|
if (approvalTicketId) this.ticketToEvaluation.set(approvalTicketId, evaluationId);
|
|
5844
6851
|
return { status: 200, body: responseBody };
|
|
5845
6852
|
}
|
|
@@ -5883,20 +6890,20 @@ var GovernanceService = class {
|
|
|
5883
6890
|
let approvedBy = null;
|
|
5884
6891
|
let approvalContext;
|
|
5885
6892
|
if (entry.approvalTicketId) {
|
|
5886
|
-
|
|
5887
|
-
const
|
|
5888
|
-
if (!
|
|
6893
|
+
this.snapshotTicketResolution(entry);
|
|
6894
|
+
const resolution = entry.ticketResolution;
|
|
6895
|
+
if (!resolution) {
|
|
5889
6896
|
return { status: 409, body: { error: "approval_unresolved" } };
|
|
5890
6897
|
}
|
|
5891
|
-
approvalStatus = status;
|
|
5892
|
-
approvedBy =
|
|
5893
|
-
if (
|
|
6898
|
+
approvalStatus = resolution.status;
|
|
6899
|
+
approvedBy = resolution.resolvedBy;
|
|
6900
|
+
if (resolution.denialReason || resolution.escalatedAt) {
|
|
5894
6901
|
approvalContext = {
|
|
5895
6902
|
ticket_id: entry.approvalTicketId,
|
|
5896
|
-
...
|
|
5897
|
-
...
|
|
5898
|
-
escalated_at:
|
|
5899
|
-
escalated_to: [...
|
|
6903
|
+
...resolution.denialReason ? { denial_reason: resolution.denialReason } : {},
|
|
6904
|
+
...resolution.escalatedAt ? {
|
|
6905
|
+
escalated_at: resolution.escalatedAt,
|
|
6906
|
+
escalated_to: [...resolution.escalatedTo ?? []]
|
|
5900
6907
|
} : {}
|
|
5901
6908
|
};
|
|
5902
6909
|
}
|
|
@@ -5905,20 +6912,32 @@ var GovernanceService = class {
|
|
|
5905
6912
|
if (!Number.isFinite(req.actual_amount) || req.actual_amount < 0) {
|
|
5906
6913
|
return { status: 400, body: { error: "invalid_actual_amount" } };
|
|
5907
6914
|
}
|
|
5908
|
-
|
|
6915
|
+
const hasMoneyPlan = entry.plans.some(
|
|
6916
|
+
(plan) => plan.kind === "spend" || plan.kind === "budget"
|
|
6917
|
+
);
|
|
6918
|
+
if (!hasMoneyPlan) {
|
|
5909
6919
|
return { status: 400, body: { error: "no_spend_rule" } };
|
|
5910
6920
|
}
|
|
5911
6921
|
}
|
|
5912
6922
|
const callHappened = req.status === "success" || req.status === "error";
|
|
5913
|
-
|
|
5914
|
-
|
|
5915
|
-
|
|
6923
|
+
if (entry.commitState && entry.commitState.payloadHash !== payloadHash) {
|
|
6924
|
+
return { status: 409, body: { error: "evaluation_conflict" } };
|
|
6925
|
+
}
|
|
6926
|
+
const auditId = entry.commitState?.auditId ?? randomUUID5();
|
|
6927
|
+
let limitsChain = entry.commitState?.limitsChain;
|
|
6928
|
+
if (callHappened && entry.plans.length > 0 && !entry.commitState) {
|
|
6929
|
+
limitsChain = this.commitPlans(entry, req.actual_amount, auditId, approvalStatus);
|
|
6930
|
+
entry.commitState = { auditId, limitsChain, payloadHash };
|
|
6931
|
+
} else if (!callHappened && !entry.commitState && entry.budgetsAtEvaluate) {
|
|
6932
|
+
limitsChain = { ...limitsChain ?? {}, budgets: entry.budgetsAtEvaluate };
|
|
5916
6933
|
}
|
|
6934
|
+
const budgetBreachBlocked = !callHappened && entry.action !== "require_approval" && (approvalStatus === "denied" || approvalStatus === "timeout") && entry.plans.some((plan) => plan.kind === "budget" && plan.breached);
|
|
5917
6935
|
if (callHappened && this.evidenceStore && entry.sessionId) {
|
|
5918
6936
|
this.evidenceStore.recordToolCall(entry.sessionId, entry.toolName, req.status === "success");
|
|
5919
6937
|
}
|
|
5920
6938
|
const evidenceOutcomes = this.populateEvidence(req, entry);
|
|
5921
|
-
|
|
6939
|
+
this.writeAudit({
|
|
6940
|
+
id: auditId,
|
|
5922
6941
|
timestampIso: entry.timestampIso,
|
|
5923
6942
|
origin: entry.origin,
|
|
5924
6943
|
agentId: entry.agentId,
|
|
@@ -5927,6 +6946,7 @@ var GovernanceService = class {
|
|
|
5927
6946
|
toolInput: entry.toolInput,
|
|
5928
6947
|
metadata: entry.metadata,
|
|
5929
6948
|
action: entry.action,
|
|
6949
|
+
budgetBreachBlocked,
|
|
5930
6950
|
wire: entry.action === "require_approval" ? "require_approval" : "allow",
|
|
5931
6951
|
matchedRuleName: entry.matchedRuleName,
|
|
5932
6952
|
matchedRuleIndex: entry.matchedRuleIndex,
|
|
@@ -6015,7 +7035,7 @@ var GovernanceService = class {
|
|
|
6015
7035
|
return { status: 400, body: { error: "reserved_metadata_key", key: reserved } };
|
|
6016
7036
|
}
|
|
6017
7037
|
this.touchAdapter(req.origin);
|
|
6018
|
-
const evaluationId =
|
|
7038
|
+
const evaluationId = randomUUID5();
|
|
6019
7039
|
const toolName = `install:${req.package.source ?? "pkg"}:${req.package.name}`;
|
|
6020
7040
|
const verdict = this.evaluateInstall(req);
|
|
6021
7041
|
const denied = verdict.decision === "deny";
|
|
@@ -6167,6 +7187,7 @@ var GovernanceService = class {
|
|
|
6167
7187
|
if (!resolved) {
|
|
6168
7188
|
return { status: 409, body: { error: "already_resolved" } };
|
|
6169
7189
|
}
|
|
7190
|
+
if (entry) this.snapshotTicketResolution(entry);
|
|
6170
7191
|
return { status: 200, body: { ok: true } };
|
|
6171
7192
|
}
|
|
6172
7193
|
// -------------------------------------------------------------------------
|
|
@@ -6192,7 +7213,7 @@ var GovernanceService = class {
|
|
|
6192
7213
|
* closed, so an emptied bucket frees its slot without waiting for the sweep.
|
|
6193
7214
|
*/
|
|
6194
7215
|
reserveSenderKey(key) {
|
|
6195
|
-
if (!key
|
|
7216
|
+
if (!isSenderScopedKey(key)) return true;
|
|
6196
7217
|
if (this.senderKeys.has(key)) return true;
|
|
6197
7218
|
if (this.hasLiveBucket(key)) {
|
|
6198
7219
|
this.senderKeys.add(key);
|
|
@@ -6210,8 +7231,9 @@ var GovernanceService = class {
|
|
|
6210
7231
|
if (this.senderKeys.size === 0) return;
|
|
6211
7232
|
const inUse = /* @__PURE__ */ new Set();
|
|
6212
7233
|
for (const entry of this.pending.values()) {
|
|
6213
|
-
|
|
6214
|
-
|
|
7234
|
+
for (const plan of entry.plans) {
|
|
7235
|
+
const key = plan.kind === "budget" ? plan.bucketKey : plan.key;
|
|
7236
|
+
if (isSenderScopedKey(key)) inUse.add(key);
|
|
6215
7237
|
}
|
|
6216
7238
|
}
|
|
6217
7239
|
for (const key of this.senderKeys) {
|
|
@@ -6226,7 +7248,7 @@ var GovernanceService = class {
|
|
|
6226
7248
|
* an emptied bucket IS the prune-on-touch mechanism.
|
|
6227
7249
|
*/
|
|
6228
7250
|
hasLiveBucket(key) {
|
|
6229
|
-
return this.rateLimiter?.getKeyState(key) !== void 0 || this.spendLimiter?.getKeyState(key) !== void 0;
|
|
7251
|
+
return this.rateLimiter?.getKeyState(key) !== void 0 || this.spendLimiter?.getKeyState(key) !== void 0 || this.budgetEngine?.hasBucket(key) === true;
|
|
6230
7252
|
}
|
|
6231
7253
|
close() {
|
|
6232
7254
|
if (this.closed) return;
|
|
@@ -6251,7 +7273,17 @@ var GovernanceService = class {
|
|
|
6251
7273
|
if (now >= entry.evaluationExpiresAtMs) {
|
|
6252
7274
|
if (entry.approvalTicketId) {
|
|
6253
7275
|
this.approvalRouter?.resolveNativeTicket(entry.approvalTicketId, "timeout");
|
|
7276
|
+
this.snapshotTicketResolution(entry);
|
|
6254
7277
|
}
|
|
7278
|
+
const resolution = entry.ticketResolution;
|
|
7279
|
+
const approvalContext = entry.approvalTicketId && resolution && (resolution.denialReason || resolution.escalatedAt) ? {
|
|
7280
|
+
ticket_id: entry.approvalTicketId,
|
|
7281
|
+
...resolution.denialReason ? { denial_reason: resolution.denialReason } : {},
|
|
7282
|
+
...resolution.escalatedAt ? {
|
|
7283
|
+
escalated_at: resolution.escalatedAt,
|
|
7284
|
+
escalated_to: [...resolution.escalatedTo ?? []]
|
|
7285
|
+
} : {}
|
|
7286
|
+
} : void 0;
|
|
6255
7287
|
const auditId = this.writeAudit({
|
|
6256
7288
|
timestampIso: entry.timestampIso,
|
|
6257
7289
|
origin: entry.origin,
|
|
@@ -6267,6 +7299,10 @@ var GovernanceService = class {
|
|
|
6267
7299
|
flaggedDestructive: entry.flaggedDestructive,
|
|
6268
7300
|
dryRun: false,
|
|
6269
7301
|
recordKind: "evaluation_expired",
|
|
7302
|
+
approvalStatus: resolution?.status ?? null,
|
|
7303
|
+
approvedBy: resolution?.resolvedBy ?? null,
|
|
7304
|
+
approvalContext,
|
|
7305
|
+
limitsChain: !entry.commitState && entry.budgetsAtEvaluate ? { budgets: entry.budgetsAtEvaluate } : void 0,
|
|
6270
7306
|
sidebandUnreported: true
|
|
6271
7307
|
});
|
|
6272
7308
|
this.discardPending(entry);
|
|
@@ -6283,6 +7319,7 @@ var GovernanceService = class {
|
|
|
6283
7319
|
}
|
|
6284
7320
|
if (entry.approvalTicketId && entry.ticketTimeoutAtMs !== void 0 && now >= entry.ticketTimeoutAtMs) {
|
|
6285
7321
|
this.approvalRouter?.resolveNativeTicket(entry.approvalTicketId, "timeout");
|
|
7322
|
+
this.snapshotTicketResolution(entry);
|
|
6286
7323
|
}
|
|
6287
7324
|
return "active";
|
|
6288
7325
|
}
|
|
@@ -6303,6 +7340,18 @@ var GovernanceService = class {
|
|
|
6303
7340
|
getTicketStatus(ticketId) {
|
|
6304
7341
|
return this.approvalRouter?.getTicket(ticketId);
|
|
6305
7342
|
}
|
|
7343
|
+
/** Latch the entry's ticket resolution while the ticket still exists. */
|
|
7344
|
+
snapshotTicketResolution(entry) {
|
|
7345
|
+
if (entry.ticketResolution || !entry.approvalTicketId) return;
|
|
7346
|
+
const ticket = this.getTicketStatus(entry.approvalTicketId);
|
|
7347
|
+
if (!ticket || ticket.status === "pending") return;
|
|
7348
|
+
entry.ticketResolution = {
|
|
7349
|
+
status: ticket.status,
|
|
7350
|
+
resolvedBy: ticket.resolved_by ?? null,
|
|
7351
|
+
...ticket.denial_reason ? { denialReason: ticket.denial_reason } : {},
|
|
7352
|
+
...ticket.escalated_at ? { escalatedAt: ticket.escalated_at, escalatedTo: [...ticket.escalated_to ?? []] } : {}
|
|
7353
|
+
};
|
|
7354
|
+
}
|
|
6306
7355
|
planRate(decision, toolName, sessionId, senderId) {
|
|
6307
7356
|
const limits = decision.matchedRule?.limits;
|
|
6308
7357
|
if (!this.rateLimiter || !limits?.maxCalls || !limits.windowMs) {
|
|
@@ -6328,7 +7377,10 @@ var GovernanceService = class {
|
|
|
6328
7377
|
planSpend(decision, toolName, sessionId, args, senderId) {
|
|
6329
7378
|
const maxSpend = decision.matchedRule?.limits?.maxSpend;
|
|
6330
7379
|
if (!this.spendLimiter || !maxSpend) return { allowed: true };
|
|
6331
|
-
const key =
|
|
7380
|
+
const key = spendBucketKey(
|
|
7381
|
+
buildLimitKey(maxSpend.key, toolName, sessionId, senderId),
|
|
7382
|
+
decision.matchedRule.index
|
|
7383
|
+
);
|
|
6332
7384
|
const rawAmount = resolvePath(maxSpend.field, args ?? {});
|
|
6333
7385
|
if (typeof rawAmount !== "number" || !Number.isFinite(rawAmount) || rawAmount < 0) {
|
|
6334
7386
|
return { allowed: false, block: { reason: "invalid_amount", limit: maxSpend.limit } };
|
|
@@ -6357,47 +7409,88 @@ var GovernanceService = class {
|
|
|
6357
7409
|
allowed: peek.allowed
|
|
6358
7410
|
};
|
|
6359
7411
|
}
|
|
6360
|
-
/** Commit
|
|
6361
|
-
|
|
6362
|
-
|
|
6363
|
-
|
|
6364
|
-
|
|
6365
|
-
|
|
6366
|
-
|
|
6367
|
-
|
|
6368
|
-
|
|
6369
|
-
|
|
6370
|
-
|
|
6371
|
-
|
|
6372
|
-
|
|
6373
|
-
|
|
6374
|
-
|
|
7412
|
+
/** Commit every plan of one call at /audit time; returns the chain blocks. */
|
|
7413
|
+
commitPlans(entry, actualAmount, auditId, approvalStatus) {
|
|
7414
|
+
let chain;
|
|
7415
|
+
const budgetPlans = entry.plans.filter((plan) => plan.kind === "budget");
|
|
7416
|
+
if (budgetPlans.length > 0 && this.budgetEngine) {
|
|
7417
|
+
const kinds = new Map(
|
|
7418
|
+
budgetPlans.filter((plan) => plan.breached && approvalStatus === "approved").map((plan) => [plan.budget.name, "approved_overage"])
|
|
7419
|
+
);
|
|
7420
|
+
const snapshots = this.budgetEngine.recordAll(
|
|
7421
|
+
budgetPlans.map((plan) => ({
|
|
7422
|
+
budget: plan.budget,
|
|
7423
|
+
bucketKey: plan.bucketKey,
|
|
7424
|
+
amount: actualAmount ?? plan.amount,
|
|
7425
|
+
generation: plan.generation
|
|
7426
|
+
})),
|
|
7427
|
+
{
|
|
7428
|
+
kind: "spend",
|
|
7429
|
+
...kinds.size > 0 ? { kinds } : {},
|
|
7430
|
+
auditRecordId: auditId,
|
|
7431
|
+
origin: entry.origin,
|
|
7432
|
+
toolName: entry.toolName,
|
|
7433
|
+
timestampIso: new Date(this.now()).toISOString()
|
|
6375
7434
|
}
|
|
7435
|
+
);
|
|
7436
|
+
const frozenByName = new Map(
|
|
7437
|
+
(entry.budgetsAtEvaluate ?? []).map((block) => [block["name"], block])
|
|
7438
|
+
);
|
|
7439
|
+
chain = {
|
|
7440
|
+
...chain ?? {},
|
|
7441
|
+
budgets: snapshots.map((snapshot) => {
|
|
7442
|
+
const kind = kinds.get(snapshot.budget.name) ?? "spend";
|
|
7443
|
+
const frozen = frozenByName.get(snapshot.budget.name);
|
|
7444
|
+
return snapshot.stale && frozen ? { ...frozen, kind, stale: true } : budgetWireBlock(snapshot, kind);
|
|
7445
|
+
})
|
|
6376
7446
|
};
|
|
6377
7447
|
}
|
|
6378
|
-
|
|
6379
|
-
|
|
6380
|
-
|
|
6381
|
-
|
|
6382
|
-
|
|
6383
|
-
|
|
6384
|
-
|
|
6385
|
-
|
|
6386
|
-
|
|
6387
|
-
|
|
6388
|
-
|
|
6389
|
-
|
|
6390
|
-
|
|
6391
|
-
|
|
6392
|
-
|
|
6393
|
-
|
|
6394
|
-
|
|
6395
|
-
|
|
7448
|
+
for (const plan of entry.plans) {
|
|
7449
|
+
if (plan.kind === "budget") {
|
|
7450
|
+
continue;
|
|
7451
|
+
}
|
|
7452
|
+
if (plan.kind === "rate" && this.rateLimiter && plan.limits.maxCalls && plan.limits.windowMs) {
|
|
7453
|
+
const r = this.rateLimiter.record({
|
|
7454
|
+
key: plan.key,
|
|
7455
|
+
maxCalls: plan.limits.maxCalls,
|
|
7456
|
+
windowMs: plan.limits.windowMs
|
|
7457
|
+
});
|
|
7458
|
+
chain = {
|
|
7459
|
+
...chain ?? {},
|
|
7460
|
+
rate_limit: {
|
|
7461
|
+
allowed: r.allowed,
|
|
7462
|
+
current: r.current,
|
|
7463
|
+
limit: r.limit,
|
|
7464
|
+
window_ms: r.windowMs,
|
|
7465
|
+
reset_at_ms: r.resetAtMs
|
|
7466
|
+
}
|
|
7467
|
+
};
|
|
7468
|
+
}
|
|
7469
|
+
if (plan.kind === "spend" && this.spendLimiter && plan.limits.maxSpend) {
|
|
7470
|
+
const amount = actualAmount ?? plan.amount ?? 0;
|
|
7471
|
+
const r = this.spendLimiter.record({
|
|
7472
|
+
key: plan.key,
|
|
7473
|
+
amount,
|
|
7474
|
+
limit: plan.limits.maxSpend.limit,
|
|
7475
|
+
windowMs: plan.limits.maxSpend.windowMs
|
|
7476
|
+
});
|
|
7477
|
+
this.spendLimiter.setCurrency(plan.key, plan.limits.maxSpend.currency);
|
|
7478
|
+
chain = {
|
|
7479
|
+
...chain ?? {},
|
|
7480
|
+
spend_limit: {
|
|
7481
|
+
allowed: r.allowed,
|
|
7482
|
+
current_spend: r.currentSpend,
|
|
7483
|
+
limit: r.limit,
|
|
7484
|
+
window_ms: r.windowMs,
|
|
7485
|
+
reset_at_ms: r.resetAtMs
|
|
7486
|
+
}
|
|
7487
|
+
};
|
|
7488
|
+
}
|
|
6396
7489
|
}
|
|
6397
|
-
return
|
|
7490
|
+
return chain;
|
|
6398
7491
|
}
|
|
6399
7492
|
writeAudit(args) {
|
|
6400
|
-
const id =
|
|
7493
|
+
const id = args.id ?? randomUUID5();
|
|
6401
7494
|
if (!this.auditWriter) return id;
|
|
6402
7495
|
const blockReason = deriveBlockReason(args);
|
|
6403
7496
|
let evidenceChain = args.limitsChain ?? null;
|
|
@@ -6450,6 +7543,7 @@ function deriveBlockReason(args) {
|
|
|
6450
7543
|
if (args.recordKind === "evaluation_expired") return null;
|
|
6451
7544
|
if (args.recordKind === "install_scan") return args.wire === "deny" ? "install_denied" : null;
|
|
6452
7545
|
if (args.dryRun) return null;
|
|
7546
|
+
if (args.budgetBreachBlocked) return "budget_exceeded";
|
|
6453
7547
|
if (args.approvalStatus === "denied") return "approval_denied";
|
|
6454
7548
|
if (args.approvalStatus === "timeout") return "approval_timeout";
|
|
6455
7549
|
if (args.approvalStatus === "cancelled") return "cancelled";
|
|
@@ -6460,6 +7554,8 @@ function deriveBlockReason(args) {
|
|
|
6460
7554
|
return "rate_limited";
|
|
6461
7555
|
case "spend_limited":
|
|
6462
7556
|
return "spend_limited";
|
|
7557
|
+
case "budget_exceeded":
|
|
7558
|
+
return "budget_exceeded";
|
|
6463
7559
|
default:
|
|
6464
7560
|
return null;
|
|
6465
7561
|
}
|
|
@@ -6470,10 +7566,15 @@ function buildFeedback(rule, reason) {
|
|
|
6470
7566
|
return suggestion ? { message, suggestion } : { message };
|
|
6471
7567
|
}
|
|
6472
7568
|
function isBlocking(wire) {
|
|
6473
|
-
return wire === "deny" || wire === "rate_limited" || wire === "spend_limited";
|
|
7569
|
+
return wire === "deny" || wire === "rate_limited" || wire === "spend_limited" || wire === "budget_exceeded";
|
|
7570
|
+
}
|
|
7571
|
+
function shouldAttachFeedback(wire, decision) {
|
|
7572
|
+
if (isBlocking(wire)) return true;
|
|
7573
|
+
const gating = wire === "require_approval" || wire === "dry_run";
|
|
7574
|
+
return gating && decision.action !== "allow" && decision.matchedRule?.feedback != null;
|
|
6474
7575
|
}
|
|
6475
7576
|
function isTerminalAtEvaluate(wire) {
|
|
6476
|
-
return wire === "deny" || wire === "rate_limited" || wire === "spend_limited" || wire === "dry_run";
|
|
7577
|
+
return wire === "deny" || wire === "rate_limited" || wire === "spend_limited" || wire === "budget_exceeded" || wire === "dry_run";
|
|
6477
7578
|
}
|
|
6478
7579
|
function policyCanRequireApproval(policy) {
|
|
6479
7580
|
if (policy.flagDestructive === "require_approval" || policy.onToolDrift === "require_approval") {
|
|
@@ -6526,9 +7627,62 @@ function toMcpToolDef(tool) {
|
|
|
6526
7627
|
function byteLength(value) {
|
|
6527
7628
|
return Buffer.byteLength(canonicalize(value), "utf8");
|
|
6528
7629
|
}
|
|
7630
|
+
function isSenderScopedKey(key) {
|
|
7631
|
+
if (key.startsWith("sender:")) return true;
|
|
7632
|
+
if (!key.startsWith("budget:")) return false;
|
|
7633
|
+
const scope = key.slice("budget:".length);
|
|
7634
|
+
const sep = scope.indexOf(":");
|
|
7635
|
+
return sep !== -1 && scope.slice(sep + 1).startsWith("sender:");
|
|
7636
|
+
}
|
|
7637
|
+
function budgetWireBlock(entry, kind) {
|
|
7638
|
+
return {
|
|
7639
|
+
...kind ? { kind } : {},
|
|
7640
|
+
name: entry.budget.name,
|
|
7641
|
+
limit: entry.budget.limit,
|
|
7642
|
+
spent: entry.spent,
|
|
7643
|
+
remaining: entry.remaining,
|
|
7644
|
+
attempted_amount: entry.amount,
|
|
7645
|
+
currency: entry.budget.currency,
|
|
7646
|
+
window: entry.budget.windowRaw,
|
|
7647
|
+
on_exceed: entry.budget.onExceed,
|
|
7648
|
+
allowed: entry.allowed,
|
|
7649
|
+
reset_at_ms: entry.resetAtMs,
|
|
7650
|
+
...entry.stale ? { stale: true } : {}
|
|
7651
|
+
};
|
|
7652
|
+
}
|
|
7653
|
+
function budgetFailureBlock(failure) {
|
|
7654
|
+
return {
|
|
7655
|
+
name: failure.budget.name,
|
|
7656
|
+
limit: failure.budget.limit,
|
|
7657
|
+
spent: failure.spent,
|
|
7658
|
+
remaining: failure.remaining,
|
|
7659
|
+
attempted_amount: null,
|
|
7660
|
+
currency: failure.budget.currency,
|
|
7661
|
+
window: failure.budget.windowRaw,
|
|
7662
|
+
on_exceed: failure.budget.onExceed,
|
|
7663
|
+
allowed: false,
|
|
7664
|
+
reason: "invalid_amount",
|
|
7665
|
+
reset_at_ms: failure.resetAtMs
|
|
7666
|
+
};
|
|
7667
|
+
}
|
|
7668
|
+
function planBytes(plans) {
|
|
7669
|
+
let total = 0;
|
|
7670
|
+
for (const plan of plans) {
|
|
7671
|
+
total += byteLength(
|
|
7672
|
+
plan.kind === "budget" ? {
|
|
7673
|
+
kind: plan.kind,
|
|
7674
|
+
name: plan.budget.name,
|
|
7675
|
+
key: plan.bucketKey,
|
|
7676
|
+
amount: plan.amount,
|
|
7677
|
+
breached: plan.breached
|
|
7678
|
+
} : { kind: plan.kind, key: plan.key, amount: plan.amount ?? 0 }
|
|
7679
|
+
);
|
|
7680
|
+
}
|
|
7681
|
+
return total;
|
|
7682
|
+
}
|
|
6529
7683
|
|
|
6530
7684
|
// src/approval/queue.ts
|
|
6531
|
-
import { randomUUID as
|
|
7685
|
+
import { randomUUID as randomUUID6 } from "crypto";
|
|
6532
7686
|
var ApprovalQueue = class {
|
|
6533
7687
|
tickets = /* @__PURE__ */ new Map();
|
|
6534
7688
|
now;
|
|
@@ -6558,7 +7712,7 @@ var ApprovalQueue = class {
|
|
|
6558
7712
|
if (this.closed) throw new Error("ApprovalQueue is closed");
|
|
6559
7713
|
const now = this.now();
|
|
6560
7714
|
const ticket = {
|
|
6561
|
-
id:
|
|
7715
|
+
id: randomUUID6(),
|
|
6562
7716
|
tool_name: params.tool_name,
|
|
6563
7717
|
tool_input: params.tool_input,
|
|
6564
7718
|
matched_rule: params.matched_rule,
|
|
@@ -6568,6 +7722,9 @@ var ApprovalQueue = class {
|
|
|
6568
7722
|
requested_at: new Date(now).toISOString(),
|
|
6569
7723
|
timeout_at: new Date(now + params.timeout_ms).toISOString(),
|
|
6570
7724
|
timeout_ms: params.timeout_ms,
|
|
7725
|
+
// Absent (not empty) on plain rule tickets: presence is the marker a
|
|
7726
|
+
// future standing-approval store must exclude (issue #127).
|
|
7727
|
+
...params.breached_budgets?.length ? { breached_budgets: params.breached_budgets } : {},
|
|
6571
7728
|
status: "pending",
|
|
6572
7729
|
notification_failures: []
|
|
6573
7730
|
};
|
|
@@ -6671,8 +7828,9 @@ var ApprovalRouter = class {
|
|
|
6671
7828
|
return { status: "denied", resolvedBy: "system", reason: "Router is closed", ticketId: "" };
|
|
6672
7829
|
}
|
|
6673
7830
|
const rule = params.matched_rule;
|
|
6674
|
-
const
|
|
6675
|
-
const
|
|
7831
|
+
const approvalConfig = params.approval ?? rule?.approval;
|
|
7832
|
+
const timeoutMs = approvalConfig?.timeoutMs ?? this.defaultTimeoutMs;
|
|
7833
|
+
const channelName = approvalConfig?.channel ?? "dashboard";
|
|
6676
7834
|
const ticket = this.queue.add({
|
|
6677
7835
|
tool_name: params.tool_name,
|
|
6678
7836
|
tool_input: params.tool_input,
|
|
@@ -6680,7 +7838,8 @@ var ApprovalRouter = class {
|
|
|
6680
7838
|
rule_index: rule?.index ?? null,
|
|
6681
7839
|
channel_name: channelName,
|
|
6682
7840
|
session_id: params.session_id,
|
|
6683
|
-
timeout_ms: timeoutMs
|
|
7841
|
+
timeout_ms: timeoutMs,
|
|
7842
|
+
breached_budgets: params.breached_budgets
|
|
6684
7843
|
});
|
|
6685
7844
|
this.onSubmit?.(ticket);
|
|
6686
7845
|
const outcome = await new Promise((resolve2) => {
|
|
@@ -6698,8 +7857,8 @@ var ApprovalRouter = class {
|
|
|
6698
7857
|
}, timeoutMs);
|
|
6699
7858
|
timer.unref();
|
|
6700
7859
|
let escalationTimer;
|
|
6701
|
-
const delegates =
|
|
6702
|
-
const escalationAfterMs =
|
|
7860
|
+
const delegates = approvalConfig?.delegates;
|
|
7861
|
+
const escalationAfterMs = approvalConfig?.escalationAfterMs;
|
|
6703
7862
|
if (escalationAfterMs !== void 0 && escalationAfterMs > 0 && escalationAfterMs < timeoutMs) {
|
|
6704
7863
|
escalationTimer = setTimeout(() => {
|
|
6705
7864
|
if (!this.pending.has(ticket.id)) return;
|
|
@@ -6775,7 +7934,8 @@ var ApprovalRouter = class {
|
|
|
6775
7934
|
rule_index: rule?.index ?? null,
|
|
6776
7935
|
channel_name: `${NATIVE_CHANNEL_PREFIX}${params.origin}`,
|
|
6777
7936
|
session_id: params.session_id,
|
|
6778
|
-
timeout_ms: timeoutMs
|
|
7937
|
+
timeout_ms: timeoutMs,
|
|
7938
|
+
breached_budgets: params.breached_budgets
|
|
6779
7939
|
});
|
|
6780
7940
|
this.onSubmit?.(ticket);
|
|
6781
7941
|
return ticket;
|
|
@@ -6953,6 +8113,7 @@ ${safeInput}
|
|
|
6953
8113
|
if (ticket.session_id) {
|
|
6954
8114
|
detailLines.push(`*Session:* \`${sanitizeCodeSpanContent(ticket.session_id)}\``);
|
|
6955
8115
|
}
|
|
8116
|
+
const budgetBlocks = buildBudgetBlocks(ticket);
|
|
6956
8117
|
return [
|
|
6957
8118
|
{
|
|
6958
8119
|
type: "header",
|
|
@@ -6962,6 +8123,7 @@ ${safeInput}
|
|
|
6962
8123
|
type: "section",
|
|
6963
8124
|
text: { type: "mrkdwn", text: detailLines.join("\n") }
|
|
6964
8125
|
},
|
|
8126
|
+
...budgetBlocks,
|
|
6965
8127
|
{
|
|
6966
8128
|
type: "context",
|
|
6967
8129
|
elements: [
|
|
@@ -6988,8 +8150,56 @@ ${safeInput}
|
|
|
6988
8150
|
action_id: `helio_deny:${ticket.id}`
|
|
6989
8151
|
}
|
|
6990
8152
|
]
|
|
6991
|
-
}
|
|
6992
|
-
];
|
|
8153
|
+
}
|
|
8154
|
+
];
|
|
8155
|
+
}
|
|
8156
|
+
var MAX_SECTION_TEXT = 2900;
|
|
8157
|
+
var MAX_BUDGET_SECTIONS = 40;
|
|
8158
|
+
function buildBudgetBlocks(ticket) {
|
|
8159
|
+
const breached = ticket.breached_budgets;
|
|
8160
|
+
if (!breached?.length) return [];
|
|
8161
|
+
const lines = breached.map(
|
|
8162
|
+
(b) => `\u2022 \`${sanitizeCodeSpanContent(b.name)}\` \u2014 ${String(b.spent)}/${String(b.limit)} ${sanitizeMrkdwnText(b.currency)} spent, attempting +${String(b.attempted_amount)} (${sanitizeMrkdwnText(b.window)} window)`
|
|
8163
|
+
);
|
|
8164
|
+
const sections = [];
|
|
8165
|
+
let current = "*Breached budgets (approval spends past the limit):*";
|
|
8166
|
+
let pendingLines = 0;
|
|
8167
|
+
let rendered = 0;
|
|
8168
|
+
let capped = false;
|
|
8169
|
+
for (const line of lines) {
|
|
8170
|
+
if (current.length + 1 + line.length > MAX_SECTION_TEXT) {
|
|
8171
|
+
sections.push(current);
|
|
8172
|
+
if (sections.length >= MAX_BUDGET_SECTIONS) {
|
|
8173
|
+
capped = true;
|
|
8174
|
+
break;
|
|
8175
|
+
}
|
|
8176
|
+
current = line;
|
|
8177
|
+
pendingLines = 1;
|
|
8178
|
+
} else {
|
|
8179
|
+
current = `${current}
|
|
8180
|
+
${line}`;
|
|
8181
|
+
pendingLines += 1;
|
|
8182
|
+
}
|
|
8183
|
+
rendered += 1;
|
|
8184
|
+
}
|
|
8185
|
+
if (!capped && pendingLines > 0) sections.push(current);
|
|
8186
|
+
const blocks = sections.map((text) => ({
|
|
8187
|
+
type: "section",
|
|
8188
|
+
text: { type: "mrkdwn", text }
|
|
8189
|
+
}));
|
|
8190
|
+
const omitted = lines.length - rendered;
|
|
8191
|
+
if (omitted > 0) {
|
|
8192
|
+
blocks.push({
|
|
8193
|
+
type: "context",
|
|
8194
|
+
elements: [
|
|
8195
|
+
{
|
|
8196
|
+
type: "mrkdwn",
|
|
8197
|
+
text: `\u2026and ${String(omitted)} more breached budget${omitted === 1 ? "" : "s"} \u2014 the approval ticket carries the full list (approvals REST API / dashboard).`
|
|
8198
|
+
}
|
|
8199
|
+
]
|
|
8200
|
+
});
|
|
8201
|
+
}
|
|
8202
|
+
return blocks;
|
|
6993
8203
|
}
|
|
6994
8204
|
var SlackChannel = class {
|
|
6995
8205
|
type = "slack";
|
|
@@ -7488,10 +8698,821 @@ function createApprovalApp(router, queue, options) {
|
|
|
7488
8698
|
return app;
|
|
7489
8699
|
}
|
|
7490
8700
|
|
|
8701
|
+
// src/budget/engine.ts
|
|
8702
|
+
function kindOf(meta, budgetName) {
|
|
8703
|
+
return meta.kinds?.get(budgetName) ?? meta.kind;
|
|
8704
|
+
}
|
|
8705
|
+
function isBudgetPersistence(sink) {
|
|
8706
|
+
const candidate = sink;
|
|
8707
|
+
return typeof candidate.readMeta === "function" && typeof candidate.readAllMeta === "function" && typeof candidate.writeMeta === "function" && typeof candidate.writeMetaBatch === "function" && typeof candidate.maxEventEpoch === "function" && typeof candidate.replayDurationEvents === "function" && typeof candidate.replaySessionBuckets === "function" && typeof candidate.recordBucketGc === "function";
|
|
8708
|
+
}
|
|
8709
|
+
var NOOP_LEDGER = { commitAll: () => {
|
|
8710
|
+
} };
|
|
8711
|
+
var BudgetEngine = class {
|
|
8712
|
+
budgets = /* @__PURE__ */ new Map();
|
|
8713
|
+
/** budget name → bucket key → bucket. */
|
|
8714
|
+
state = /* @__PURE__ */ new Map();
|
|
8715
|
+
/** budget name → config generation; bumped whenever the pot resets. */
|
|
8716
|
+
generations = /* @__PURE__ */ new Map();
|
|
8717
|
+
now;
|
|
8718
|
+
ledger;
|
|
8719
|
+
/** The sink again, when it carries the full persistence contract. */
|
|
8720
|
+
persistence;
|
|
8721
|
+
onCommit;
|
|
8722
|
+
onBreach;
|
|
8723
|
+
timer = null;
|
|
8724
|
+
closed = false;
|
|
8725
|
+
hydrated = false;
|
|
8726
|
+
constructor(options = {}) {
|
|
8727
|
+
this.now = options.now ?? Date.now;
|
|
8728
|
+
this.ledger = options.ledger ?? NOOP_LEDGER;
|
|
8729
|
+
this.persistence = isBudgetPersistence(this.ledger) ? this.ledger : null;
|
|
8730
|
+
this.onCommit = options.onCommit;
|
|
8731
|
+
this.onBreach = options.onBreach;
|
|
8732
|
+
for (const budget of options.budgets ?? []) {
|
|
8733
|
+
this.budgets.set(budget.name, budget);
|
|
8734
|
+
this.generations.set(budget.name, 1);
|
|
8735
|
+
}
|
|
8736
|
+
const intervalMs = options.cleanupIntervalMs ?? 6e4;
|
|
8737
|
+
if (intervalMs > 0) {
|
|
8738
|
+
this.timer = setInterval(() => {
|
|
8739
|
+
this.gc();
|
|
8740
|
+
}, intervalMs);
|
|
8741
|
+
this.timer.unref();
|
|
8742
|
+
}
|
|
8743
|
+
}
|
|
8744
|
+
// -------------------------------------------------------------------------
|
|
8745
|
+
// Gate operations
|
|
8746
|
+
// -------------------------------------------------------------------------
|
|
8747
|
+
/**
|
|
8748
|
+
* Resolve which budgets a call feeds and how much it charges each.
|
|
8749
|
+
*
|
|
8750
|
+
* A budget participates when any contributor glob matches the tool name;
|
|
8751
|
+
* the FIRST matching contributor (config order) supplies the amount field.
|
|
8752
|
+
* A missing, non-numeric, negative, or non-finite amount fails closed as a
|
|
8753
|
+
* `failures` entry — the caller must deny the call.
|
|
8754
|
+
*/
|
|
8755
|
+
resolveCharges(ctx) {
|
|
8756
|
+
const charges = [];
|
|
8757
|
+
const failures = [];
|
|
8758
|
+
for (const budget of this.budgets.values()) {
|
|
8759
|
+
const contributor = budget.contributors.find((c) => c.tool.test(ctx.toolName));
|
|
8760
|
+
if (!contributor) continue;
|
|
8761
|
+
const raw = resolvePath(contributor.field, ctx.toolArguments ?? {});
|
|
8762
|
+
if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 0) {
|
|
8763
|
+
const bucketKey = this.bucketKey(budget, ctx);
|
|
8764
|
+
const nowMs = this.now();
|
|
8765
|
+
const bucket = this.liveBucket(budget, bucketKey, nowMs);
|
|
8766
|
+
const spent = bucket ? this.spentOf(budget, bucket, nowMs) : 0;
|
|
8767
|
+
failures.push({
|
|
8768
|
+
budget,
|
|
8769
|
+
bucketKey,
|
|
8770
|
+
reason: "invalid_amount",
|
|
8771
|
+
spent,
|
|
8772
|
+
remaining: Math.max(0, budget.limit - spent),
|
|
8773
|
+
// null is reserved for session pots on the wire; an empty duration
|
|
8774
|
+
// bucket resets one window from now.
|
|
8775
|
+
resetAtMs: budget.window.kind === "duration" ? bucket && bucket.entries.length > 0 ? (bucket.entries[0]?.timestampMs ?? nowMs) + budget.window.windowMs : nowMs + budget.window.windowMs : null
|
|
8776
|
+
});
|
|
8777
|
+
continue;
|
|
8778
|
+
}
|
|
8779
|
+
charges.push({
|
|
8780
|
+
budget,
|
|
8781
|
+
bucketKey: this.bucketKey(budget, ctx),
|
|
8782
|
+
amount: raw,
|
|
8783
|
+
generation: this.generations.get(budget.name) ?? 0
|
|
8784
|
+
});
|
|
8785
|
+
}
|
|
8786
|
+
return { charges, failures };
|
|
8787
|
+
}
|
|
8788
|
+
/** Check every charge without mutating. All-or-nothing: one deny flips `allowed`. */
|
|
8789
|
+
peekAll(charges) {
|
|
8790
|
+
const entries = charges.map((charge) => this.snapshot(charge));
|
|
8791
|
+
return { allowed: entries.every((entry) => entry.allowed), entries };
|
|
8792
|
+
}
|
|
8793
|
+
/**
|
|
8794
|
+
* Commit every charge of one call: ledger first (one atomic batch), then
|
|
8795
|
+
* in-memory state, then the commit events. A sink throw propagates and
|
|
8796
|
+
* leaves ALL in-memory buckets untouched — no partial commit, ever.
|
|
8797
|
+
* Recording is unconditional past the sink (an approved overage
|
|
8798
|
+
* legitimately pushes a bucket past its limit).
|
|
8799
|
+
*/
|
|
8800
|
+
recordAll(charges, meta) {
|
|
8801
|
+
const nowMs = this.now();
|
|
8802
|
+
const current = [];
|
|
8803
|
+
const stale = [];
|
|
8804
|
+
for (const charge of charges) {
|
|
8805
|
+
if (this.generations.get(charge.budget.name) === charge.generation) {
|
|
8806
|
+
current.push(charge);
|
|
8807
|
+
} else {
|
|
8808
|
+
stale.push(charge);
|
|
8809
|
+
console.error(
|
|
8810
|
+
`[helio] Budget "${charge.budget.name}": an in-flight charge outlived a config change; its amount is ledgered under the old generation but does not count against the current pot`
|
|
8811
|
+
);
|
|
8812
|
+
}
|
|
8813
|
+
}
|
|
8814
|
+
this.ledger.commitAll(
|
|
8815
|
+
charges.map((charge) => ({
|
|
8816
|
+
budget_name: charge.budget.name,
|
|
8817
|
+
bucket_key: charge.bucketKey,
|
|
8818
|
+
kind: kindOf(meta, charge.budget.name),
|
|
8819
|
+
amount: charge.amount,
|
|
8820
|
+
currency: charge.budget.currency,
|
|
8821
|
+
tool_name: meta.toolName,
|
|
8822
|
+
origin: meta.origin,
|
|
8823
|
+
audit_record_id: meta.auditRecordId,
|
|
8824
|
+
timestamp: meta.timestampIso,
|
|
8825
|
+
timestamp_ms: nowMs,
|
|
8826
|
+
generation: charge.generation
|
|
8827
|
+
}))
|
|
8828
|
+
);
|
|
8829
|
+
const snapshots = [];
|
|
8830
|
+
for (const charge of current) {
|
|
8831
|
+
const bucket = this.bucketFor(charge.budget.name, charge.bucketKey);
|
|
8832
|
+
if (charge.budget.window.kind === "duration") {
|
|
8833
|
+
this.evictExpired(bucket, charge.budget.window.windowMs, nowMs);
|
|
8834
|
+
bucket.entries.push({ timestampMs: nowMs, amount: charge.amount });
|
|
8835
|
+
} else {
|
|
8836
|
+
bucket.total += charge.amount;
|
|
8837
|
+
}
|
|
8838
|
+
bucket.lastActivityMs = nowMs;
|
|
8839
|
+
snapshots.push(this.snapshot(charge, { postRecord: true }));
|
|
8840
|
+
}
|
|
8841
|
+
for (const charge of stale) {
|
|
8842
|
+
const liveBudget = this.budgets.get(charge.budget.name) ?? charge.budget;
|
|
8843
|
+
snapshots.push({ ...this.snapshot({ ...charge, budget: liveBudget }), stale: true });
|
|
8844
|
+
}
|
|
8845
|
+
for (let i = 0; i < current.length; i++) {
|
|
8846
|
+
const charge = current[i];
|
|
8847
|
+
const snapshot = snapshots[i];
|
|
8848
|
+
if (!charge || !snapshot || !this.onCommit) continue;
|
|
8849
|
+
try {
|
|
8850
|
+
this.onCommit({
|
|
8851
|
+
name: charge.budget.name,
|
|
8852
|
+
bucket_key: charge.bucketKey,
|
|
8853
|
+
kind: kindOf(meta, charge.budget.name),
|
|
8854
|
+
amount: charge.amount,
|
|
8855
|
+
spent: snapshot.spent,
|
|
8856
|
+
remaining: snapshot.remaining,
|
|
8857
|
+
limit: charge.budget.limit,
|
|
8858
|
+
currency: charge.budget.currency,
|
|
8859
|
+
utilization: snapshot.spent / charge.budget.limit
|
|
8860
|
+
});
|
|
8861
|
+
} catch (err) {
|
|
8862
|
+
console.error("[helio] budget onCommit subscriber threw:", err);
|
|
8863
|
+
}
|
|
8864
|
+
}
|
|
8865
|
+
return snapshots;
|
|
8866
|
+
}
|
|
8867
|
+
/**
|
|
8868
|
+
* Fire one `onBreach` event per breached entry. Called by the doors at the
|
|
8869
|
+
* moment a peek outcome actually denies the call or raises the composite
|
|
8870
|
+
* break-glass ticket (never for dry-run peeks, never for invalid-amount
|
|
8871
|
+
* failures — those are input errors, not breaches). Subscriber throws are
|
|
8872
|
+
* isolated: a dashboard bug must never affect a gate outcome.
|
|
8873
|
+
*/
|
|
8874
|
+
reportBreaches(entries) {
|
|
8875
|
+
if (!this.onBreach) return;
|
|
8876
|
+
for (const entry of entries) {
|
|
8877
|
+
try {
|
|
8878
|
+
this.onBreach({
|
|
8879
|
+
name: entry.budget.name,
|
|
8880
|
+
bucket_key: entry.bucketKey,
|
|
8881
|
+
on_exceed: entry.budget.onExceed,
|
|
8882
|
+
attempted_amount: entry.amount,
|
|
8883
|
+
spent: entry.spent,
|
|
8884
|
+
limit: entry.budget.limit,
|
|
8885
|
+
currency: entry.budget.currency
|
|
8886
|
+
});
|
|
8887
|
+
} catch (err) {
|
|
8888
|
+
console.error("[helio] budget onBreach subscriber threw:", err);
|
|
8889
|
+
}
|
|
8890
|
+
}
|
|
8891
|
+
}
|
|
8892
|
+
// -------------------------------------------------------------------------
|
|
8893
|
+
// Lifecycle
|
|
8894
|
+
// -------------------------------------------------------------------------
|
|
8895
|
+
/**
|
|
8896
|
+
* Rebuild in-memory state from the ledger. Call once at startup, after
|
|
8897
|
+
* construction and before serving traffic; a no-op when the configured
|
|
8898
|
+
* sink does not carry the persistence contract (in-memory mode).
|
|
8899
|
+
*
|
|
8900
|
+
* Per configured budget, `budget_meta` decides:
|
|
8901
|
+
* - no row → first boot for this name: mint epoch 1, nothing to replay;
|
|
8902
|
+
* - a different `{limit, currency, window, key}` tuple → the config
|
|
8903
|
+
* changed while down: bump the epoch, replay nothing (the same reset a
|
|
8904
|
+
* live tuple-changing reload performs, extended across restarts). Old
|
|
8905
|
+
* rows keep their epoch — history stays queryable, replay ignores it;
|
|
8906
|
+
* - a matching tuple → replay at the meta epoch: duration windows rebuild
|
|
8907
|
+
* entry lists from a window lookback (bit-equivalent to never having
|
|
8908
|
+
* restarted), session windows rebuild still-live pots (idle-TTL bound)
|
|
8909
|
+
* from their post-GC-watermark lifetime sums.
|
|
8910
|
+
*
|
|
8911
|
+
* Meta writes here propagate failures: a ledger that cannot record epochs
|
|
8912
|
+
* at startup must fail the boot loudly, the same posture as the audit
|
|
8913
|
+
* store's schema assertion.
|
|
8914
|
+
*/
|
|
8915
|
+
hydrate() {
|
|
8916
|
+
if (!this.persistence) return;
|
|
8917
|
+
if (this.hydrated) return;
|
|
8918
|
+
this.hydrated = true;
|
|
8919
|
+
const nowMs = this.now();
|
|
8920
|
+
const metaByName = new Map(
|
|
8921
|
+
this.persistence.readAllMeta().map((meta) => [meta.budget_name, meta])
|
|
8922
|
+
);
|
|
8923
|
+
for (const budget of this.budgets.values()) {
|
|
8924
|
+
const meta = metaByName.get(budget.name);
|
|
8925
|
+
if (!meta) {
|
|
8926
|
+
const epoch = this.persistence.maxEventEpoch(budget.name) + 1;
|
|
8927
|
+
this.persistence.writeMeta(metaOf(budget, epoch));
|
|
8928
|
+
this.generations.set(budget.name, epoch);
|
|
8929
|
+
continue;
|
|
8930
|
+
}
|
|
8931
|
+
if (metaTupleChanged(meta, budget)) {
|
|
8932
|
+
const epoch = Math.max(meta.epoch, this.persistence.maxEventEpoch(budget.name)) + 1;
|
|
8933
|
+
this.persistence.writeMeta(metaOf(budget, epoch));
|
|
8934
|
+
this.generations.set(budget.name, epoch);
|
|
8935
|
+
continue;
|
|
8936
|
+
}
|
|
8937
|
+
this.generations.set(budget.name, meta.epoch);
|
|
8938
|
+
if (budget.window.kind === "duration") {
|
|
8939
|
+
const events = this.persistence.replayDurationEvents(
|
|
8940
|
+
budget.name,
|
|
8941
|
+
meta.epoch,
|
|
8942
|
+
nowMs - budget.window.windowMs
|
|
8943
|
+
);
|
|
8944
|
+
for (const event of events) {
|
|
8945
|
+
const bucket = this.bucketFor(budget.name, event.bucket_key);
|
|
8946
|
+
bucket.entries.push({ timestampMs: event.timestamp_ms, amount: event.amount });
|
|
8947
|
+
bucket.lastActivityMs = event.timestamp_ms;
|
|
8948
|
+
}
|
|
8949
|
+
} else {
|
|
8950
|
+
const liveAfterMs = nowMs - budget.window.idleTtlMs;
|
|
8951
|
+
for (const row of this.persistence.replaySessionBuckets(budget.name, meta.epoch)) {
|
|
8952
|
+
if (row.last_activity_ms >= liveAfterMs) {
|
|
8953
|
+
const bucket = this.bucketFor(budget.name, row.bucket_key);
|
|
8954
|
+
bucket.total = row.total;
|
|
8955
|
+
bucket.lastActivityMs = row.last_activity_ms;
|
|
8956
|
+
} else if (row.total > 0) {
|
|
8957
|
+
this.persistence.recordBucketGc(budget.name, row.bucket_key, nowMs);
|
|
8958
|
+
}
|
|
8959
|
+
}
|
|
8960
|
+
}
|
|
8961
|
+
}
|
|
8962
|
+
for (const [name, meta] of metaByName) {
|
|
8963
|
+
if (this.budgets.has(name)) continue;
|
|
8964
|
+
const maxEventEpoch = this.persistence.maxEventEpoch(name);
|
|
8965
|
+
if (maxEventEpoch < meta.epoch) continue;
|
|
8966
|
+
this.persistence.writeMeta({ ...meta, epoch: Math.max(meta.epoch, maxEventEpoch) + 1 });
|
|
8967
|
+
}
|
|
8968
|
+
}
|
|
8969
|
+
/**
|
|
8970
|
+
* Swap budget configs on hot-reload. Identity is the NAME: removed names
|
|
8971
|
+
* drop their live buckets; a changed `{limit, currency, window, key}` tuple
|
|
8972
|
+
* resets the budget's buckets (a different pool or scope structure);
|
|
8973
|
+
* everything else — contributors, on_exceed — applies to the accrued state
|
|
8974
|
+
* as-is, because those edits do not change what was already spent.
|
|
8975
|
+
*
|
|
8976
|
+
* Persist-before-swap: every epoch this reload mints lands in
|
|
8977
|
+
* `budget_meta` in ONE transaction BEFORE any memory changes. A throw
|
|
8978
|
+
* rejects the whole reload — the caller keeps the previous config — so
|
|
8979
|
+
* disk and memory can never diverge; a failed reload simply never
|
|
8980
|
+
* happened, and no later restart can misread it. (A swallow-and-continue
|
|
8981
|
+
* posture here would let an A→B reload with a failed flush resurrect the
|
|
8982
|
+
* retired A pot after a revert-and-restart.)
|
|
8983
|
+
*
|
|
8984
|
+
* Removed names mint too: an in-flight charge frozen before the removal
|
|
8985
|
+
* must go stale, or its commit would recreate hidden bucket state for a
|
|
8986
|
+
* budget that no longer exists — and without the on-disk tombstone, a
|
|
8987
|
+
* restart with the budget back in the config would resurrect the
|
|
8988
|
+
* pre-removal pot that the removal had reset. Generations for removed
|
|
8989
|
+
* names are kept (not deleted) so a later re-add keeps counting up.
|
|
8990
|
+
*
|
|
8991
|
+
* @throws When the epoch flush fails; the engine is unchanged.
|
|
8992
|
+
*/
|
|
8993
|
+
reconcile(next) {
|
|
8994
|
+
const nextByName = new Map(next.map((budget) => [budget.name, budget]));
|
|
8995
|
+
const mints = [];
|
|
8996
|
+
for (const [name, budget] of nextByName) {
|
|
8997
|
+
const current = this.budgets.get(name);
|
|
8998
|
+
if (!current || tupleChanged(current, budget)) {
|
|
8999
|
+
mints.push({ name, tuple: budget, epoch: this.nextEpoch(name) });
|
|
9000
|
+
}
|
|
9001
|
+
}
|
|
9002
|
+
for (const [name, removed] of this.budgets) {
|
|
9003
|
+
if (nextByName.has(name)) continue;
|
|
9004
|
+
mints.push({ name, tuple: removed, epoch: this.nextEpoch(name) });
|
|
9005
|
+
}
|
|
9006
|
+
if (this.persistence && mints.length > 0) {
|
|
9007
|
+
this.persistence.writeMetaBatch(mints.map((mint) => metaOf(mint.tuple, mint.epoch)));
|
|
9008
|
+
}
|
|
9009
|
+
for (const mint of mints) {
|
|
9010
|
+
this.state.delete(mint.name);
|
|
9011
|
+
this.generations.set(mint.name, mint.epoch);
|
|
9012
|
+
}
|
|
9013
|
+
this.budgets = nextByName;
|
|
9014
|
+
}
|
|
9015
|
+
/** Sweep: collect idle session pots, evict expired duration entries. */
|
|
9016
|
+
gc() {
|
|
9017
|
+
const nowMs = this.now();
|
|
9018
|
+
for (const [name, buckets] of this.state) {
|
|
9019
|
+
const budget = this.budgets.get(name);
|
|
9020
|
+
if (!budget) {
|
|
9021
|
+
this.state.delete(name);
|
|
9022
|
+
continue;
|
|
9023
|
+
}
|
|
9024
|
+
for (const [key, bucket] of buckets) {
|
|
9025
|
+
if (budget.window.kind === "session") {
|
|
9026
|
+
if (nowMs - bucket.lastActivityMs > budget.window.idleTtlMs) {
|
|
9027
|
+
if (this.persistence) {
|
|
9028
|
+
try {
|
|
9029
|
+
this.persistence.recordBucketGc(name, key, nowMs);
|
|
9030
|
+
} catch (err) {
|
|
9031
|
+
console.error(
|
|
9032
|
+
`[helio] Budget "${name}": failed to record the GC watermark for "${key}"; keeping the idle pot until the next sweep:`,
|
|
9033
|
+
err
|
|
9034
|
+
);
|
|
9035
|
+
continue;
|
|
9036
|
+
}
|
|
9037
|
+
}
|
|
9038
|
+
buckets.delete(key);
|
|
9039
|
+
}
|
|
9040
|
+
} else {
|
|
9041
|
+
this.evictExpired(bucket, budget.window.windowMs, nowMs);
|
|
9042
|
+
if (bucket.entries.length === 0) buckets.delete(key);
|
|
9043
|
+
}
|
|
9044
|
+
}
|
|
9045
|
+
if (buckets.size === 0) this.state.delete(name);
|
|
9046
|
+
}
|
|
9047
|
+
}
|
|
9048
|
+
// -------------------------------------------------------------------------
|
|
9049
|
+
// Read surface
|
|
9050
|
+
// -------------------------------------------------------------------------
|
|
9051
|
+
/**
|
|
9052
|
+
* Wire-ready state for `GET /api/budgets`. Configured budgets appear even
|
|
9053
|
+
* with zero live buckets, so the dashboard shows every pot at headroom.
|
|
9054
|
+
*/
|
|
9055
|
+
listStates() {
|
|
9056
|
+
const nowMs = this.now();
|
|
9057
|
+
return [...this.budgets.values()].map((budget) => {
|
|
9058
|
+
const buckets = [];
|
|
9059
|
+
for (const key of [...this.state.get(budget.name)?.keys() ?? []]) {
|
|
9060
|
+
const bucket = this.liveBucket(budget, key, nowMs);
|
|
9061
|
+
if (!bucket) continue;
|
|
9062
|
+
const spent = this.spentOf(budget, bucket, nowMs);
|
|
9063
|
+
buckets.push({
|
|
9064
|
+
bucket_key: key,
|
|
9065
|
+
spent,
|
|
9066
|
+
remaining: Math.max(0, budget.limit - spent),
|
|
9067
|
+
reset_at_ms: budget.window.kind === "duration" ? (bucket.entries[0]?.timestampMs ?? nowMs) + budget.window.windowMs : null,
|
|
9068
|
+
last_activity_ms: bucket.lastActivityMs
|
|
9069
|
+
});
|
|
9070
|
+
}
|
|
9071
|
+
return {
|
|
9072
|
+
name: budget.name,
|
|
9073
|
+
limit: budget.limit,
|
|
9074
|
+
currency: budget.currency,
|
|
9075
|
+
window: budget.windowRaw,
|
|
9076
|
+
key: budget.key,
|
|
9077
|
+
on_exceed: budget.onExceed,
|
|
9078
|
+
buckets
|
|
9079
|
+
};
|
|
9080
|
+
});
|
|
9081
|
+
}
|
|
9082
|
+
/** Whether any budget holds a live bucket under `key` (cardinality probes). */
|
|
9083
|
+
hasBucket(key) {
|
|
9084
|
+
const nowMs = this.now();
|
|
9085
|
+
for (const name of [...this.state.keys()]) {
|
|
9086
|
+
const budget = this.budgets.get(name);
|
|
9087
|
+
if (!budget) continue;
|
|
9088
|
+
if (this.liveBucket(budget, key, nowMs)) return true;
|
|
9089
|
+
}
|
|
9090
|
+
return false;
|
|
9091
|
+
}
|
|
9092
|
+
close() {
|
|
9093
|
+
if (this.closed) return;
|
|
9094
|
+
this.closed = true;
|
|
9095
|
+
if (this.timer) {
|
|
9096
|
+
clearInterval(this.timer);
|
|
9097
|
+
this.timer = null;
|
|
9098
|
+
}
|
|
9099
|
+
this.state.clear();
|
|
9100
|
+
}
|
|
9101
|
+
// -------------------------------------------------------------------------
|
|
9102
|
+
// Internals
|
|
9103
|
+
// -------------------------------------------------------------------------
|
|
9104
|
+
/**
|
|
9105
|
+
* The next epoch for a name: one past the highest that memory, the meta
|
|
9106
|
+
* row, or the rows themselves have seen. Pure — the caller applies it to
|
|
9107
|
+
* `generations` only after the mint is durable. The meta consult matters
|
|
9108
|
+
* for names this process has no memory of (a hot-reload re-add after a
|
|
9109
|
+
* restart); the rows consult is a backstop against historical divergence
|
|
9110
|
+
* (rows at an epoch no meta row records) — minting from memory or meta
|
|
9111
|
+
* alone could collide into an epoch that already has rows and replay them
|
|
9112
|
+
* into a different pot.
|
|
9113
|
+
*/
|
|
9114
|
+
nextEpoch(name) {
|
|
9115
|
+
const memory = this.generations.get(name) ?? 0;
|
|
9116
|
+
const disk = this.persistence ? Math.max(this.persistence.readMeta(name)?.epoch ?? 0, this.persistence.maxEventEpoch(name)) : 0;
|
|
9117
|
+
return Math.max(memory, disk) + 1;
|
|
9118
|
+
}
|
|
9119
|
+
/**
|
|
9120
|
+
* The key format is part of the ON-DISK contract: hydrate rebuilds buckets
|
|
9121
|
+
* from `budget_events.bucket_key` verbatim, so renaming any segment here
|
|
9122
|
+
* would strand every persisted bucket of an unchanged tuple as an
|
|
9123
|
+
* unreachable ghost (displayed, never charged). Changing the format
|
|
9124
|
+
* requires folding a format version into the epoch decision.
|
|
9125
|
+
*/
|
|
9126
|
+
bucketKey(budget, ctx) {
|
|
9127
|
+
switch (budget.key) {
|
|
9128
|
+
case "session":
|
|
9129
|
+
return `budget:${budget.name}:session:${ctx.sessionId ?? "unknown"}`;
|
|
9130
|
+
case "sender_id":
|
|
9131
|
+
return `budget:${budget.name}:sender:${ctx.senderId ?? "unknown"}`;
|
|
9132
|
+
case "global":
|
|
9133
|
+
return `budget:${budget.name}:global`;
|
|
9134
|
+
}
|
|
9135
|
+
}
|
|
9136
|
+
bucketFor(name, key) {
|
|
9137
|
+
let buckets = this.state.get(name);
|
|
9138
|
+
if (!buckets) {
|
|
9139
|
+
buckets = /* @__PURE__ */ new Map();
|
|
9140
|
+
this.state.set(name, buckets);
|
|
9141
|
+
}
|
|
9142
|
+
let bucket = buckets.get(key);
|
|
9143
|
+
if (!bucket) {
|
|
9144
|
+
bucket = { entries: [], total: 0, lastActivityMs: this.now() };
|
|
9145
|
+
buckets.set(key, bucket);
|
|
9146
|
+
}
|
|
9147
|
+
return bucket;
|
|
9148
|
+
}
|
|
9149
|
+
evictExpired(bucket, windowMs, nowMs) {
|
|
9150
|
+
const windowStart = nowMs - windowMs;
|
|
9151
|
+
bucket.entries = bucket.entries.filter((entry) => entry.timestampMs > windowStart);
|
|
9152
|
+
}
|
|
9153
|
+
/**
|
|
9154
|
+
* Fetch a bucket for reading, evicting expired duration entries first and
|
|
9155
|
+
* pruning the bucket if nothing is left. Reads must never see (or keep
|
|
9156
|
+
* alive, via `hasBucket`-driven capacity slots) state the window has
|
|
9157
|
+
* already expired — expiry is lazy on read, not just on the sweep timer.
|
|
9158
|
+
*/
|
|
9159
|
+
liveBucket(budget, key, nowMs) {
|
|
9160
|
+
const buckets = this.state.get(budget.name);
|
|
9161
|
+
const bucket = buckets?.get(key);
|
|
9162
|
+
if (!bucket || !buckets) return void 0;
|
|
9163
|
+
if (budget.window.kind === "duration") {
|
|
9164
|
+
this.evictExpired(bucket, budget.window.windowMs, nowMs);
|
|
9165
|
+
if (bucket.entries.length === 0) {
|
|
9166
|
+
buckets.delete(key);
|
|
9167
|
+
if (buckets.size === 0) this.state.delete(budget.name);
|
|
9168
|
+
return void 0;
|
|
9169
|
+
}
|
|
9170
|
+
}
|
|
9171
|
+
return bucket;
|
|
9172
|
+
}
|
|
9173
|
+
spentOf(budget, bucket, nowMs) {
|
|
9174
|
+
if (budget.window.kind === "session") return bucket.total;
|
|
9175
|
+
const windowStart = nowMs - budget.window.windowMs;
|
|
9176
|
+
return bucket.entries.reduce(
|
|
9177
|
+
(sum, entry) => entry.timestampMs > windowStart ? sum + entry.amount : sum,
|
|
9178
|
+
0
|
|
9179
|
+
);
|
|
9180
|
+
}
|
|
9181
|
+
snapshot(charge, options = {}) {
|
|
9182
|
+
const nowMs = this.now();
|
|
9183
|
+
const bucket = this.liveBucket(charge.budget, charge.bucketKey, nowMs);
|
|
9184
|
+
const accrued = bucket ? this.spentOf(charge.budget, bucket, nowMs) : 0;
|
|
9185
|
+
const spent = accrued;
|
|
9186
|
+
const checkedAgainst = options.postRecord ? accrued - charge.amount : accrued;
|
|
9187
|
+
const resetAtMs = charge.budget.window.kind === "duration" ? bucket && bucket.entries.length > 0 ? (bucket.entries[0]?.timestampMs ?? nowMs) + charge.budget.window.windowMs : nowMs + charge.budget.window.windowMs : null;
|
|
9188
|
+
return {
|
|
9189
|
+
budget: charge.budget,
|
|
9190
|
+
bucketKey: charge.bucketKey,
|
|
9191
|
+
amount: charge.amount,
|
|
9192
|
+
allowed: checkedAgainst + charge.amount <= charge.budget.limit,
|
|
9193
|
+
spent,
|
|
9194
|
+
remaining: Math.max(0, charge.budget.limit - spent),
|
|
9195
|
+
resetAtMs
|
|
9196
|
+
};
|
|
9197
|
+
}
|
|
9198
|
+
};
|
|
9199
|
+
function metaOf(budget, epoch) {
|
|
9200
|
+
return {
|
|
9201
|
+
budget_name: budget.name,
|
|
9202
|
+
limit_amount: budget.limit,
|
|
9203
|
+
currency: budget.currency,
|
|
9204
|
+
window: budget.windowRaw,
|
|
9205
|
+
key: budget.key,
|
|
9206
|
+
epoch
|
|
9207
|
+
};
|
|
9208
|
+
}
|
|
9209
|
+
function metaTupleChanged(meta, budget) {
|
|
9210
|
+
return meta.limit_amount !== budget.limit || meta.currency !== budget.currency || meta.window !== budget.windowRaw || meta.key !== budget.key;
|
|
9211
|
+
}
|
|
9212
|
+
function tupleChanged(a, b) {
|
|
9213
|
+
return a.limit !== b.limit || a.currency !== b.currency || a.windowRaw !== b.windowRaw || a.key !== b.key;
|
|
9214
|
+
}
|
|
9215
|
+
|
|
9216
|
+
// src/budget/ledger.ts
|
|
9217
|
+
import Database2 from "better-sqlite3";
|
|
9218
|
+
import { randomUUID as randomUUID7 } from "crypto";
|
|
9219
|
+
var CREATE_TABLES_DDL = `
|
|
9220
|
+
CREATE TABLE IF NOT EXISTS budget_meta (
|
|
9221
|
+
budget_name TEXT PRIMARY KEY,
|
|
9222
|
+
limit_amount REAL NOT NULL,
|
|
9223
|
+
currency TEXT NOT NULL,
|
|
9224
|
+
window TEXT NOT NULL,
|
|
9225
|
+
key TEXT NOT NULL,
|
|
9226
|
+
epoch INTEGER NOT NULL,
|
|
9227
|
+
updated_at TEXT NOT NULL
|
|
9228
|
+
);
|
|
9229
|
+
|
|
9230
|
+
CREATE TABLE IF NOT EXISTS budget_events (
|
|
9231
|
+
id TEXT PRIMARY KEY,
|
|
9232
|
+
budget_name TEXT NOT NULL,
|
|
9233
|
+
epoch INTEGER NOT NULL,
|
|
9234
|
+
bucket_key TEXT NOT NULL,
|
|
9235
|
+
kind TEXT NOT NULL,
|
|
9236
|
+
amount REAL NOT NULL,
|
|
9237
|
+
currency TEXT NOT NULL,
|
|
9238
|
+
tool_name TEXT NOT NULL,
|
|
9239
|
+
origin TEXT NOT NULL,
|
|
9240
|
+
audit_record_id TEXT,
|
|
9241
|
+
timestamp TEXT NOT NULL,
|
|
9242
|
+
timestamp_ms INTEGER NOT NULL,
|
|
9243
|
+
created_at TEXT NOT NULL
|
|
9244
|
+
);
|
|
9245
|
+
|
|
9246
|
+
CREATE TABLE IF NOT EXISTS budget_bucket_gc (
|
|
9247
|
+
budget_name TEXT NOT NULL,
|
|
9248
|
+
bucket_key TEXT NOT NULL,
|
|
9249
|
+
gc_after_ms INTEGER NOT NULL,
|
|
9250
|
+
PRIMARY KEY (budget_name, bucket_key)
|
|
9251
|
+
);
|
|
9252
|
+
`;
|
|
9253
|
+
var CREATE_INDEX_DDL2 = `
|
|
9254
|
+
CREATE INDEX IF NOT EXISTS idx_budget_events_replay
|
|
9255
|
+
ON budget_events (budget_name, epoch, bucket_key, timestamp_ms);
|
|
9256
|
+
CREATE INDEX IF NOT EXISTS idx_budget_events_timestamp_ms
|
|
9257
|
+
ON budget_events (timestamp_ms);
|
|
9258
|
+
`;
|
|
9259
|
+
var BUDGET_TABLES = ["budget_meta", "budget_events", "budget_bucket_gc"];
|
|
9260
|
+
var INSERT_EVENT_SQL = `
|
|
9261
|
+
INSERT INTO budget_events (
|
|
9262
|
+
id, budget_name, epoch, bucket_key, kind, amount, currency,
|
|
9263
|
+
tool_name, origin, audit_record_id, timestamp, timestamp_ms, created_at
|
|
9264
|
+
) VALUES (
|
|
9265
|
+
@id, @budget_name, @epoch, @bucket_key, @kind, @amount, @currency,
|
|
9266
|
+
@tool_name, @origin, @audit_record_id, @timestamp, @timestamp_ms, @created_at
|
|
9267
|
+
)
|
|
9268
|
+
`;
|
|
9269
|
+
var UPSERT_META_SQL = `
|
|
9270
|
+
INSERT INTO budget_meta (budget_name, limit_amount, currency, window, key, epoch, updated_at)
|
|
9271
|
+
VALUES (@budget_name, @limit_amount, @currency, @window, @key, @epoch, @updated_at)
|
|
9272
|
+
ON CONFLICT (budget_name) DO UPDATE SET
|
|
9273
|
+
limit_amount = excluded.limit_amount,
|
|
9274
|
+
currency = excluded.currency,
|
|
9275
|
+
window = excluded.window,
|
|
9276
|
+
key = excluded.key,
|
|
9277
|
+
epoch = excluded.epoch,
|
|
9278
|
+
updated_at = excluded.updated_at
|
|
9279
|
+
`;
|
|
9280
|
+
var UPSERT_GC_SQL = `
|
|
9281
|
+
INSERT INTO budget_bucket_gc (budget_name, bucket_key, gc_after_ms)
|
|
9282
|
+
VALUES (@budget_name, @bucket_key, @gc_after_ms)
|
|
9283
|
+
ON CONFLICT (budget_name, bucket_key) DO UPDATE SET
|
|
9284
|
+
gc_after_ms = excluded.gc_after_ms
|
|
9285
|
+
`;
|
|
9286
|
+
var REPLAY_DURATION_SQL = `
|
|
9287
|
+
SELECT bucket_key, amount, timestamp_ms
|
|
9288
|
+
FROM budget_events
|
|
9289
|
+
WHERE budget_name = ? AND epoch = ? AND timestamp_ms > ?
|
|
9290
|
+
ORDER BY timestamp_ms ASC, rowid ASC
|
|
9291
|
+
`;
|
|
9292
|
+
var REPLAY_SESSION_SQL = `
|
|
9293
|
+
SELECT e.bucket_key AS bucket_key,
|
|
9294
|
+
SUM(CASE WHEN e.timestamp_ms >= COALESCE(g.gc_after_ms, 0) THEN e.amount ELSE 0 END) AS total,
|
|
9295
|
+
MAX(e.timestamp_ms) AS last_activity_ms
|
|
9296
|
+
FROM budget_events e
|
|
9297
|
+
LEFT JOIN budget_bucket_gc g
|
|
9298
|
+
ON g.budget_name = e.budget_name AND g.bucket_key = e.bucket_key
|
|
9299
|
+
WHERE e.budget_name = ? AND e.epoch = ?
|
|
9300
|
+
GROUP BY e.bucket_key
|
|
9301
|
+
ORDER BY e.bucket_key ASC
|
|
9302
|
+
`;
|
|
9303
|
+
var LIST_EVENTS_SQL = `
|
|
9304
|
+
SELECT id, budget_name, bucket_key, kind, amount, currency, tool_name,
|
|
9305
|
+
origin, audit_record_id, timestamp, timestamp_ms, created_at
|
|
9306
|
+
FROM budget_events
|
|
9307
|
+
WHERE budget_name = ?
|
|
9308
|
+
ORDER BY timestamp_ms DESC, rowid DESC
|
|
9309
|
+
LIMIT ? OFFSET ?
|
|
9310
|
+
`;
|
|
9311
|
+
var COUNT_EVENTS_SQL = "SELECT COUNT(*) AS total FROM budget_events WHERE budget_name = ?";
|
|
9312
|
+
var LIST_EVENTS_DEFAULT_LIMIT = 50;
|
|
9313
|
+
function describeColumn(column) {
|
|
9314
|
+
return `"${column.type}${column.notnull ? " NOT NULL" : ""}${column.pk ? " PRIMARY KEY" : ""}"`;
|
|
9315
|
+
}
|
|
9316
|
+
var BudgetLedger = class {
|
|
9317
|
+
db;
|
|
9318
|
+
now;
|
|
9319
|
+
insertEventStmt;
|
|
9320
|
+
upsertMetaStmt;
|
|
9321
|
+
upsertGcStmt;
|
|
9322
|
+
readMetaStmt;
|
|
9323
|
+
readAllMetaStmt;
|
|
9324
|
+
maxEventEpochStmt;
|
|
9325
|
+
replayDurationStmt;
|
|
9326
|
+
replaySessionStmt;
|
|
9327
|
+
listEventsStmt;
|
|
9328
|
+
countEventsStmt;
|
|
9329
|
+
commitTxn;
|
|
9330
|
+
writeMetaTxn;
|
|
9331
|
+
purgeTxn;
|
|
9332
|
+
constructor(options) {
|
|
9333
|
+
this.db = options.database;
|
|
9334
|
+
this.now = options.now ?? Date.now;
|
|
9335
|
+
this.db.exec(CREATE_TABLES_DDL);
|
|
9336
|
+
this.assertRequiredSchema();
|
|
9337
|
+
this.db.exec(CREATE_INDEX_DDL2);
|
|
9338
|
+
this.insertEventStmt = this.db.prepare(INSERT_EVENT_SQL);
|
|
9339
|
+
this.upsertMetaStmt = this.db.prepare(UPSERT_META_SQL);
|
|
9340
|
+
this.upsertGcStmt = this.db.prepare(UPSERT_GC_SQL);
|
|
9341
|
+
this.readMetaStmt = this.db.prepare(
|
|
9342
|
+
"SELECT budget_name, limit_amount, currency, window, key, epoch FROM budget_meta WHERE budget_name = ?"
|
|
9343
|
+
);
|
|
9344
|
+
this.readAllMetaStmt = this.db.prepare(
|
|
9345
|
+
"SELECT budget_name, limit_amount, currency, window, key, epoch FROM budget_meta"
|
|
9346
|
+
);
|
|
9347
|
+
this.maxEventEpochStmt = this.db.prepare(
|
|
9348
|
+
"SELECT COALESCE(MAX(epoch), 0) AS epoch FROM budget_events WHERE budget_name = ?"
|
|
9349
|
+
);
|
|
9350
|
+
this.replayDurationStmt = this.db.prepare(REPLAY_DURATION_SQL);
|
|
9351
|
+
this.replaySessionStmt = this.db.prepare(REPLAY_SESSION_SQL);
|
|
9352
|
+
this.listEventsStmt = this.db.prepare(LIST_EVENTS_SQL);
|
|
9353
|
+
this.countEventsStmt = this.db.prepare(COUNT_EVENTS_SQL);
|
|
9354
|
+
const purgeEventsStmt = this.db.prepare("DELETE FROM budget_events WHERE timestamp_ms < ?");
|
|
9355
|
+
const purgeGcStmt = this.db.prepare("DELETE FROM budget_bucket_gc WHERE gc_after_ms < ?");
|
|
9356
|
+
this.purgeTxn = this.db.transaction((cutoffMs) => ({
|
|
9357
|
+
events: purgeEventsStmt.run(cutoffMs).changes,
|
|
9358
|
+
watermarks: purgeGcStmt.run(cutoffMs).changes
|
|
9359
|
+
}));
|
|
9360
|
+
this.writeMetaTxn = this.db.transaction((metas) => {
|
|
9361
|
+
for (const meta of metas) this.writeMeta(meta);
|
|
9362
|
+
});
|
|
9363
|
+
this.commitTxn = this.db.transaction((rows) => {
|
|
9364
|
+
const createdAt = new Date(this.now()).toISOString();
|
|
9365
|
+
for (const row of rows) {
|
|
9366
|
+
this.insertEventStmt.run({
|
|
9367
|
+
id: randomUUID7(),
|
|
9368
|
+
budget_name: row.budget_name,
|
|
9369
|
+
epoch: row.generation,
|
|
9370
|
+
bucket_key: row.bucket_key,
|
|
9371
|
+
kind: row.kind,
|
|
9372
|
+
amount: row.amount,
|
|
9373
|
+
currency: row.currency,
|
|
9374
|
+
tool_name: row.tool_name,
|
|
9375
|
+
origin: row.origin,
|
|
9376
|
+
audit_record_id: row.audit_record_id,
|
|
9377
|
+
timestamp: row.timestamp,
|
|
9378
|
+
timestamp_ms: row.timestamp_ms,
|
|
9379
|
+
created_at: createdAt
|
|
9380
|
+
});
|
|
9381
|
+
}
|
|
9382
|
+
});
|
|
9383
|
+
}
|
|
9384
|
+
/**
|
|
9385
|
+
* Validate the on-disk budget schema against the canonical DDL — column
|
|
9386
|
+
* NAMES, declared TYPES, NOT NULL constraints, and PRIMARY KEYS — with the
|
|
9387
|
+
* same clean-break recovery contract as the audit table (store.ts):
|
|
9388
|
+
* pre-1.0 local databases are deleted, not migrated. Types matter beyond
|
|
9389
|
+
* presence: a `timestamp_ms` with TEXT affinity would make every replay
|
|
9390
|
+
* and retention comparison lexicographic ('900' > '1000'), silently
|
|
9391
|
+
* resurrecting expired spend. The canonical shape is derived by executing
|
|
9392
|
+
* the DDL itself in a scratch database, so the assertion cannot drift
|
|
9393
|
+
* from what the DDL creates. Extra columns in the live table are
|
|
9394
|
+
* tolerated (forward compatibility, matching the audit store's posture).
|
|
9395
|
+
*/
|
|
9396
|
+
assertRequiredSchema() {
|
|
9397
|
+
const canonical = new Database2(":memory:");
|
|
9398
|
+
let mismatches;
|
|
9399
|
+
try {
|
|
9400
|
+
canonical.exec(CREATE_TABLES_DDL);
|
|
9401
|
+
mismatches = [];
|
|
9402
|
+
for (const table of BUDGET_TABLES) {
|
|
9403
|
+
const expected = canonical.pragma(`table_info(${table})`);
|
|
9404
|
+
const live = new Map(
|
|
9405
|
+
this.db.pragma(`table_info(${table})`).map((col) => [col.name, col])
|
|
9406
|
+
);
|
|
9407
|
+
for (const column of expected) {
|
|
9408
|
+
const actual = live.get(column.name);
|
|
9409
|
+
if (!actual) {
|
|
9410
|
+
mismatches.push(`${table}.${column.name} (missing)`);
|
|
9411
|
+
continue;
|
|
9412
|
+
}
|
|
9413
|
+
if (actual.type !== column.type || actual.notnull !== column.notnull || actual.pk !== column.pk) {
|
|
9414
|
+
mismatches.push(
|
|
9415
|
+
`${table}.${column.name} (found ${describeColumn(actual)}, expected ${describeColumn(column)})`
|
|
9416
|
+
);
|
|
9417
|
+
}
|
|
9418
|
+
}
|
|
9419
|
+
}
|
|
9420
|
+
} finally {
|
|
9421
|
+
canonical.close();
|
|
9422
|
+
}
|
|
9423
|
+
if (mismatches.length === 0) return;
|
|
9424
|
+
const dbPath = this.db.name;
|
|
9425
|
+
throw new Error(
|
|
9426
|
+
`[helio] Budget ledger schema mismatch: incompatible columns ${mismatches.join(", ")}. This local database was created by an older Helio build. Delete "${dbPath}", "${dbPath}-wal", and "${dbPath}-shm", then restart Helio.`
|
|
9427
|
+
);
|
|
9428
|
+
}
|
|
9429
|
+
// -------------------------------------------------------------------------
|
|
9430
|
+
// BudgetPersistence
|
|
9431
|
+
// -------------------------------------------------------------------------
|
|
9432
|
+
/** Persist every row of one call in a single transaction (all or nothing). */
|
|
9433
|
+
commitAll(rows) {
|
|
9434
|
+
this.commitTxn(rows);
|
|
9435
|
+
}
|
|
9436
|
+
readMeta(budgetName) {
|
|
9437
|
+
return this.readMetaStmt.get(budgetName);
|
|
9438
|
+
}
|
|
9439
|
+
readAllMeta() {
|
|
9440
|
+
return this.readAllMetaStmt.all();
|
|
9441
|
+
}
|
|
9442
|
+
maxEventEpoch(budgetName) {
|
|
9443
|
+
const { epoch } = this.maxEventEpochStmt.get(budgetName);
|
|
9444
|
+
return epoch;
|
|
9445
|
+
}
|
|
9446
|
+
writeMeta(meta) {
|
|
9447
|
+
this.upsertMetaStmt.run({
|
|
9448
|
+
budget_name: meta.budget_name,
|
|
9449
|
+
limit_amount: meta.limit_amount,
|
|
9450
|
+
currency: meta.currency,
|
|
9451
|
+
window: meta.window,
|
|
9452
|
+
key: meta.key,
|
|
9453
|
+
epoch: meta.epoch,
|
|
9454
|
+
updated_at: new Date(this.now()).toISOString()
|
|
9455
|
+
});
|
|
9456
|
+
}
|
|
9457
|
+
/** All of one reload's epoch mints in a single transaction (all or nothing). */
|
|
9458
|
+
writeMetaBatch(metas) {
|
|
9459
|
+
this.writeMetaTxn(metas);
|
|
9460
|
+
}
|
|
9461
|
+
replayDurationEvents(budgetName, epoch, sinceMs) {
|
|
9462
|
+
return this.replayDurationStmt.all(budgetName, epoch, sinceMs);
|
|
9463
|
+
}
|
|
9464
|
+
replaySessionBuckets(budgetName, epoch) {
|
|
9465
|
+
return this.replaySessionStmt.all(budgetName, epoch);
|
|
9466
|
+
}
|
|
9467
|
+
recordBucketGc(budgetName, bucketKey, gcAfterMs) {
|
|
9468
|
+
this.upsertGcStmt.run({
|
|
9469
|
+
budget_name: budgetName,
|
|
9470
|
+
bucket_key: bucketKey,
|
|
9471
|
+
gc_after_ms: gcAfterMs
|
|
9472
|
+
});
|
|
9473
|
+
}
|
|
9474
|
+
// -------------------------------------------------------------------------
|
|
9475
|
+
// Dashboard read surface
|
|
9476
|
+
// -------------------------------------------------------------------------
|
|
9477
|
+
/**
|
|
9478
|
+
* One page of a budget's spend history for the dashboard, newest first.
|
|
9479
|
+
* `limit` defaults to 50 and clamps to `LIST_MAX_PAGE_SIZE`; `offset`
|
|
9480
|
+
* floors at 0. An unknown budget name simply lists nothing (names are
|
|
9481
|
+
* config, not secrets — no 404 semantics on hot-reload races).
|
|
9482
|
+
*/
|
|
9483
|
+
listEvents(budgetName, page) {
|
|
9484
|
+
const limit = Math.min(
|
|
9485
|
+
Math.max(Math.trunc(page.limit ?? LIST_EVENTS_DEFAULT_LIMIT), 1),
|
|
9486
|
+
LIST_MAX_PAGE_SIZE
|
|
9487
|
+
);
|
|
9488
|
+
const offset = Math.max(Math.trunc(page.offset ?? 0), 0);
|
|
9489
|
+
const events = this.listEventsStmt.all(budgetName, limit, offset);
|
|
9490
|
+
const { total } = this.countEventsStmt.get(budgetName);
|
|
9491
|
+
return { events, total };
|
|
9492
|
+
}
|
|
9493
|
+
// -------------------------------------------------------------------------
|
|
9494
|
+
// Retention
|
|
9495
|
+
// -------------------------------------------------------------------------
|
|
9496
|
+
/**
|
|
9497
|
+
* Purge events past the retention cutoff (event-time milliseconds — the
|
|
9498
|
+
* same axis every replay query filters on, so the retention bound and the
|
|
9499
|
+
* replay bound cannot diverge). Watermarks older than the cutoff prune
|
|
9500
|
+
* with the same statement's cutoff: every row such a watermark could
|
|
9501
|
+
* filter has `timestamp_ms <= gc_after_ms < cutoffMs` and is deleted here
|
|
9502
|
+
* too, so the watermark guards nothing and is safe to drop.
|
|
9503
|
+
*
|
|
9504
|
+
* Called from the audit store's retention sweep (one sweep schedule); the
|
|
9505
|
+
* cutoff is computed once per sweep by the store.
|
|
9506
|
+
*/
|
|
9507
|
+
purgeExpired(cutoffMs) {
|
|
9508
|
+
return this.purgeTxn(cutoffMs);
|
|
9509
|
+
}
|
|
9510
|
+
};
|
|
9511
|
+
|
|
7491
9512
|
// src/dashboard/api.ts
|
|
7492
9513
|
import { readFileSync } from "fs";
|
|
7493
9514
|
import { join } from "path";
|
|
7494
|
-
import { randomUUID as
|
|
9515
|
+
import { randomUUID as randomUUID8 } from "crypto";
|
|
7495
9516
|
import { Hono as Hono8 } from "hono";
|
|
7496
9517
|
import { HTTPException as HTTPException2 } from "hono/http-exception";
|
|
7497
9518
|
import { z as z8 } from "zod";
|
|
@@ -7710,6 +9731,10 @@ var auditQuerySchema = z8.object({
|
|
|
7710
9731
|
channel_id: optionalQueryString,
|
|
7711
9732
|
sender_id: optionalQueryString
|
|
7712
9733
|
});
|
|
9734
|
+
var budgetEventsQuerySchema = z8.object({
|
|
9735
|
+
limit: clampedQueryInt(50, 1, LIST_MAX_PAGE_SIZE),
|
|
9736
|
+
offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER)
|
|
9737
|
+
});
|
|
7713
9738
|
var analyticsQuerySchema = z8.object({
|
|
7714
9739
|
from: optionalQueryString,
|
|
7715
9740
|
to: optionalQueryString
|
|
@@ -7782,7 +9807,8 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
7782
9807
|
spendLimiter,
|
|
7783
9808
|
evidenceStore,
|
|
7784
9809
|
eventBus,
|
|
7785
|
-
adapterLiveness
|
|
9810
|
+
adapterLiveness,
|
|
9811
|
+
budgets
|
|
7786
9812
|
} = deps;
|
|
7787
9813
|
const apiSecret = options?.apiSecret;
|
|
7788
9814
|
const sessionStore = apiSecret ? new DashboardSessionStore({ secret: apiSecret, ttlMs: SESSION_TTL_MS }) : void 0;
|
|
@@ -8004,6 +10030,22 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
8004
10030
|
app.get("/api/adapters", (c) => {
|
|
8005
10031
|
return c.json({ adapters: adapterLiveness?.listAdapters() ?? [] });
|
|
8006
10032
|
});
|
|
10033
|
+
app.get("/api/budgets", (c) => {
|
|
10034
|
+
return c.json({ budgets: budgets?.listStates() ?? [] });
|
|
10035
|
+
});
|
|
10036
|
+
app.get("/api/budgets/:name/events", (c) => {
|
|
10037
|
+
const query = budgetEventsQuerySchema.parse(c.req.query());
|
|
10038
|
+
const page = budgets?.listEvents(c.req.param("name"), {
|
|
10039
|
+
limit: query.limit,
|
|
10040
|
+
offset: query.offset
|
|
10041
|
+
}) ?? { events: [], total: 0 };
|
|
10042
|
+
return c.json({
|
|
10043
|
+
data: page.events,
|
|
10044
|
+
total: page.total,
|
|
10045
|
+
limit: query.limit,
|
|
10046
|
+
offset: query.offset
|
|
10047
|
+
});
|
|
10048
|
+
});
|
|
8007
10049
|
app.get("/api/analytics", (c) => {
|
|
8008
10050
|
const query = analyticsQuerySchema.parse(c.req.query());
|
|
8009
10051
|
const now = /* @__PURE__ */ new Date();
|
|
@@ -8045,7 +10087,7 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
8045
10087
|
app.get("/api/events", (c) => {
|
|
8046
10088
|
return streamSSE(c, async (stream) => {
|
|
8047
10089
|
if (closed) return;
|
|
8048
|
-
const connId =
|
|
10090
|
+
const connId = randomUUID8();
|
|
8049
10091
|
let streamClosed = false;
|
|
8050
10092
|
let stopHeartbeat = () => {
|
|
8051
10093
|
};
|
|
@@ -8074,7 +10116,7 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
8074
10116
|
void stream.writeSSE({
|
|
8075
10117
|
event: eventType,
|
|
8076
10118
|
data: JSON.stringify(data),
|
|
8077
|
-
id:
|
|
10119
|
+
id: randomUUID8()
|
|
8078
10120
|
}).then(() => {
|
|
8079
10121
|
const conn = activeConnections.get(connId);
|
|
8080
10122
|
if (conn) conn.lastWrite = Date.now();
|
|
@@ -8120,7 +10162,9 @@ var EVENT_TYPES = [
|
|
|
8120
10162
|
"approval_requested",
|
|
8121
10163
|
"approval_resolved",
|
|
8122
10164
|
"limit_warning",
|
|
8123
|
-
"approval_notification_failed"
|
|
10165
|
+
"approval_notification_failed",
|
|
10166
|
+
"budget_update",
|
|
10167
|
+
"budget_breached"
|
|
8124
10168
|
];
|
|
8125
10169
|
var DashboardEventBus = class {
|
|
8126
10170
|
emitter = new EventEmitter();
|
|
@@ -8169,6 +10213,20 @@ var DashboardEventBus = class {
|
|
|
8169
10213
|
function isLoopbackHost2(host) {
|
|
8170
10214
|
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
8171
10215
|
}
|
|
10216
|
+
function warnIfBudgetWindowExceedsRetention(config, log = console.error) {
|
|
10217
|
+
const retentionMs = parseDuration(config.audit.retention);
|
|
10218
|
+
let warned = false;
|
|
10219
|
+
for (const budget of config.budgets) {
|
|
10220
|
+
const horizonMs = budget.window === "session" ? parseDuration(budget.idle_ttl ?? "24h") : parseDuration(budget.window);
|
|
10221
|
+
const horizonLabel = budget.window === "session" ? `idle_ttl ${budget.idle_ttl ?? "24h"}` : `window ${budget.window}`;
|
|
10222
|
+
if (horizonMs <= retentionMs) continue;
|
|
10223
|
+
log(
|
|
10224
|
+
`[helio] Warning: budget "${budget.name}" has ${horizonLabel}, longer than audit.retention ${config.audit.retention}. The spend ledger is purged on the retention sweep, so a restart can forget in-window spend and re-open the pot. Raise audit.retention or shorten the budget window.`
|
|
10225
|
+
);
|
|
10226
|
+
warned = true;
|
|
10227
|
+
}
|
|
10228
|
+
return warned;
|
|
10229
|
+
}
|
|
8172
10230
|
function warnIfWebhookChannelUnreachable(config, log = console.error) {
|
|
8173
10231
|
const hasWebhookChannel = config.approval.channels.some((ch) => ch.type === "webhook");
|
|
8174
10232
|
const localOnlyDashboard = config.dashboard.enabled && isLoopbackHost2(config.dashboard.host);
|
|
@@ -8202,6 +10260,26 @@ function warnIfNoEnforcement(policy, log = console.error) {
|
|
|
8202
10260
|
return true;
|
|
8203
10261
|
}
|
|
8204
10262
|
|
|
10263
|
+
// src/shutdown.ts
|
|
10264
|
+
async function closeResources(resources) {
|
|
10265
|
+
resources.annotationPrime?.stop();
|
|
10266
|
+
resources.configWatcher?.close();
|
|
10267
|
+
resources.approvalRouter?.close();
|
|
10268
|
+
resources.approvalQueue?.close();
|
|
10269
|
+
resources.closeDashboardApp?.();
|
|
10270
|
+
resources.eventBus?.close();
|
|
10271
|
+
if (resources.dashboardHandle) await resources.dashboardHandle.close();
|
|
10272
|
+
if (resources.sidebandHandle) await resources.sidebandHandle.close();
|
|
10273
|
+
await resources.handle.close();
|
|
10274
|
+
resources.governanceService?.close();
|
|
10275
|
+
resources.rateLimiter?.close();
|
|
10276
|
+
resources.spendLimiter?.close();
|
|
10277
|
+
resources.budgetEngine?.close();
|
|
10278
|
+
resources.evidenceStore?.close();
|
|
10279
|
+
resources.auditWriter?.close();
|
|
10280
|
+
if (resources.closeForwarder) await resources.closeForwarder();
|
|
10281
|
+
}
|
|
10282
|
+
|
|
8205
10283
|
// src/crash-drain.ts
|
|
8206
10284
|
var hooks = [];
|
|
8207
10285
|
var draining = false;
|
|
@@ -8427,12 +10505,18 @@ async function startCommand(configPath, options) {
|
|
|
8427
10505
|
const label = w.ruleName ? `rule "${w.ruleName}"` : `rule ${String(w.ruleIndex)}`;
|
|
8428
10506
|
console.error(`Warning: policy ${label}: ${w.message}`);
|
|
8429
10507
|
}
|
|
10508
|
+
const budgets = compileBudgets(config.budgets);
|
|
8430
10509
|
const eventBus = new DashboardEventBus();
|
|
8431
10510
|
const auditStore = new AuditStore({
|
|
8432
10511
|
path: config.audit.path,
|
|
8433
10512
|
retention: config.audit.retention,
|
|
8434
10513
|
includeResponses: config.audit.include_responses
|
|
8435
10514
|
});
|
|
10515
|
+
const budgetLedger = new BudgetLedger({ database: auditStore.database });
|
|
10516
|
+
auditStore.onRetentionSweep((cutoff) => {
|
|
10517
|
+
budgetLedger.purgeExpired(cutoff.ms);
|
|
10518
|
+
});
|
|
10519
|
+
auditStore.runRetentionSweep();
|
|
8436
10520
|
const auditWriter = new AuditWriter({
|
|
8437
10521
|
store: auditStore,
|
|
8438
10522
|
onPersist: (record, id) => {
|
|
@@ -8468,6 +10552,7 @@ async function startCommand(configPath, options) {
|
|
|
8468
10552
|
const evidenceStore = new EvidenceStore();
|
|
8469
10553
|
const approvalQueue = new ApprovalQueue();
|
|
8470
10554
|
const channels = createChannels(config.approval.channels);
|
|
10555
|
+
const runtimeChannelTypes = new Map([...channels].map(([key, ch]) => [key, ch.type]));
|
|
8471
10556
|
const approvalRouter = new ApprovalRouter({
|
|
8472
10557
|
defaultTimeoutMs: parseDuration(config.approval.timeout),
|
|
8473
10558
|
defaultOnTimeout: config.approval.default_on_timeout,
|
|
@@ -8515,13 +10600,25 @@ async function startCommand(configPath, options) {
|
|
|
8515
10600
|
});
|
|
8516
10601
|
}
|
|
8517
10602
|
});
|
|
10603
|
+
const budgetEngine = new BudgetEngine({
|
|
10604
|
+
budgets,
|
|
10605
|
+
ledger: budgetLedger,
|
|
10606
|
+
onCommit: (event) => {
|
|
10607
|
+
eventBus.emit("budget_update", event);
|
|
10608
|
+
},
|
|
10609
|
+
onBreach: (event) => {
|
|
10610
|
+
eventBus.emit("budget_breached", event);
|
|
10611
|
+
}
|
|
10612
|
+
});
|
|
10613
|
+
budgetEngine.hydrate();
|
|
8518
10614
|
const governedForwarder = new GovernedForwarder(forwarder, policy, {
|
|
8519
10615
|
environment: config.environment,
|
|
8520
10616
|
auditWriter,
|
|
8521
10617
|
evidenceStore,
|
|
8522
10618
|
approvalRouter,
|
|
8523
10619
|
rateLimiter,
|
|
8524
|
-
spendLimiter
|
|
10620
|
+
spendLimiter,
|
|
10621
|
+
budgetEngine
|
|
8525
10622
|
});
|
|
8526
10623
|
const annotationPrime = await startAnnotationPrimeLoop(governedForwarder);
|
|
8527
10624
|
const hasSlackChannels = [...channels.values()].some((ch) => ch.type === "slack");
|
|
@@ -8560,6 +10657,7 @@ async function startCommand(configPath, options) {
|
|
|
8560
10657
|
approvalRouter,
|
|
8561
10658
|
rateLimiter,
|
|
8562
10659
|
spendLimiter,
|
|
10660
|
+
budgetEngine,
|
|
8563
10661
|
auditWriter,
|
|
8564
10662
|
approvalTimeoutMs: parseDuration(config.approval.timeout),
|
|
8565
10663
|
ttlMs: parseDuration(config.sdk.evaluation_ttl)
|
|
@@ -8585,7 +10683,13 @@ async function startCommand(configPath, options) {
|
|
|
8585
10683
|
eventBus,
|
|
8586
10684
|
// Adapter liveness for GET /api/adapters (issue #126); undefined
|
|
8587
10685
|
// unless the SDK sideband is enabled → endpoint serves an empty list.
|
|
8588
|
-
adapterLiveness: governanceService
|
|
10686
|
+
adapterLiveness: governanceService,
|
|
10687
|
+
// Budget read surface (issue #14): live pot states from the engine,
|
|
10688
|
+
// spend history from the ledger.
|
|
10689
|
+
budgets: {
|
|
10690
|
+
listStates: () => budgetEngine.listStates(),
|
|
10691
|
+
listEvents: (name, page) => budgetLedger.listEvents(name, page)
|
|
10692
|
+
}
|
|
8589
10693
|
},
|
|
8590
10694
|
{
|
|
8591
10695
|
apiSecret: config.dashboard.api_secret,
|
|
@@ -8637,12 +10741,17 @@ async function startCommand(configPath, options) {
|
|
|
8637
10741
|
warnIfWebhookChannelUnreachable(config);
|
|
8638
10742
|
warnIfSdkSidebandExposed(config);
|
|
8639
10743
|
warnIfDashboardOpenMode(config);
|
|
10744
|
+
warnIfBudgetWindowExceedsRetention(config);
|
|
8640
10745
|
const channelCount = config.approval.channels.length;
|
|
8641
10746
|
console.error(
|
|
8642
10747
|
`Approvals: timeout ${config.approval.timeout}, default on timeout: ${config.approval.default_on_timeout}, ${String(channelCount)} channel${channelCount !== 1 ? "s" : ""} configured`
|
|
8643
10748
|
);
|
|
8644
10749
|
console.error(`Rate limits: enabled`);
|
|
8645
10750
|
console.error(`Spend limits: enabled`);
|
|
10751
|
+
const budgetCount = budgets.length;
|
|
10752
|
+
console.error(
|
|
10753
|
+
`Budgets: ${String(budgetCount)} configured${budgetCount > 0 ? ` (${budgets.map((b) => b.name).join(", ")})` : ""}`
|
|
10754
|
+
);
|
|
8646
10755
|
if (policy.dryRun) {
|
|
8647
10756
|
console.error(`Dry-run: ENABLED (no requests will be forwarded to upstream)`);
|
|
8648
10757
|
}
|
|
@@ -8653,9 +10762,24 @@ async function startCommand(configPath, options) {
|
|
|
8653
10762
|
configWatcher = new ConfigWatcher({
|
|
8654
10763
|
configPath,
|
|
8655
10764
|
initialConfig: config,
|
|
8656
|
-
|
|
10765
|
+
onReload: (newPolicy, reloadWarnings, restartRequiredPaths, newBudgets) => {
|
|
10766
|
+
const unroutable = findUnroutableApprovalReferences(newPolicy, newBudgets, {
|
|
10767
|
+
channelTypes: runtimeChannelTypes,
|
|
10768
|
+
dashboardEnabled: config.dashboard.enabled,
|
|
10769
|
+
defaultApprovalTimeoutMs: parseDuration(config.approval.timeout)
|
|
10770
|
+
});
|
|
10771
|
+
if (unroutable.length > 0) {
|
|
10772
|
+
throw new Error(
|
|
10773
|
+
`approval routing is not available in the running process (restart required to apply approval.channels/dashboard changes): ${unroutable.join("; ")}`
|
|
10774
|
+
);
|
|
10775
|
+
}
|
|
10776
|
+
budgetEngine.reconcile(newBudgets);
|
|
8657
10777
|
governedForwarder.updatePolicy(newPolicy);
|
|
8658
10778
|
governanceService?.updatePolicy(newPolicy);
|
|
10779
|
+
const budgetTotal = newBudgets.length;
|
|
10780
|
+
console.error(
|
|
10781
|
+
`[helio] Budgets reloaded: ${String(budgetTotal)} budget${budgetTotal !== 1 ? "s" : ""}`
|
|
10782
|
+
);
|
|
8659
10783
|
const count = newPolicy.rules.length;
|
|
8660
10784
|
console.error(
|
|
8661
10785
|
`[helio] Policy reloaded: ${String(count)} rule${count !== 1 ? "s" : ""} (default: ${newPolicy.defaultAction})`
|
|
@@ -8675,7 +10799,9 @@ async function startCommand(configPath, options) {
|
|
|
8675
10799
|
}
|
|
8676
10800
|
},
|
|
8677
10801
|
onError: (error) => {
|
|
8678
|
-
console.error(
|
|
10802
|
+
console.error(
|
|
10803
|
+
`[helio] Config reload failed (keeping current configuration): ${error.message}`
|
|
10804
|
+
);
|
|
8679
10805
|
}
|
|
8680
10806
|
});
|
|
8681
10807
|
configWatcher.start();
|
|
@@ -8697,6 +10823,7 @@ async function startCommand(configPath, options) {
|
|
|
8697
10823
|
approvalQueue,
|
|
8698
10824
|
rateLimiter,
|
|
8699
10825
|
spendLimiter,
|
|
10826
|
+
budgetEngine,
|
|
8700
10827
|
closeDashboardApp,
|
|
8701
10828
|
dashboardHandle,
|
|
8702
10829
|
eventBus,
|
|
@@ -8726,6 +10853,7 @@ async function validateCommand(configPath) {
|
|
|
8726
10853
|
const label = w.ruleName ? `rule "${w.ruleName}"` : `rule ${String(w.ruleIndex)}`;
|
|
8727
10854
|
console.error(`Warning: policy ${label}: ${w.message}`);
|
|
8728
10855
|
}
|
|
10856
|
+
compileBudgets(config.budgets);
|
|
8729
10857
|
if (config.dashboard.enabled && !getBundledDashboardDistPath()) {
|
|
8730
10858
|
console.error(
|
|
8731
10859
|
"Invalid config: dashboard.enabled is true but bundled dashboard assets are missing. " + DASHBOARD_ASSETS_RECOVERY_MESSAGE_FOR_VALIDATE
|
|
@@ -8816,7 +10944,7 @@ function writeCsv(records) {
|
|
|
8816
10944
|
console.log(values.join(","));
|
|
8817
10945
|
}
|
|
8818
10946
|
}
|
|
8819
|
-
function registerShutdown(handle, annotationPrime, closeForwarder, auditWriter, configWatcher, sidebandHandle, evidenceStore, approvalRouter, approvalQueue, rateLimiter, spendLimiter, closeDashboardApp, dashboardHandle, eventBus, governanceService) {
|
|
10947
|
+
function registerShutdown(handle, annotationPrime, closeForwarder, auditWriter, configWatcher, sidebandHandle, evidenceStore, approvalRouter, approvalQueue, rateLimiter, spendLimiter, budgetEngine, closeDashboardApp, dashboardHandle, eventBus, governanceService) {
|
|
8820
10948
|
let isShuttingDown = false;
|
|
8821
10949
|
const shutdown = () => {
|
|
8822
10950
|
if (isShuttingDown) return;
|
|
@@ -8827,24 +10955,24 @@ function registerShutdown(handle, annotationPrime, closeForwarder, auditWriter,
|
|
|
8827
10955
|
process.exit(1);
|
|
8828
10956
|
}, SHUTDOWN_TIMEOUT_MS);
|
|
8829
10957
|
forceShutdownTimer.unref();
|
|
8830
|
-
|
|
8831
|
-
|
|
8832
|
-
|
|
8833
|
-
|
|
8834
|
-
|
|
8835
|
-
|
|
8836
|
-
|
|
8837
|
-
|
|
8838
|
-
|
|
8839
|
-
|
|
8840
|
-
|
|
8841
|
-
|
|
8842
|
-
|
|
8843
|
-
|
|
8844
|
-
|
|
8845
|
-
|
|
8846
|
-
|
|
8847
|
-
|
|
10958
|
+
void closeResources({
|
|
10959
|
+
handle,
|
|
10960
|
+
annotationPrime,
|
|
10961
|
+
closeForwarder,
|
|
10962
|
+
auditWriter,
|
|
10963
|
+
configWatcher,
|
|
10964
|
+
sidebandHandle,
|
|
10965
|
+
evidenceStore,
|
|
10966
|
+
approvalRouter,
|
|
10967
|
+
approvalQueue,
|
|
10968
|
+
rateLimiter,
|
|
10969
|
+
spendLimiter,
|
|
10970
|
+
budgetEngine,
|
|
10971
|
+
closeDashboardApp,
|
|
10972
|
+
dashboardHandle,
|
|
10973
|
+
eventBus,
|
|
10974
|
+
governanceService
|
|
10975
|
+
}).then(() => {
|
|
8848
10976
|
clearTimeout(forceShutdownTimer);
|
|
8849
10977
|
process.exit(0);
|
|
8850
10978
|
}).catch((err) => {
|