Give an AI Coding Agent a Real Git Workflow in a Sandbox
You want an autonomous coding agent to do the boring end-to-end loop: clone a repo, cut a branch, edit some files, run the tests, commit, and open a pull request — all without a human babysitting each step. The mechanics are easy. The scary part is that a tool-calling model is, by construction, a program that generates shell commands you have not read yet. Somewhere in the distribution of commands it might emit is a confident, well-formatted `git clean -fdx` in the wrong directory, or a `rm -rf` that resolves to `/` because a path variable came back empty. The fix is not to trust the model harder. The fix is to give it a real git workflow inside a disposable Firecracker microVM, so that when it does something dumb, it does it to a VM you were about to delete anyway.
Why the agent must not run on your host
The whole appeal of an autonomous coding agent is that it acts without asking. That is also the whole problem. Every command it runs is model output, and model output is occasionally creative in ways your filesystem will not appreciate.
- It types the commands you didn't review: the agent decides to "clean up the working tree" and runs git reset --hard plus a recursive delete. On a shared host that's your working tree. In a throwaway VM it's nobody's.
- Empty variables are a loaded gun: a start command like rm -rf "$BUILD_DIR/" is fine until $BUILD_DIR expands to nothing and the agent cheerfully removes the root. This is a classic even for humans; an agent chaining tool calls will find the edge case faster than you can.
- Dependency install runs arbitrary code: npm ci, pip install, and a repo's own build scripts execute maintainer-written (or attacker-written) code with the agent's privileges. A postinstall hook is a shell script you didn't audit.
- A container is not the boundary you think it is: a Docker container shares the host kernel. A kernel bug or container escape in code the agent fetched reaches the host and every neighbor. For code you didn't write and didn't read, shared-kernel isolation is a hope, not a boundary.
The loop: create, clone, branch, edit, test, commit, destroy
The workflow is a tight control loop that your orchestrator drives. The agent only ever acts inside the sandbox; your host just issues API calls and reads results back out.
- Create a sandbox from a template (baked with the toolchain you need), with a hard ttl_seconds so it self-destructs even if the loop hangs.
- Clone the repo inside the VM and check out the exact base commit — never a mutable branch a race could re-point.
- Cut a working branch (git checkout -b agent/fix-<issue>) so the agent's edits are isolated from the base.
- Let the agent edit and exec: it reads files, writes patches over the filesystem API, runs the code, reads stack traces, and iterates. Every command lands in the VM.
- Run the tests. The exit code is ground truth — pass/fail comes from the process, not from the model's self-assessment.
- Commit and push with a narrowly scoped git token, then open the PR via the git host's API.
- Destroy the sandbox. The branch and the PR survive on the git host; nothing else does.
Because the VM is disposable, you do not need defensive cleanup between steps. If the agent corrupts the environment mid-loop, it corrupted a machine that is about to be deleted. The only things that escape are what you deliberately push (a branch, a PR) and what you read out (logs, a diff).
The whole loop in Python
Here is the loop end to end with the PandaStack SDK: create a hardware-isolated sandbox, clone a pinned base commit, hand the agent a branch to work on, run its edits and the test suite, commit with a scoped token, and tear the VM down. Set PANDASTACK_API_KEY in the environment first. Each exec returns stdout, stderr, and exit_code so your orchestrator — not the model — decides what happened.
import os
from pandastack import PandaStack
ps = PandaStack() # reads PANDASTACK_API_KEY, base url https://api.pandastack.ai
REPO = "github.com/acme/widget.git"
BASE_SHA = "a1b2c3d4" # exact base commit; never a mutable branch
BRANCH = "agent/fix-flaky-login"
# A token scoped to ONE repo, contents:write only. Not your account PAT.
GIT_TOKEN = os.environ["SCOPED_REPO_TOKEN"]
# 1. Fresh, hardware-isolated VM. Baked toolchain snapshot so there's no install.
# ttl_seconds is a hard kill switch: the VM self-destructs even if we hang.
sb = ps.sandboxes.create(
template="base",
ttl_seconds=1800, # 30-min ceiling, no matter what the agent does
metadata={"repo": REPO, "base": BASE_SHA, "kind": "coding-agent"},
)
try:
# 2. Clone + pin the base commit, INSIDE the VM. A malicious repo hook or
# postinstall script is contained to this disposable machine.
clone_url = f"https://x-access-token:{GIT_TOKEN}@{REPO}"
sb.exec(f"git clone {clone_url} /work && cd /work && git checkout {BASE_SHA}")
# 3. Cut the agent's working branch.
sb.exec(f"cd /work && git checkout -b {BRANCH}")
# 4. Agent edits: read the failing file, write a patch, iterate. Your agent
# loop lives here; the filesystem API is how it reads and writes code.
src = sb.filesystem.read("/work/src/login.ts").decode()
patched = run_your_agent(src) # the model proposes a fix
sb.filesystem.write("/work/src/login.ts", patched)
# 5. Tests are ground truth. Exit code decides pass/fail, not the model.
test = sb.exec("cd /work && npm test", timeout_seconds=600)
if test.exit_code != 0:
print("tests failed, agent iterates:", test.stdout[-2000:])
# ... loop back to step 4, or give up and delete ...
# 6. Commit + push with the SCOPED token. The VM can push this branch and
# nothing else — it has no path to prod, deploy keys, or other repos.
sb.exec(
"cd /work && git config user.email [email protected] "
"&& git config user.name 'ACME Agent' "
f"&& git commit -am 'fix: flaky login test' && git push origin {BRANCH}"
)
# Open the PR via your git host's REST API from the orchestrator (has the
# scoped token too) — the sandbox never needs PR-creation rights.
open_pull_request(base="main", head=BRANCH)
finally:
# 7. Destroy. The branch + PR live on the git host; the VM does not.
sb.delete()Notice what the sandbox never sees: your account credentials, your deploy keys, or any repo other than this one. It gets a single scoped token, does its job, and evaporates.
Why hardware isolation is the right boundary for a tool-calling agent
A coding agent is a worst-case tenant on purpose: it runs arbitrary shell, installs arbitrary dependencies, and executes a codebase you may not have read. The isolation you put around it should assume the code is hostile, because from a security standpoint model-generated commands and attacker-supplied build scripts are indistinguishable — both are unaudited code running with the agent's privileges.
A Firecracker microVM boots its own guest kernel and is confined by hardware virtualization (KVM). The guest talks to the outside world only through a tiny set of emulated virtio devices. There is no shared host kernel to attack; an escape would have to break the hypervisor itself — a far smaller, far more audited surface than the full Linux syscall interface a container sees. That is the same primitive AWS Lambda uses to run untrusted code from every customer, and it is exactly the boundary you want between your infrastructure and a program whose next command you have not seen yet.
- Kernel boundary — Shared-kernel container: a container escape or kernel bug in fetched code reaches the host. Firecracker microVM: a compromise is trapped in one VM you're about to delete.
- Blast radius — Host process / container: a runaway or malicious step can touch neighbors and the host filesystem. microVM: the radius is one disposable guest.
- Clean state — Long-lived runner: leftover files, caches, and processes leak into the next run. Snapshot-restored microVM: every run starts from an identical, known-good image.
- Verify against their docs: exact isolation guarantees for shared-kernel and secure-container options vary by configuration — check the provider's own security docs before trusting one with unread code.
Scope the git token so the sandbox can't touch prod
The one credential the sandbox genuinely needs is a way to push its branch. Give it the smallest possible one. A personal access token with org-wide scope inside a VM running model-generated commands is how a helpful agent becomes an incident.
- One repo, not the account: use a fine-grained token (GitHub fine-grained PAT, a GitHub App installation token, or a GitLab project access token) scoped to the single repo the agent is working on.
- Least privilege on scope: contents:write to push a branch is usually enough. It does not need admin, deploy-key, secrets, or environment access — and it should not be able to push to main behind a branch-protection rule.
- Short-lived and injected at runtime: mint the token per task and pass it through the exec/env, never bake it into the template snapshot. An installation token that expires in an hour beats a PAT that lives forever.
- Keep PR creation off the sandbox: let the orchestrator open the pull request via the git host API. The VM only needs push rights; PR-open, labels, and reviewers stay on your side of the boundary.
- Nothing that reaches prod: no cloud keys, no database URLs, no deploy tokens in the VM's environment. If the agent's job is to edit code and open a PR, that is all its credentials should permit.
The mental model: the sandbox is a locked room with a mail slot. It can slide one branch out to one repo. It cannot open the door, walk to production, and start pressing buttons — because the only key it holds fits exactly one lock.
Branch-and-explore: try several fixes in parallel with fork
The most interesting part is what the microVM model unlocks that a plain container can't. When your agent reaches a decision point — say three plausible fixes for a failing test — you don't have to pick one and hope. You can snapshot the sandbox at that exact state and fork it into several independent VMs, run a different candidate fix in each, test them in parallel, and keep whichever one goes green.
# Agent has cloned, branched, and reproduced the failing test. Freeze here.
snap = sb.snapshot() # capture filesystem + memory at this point
candidates = ["widen-timeout", "retry-on-503", "fix-race-in-poll"]
results = {}
for name in candidates:
# Fork the frozen state into an independent VM per candidate. Same-host
# forks are 400-750ms (reflink rootfs + memory restore); cross-host is
# 1.2-3.5s if the scheduler places the fork on another agent.
child = ps.sandboxes.fork(snap.id, metadata={"attempt": name})
try:
child.filesystem.write("/work/src/login.ts", propose_fix(name))
r = child.exec("cd /work && npm test", timeout_seconds=600)
results[name] = r.exit_code == 0
finally:
child.delete() # each branch is disposable
winners = [n for n, ok in results.items() if ok]
print("passing candidates:", winners) # commit + push the best one from sbThis is a real tree-of-attempts, not a metaphor. Copy-on-write does the heavy lifting: forked memory is MAP_PRIVATE and the rootfs is a reflink clone, so each fork shares the parent's baked pages until it writes. Spinning up the Nth attempt doesn't copy gigabytes — it diverges from a shared base only where the candidate actually changes something. You explore the fix space in parallel for roughly the cost of exploring it once, then commit the winner from your main sandbox and let the losers evaporate.
When you don't need all of this
Per-task microVMs are not free of operational cost — you own the orchestration loop, token minting, and result plumbing. Be honest about your threat model.
- The agent only edits repos you fully trust and never runs their build scripts or dependency installs. The untrusted-code argument weakens, though clean-state-per-run still helps.
- A human reviews and runs every command the agent proposes before it executes. If there's a person in the loop for each step, the autonomous-blast-radius risk is lower — but so is the reason to have an agent.
- Your tasks are read-only (summarize a repo, answer questions about code) and never write, install, or push. Less can go wrong when nothing executes.
Reach for disposable microVMs the moment the agent is autonomous, runs untrusted or model-generated commands, installs dependencies, or needs push access to a repo. That is precisely when you want a hardware boundary between the agent and everything else — and with snapshot-restore creates in ~179ms and same-host forks in 400-750ms, that boundary costs you almost nothing. Give the agent a real git workflow, a room it can't leave, and one key that fits one lock. Then let it run rm -rf all it wants.
Frequently asked questions
Why should an AI coding agent run inside a microVM instead of on my host or in a container?
An autonomous agent runs commands it generates at runtime, so you're executing unreviewed shell — plus arbitrary dependency-install and build scripts from the repo. On your host or in a shared-kernel container, a destructive command (a mis-expanded rm -rf, a git reset --hard in the wrong tree) or a container escape can reach your files and neighbors. A Firecracker microVM gives the agent its own guest kernel behind a hardware virtualization boundary, so a mistake or exploit is trapped in a VM you were going to delete anyway.
How does the create-clone-branch-edit-test-commit loop work?
Your orchestrator creates a sandbox from a baked template with a hard ttl_seconds, clones the repo and checks out the exact base commit inside the VM, cuts a working branch, then lets the agent read files and write patches over the filesystem API while running commands via exec. Tests run in the VM and the exit code decides pass/fail. On success, the agent commits and pushes the branch with a narrowly scoped git token, the orchestrator opens the PR via the git host API, and the sandbox is destroyed. The branch and PR survive on the git host; nothing else does.
How do I scope the git token so the agent can't touch production?
Give the sandbox a fine-grained, single-repo token (a GitHub fine-grained PAT, a GitHub App installation token, or a GitLab project access token) with just contents:write so it can push a branch — not admin, deploy keys, secrets, or environment access, and not the ability to bypass branch protection on main. Mint it per task and inject it at runtime rather than baking it into the snapshot, keep cloud and database credentials out of the VM entirely, and let the orchestrator (not the sandbox) create the pull request. The VM ends up able to push exactly one branch to exactly one repo.
How does snapshot and fork let an agent try multiple fixes at once?
When the agent reaches a point with several candidate fixes, you snapshot the sandbox at that exact filesystem-plus-memory state, then fork it into an independent microVM per candidate. Each fork applies a different fix and runs the tests in parallel, and you keep whichever passes. Copy-on-write makes this cheap: forked memory is MAP_PRIVATE and the rootfs is a reflink clone, so forks share the parent's baked pages until they write. On PandaStack a same-host fork is 400-750ms and a cross-host fork is 1.2-3.5s, so a fan-out of attempts costs roughly the same as running one.
How much latency does a disposable microVM add per task?
On PandaStack, creating a sandbox is about 179ms at p50 (roughly 203ms p99) because every create restores a baked snapshot on demand — the ~49ms restore step plus the network fast path — rather than cold-booting. Only the first-ever boot of a template is slow (~3s) while the snapshot bakes; after that every create takes the fast path. Same-host forks add 400-750ms. Compared with the seconds your clone, install, build, and test steps actually take, the isolation overhead is negligible.
49ms p50 cold start. Fork, snapshot, and scale to zero.