all posts

How to run SWE-agent in a sandbox

Ajay Kumar··8 min read

SWE-agent is an open-source agent from the research group behind SWE-bench. You point it at a repository and a GitHub issue, and it works roughly the way a developer does: read files, search, edit, run the tests, look at the failure, try again. Its actual research contribution is the agent-computer interface — the observation that a model performs better against a small, deliberately shaped set of commands than against a raw terminal.

The part that gets skipped in the write-ups is what that means for the machine it runs on. The interface is still a shell, and the commands still come from a language model. Running it on your workstation gives a model-authored command loop write access to the same filesystem that holds your SSH keys, your cloud credentials, your npm token and your pip config. It has network access too, because a repository's dependencies have to come from somewhere. None of that is malicious by design. It only has to be wrong once.

CI is not the fix people assume it is. A hosted runner is a shared machine with the union of every job's secrets reachable from it, and the separation between a job and the runner underneath it is a process boundary on a good day. Moving the agent there mostly relocates the blast radius onto a box with more credentials on it than your laptop has.

I build PandaStack, which is Firecracker microVM sandboxes, so that is the sandbox in the code below. The shape works with any provider that gives you create, exec and read-a-file over an API.

Where Docker-per-task runs out

SWE-agent's conventional environment is a Docker container per task, and for one issue on a workstation that is the right call. It is one dependency you already have, the image is a reasonable unit of reproducibility, and you can inspect the container while the run is happening. I am not going to pretend otherwise.

Three things go wrong as the shape of the work changes.

The daemon is the first. Container-per-task means a Docker daemon on the machine driving the run, and something in your harness has to be able to talk to it. Access to that socket is effectively root on the host. So the boundary you carefully drew around the agent has a hole in it exactly where your orchestrator sits, and orchestrators are the components most likely to end up handling model output.

The shared kernel is the second. A container is namespaces and cgroups layered over the host's kernel, which is a strong boundary against accidents and a considerably weaker one against something deliberate. For repositories you wrote, that is a fine bet. For an issue set scraped off public GitHub, where you are running unfamiliar build scripts and install hooks as a matter of course, you are relying on kernel hardening you did not audit and cannot see.

Preparation cost is the third, and it is the one that actually stops people. The interesting way to use a coding agent is not once. It is the same issue five times at a non-zero temperature to see which attempt passes, or two hundred issues in a sweep to measure a prompt change. Every one of those attempts wants a machine with the repository at a specific commit and its dependency tree installed, and if you build that with an image build plus an install step you are paying minutes per attempt, forever, including for the four attempts out of five you throw away.

What a microVM changes

A microVM is a virtual machine with a stripped-down device model and a boot path measured in tens of milliseconds. Firecracker is the well-known one. Three properties matter here and they map onto the three problems above.

There is a kernel boundary. The guest runs its own kernel and talks to a hypervisor with a deliberately tiny surface — a handful of virtio devices, no general-purpose emulation. An agent that finds a kernel bug inside the guest has found a bug in a kernel that is not yours, on a machine that exists to be thrown away.

There is a network namespace per sandbox. Egress rules attach to the individual sandbox rather than to one host-wide iptables ruleset that every workload shares. That is what makes a per-run network policy something you can actually state, rather than something you approximate and hope holds.

And there is snapshot-restore, which is the property that changes how you design the harness rather than just how safe it is. You can freeze a VM's memory and disk after the repository is cloned and the dependencies are installed, and restoring that state is a memory-map plus a copy-on-write disk clone rather than a rebuild. On PandaStack a create through that path has a p50 of 179ms. So "give me a fresh machine with this repository, at this commit, with its dependency tree already installed" stops being a build step and becomes a call you can make inside a loop.

None of that is free. You pay for a heavier substrate than a container, you take a network hop on every exec instead of a local pipe, and somebody has to bake the template once. Worth naming, because the rest of this post is about what you get for it.

A driver: one sandbox per issue

SWE-agent has its own runtime that abstracts the environment, and pinning a post to a specific API surface is how posts go stale. So the code below is the loop underneath: the shell-and-edit interface, wired straight to sandbox exec. It is the same thing you would implement behind a custom environment backend, and writing it out once is the clearest way to see exactly what the agent is doing to the machine.

# pip install pandastack
import json
from pandastack import Sandbox

REPO_DIR = "/workspace/repo"

TOOLS = [
    {
        "name": "shell",
        "description": "Run one shell command inside the repository checkout.",
        "input_schema": {
            "type": "object",
            "properties": {"cmd": {"type": "string"}},
            "required": ["cmd"],
        },
    },
    {
        "name": "write_file",
        "description": "Replace a file's full contents. Path is relative to the repo root.",
        "input_schema": {
            "type": "object",
            "properties": {"path": {"type": "string"}, "content": {"type": "string"}},
            "required": ["path", "content"],
        },
    },
]

