Isolating AI Agents That Publish Packages
There is a specific moment in every "let the agent handle releases" project where someone gets quiet. It's usually right after the agent proposes running `npm publish`, and right before someone realizes the shared CI runner it would run on has a long-lived `NPM_TOKEN` sitting in `~/.npmrc` — a file that any of the four hundred packages in the dependency tree can read during install, because a postinstall script is arbitrary code you agreed to run because a lockfile said so. The agent isn't the new risk. The agent is the thing that finally made you look at the risk that was already there.
I'm Ajay — I built PandaStack, a Firecracker microVM platform for running untrusted and semi-trusted workloads. This post is about the shape of a release pipeline that an AI agent can drive end to end without any point in that pipeline holding both "arbitrary third-party code" and "a credential that can publish to the world" at the same time. The core move is boring and effective: split the release into a build that has no credentials and an upload that has no code.
Publish is the highest-privilege verb you own
Most credentials in a build pipeline are read-scoped or blast-limited. A registry publish token is neither. It writes to an immutable, globally-distributed artifact store that hundreds or millions of machines pull from automatically, often into environments with more privilege than yours. An attacker who steals your deploy key gets your one deployment. An attacker who steals your publish token gets everyone who runs `npm install` tomorrow morning, and gets it through a channel your users have specifically configured their machines to trust.
That asymmetry is worth naming because it changes how you should reason about "probably fine." Ambient credentials on a build box are a normal, accepted-by-everyone compromise for most things. For publish credentials the accepted compromise is a bad trade, because the failure mode isn't "our staging environment got popped," it's "we shipped a credential stealer to our users under our own signature and they had no reason to be suspicious."
- npm — a token in `~/.npmrc` or `NPM_TOKEN`; often account-wide, often long-lived, readable by any process running as your user.
- PyPI — an API token in `~/.pypirc` or `TWINE_PASSWORD`; historically account-scoped unless you deliberately narrowed it to one project.
- crates.io — `cargo login` writes a token to `~/.cargo/credentials.toml`, and `cargo publish` is irreversible: you can yank a version, you cannot unpublish it.
- Container registries — a `docker login` writes credentials to `~/.docker/config.json`, and a pushed tag is what everyone's `:latest` resolves to next.
Why the shared runner makes it worse
The traditional release box is a machine that does everything: clone, install dependencies, run tests, build the artifact, and publish it. Which means at the moment `npm install` executes several hundred packages' worth of install-time code, the publish credential is already present on disk and in the environment, because it was provisioned for the job as a whole. The install step and the publish step share a trust domain for no reason other than that they happen to share a machine.
You don't need a compromised dependency for this to hurt, either. You need any code path on that box that can read a file and open a socket. A test fixture that shells out. A build plugin that phones home for telemetry. A generated script the agent wrote to "check something quickly." The interesting failures here are rarely kernel exploits — they're a token that was sitting in the environment being read by code that had a perfectly ordinary reason to be running.
A shared release runner is a machine where the least-trusted code you run and the most powerful credential you own are colocated for operational convenience. Nobody would design that on purpose. Everyone inherits it.
Now add an agent. The agent's job is to make the release work, and it will do reasonable-looking things to get there: add a dependency the build seems to need, write and run a helper script, install a CLI tool it read about. Each of those is a new code path executing on the box that holds your publish token, chosen at runtime by a model whose inputs may include a README, an issue thread, or a tool result that an attacker wrote. The agent didn't create the colocation problem, but it dramatically raises the rate at which novel code lands on the credentialed machine.
The release-preflight pattern: split build from upload
The fix is structural, not vigilant. Instead of one machine that has everything, use two microVMs with disjoint capabilities, and put a checkpoint between them:
- Preflight VM (no credentials): the agent clones, installs dependencies, runs tests, and builds the artifact. This VM has no registry token, no SSH key, no cloud credentials — nothing worth stealing. All the arbitrary third-party code runs here.
- Checkpoint (no code): the artifact's SHA-256 hash, filename, version, and a manifest of what the agent actually ran are emitted for review. A human, or a policy engine, approves that exact hash. This step executes nothing.
- Publish VM (credentials, no build): a second, freshly-created microVM receives the approved artifact bytes and a short-lived, narrowly-scoped token, verifies the hash matches what was approved, and does exactly one thing — the upload. No `npm install`, no test run, no repo clone.
- Destroy: the publish VM is killed the moment the upload returns. The credential's useful lifetime is bounded by the lifetime of a VM that existed for a few seconds.
The property this buys you is precise: at no point does a machine hold both untrusted code and a publish credential. The preflight VM runs everything hostile but has nothing to steal. The publish VM has something to steal but runs nothing hostile — its entire program is "upload these bytes." A malicious postinstall in the dependency tree finds an empty `~/.npmrc` and a network that won't route to its collection endpoint. A stolen artifact hash is not a credential and can't publish anything.
Step one: build in a microVM that has nothing to steal
The preflight sandbox is where the agent gets to be an agent. It installs, builds, iterates, and fails as many times as it needs to, because the worst thing in the room is a VM you're about to delete. What comes out is not a decision to publish — it's a candidate artifact and a hash.
import hashlib
from pandastack import Sandbox
REPO = "https://github.com/acme/widget.git"
# PREFLIGHT: build + test with ZERO publish credentials in the guest.
# Every postinstall, plugin, and agent-authored helper script runs here.
with Sandbox.create(template="base", ttl_seconds=900) as sbx:
sbx.exec(f"git clone --depth 1 {REPO} /work", timeout_seconds=180)
# Third-party lifecycle scripts execute right here. There is no
# ~/.npmrc, no NPM_TOKEN, no SSH key, and no cloud metadata to read.
install = sbx.exec(
"cd /work && npm ci --no-audit --no-fund",
timeout_seconds=600,
)
if install.exit_code != 0:
raise RuntimeError(f"install failed:\n{install.stderr}")
tests = sbx.exec("cd /work && npm test", timeout_seconds=900)
if tests.exit_code != 0:
raise RuntimeError(f"tests failed:\n{tests.stdout[-4000:]}")
# Produce the tarball WITHOUT publishing it. `npm pack` is the
# dry-run half of `npm publish`: same bytes, no upload, no token.
pack = sbx.exec(
"cd /work && npm pack --pack-destination /out",
timeout_seconds=300,
)
tarball = pack.stdout.strip().splitlines()[-1]
# Capture an audit trail of what the agent actually ran in here.
manifest = sbx.filesystem.read("/work/package.json")
artifact = sbx.filesystem.read(f"/out/{tarball}")
digest = hashlib.sha256(artifact).hexdigest()
print(f"candidate: {tarball}")
print(f"sha256: {digest}")
print(f"bytes: {len(artifact)}")
# Preflight VM is destroyed here. Nothing it installed survives.Note what crossed the boundary: a byte string and a hash. Not a shell session, not a mounted volume, not a process that keeps running. The artifact is inert data now, and inert data is the only thing that should ever move from a low-trust environment to a high-trust one. If the build was compromised, you have a compromised tarball — which is a real problem, but it's a problem you can inspect, diff, and refuse, rather than a token that already left the building.
Step two: approve the hash, not the intention
The checkpoint is the part teams skip, and it's the part that makes the split load-bearing. "The agent says the build is good" is not an approval; it's a summary written by the thing being reviewed. What you approve is a specific SHA-256 over specific bytes, and the publish step refuses anything that doesn't match. That turns approval from a vibe into a comparison two lines of code can enforce.
What should be in front of the approver — human or policy engine — is small and mechanical: version number, artifact hash, byte size, the diff of `package.json` (especially `files`, `bin`, and any lifecycle `scripts` — a `postinstall` appearing in your own package is a five-alarm diff), the list of commands the agent executed in preflight, and the test exit code. For an automated gate, the policy can be as simple as: version is a semver bump from the published latest, no new lifecycle scripts, hash is present in the preflight record, and preflight tests exited zero.
- Publish credential exposure — Shared runner: present on disk for the whole job, readable by every dependency's install script. Two-VM split: present only in a VM that runs one upload command and then dies.
- Untrusted code reach — Shared runner: install/test/build code runs alongside the token. Two-VM split: runs in a VM with no credentials at all.
- What crosses the trust boundary — Shared runner: nothing crosses; it's all one box. Two-VM split: an artifact byte string plus a hash — inert data, no execution.
- Approval semantics — Shared runner: 'the job succeeded, so it published.' Two-VM split: a human or policy approves one specific hash; a mismatch aborts.
- Blast radius of a compromised dependency — Shared runner: token exfiltrated, attacker publishes at will, indefinitely. Two-VM split: attacker gets a sandbox with nothing in it.
- Auditability — Shared runner: interleaved logs from one long job. Two-VM split: an explicit record of what preflight ran and exactly which bytes were uploaded.
Step three: a credentialed microVM that only uploads
The publish VM is deliberately boring, and boring is the security property. It gets a fresh microVM, an approved artifact, a hash to verify against, and a short-lived token minted for this one release. It does not clone the repo. It does not install dependencies. It does not run the agent's scripts. Its entire job is to move bytes to a registry and exit.
from pandastack import Sandbox
def publish(artifact: bytes, filename: str, approved_sha256: str, token: str):
"""Upload an approved artifact from a single-purpose, short-lived VM."""
# Short ttl: the credential cannot outlive the VM, and the VM is
# measured in seconds. If this function throws, the ttl reaps it.
with Sandbox.create(template="base", ttl_seconds=300) as sbx:
sbx.filesystem.write(f"/out/{filename}", artifact)
# Verify INSIDE the guest that the bytes are the approved bytes.
check = sbx.exec(
f"cd /out && echo '{approved_sha256} {filename}' | sha256sum -c -",
timeout_seconds=30,
)
if check.exit_code != 0:
raise RuntimeError(f"artifact hash mismatch: {check.stdout}")
# The token enters the guest here and nowhere else. No repo was
# cloned into this VM; no third-party install script ever runs in it.
sbx.filesystem.write("/root/.npmrc",
f"//registry.npmjs.org/:_authToken={token}\n")
up = sbx.exec(
f"cd /out && npm publish {filename} --access public",
timeout_seconds=300,
)
if up.exit_code != 0:
raise RuntimeError(f"publish failed:\n{up.stderr}")
print(up.stdout)
sbx.kill() # explicit: destroy the credentialed VM immediately
# Nothing persists: not the token, not the .npmrc, not the artifact.Read the guest's threat surface here and it's almost empty. The only code in the VM is `sha256sum` and the registry client. There is no dependency tree, so there is no lifecycle script. The token exists for the duration of one upload inside a VM with its own guest kernel, and then the VM is gone. Compare that to a token sitting in a runner's home directory across every job for the next ninety days.
The reason a VM per release step is practical rather than aspirational is that PandaStack creates sandboxes by restoring a baked Firecracker snapshot on demand — p50 179ms, p99 around 203ms, with the snapshot-restore step itself roughly 49ms (only a true first cold boot, before any snapshot exists, runs ~3s). Spinning up a dedicated, disposable machine for a thirty-second upload is not a boot-time tax you avoid by reusing a runner across trust domains. If you want every publish VM to start from an identical, audited base — registry config, CA bundle, CLI versions pinned — snapshot one configured sandbox and fork it: same-host forks land in 400–750ms (cross-host 1.2–3.5s) and share memory copy-on-write.
Egress: let it reach the registry and nothing else
Isolation decides what a compromised guest can touch locally; egress control decides whether it can tell anyone. These are different controls and you want both, with different postures per VM. The publish VM's needs are unusually easy to express, which is exactly why you should express them: it must reach one registry endpoint over TLS, and nothing else, ever. That's a default-deny allowlist with a single entry — the rare case where the restrictive policy is also the simple one.
- Publish VM: default-deny egress with an allowlist containing only the registry host. It has no other legitimate destination, so any other connection attempt is by definition a problem.
- Preflight VM: allow the package registry and your source host, deny the rest. This VM has no credentials, but denying arbitrary egress still stops a hostile dependency from beaconing out or pulling a second-stage payload.
- Both VMs: block the cloud metadata endpoint (169.254.169.254) unconditionally. Nothing in a build or an upload has business asking a hypervisor for instance credentials.
- Treat DNS as an exfil channel — an allowlist naming only your registry closes the trick of encoding a stolen token into a subdomain lookup.
- Per-VM network isolation is the default, not an upgrade: each PandaStack agent pre-allocates 16,384 /30 subnets, so every sandbox gets its own namespace and there's no shared bridge to sniff.
Auditing what the agent actually ran
"The agent published version 2.4.1" is a fact. "The agent ran fourteen commands, three of which it wrote itself, one of which installed a package not in the lockfile" is the fact you actually need during an incident. Agent-driven releases make the audit trail more valuable than in a human pipeline, because the command list isn't fixed — the model chose it at runtime, partly in response to inputs you didn't write.
The good news is that the sandbox boundary is a natural chokepoint for this. Every command reaches the guest through `exec`, so logging the invocation, exit code, and output tail on the host side gives you a complete record with no in-guest agent to tamper with. Attach that record to the artifact hash and you have something that answers questions six months later.
#!/usr/bin/env bash
# Runs INSIDE the preflight microVM, right before the artifact is packed.
# Everything here is evidence attached to the candidate release.
set -euo pipefail
cd /work
# 1. Prove the credential surface is genuinely empty in this guest.
for f in ~/.npmrc ~/.pypirc ~/.cargo/credentials.toml ~/.docker/config.json; do
[ -e "$f" ] && { echo "FAIL: credential file present: $f"; exit 1; }
done
echo "ok: no registry credentials in preflight guest"
# 2. Flag lifecycle scripts in OUR OWN package — a postinstall appearing
# in your published package is a diff worth waking someone up for.
node -e 'const s=require("./package.json").scripts||{};
const danger=["preinstall","install","postinstall","prepublishOnly"];
const found=danger.filter(k=>s[k]);
if(found.length){console.log("REVIEW lifecycle scripts:",found.join(","))}
else{console.log("ok: no install-time lifecycle scripts")}'
# 3. Record exactly what will ship: the file list npm would include.
npm pack --dry-run --json > /out/filelist.json
echo "ok: shipped-file manifest written"
# 4. Record the resolved dependency tree that produced these bytes.
npm ls --all --json > /out/deptree.json 2>/dev/null || true
echo "ok: dependency tree captured"
Two of those checks earn their keep disproportionately. The credential-absence assertion turns "we're pretty sure preflight has no token" into a build failure if someone ever wires one in by accident — configuration drift is how these splits quietly stop being splits. And the shipped-file manifest catches the classic packaging own-goal, which is not a malicious dependency at all but a `.env` or a private key sliding into the tarball because someone edited `files` and nobody looked. Registries do not let you take that back.
When this is overkill
If a human runs releases from a laptop, on a package with a small audited dependency tree, and no agent or contributor influences what gets installed, the two-VM split is more machinery than the risk deserves. Use a scoped token, use your registry's short-lived publishing auth if it offers one, and get on with your day. The apparatus here exists for a specific situation: a release path where the set of code that runs is chosen at runtime — by a model, a contributor's PR, or a dependency tree you don't fully control — while a credential that can push to the world is somewhere on the same machine.
That situation is becoming the normal one, which is why it's worth building the structural fix rather than the vigilant one. The reason to like the split isn't that microVMs are exciting; it's that it converts a judgment call you have to make correctly every single release into a property of the topology that holds whether or not anyone was paying attention. The preflight VM has nothing to steal. The publish VM has nothing to run. And an agent that gets prompt-injected into doing something clever finds itself in a machine that was designed to be a disappointing place to end up.
Frequently asked questions
Why is it risky to let an AI agent run npm publish on a shared CI runner?
Because that runner holds a publish credential in a plain file (~/.npmrc, ~/.pypirc, ~/.cargo/credentials.toml) while also executing hundreds of packages' worth of install-time code. Any lifecycle script, build plugin, or agent-authored helper running as that user can read the token and exfiltrate it — no exploit needed, just a file read and a socket. An agent raises the rate at which novel, runtime-chosen code lands on that machine, especially if its inputs (a README, an issue, a tool result) can be attacker-controlled. The problem isn't the agent; it's that untrusted code and your most powerful credential share a machine.
What is the release-preflight pattern?
Split the release into two microVMs with disjoint capabilities. The preflight VM clones, installs, tests, and builds the artifact with zero publish credentials present — all the third-party and agent-generated code runs there, and it has nothing worth stealing. It emits an artifact plus a SHA-256 hash. A human or policy engine approves that exact hash. Then a second, freshly created microVM receives the approved bytes and a short-lived token, verifies the hash, runs only the upload, and is destroyed. At no point does one machine hold both untrusted code and a publish credential.
Doesn't a scoped or short-lived token already solve this?
It helps a lot and you should use it — project-scoped tokens and OIDC-based short-lived publishing (like PyPI Trusted Publishing) shrink both the scope and the lifetime of what an attacker can steal. But a short-lived token is still fully usable during the job it's issued for, which is exactly the window in which npm install is running arbitrary third-party code on the same box. Scoping reduces the damage; isolation reduces the opportunity. The two compose: mint a narrow, short-lived credential and hand it only to a VM that runs one upload command and then dies. Check your registry's current docs, since short-lived publishing auth is evolving quickly.
What should the approval gate actually check?
A specific SHA-256 over specific bytes, not a summary written by the agent. Put in front of the approver: the version number, the artifact hash and size, the diff of the package manifest (especially the files list, bin entries, and any lifecycle scripts — a postinstall appearing in your own package deserves a hard stop), the list of commands preflight actually executed, and the test exit code. An automated policy can enforce: valid semver bump from published latest, no new lifecycle scripts, hash present in the preflight record, tests exited zero. The publish step then refuses any artifact whose hash doesn't match what was approved.
How should egress be configured for a publishing microVM?
Default-deny with an allowlist containing only the registry host — the publish VM has exactly one legitimate destination, so any other connection attempt is by definition anomalous. Block the cloud metadata endpoint (169.254.169.254) unconditionally in both VMs, and treat DNS as an exfil channel so an allowlist naming only your registry also blocks encoding a stolen token into a lookup. Give the preflight VM slightly wider egress (registry plus your source host) so installs resolve, while still preventing a hostile dependency from beaconing out or pulling a second stage.
Isn't creating a microVM per release step too slow?
No. PandaStack creates a sandbox by restoring a baked Firecracker snapshot rather than cold-booting: p50 179ms, p99 around 203ms, with the restore step itself about 49ms — only the first-ever cold boot of a template takes around 3 seconds. So a dedicated, disposable VM for a thirty-second upload costs milliseconds of provisioning, not minutes. If you want every publish VM to start from an identical audited base with pinned CLI versions and registry config, snapshot one configured sandbox and fork it — same-host forks are 400–750ms and share memory copy-on-write.
49ms p50 cold start. Fork, snapshot, and scale to zero.