How to put sandbox in front of npm, pnpm, yarn, and bun
15 Jun 2026Get the sandbox command, run one setup step, and put sandbox in front of the commands you already run.
New to Node, or not sure why npm install is risky in the first place? Start with why install-time code is a trust decision, then what running it in a throwaway container buys you. Otherwise, read on.
Requirements
One dependency: Docker. Docker Desktop, OrbStack, or any Docker-compatible engine works, and the CLI builds its own images on first run. You also need Node 20 or newer, on macOS or Linux (on Windows, run it inside WSL2). Make sure the engine is running before you start.
The fast path is short:
npx @jagreehal/sandbox-node@latest setup --vibe
npx @jagreehal/sandbox-node@latest npm install
That is enough to try it on a fresh clone without changing your shell or project setup first.
What you get
This is not a new workflow. It is the same install and run commands with the risky part moved behind a boundary.
Same workflow, smaller blast radius.
That means:
- You keep install scripts working
- You keep npm, pnpm, yarn, and bun
- You can apply the same boundary in local dev, AI-agent sessions, and CI
- You stop depending on memory and caution every time you clone a repo
Get the command
Use @latest so npx does not reuse a stale cached version. For day-to-day work, add it to the project:
npm install -D @jagreehal/sandbox-node
The CLI installs as both sandbox and sandbox-node.
One-button setup
sandbox setup writes a config if you do not have one, checks Docker, builds the images if needed, and prints the next commands:
sandbox setup --vibe # safe defaults for exploring and cloning repos
sandbox npm install # install deps, lifecycle scripts contained
sandbox pnpm add zod # add a dependency
sandbox npm run dev # run a dev server, tests, a build
--vibe is the relaxed preset for exploring: containment is on, but the release-age gate is warn-only so a fresh clone is not blocked. When you want the gate enforcing, pick a stricter preset:
sandbox init --preset balanced # blocks versions younger than 3 days (the default)
sandbox init --preset strict # blocks 7 days, plus the OSV malware check and canary tokens
Preview any command in plain English before it runs, without executing it:
sandbox --dry-run npm install
It prints what would be mounted, which hosts the install can reach, and that your host credentials stay out.
Make it automatic
Remembering to type sandbox on every install is a rule you will break under pressure. Set the boundary once instead.
sandbox path installs shell functions so a bare npm install routes through the sandbox out of habit:
sandbox path install # auto-detects zsh, bash, or fish
sandbox path status
sandbox path uninstall
After you open a new terminal, the install vector (npm install, pnpm add, npm ci, npx, and friends) runs sandboxed, while npm run dev, npm test, and node app.js hit the real tool on the host untouched. Two escape hatches are always there:
command npm install # bypass the wrapper for one call
export SANDBOX_OFF=1 # disable the wrappers for the whole shell
This is a convenience guardrail, not the boundary itself. The real protection is still sandbox running the command in a container.
Cloning a repo or letting an agent install
This is where containment earns its keep:
git clone https://github.com/some/app && cd app
sandbox setup --vibe
sandbox npm install
sandbox npm run dev # common dev ports are auto-forwarded
For coding agents like Claude Code, the --agent preset stops relying on the model to remember the rule:
sandbox init --agent
It writes .sandbox/AGENT.md (guidance the agent reads), a PreToolUse hook that blocks a bare npm install or npx on the host and tells the agent to re-run it through sandbox, and permissions.deny rules so the agent cannot read .env or secrets/** into its own context. A model that ignores the markdown still hits the hook.
To contain the agent's whole session rather than each operation, generate a hardened devcontainer from the same config:
sandbox devcontainer init
# then: open in VS Code, "Reopen in Container", run the agent inside
The per-operation form (default) and the per-session form (devcontainer) apply the same hardening at two lifecycles, both driven by one sandbox.config.json.
Give the agent the install skill
The repo also ships a Claude Code skill, sandbox-install, that turns the agent into the human-in-the-loop reviewer. Instead of installing blind, the agent runs sandbox preflight first (the gates, without installing anything), shows you each finding with a recommended action, and runs the real install only once you have cleared the risk, with the flags that match your choices. It will not proceed past a known-malware advisory on its own.
Install it with the skills CLI:
npx skills add jagreehal/sandbox-node
That drops the skill into your agent's skills directory, and the agent picks it up when you ask it to install or vet a package. If you would rather not use the CLI, copy skills/sandbox-install from the repo into your project's .claude/skills (or ~/.claude/skills for every project) by hand.
Wire it into CI
CI is where untrusted dependency code runs unattended, so it is where containment pays off most. The pattern is --frozen (reproducible, read-only install) plus --fail-on-egress (fail the build if install-time code tries to phone home):
# .github/workflows/install.yml
jobs:
install:
runs-on: ubuntu-latest # the runner already has Docker
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm i -g @jagreehal/sandbox-node
- run: sandbox --frozen --fail-on-egress npm install
- run: sandbox npm test
The guarantee this gives you has two honest halves. Host secrets stay out: anything you do not explicitly grant, your home credentials and any environment variable you did not forward, is never in the container for dependency code to read. Nothing leaves without a route: if a host is not in egress.allow, install-time code cannot send data there, and --fail-on-egress turns the attempt into a failed build instead of a silent leak.
What the install can read is the project tree it runs against. A secret committed into the repo, or the checkout token below, sits in that tree and is readable inside the box. Default-deny egress is what contains it: readable, but with nowhere to send it.
--frozen needs a committed, in-sync lockfile. If an install legitimately needs a host (a private registry, nodejs.org for native modules), allow it once so it is committed in your config:
sandbox allow npm.pkg.github.com
One CI-specific gotcha worth knowing: actions/checkout writes the job's GitHub token into .git/config by default. sandbox mounts .git read-only, so install code can read that token but not change it, and default-deny egress stops it leaving. To keep it out of the tree entirely, set persist-credentials: false on the install job:
- uses: actions/checkout@v4
with:
persist-credentials: false
To gate only what a pull request changes, sandbox delta diffs the lockfile against the merge target and runs the age, malware, and deprecation gates over just the added or bumped versions. Run sandbox scan on a nightly schedule to catch dependencies that turn malicious after merge, and gate merges with sandbox verify so the committed boundary cannot be quietly loosened.
Grant a secret when an install needs one
The container starts with an almost-empty environment, so nothing from your shell or CI runner is in the box by default. When an install genuinely needs one host secret, like a private-registry token, grant exactly that and nothing more:
sandbox --env NPM_TOKEN npm install # forward one host var by name (its value only)
sandbox allow npm.pkg.github.com # plus allow the private-registry host
To pull values from an env file, use --env-from, and narrow it to specific keys with a :KEY,KEY suffix so the rest of the file stays out:
sandbox --env-from .env npm install # inject every key in .env
sandbox --env-from .env:NPM_TOKEN npm install # inject only NPM_TOKEN; everything else stays out
sandbox --env-from .env tsx script.ts # one-off script with its env injected
The file is never mounted; only the values you ask for become container env vars. To make a grant permanent for the project, put it in sandbox.config.json under grants.env (named vars) or grants.envFiles (files, with the same :KEY,KEY filtering), so it is reviewed in one place instead of retyped each run.
One thing to know: a .env that lives inside the project is not a grant. It is part of the tree sandbox mounts so the install can run, so dependency code can read it like any other project file. Default-deny egress is what keeps that contained: the code can read a project .env, but under the registry-only allowlist it has nowhere to send the contents. Keep egress.allow tight if your tree holds real secrets, and prefer ssh-agent over mounting key files.
Going further
The defaults cover everyday installs. A few more commands are worth knowing once it is running.
Review before you install. sandbox preflight runs the same gates as a real install (release-age, malware, deprecation, risk hints) without installing anything. It prints each finding and exits non-zero exactly when the install would be blocked, so it drops into a pre-merge check. Tighten it per run:
sandbox --min-release-age 7 --fail-on-advisory preflight npm install
Check your setup. sandbox doctor validates the config, package manager, and container backend, and flags a container-escape CVE in the runtime or an end-of-life Node line in the image.
Block packages you already know are bad. When you do not want to wait for OSV, drop a committed sandbox.advisories.json in the repo, or list malware feeds you trust and cache them locally:
// sandbox.config.json — Aikido's public npm malware list
{ "install": { "malwareFeeds": ["https://malware-list.aikido.dev/malware_predictions.json"] } }
sandbox feeds update # fetch and cache the feeds; the install check then reads them offline
A match in either source always blocks, independent of the OSV lookup.
Catch theft in the act. --canaries plants fake AWS, Stripe, and Slack credentials in the install environment. If one ever leaves the box, the run fails with proof of exfiltration. It is on by default in the strict and agent presets.
Re-check later, and scan the repo. Dependencies can turn malicious after you install them, and secrets can slip into the repo:
sandbox scan # re-query OSV for every version in the committed lockfile
sandbox secrets # offline scan for committed credentials (values redacted)
Neither needs a container, and both exit non-zero on a finding, so they fit a nightly cron or a CI step.
Prove the boundary held. For a chain of custody, sandbox verify --sign emits an Ed25519-signed receipt that the boundary is intact, which a later stage checks with sandbox verify-receipt. Set SANDBOX_AUDIT_LOG=<path> to append a tamper-evident, hash-chained log of every run.
Watch it work
You do not have to take the claims on faith:
sandbox demo
It runs real supply-chain attacks (credential theft, a persistence hook, an IMDS pivot, egress exfiltration) against the live sandbox in a throwaway project and shows each one contained. It exits non-zero if any attack is not contained.
Bottom line
Get Docker running, npm install -D @jagreehal/sandbox-node, run sandbox setup, and put sandbox in front of the commands you already type. Wire sandbox path into your shell so you stop having to remember, and add the --frozen --fail-on-egress step in CI.
Your installs run the same as before. Same workflow, smaller blast radius. The code they execute has nothing of yours to steal and nowhere to send it.