Skip to content

feat(agent-sdk): wire maxBudgetUsd cost ceiling so runaway runs terminate via SDK error_max_budget_usd, not 60-min wall-clock #252

Description

@chrisleekr

Finding

The Claude Agent SDK exposes a per-query() cost ceiling via the maxBudgetUsd option that terminates the agent loop with result.subtype === "error_max_budget_usd" once cumulative total_cost_usd reaches the cap. The executor never sets it. executeAgent in src/core/executor.ts builds queryOptions (src/core/executor.ts:258-345) with cwd, permissionMode, allowedTools, disallowedTools, hooks, settingSources, mcpServers, strictMcpConfig, systemPrompt, env, abortController, stderr, then conditionally appends maxTurns, model, and pathToClaudeCodeExecutable (src/core/executor.ts:338-345). maxBudgetUsd is absent from that construction and src/config.ts has no field for it. The only runtime safety net is agentTimeoutMs (src/config.ts:239, default 3_600_000 ms = 60 min), which aborts the SDK controller on wall-clock alone (src/core/executor.ts:367-370).

The acknowledgement of the missing cap is already in the codebase: buildExecutionResult carries a comment listing error_max_budget_usd as a known terminal subtype it would surface (src/core/executor.ts:543), and the chat-thread schema even reserves a CHAT_THREAD_MAX_COST_USD column for a future per-thread cap (src/db/migrations/010_chat_proposals.sql:82). Neither path is wired. The executor logs costUsd: result?.total_cost_usd per run (src/core/executor.ts:471), so the observability to set a sane default already exists, only enforcement is missing.

Wiring maxBudgetUsd adds a defense-in-depth resource gate. The runtime is bypassPermissions with the Bash tool allowed (src/core/executor.ts:260-275); a prompt-injected or simply confused Opus 4.7 loop ($5/MTok in, $25/MTok out per Anthropic Pricing) can burn five-figure-token output streams before the 60-minute wall-clock fires. Per the SDK example (max_budget_usd.py), budget checking happens after each API call, so the final cost overshoots the cap by at most one tool turn, predictable and bounded.

Diagram

flowchart TD
    Start([Job dispatched to daemon]) --> Build[buildPrompt and resolveAllowedTools]
    Build --> Query[query SDK: agent loop begins]

    Query --> Tool{Model emits tool_use?}
    Tool -- yes --> Exec[Tool executes: Bash, Read, MCP, ...]
    Exec --> Accum[SDK accumulates total_cost_usd]
    Accum --> Tool

    Tool -- no --> Done([SDKResultMessage subtype=success]):::terminal

    Accum -.->|"AGENT_TIMEOUT_MS 60 min wall-clock"| Timer{Timer fired?}:::existing
    Timer -- yes --> Abort([controller.abort then success=false]):::existing
    Timer -- no --> Tool

    Accum -.->|"NEW: maxBudgetUsd cost ceiling"| Budget{cost over cap?}:::proposed
    Budget -- yes --> Term([result.subtype is error_max_budget_usd]):::proposed
    Budget -- no --> Tool

    classDef proposed fill:#196f3d,color:#ffffff,stroke:#0e4d28
    classDef existing fill:#1f618d,color:#ffffff,stroke:#154360
    classDef terminal fill:#922b21,color:#ffffff,stroke:#641e16
Loading

Rationale

The bot runs Opus 4.7 by default (src/config.ts:697-700, model claude-opus-4-7) with bypassPermissions and Bash. A 60-minute wall-clock budget at Opus output throughput translates to a worst-case bill that operators cannot bound today: a runaway tool-loop with a hot prompt cache and verbose output can plausibly emit hundreds of thousands of output tokens per hour. Workflows like implement already cite multi-dollar cost lines in their state machines (docs/use/workflows/implement.md, state.costUsd), but there is no per-call ceiling that prevents one bad run from being twenty times more expensive than the next. The chat-thread roadmap row at src/db/migrations/010_chat_proposals.sql:82 is direct evidence that the team has already wanted a cost cap for at least one execution surface.

