packjail runs npm, pnpm, yarn, and bun installs inside a sandbox so
package install scripts can do the work they need without getting broad access
to your machine. It keeps package installs usable while making credential
theft, surprise downloads, and outbound callbacks to untrusted hosts fail.
What it does:
- Sandboxes install commands for npm, pnpm, Yarn, and Bun.
- Runs install scripts with project-only filesystem access and a temporary home directory.
- Denies network egress by default, except for built-in package registry hosts and hosts you explicitly allow.
- Applies a 48-hour minimum package age gate by default; opt out per install
with
--skip-min-package-age, or change it with--min-package-age=<duration>. - Provides optional runtime containment with
packjail run,packjail dev, andpackjail shell. - Supports host sandboxes on macOS/Linux/WSL2 and an experimental OCI container backend with Docker or Podman.
npm supply-chain attacks often rely on install scripts: read ~/.npmrc,
~/.ssh, cloud credentials, or wallet files; download a second-stage payload;
then beacon to an attacker-controlled host. A tool like packjail would have
broken the install-time mechanics used by incidents such as eslint-scope,
ua-parser-js, coa/rc, TeamPCP / Mini Shai-Hulud credential-stealing
waves such as the May 2026 TanStack compromise, PhantomRaven remote tarballs,
and Bun downloader payloads.
packjail is containment, not malware detection. It does not decide whether a package is good or bad; it limits what install scripts can touch and where they can connect.
flowchart LR
A["Package request: latest / semver range / lockfile"] --> B{"Version newer than 48h?"}
B -->|yes, range or latest| B1["Fall back to newest eligible older version"]
B -->|yes, exact or lockfile| B2["Block install before scripts run"]
B -->|no| C["Install selected package"]
C --> D["preinstall / postinstall runs"]
D --> E["Try to read credentials"]
D --> F["Try to download second-stage payload"]
D --> G["Try to contact cloud metadata"]
D --> H["Try to publish more malware"]
D --> I["Try to exfiltrate secrets"]
E -. blocked .-> E1["Real HOME, ~/.npmrc, ~/.ssh, cloud config not mounted"]
F -. blocked .-> F1["Only registry and explicitly allowed hosts are reachable"]
G -. blocked .-> G1["169.254.169.254 is unreachable"]
H -. blocked by default .-> H1["No npm auth token is mounted or forwarded"]
I -. blocked .-> I1["Attacker hosts are denied by the proxy"]
subgraph PackJail["packjail controls"]
B1
B2
E1
F1
G1
H1
I1
end
style D fill:#3d1f24,color:#fff,stroke:#ff7b72
style E fill:#3d1f24,color:#fff,stroke:#ff7b72
style F fill:#3d1f24,color:#fff,stroke:#ff7b72
style G fill:#3d1f24,color:#fff,stroke:#ff7b72
style H fill:#3d1f24,color:#fff,stroke:#ff7b72
style I fill:#3d1f24,color:#fff,stroke:#ff7b72
style B fill:#1f2937,color:#fff,stroke:#9ca3af
style PackJail fill:#0d1117,stroke:#1f6feb,color:#c9d1d9
| Platform | Requirement |
|---|---|
| Linux | bubblewrap (bwrap), install via your distro's package manager |
| macOS | sandbox-exec (ships with macOS) |
| Windows | WSL2 with packjail installed inside the distro |
Optional container/runtime containment:
| Feature | Requirement |
|---|---|
--backend=container, packjail run, packjail dev, packjail shell |
Docker or Podman |
Install the Linux sandbox dependency with your distro package manager:
sudo apt install bubblewrap # Debian / Ubuntu
sudo dnf install bubblewrap # Fedora
sudo pacman -S bubblewrap # ArchmacOS already ships sandbox-exec. On Windows, install WSL2 with
wsl --install, then install packjail inside the distro using the Linux
instructions above. Without WSL2, packjail refuses to run.
To build from source: Go 1.22 or newer.
Unix/Linux/macOS:
curl -fsSL https://github.com/Icex0/packjail/releases/latest/download/install-packjail.sh | shWindows PowerShell:
iex (iwr "https://github.com/Icex0/packjail/releases/latest/download/install-packjail.ps1" -UseBasicParsing)To pin a specific version, replace latest with the version tag:
curl -fsSL https://github.com/Icex0/packjail/releases/download/v0.1.0/install-packjail.sh | shiex (iwr "https://github.com/Icex0/packjail/releases/download/v0.1.0/install-packjail.ps1" -UseBasicParsing)The Unix installer downloads packjail-<os>-<arch> and installs it to
/usr/local/bin by default. Set PACKJAIL_INSTALL_DIR to install
elsewhere. The PowerShell installer downloads packjail-windows-<arch>.exe,
installs it under %LOCALAPPDATA%\Programs\packjail, and adds that
directory to the current user's PATH.
After installation, run:
packjail setupThen restart your shell, or source your shell profile, so normal
npm install, pnpm install, yarn install, and bun install commands are
wrapped automatically.
git clone https://github.com/Icex0/packjail.git
cd packjail
make build
sudo install -m 0755 packjail /usr/local/bin/packjailOr, without make:
go install github.com/Icex0/packjail/cmd/packjail@latestThis puts the binary at $(go env GOPATH)/bin/packjail. Add that
directory to your PATH if it isn't already.
To sandbox normal install commands automatically, run:
packjail setupThis updates ~/.bashrc and ~/.zshrc on macOS/Linux, or the current-user
PowerShell profile locations on Windows, with marked shell functions for
npm, pnpm, yarn, and bun. Restart your shell afterwards.
The wrapper is deliberately conservative. It auto-sandboxes install-like commands only:
| Tool | Auto-wrapped commands |
|---|---|
npm |
install, i, ci, add, update, up, rebuild, dedupe |
pnpm |
install, i, add, update, up, ci |
yarn |
bare yarn, install, add, upgrade |
bun |
install, i, add, update, upgrade |
When a wrapper fires, npm install ... becomes packjail npm install ....
Arguments after npm install still belong to npm, not packjail:
npm install lodash --save-dev
bun install --frozen-lockfileTo pass packjail flags, call packjail explicitly:
packjail --allow download.cypress.io npm install
packjail --allow-registry-auth npm ci
packjail --backend container bun install
packjail --timeout 10m npm installFor project-wide network exceptions, prefer .packjail-allow; wrapped
commands read it automatically.
Runtime commands are not wrapped implicitly. Use packjail run ...,
packjail dev ..., or packjail shell when you want runtime containment.
To bypass the wrapper for a single call, use command npm ...,
command bun ..., and so on. To remove the shell integration:
packjail teardownpackjail npm ci
packjail npm install lodash
packjail pnpm add react
packjail --allow download.cypress.io npm install
packjail --verbose npm ci
packjail --dry-run npm ci
packjail run bun test
packjail dev --port 3000 bun run dev
packjail shell
packjail setup
packjail teardownrun, dev, and shell use the container backend by default. They are the
runtime-sandboxing interface: commands run in the same container sandbox shape
as installs. dev publishes only explicitly requested ports, bound to
127.0.0.1 on the host.
setup installs the shell wrappers described above. teardown removes them.
Flags (must come before the package manager command):
| Flag | Meaning |
|---|---|
--allow-user-npmrc |
Mount ~/.npmrc into the sandbox read-only. Use this if your registry auth token is stored there as a literal. |
--allow-registry-auth |
Pass NPM_TOKEN, NODE_AUTH_TOKEN, GITHUB_TOKEN, and GH_TOKEN through to the sandbox. Required for installs from a private npm registry, GitHub Packages, or git+https private GitHub repos. See "Private registries" below; the token must be read-only. |
--allow-insecure-network |
Disable backend network isolation where supported. Intended for debugging compatibility issues; malicious scripts can ignore proxy environment variables or direct bridge networking. |
--backend <name> |
Select host, container, or auto. auto currently means host. The container backend is experimental but enforces proxy-only networking by default using an internal container network and proxy sidecar. |
--allow <hostname> |
Add a hostname to the proxy allowlist for this invocation only. Repeatable. |
--timeout <duration> |
Kill the install after a duration such as 30m, 10m, or 90s. Defaults to 30m. |
--min-package-age <duration> |
Avoid public npm registry package versions published more recently than this age. Defaults to 48h; use 0 to disable. |
--skip-min-package-age |
Disable minimum package age checks for this invocation. |
--verbose |
Print sandbox setup details (mounts, proxy address, generated profile). |
--dry-run |
Print what would be executed and exit 0. |
--version |
Print the packjail version. |
--help |
Show the help text. |
Runtime command options:
| Command | Meaning |
|---|---|
run <command> |
Run a build/test/runtime command in the container sandbox. |
dev --port <port> <command> |
Run a long-lived dev command and publish a loopback-only port. Repeat --port; use 3000 or 5173:3000. |
shell |
Start a basic shell in the container sandbox. |
Network egress is denied by default. Install scripts can only reach hosts
that are in packjail's allowlist. The effective allowlist is built from
the defaults below, hosts in .packjail-allow, and any --allow <hostname>
flags for that invocation.
The canonical npm registry and these ecosystem registry/download hosts are allowed by default:
| Host | Used for |
|---|---|
registry.npmjs.org |
npm (and pnpm) default |
registry.yarnpkg.com |
Yarn 1 default; Facebook-operated mirror of npm |
repo.yarnpkg.com |
Yarn Berry default |
codeload.github.com |
GitHub's tarball endpoint, used by npm/pnpm for github:org/repo and git+https://github.com/...#commit deps |
These are unconditional defaults; you don't need to add them.
Project .npmrc files do not expand the packjail network allowlist. If your
project uses a private or custom registry, explicitly trust that host with
.packjail-allow or --allow <hostname>.
By default, packjail avoids public npm registry package versions published in the last 48 hours. This gives newly published malware time to be reported or removed before install scripts can run.
packjail --min-package-age 24h npm install
packjail --min-package-age 0 npm install
packjail --skip-min-package-age npm installFor normal latest and semver-range installs, packjail asks the package
manager to resolve only versions older than the configured age, so npm, pnpm,
Bun, and Yarn Berry >=4.10 can fall back to the newest eligible version
instead of failing. Yarn Classic does not support native age-gated resolution,
so packjail checks those range/tag specs itself and blocks if the selected
public npm version is too new. For exact versions where there is no safe
fallback, packjail preflights command arguments, direct dependencies in
package.json, and exact versions found in package-lock.json,
pnpm-lock.yaml, yarn.lock, and Bun's text bun.lock.
Packages fetched from private/custom registries are not age-checked by this
public npm registry preflight; use pinned lockfiles and explicit allowlists for
those.
After a successful install, packjail prints a short minimum-age summary. If all
checked latest/range requests were eligible, it says so; if a fresh latest
was avoided, it names the package and the older version that was installed.
Create a file .packjail-allow at your project root with one
hostname per line. # introduces a comment; *.example.com matches
any subdomain (but not the bare suffix).
# Cypress downloads its binary from this CDN
download.cypress.io
# Allow any subdomain
*.cypress.io
Real-world projects often depend on package-bundled native binaries
or browsers that are downloaded at install time from non-registry
CDNs. Add to .packjail-allow per project:
# Puppeteer downloads Chromium from Google Cloud Storage. Note: this
# whitelists ALL of GCS, not just Puppeteer's bucket. If you only need
# Puppeteer to work, that is the trade. Otherwise pin to your own bucket.
storage.googleapis.com
# Playwright downloads browser binaries (microsoft.com subdomains)
cdn.playwright.dev
playwright.download.prss.microsoft.com
# Many native modules download a prebuild from GitHub releases
github.com
objects.githubusercontent.com
# Node.js version probes used by some tooling
nodejs.org
# Homebrew formula probes (Sharp, node-gyp build env checks).
# Optional. Blocking it is fine; installs that use it fall back
# to bundled prebuilds.
# formulae.brew.sh
# Used by some yarn/pnpm projects fetching prebuilt binaries from a
# project-specific S3 bucket. Replace with your actual bucket name.
# my-project-binaries.s3.amazonaws.com
An allowed host is fully trusted: a compromised package can POST,
PUT, or DELETE to it as easily as GET.
packjail strips env vars matching TOKEN/SECRET/KEY/
PASSWORD/CREDENTIAL by default, which breaks the standard
${NPM_TOKEN}-in-.npmrc pattern. Pass --allow-registry-auth to
opt four standardized names back in (NPM_TOKEN, NODE_AUTH_TOKEN,
GITHUB_TOKEN, GH_TOKEN):
packjail --allow-registry-auth npm ciIf your private registry is not one of the default hosts above, also add it explicitly:
packjail --allow-registry-auth --allow registry.company.example npm cior put registry.company.example in .packjail-allow.
Use a read-only token. A leaked publish-capable token in the
sandbox is a working npm publish credential.
- npm: granular access token with Read-only permission, scoped to specific packages.
- GitHub Packages /
git+https: fine-grained PAT withread:packages(andContents: readfor private repos). Not classic PATs.
packjail currently has two execution backends:
-
--backend=host(default viaauto) runs the host package manager inside the platform sandbox. This produces host-nativenode_modulesand is the most compatible path for normal local development, but it is the harder security boundary: the sandbox must expose just enough of the host toolchain for Node/npm/pnpm/yarn/bun, git, curl, shells, native builds, and package manager helpers to work. -
--backend=containerruns the command in an OCI-compatible container using Docker or Podman. This is the intended direction for stronger install and runtime containment, because the filesystem, process tree, home directory, tmp, and runtime image are explicit.In secure mode, the workload container is attached only to an internal container network. A trusted proxy sidecar on that network forwards
HTTP_PROXY/HTTPS_PROXYtraffic to packjail's host-side filtering proxy; direct sockets from the workload have no routed egress.Container runtime and image knobs:
Setting Meaning PACKJAIL_RUNTIME=podman/dockerForce a container runtime. PACKJAIL_IMAGEOverride the workload image. PACKJAIL_PROXY_IMAGEOverride the proxy sidecar image. Defaults to docker.io/alpine/socat:latest.PACKJAIL_VOLUME_LABEL=z,Z, ordisableOverride SELinux bind-mount labeling behavior. Default workload images:
Command Image npm / pnpm / yarn docker.io/library/node:22-bookwormbun docker.io/oven/bun:1The npm/pnpm/yarn image uses the full Debian Node image rather than
node:*-slimbecause real install scripts commonly assumecurl,git,ssh, Python, and a basic native build toolchain exist. Image names are fully qualified so Podman does not need interactive short-name resolution.Linux notes:
- On SELinux-enforcing hosts such as Fedora, Podman bind mounts are labeled
with
:Zby default so the container can read/write the project directory. - The host-side filtering proxy binds a temporary bridge-reachable TCP
listener so Docker/Podman containers can reach it via
host.docker.internal/host.containers.internal. The proxy still enforces the packjail allowlist, but the container runtime and local host should be treated as trusted for the duration of the command.
- On SELinux-enforcing hosts such as Fedora, Podman bind mounts are labeled
with
The long-term recommended container model is: edit source files on the host,
run install/build/test/dev commands through packjail inside the same
container environment, and avoid treating container-installed node_modules
as host-native artifacts.
The detailed tables below expand on the summary above. packjail does not stop
malicious code that later runs in commands you execute outside packjail; use
packjail run, packjail dev, or packjail shell when you want build, test,
dev, or runtime containment too.
Detailed campaign coverage
The tables below cover real npm campaigns from 2018 through 2026. Each row says what the attack actually did and whether install-time sandboxing stops it.
| Campaign | Year | Mechanism it relies on | Why we stop it |
|---|---|---|---|
| eslint-scope | 2018 | Maintainer with no 2FA; postinstall fetched a Pastebin script and eval'd it; the script read _authToken from ~/.npmrc and exfil'd via the Referer header to histats / statcounter |
~/.npmrc not mounted; pastebin / histats / statcounter all blocked by the proxy |
| crossenv typosquat | 2017–2018 | Mimicked cross-env; install script base64'd process.env and POSTed it to an attacker server |
Names matching *TOKEN*/*SECRET*/*KEY*/*PASSWORD*/*CREDENTIAL* are stripped from the env passed to the child; the POST destination isn't in the allowlist anyway |
| ua-parser-js hijack | 2021 | Account hijack; pre/postinstall script downloaded an XMRig Monero miner from an attacker host and ran it | Miner download blocked by the proxy. Even if bundled, mining-pool egress is blocked too |
| coa / rc hijacks | 2021 | Account hijacks; identical password-stealer payloads via install scripts | Browser profiles, keychain, ssh, env-var secrets, none mounted. Exfil destinations not in allowlist |
| Dependency confusion (Birsan) | 2021 | Internal package names registered on public npm; npm pulled the public attacker version; PoC payload was a postinstall HTTP callback | Stops the install-time PoC: the callback is blocked by proxy egress filtering. (See "Not stopped"; the broader class isn't fully covered if the payload defers to app runtime) |
| Lazarus npm campaigns | 2023+ | Typosquat packages steal credentials, download InvisibleFerret, target id.json (Solana) and exodus.wallet files |
Wallet directories not mounted; backdoor download and credential exfil both blocked |
| Nx s1ngularity | Aug 2025 | Postinstall steals GitHub/SSH/npm tokens + invokes claude --dangerously-skip-permissions, gemini --yolo, q to dump filesystem secrets via the AI CLIs |
Same credential paths blocked. AI CLIs run inside the sandbox with $HOME pointing at a fresh tmpfs, so they have no config / no auth and can't read /Users/... either |
| PhantomRaven (Remote Dynamic Dependencies) | Aug 2025 → ongoing | package.json declares a dep as "https://attacker.com/x.tgz"; npm fetches the tarball; postinstall runs the payload |
Proxy denies the fetch; the attacker host isn't a registry, isn't in .packjail-allow. npm install fails before the payload runs |
| Shai-Hulud / TeamPCP / Mini Shai-Hulud family, including the May 2026 TanStack npm compromise | Sept 2025 – May 2026 | Install-time malware reads ~/.npmrc, ~/.ssh, ~/.aws, GitHub/CI/cloud credentials, Kubernetes/Vault tokens, and cloud metadata; exfils to attacker-controlled or anonymous file-upload infrastructure; self-propagates by using stolen package-manager or CI credentials to publish more compromised packages |
Fresh malicious versions are blocked by the default 48-hour minimum package age policy. $HOME is a fresh temporary directory, so normal user credentials are not visible. 169.254.169.254 is unreachable. Exfil hosts such as GitHub API endpoints or Session/Oxen upload infrastructure are not in the default allowlist. ~/.npmrc is not mounted, so npm publish has no auth token. (If you opt in to --allow-registry-auth for private installs, use a read-only granular token; see the flag's documentation above) |
| Bun "Ghost Runtime" | 2025–2026 | Postinstall downloads Bun from bun.sh, executes payload via Bun (AV monitoring node.exe misses it) |
bun.sh isn't in the allowlist; proxy blocks the download. Even if Bun were bundled in the package, it inherits the same filesystem and network restrictions |
| Axios RAT (Sapphire Sleet / DPRK) | Mar 2026 | Injected plain-crypto-js dep with a postinstall that drops a cross-platform RAT and beacons to C2 |
C2 host is not in the allowlist; the proxy blocks the CONNECT. RAT can't call home |
| 36-package Redis/Postgres implant campaign | Apr 2026 | Postinstall connects to local 127.0.0.1:5432 / :6379 to plant persistent SQL/Redis implants |
Sandbox network policy only permits 127.0.0.1:<proxy-port>. Other localhost ports are unreachable |
| Generic install-time credential theft via env vars | continuous | Reads AWS_SECRET_ACCESS_KEY, GH_TOKEN, NPM_TOKEN, … from process.env |
Env names matching *TOKEN* / *SECRET* / *KEY* / *PASSWORD* / *CREDENTIAL* are stripped before the child runs, regardless of allowlist |
The package was installed through us, but the malicious behavior happens somewhere our sandbox isn't watching. These are the cases where install-time containment alone is insufficient.
| Campaign / class | Year | Why we don't help |
|---|---|---|
| event-stream / flatmap-stream | 2018 | The malicious dep did nothing at install. It activated only when imported by Copay's bundler at build time and rewrote the bundle to harvest >100 BTC wallet keys at app runtime. The code lived in node_modules/ (writable by design), fired during npm run build (passthrough in the README's shell snippet), and the actual theft happened in end-user wallets. The canonical deferred-payload attack; threat-intel scanners are the right tool |
| node-ipc / peacenotwar | 2022 | Maintainer protestware. Geolocated by IP; on Russian/Belarusian addresses, wiped files at app runtime when the package was imported. Same deferred-payload class as event-stream. (The peacenotwar sub-dep's desktop-write is blocked at install; ~/Desktop is not mounted; but that's the harmless half) |
| chalk / debug wallet drainer | Sept 2025 | Malicious code runs in your end users' browsers when they load your shipped frontend. Nothing executes at install time. Sandboxing the install is irrelevant to a payload that activates weeks later in someone else's browser |
| Deferred runtime payloads (general class) | continuous | A postinstall can write into node_modules/foo/index.js or your project source. If you later run npm run build, npm start, npm test, or anything else outside packjail, that code executes unrestricted. Use packjail run ... / packjail dev ... to sandbox build, test, and dev commands too. |
| Slopsquatting / typosquatting / "the malware is just regular code" | continuous | If a package's malice lives in its normal index.js and only fires at app runtime, install-time sandboxing doesn't see it. Threat-intel territory |
| Project-local secret exfil to an allowed host | continuous | Files in your project root (.env, terraform.tfstate, etc.) are fully readable inside the sandbox. The proxy blocks attacker hosts, but any host you put in .packjail-allow is a permitted exfil channel for project-local data. Treat the allowlist as a trust decision |
Compromise of the package manager, registry, or sandbox host tools (bwrap, sandbox-exec) |
continuous | Out of scope. We assume npm/pnpm/yarn/bun and the OS sandbox are honest |
| Trusted Publisher / OIDC bypass attacks (e.g., the GHA path used in Axios) | continuous | Happens upstream, before the package reaches the registry |
| Sandbox escapes via kernel vulnerabilities | continuous | Out of scope; same risk class as any other OS sandbox |
Show limitations and compatibility notes
-
~/.npmrcis not mounted by default. If your auth token lives there, either move the relevant lines into a project-local.npmrc, pass--allow-user-npmrc, or use--allow-registry-authwithNPM_TOKEN/NODE_AUTH_TOKENset in the environment. Custom registry hosts still need.packjail-allowor--allow;.npmrcdoes not grant network egress by itself. -
Project directory is fully readable inside the sandbox (it has to be; the install writes into it).
.env,terraform.tfstate, and any other project-local secrets are visible to postinstall scripts. Egress filtering is the thing preventing exfiltration. -
Native modules that compile from source need a build toolchain we don't fully expose. Packages like
re2,sqlite3,better-sqlite3, and somenode-gypbuilds fall back to compiling from source when no platform prebuild is available for your machine. The sandbox lacks the full Xcode / build-essential environment those compiles need, so they fail. Workarounds:- Prefer packages that ship prebuilds for your platform (most
popular native modules do; Sharp, fsevents,
@next/swcworked cleanly in our reliability harness). - For specific projects, run the affected install outside
packjail once to populate
node_modules/, then use packjail for subsequent installs (the prebuild is now cached).
- Prefer packages that ship prebuilds for your platform (most
popular native modules do; Sharp, fsevents,
-
Postinstall scripts that don't honor
HTTP_PROXYcan't reach upstream. We setHTTP_PROXY/HTTPS_PROXY(both cases) andYARN_HTTP_PROXY/YARN_HTTPS_PROXYin the child env, and the OS-level sandbox blocks any direct socket egress. Tools that build their own HTTP client and skip the env var (notablynapi-postinstallused by some Rust-prebuild packages likeunrs-resolver, electron's prebuild downloader, and Puppeteer's newer browser-fetch path in some versions) callgetaddrinfodirectly and fail withENOTFOUND. There's no way to fix this from the packjail side without weakening the network policy; the fix has to land in the affected upstream tools. -
Linux uses a relay for proxy-only networking. Bubblewrap's
--unshare-netcreates a private network namespace whose loopback is separate from the host's loopback. packjail starts a tiny relay inside that namespace on127.0.0.1and exposes only the filtered proxy over a private Unix socket mounted from the sandbox home. Passing--allow-insecure-networkrestores the old env-var-only behavior for debugging, but malicious scripts can ignore proxy env vars and open direct sockets. -
Yarn Classic cannot fall back to older versions for minimum package age. npm, pnpm, Bun, and Yarn Berry
>=4.10have native age-gated resolution, solatestand semver ranges can select the newest eligible older version. Yarn Classic1.xhas no equivalent knob; packjail therefore blocks too-new range/tag resolutions instead of silently installing the fresh version.
packjail is licensed under the PolyForm Internal Use License 1.0.0.
Internal business use is permitted. Redistribution, sublicensing, or inclusion in a distributed product requires a separate license.