sandboxpm

Security model

npm, pnpm, and yarn all execute preinstall, install, and postinstall scripts silently during installation. A malicious package can read your SSH keys, exfiltrate .env files, or install a backdoor — all during a simple npm install. sandboxpm operates on a zero-trust principle instead:

  • No package has filesystem or network access during installation unless explicitly granted.
  • Scripts are never executed silently — every script requires developer consent.
  • Approved scripts run in ephemeral Docker containers with no access to host credentials, SSH keys, or environment variables (beyond an explicit, minimal envPassthrough allowlist).
  • All tarballs are verified against the SHA-512 published by the registry before extraction — never after.

The install-script prompt

When sandboxpm finds a package with a lifecycle script, you see exactly what it is before anything runs:

──────────────────────────────────────────────────────
⚠  3 packages have install scripts
──────────────────────────────────────────────────────

  1. esbuild@0.19.4
     Type:    postinstall
     Script:  node install.js
     Inspect: https://unpkg.com/esbuild@0.19.4/install.js

     Run this script? [y/N/inspect/always/never]

.sandboxpmrc's policies.onWarn / policies.onBlock settings let a team pre-decide how sandbox warnings and blocks are handled in CI (abort, prompt, or continue), and whitelist/ blacklist entries let you pre-approve or permanently ban specific packages — see Configuration.

Typosquat & package-risk checks

The install-script prompt only helps once a malicious package is already being installed. Before that, during dependency resolution, every package name is checked against a bundled snapshot of popular npm package names using edit-distance (so lodahs, epxress, or expres5 all get caught as look-alikes of lodash/express). A name-similarity match is escalated to high severity when the matched package is also newly published (under 90 days old, or has very few released versions) or has a single maintainer — signals pulled from data the resolver already has in hand, with no extra network calls.

──────────────────────────────────────────────────────
⚠  lodahs@1.0.0 looks risky
──────────────────────────────────────────────────────
  Severity: high
  Reason:   typosquat:lodash(distance=1)
  Reason:   new-package(12 days old)

  Proceed with this package? [y/inspect/always/never]

.sandboxpmrc's policies.onPackageRisk (continue/prompt/abort) controls this the same way onWarn controls the script prompt. trustedPackages exempts specific names from the check permanently (e.g. an internal fork); blockedPackages is a hard blocklist — any match aborts resolution before a single tarball is downloaded, and it applies even to installs from an existing sandboxpm.lock, since it's a pure name comparison. Manage both lists with sandboxpm trust add/remove and sandboxpm block add/remove, or edit .sandboxpmrc directly — see Configuration.

Known limits: there's no live CVE/advisory feed and no live npm download-count signal (both would require an ongoing external data dependency); the bundled popular-package list is a manually curated snapshot, not an automatically refreshed top-N export; and a lockfile-based install (sandboxpm.lock already fresh) skips the typosquat/low-trust scan entirely, since it never contacts the registry — only the blockedPackages hard blocklist still applies there.

Content-addressable store

Every file from every package is stored once in ~/.sandboxpm/store/, keyed by its SHA-512 hash. When a package is installed into a project, its files are hard-linked from the store — never duplicated across projects:

~/.sandboxpm/store/
└── sha512-{hash}    ← one file, shared across all projects via hard links

project-a/node_modules/express  →  hard link to store
project-b/node_modules/express  →  same hard link, zero extra disk space

Non-flat node_modules

Only direct dependencies appear in the root node_modules/. Transitive dependencies live nested under node_modules/.sandboxpm/. This prevents phantom dependency access: your code can only import what you explicitly declared in package.json.

Docker sandbox

Approved scripts run through dockerode in a container that is:

  • read-only, with CapDrop: ALL
  • restricted by an explicit seccomp syscall allowlist (Linux) or Hyper-V isolation (Windows)
  • attached to an isolated bridge network with no host credential or network access by default

A native (unsandboxed) fallback exists for sandbox-start failures or Linux-ELF/Windows-host native-addon mismatches, but it requires an explicit double-confirmation — sandboxpm never falls back to running a script unsandboxed silently. Full detail on the sandbox images and syscall policy lives in Architecture.