You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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)
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.
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.
Conditionally set queryOptions.maxBudgetUsd only when the resolved value is defined (mirrors the exactOptionalPropertyTypes-safe pattern used for maxTurns).
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.
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.
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)
Finding
The Claude Agent SDK exposes a per-
query()cost ceiling via themaxBudgetUsdoption that terminates the agent loop withresult.subtype === "error_max_budget_usd"once cumulativetotal_cost_usdreaches the cap. The executor never sets it.executeAgentinsrc/core/executor.tsbuildsqueryOptions(src/core/executor.ts:258-345) withcwd,permissionMode,allowedTools,disallowedTools,hooks,settingSources,mcpServers,strictMcpConfig,systemPrompt,env,abortController,stderr, then conditionally appendsmaxTurns,model, andpathToClaudeCodeExecutable(src/core/executor.ts:338-345).maxBudgetUsdis absent from that construction andsrc/config.tshas no field for it. The only runtime safety net isagentTimeoutMs(src/config.ts:239, default3_600_000ms = 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:
buildExecutionResultcarries a comment listingerror_max_budget_usdas a known terminal subtype it would surface (src/core/executor.ts:543), and the chat-thread schema even reserves aCHAT_THREAD_MAX_COST_USDcolumn for a future per-thread cap (src/db/migrations/010_chat_proposals.sql:82). Neither path is wired. The executor logscostUsd: result?.total_cost_usdper run (src/core/executor.ts:471), so the observability to set a sane default already exists, only enforcement is missing.Wiring
maxBudgetUsdadds a defense-in-depth resource gate. The runtime isbypassPermissionswith 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:#641e16Rationale
The bot runs Opus 4.7 by default (
src/config.ts:697-700, modelclaude-opus-4-7) withbypassPermissionsand 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 likeimplementalready 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 atsrc/db/migrations/010_chat_proposals.sql:82is 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_usdis already accounted for inbuildExecutionResult(src/core/executor.ts:541-555) which surfacesSDK ${subtype}: ${errors.join("; ")}aserrorMessage. 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. Theexecutionstable already persistscostUsd(src/types.ts:90, migration016_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-bashPreToolUse hook (src/core/hooks/forbidden-bash.ts) blocks destructive Bash, the workspace TTL sweeper (src/config.ts:248) bounds disk leakage, andAGENT_TIMEOUT_MSbounds 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, nomaxBudgetUsd)src/core/executor.ts:367-370(agentTimeoutMswall-clock-only abort)src/core/executor.ts:541-555(buildExecutionResulthandleserror_max_budget_usdsubtype already)src/config.ts:239(agentTimeoutMsdefault 3,600,000 ms)src/db/migrations/010_chat_proposals.sql:82(reservedCHAT_THREAD_MAX_COST_USDcap proves the need)docs/operate/configuration.md(env var matrix; newAGENT_MAX_BUDGET_USDbelongs here)External:
maxBudgetUsd: numberand theerror_max_budget_usdterminal subtype.anthropics/claude-agent-sdk-python/examples/max_budget_usd.py— canonical pattern: budget checked after each API call, final cost may overshoot by at most one turn.Suggested Next Steps
agentMaxBudgetUsd: z.coerce.number().positive().optional()tosrc/config.ts(env varAGENT_MAX_BUDGET_USD), default unset to preserve current behaviour for explicit opt-in.ExecuteAgentParamsinsrc/core/executor.tswith an optionalmaxBudgetUsd?: numberand resolvemaxBudgetUsd ?? config.agentMaxBudgetUsdexactly the waymaxTurnsis resolved atsrc/core/executor.ts:338-341.queryOptions.maxBudgetUsdonly when the resolved value is defined (mirrors theexactOptionalPropertyTypes-safe pattern used formaxTurns).budget_exceeded: truefield to the executor's completion log (src/core/executor.ts:467-481) whenresult?.subtype === "error_max_budget_usd", so an operator alert on the structured stream is one filter away.docs/operate/configuration.mdand the observability table referencingcostUsd(docs/operate/observability.md:56-63) with the new knob and the new terminal subtype, per the CLAUDE.md doc-sync rule.test/core/executor.test.tsthat asserts (a)maxBudgetUsdis forwarded intoqueryOptionsonly when set, and (b)errorMessagesurfaces asSDK error_max_budget_usd: ...on a synthesised result.Areas Evaluated
src/core/executor.ts(queryOptions, abort plumbing, result handling)src/core/hooks/forbidden-bash.tsandsrc/utils/forbidden-bash.ts(existing runtime resource gates)src/core/pipeline.ts(resource lifecycle, tracking-comment redaction, finalize policy)src/config.ts(existingAGENT_TIMEOUT_MS/AGENT_MAX_TURNS/DEFAULT_MAXTURNSprecedent)src/db/migrations/010_chat_proposals.sql(CHAT_THREAD_MAX_COST_USDreservation)permission_denialslogging, destructive-action gate scope,.claude/settings.jsoncwd load) and CLOSED agent-sdk issues — none overlap with a per-call cost cap.max_budget_usd.pyexample + Opus 4.7 pricing.Generated by the scheduled research action on 2026-06-25