@gethelio/proxy 0.8.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 +3469 -1234
- 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 +678 -8
- package/dist/index.js +4199 -2419
- 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/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { Hono } from 'hono';
|
|
3
3
|
import { ServerType } from '@hono/node-server';
|
|
4
|
+
import { Database } from 'better-sqlite3';
|
|
4
5
|
import { KnownBlock } from '@slack/web-api';
|
|
5
6
|
|
|
6
7
|
declare const VERSION: string;
|
|
@@ -123,6 +124,32 @@ declare const policiesSchema: z.ZodObject<{
|
|
|
123
124
|
}>>;
|
|
124
125
|
hot_reload: z.ZodOptional<z.ZodBoolean>;
|
|
125
126
|
}, z.core.$strict>;
|
|
127
|
+
declare const budgetSchema: z.ZodObject<{
|
|
128
|
+
name: z.ZodString;
|
|
129
|
+
limit: z.ZodNumber;
|
|
130
|
+
currency: z.ZodString;
|
|
131
|
+
window: z.ZodUnion<readonly [z.ZodString, z.ZodLiteral<"session">]>;
|
|
132
|
+
key: z.ZodDefault<z.ZodEnum<{
|
|
133
|
+
session: "session";
|
|
134
|
+
sender_id: "sender_id";
|
|
135
|
+
global: "global";
|
|
136
|
+
}>>;
|
|
137
|
+
on_exceed: z.ZodDefault<z.ZodEnum<{
|
|
138
|
+
deny: "deny";
|
|
139
|
+
require_approval: "require_approval";
|
|
140
|
+
}>>;
|
|
141
|
+
approval: z.ZodOptional<z.ZodObject<{
|
|
142
|
+
channel: z.ZodString;
|
|
143
|
+
timeout: z.ZodOptional<z.ZodString>;
|
|
144
|
+
delegates: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
145
|
+
escalation_after: z.ZodOptional<z.ZodString>;
|
|
146
|
+
}, z.core.$strict>>;
|
|
147
|
+
idle_ttl: z.ZodOptional<z.ZodString>;
|
|
148
|
+
contributors: z.ZodArray<z.ZodObject<{
|
|
149
|
+
tool: z.ZodString;
|
|
150
|
+
field: z.ZodString;
|
|
151
|
+
}, z.core.$strict>>;
|
|
152
|
+
}, z.core.$strict>;
|
|
126
153
|
declare const approvalChannelSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
127
154
|
type: z.ZodLiteral<"slack">;
|
|
128
155
|
name: z.ZodOptional<z.ZodString>;
|
|
@@ -286,6 +313,32 @@ declare const helioConfigSchema: z.ZodObject<{
|
|
|
286
313
|
}>>;
|
|
287
314
|
hot_reload: z.ZodOptional<z.ZodBoolean>;
|
|
288
315
|
}, z.core.$strict>>;
|
|
316
|
+
budgets: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
317
|
+
name: z.ZodString;
|
|
318
|
+
limit: z.ZodNumber;
|
|
319
|
+
currency: z.ZodString;
|
|
320
|
+
window: z.ZodUnion<readonly [z.ZodString, z.ZodLiteral<"session">]>;
|
|
321
|
+
key: z.ZodDefault<z.ZodEnum<{
|
|
322
|
+
session: "session";
|
|
323
|
+
sender_id: "sender_id";
|
|
324
|
+
global: "global";
|
|
325
|
+
}>>;
|
|
326
|
+
on_exceed: z.ZodDefault<z.ZodEnum<{
|
|
327
|
+
deny: "deny";
|
|
328
|
+
require_approval: "require_approval";
|
|
329
|
+
}>>;
|
|
330
|
+
approval: z.ZodOptional<z.ZodObject<{
|
|
331
|
+
channel: z.ZodString;
|
|
332
|
+
timeout: z.ZodOptional<z.ZodString>;
|
|
333
|
+
delegates: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
334
|
+
escalation_after: z.ZodOptional<z.ZodString>;
|
|
335
|
+
}, z.core.$strict>>;
|
|
336
|
+
idle_ttl: z.ZodOptional<z.ZodString>;
|
|
337
|
+
contributors: z.ZodArray<z.ZodObject<{
|
|
338
|
+
tool: z.ZodString;
|
|
339
|
+
field: z.ZodString;
|
|
340
|
+
}, z.core.$strict>>;
|
|
341
|
+
}, z.core.$strict>>>;
|
|
289
342
|
approval: z.ZodPrefault<z.ZodObject<{
|
|
290
343
|
timeout: z.ZodDefault<z.ZodString>;
|
|
291
344
|
default_on_timeout: z.ZodDefault<z.ZodEnum<{
|
|
@@ -329,6 +382,10 @@ type HelioConfig = z.infer<typeof helioConfigSchema>;
|
|
|
329
382
|
type ApprovalChannel$1 = z.infer<typeof approvalChannelSchema>;
|
|
330
383
|
/** The policies section of the config. */
|
|
331
384
|
type PoliciesConfig = z.infer<typeof policiesSchema>;
|
|
385
|
+
/** A single named budget from the `budgets` array (issue #14). */
|
|
386
|
+
type BudgetConfig = z.infer<typeof budgetSchema>;
|
|
387
|
+
/** The `budgets` section of the config. */
|
|
388
|
+
type BudgetsConfig = readonly BudgetConfig[];
|
|
332
389
|
|
|
333
390
|
/** Structured error for configuration loading failures. */
|
|
334
391
|
declare class ConfigError extends Error {
|
|
@@ -546,6 +603,47 @@ interface MatchContext {
|
|
|
546
603
|
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
547
604
|
}
|
|
548
605
|
|
|
606
|
+
/** One compiled contributor: which tools feed the budget, and from which field. */
|
|
607
|
+
interface CompiledBudgetContributor {
|
|
608
|
+
readonly tool: ToolMatcher;
|
|
609
|
+
/** Dot-path into the tool arguments (e.g. "$.amount"), resolved per call. */
|
|
610
|
+
readonly field: string;
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* A budget's replenishment semantics.
|
|
614
|
+
*
|
|
615
|
+
* - `duration`: sliding window; spend ages out after `windowMs`.
|
|
616
|
+
* - `session`: a depleting pot per session key that never replenishes on a
|
|
617
|
+
* timer; idle pots are garbage-collected after `idleTtlMs` because neither
|
|
618
|
+
* door has an authoritative session-end signal.
|
|
619
|
+
*/
|
|
620
|
+
type CompiledBudgetWindow = {
|
|
621
|
+
readonly kind: 'duration';
|
|
622
|
+
readonly windowMs: number;
|
|
623
|
+
} | {
|
|
624
|
+
readonly kind: 'session';
|
|
625
|
+
readonly idleTtlMs: number;
|
|
626
|
+
};
|
|
627
|
+
/** A fully compiled named budget, ready for the engine. */
|
|
628
|
+
interface CompiledBudget {
|
|
629
|
+
readonly name: string;
|
|
630
|
+
readonly limit: number;
|
|
631
|
+
readonly currency: string;
|
|
632
|
+
readonly window: CompiledBudgetWindow;
|
|
633
|
+
/** The raw config window string ("1h" | "session") for wire/docs surfaces. */
|
|
634
|
+
readonly windowRaw: string;
|
|
635
|
+
readonly key: 'global' | 'session' | 'sender_id';
|
|
636
|
+
/** What a breach does: deny the call, or raise a break-glass ticket. */
|
|
637
|
+
readonly onExceed: 'deny' | 'require_approval';
|
|
638
|
+
/**
|
|
639
|
+
* Break-glass ticket routing (`on_exceed: require_approval` only). Absent
|
|
640
|
+
* means the dashboard channel and the router's default timeout. Budget
|
|
641
|
+
* tickets never consult `default_on_timeout` — timeout fails closed.
|
|
642
|
+
*/
|
|
643
|
+
readonly approval?: CompiledApproval;
|
|
644
|
+
readonly contributors: readonly CompiledBudgetContributor[];
|
|
645
|
+
}
|
|
646
|
+
|
|
549
647
|
/** A JSON-RPC 2.0 request object. */
|
|
550
648
|
interface JsonRpcRequest {
|
|
551
649
|
jsonrpc: '2.0';
|
|
@@ -921,7 +1019,7 @@ interface AuditQueryFilters {
|
|
|
921
1019
|
}
|
|
922
1020
|
/** Pagination options for list queries. */
|
|
923
1021
|
interface AuditPaginationOptions {
|
|
924
|
-
/** Maximum number of records to return (default: 50, max:
|
|
1022
|
+
/** Maximum number of records to return (default: 50, max: 1,000 — `LIST_MAX_PAGE_SIZE`). */
|
|
925
1023
|
readonly limit?: number;
|
|
926
1024
|
/** Number of records to skip (default: 0). */
|
|
927
1025
|
readonly offset?: number;
|
|
@@ -986,6 +1084,26 @@ interface AuditStoreOptions {
|
|
|
986
1084
|
readonly cleanupIntervalMs?: number;
|
|
987
1085
|
}
|
|
988
1086
|
|
|
1087
|
+
/**
|
|
1088
|
+
* Maximum records a single bulk export may return. Shared by the dashboard
|
|
1089
|
+
* export route schema and the CLI export command so the advertised cap and
|
|
1090
|
+
* the store's actual cap cannot diverge.
|
|
1091
|
+
*/
|
|
1092
|
+
declare const EXPORT_MAX_RECORDS = 10000;
|
|
1093
|
+
/**
|
|
1094
|
+
* Maximum page size for paginated `list()` reads. Shared with the dashboard
|
|
1095
|
+
* audit route schema for the same reason as {@link EXPORT_MAX_RECORDS}.
|
|
1096
|
+
*/
|
|
1097
|
+
declare const LIST_MAX_PAGE_SIZE = 1000;
|
|
1098
|
+
/**
|
|
1099
|
+
* The retention cutoff of one sweep, computed once and shared with every
|
|
1100
|
+
* registered hook: `iso` for `created_at`-style string comparisons, `ms`
|
|
1101
|
+
* for epoch-millisecond columns.
|
|
1102
|
+
*/
|
|
1103
|
+
interface RetentionSweepCutoff {
|
|
1104
|
+
readonly iso: string;
|
|
1105
|
+
readonly ms: number;
|
|
1106
|
+
}
|
|
989
1107
|
/**
|
|
990
1108
|
* SQLite-backed audit record store.
|
|
991
1109
|
*
|
|
@@ -997,8 +1115,33 @@ declare class AuditStore {
|
|
|
997
1115
|
private readonly insertStmt;
|
|
998
1116
|
private readonly retentionMs;
|
|
999
1117
|
private readonly includeResponses;
|
|
1118
|
+
private readonly retentionSweepHooks;
|
|
1000
1119
|
private cleanupTimer;
|
|
1001
1120
|
constructor(options: AuditStoreOptions);
|
|
1121
|
+
/**
|
|
1122
|
+
* Register a hook to run on every retention sweep, receiving the sweep's
|
|
1123
|
+
* cutoff. This is how co-resident tables (the budget ledger) join the
|
|
1124
|
+
* store's single sweep schedule instead of running their own timers.
|
|
1125
|
+
* Hooks registered after construction miss the constructor's initial
|
|
1126
|
+
* purge — call {@link runRetentionSweep} once after registering to cover
|
|
1127
|
+
* rows that aged out while the process was down.
|
|
1128
|
+
*/
|
|
1129
|
+
onRetentionSweep(fn: (cutoff: RetentionSweepCutoff) => void): void;
|
|
1130
|
+
/**
|
|
1131
|
+
* One full retention sweep: purge expired audit records, then fire every
|
|
1132
|
+
* registered hook with the sweep's cutoff. Hook failures degrade to a
|
|
1133
|
+
* logged error — a broken co-resident purge must not stop the audit
|
|
1134
|
+
* table's own retention.
|
|
1135
|
+
*/
|
|
1136
|
+
runRetentionSweep(): void;
|
|
1137
|
+
/**
|
|
1138
|
+
* Package-internal: the store's open database handle, for components that
|
|
1139
|
+
* co-locate their tables in the audit db (the budget ledger). Sharing the
|
|
1140
|
+
* handle keeps one connection, one WAL domain, and one file-permission
|
|
1141
|
+
* hardening pass. Not part of the public embedding API — do not re-export
|
|
1142
|
+
* anything built on this from the package root.
|
|
1143
|
+
*/
|
|
1144
|
+
get database(): Database;
|
|
1002
1145
|
/**
|
|
1003
1146
|
* Validate that the on-disk audit schema contains all required canonical columns.
|
|
1004
1147
|
*
|
|
@@ -1032,14 +1175,23 @@ declare class AuditStore {
|
|
|
1032
1175
|
insertBatch(records: ReadonlyArray<Omit<AuditRecord, 'id' | 'created_at'>>, onError?: (record: Omit<AuditRecord, 'id' | 'created_at'>, err: unknown) => void, ids?: ReadonlyArray<string>, onPersist?: (record: Omit<AuditRecord, 'id' | 'created_at'>, id: string) => void): number;
|
|
1033
1176
|
/** Get a single record by ID, or undefined if not found. */
|
|
1034
1177
|
get(id: string): AuditRecord | undefined;
|
|
1035
|
-
/** Query records with filters and pagination. */
|
|
1178
|
+
/** Query records with filters and pagination. Capped at 1,000 per page. */
|
|
1036
1179
|
list(filters?: AuditQueryFilters, pagination?: AuditPaginationOptions): AuditListResult;
|
|
1180
|
+
/**
|
|
1181
|
+
* Query records for bulk export. Unlike `list()`, which enforces the
|
|
1182
|
+
* dashboard's 1,000-row page cap, this path allows up to
|
|
1183
|
+
* {@link EXPORT_MAX_RECORDS} in a single call. Always oldest-first
|
|
1184
|
+
* (ascending `created_at`), so a capped export keeps the earliest records.
|
|
1185
|
+
*/
|
|
1186
|
+
listForExport(filters?: AuditQueryFilters, limit?: number): AuditListResult;
|
|
1187
|
+
private query;
|
|
1037
1188
|
/** Count records matching the given filters. */
|
|
1038
1189
|
count(filters?: AuditQueryFilters): number;
|
|
1039
1190
|
/** Get aggregate statistics for a time range. */
|
|
1040
1191
|
aggregate(from?: string, to?: string): AuditAggregateStats;
|
|
1041
1192
|
/** Delete records older than the retention period. Returns the count of deleted records. */
|
|
1042
1193
|
purgeExpired(): number;
|
|
1194
|
+
private purgeBefore;
|
|
1043
1195
|
/** Close the database and stop the cleanup timer. */
|
|
1044
1196
|
close(): void;
|
|
1045
1197
|
}
|
|
@@ -1285,6 +1437,21 @@ type ApprovalStatus = 'pending' | 'approved' | 'denied' | 'timeout' | 'break_gla
|
|
|
1285
1437
|
* aborting the held MCP request. Sideband (native) approvals only. (#12.)
|
|
1286
1438
|
*/
|
|
1287
1439
|
| 'cancelled';
|
|
1440
|
+
/**
|
|
1441
|
+
* One breached budget's context on a break-glass approval ticket (issue #14).
|
|
1442
|
+
*
|
|
1443
|
+
* DTO: snake_case because it rides {@link ApprovalTicket}, which is emitted
|
|
1444
|
+
* verbatim over REST and webhooks. `spent` is the accrued spend BEFORE the
|
|
1445
|
+
* attempted charge; `window` is the raw config string ("1h" | "session").
|
|
1446
|
+
*/
|
|
1447
|
+
interface BudgetBreachContext {
|
|
1448
|
+
readonly name: string;
|
|
1449
|
+
readonly limit: number;
|
|
1450
|
+
readonly spent: number;
|
|
1451
|
+
readonly attempted_amount: number;
|
|
1452
|
+
readonly currency: string;
|
|
1453
|
+
readonly window: string;
|
|
1454
|
+
}
|
|
1288
1455
|
/** A failed attempt to deliver an approval notification. */
|
|
1289
1456
|
interface ApprovalNotificationFailure {
|
|
1290
1457
|
readonly channel: string;
|
|
@@ -1312,6 +1479,14 @@ interface ApprovalTicket {
|
|
|
1312
1479
|
readonly requested_at: string;
|
|
1313
1480
|
readonly timeout_at: string;
|
|
1314
1481
|
readonly timeout_ms: number;
|
|
1482
|
+
/**
|
|
1483
|
+
* Every budget the call breached, when this is a break-glass (budget) or
|
|
1484
|
+
* merged rule+budget ticket (issue #14). Its presence marks the ticket as
|
|
1485
|
+
* budget-context: one approval covers every listed overage, the approval is
|
|
1486
|
+
* scope-once by definition (issue #127 interlock — a `scope: "always"`
|
|
1487
|
+
* resolution grants nothing beyond this call), and timeout fails closed.
|
|
1488
|
+
*/
|
|
1489
|
+
readonly breached_budgets?: readonly BudgetBreachContext[];
|
|
1315
1490
|
status: ApprovalStatus;
|
|
1316
1491
|
resolved_at?: string;
|
|
1317
1492
|
resolved_by?: string;
|
|
@@ -1394,6 +1569,8 @@ declare class ApprovalQueue {
|
|
|
1394
1569
|
channel_name: string;
|
|
1395
1570
|
session_id: string | null;
|
|
1396
1571
|
timeout_ms: number;
|
|
1572
|
+
/** Breached budget context on break-glass / merged tickets (issue #14). */
|
|
1573
|
+
breached_budgets?: readonly BudgetBreachContext[];
|
|
1397
1574
|
}): ApprovalTicket;
|
|
1398
1575
|
/** Get a ticket by ID. Returns undefined if not found. */
|
|
1399
1576
|
get(id: string): ApprovalTicket | undefined;
|
|
@@ -1449,6 +1626,17 @@ interface ApprovalSubmitParams {
|
|
|
1449
1626
|
readonly tool_input: Record<string, unknown>;
|
|
1450
1627
|
readonly matched_rule: CompiledPolicyRule | undefined;
|
|
1451
1628
|
readonly session_id: string | null;
|
|
1629
|
+
/** Breached budget context; marks the ticket as break-glass (issue #14). */
|
|
1630
|
+
readonly breached_budgets?: readonly BudgetBreachContext[];
|
|
1631
|
+
/**
|
|
1632
|
+
* Total approval-config override. When set, channel/timeout/delegates/
|
|
1633
|
+
* escalation come from HERE and the matched rule's approval config is
|
|
1634
|
+
* ignored entirely — budget tickets are routed by the breached BUDGET's
|
|
1635
|
+
* config (the matched rule may be an allow rule whose approval block, if
|
|
1636
|
+
* any, has no authority over the money gate). Fields the override omits
|
|
1637
|
+
* fall back to the router defaults, never to the rule.
|
|
1638
|
+
*/
|
|
1639
|
+
readonly approval?: CompiledApproval;
|
|
1452
1640
|
}
|
|
1453
1641
|
/** Resolution statuses a native (sideband) ticket can be moved to. */
|
|
1454
1642
|
type NativeResolution = 'approved' | 'denied' | 'timeout' | 'cancelled';
|
|
@@ -1462,6 +1650,8 @@ interface NativeTicketParams {
|
|
|
1462
1650
|
readonly origin: string;
|
|
1463
1651
|
/** Ticket timeout in ms (rule timeout, else the router default). */
|
|
1464
1652
|
readonly timeout_ms?: number;
|
|
1653
|
+
/** Breached budget context; marks the ticket as break-glass (issue #14). */
|
|
1654
|
+
readonly breached_budgets?: readonly BudgetBreachContext[];
|
|
1465
1655
|
}
|
|
1466
1656
|
declare class ApprovalRouter {
|
|
1467
1657
|
private readonly defaultTimeoutMs;
|
|
@@ -1658,6 +1848,13 @@ declare class RateLimiter {
|
|
|
1658
1848
|
windowMs: number;
|
|
1659
1849
|
}>): void;
|
|
1660
1850
|
/** Stop the cleanup timer and mark as closed. */
|
|
1851
|
+
/**
|
|
1852
|
+
* Invoke the warning callback without letting a subscriber throw into the
|
|
1853
|
+
* limiter's caller: a warning fires after state has already mutated, and a
|
|
1854
|
+
* governed call must not be blocked (or double-charged on retry) by an
|
|
1855
|
+
* observability bug.
|
|
1856
|
+
*/
|
|
1857
|
+
private safeWarn;
|
|
1661
1858
|
close(): void;
|
|
1662
1859
|
}
|
|
1663
1860
|
|
|
@@ -1777,6 +1974,13 @@ declare class SpendLimiter {
|
|
|
1777
1974
|
* whose config is gone (rule changed or removed) are evicted so the next
|
|
1778
1975
|
* check lazy-creates a fresh bucket under the new config.
|
|
1779
1976
|
*
|
|
1977
|
+
* Keys built by {@link spendBucketKey} carry the owning rule's index, and
|
|
1978
|
+
* for those the tuple must match at THAT index (`config.ruleIndex`): a
|
|
1979
|
+
* reorder that shifts a spend rule's index evicts its old-index bucket
|
|
1980
|
+
* instead of leaving an orphan no rule reads again — or worse, letting
|
|
1981
|
+
* whatever rule now sits at that index adopt another rule's accrued spend.
|
|
1982
|
+
* Un-suffixed keys keep the tuple-anywhere match.
|
|
1983
|
+
*
|
|
1780
1984
|
* Currency is part of the tuple because a USD→EUR switch is a meaningful
|
|
1781
1985
|
* policy change — the same numeric limit buys a different amount of real
|
|
1782
1986
|
* spend, so the bucket must reset. This replaces the old `reset()` call
|
|
@@ -1787,9 +1991,310 @@ declare class SpendLimiter {
|
|
|
1787
1991
|
limit: number;
|
|
1788
1992
|
currency: string;
|
|
1789
1993
|
windowMs: number;
|
|
1994
|
+
ruleIndex?: number;
|
|
1790
1995
|
}>): void;
|
|
1791
1996
|
/** Stop the cleanup timer and mark as closed. */
|
|
1997
|
+
/**
|
|
1998
|
+
* Invoke the warning callback without letting a subscriber throw into the
|
|
1999
|
+
* limiter's caller: a warning fires after state has already mutated, and a
|
|
2000
|
+
* governed call must not be blocked (or double-charged on retry) by an
|
|
2001
|
+
* observability bug.
|
|
2002
|
+
*/
|
|
2003
|
+
private safeWarn;
|
|
2004
|
+
close(): void;
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
/** Everything the engine needs about one tool call to resolve its charges. */
|
|
2008
|
+
interface BudgetChargeContext {
|
|
2009
|
+
readonly toolName: string;
|
|
2010
|
+
readonly toolArguments: Record<string, unknown> | undefined;
|
|
2011
|
+
readonly sessionId: string | null;
|
|
2012
|
+
/** Adapter-supplied sender id (sideband only); null on the MCP path. */
|
|
2013
|
+
readonly senderId: string | null;
|
|
2014
|
+
}
|
|
2015
|
+
/** One budget's share of a call: which bucket, how much. */
|
|
2016
|
+
interface BudgetCharge {
|
|
2017
|
+
readonly budget: CompiledBudget;
|
|
2018
|
+
readonly bucketKey: string;
|
|
2019
|
+
readonly amount: number;
|
|
2020
|
+
/**
|
|
2021
|
+
* The budget's config generation at peek time. A tuple-changing reload
|
|
2022
|
+
* bumps the generation and resets the pot; a charge frozen before the bump
|
|
2023
|
+
* (sideband /evaluate → /audit, or an MCP approval wait) is stale and MUST
|
|
2024
|
+
* NOT repopulate the new pot with old-config spend — recordAll skips it.
|
|
2025
|
+
*/
|
|
2026
|
+
readonly generation: number;
|
|
2027
|
+
}
|
|
2028
|
+
/** A budget whose contributor matched but whose amount was unusable. */
|
|
2029
|
+
interface BudgetChargeFailure {
|
|
2030
|
+
readonly budget: CompiledBudget;
|
|
2031
|
+
readonly bucketKey: string;
|
|
2032
|
+
readonly reason: 'invalid_amount';
|
|
2033
|
+
/** REAL accrued spend on the bucket the charge would have hit. */
|
|
2034
|
+
readonly spent: number;
|
|
2035
|
+
readonly remaining: number;
|
|
2036
|
+
/** Epoch ms when the oldest entry ages out (duration); null for session pots. */
|
|
2037
|
+
readonly resetAtMs: number | null;
|
|
2038
|
+
}
|
|
2039
|
+
/** Snapshot of one budget's state relative to a charge. */
|
|
2040
|
+
interface BudgetPeekEntry {
|
|
2041
|
+
readonly budget: CompiledBudget;
|
|
2042
|
+
readonly bucketKey: string;
|
|
2043
|
+
readonly amount: number;
|
|
2044
|
+
readonly allowed: boolean;
|
|
2045
|
+
/** Spend accrued before this charge. */
|
|
2046
|
+
readonly spent: number;
|
|
2047
|
+
/** Headroom before this charge: max(0, limit - spent). */
|
|
2048
|
+
readonly remaining: number;
|
|
2049
|
+
/** Epoch ms when the oldest entry ages out (duration); null for session pots. */
|
|
2050
|
+
readonly resetAtMs: number | null;
|
|
2051
|
+
/**
|
|
2052
|
+
* Set on recordAll snapshots for charges frozen before a tuple-changing
|
|
2053
|
+
* reload: the executed spend was ledgered under its evaluate-time
|
|
2054
|
+
* generation, but the reset pot was not touched.
|
|
2055
|
+
*/
|
|
2056
|
+
readonly stale?: true;
|
|
2057
|
+
}
|
|
2058
|
+
/** Metadata recorded with every committed charge of one call. */
|
|
2059
|
+
interface BudgetCommitMeta {
|
|
2060
|
+
/** Default kind for every charge of the call. */
|
|
2061
|
+
readonly kind: 'spend' | 'approved_overage';
|
|
2062
|
+
/**
|
|
2063
|
+
* Per-budget overrides by budget name (break-glass): one approved call can
|
|
2064
|
+
* mix kinds — breached budgets commit as `approved_overage` while
|
|
2065
|
+
* unbreached ones stay `spend` — and all rows must still land in ONE
|
|
2066
|
+
* ledger transaction, so the split is expressed here, not via two calls.
|
|
2067
|
+
*/
|
|
2068
|
+
readonly kinds?: ReadonlyMap<string, 'spend' | 'approved_overage'>;
|
|
2069
|
+
readonly auditRecordId: string;
|
|
2070
|
+
readonly origin: string;
|
|
2071
|
+
readonly toolName: string;
|
|
2072
|
+
readonly timestampIso: string;
|
|
2073
|
+
}
|
|
2074
|
+
/**
|
|
2075
|
+
* One durable ledger row. DTO: snake_case, matching the `budget_events`
|
|
2076
|
+
* table columns the persistence layer writes.
|
|
2077
|
+
*/
|
|
2078
|
+
interface BudgetLedgerRow {
|
|
2079
|
+
readonly budget_name: string;
|
|
2080
|
+
readonly bucket_key: string;
|
|
2081
|
+
readonly kind: 'spend' | 'approved_overage';
|
|
2082
|
+
readonly amount: number;
|
|
2083
|
+
readonly currency: string;
|
|
2084
|
+
readonly tool_name: string;
|
|
2085
|
+
readonly origin: string;
|
|
2086
|
+
readonly audit_record_id: string;
|
|
2087
|
+
readonly timestamp: string;
|
|
2088
|
+
readonly timestamp_ms: number;
|
|
2089
|
+
/**
|
|
2090
|
+
* The charge's config generation at evaluate time. Rows from a stale
|
|
2091
|
+
* generation are historical accounting for money that really moved; live
|
|
2092
|
+
* replay only ever reads the current generation (the epoch of PR 2).
|
|
2093
|
+
*/
|
|
2094
|
+
readonly generation: number;
|
|
2095
|
+
}
|
|
2096
|
+
/**
|
|
2097
|
+
* Durable sink for committed charges. `commitAll` MUST be transactional:
|
|
2098
|
+
* either every row of the batch persists or none does (a throw means none).
|
|
2099
|
+
* The in-memory default is a no-op; the SQLite ledger implements this.
|
|
2100
|
+
*/
|
|
2101
|
+
interface BudgetLedgerSink {
|
|
2102
|
+
commitAll(rows: readonly BudgetLedgerRow[]): void;
|
|
2103
|
+
}
|
|
2104
|
+
/** Payload for the per-charge commit callback (dashboard event bus). */
|
|
2105
|
+
interface BudgetCommitEvent {
|
|
2106
|
+
readonly name: string;
|
|
2107
|
+
readonly bucket_key: string;
|
|
2108
|
+
readonly kind: 'spend' | 'approved_overage';
|
|
2109
|
+
readonly amount: number;
|
|
2110
|
+
readonly spent: number;
|
|
2111
|
+
readonly remaining: number;
|
|
2112
|
+
readonly limit: number;
|
|
2113
|
+
readonly currency: string;
|
|
2114
|
+
readonly utilization: number;
|
|
2115
|
+
}
|
|
2116
|
+
/**
|
|
2117
|
+
* Payload for the per-budget breach callback (dashboard event bus). Fired
|
|
2118
|
+
* via {@link BudgetEngine.reportBreaches} by the DOORS at the moment a peek
|
|
2119
|
+
* actually denies a call or raises the composite break-glass ticket — never
|
|
2120
|
+
* by `peekAll` itself, which is pure and also runs for dry-run.
|
|
2121
|
+
*/
|
|
2122
|
+
interface BudgetBreachEvent {
|
|
2123
|
+
readonly name: string;
|
|
2124
|
+
readonly bucket_key: string;
|
|
2125
|
+
/** The budget's configured posture, even when the outcome was a deny. */
|
|
2126
|
+
readonly on_exceed: 'deny' | 'require_approval';
|
|
2127
|
+
readonly attempted_amount: number;
|
|
2128
|
+
readonly spent: number;
|
|
2129
|
+
readonly limit: number;
|
|
2130
|
+
readonly currency: string;
|
|
2131
|
+
}
|
|
2132
|
+
/** Wire-ready bucket state for `GET /api/budgets` (snake_case DTO). */
|
|
2133
|
+
interface BudgetBucketState {
|
|
2134
|
+
readonly bucket_key: string;
|
|
2135
|
+
readonly spent: number;
|
|
2136
|
+
readonly remaining: number;
|
|
2137
|
+
readonly reset_at_ms: number | null;
|
|
2138
|
+
readonly last_activity_ms: number;
|
|
2139
|
+
}
|
|
2140
|
+
/** Wire-ready budget state for `GET /api/budgets` (snake_case DTO). */
|
|
2141
|
+
interface BudgetState {
|
|
2142
|
+
readonly name: string;
|
|
2143
|
+
readonly limit: number;
|
|
2144
|
+
readonly currency: string;
|
|
2145
|
+
readonly window: string;
|
|
2146
|
+
readonly key: 'global' | 'session' | 'sender_id';
|
|
2147
|
+
readonly on_exceed: 'deny' | 'require_approval';
|
|
2148
|
+
readonly buckets: readonly BudgetBucketState[];
|
|
2149
|
+
}
|
|
2150
|
+
interface BudgetEngineOptions {
|
|
2151
|
+
readonly budgets?: readonly CompiledBudget[];
|
|
2152
|
+
/** Clock function for testable time. Defaults to `Date.now`. */
|
|
2153
|
+
readonly now?: () => number;
|
|
2154
|
+
/** Interval (ms) between GC sweeps. 0 disables the timer. Default: 60000. */
|
|
2155
|
+
readonly cleanupIntervalMs?: number;
|
|
2156
|
+
/** Durable sink; defaults to a no-op (state resets on restart). */
|
|
2157
|
+
readonly ledger?: BudgetLedgerSink;
|
|
2158
|
+
/** Fired once per committed charge with post-record numbers. */
|
|
2159
|
+
readonly onCommit?: (event: BudgetCommitEvent) => void;
|
|
2160
|
+
/** Fired once per breached budget when a door denies or raises a ticket. */
|
|
2161
|
+
readonly onBreach?: (event: BudgetBreachEvent) => void;
|
|
2162
|
+
}
|
|
2163
|
+
declare class BudgetEngine {
|
|
2164
|
+
private budgets;
|
|
2165
|
+
/** budget name → bucket key → bucket. */
|
|
2166
|
+
private readonly state;
|
|
2167
|
+
/** budget name → config generation; bumped whenever the pot resets. */
|
|
2168
|
+
private readonly generations;
|
|
2169
|
+
private readonly now;
|
|
2170
|
+
private readonly ledger;
|
|
2171
|
+
/** The sink again, when it carries the full persistence contract. */
|
|
2172
|
+
private readonly persistence;
|
|
2173
|
+
private readonly onCommit;
|
|
2174
|
+
private readonly onBreach;
|
|
2175
|
+
private timer;
|
|
2176
|
+
private closed;
|
|
2177
|
+
private hydrated;
|
|
2178
|
+
constructor(options?: BudgetEngineOptions);
|
|
2179
|
+
/**
|
|
2180
|
+
* Resolve which budgets a call feeds and how much it charges each.
|
|
2181
|
+
*
|
|
2182
|
+
* A budget participates when any contributor glob matches the tool name;
|
|
2183
|
+
* the FIRST matching contributor (config order) supplies the amount field.
|
|
2184
|
+
* A missing, non-numeric, negative, or non-finite amount fails closed as a
|
|
2185
|
+
* `failures` entry — the caller must deny the call.
|
|
2186
|
+
*/
|
|
2187
|
+
resolveCharges(ctx: BudgetChargeContext): {
|
|
2188
|
+
charges: BudgetCharge[];
|
|
2189
|
+
failures: BudgetChargeFailure[];
|
|
2190
|
+
};
|
|
2191
|
+
/** Check every charge without mutating. All-or-nothing: one deny flips `allowed`. */
|
|
2192
|
+
peekAll(charges: readonly BudgetCharge[]): {
|
|
2193
|
+
allowed: boolean;
|
|
2194
|
+
entries: BudgetPeekEntry[];
|
|
2195
|
+
};
|
|
2196
|
+
/**
|
|
2197
|
+
* Commit every charge of one call: ledger first (one atomic batch), then
|
|
2198
|
+
* in-memory state, then the commit events. A sink throw propagates and
|
|
2199
|
+
* leaves ALL in-memory buckets untouched — no partial commit, ever.
|
|
2200
|
+
* Recording is unconditional past the sink (an approved overage
|
|
2201
|
+
* legitimately pushes a bucket past its limit).
|
|
2202
|
+
*/
|
|
2203
|
+
recordAll(charges: readonly BudgetCharge[], meta: BudgetCommitMeta): BudgetPeekEntry[];
|
|
2204
|
+
/**
|
|
2205
|
+
* Fire one `onBreach` event per breached entry. Called by the doors at the
|
|
2206
|
+
* moment a peek outcome actually denies the call or raises the composite
|
|
2207
|
+
* break-glass ticket (never for dry-run peeks, never for invalid-amount
|
|
2208
|
+
* failures — those are input errors, not breaches). Subscriber throws are
|
|
2209
|
+
* isolated: a dashboard bug must never affect a gate outcome.
|
|
2210
|
+
*/
|
|
2211
|
+
reportBreaches(entries: readonly BudgetPeekEntry[]): void;
|
|
2212
|
+
/**
|
|
2213
|
+
* Rebuild in-memory state from the ledger. Call once at startup, after
|
|
2214
|
+
* construction and before serving traffic; a no-op when the configured
|
|
2215
|
+
* sink does not carry the persistence contract (in-memory mode).
|
|
2216
|
+
*
|
|
2217
|
+
* Per configured budget, `budget_meta` decides:
|
|
2218
|
+
* - no row → first boot for this name: mint epoch 1, nothing to replay;
|
|
2219
|
+
* - a different `{limit, currency, window, key}` tuple → the config
|
|
2220
|
+
* changed while down: bump the epoch, replay nothing (the same reset a
|
|
2221
|
+
* live tuple-changing reload performs, extended across restarts). Old
|
|
2222
|
+
* rows keep their epoch — history stays queryable, replay ignores it;
|
|
2223
|
+
* - a matching tuple → replay at the meta epoch: duration windows rebuild
|
|
2224
|
+
* entry lists from a window lookback (bit-equivalent to never having
|
|
2225
|
+
* restarted), session windows rebuild still-live pots (idle-TTL bound)
|
|
2226
|
+
* from their post-GC-watermark lifetime sums.
|
|
2227
|
+
*
|
|
2228
|
+
* Meta writes here propagate failures: a ledger that cannot record epochs
|
|
2229
|
+
* at startup must fail the boot loudly, the same posture as the audit
|
|
2230
|
+
* store's schema assertion.
|
|
2231
|
+
*/
|
|
2232
|
+
hydrate(): void;
|
|
2233
|
+
/**
|
|
2234
|
+
* Swap budget configs on hot-reload. Identity is the NAME: removed names
|
|
2235
|
+
* drop their live buckets; a changed `{limit, currency, window, key}` tuple
|
|
2236
|
+
* resets the budget's buckets (a different pool or scope structure);
|
|
2237
|
+
* everything else — contributors, on_exceed — applies to the accrued state
|
|
2238
|
+
* as-is, because those edits do not change what was already spent.
|
|
2239
|
+
*
|
|
2240
|
+
* Persist-before-swap: every epoch this reload mints lands in
|
|
2241
|
+
* `budget_meta` in ONE transaction BEFORE any memory changes. A throw
|
|
2242
|
+
* rejects the whole reload — the caller keeps the previous config — so
|
|
2243
|
+
* disk and memory can never diverge; a failed reload simply never
|
|
2244
|
+
* happened, and no later restart can misread it. (A swallow-and-continue
|
|
2245
|
+
* posture here would let an A→B reload with a failed flush resurrect the
|
|
2246
|
+
* retired A pot after a revert-and-restart.)
|
|
2247
|
+
*
|
|
2248
|
+
* Removed names mint too: an in-flight charge frozen before the removal
|
|
2249
|
+
* must go stale, or its commit would recreate hidden bucket state for a
|
|
2250
|
+
* budget that no longer exists — and without the on-disk tombstone, a
|
|
2251
|
+
* restart with the budget back in the config would resurrect the
|
|
2252
|
+
* pre-removal pot that the removal had reset. Generations for removed
|
|
2253
|
+
* names are kept (not deleted) so a later re-add keeps counting up.
|
|
2254
|
+
*
|
|
2255
|
+
* @throws When the epoch flush fails; the engine is unchanged.
|
|
2256
|
+
*/
|
|
2257
|
+
reconcile(next: readonly CompiledBudget[]): void;
|
|
2258
|
+
/** Sweep: collect idle session pots, evict expired duration entries. */
|
|
2259
|
+
gc(): void;
|
|
2260
|
+
/**
|
|
2261
|
+
* Wire-ready state for `GET /api/budgets`. Configured budgets appear even
|
|
2262
|
+
* with zero live buckets, so the dashboard shows every pot at headroom.
|
|
2263
|
+
*/
|
|
2264
|
+
listStates(): BudgetState[];
|
|
2265
|
+
/** Whether any budget holds a live bucket under `key` (cardinality probes). */
|
|
2266
|
+
hasBucket(key: string): boolean;
|
|
1792
2267
|
close(): void;
|
|
2268
|
+
/**
|
|
2269
|
+
* The next epoch for a name: one past the highest that memory, the meta
|
|
2270
|
+
* row, or the rows themselves have seen. Pure — the caller applies it to
|
|
2271
|
+
* `generations` only after the mint is durable. The meta consult matters
|
|
2272
|
+
* for names this process has no memory of (a hot-reload re-add after a
|
|
2273
|
+
* restart); the rows consult is a backstop against historical divergence
|
|
2274
|
+
* (rows at an epoch no meta row records) — minting from memory or meta
|
|
2275
|
+
* alone could collide into an epoch that already has rows and replay them
|
|
2276
|
+
* into a different pot.
|
|
2277
|
+
*/
|
|
2278
|
+
private nextEpoch;
|
|
2279
|
+
/**
|
|
2280
|
+
* The key format is part of the ON-DISK contract: hydrate rebuilds buckets
|
|
2281
|
+
* from `budget_events.bucket_key` verbatim, so renaming any segment here
|
|
2282
|
+
* would strand every persisted bucket of an unchanged tuple as an
|
|
2283
|
+
* unreachable ghost (displayed, never charged). Changing the format
|
|
2284
|
+
* requires folding a format version into the epoch decision.
|
|
2285
|
+
*/
|
|
2286
|
+
private bucketKey;
|
|
2287
|
+
private bucketFor;
|
|
2288
|
+
private evictExpired;
|
|
2289
|
+
/**
|
|
2290
|
+
* Fetch a bucket for reading, evicting expired duration entries first and
|
|
2291
|
+
* pruning the bucket if nothing is left. Reads must never see (or keep
|
|
2292
|
+
* alive, via `hasBucket`-driven capacity slots) state the window has
|
|
2293
|
+
* already expired — expiry is lazy on read, not just on the sweep timer.
|
|
2294
|
+
*/
|
|
2295
|
+
private liveBucket;
|
|
2296
|
+
private spentOf;
|
|
2297
|
+
private snapshot;
|
|
1793
2298
|
}
|
|
1794
2299
|
|
|
1795
2300
|
/** Options for constructing a GovernedForwarder. */
|
|
@@ -1806,6 +2311,8 @@ interface GovernedForwarderOptions {
|
|
|
1806
2311
|
rateLimiter?: RateLimiter;
|
|
1807
2312
|
/** Spend limiter for handling spend_limit decisions. */
|
|
1808
2313
|
spendLimiter?: SpendLimiter;
|
|
2314
|
+
/** Budget engine for named cross-tool budgets (issue #14). */
|
|
2315
|
+
budgetEngine?: BudgetEngine;
|
|
1809
2316
|
}
|
|
1810
2317
|
/** Result of attempting to prime the tool annotation cache. */
|
|
1811
2318
|
interface AnnotationCachePrimeResult {
|
|
@@ -1833,6 +2340,7 @@ declare class GovernedForwarder implements McpForwarder {
|
|
|
1833
2340
|
private readonly approvalRouter;
|
|
1834
2341
|
private readonly rateLimiter;
|
|
1835
2342
|
private readonly spendLimiter;
|
|
2343
|
+
private readonly budgetEngine;
|
|
1836
2344
|
private readonly annotationCache;
|
|
1837
2345
|
private agentKeyWarned;
|
|
1838
2346
|
private senderKeyWarned;
|
|
@@ -1876,6 +2384,51 @@ declare class GovernedForwarder implements McpForwarder {
|
|
|
1876
2384
|
/** Write an immediate audit record for a drift event (not a tool call). */
|
|
1877
2385
|
private writeDriftAuditRecord;
|
|
1878
2386
|
private handleToolsCall;
|
|
2387
|
+
/**
|
|
2388
|
+
* Await the composite break-glass ticket for a budget overage (issue #14).
|
|
2389
|
+
*
|
|
2390
|
+
* The ticket is routed by the BUDGET's approval config (first breached
|
|
2391
|
+
* budget in config order), never the matched rule's. Deviation from rule
|
|
2392
|
+
* approvals, by design: timeout ALWAYS fails closed — `default_on_timeout:
|
|
2393
|
+
* allow` would forward an unapproved overage, and recording it as
|
|
2394
|
+
* `approved_overage` would be a lie while not recording it would corrupt
|
|
2395
|
+
* the pot. Money gates do not fail open.
|
|
2396
|
+
*/
|
|
2397
|
+
private handleBudgetApproval;
|
|
2398
|
+
/** writeAuditRecord, isolated: an audit-writer bug must not reject the response. */
|
|
2399
|
+
private writeAuditRecordSafely;
|
|
2400
|
+
/**
|
|
2401
|
+
* Everything the non-approval action branches decide, minus the forward
|
|
2402
|
+
* itself. Deliberately SYNCHRONOUS: the caller must reach the phase-3
|
|
2403
|
+
* commits without yielding to the microtask queue, or concurrent calls
|
|
2404
|
+
* could double-spend a peeked limiter slot. The approval branch (the only
|
|
2405
|
+
* one that genuinely waits) is dispatched by the caller directly.
|
|
2406
|
+
*/
|
|
2407
|
+
private resolveActionGate;
|
|
2408
|
+
/**
|
|
2409
|
+
* Phase 2: check every budget the call feeds, all-or-nothing (issue #14).
|
|
2410
|
+
*
|
|
2411
|
+
* Any `on_exceed: deny` breach (or invalid amount) denies and records
|
|
2412
|
+
* NOTHING on any budget — rejected calls never consume budget anywhere.
|
|
2413
|
+
* Breaches that are all `on_exceed: require_approval` yield the `approval`
|
|
2414
|
+
* variant: one composite break-glass ticket per call, and only an explicit
|
|
2415
|
+
* approval commits (breached budgets as `approved_overage`). On proceed,
|
|
2416
|
+
* the returned `commit` records every charge together (ledger rows first,
|
|
2417
|
+
* atomically, referencing the pre-generated audit id).
|
|
2418
|
+
*/
|
|
2419
|
+
private gateBudgets;
|
|
2420
|
+
/**
|
|
2421
|
+
* Reject a `tools/call` that carries no usable tool name and record it.
|
|
2422
|
+
*
|
|
2423
|
+
* The rejection is its own audit shape, not a governed decision: no rule was
|
|
2424
|
+
* evaluated, so it is written directly (like {@link writeDriftAuditRecord})
|
|
2425
|
+
* rather than threaded through {@link writeAuditRecord}, whose
|
|
2426
|
+
* `PolicyDecision.action` union has no `rejected` member and whose
|
|
2427
|
+
* forwarded-upstream logic does not apply. The raw `params` are preserved in
|
|
2428
|
+
* `tool_input` so an investigator can see exactly what a lenient upstream
|
|
2429
|
+
* could have keyed off.
|
|
2430
|
+
*/
|
|
2431
|
+
private rejectNamelessToolsCall;
|
|
1879
2432
|
private handleApproval;
|
|
1880
2433
|
private handleRateLimit;
|
|
1881
2434
|
private handleSpendLimit;
|
|
@@ -1886,8 +2439,12 @@ declare class GovernedForwarder implements McpForwarder {
|
|
|
1886
2439
|
private handleDryRun;
|
|
1887
2440
|
/** Construct a limit bucket key based on the configured key type. */
|
|
1888
2441
|
private buildLimitKey;
|
|
1889
|
-
/**
|
|
1890
|
-
|
|
2442
|
+
/**
|
|
2443
|
+
* Construct a spend bucket key via the shared {@link spendBucketKey}
|
|
2444
|
+
* composer — see its doc for why spend buckets are rule-discriminated.
|
|
2445
|
+
* Rate buckets keep the undiscriminated keys.
|
|
2446
|
+
*/
|
|
2447
|
+
private buildSpendLimitKey;
|
|
1891
2448
|
private writeAuditRecord;
|
|
1892
2449
|
private makeDriftBlockResult;
|
|
1893
2450
|
private makeDenyResult;
|
|
@@ -1899,8 +2456,48 @@ declare class GovernedForwarder implements McpForwarder {
|
|
|
1899
2456
|
private makeClientDisconnectedBlockResult;
|
|
1900
2457
|
}
|
|
1901
2458
|
|
|
2459
|
+
/** A budget failed to compile (invalid contributor glob). */
|
|
2460
|
+
declare class BudgetParseError extends Error {
|
|
2461
|
+
readonly budgetName: string;
|
|
2462
|
+
constructor(message: string, budgetName: string);
|
|
2463
|
+
}
|
|
2464
|
+
/**
|
|
2465
|
+
* Compile validated budget configs into engine-ready form.
|
|
2466
|
+
*
|
|
2467
|
+
* Contributor globs use the same picomatch engine as `match.tool` so a
|
|
2468
|
+
* pattern behaves identically whether it gates a rule or feeds a budget.
|
|
2469
|
+
*
|
|
2470
|
+
* @throws {BudgetParseError} On an invalid contributor glob.
|
|
2471
|
+
*/
|
|
2472
|
+
declare function compileBudgets(budgets: BudgetsConfig): CompiledBudget[];
|
|
2473
|
+
|
|
2474
|
+
/**
|
|
2475
|
+
* One `budget_events` row as listed by `GET /api/budgets/:name/events` —
|
|
2476
|
+
* the table columns minus `epoch` (internal replay bookkeeping), snake_case
|
|
2477
|
+
* verbatim. `budget_name` stays in the row so a page is self-describing.
|
|
2478
|
+
*/
|
|
2479
|
+
interface BudgetEventRecord {
|
|
2480
|
+
readonly id: string;
|
|
2481
|
+
readonly budget_name: string;
|
|
2482
|
+
readonly bucket_key: string;
|
|
2483
|
+
readonly kind: 'spend' | 'approved_overage';
|
|
2484
|
+
readonly amount: number;
|
|
2485
|
+
readonly currency: string;
|
|
2486
|
+
readonly tool_name: string;
|
|
2487
|
+
readonly origin: string;
|
|
2488
|
+
readonly audit_record_id: string | null;
|
|
2489
|
+
readonly timestamp: string;
|
|
2490
|
+
readonly timestamp_ms: number;
|
|
2491
|
+
readonly created_at: string;
|
|
2492
|
+
}
|
|
2493
|
+
/** One page of a budget's event history plus the unpaginated total. */
|
|
2494
|
+
interface BudgetEventsPage {
|
|
2495
|
+
readonly events: readonly BudgetEventRecord[];
|
|
2496
|
+
readonly total: number;
|
|
2497
|
+
}
|
|
2498
|
+
|
|
1902
2499
|
/** The outcome vocabulary adapters branch on — never internal rule actions. */
|
|
1903
|
-
type WireDecision = 'allow' | 'deny' | 'require_approval' | 'rate_limited' | 'spend_limited' | 'dry_run';
|
|
2500
|
+
type WireDecision = 'allow' | 'deny' | 'require_approval' | 'rate_limited' | 'spend_limited' | 'budget_exceeded' | 'dry_run';
|
|
1904
2501
|
/** Tool definition carried by /evaluate (optional; enables the drift guard). */
|
|
1905
2502
|
interface WireToolDefinition {
|
|
1906
2503
|
readonly name: string;
|
|
@@ -1962,6 +2559,17 @@ interface ServiceResult {
|
|
|
1962
2559
|
readonly status: number;
|
|
1963
2560
|
readonly body: Record<string, unknown>;
|
|
1964
2561
|
}
|
|
2562
|
+
/**
|
|
2563
|
+
* Per-origin adapter liveness, wire-ready for the dashboard's
|
|
2564
|
+
* `GET /api/adapters` (issue #126). ISO-8601 timestamps; `adapter_version`
|
|
2565
|
+
* stays null until an /evaluate supplies one.
|
|
2566
|
+
*/
|
|
2567
|
+
interface AdapterLivenessEntry {
|
|
2568
|
+
readonly origin: string;
|
|
2569
|
+
readonly adapter_version: string | null;
|
|
2570
|
+
readonly first_seen: string;
|
|
2571
|
+
readonly last_seen: string;
|
|
2572
|
+
}
|
|
1965
2573
|
interface GovernanceServiceOptions {
|
|
1966
2574
|
readonly policy: CompiledPolicy;
|
|
1967
2575
|
readonly environment?: string;
|
|
@@ -1969,6 +2577,8 @@ interface GovernanceServiceOptions {
|
|
|
1969
2577
|
readonly approvalRouter?: ApprovalRouter;
|
|
1970
2578
|
readonly rateLimiter?: RateLimiter;
|
|
1971
2579
|
readonly spendLimiter?: SpendLimiter;
|
|
2580
|
+
/** Budget engine for named cross-tool budgets (issue #14). */
|
|
2581
|
+
readonly budgetEngine?: BudgetEngine;
|
|
1972
2582
|
readonly auditWriter?: AuditWriter;
|
|
1973
2583
|
/** Default approval timeout (ms) when a rule sets none. */
|
|
1974
2584
|
readonly approvalTimeoutMs?: number;
|
|
@@ -1992,6 +2602,7 @@ declare class GovernanceService {
|
|
|
1992
2602
|
private readonly approvalRouter;
|
|
1993
2603
|
private readonly rateLimiter;
|
|
1994
2604
|
private readonly spendLimiter;
|
|
2605
|
+
private readonly budgetEngine;
|
|
1995
2606
|
private readonly auditWriter;
|
|
1996
2607
|
private readonly approvalTimeoutMs;
|
|
1997
2608
|
private readonly ttlMs;
|
|
@@ -2001,6 +2612,13 @@ declare class GovernanceService {
|
|
|
2001
2612
|
private readonly maxSenderKeys;
|
|
2002
2613
|
/** Distinct sender_id limit keys with live state (reservation registry, issue #13). */
|
|
2003
2614
|
private readonly senderKeys;
|
|
2615
|
+
/**
|
|
2616
|
+
* Per-origin adapter liveness (issue #126). New origins are inserted ONLY on
|
|
2617
|
+
* the /evaluate path, which sits behind the MAX_ORIGINS cache gate — every
|
|
2618
|
+
* other path updates existing entries and skips unknown origins, so the
|
|
2619
|
+
* registry shares the origin cap instead of adding a second growth vector.
|
|
2620
|
+
*/
|
|
2621
|
+
private readonly adapters;
|
|
2004
2622
|
private readonly pending;
|
|
2005
2623
|
private readonly tombstones;
|
|
2006
2624
|
private readonly caches;
|
|
@@ -2031,6 +2649,19 @@ declare class GovernanceService {
|
|
|
2031
2649
|
installScan(req: InstallScanInput): ServiceResult;
|
|
2032
2650
|
/** First-match-wins evaluation of the compiled install policy (issue #13). */
|
|
2033
2651
|
private evaluateInstall;
|
|
2652
|
+
/** Wire-ready liveness entries, most recently seen first. */
|
|
2653
|
+
listAdapters(): AdapterLivenessEntry[];
|
|
2654
|
+
/** Insert-or-refresh on the /evaluate path (the only insert site). */
|
|
2655
|
+
private recordAdapterSeen;
|
|
2656
|
+
/** Refresh-only for paths without an origin budget gate (install-scan, audit). */
|
|
2657
|
+
private touchAdapter;
|
|
2658
|
+
/**
|
|
2659
|
+
* Log a version sighting/change, capped per origin per boot. Both origin and
|
|
2660
|
+
* version are caller-controlled free text, so both are JSON-escaped — a
|
|
2661
|
+
* newline or control character must not be able to forge extra log lines
|
|
2662
|
+
* (the route's origin regex does not protect direct embedders).
|
|
2663
|
+
*/
|
|
2664
|
+
private logVersionEvent;
|
|
2034
2665
|
resolveApproval(ticketId: string, req: ResolveApprovalInput): ServiceResult;
|
|
2035
2666
|
sweep(): void;
|
|
2036
2667
|
/**
|
|
@@ -2057,10 +2688,12 @@ declare class GovernanceService {
|
|
|
2057
2688
|
private cacheFor;
|
|
2058
2689
|
private discardPending;
|
|
2059
2690
|
private getTicketStatus;
|
|
2691
|
+
/** Latch the entry's ticket resolution while the ticket still exists. */
|
|
2692
|
+
private snapshotTicketResolution;
|
|
2060
2693
|
private planRate;
|
|
2061
2694
|
private planSpend;
|
|
2062
|
-
/** Commit
|
|
2063
|
-
private
|
|
2695
|
+
/** Commit every plan of one call at /audit time; returns the chain blocks. */
|
|
2696
|
+
private commitPlans;
|
|
2064
2697
|
private writeAudit;
|
|
2065
2698
|
private assertApprovalRouter;
|
|
2066
2699
|
}
|
|
@@ -2292,6 +2925,19 @@ interface ApprovalNotificationFailedEvent {
|
|
|
2292
2925
|
readonly phase: 'initial' | 'escalation';
|
|
2293
2926
|
readonly error: string;
|
|
2294
2927
|
}
|
|
2928
|
+
/**
|
|
2929
|
+
* Payload for a budget_update event: one committed charge with post-record
|
|
2930
|
+
* numbers (issue #14). The engine's commit-event DTO is already snake_case
|
|
2931
|
+
* wire shape, so it is emitted verbatim. `utilization` drives dashboard
|
|
2932
|
+
* thresholds — there is no separate budget warning event.
|
|
2933
|
+
*/
|
|
2934
|
+
type BudgetUpdateEvent = BudgetCommitEvent;
|
|
2935
|
+
/**
|
|
2936
|
+
* Payload for a budget_breached event: a peek denied the call or raised the
|
|
2937
|
+
* composite break-glass ticket (issue #14). Emitted verbatim from the
|
|
2938
|
+
* engine's breach-event DTO.
|
|
2939
|
+
*/
|
|
2940
|
+
type BudgetBreachedEvent = BudgetBreachEvent;
|
|
2295
2941
|
/** Map of event type names to their payload types. */
|
|
2296
2942
|
interface DashboardEvents {
|
|
2297
2943
|
action: ActionEvent;
|
|
@@ -2299,6 +2945,8 @@ interface DashboardEvents {
|
|
|
2299
2945
|
approval_resolved: ApprovalResolvedEvent;
|
|
2300
2946
|
limit_warning: LimitWarningEvent;
|
|
2301
2947
|
approval_notification_failed: ApprovalNotificationFailedEvent;
|
|
2948
|
+
budget_update: BudgetUpdateEvent;
|
|
2949
|
+
budget_breached: BudgetBreachedEvent;
|
|
2302
2950
|
}
|
|
2303
2951
|
/** Union of all dashboard event type names. */
|
|
2304
2952
|
type DashboardEventType = keyof DashboardEvents;
|
|
@@ -2338,6 +2986,28 @@ interface DashboardAppDeps {
|
|
|
2338
2986
|
readonly spendLimiter: SpendLimiter;
|
|
2339
2987
|
readonly evidenceStore: EvidenceStore;
|
|
2340
2988
|
readonly eventBus: DashboardEventBus;
|
|
2989
|
+
/**
|
|
2990
|
+
* Adapter liveness source for `GET /api/adapters` (issue #126) — a narrow
|
|
2991
|
+
* view of the SDK sideband's GovernanceService. Absent when the SDK
|
|
2992
|
+
* sideband is disabled; the endpoint then serves an empty list.
|
|
2993
|
+
*/
|
|
2994
|
+
readonly adapterLiveness?: {
|
|
2995
|
+
listAdapters(): AdapterLivenessEntry[];
|
|
2996
|
+
};
|
|
2997
|
+
/**
|
|
2998
|
+
* Budget read surface for `GET /api/budgets` and
|
|
2999
|
+
* `GET /api/budgets/:name/events` (issue #14) — narrow views of the
|
|
3000
|
+
* BudgetEngine (live pot states) and BudgetLedger (spend history).
|
|
3001
|
+
* Optional on the adapterLiveness pattern: absent (direct embedders),
|
|
3002
|
+
* both endpoints serve empty lists with 200.
|
|
3003
|
+
*/
|
|
3004
|
+
readonly budgets?: {
|
|
3005
|
+
listStates(): BudgetState[];
|
|
3006
|
+
listEvents(name: string, page: {
|
|
3007
|
+
limit: number;
|
|
3008
|
+
offset: number;
|
|
3009
|
+
}): BudgetEventsPage;
|
|
3010
|
+
};
|
|
2341
3011
|
}
|
|
2342
3012
|
/** Options for the dashboard API. */
|
|
2343
3013
|
interface DashboardAppOptions {
|
|
@@ -2356,4 +3026,4 @@ interface DashboardAppOptions {
|
|
|
2356
3026
|
*/
|
|
2357
3027
|
declare function createDashboardApp(deps: DashboardAppDeps, options?: DashboardAppOptions): Hono;
|
|
2358
3028
|
|
|
2359
|
-
export { type ApprovalAppOptions, type ApprovalChannel, type ApprovalOutcome, ApprovalQueue, type ApprovalQueueOptions, ApprovalRouter, type ApprovalRouterOptions, type ApprovalStatus, type ApprovalTicket, type AuditAggregateStats, type AuditInput, type AuditListResult, type AuditPaginationOptions, type AuditQueryFilters, type AuditRecord, AuditStore, type AuditStoreOptions, type AuditTimeBucket, AuditWriter, type AuditWriterOptions, type CompilePoliciesResult, type CompiledPolicy, type CompiledPolicyRule, ConfigError, type CreateAppOptions, type DashboardAppDeps, type DashboardAppOptions, DashboardEventBus, type DashboardEventType, type DashboardEvents, type EvaluateInput, type EvidenceEntry, EvidenceStore, type EvidenceStoreOptions, GovernanceConfigError, GovernanceService, type GovernanceServiceOptions, GovernedForwarder, type GovernedForwarderOptions, type HelioConfig, type InstallScanInput, type MatchContext, type PolicyDecision, PolicyParseError, QueueChannel, type RateLimitCheckParams, type RateLimitKeyState, type RateLimitResult, RateLimiter, type RateLimiterOptions, type ResolveApprovalInput, type ServerHandle, type SessionState, type SlackActionAppOptions, SlackChannel, type SlackChannelOptions, type SpendLimitCheckParams, type SpendLimitKeyState, type SpendLimitResult, SpendLimiter, type SpendLimiterOptions, SseUpstreamForwarder, type SseUpstreamForwarderOptions, StdioForwarder, type StdioForwarderOptions, StreamableHttpForwarder, type StreamableHttpForwarderOptions, UpstreamForwarder, type UpstreamForwarderOptions, VERSION, WebhookChannel, type WebhookChannelOptions, type WireDecision, compilePolicies, createApp, createApprovalApp, createChannels, createDashboardApp, createSidebandApp, createSlackActionApp, evaluatePolicy, loadConfig, matchRule, startServer, startSidebandServer };
|
|
3029
|
+
export { type AdapterLivenessEntry, type ApprovalAppOptions, type ApprovalChannel, type ApprovalOutcome, ApprovalQueue, type ApprovalQueueOptions, ApprovalRouter, type ApprovalRouterOptions, type ApprovalStatus, type ApprovalTicket, type AuditAggregateStats, type AuditInput, type AuditListResult, type AuditPaginationOptions, type AuditQueryFilters, type AuditRecord, AuditStore, type AuditStoreOptions, type AuditTimeBucket, AuditWriter, type AuditWriterOptions, type BudgetBreachContext, type BudgetBreachEvent, type BudgetBucketState, type BudgetCommitEvent, BudgetEngine, type BudgetEventRecord, type BudgetEventsPage, type BudgetLedgerRow, type BudgetLedgerSink, BudgetParseError, type BudgetState, type CompilePoliciesResult, type CompiledBudget, type CompiledBudgetContributor, type CompiledBudgetWindow, type CompiledPolicy, type CompiledPolicyRule, ConfigError, type CreateAppOptions, type DashboardAppDeps, type DashboardAppOptions, DashboardEventBus, type DashboardEventType, type DashboardEvents, EXPORT_MAX_RECORDS, type EvaluateInput, type EvidenceEntry, EvidenceStore, type EvidenceStoreOptions, GovernanceConfigError, GovernanceService, type GovernanceServiceOptions, GovernedForwarder, type GovernedForwarderOptions, type HelioConfig, type InstallScanInput, LIST_MAX_PAGE_SIZE, type MatchContext, type PolicyDecision, PolicyParseError, QueueChannel, type RateLimitCheckParams, type RateLimitKeyState, type RateLimitResult, RateLimiter, type RateLimiterOptions, type ResolveApprovalInput, type ServerHandle, type SessionState, type SlackActionAppOptions, SlackChannel, type SlackChannelOptions, type SpendLimitCheckParams, type SpendLimitKeyState, type SpendLimitResult, SpendLimiter, type SpendLimiterOptions, SseUpstreamForwarder, type SseUpstreamForwarderOptions, StdioForwarder, type StdioForwarderOptions, StreamableHttpForwarder, type StreamableHttpForwarderOptions, UpstreamForwarder, type UpstreamForwarderOptions, VERSION, WebhookChannel, type WebhookChannelOptions, type WireDecision, compileBudgets, compilePolicies, createApp, createApprovalApp, createChannels, createDashboardApp, createSidebandApp, createSlackActionApp, evaluatePolicy, loadConfig, matchRule, startServer, startSidebandServer };
|