How to Sandbox Aider's Command Execution
Aider is the terminal pair programmer that most of the category is a reaction to, and it is good for a specific and deliberate reason: it edits the files in the repository you are actually sitting in, and it makes a git commit per change. There is no workspace abstraction, no synced copy, no 'apply patch?' modal. You talk, files change, git records it. That directness is the product.
It is also, precisely, the blast radius. The pleasant part and the dangerous part are the same architectural decision viewed from two angles. And the danger is not really in the edits — those are committed, reviewable, and trivially reversible. It is in the second half of the loop, the half people install and then stop thinking about: the commands. Aider can run your test suite, run your linter, run whatever you type after a run-this slash command, read the output, and edit again. Unattended, repeatedly, in a shell that inherits your environment.
This post is about splitting those two halves apart: keeping the part of Aider that is delightful on your laptop, and moving the part that executes model-chosen commands somewhere that does not contain your AWS credentials.
Which half of Aider actually needs isolating
It is worth being specific, because 'sandbox your coding agent' is usually advice delivered at a volume that drowns out the detail. Not everything Aider does is equally dangerous, and treating it as one undifferentiated risk leads people to either sandbox nothing or abandon the tool.
- The edit loop is fine locally. The model reads files, proposes a diff, Aider applies it. Nothing executes. The worst case is a bad patch in your working tree, which is what version control is for.
- Git commits are fine locally, and are a feature. A commit per change means your undo is `git reset` and your review is `git diff` — the two operations you already know how to do at three in the morning.
- Command execution is not fine locally. Test runs, lint runs, the run-this slash command, and anything an auto-fix loop invokes are commands chosen by a language model and executed by your shell, as you.
- Dependency installs are the worst case of that. The agent decides it needs a package, the install runs a postinstall or setup script as your user, and arbitrary third-party code has executed before Aider has printed a word of output. The model did nothing wrong. The supply chain did the damage.
- Anything driven by a model reading third-party code wants isolation regardless. A README, a vendored dependency, an issue body pasted into the chat — if a stranger can influence the text the model reads, a stranger has partial influence over the commands it proposes.
So the boundary is not 'Aider' versus 'not Aider.' It is 'reads and writes files in a checkout' versus 'runs processes.' The first belongs wherever you want it. The second belongs on a machine you would be happy to delete.
The auto-test loop is the sharp edge
Here is the workflow that makes Aider feel like magic, and the reason it is the thing to worry about. You point it at a failing test, tell it the command that runs your suite, and let it iterate: edit, commit, run the tests, read the failure, edit again. It is a genuinely excellent feedback loop, and the reason it works is that the model gets ground truth from an exit code instead of guessing.
# The pleasant version, on your laptop. Aider edits, commits, runs the tests,
# reads the failure, and edits again — in a loop, without asking each time.
cd ~/work/payments-api
aider --auto-test --test-cmd "pytest -q" src/billing.py
# Also in scope of every command that loop runs:
ls ~/.aws/credentials ~/.ssh/id_ed25519 ~/.kube/config .env
env | grep -c TOKEN
# And your shell is on the VPN, so "localhost" is not the only database
# a confidently-wrong migration command can reach.Now consider the failure modes that are not malicious at all, just wrong. A model asked to make a failing test pass has more than one way to satisfy that objective, and deleting the test is a valid solution to the stated problem. That is the funny version. The unfunny version is the same reasoning applied one directory up: the fixtures look stale, the fixture directory is in the way, and the cleanest path to a green suite is to remove it — which, on your machine, in a shell holding your credentials, is a command with consequences that outlive the session.
Aider's own defaults are conservative here — it is built for a developer who is present, and it generally asks before running things. But 'it asks' is a weaker control than it sounds. The whole point of the auto-test flag is to stop asking, because being asked forty times is what made you turn it on. And an approval prompt for a plausible-looking command is a prompt you approve, which is exactly what makes prompt injection through repository content effective rather than theoretical.
Commit-per-change is recovery, not safety
Aider's commit-per-change design deserves the credit it gets. It genuinely does make agent edits safe to accept, because every change is a reviewable, revertible unit and nothing lands as an anonymous blob of rewritten files. If your worry is 'the agent will mangle my code', git has already solved it.
But it is worth naming the category error, because it is the most common one in this whole space: a commit records a file change. It does not record, contain, or reverse a process. `git reset` does not un-run an install script, un-send a request, un-drop a table, or un-read the credentials file that a subprocess just POSTed somewhere. Version control is an excellent undo for the edit half of Aider and no undo at all for the execution half.
Commits make the agent's edits reviewable. They do nothing about the agent's side effects — and the side effects are the part that leaves your machine.
The pattern: Aider against a sandboxed checkout
The version of this that actually works in practice is not 'wrap every Aider command in a jail.' It is to move the whole session — Aider, the checkout, the toolchain, the test suite — into an isolated machine, and turn the output into a branch you review. You keep the loop intact, and the loop stops running next to your keys.
- Push the branch you want worked on, so the sandbox has something to clone and you have something to review.
- Create a microVM from a template that already has your language toolchain baked in, with a TTL so it dies even if your orchestrator does.
- Clone into it with a token scoped to one repository, contents-write only — not the personal access token that can reach forty repos.
- Install dependencies inside the VM. Every postinstall script now runs as root on a disposable guest instead of as you on your laptop.
- Run Aider there, in unattended mode, with the model API key as the only credential in the environment.
- Push the resulting branch. Review the commits as a pull request, the way you would review a colleague's.
- Destroy the VM. Everything that mattered is in git; everything that did not is gone.
The reason this is practical rather than aspirational is that creating the machine is cheap. On PandaStack every create is a restore of a baked Firecracker snapshot rather than a boot — around 179ms p50 and 203ms p99 — so a fresh, hardware-isolated machine per Aider session costs you less wall-clock than the first `pytest` collection. The expensive thing in an agent session is the model, and always was.
import os
from pandastack import Sandbox
REPO = "github.com/acme/payments-api"
BRANCH = "agent/fix-billing-rounding"
# A token for ONE repo, contents:write only. Not your account PAT. The VM is
# destroyed at the end, so the tokened remote dies with it.
REPO_TOKEN = os.environ["SCOPED_REPO_TOKEN"]
BOOTSTRAP = f"""#!/usr/bin/env bash
set -euo pipefail
git clone --depth 50 --branch {BRANCH} \\
"https://x-access-token:{REPO_TOKEN}@{REPO}" /work
cd /work
pip install -q -r requirements-dev.txt
pip install -q aider-chat
git config user.email agent@acme.dev
git config user.name 'Aider (sandboxed)'
"""
# "base" ships a warm toolchain, so the session starts at the useful part.
sbx = Sandbox.create(
template="base",
ttl_seconds=3600, # hard kill switch, no matter what
metadata={"repo": REPO, "branch": BRANCH, "kind": "aider-session"},
)
try:
sbx.filesystem.write("/root/bootstrap.sh", BOOTSTRAP)
sbx.exec("bash /root/bootstrap.sh", timeout_seconds=900, check=True)
# Aider runs HERE. --yes because there is nobody to ask, and because there
# is nothing in this VM worth being asked about.
r = sbx.exec(
"cd /work && "
f"ANTHROPIC_API_KEY={os.environ['MODEL_KEY']} "
"aider --yes --auto-test --test-cmd 'pytest -q' "
"--message 'fix the rounding bug in invoice totals' "
"src/billing.py tests/test_billing.py",
timeout_seconds=1800,
)
print("aider exit:", r.exit_code)
print(r.stdout[-4000:]) # truncate; sessions are chatty
# The deliverable is commits. Push the branch and review it like a human's.
sbx.exec("cd /work && git push origin HEAD", timeout_seconds=180, check=True)
finally:
sbx.kill() # the machine was always disposableThree details in there are load-bearing. The `try/finally` means the VM dies even when your orchestrator throws — orphaned sandboxes are how teams discover their own billing. The TTL is the backstop for when the process dies before `finally` ever runs. And truncating Aider's stdout matters more than it looks, because an auto-test session against a verbose suite will otherwise fill your logs, and your context window if you feed it onward, with pytest collection noise.
Getting the edits back
The natural instinct is to rsync the sandbox's working tree back over your local checkout, and it is the wrong instinct. It reintroduces exactly the property you just removed — a process on your machine writing into the directory you work in — and it destroys the review step, because a synced tree is a pile of changes with no history.
Push the branch instead. Aider's commit-per-change design is doing real work here: what comes back is not a diff, it is a sequence of labelled commits with messages, which is the single most reviewable artefact an agent can produce. Fetch it, read it, cherry-pick or squash or reject it. If you want the changes locally to keep iterating by hand, `git fetch && git checkout` is the sync mechanism, and it has been battle-tested rather longer than any file-sync you would write this afternoon.
For the interactive case — where you genuinely want to talk to Aider in a terminal rather than fire a one-shot message — run the session inside the sandbox over an interactive exec rather than driving a remote checkout from a local process. The mental model is a dev container you can throw away, not a remote filesystem you have mounted.
Keeping credentials out, honestly
You cannot get to zero credentials, and anyone selling you that is selling something. Aider needs a model API key, and if you want the branch pushed, the VM needs push rights. The goal is not zero. The goal is that the set of things reachable from inside the sandbox is exactly the set of things the task needs, and that every one of them is individually revocable.
- One model key, ideally a project-scoped key with its own spend limit, so a runaway loop is a line item rather than an incident.
- One repository token, contents-write, single repo. If it leaks, you revoke one token and force-push one branch.
- Nothing else. No forwarded environment, no mounted `~/.aws`, no SSH agent socket, no kubeconfig. If you find yourself passing a credential 'so the tests can run', that test is talking to something a sandbox should not reach, and that is a finding about your test suite.
- Default-deny egress, allow-listing the package registry and the model API. An agent that can reach your staging network is an agent that can migrate it, and it will not know that was a surprise.
- A TTL on every sandbox, always. It is the only control that still works after your orchestrator has crashed.
The pleasing consequence is that the worst-case story changes shape entirely. A prompt-injected instruction that says to exfiltrate credentials now finds a VM containing a repo checkout, a scoped token, and a model key it cannot reach anything interesting with — on a guest kernel isolated by KVM, with no route to your host or your network. That is not 'unbreakable.' It is 'the reward is not worth the exploit', which is the actual goal of a security boundary.
Local versus sandboxed, dimension by dimension
- Blast radius — Local execution: your working tree, your dotfiles, your SSH agent, and every credential a shell can read. Sandboxed execution: one guest kernel and a checkout you were going to delete anyway.
- Dependency installs — Local execution: a postinstall script runs as your user before Aider prints a line. Sandboxed execution: the same script runs as root on a disposable guest with no keys and no network routes worth having.
- The auto-test loop — Local execution: model-chosen commands, repeatedly, unattended, in the directory where your life is. Sandboxed execution: the same loop, in a directory that contains a repo and nothing else.
- Recovering a bad edit — Local execution: excellent, because every change is a commit. Sandboxed execution: identical, plus the branch is remote, so recovery is 'do not merge it'.
- Recovering a bad command — Local execution: whatever your backups and your luck allow. Sandboxed execution: destroy the VM, create another in roughly the time a test runner spends starting up.
- Feedback latency — Local execution: as fast as your machine. Sandboxed execution: one hop per command, and the machine itself is a snapshot restore at about 179ms p50, so the cost you feel is your test suite, not the VM.
- Network reach — Local execution: everything your laptop can reach, including the VPN and staging. Sandboxed execution: default-deny, with an allow-list you wrote on purpose.
- Human in the loop — Local execution: you watch every step, which is the entire point of a pair programmer and genuinely valuable. Sandboxed execution: you review a branch, which is a different kind of attention and the only kind that scales past one session at a time.
Read that table honestly and the conclusion is not 'always sandbox Aider.' It is that the two modes are for different jobs. Sitting with Aider on a repo you own, iterating on code you understand, watching each step: local is right, and sandboxing it mostly adds friction to the thing you liked. Running unattended loops, working on unfamiliar code, letting it install dependencies, or running more than one session at once: the local answer stops being defensible somewhere in there, and it is better to notice before the incident than during it.
The upside nobody mentions: more than one at a time
Isolation is usually sold as a cost you pay for safety, which undersells it. The moment a session lives in its own machine, you can run several. Three Aider sessions on three branches of the same repo is impossible on one laptop — they share a working tree, a port range, a package cache and a test database — and trivial when each has a machine.
It gets better if the substrate can fork. Warm one sandbox to the point where the repo is cloned and dependencies are installed, then fork it per attempt: a same-host fork lands in roughly 400–750ms because guest memory is copy-on-write and the rootfs is a reflink, so each branch inherits the setup for free. Point Aider at a different approach in each fork, run the suite in all of them, keep the branch that goes green. That is best-of-N on a pair programmer, and it exists only because the sessions are separable in the first place.
The bottom line
Aider is local-first by design and that design is correct for what it is. The mistake is treating 'local-first' as a single property, when it is really two: local editing, which is safe and pleasant and should stay exactly where it is, and local execution, which is a model choosing commands that run as you, in a shell holding every credential you own. Split them. Put the checkout, the toolchain and the command loop inside a disposable microVM, give it one scoped repo token and one model key, default-deny its egress, put a TTL on it, and take the output as a branch to review. You keep the commit-per-change ergonomics that make Aider good, you lose the property where an auto-test loop's worst idea is a command with your name on it, and you gain the ability to run more than one session at once. The agent's bad day becomes a red test and a deleted VM.
Frequently asked questions
Is Aider unsafe? It only edits files.
The edit half is about as safe as an agent gets — it proposes a diff, Aider applies it, and every change becomes a git commit you can read and revert. That is a genuinely good design and it solves the 'the agent mangled my code' worry outright. The risk lives in the other half: Aider can run your tests, your linter, and whatever command you hand it, and in an auto-test or auto-fix loop it does so repeatedly and unattended. Those are commands chosen by a language model, executed by your shell, with your SSH keys, cloud credentials and VPN routes in scope. A commit is an excellent undo for a file change and no undo at all for a process that already ran.
Can I just run Aider in Docker?
It is a real improvement over running it directly on your host, and for a lot of people it is the right amount of effort — a container gives you a scoped filesystem, a controllable environment, and a clean answer to 'what does a postinstall script get to see'. The caveat is that every container on a machine shares one host kernel, so a kernel bug or a container escape reaches the host and every neighbouring workload. For code you wrote that is a reasonable bet; for a loop executing model-chosen commands, particularly on a shared build box running several people's sessions, a microVM with its own guest kernel is the boundary that matches the threat. Container first if you have nothing, microVM if the sessions are unattended or multi-tenant.
How do I get the agent's changes back to my machine?
Push the branch and fetch it — do not sync the working tree. Syncing reintroduces the exact property you removed, a process writing into the directory you work in, and it throws away the most valuable thing Aider produced. Because it commits per change, what comes back is a sequence of labelled commits rather than an undifferentiated diff, which means you can review it as a pull request, cherry-pick the good parts, and reject the rest. If you want to keep iterating by hand, git fetch and git checkout is your sync mechanism, and it is considerably better tested than anything you would write for the purpose.
Does sandboxing slow down the edit-test loop?
Less than the intuition suggests, because the thing you are adding is not a boot. On PandaStack a create restores a baked Firecracker snapshot rather than starting a machine from scratch — about 179ms at p50 and 203ms at p99 — so the sandbox is created once per session and then amortised over every command in it. What you actually feel is one network hop per exec, against a test suite that takes seconds to minutes. If the loop feels slow after the move, the cause is almost always dependency installation on each run, which is what a pre-baked template exists to fix.
What credentials does the sandbox actually need?
Two, ideally: a model API key so Aider can talk to a model, and a repository token scoped to one repo with contents-write so the branch can be pushed. Nothing else — no forwarded environment, no mounted cloud credentials, no SSH agent socket, no kubeconfig. Both should be individually revocable and the model key should have its own spend limit, so a runaway loop is a line item rather than an incident. If a test only passes when you pass in another credential, treat that as a finding about the test rather than a reason to widen the sandbox: a unit test reaching a real cloud service is a problem that predates the agent.
Keep reading
- Building a sandboxed coding agent — the loop, from scratch
- Where each open-source coding agent executes
- A full git workflow inside a microVM
- Filesystem and network isolation for agents
- Sandboxes for AI agents — a disposable machine per session
49ms p50 cold start. Fork, snapshot, and scale to zero.