def dispatch(sbx, name, args):
    if name == "shell":
        r = sbx.exec("cd " + REPO_DIR + " && " + args["cmd"], timeout_seconds=120)
        tail = (r.stdout + r.stderr)[-6000:]
        return f"[exit {r.exit_code}] {tail}"
    if name == "write_file":
        sbx.filesystem.write(REPO_DIR + "/" + args["path"], args["content"])
        return "ok"
    return "unknown tool: " + name

def solve(issue_id, issue_text, repo_url, commit, turn):
    """turn(messages, TOOLS) -> (text, tool_calls). Any model client will do."""
    sbx = Sandbox.create(
        template="base",
        ttl_seconds=1800,
        metadata={"harness": "swe-agent", "issue": issue_id},
    )
    try:
        sbx.exec(f"git clone {repo_url} {REPO_DIR}", timeout_seconds=600)
        sbx.exec(f"cd {REPO_DIR} && git checkout {commit}")
        sbx.exec(f"cd {REPO_DIR} && pip install -e .", timeout_seconds=900)

        messages = [{"role": "user", "content": issue_text}]
        for _ in range(50):
            text, calls = turn(messages, TOOLS)
            if not calls:
                break
            results = [
                {"id": c["id"], "output": dispatch(sbx, c["name"], c["input"])}
                for c in calls
            ]
            messages.append({"role": "assistant", "content": text, "tool_calls": calls})
            messages.append({"role": "user", "content": json.dumps(results)})

        # Stage first. A plain diff misses new files, and new files are
        # where the agent puts the regression test it just wrote.
        sbx.exec(f"cd {REPO_DIR} && git add -A")
        return sbx.exec(f"cd {REPO_DIR} && git diff --cached").stdout
    finally:
        sbx.kill()

Four details in there are load-bearing, and three of them are the ones people leave out on the first pass.

  • Every exec has a timeout. Agents write commands that hang — an interactive prompt, a test suite waiting on a socket, a watch mode nobody asked for — and without a timeout your run stops making progress without ever failing.
  • Output is truncated from the tail. A test suite can emit megabytes; the model needs the last few thousand characters, and sending the rest costs money and buries the actual error.
  • The sandbox carries metadata identifying the run. You will want to find a specific issue's machine later, and searching a list by tag beats remembering an id.
  • The finally block kills the sandbox, and the TTL set at creation is the backstop for the case where your harness dies before the finally runs.

The patch comes out as text and stays text. Nothing inside the sandbox pushes anywhere. You apply the diff outside, in a checkout that has actual credentials, after a human or a test suite has looked at it.

Bake once, fork per attempt

The driver above clones and installs per issue. That is correct and it is slow, and on a single repository it is wasteful in the most obvious way: you pay the same install for every attempt at the same problem.

The better shape is to prepare the state once and fork it. Snapshot the VM after the clone and the install and a sanity test run, then give each attempt a copy-on-write child of that snapshot. Memory and disk are shared until something writes, so the second attempt costs almost nothing to start and cannot see what the first one did.

from pandastack import Sandbox

# Prepare the repository state once. This is the slow part, and you
# only do it when the target commit changes.
base = Sandbox.create(template="base", persistent=True, ttl_seconds=7200)
base.exec("git clone https://github.com/example/project /workspace/repo",
          timeout_seconds=600)
base.exec("cd /workspace/repo && pip install -e '.[dev]'", timeout_seconds=900)
base.exec("cd /workspace/repo && python -m pytest -q", timeout_seconds=600)

attempts = []
try:
    for i in range(5):
        # Copy-on-write child: identical starting state, isolated VM.
        attempts.append(base.fork(metadata={"attempt": str(i)}))

    for i, child in enumerate(attempts):
        patch = run_agent_loop(child, issue_text, temperature=0.7)
        check = child.exec("cd /workspace/repo && python -m pytest -q",
                           timeout_seconds=600)
        print(i, "PASS" if check.exit_code == 0 else "fail", len(patch))
finally:
    for child in attempts:
        child.kill()   # keep the winning patch, throw away the machines
    base.kill()

A same-host fork on our setup lands between 400 and 750ms. The number matters less than what it does to the cost model: preparation is paid once per repository state instead of once per attempt, so running five attempts costs roughly the same as running one plus five agent loops.

It is also the honest way to do best-of-N. Five children of one snapshot start from a byte-identical environment, so the variation you measure is the model's and not a difference in which version of a transitive dependency happened to resolve that afternoon. If you are trying to tell whether a prompt change helped, that distinction is the entire experiment.

Fork the prepared state, not a running agent loop. A snapshot taken mid-conversation restores with whatever the agent had half-written on disk, and you will spend a confusing hour deciding whether attempt three's weird failure came from the model or from state it inherited.

Egress: the agent will try to install things

