chrisleekr / chrisleekr/github-app

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

Open
#252 0 comments 0 reactions 0 assignees View on GitHub
research type: feature ✨
Dominant language
TypeScript
Stars
1
Forks
0
Avg merge
6h 39m
Merged PRs (30d)
27

Description

## 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](https://www.anthropic.com/news/claude-opus-4-5)) 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

```mermaid
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
```

## 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**:
- [Claude Agent SDK TypeScript Options reference](https://code.claude.com/docs/en/agent-sdk/typescript) — lists `maxBudgetUsd: number` and the `error_max_budget_usd` terminal subtype.
- [`anthropics/claude-agent-sdk-python/examples/max_budget_usd.py`](https://github.com/anthropics/claude-agent-sdk-python/blob/main/examples/max_budget_usd.py) — canonical pattern: budget checked after each API call, final cost may overshoot by at most one turn.
- [Anthropic Opus 4.5/4.7/4.8 pricing](https://www.anthropic.com/news/claude-opus-4-5) — $5 in / $25 out per million tokens; basis for picking a default cap.
- [MindStudio: AI Agent Token Budget Management](https://www.mindstudio.ai/blog/ai-agent-token-budget-management-claude-code) — industry pattern for runaway-cost prevention in Claude Code agents.

## 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*

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.