The fix is minimal and reuses paths the executor already exercises. The SDK's terminal-subtype handling for error_max_budget_usd is already accounted for in buildExecutionResult (src/core/executor.ts:541-555) which surfaces SDK ${subtype}: ${errors.join("; ")} as errorMessage. Pipeline finalize redacts that string from the public tracking comment (src/core/pipeline.ts:150-157), so a budget abort would not leak cost data. The executions table already persists costUsd (src/types.ts:90, migration 016_executions_tokens.sql), making post-rollout tuning straightforward: set the cap above the p99 of recent successful runs.

Operationally, a budget cap also closes a gap that no other layer covers. The forbidden-bash PreToolUse hook (src/core/hooks/forbidden-bash.ts) blocks destructive Bash, the workspace TTL sweeper (src/config.ts:248) bounds disk leakage, and AGENT_TIMEOUT_MS bounds wall-clock. Cost has no equivalent gate. That is the one resource an attacker who succeeded at prompt injection can still freely drain.

References

Internal:

  • src/core/executor.ts:258-345 (queryOptions construction, no maxBudgetUsd)
  • src/core/executor.ts:367-370 (agentTimeoutMs wall-clock-only abort)
  • src/core/executor.ts:541-555 (buildExecutionResult handles error_max_budget_usd subtype already)
  • src/config.ts:239 (agentTimeoutMs default 3,600,000 ms)
  • src/db/migrations/010_chat_proposals.sql:82 (reserved CHAT_THREAD_MAX_COST_USD cap proves the need)
  • docs/operate/configuration.md (env var matrix; new AGENT_MAX_BUDGET_USD belongs here)

External:

Suggested Next Steps

  1. Add agentMaxBudgetUsd: z.coerce.number().positive().optional() to src/config.ts (env var AGENT_MAX_BUDGET_USD), default unset to preserve current behaviour for explicit opt-in.
  2. Extend ExecuteAgentParams in src/core/executor.ts with an optional maxBudgetUsd?: number and resolve maxBudgetUsd ?? config.agentMaxBudgetUsd exactly the way maxTurns is resolved at src/core/executor.ts:338-341.
  3. Conditionally set queryOptions.maxBudgetUsd only when the resolved value is defined (mirrors the exactOptionalPropertyTypes-safe pattern used for maxTurns).
  4. Add a budget_exceeded: true field to the executor's completion log (src/core/executor.ts:467-481) when result?.subtype === "error_max_budget_usd", so an operator alert on the structured stream is one filter away.
  5. Update docs/operate/configuration.md and the observability table referencing costUsd (docs/operate/observability.md:56-63) with the new knob and the new terminal subtype, per the CLAUDE.md doc-sync rule.
  6. Add a unit test in test/core/executor.test.ts that asserts (a) maxBudgetUsd is forwarded into queryOptions only when set, and (b) errorMessage surfaces as SDK error_max_budget_usd: ... on a synthesised result.

Areas Evaluated

  • src/core/executor.ts (queryOptions, abort plumbing, result handling)
  • src/core/hooks/forbidden-bash.ts and src/utils/forbidden-bash.ts (existing runtime resource gates)
  • src/core/pipeline.ts (resource lifecycle, tracking-comment redaction, finalize policy)
  • src/config.ts (existing AGENT_TIMEOUT_MS / AGENT_MAX_TURNS / DEFAULT_MAXTURNS precedent)
  • src/db/migrations/010_chat_proposals.sql (CHAT_THREAD_MAX_COST_USD reservation)
  • Existing OPEN agent-sdk issues (PreToolUse Bash-only scope, permission_denials logging, destructive-action gate scope, .claude/settings.json cwd load) and CLOSED agent-sdk issues — none overlap with a per-call cost cap.
  • Claude Agent SDK TypeScript Options reference + Python max_budget_usd.py example + Opus 4.7 pricing.

Generated by the scheduled research action on 2026-06-25

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions