all posts

Isolating AI-Agent Mobile App Builds in a MicroVM

Ajay Kumar··9 min read

Here's a workload that sounds harmless and isn't: an AI coding agent generates a mobile app and builds it. It runs `npm ci` on a React Native project, or `npx expo prebuild`, or `gradle assembleRelease`. Each of those pulls in a dependency tree with hundreds to thousands of transitive packages you never read, and — this is the part people forget — an npm `postinstall` script runs arbitrary code with the exact same rights as your build user. If your agent runs that on your CI host or your laptop, a single poisoned transitive dependency owns the machine. This post is about the pattern that fixes it: one ephemeral Firecracker microVM per build, generously sized, that clones the repo, builds the artifact, hands you back the APK or bundle, and then dies.

I'm Ajay — I built PandaStack, a Firecracker microVM platform, and I'll be concrete about the mechanics and honest about where the boundary actually sits. The threat here isn't hypothetical: the classic npm supply-chain attack is a lifecycle script (`preinstall`, `postinstall`, `prepare`) that fires the moment the package is installed, before you've run a single line of the app. `npm install` is code execution, not a download.

Why the build step is the dangerous part

People sandbox the AI agent's runtime code and then run the build on trusted infrastructure, which is exactly backwards. The generated app source is usually the least interesting attack surface — it's a few hundred lines you can skim. The dependency install is where thousands of untrusted maintainers' code executes on your box. A React Native project pulls native modules that run gyp builds; Expo prebuild generates and compiles native iOS/Android projects; Gradle downloads and executes plugins from repositories. Every one of those steps runs with your build user's filesystem access, your network, and — if you're on CI — very often your cloud credentials sitting in environment variables.

A postinstall script runs with the same rights as your build user. On a shared CI box that means it can read every other job's checkout, your ~/.npmrc token, and whatever cloud credentials the runner has. The well-known failure mode: a compromised dependency quietly mines crypto on your CI fleet until the bill arrives, or exfiltrates your signing keys while your green checkmark says the build passed.

The mining case is the funny one only in retrospect. A postinstall that spins up a miner doesn't fail your build — it succeeds, ships your artifact, and leaves a friendly little process pegging your runners at 100% CPU. You find out from the invoice, or from the finance team, whichever screams first.

The pattern: one ephemeral microVM per build

Give each build its own Firecracker microVM. It boots its own guest kernel under KVM, so a postinstall script that escapes the package manager is still trapped inside a disposable VM — to reach the host it would have to escape the hypervisor, a far smaller and more audited surface than the Linux syscall interface a container shares with your host. The lifecycle is deliberately short: create the VM, clone the repo into it, run the install and build, read the artifact back out through the filesystem API, and kill the VM. Nothing survives. The next build starts from a clean baked snapshot, not from whatever the last build's dependencies left lying around.

