How to Sandbox OpenHands Agent Execution in a microVM
OpenHands (formerly OpenDevin) is one of the more honest open-source agents, in the sense that it does not pretend to be a chatbot with tools bolted on. It is a software engineer. It reads an issue, runs bash, edits files, executes Python, opens a browser, runs your test suite, and iterates until it thinks it is done. That is genuinely useful, and it is also a precise description of a process you have handed a shell to on the basis of text a language model produced.
The architecture that makes this work is a separate runtime — an action-execution server that lives apart from the agent loop. The agent emits actions, the runtime performs them, observations come back. That split is the right design, and it is also the exact seam you want to exploit, because it means the question 'where does the agent's shell actually live?' has a configurable answer. The default answer is a Docker container. This post is about why that answer stops being good the moment the agent is acting on text you did not write, and what to replace it with.
What the OpenHands runtime actually needs
Before swapping the substrate, it is worth being precise about the requirements, because they are less exotic than the architecture diagrams suggest. Stripped down, the runtime needs five things, and every one of them maps onto a plain Linux box:
- A writable workspace directory the agent treats as the repository root — it clones into it, edits files in it, and expects those edits to still be there on the next action.
- A real shell, not a filtered one. The agent will run git, make, pytest, npm, and things you did not anticipate, and it reads exit codes and stderr to decide what went wrong.
- A Python interpreter with state that survives between actions, because 'run this snippet' followed by 'now use the variable from the last snippet' is a normal thing for it to do.
- Network access, at least outbound, for pip, npm, git clone, and the browser tool. This is the requirement everybody wishes were optional and nobody can actually drop.
- Session lifetime measured in minutes to hours, not milliseconds. A real task is dozens of actions and one long-lived environment, not a fresh box per command.
Notice what is not on that list: nothing about containers specifically. The runtime wants a machine. Docker is one way to hand it one; it is not the only way, and it is a weaker one than it looks once the input is untrusted.
Why the container boundary is thinner than the workload
On a laptop, running the OpenHands runtime in a container is entirely reasonable. You wrote the task, you are watching the terminal, and the blast radius is your own machine on a day you have backups. The trade changes when the agent runs on shared infrastructure and the task text comes from somewhere else.
Think about what a real deployment looks like. The agent is triggered by a GitHub issue, so the task description is written by whoever opened it. It reads PR comments. It clones a repository and executes that repository's build scripts. If the browser tool is enabled, it reads scraped web pages. Every one of those is attacker-influenceable text, flowing into a model whose output is then executed as bash. The prompt-injection path here is not theoretical or clever; it is the happy path with a hostile issue body.
And a container is a shared-kernel boundary. Namespaces and cgroups are a polite suggestion to the kernel about what a process should be able to see, and every container on the host is talking to the same kernel through the full Linux syscall interface. That surface is enormous, it has had escapes, and it will have more. For code you wrote, that is an acceptable risk. For code a model wrote in response to a stranger's issue body, on a host that also runs another customer's session, it is a bet you are making without pricing it.
One microVM per session maps cleanly onto the runtime
The unit of isolation should match the unit of state, and in OpenHands that unit is obvious: the session. One task, one long-lived runtime, one workspace, one shell history, one set of installed packages. Give that session its own microVM and every requirement in the list above is satisfied by a machine that boots its own guest kernel, is isolated by KVM, and shares nothing with the host beyond a small, heavily-audited virtual machine monitor.
The objection is always latency: a VM per session sounds expensive next to a container start. It is worth checking that against numbers rather than instinct. On PandaStack there is no warm pool of idle VMs — every create restores a baked Firecracker snapshot, landing at roughly 179ms p50 and 203ms p99, with the restore step itself around 49ms. A first cold boot of a brand-new template is about 3 seconds, and after that you are on the snapshot path. Against an agent session that runs for ten minutes and spends most of it waiting on a model, a couple hundred milliseconds at the start is not a design constraint.
Here is the shape of a runtime backend. It is deliberately plain: create a machine, prepare a workspace, expose exec and file operations, and guarantee the thing dies at the end.
from pandastack import Sandbox
class MicroVMRuntime:
"""One Firecracker microVM per OpenHands session.
Holds the workspace, the shell, and the Python kernel for the whole task.
Nothing here is shared with the host or with another session.
"""
WORKSPACE = "/workspace"
def __init__(self, session_id: str, ttl_seconds: int = 3600):
# persistent=True: the idle reaper leaves it alone; we own the teardown.
# create() is a snapshot restore (~179ms p50), not a cold boot.
self.sbx = Sandbox.create(
template="agent",
persistent=True,
ttl_seconds=ttl_seconds,
metadata={"openhands_session": session_id},
)
self.sbx.exec(f"mkdir -p {self.WORKSPACE}", check=True)
# One kernel for the whole session, so Python state survives actions.
self.ctx = self.sbx.create_code_context(language="python")
# --- the three action types the agent actually emits ---------------
def run_bash(self, command: str, timeout_seconds: int = 120) -> dict:
r = self.sbx.exec(f"cd {self.WORKSPACE} && {command}",
timeout_seconds=timeout_seconds)
return {
"exit_code": r.exit_code,
"stdout": r.stdout[-8000:], # observations go into the prompt
"stderr": r.stderr[-4000:], # so cap them before they cost money
}
def run_python(self, code: str, timeout_seconds: int = 120) -> dict:
ex = self.ctx.run_code(code, timeout_seconds=timeout_seconds)
return {"exit_code": ex.exit_code,
"stdout": ex.stdout[-8000:],
"stderr": ex.stderr[-4000:]}
def write_file(self, path: str, content: str) -> None:
self.sbx.filesystem.write(f"{self.WORKSPACE}/{path}", content)
def read_file(self, path: str) -> str:
return self.sbx.filesystem.read(f"{self.WORKSPACE}/{path}").decode()
def close(self) -> None:
self.ctx.close()
self.sbx.kill() # the machine and its kernel cease to existThree details in there are load-bearing. The code context is a persistent kernel, which is what makes 'use the dataframe from the previous step' work — an agent that loses its Python state between actions rebuilds it every turn, burns tokens, and plans worse. The truncation on observations is not paranoia: the first time an agent runs a verbose build and pipes the whole log back into the model, you learn about context windows and invoices simultaneously. And close() is the only cleanup that matters, with the TTL as a backstop for the day your orchestrator crashes before reaching it.
Workspace persistence across steps
The instinct carried over from Docker deployments is to bind-mount the workspace from the host so you can inspect it, and that instinct is exactly the one to drop. A host mount is a hole punched straight through the boundary you just paid for; it is also how a model-generated path traversal stops being funny. Keep the workspace inside the guest, and move data across the boundary deliberately.
In practice that means three explicit operations: seed the workspace at session start, read specific artifacts out during the run, and extract the result at the end. The result of an OpenHands session is almost always a diff, which is a small text file, not a directory tree — so 'extract the result' is one exec and one read.
# Session start: clone into the guest, not onto the host.
# The token is passed for this command only; it is not baked into a template.
git clone --depth 1 https://x-access-token:$GH_TOKEN@github.com/acme/api /workspace/repo
cd /workspace/repo && git checkout -b agent/fix-flaky-test
# ...the agent works: edits files, runs pytest, iterates...
# Session end: the artifact is a patch, not a filesystem.
cd /workspace/repo
git add -A
git diff --cached > /workspace/session.patch
git diff --cached --stat # cheap summary to feed back to the modelRead that patch out with a filesystem read, apply it in a review workflow that a human or a separate trusted process controls, and the agent never needs write access to anything outside its own disposable guest. The agent proposes; something else with actual authority disposes. That separation is worth more than any amount of prompt engineering about being careful.
Egress: the requirement you cannot drop, so control it instead
The runtime needs outbound network. Package installs, git, and the browser tool all require it, and any guide that tells you to just turn networking off has not run a real software-engineering agent. So the goal is not 'no network' but 'network that only goes where the task needs.'
Per-sandbox network namespaces are what make this practical rather than a shared-firewall nightmare. Each PandaStack sandbox gets its own namespace, veth pair, and tap device out of a pool of 16,384 pre-allocated /30 subnets per agent host, which means egress policy is per-sandbox state rather than a global rule set you are afraid to touch. Three rules cover most of the risk:
- Deny RFC1918 and link-local by default. The agent has no business reaching your VPC, your database, or a cloud metadata endpoint, and the first thing a prompt-injection payload tries is exactly that.
- Allow-list the package registries and git hosts the task actually needs. If a task requires a domain you did not anticipate, that is a signal to update the policy, not to open everything.
- Log denied egress and treat a spike as an incident signal. A session suddenly trying to reach an address nobody allow-listed is the most useful alert in the whole system.
Pre-installing what the agent will need is the other half of this. A template that already carries the toolchain and the common dependencies means fewer network calls, faster sessions, and reproducible runs that do not depend on what a registry served that afternoon.
Docker runtime vs microVM runtime, honestly
OpenHands' own runtime abstraction is good enough that this is a substrate choice rather than a rewrite. Here is where the two actually differ, with the OpenHands and Docker behaviour described qualitatively — check the specifics against their current docs, because both projects move — and concrete numbers only for our own platform:
- Kernel boundary — Docker runtime: every session shares the host's one kernel through the full syscall interface; a kernel bug or escape reaches the host and every neighbouring session. microVM runtime: each session boots its own guest kernel behind KVM, so the exposed surface is a small virtual machine monitor rather than all of Linux.
- Multi-tenancy — Docker runtime: workable if every session belongs to the same trust domain, which stops being true the moment two customers share a host. microVM runtime: sessions are hardware-isolated from each other by construction, which is the same model the large serverless platforms use to run everyone's code on shared hardware.
- Start latency — Docker runtime: container start, generally fast; measure it on your own image, because a fat agent image is not a small one. microVM runtime: on PandaStack, a snapshot restore at roughly 179ms p50 and 203ms p99, with about 3 seconds only for the very first cold boot of a template.
- Workspace safety — Docker runtime: bind-mounting the host workspace is the common convenience, and it is also a direct path from a model-generated command to your files. microVM runtime: the workspace lives on a copy-on-write guest disk, and data crosses the boundary only through explicit reads and writes.
- Branching a session — Docker runtime: re-running setup in a fresh container, paying the install cost again for each attempt. microVM runtime: fork a warm session copy-on-write, roughly 400-750ms same-host and 1.2-3.5s cross-host, with every branch inheriting the clone and the installs.
- Teardown — Docker runtime: stopping a container leaves the host kernel to clean up state that process touched. microVM runtime: killing the VM destroys a guest kernel and a disposable rootfs, and there is nothing left to reason about.
- Operational cost — Docker runtime: cheap per session, and the cost of an escape is the host. microVM runtime: on PandaStack, $0.054 per active vCPU-hour and $0.0162 per GiB-hour, billed while the session exists — which is small next to the model tokens a single OpenHands task burns.
The last line is the one that settles most arguments. The expensive part of an agent session is the model, by a wide margin. Choosing a thinner isolation boundary to save on compute is optimising the line item that was never the problem.
The bonus: branch a session instead of restarting it
Once the runtime is a VM, you get a capability the container deployment cannot easily match. OpenHands sessions are unreliable per-attempt and much better in aggregate — the classic pattern is to try several fixes and keep the one whose tests pass. In a container world each attempt re-clones and re-installs. With copy-on-write forking, you warm the environment once and branch it.
import concurrent.futures as cf
# Warm ONE session: clone, install, confirm the suite runs. Pay setup once.
trunk = MicroVMRuntime(session_id="issue-4412", ttl_seconds=3600)
trunk.run_bash("git clone --depth 1 https://github.com/acme/api repo")
trunk.run_bash("cd repo && pip install -r requirements.txt", timeout_seconds=600)
def try_candidate(patch: str) -> dict:
# Fork the warm trunk (~400-750ms same-host). The child inherits the install.
child = trunk.sbx.fork()
try:
child.filesystem.write("/workspace/repo/candidate.patch", patch)
child.exec("cd /workspace/repo && git apply candidate.patch")
r = child.exec("cd /workspace/repo && pytest -q", timeout_seconds=300)
return {"patch": patch, "passed": r.exit_code == 0, "tail": r.stdout[-1500:]}
finally:
child.kill() # branches are disposable; the trunk survives
candidates = agent_generates_n_fixes(n=4) # four OpenHands attempts
with cf.ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(try_candidate, candidates))
trunk.close()
winners = [r["patch"] for r in results if r["passed"]]
print(f"{len(winners)}/{len(results)} candidate fixes passed the suite")Four real machines, four independent attempts, one paid setup, and the dead ends cost a kill(). This is the part where the microVM stops being purely a security tax and starts being a feature — an agent architecture where exploring a branch is cheap looks quite different from one where every attempt starts from nothing.
The checklist before you point real traffic at it
- One runtime per session, never shared between users. The module-level global that seemed harmless in development becomes multi-tenant in production without anyone deciding it should.
- No host bind-mounts into the workspace. Move data across the boundary with explicit reads and writes.
- A TTL on every session, sized to outlive a long task and nothing more. It is the backstop for the orchestrator crash that skips your cleanup.
- Per-action timeouts. The agent will eventually run something that never returns, and it will do so at 3am.
- Truncated observations, and tell the model they were truncated so it does not conclude the build output was empty.
- Default-deny egress to private ranges and metadata endpoints; allow-list the registries and hosts the task needs.
- Credentials passed per-command and scoped to one repository, never exported into the session environment.
- Teardown in a finally block. Orphaned sandboxes are usually discovered by the invoice, which is a poor monitoring system.
The bottom line
OpenHands got the hard architectural decision right by separating the agent from the action-execution runtime. That separation is what lets you change the answer to 'where does the shell live' without touching the agent. On a laptop, a container is a fine answer. On shared infrastructure, with tasks that originate from issue bodies and PR comments and web pages, a shared kernel is not a boundary you want between a confidently-wrong model and your other tenants.
Give each session its own microVM. It satisfies everything the runtime actually asks for — a workspace, a real shell, a persistent Python kernel, network, and a lifetime measured in minutes — while making the worst case a destroyed guest and a non-zero exit code the agent learns from. On PandaStack that costs about 179ms at session start and per-second billing while the session exists, and it hands you copy-on-write forking as a side effect. Then go verify the numbers on your own workload, because the only benchmark that means anything is the one you ran yourself.
Frequently asked questions
Is the default OpenHands Docker runtime unsafe?
Not unsafe in the abstract — it is a reasonable default and it is fine on a single-user laptop where you wrote the task and are watching the terminal. It becomes a real risk in two specific situations: when sessions from different users share a host, and when the task input comes from somewhere you do not control, such as a GitHub issue body, a PR comment, a cloned repository's build scripts, or a web page the browser tool fetched. In both cases the agent is executing model output derived from attacker-influenceable text, and a container shares one kernel with everything else on the host. A microVM changes the worst case from 'host and neighbours compromised' to 'one disposable guest destroyed.'
Does OpenHands support a custom execution runtime?
OpenHands deliberately separates the agent loop from an action-execution runtime that performs bash commands, file edits, Python execution, and browser steps. That separation is what makes swapping the substrate practical: the runtime needs a writable workspace, a shell, a Python interpreter with persistent state, outbound network, and a session lifetime measured in minutes to hours, all of which a microVM provides as well as a container does. The exact configuration surface and the names of the runtime classes change between releases, so wire it up against the version you are running and check their current documentation rather than a blog post's snapshot of it.
How do I keep the agent's workspace between actions if it is inside a VM?
Keep the session's sandbox alive for the whole task rather than creating one per action. The workspace is a directory on the guest's copy-on-write disk, so file edits, installed packages, and the git working tree persist across actions exactly as they would in a container. Use a persistent code context for Python so kernel state survives too. What you should not do is bind-mount the workspace from the host for convenience — that punches a hole through the boundary you just paid for, and it is the shortest path from a model-generated command to your own files. Move data in and out with explicit filesystem reads and writes, and extract the session's result as a patch rather than a directory.
Does a microVM per session make OpenHands slower?
Not meaningfully, because the cost is paid once at session start and an OpenHands session runs for minutes. On PandaStack every create restores a baked Firecracker snapshot rather than cold-booting, which lands at roughly 179ms p50 and 203ms p99, with about 3 seconds only for the first cold boot of a new template. Against a session that will make dozens of model calls, that start-up cost does not show up. The slow parts of an agent session are the model and the test suite, in that order, and neither of them cares what the execution substrate is.
Can the agent still install packages and clone repositories?
Yes, and it must be able to — pip, npm, git, and the browser tool all need outbound network, so 'just turn networking off' is not a workable policy for a software-engineering agent. The right posture is controlled egress rather than no egress: default-deny private address ranges and cloud metadata endpoints, allow-list the package registries and git hosts the task actually needs, and log denials so an unexpected destination becomes an alert. Per-sandbox network namespaces make this per-session state rather than a global firewall you are afraid to edit. Pre-installing the common toolchain into the template also helps twice over: fewer network calls, and runs that reproduce instead of depending on what a registry served that day.
Keep reading
49ms p50 cold start. Fork, snapshot, and scale to zero.