/** * otel-budget.ts — OpenTelemetry Budget Circuit-Breaker for AWS AgentCore * * Reads OTel span data from AgentCore-instrumented agents, applies per-agent * and per-task budget policies against accumulated spend, emits budget * enforcement decisions as OTel events, and supports circuit-breaker patterns * (auto-kill agent runs exceeding budget thresholds). * * Why this exists: AWS AgentCore Policy Controls (GA March 2026) provide * observability and guardrails but NO native per-agent/per-session spend cap * APIs. This module fills that gap by sitting between the OTel telemetry * pipeline and agentpay-mcp's existing budget enforcement. * * @module otel-budget * @since 4.2.0 */ import { z } from 'zod'; export interface AgentBudgetPolicy { /** Unique agent or session identifier */ agentId: string; /** Optional task-level identifier for fine-grained budgets */ taskId?: string; /** Maximum spend in USD for this agent/task */ maxSpendUsd: number; /** Rolling window in milliseconds (0 = lifetime budget) */ windowMs: number; /** Action when budget exceeded: 'warn' | 'block' | 'kill' */ breachAction: 'warn' | 'block' | 'kill'; /** Optional callback URL for circuit-breaker kill signal */ killCallbackUrl?: string; } export interface SpendRecord { agentId: string; taskId?: string; amountUsd: number; timestamp: number; spanId: string; traceId: string; } export interface BudgetDecision { agentId: string; taskId?: string; action: 'allow' | 'warn' | 'block' | 'kill'; accumulatedSpendUsd: number; budgetLimitUsd: number; remainingUsd: number; utilizationPct: number; reason: string; timestamp: number; } export interface OTelSpanCostAttributes { /** OTel span attribute: agentcore.agent.id */ 'agentcore.agent.id'?: string; /** OTel span attribute: agentcore.task.id */ 'agentcore.task.id'?: string; /** OTel span attribute: agentcore.cost.usd — cost incurred in this span */ 'agentcore.cost.usd'?: number; /** OTel span attribute: gen_ai.usage.input_tokens */ 'gen_ai.usage.input_tokens'?: number; /** OTel span attribute: gen_ai.usage.output_tokens */ 'gen_ai.usage.output_tokens'?: number; /** OTel span attribute: gen_ai.usage.cost */ 'gen_ai.usage.cost'?: number; /** Standard OTel trace/span IDs */ traceId?: string; spanId?: string; } /** Timeout for the circuit-breaker kill callback POST. */ export declare const KILL_CALLBACK_TIMEOUT_MS = 10000; /** * Comma-separated operator allowlist of hosts whose private-space destination is * explicitly permitted, for deployments whose circuit-breaker webhook genuinely * lives in-VPC (`orchestrator.svc.internal`, a 10/8 address, …). Empty by * default, so the private-space rule is fail-closed unless an operator opts a * specific host in. It waives only the private/loopback/link-local rule — the * http(s)-scheme and no-embedded-credentials rules always apply. */ export declare const KILL_CALLBACK_ALLOWLIST_ENV = "AGENTPAY_KILL_CALLBACK_ALLOWED_HOSTS"; /** * Generic refusal handed back to the MCP caller when a kill callback is not * delivered. Every refusal and every transport failure shares this one string * on purpose: the destination is caller-supplied, so a reason that varied with * what the server found (name resolves / does not resolve, resolves into * private space, port closed, port open but silent, HTTP error) would let an * untrusted caller enumerate internal DNS and internal services through this * tool. The specific reason goes to the server log instead. */ export declare const KILL_CALLBACK_GENERIC_FAILURE = "Kill callback was not delivered"; /** * Validate a kill-callback URL against the rules for outbound webhooks: * must parse as an absolute URL, must use the http: or https: scheme, must * not embed credentials (userinfo), and must not name a host inside the * private/loopback/link-local ranges the server itself can reach — the URL * comes from an untrusted MCP caller, so an unconstrained destination is an * SSRF against internal services. Returns a human-readable error message naming * the violated rule, or null if the URL is acceptable. * * This check is literal-only: it sees IP literals in every encoding the WHATWG * URL parser normalizes (decimal/octal/hex/short IPv4, IPv4-mapped, NAT64, 6to4, * Teredo and IPv4-compatible IPv6), but it cannot see where a DNS name points. * Names are resolved and re-checked at fire time by * {@link resolvedHostIsPrivate}; see its notes for the residual rebinding gap. * * The private-space rule (only that rule) is waived for hosts an operator has * opted in via {@link KILL_CALLBACK_ALLOWLIST_ENV}. * * Applied at configuration time (registerPolicy drops a callback URL that fails * this check, keeping the rest of the policy) and again at fire time * (invokeKillCallback) as defense in depth. It gates the callback only: it * never gates whether the spend cap itself is registered and enforced. */ export declare function validateKillCallbackUrl(raw: string): string | null; /** * Resolve a callback host and report whether any address it maps to sits in * private space. This is what closes the DNS half of the SSRF: without it a * caller-supplied name pointing at 169.254.169.254 passes the literal checks * above and gets fetched verbatim. * * Fails closed — a name that cannot be resolved is refused rather than fetched. * Skipped for hosts an operator has opted in via * {@link KILL_CALLBACK_ALLOWLIST_ENV}. * * `deadline` is the one budget for the whole callback path, shared with the * POST that follows. Without it the awaited lookup was unbounded: a resolver * that accepts the query and never answers held the caller — and, through * otel_evaluate_spend, the caller's span evaluation — open indefinitely, well * past KILL_CALLBACK_TIMEOUT_MS, because the abort signal used to be created * only for the fetch afterwards. node:dns/promises takes no signal of its own, * so the lookup is raced against the deadline instead. * * The reason string returned here is detailed on purpose — it is for the server * log. It must not be handed to the MCP caller verbatim: "resolves into private * space" vs "does not resolve" vs "resolves to nothing" would let an untrusted * caller enumerate the server's internal DNS view. invokeKillCallback replaces * it with {@link KILL_CALLBACK_GENERIC_FAILURE} before returning. * * Known residual gap — this check and the POST do separate DNS lookups, so the * connection is not pinned to the address that was validated, and a resolver * that answers with a public address here and a private one to the fetch (DNS * rebinding) still wins the race. * * This is a scope decision, not an impossibility, and it should not be read as * one. Pinning needs a connection whose DNS lookup can be overridden, and: * * - Global `fetch` cannot do it. Node does not expose undici as a builtin * (`node:undici` is not a module), so `new Agent({ connect: { lookup } })` * plus the non-standard `dispatcher` option means taking on `undici` as a * runtime dependency, which this package does not have and does not want * for one call site. * - `node:http` / `node:https` `request()` CAN do it, dependency-free: their * `lookup` option is honoured for the connect, and TLS `servername` still * defaults to the URL hostname so certificate validation is unaffected. * Pinning to the already-validated address set is the correct fix. * * The second option means replacing the fetch transport on this path outright, * so it is deliberately left to its own change rather than folded into a branch * whose contract is that no spend figure or breaker decision moves. Until then: * operators who need a hard guarantee should front the webhook with an egress * allowlist, and the tool description says so. What a successful rebind yields * the caller is one bit — whether the POST was delivered — plus response * latency; the HTTP status and the failure reason are withheld. That bit cannot * be removed without lying to operators about whether their breaker fired. */ export declare function resolvedHostIsPrivate(rawUrl: string, deadline?: AbortSignal): Promise; /** Reset all state — useful for testing */ export declare function _resetOTelBudgetState(): void; /** Outcome of {@link registerPolicy}. */ export interface PolicyRegistrationResult { /** * Present when the policy's killCallbackUrl was refused and dropped. The * policy — including its spend cap — was still registered. */ killCallbackWarning?: string; } /** * Register a budget policy for an agent or agent+task combination. * * Fail-closed on the kill-callback URL, but scoped to the callback alone: a * policy naming a destination this server must not POST to (non-http(s) scheme, * embedded credentials, private / loopback / link-local host) is stored with * that URL stripped and a warning returned, rather than being rejected. * * Refusing the whole policy would be a worse outcome than the SSRF it prevents: * the caller asked for a $N spend ceiling, and dropping the registration * silently leaves that agent's spend uncapped (evaluateSpan falls through to * "No budget policy registered", action 'allow', limit Infinity). Stripping the * URL refuses the SSRF just as completely — nothing is ever POSTed to it — while * the cap the caller actually asked for takes effect. Operators whose webhook * legitimately lives in private space can opt that host in via * {@link KILL_CALLBACK_ALLOWLIST_ENV}. */ export declare function registerPolicy(policy: AgentBudgetPolicy): PolicyRegistrationResult; /** * Process an OTel span and evaluate budget. Returns a BudgetDecision. * * This is the main entry point: feed it span attributes from an * AgentCore-instrumented agent, and it returns an enforcement decision. */ export declare function evaluateSpan(attrs: OTelSpanCostAttributes): BudgetDecision | null; /** * POST the kill decision to the policy's circuit-breaker webhook. * * Failure handling is loud but non-fatal: an invalid URL, a host that resolves * into private space, a timeout, or a network/HTTP failure is reported in the * returned object (surfaced verbatim in the * otel_evaluate_spend result) and never throws. The breaker decision is * computed before this runs and is never altered or swallowed by it — a broken * webhook cannot turn a kill into an allow. Redirects are not followed: a 3xx * is reported as a failed callback rather than re-issued against a host that * never passed validation. * * KILL_CALLBACK_TIMEOUT_MS is one deadline for the whole path, DNS resolution * included, not a per-leg timeout: a resolver that never answers cannot hold * span evaluation open past it. * * Every unsuccessful outcome — refused URL host, refused DNS answer, closed * port, open-but-silent port, HTTP error status, timeout — returns the same * {@link KILL_CALLBACK_GENERIC_FAILURE} string, and the response status is * never returned. The destination is caller-supplied, so any reason that varied * with what the server found would be a probe oracle: distinct strings for * "resolves into private space" / "does not resolve" / "connection refused" / * "timed out" / "HTTP error" are between them a working internal DNS * enumerator and port scanner. The specific reason is logged server-side. * * What remains visible to the caller is two bits, not one, plus how long the * call took: `ok` (was the POST delivered) and `attempted` (did the request * leave this process at all). `attempted` is false for exactly the two * pre-flight refusals — the literal-URL check and the DNS gate. * * On the literal-URL side that second bit carries nothing: those refusals are a * pure function of the string the caller itself supplied (bad scheme, embedded * credentials, a private address written out as a literal), so the caller learns * only what it already knew. That is also why those refusals keep their specific * message rather than collapsing to the generic one. * * On the DNS side it is a real oracle, and a deliberately accepted one: * attempted=false says "this name did not yield a usable public address from * this server". Judgement: acceptable. The three underlying states — resolves * into private space, does not resolve, resolves to nothing — still collapse * into that single value, so the primitive that actually enumerates an internal * network (does this name exist? does it point somewhere private?) stays shut. * What leaks is one bit per name, and it says something a public resolver does * not already say only where the name is split-horizon. The alternative — * reporting attempted=true for a callback that never left the process — would * tell an operator their circuit-breaker webhook fired when it never did, so * neither bit can be removed without lying about whether the breaker fired. * * Retry policy: no in-process retry. Spend evaluation must not block on * webhook health, and every subsequent over-budget span evaluation fires the * callback again, which gives natural at-least-once redelivery while the * breach persists. Callers that need stronger guarantees should act on the * returned ok/error fields. */ export declare function invokeKillCallback(policy: AgentBudgetPolicy, decision: BudgetDecision): Promise<{ attempted: boolean; ok: boolean; error?: string; } | null>; /** * Convert a BudgetDecision to OTel-compatible event attributes. * These can be emitted as span events for AgentCore dashboard visibility. */ export declare function decisionToOTelEvent(decision: BudgetDecision): Record; /** * Get recent decisions for an agent (for dashboard/audit). */ export declare function getDecisionHistory(agentId: string, limit?: number): BudgetDecision[]; /** * Get all registered policies (for introspection). */ export declare function listPolicies(): AgentBudgetPolicy[]; export declare const OTelRegisterPolicySchema: z.ZodObject<{ agentId: z.ZodString; taskId: z.ZodOptional; maxSpendUsd: z.ZodNumber; windowMs: z.ZodDefault; breachAction: z.ZodDefault>; killCallbackUrl: z.ZodOptional; }, z.core.$strip>; export type OTelRegisterPolicyInput = z.infer; export declare const otelRegisterPolicyTool: { name: string; description: string; inputSchema: { type: "object"; properties: { agentId: { type: string; description: string; }; taskId: { type: string; description: string; }; maxSpendUsd: { type: string; description: string; }; windowMs: { type: string; description: string; }; breachAction: { type: string; enum: string[]; description: string; }; killCallbackUrl: { type: string; description: string; }; }; required: string[]; }; }; export declare function handleOTelRegisterPolicy(input: OTelRegisterPolicyInput): Promise<{ content: Array<{ type: 'text'; text: string; }>; isError?: boolean; }>; export declare const OTelEvaluateSpendSchema: z.ZodObject<{ agentId: z.ZodString; taskId: z.ZodOptional; costUsd: z.ZodNumber; spanId: z.ZodOptional; traceId: z.ZodOptional; }, z.core.$strip>; export type OTelEvaluateSpendInput = z.infer; export declare const otelEvaluateSpendTool: { name: string; description: string; inputSchema: { type: "object"; properties: { agentId: { type: string; description: string; }; taskId: { type: string; description: string; }; costUsd: { type: string; description: string; }; spanId: { type: string; description: string; }; traceId: { type: string; description: string; }; }; required: string[]; }; }; export declare function handleOTelEvaluateSpend(input: OTelEvaluateSpendInput): Promise<{ content: Array<{ type: 'text'; text: string; }>; isError?: boolean; }>; export declare const OTelBudgetStatusSchema: z.ZodObject<{ agentId: z.ZodString; includeHistory: z.ZodDefault; historyLimit: z.ZodDefault; }, z.core.$strip>; export type OTelBudgetStatusInput = z.infer; export declare const otelBudgetStatusTool: { name: string; description: string; inputSchema: { type: "object"; properties: { agentId: { type: string; description: string; }; includeHistory: { type: string; description: string; }; historyLimit: { type: string; description: string; }; }; required: string[]; }; }; export declare function handleOTelBudgetStatus(input: OTelBudgetStatusInput): Promise<{ content: Array<{ type: 'text'; text: string; }>; isError?: boolean; }>; //# sourceMappingURL=otel-budget.d.ts.map