Somewhere in the first ten turns the agent will run a package install. It is the most natural move in the world when a test fails on a missing import. Decide before the run whether it is allowed to, because deciding during the run means deciding by accident.

Allowing it is realistic. Repositories genuinely need their dependencies, and an agent that cannot install anything will fail on tasks a human would solve in one command. The cost is that the sandbox now reaches the open internet, and a compromised transitive dependency executes its install hook inside your environment. That is an acceptable trade when the sandbox holds nothing worth taking and the boundary around it is real, which is most of the argument for a VM rather than a container.

Denying it is more reproducible and cheaper. Bake the dependency tree into the snapshot and run attempts with egress restricted to your model API. Runs stop varying with whatever the index served that day, and the agent stops trying to fix logic bugs by upgrading a package, which it does more often than you would like. The cost is real setup work per repository, and a failure mode where the agent loops on a missing module without understanding why it cannot get it. If you go this way, say so in the system prompt.

There is a middle position worth knowing about: allow a package mirror you run, deny everything else. You get installs without the open internet, and you get a log of exactly what was pulled.

Whichever you pick, the rule underneath does not change. The sandbox should contain nothing worth stealing. No cloud credentials, no long-lived Git token, no SSH key that opens anything but the one repository. Clone with a short-lived read-only token, extract the diff as text, and do the writing from outside where a human is accountable for it.

When Docker is the right answer

One issue, a repository you wrote, on your own machine: use Docker. It is simpler, you already have it, and the failure you are guarding against — a model deleting something outside the checkout — is real but rare and mostly recoverable if your work is committed. Building microVM infrastructure for that is a way of avoiding the actual task.

The microVM version earns its keep in three situations, and they are worth checking yourself against honestly.

  1. Fan-out. Dozens or hundreds of attempts, where the per-attempt cost of building an environment dominates the cost of the model calls and snapshot-restore is the only thing that makes the sweep affordable.
  2. Untrusted code. An issue set you did not write, whose setup scripts and install hooks run as a matter of course, where a shared kernel is a bet you are making without evidence.
  3. Reproducibility that has to hold. Grading a change to the agent means every attempt starting from the same state, and a snapshot is a much stronger guarantee than a Dockerfile whose base image and package index both move underneath you.

If none of those describe what you are doing, the honest answer is that this is infrastructure you do not need yet, and the post you actually want is the one about writing a better issue description.

The idea to take away is smaller than the machinery. SWE-agent's own finding is that constraining the interface makes the model more effective. The same thing turns out to be true one level down: constraining what the machine can reach makes the run safer, and the constrained version is also the fast one, because a machine you can snapshot is a machine you never have to build twice.

Frequently asked questions

Is Docker enough to run SWE-agent safely?

For your own repository on your own machine, usually yes. A container stops the ordinary accident, and the agent is not trying to escape. It stops being enough in two cases. If you are running an issue set you did not write, you are executing unfamiliar build scripts against a kernel shared with the host, and that is a bet on kernel hardening you have not audited. And if your harness holds a Docker socket to create containers, that socket is root-equivalent on the host, so the boundary has a hole in it exactly where your model output is handled.

How do I get the patch out of a sandbox?

Stage everything and take a staged diff: git add -A followed by git diff --cached, read back as text over the exec API. The staging step matters more than it looks. A plain git diff shows nothing for untracked files, and untracked files are where an agent puts the regression test it just wrote, so the patch you extract silently misses the most useful part of the work. Do not let the sandbox push anywhere itself. Extract text, review it outside, and apply it in a checkout that actually holds credentials.

Should the agent be allowed to install packages?

Decide deliberately, because the default is whatever you did not think about. Allowing installs is realistic and matches how a human solves the task, at the cost of open internet access from inside the sandbox and install hooks from transitive dependencies running there. Denying installs and baking the dependency tree into the snapshot gives you cheaper, more reproducible runs, and stops the agent from trying to fix logic bugs by upgrading a library. If you deny installs, say so in the system prompt, or the agent will loop trying to work out why the network is broken.

How much does fan-out actually save?

It depends entirely on how expensive the environment is. For a repository with a heavy install step, preparation dominates: cloning and installing per attempt means paying that cost N times, and forking a prepared snapshot means paying it once. On our setup a same-host fork lands between 400 and 750ms, so five attempts cost roughly one preparation plus five agent loops rather than five preparations. For a tiny repository with no dependencies, the saving is small and you should not bother with the extra machinery.

Does this pattern work with other coding agents?

Yes, because nothing in it is specific to SWE-agent. Any agent that resolves issues does the same three things: run shell commands against a checkout, edit files, and read back what the tests said. That is a dispatch function over exec and a filesystem write, which is what the driver in this post is. The pieces that change between agents are the tool names, the prompt, and how the loop terminates. The sandbox, the snapshot, the fork-per-attempt pattern and the egress decision are all the same underneath.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.