Put bounds on autonomous runs
An unattended run does whatever you allowed in advance — so allow little and cap the rest. This is the lesson our whole thesis runs through: autonomy is a budget you set, not a switch you flip.
01The goal
Bound an unattended claude -p run with least privilege, the sandbox, turn and spend limits, a review gate and a cost estimate — so it can work alone and still land inside lines you drew.
02The idea
- 01Least privilege. dontAsk mode denies every call that would prompt, so allow only the folders the task edits and the one command it runs — Edit(./src/**), not Edit.
- 02The sandbox. The operating system limits what an allowed command can write and reach. Make it strict, and required for the run to start.
- 03Limits. --max-turns caps agentic turns; --max-budget-usd caps estimated spend.
- 04A review gate. Deny git commit and git push — deny rules hold in every mode — and you read the diff and commit what you accept. dontAsk also refuses the forms a rule misses, such as git -C . push.
- 05A cost estimate. Run one slice first, and set the spend limit from what the slice cost.
03The walkthrough
Work in your repository; src/ and test/ and npm test stand for your own. Save the bounds as .claude/bounded-run.json:
The sandbox keys are the quiet half: failIfUnavailable exits at startup if the sandbox cannot start, instead of quietly running commands unsandboxed; allowUnsandboxedCommands false is strict mode — the escape-hatch retry is off, and repository settings cannot loosen it from under a --settings file; autoAllowBashIfSandboxed false keeps sandboxed commands flowing through your permission rules and mode instead of being waved through. On Linux and WSL 2, the sandbox needs bubblewrap and socat installed.
Then the runner, scripts/bounded-run.sh:
- 01Make it executable and commit both files, so everyone who runs the task gets the same bounds.
- 02See a limit stop a run, once, cheaply: the same command with --max-budget-usd 0.01 exits non-zero with subtype error_max_budget_usd. The recorded total_cost_usd can sit above the limit — it includes the response that crossed it.
- 03Estimate on a slice: run the script on one small part of the bigger task — "add a test for one untested function in src/<file>, run npm test, then commit the test." The summary shows success, the turns used, the estimated cost, and under denied the git commit the bounds refused. HEAD is unchanged and the new test sits uncommitted — that is the review gate holding.
- 04Set the real limit from the slice: scale the figure to the whole task, with room to spare.
- 05Be the gate: read every change with git status and git diff (the bounds file included), run the tests yourself, and commit only what you accept. For a second opinion, start a session and have a subagent review the diff in a fresh context.
04Your turn
Bound a real task in your repository that is bigger than one slice — tests for every untested function in one folder, say. The contract: scripts/bounded-run.sh "<task>" <result.json> runs it; every allow rule names one folder or one command (no bare Bash, no Bash(npm *)); the deny rules stop git commit and git push, each with the trailing " *"; the sandbox is on, strict, and required; there is a turn limit and a spend limit above your slice cost; and you read the diff and ran the tests before committing any of it.
Know what the bounds do not cover. Hooks and local MCP servers run outside the sandbox with full access, and a -p run loads them without asking — read them before a bounded run in any repo you did not write, or set disableAllHooks: true in the bounds file. The allowed npm test executes test code the run can edit; inside the sandbox that code can write the working directory (within .git, only hooks and config are protected), though a push cannot reach a remote — dontAsk refuses hosts you have not allowed. That is why the script prints HEAD before and after, and why your review covers every changed file. A dynamic workflow started by such a run needs its own allow rule for the Workflow tool — and its agents’ spend counts toward the budget.
Why does sandbox.failIfUnavailable belong in a bounds file?
- AOtherwise a run whose sandbox cannot start proceeds unsandboxed
- BIt speeds up startup
- CIt disables the sandbox in CI
→Check your answer
Without it, an unavailable sandbox degrades to running commands unsandboxed — the exact silent failure a bounded run must not have.
What makes the review gate hold even in a long autonomous run?
- AThe turn cap
- BDeny rules for git commit and git push — deny rules apply in every mode
- CThe model choice
→Check your answer
Deny rules block in every permission mode, and dontAsk refuses anything not explicitly allowed — including forms a rule misses. The commit stays yours.
What is the slice run for?
- AIt warms the prompt cache
- BIt is the deliverable
- CIt estimates the task’s cost, so the spend limit is set from evidence
→Check your answer
One small part of the task, run under the same bounds, tells you turns and cost per unit — scale it up and set --max-budget-usd above it, with room to spare.
Go further
- Configure the sandboxed Bash tool — settings, strict mode, protected paths ↗
- Choose a sandbox environment — containers and VMs for stronger bounds ↗
- How we contain Claude across products — Anthropic on capping agent blast radius ↗
- What we actually let AI agents do — where this lesson’s philosophy comes from