Sizing matters here in a way it doesn't for a throwaway script sandbox. Mobile builds are heavy — a Next/Vite/tsc/Gradle build will happily OOM at 2 GiB and take a chunk of CPU with it. On PandaStack the guest's RAM and vCPU are a property of the baked template snapshot (Firecracker can't resize memory at snapshot-restore), so you bake a build template at, say, 4 GiB / 2 vCPU precisely because 2 GiB gets you a killed compiler. The point of the microVM isn't just isolation — it's that you can hand an untrusted build a generous, fixed slice of resources without letting it become a noisy neighbor to everything else on the host.

Because every create restores a baked snapshot instead of cold-booting, a per-build VM is cheap to spin up: p50 is around 179ms (p99 ~203ms). The first-ever boot of a template is ~3s while it bakes; every create after that takes the fast restore path. So "a fresh VM per build" costs you milliseconds of overhead, not the multi-second VM tax that historically made this impractical.

Building a React Native / Expo app in a sandbox

Here's the loop with the Python SDK. Create a sandbox on a build template, write a build script into the guest, run it with a timeout, and read the resulting artifact back as bytes. The template uses mise to manage the Node and Java toolchains, so the script can pin whatever the repo's `.nvmrc` / `.tool-versions` declare. Set `PANDASTACK_API_KEY` in your environment and the SDK picks it up.

from pandastack import Sandbox

# Untrusted repo the agent wants to build. This clones + installs + prebuilds
# an Expo/React Native app entirely inside a throwaway VM.
build_script = r"""#!/usr/bin/env bash
set -euo pipefail
cd /workspace

git clone --depth 1 "$REPO_URL" app
cd app

# mise honours .nvmrc / .tool-versions in the repo, then exposes shims.
mise install
eval "$(mise env -s bash)"

# npm ci runs postinstall scripts from the whole dependency tree --
# this is the untrusted code. It is confined to this VM.
npm ci

# Generate the native iOS/Android projects.
npx expo prebuild --platform android --no-install

# Produce the artifact.
cd android
./gradlew assembleRelease
cp app/build/outputs/apk/release/app-release.apk /workspace/app-release.apk
echo "BUILD_OK"
"""

with Sandbox.create(template="base", ttl_seconds=900) as sbx:
    sbx.filesystem.write("/workspace/build.sh", build_script)
    result = sbx.exec(
        "REPO_URL=https://github.com/example/rn-app.git bash /workspace/build.sh",
        timeout_seconds=780,  # under the VM's TTL; builds are slow
    )

    print("exit:", result.exit_code)
    if result.exit_code != 0:
        print("stderr:\n", result.stderr)
        raise SystemExit("build failed")

    # Pull the APK out as raw bytes before the VM is destroyed.
    apk = sbx.filesystem.read("/workspace/app-release.apk")
    with open("app-release.apk", "wb") as f:
        f.write(apk)
    print(f"pulled {len(apk)} bytes")
# VM (and every postinstall script's leftovers) destroyed here

`exec` returns an object with `stdout`, `stderr`, and `exit_code` — check the exit code before you trust the artifact, because a failed install means the APK was never written. Always pass `timeout_seconds` (a runaway or wedged Gradle daemon is exactly what you want a circuit breaker for) and a `ttl_seconds` on create as a backstop so a VM you forget to kill reaps itself. The `with` form kills the VM on block exit, so the throwaway path needs no manual cleanup; for a long build you drive from a job runner, keep a handle and call `sbx.kill()` in a `finally`.

The build itself: what runs inside the guest

The script above is a normal mobile build — nothing about running it in a microVM changes the commands. That's the point: your agent produces the same build script it always would, and the isolation is the container around it, not a rewrite of the build. Here's the shell view of what executes inside the guest, plus reading the artifact back out over the SDK's filesystem API.

# --- inside the guest (untrusted), driven by build.sh ---

# Clone the AI-generated repo.
git clone --depth 1 "$REPO_URL" app && cd app

# Toolchain via mise (Node + Java pinned by repo files).
mise install && eval "$(mise env -s bash)"

# React Native / Expo path:
npm ci                       # <-- postinstall scripts run HERE, in the VM
npx expo prebuild            # generate native android/ + ios/ projects

# Android artifact:
cd android && ./gradlew assembleRelease
#   -> app/build/outputs/apk/release/app-release.apk

# (iOS on an Apple builder would be: xcodebuild -scheme App archive)


# --- back on the host (trusted), over the SDK ---
# sbx.filesystem.read("/workspace/app-release.apk")  -> bytes
# write those bytes to disk / upload to your artifact store, then sbx.kill()

`filesystem.read` returns raw bytes, so the same pattern covers any output: an `.apk`, an `.aab` app bundle, an `.ipa`, a source map, or a build-log tarball. Have the script copy the final artifact to a known path under `/workspace`, verify the exit code, then read that one path. For large or multiple artifacts it's cleaner to `tar` them into a single file in the guest and pull that.

Compared to a self-hosted runner or a container

  • Self-hosted CI runner (build on the host directly) — Fastest to set up, worst isolation. A postinstall script sees the runner's whole filesystem, other jobs' checkouts, and any cloud/signing credentials in the environment. One bad dependency compromises the fleet. Fine for first-party code you trust; wrong for AI-generated or arbitrary repos.
  • Container per build — Better hygiene: fresh filesystem per job, resource limits via cgroups. But the container shares the host kernel, so a container escape or a kernel bug in the build's native toolchain reaches the host and its neighbors. Secure-container layers (gVisor, Kata) narrow this precisely because a plain container isn't a hard boundary for untrusted code.
  • MicroVM per build — Each build gets its own guest kernel under hardware virtualization; escaping to the host means escaping the hypervisor, not just the container runtime. Generous fixed RAM/CPU from the baked template contains noisy-neighbor blowups. Create is snapshot-restore fast (~179ms p50), so per-build ephemerality is practical. This is the right default when the code — or its dependency tree — is untrusted.

If you'd rather connect a repo than drive the SDK yourself, PandaStack's git-driven app hosting runs the same isolation shape under the hood: connect a GitHub repo and each deploy builds inside a persistent `base`-template sandbox (the universal Ubuntu 24.04 + mise runtime), detects the framework, runs `mise install` and the build, health-checks it, and blue-green flips to the new sandbox. It's tuned for long-running web apps rather than one-shot mobile artifacts, but the principle is identical: the untrusted build — dependency install, postinstall scripts, and all — happens inside a microVM, never on the control plane or a shared host.

The takeaway is simple. `npm install` and `gradle assembleRelease` are arbitrary code execution on dependency trees you didn't write, and an AI agent generating the project only widens that surface. Run each build in its own ephemeral, generously-sized Firecracker microVM, read the artifact back through the filesystem, and let the VM die with whatever the postinstall scripts tried to do still trapped inside it. The isolation is real hardware virtualization; the cost is a few hundred milliseconds. That's a trade worth making before the crypto miner introduces itself via your cloud bill.

Frequently asked questions

Why isolate a mobile app build in a microVM instead of just running it on CI?

Because npm install and gradle assembleRelease execute arbitrary code from your dependency tree — postinstall/preinstall lifecycle scripts run with the same rights as your build user. On a shared CI host that means access to other jobs' checkouts and any credentials in the environment. A per-build Firecracker microVM boots its own guest kernel, so a malicious dependency is confined to a disposable VM that you destroy after reading the artifact.

How big should the build template's RAM and CPU be?

Bigger than you'd guess — React Native, Expo prebuild, and Gradle (like Next/Vite/tsc) builds commonly OOM at 2 GiB. On PandaStack the guest's RAM/vCPU are fixed by the baked template snapshot (Firecracker can't resize memory at restore), so bake a build template at something like 4 GiB / 2 vCPU. The upside of a fixed slice is that an untrusted build can't become a noisy neighbor to the rest of the host.

How do I get the built APK or bundle out of the sandbox?

Have the build script copy the final artifact to a known path under /workspace, check that exec returned exit_code 0, then call sandbox.filesystem.read('/workspace/app-release.apk'), which returns raw bytes. Write those bytes to disk or upload them to your artifact store, then kill the VM. For multiple or large outputs, tar them into one file in the guest and read that.

Isn't a container per build good enough for untrusted builds?

A container is better than building on the bare host, but it shares the host kernel — a container escape or a kernel bug hit by a native build toolchain reaches the host and its neighbors. That's why secure-container layers like gVisor and Kata exist. A microVM gives each build its own kernel under hardware virtualization, so escaping to the host means escaping the hypervisor, a much smaller surface than the container runtime.

Does the per-build VM add a lot of latency?

Very little. Every create restores a baked snapshot rather than cold-booting, so it's roughly 179ms p50 (p99 ~203ms). Only the first-ever boot of a template pays the ~3s cold-boot-and-bake cost. Compared to a multi-minute mobile build, spinning up a fresh isolated VM per build is noise.

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.