# How to put sandbox in front of npm, pnpm, yarn, and bun

By Jag Reehal, 2026-06-15. Canonical: https://arrangeactassert.com/posts/how-to-put-sandbox-in-front-of-npm-pnpm-yarn-and-bun/

<!-- Excerpt Start -->

Get 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](/posts/npm-install-runs-code-you-never-read/), then [what running it in a throwaway container buys you](/posts/how-sandbox-runs-risky-installs-in-a-throwaway-container/). Otherwise, read on.

<!-- Excerpt End -->

## 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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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](https://github.com/vercel-labs/skills):

```bash
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](https://github.com/jagreehal/sandbox-node) 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):

```yaml
# .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:

```bash
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:

```yaml
- 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:

```bash
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:

```bash
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:

```bash
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:

```jsonc
// sandbox.config.json — Aikido's public npm malware list
{ "install": { "malwareFeeds": ["https://malware-list.aikido.dev/malware_predictions.json"] } }
```

```bash
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:

```bash
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:

```bash
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.