Install dependencies. Not malware.
safe-install stops malicious npm, pnpm, Yarn and bun install scripts before they run.
It analyzes your whole dependency tree from the lockfile, installs with lifecycle scripts
disabled, shows you every package that wants to run a script, and runs only the ones you
approved. Linux, macOS and Windows; single static binary.
It also covers Python (uv, pip, Poetry, Pipenv: source builds need approval) and
PHP (Composer: plugins need approval); see Python and
PHP / Composer.
- Analyze: lockfile → full tree → registry metadata → risk score per package
- Install: your package manager, with all lifecycle scripts disabled
- Inspect: list every package that wants a
preinstall/install/postinstallscript, and flag risky ones - Approve: per package, pinned to the script's content hash
- Run: only the approved scripts (Linux: optionally under a runtime monitor)
| Package manager | Lockfile | Install with scripts off | Approved scripts run with |
|---|---|---|---|
| npm | package-lock.json (v2/v3) |
npm install --ignore-scripts |
npm run <stage> --ignore-scripts in the package directory |
| pnpm | pnpm-lock.yaml (v6, v9) |
pnpm install --ignore-scripts |
npm run <stage> --ignore-scripts in the package directory |
| Yarn classic | yarn.lock (v1) |
yarn install --ignore-scripts |
npm run <stage> --ignore-scripts in the package directory |
| Yarn berry | yarn.lock (v2+) |
yarn install --mode=skip-build |
npm run <stage> --ignore-scripts in .yarn/unplugged, with .pnp.cjs preloaded |
| bun | bun.lock |
bun install --ignore-scripts |
npm run <stage> --ignore-scripts in the package directory |
| uv | uv.lock |
uv sync --locked with every unapproved package in --no-build-package |
uv builds only the approved source distributions |
| pip, Poetry, Pipenv | requirements.txt, poetry.lock, Pipfile.lock |
check and why only (installing: not yet) |
|
| Composer | composer.lock |
composer install --no-scripts |
only approved plugins load (allow-plugins) |
The package manager is detected from the lockfile, then the packageManager field in
package.json (--pm overrides both). When packageManager pins pnpm or Yarn,
safe-install runs it through corepack so you get exactly that version.
# macOS / Linux (Homebrew)
brew tap crossben/safe-install https://github.com/crossben/safe-install
brew install --cask safe-install
# Windows (Scoop)
scoop bucket add safe-install https://github.com/crossben/safe-install
scoop install safe-install
# Debian / Ubuntu, Fedora / RHEL, Alpine: download the .deb / .rpm / .apk from the latest release
sudo apt install ./safe-install_*_linux_amd64.deb
# Anything else: download an archive from the releases page, or build from source (Go 1.25+)
go install github.com/crossben/safe-install/cmd/safe-install@latestOn macOS, Homebrew keeps the quarantine flag on the (not yet notarized) binary; the cask
prints the one-line xattr command to clear it yourself.
safe-install is deliberately not distributed through npm: a supply-chain tool should not be installed through the channel it protects.
Every release signs checksums.txt with Sigstore (keyless,
bound to this repository's release workflow) and attaches SLSA build provenance:
cosign verify-blob checksums.txt \
--bundle checksums.txt.sigstore.json \
--certificate-identity-regexp '^https://github.com/crossben/safe-install/\.github/workflows/release\.yml@refs/tags/v' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
sha256sum --ignore-missing -c checksums.txt
gh attestation verify safe-install_linux_amd64.tar.gz --repo crossben/safe-installv0.1.0 predates the bundle format: verify it with --signature checksums.txt.sig --certificate checksums.txt.pem instead of --bundle.
safe-install # or: safe-install install
safe-install install --yes # approve every script below high risk
safe-install install -- --omit=dev # flags after -- go to the package manager
safe-install ci # clean install from the lockfile (npm ci, --frozen-lockfile, --immutable)Dependencies are installed with every lifecycle script disabled. safe-install then lists
the packages that want to run preinstall / install / postinstall scripts, flags
risky ones (download-and-execute, eval / encoded blobs, credential access, plus the
registry checks below), and runs only the ones you approve, dependencies first. Without a
terminal (CI) nothing is approved; --ci also exits 1 when a high-risk script is present.
Your project's own lifecycle scripts are never run for you.
Running approved scripts uses npm run inside each package's directory, so npm must be
on PATH (it ships with Node); install warns up front when it is missing.
Install scripts are not the only way in: code can also run when your app imports a
package. Every install (and safe-install scan) also scans the JavaScript of each
installed package for three shapes, each a combination rather than a single keyword:
code that downloads and executes (SI-CODE-001), code that reads credentials next to
a network send (SI-CODE-002) and obfuscated code (SI-CODE-003). Results are cached
per package version, so each version is scanned once. A high finding fails --ci; a
script package with code findings is not approved by --yes. Files over 2 MB (bundles)
are not scanned. check --deep does the same without installing: it downloads each
checked package's tarball, verifies it against the lockfile's integrity hash (a mismatch
blocks, as SI-INT-001), and scans it in memory; nothing is extracted to disk.
safe-install install --monitor # report what approved scripts do
safe-install install --monitor=kill # stop a script after its first high-risk actionApproved scripts run under strace (install it with your package manager; no root
needed). safe-install reports network connections, network tools (curl, wget, nc…),
reads of credentials (~/.ssh, ~/.npmrc, cloud and browser data) and writes to
persistence locations (shell startup files, ~/.ssh, autostart, systemd, git hooks,
system directories) or anywhere outside the project and caches. Only the scripts are
traced, not the package manager. strace sees a syscall once it happened, so kill stops
the script after the first dangerous action, not before it. Network findings name the host the script looked up (connects to registry.npmjs.org:443),
from the DNS replies it received; only replies from the system's name servers
(/etc/resolv.conf) count, so a script cannot forge one to disguise where it connects. On macOS and Windows,
--monitor is not available; everything else works the same.
safe-install install --sandbox # approved scripts can't see your home folder or the network
safe-install install --sandbox --sandbox-net # …but may download (puppeteer, prebuilt binaries)--sandbox runs each approved script under Landlock
(no root needed): it can read the system and the project, write only to its package, the
project's node_modules, temp folders and package caches, and cannot open the rest of your
home folder, so ~/.ssh, ~/.aws, ~/.npmrc, browser profiles and ~/.bashrc are out of
reach. Outgoing TCP is blocked unless you pass --sandbox-net (UDP too on Linux 6.15+-class
kernels with Landlock ABI 10). Only the scripts are sandboxed, not the package manager.
Combine it with --monitor to see what a blocked script tried.
The sandbox blocks rather than reports: a script that genuinely needs something outside those paths fails ("permission denied"). It needs Landlock (Linux 5.13+; 6.7+ to block the network) and refuses to run rather than silently doing less.
Answering y at the prompt (or running safe-install approve <pkg>) records the approval
in .safe-install.json. Commit it so your team shares approvals:
{
"minReleaseAge": "3d",
"minReleaseAgeExclude": ["typescript", "@types/*"],
"failOn": "high",
"allowScripts": {
"esbuild": { "version": "0.25.10", "hash": "sha256-…", "at": "2026-10-03" }
}
}An approval is pinned to a hash of the scripts and the files they run: if either
changes, safe-install reports SI-SCR-005 and asks again. Two options loosen or tighten that:
safe-install approve sharp --trust provenance # also future versions built by the same repository's CI
safe-install approve '@corp/*' --trust provenance # a whole scope (globs need provenance)
safe-install approve esbuild --expires 90d # ask again after 90 daysWith --trust provenance, changed scripts are accepted only when the new version carries
npm provenance from the
repository recorded at approval time; a release published by hand (a stolen token) or built
from another repository is not. safe-install reads the provenance the registry serves; it
does not re-verify its Sigstore signature. Exact names take precedence over globs. Approved scripts run without a
prompt, also in CI. A user-wide file with the same format lives in your config directory
(approve --global); the project file wins on conflicts, and flags win over both.
safe-install scripts # packages with install scripts and their approval state (--format json)
safe-install approve esbuild # approve and run now (--revoke, --global, --no-run)
safe-install add left-pad # add packages through the same review
safe-install explain SI-SCR-002 # what a rule means and what to do
safe-install why ms # the dependency chains that bring a package in
safe-install scan # scan installed packages' code (also part of every install)
safe-install cache info # cache size per part; `cache clean` empties itminReleaseAgeExclude exempts packages from the release-age findings. The age passed to
the package manager itself applies to every package.
A security team can publish one policy for every repository: a JSON file in the same
format, plus blockPackages (name globs that must never be used). Point safe-install at
it with SAFE_INSTALL_ORG_POLICY or "orgPolicy": "https://…/policy.json" (or a path) in
your user config, which win, or in .safe-install.json. SAFE_INSTALL_ORG_POLICY_TOKEN is
sent as a Bearer token to that host only, and never to a URL named by a project file:
anyone can change one in a pull request.
{
"minReleaseAge": "7d",
"failOn": "medium",
"blockPackages": ["event-stream", "@evil/*"],
"allowScripts": { "esbuild": { "trust": "provenance", "repository": "https://github.com/evanw/esbuild" } }
}Projects build on it but cannot weaken it: blocked packages are reported as SI-POL-001
(blocking) and their scripts never run, even if a project approved them; minReleaseAge
and failOn are at least as strict as the organization's. The policy must be https; if it
cannot be fetched, the last cached copy is used with a warning, and with no cache
safe-install stops rather than running without it.
Commands that run no dependency code go to your project's package manager, with its output
and exit code: your own scripts (safe-install run build, test, start, and script names
directly for Yarn, pnpm and bun) and read-only or publishing commands (ls, outdated,
view, why, audit, pack, publish, …). Install verbs (i, install, add, ci) take
safe-install's own reviewed path; uninstall / remove run with install scripts forced off.
Everything else is refused, with what to do instead: that includes update, rebuild,
exec, dlx, create, audit fix, dedupe, and any command safe-install does not know.
safe-install shell-init <bash|zsh|fish|pwsh> prints functions that send npm install,
pnpm add, yarn, bun i and friends through safe-install. It changes nothing on its own:
eval "$(safe-install shell-init bash)" # add to ~/.bashrc or ~/.zshrcCoding agents (Claude Code, Codex, Cursor…) install packages too, and they shouldn't approve
install scripts on your behalf. safe-install llm prints instructions written for them:
install through safe-install, never approve scripts, report skipped ones to you, and
how to read the results. Add them to your agent's instructions file:
safe-install llm >> AGENTS.md # or CLAUDE.md, .cursorrules, …safe-install check and safe-install why work on Python projects too: uv.lock,
poetry.lock, Pipfile.lock and pinned requirements.txt (with --hash lines and pip-compile
or uv export "via" comments). Packages are checked against PyPI and OSV with the same rules:
known malware and vulnerabilities, yanked versions, fresh releases (--min-age), names that
imitate popular PyPI packages, and lockfile hashes or download URLs that PyPI does not serve.
safe-install check # in a folder with uv.lock, poetry.lock, Pipfile.lock or requirements.txt
safe-install why urllib3 # which dependencies bring it in
safe-install check --deep # also download, verify and scan each package's codePython-specific checks: a recent release that dropped the PyPI Trusted Publishing provenance
its previous release had (SI-REC-002), packages with no wheel, whose build code runs on
install (SI-BLD-001), and with --deep, build code (setup.py, build hooks) that downloads,
evaluates or reads secrets (SI-BLD-002), .pth files that run code at every Python start
(SI-PTH-001), and the code-scan rules applied to Python source. Archives are verified against
PyPI's sha256 and the lockfile's hashes, and scanned in memory.
Installing with uv. In a project with uv.lock, safe-install install and
safe-install add install from wheels only: building a source distribution runs its build
code, so a package that has to be built is analyzed first (registry rules and a scan of its
build code) and built only once you approve it, pinned to that sdist's sha256. Every other
package is passed to uv as --no-build-package, whatever platform you are on.
safe-install install # uv lock (if needed), then uv sync --locked, wheels only
safe-install add httpx # uv add, refused if it resolves a version younger than --min-age
safe-install scan # scan the code installed in .venvThe release-age gate checks every new or changed version against PyPI and, if one is too
fresh, restores pyproject.toml and uv.lock and installs nothing. (uv's own
--exclude-newer is not used: uv writes it into uv.lock.) After installing, .pth files
that run code at every Python start are reported with the package that installed them. Your
own project is still installed by uv as usual. pip, Poetry and Pipenv projects can be
checked; installing them is not supported yet.
Packages from other indexes configured for the project ([[tool.uv.index]], uv.toml,
Pipfile sources, --index-url lines, UV_*/PIP_* variables) are not looked up on PyPI and
are listed as skipped; safe-install never contacts those indexes. The PyPI popularity list
comes from ecosyste.ms (data CC BY-SA 4.0).
safe-install check and safe-install why work on Composer projects (composer.lock),
against Packagist and OSV: malware and vulnerabilities, abandoned packages (with Packagist's
suggested replacement), fresh releases (when Packagist first saw them), names that imitate
popular packages (symfoni/console), and Composer plugins (SI-PLG-001), whose code runs
inside Composer on every command. Composer dists from GitHub carry no checksum, so integrity
is anchored on the commit: the lockfile must pin the commit Packagist serves for that version
(SI-INT-001). check --deep downloads each dist (the zipball of exactly that commit) and
scans its PHP code, including eval(gzinflate(base64_decode(…)))-style payloads.
safe-install check --deep # in a folder with composer.lock
safe-install why psr/logInstalling with Composer. Dependencies' scripts never run in Composer; what does is
plugins. safe-install install runs composer install --no-scripts (your project's own
scripts are reported, not run) with only the plugins you approved enabled, each pinned to its
commit. It points Composer at a temporary copy of composer.json whose allow-plugins lists
exactly those plugins, even if your own composer.json allows more, so your files are never
edited. safe-install add refuses versions younger than --min-age and restores
composer.json and composer.lock. safe-install scan scans the PHP code in vendor/.
safe-install install # composer install, scripts off, approved plugins only
safe-install approve php-http/discovery # review and approve a plugin (pinned to its commit)
safe-install add symfony/http-client # composer require, with the release-age gatesafe-install approve works the same way for Python: it approves building a package from
source, pinned to that sdist's sha256.
Packages from repositories listed in composer.json are skipped, never contacted. The Packagist popularity list comes from
ecosyste.ms (data CC BY-SA 4.0).
safe-install for VS Code (also Cursor, Windsurf and VSCodium) shows the same results in
the editor: risky dependencies marked in package.json with an explanation on hover, an
Install Scripts view, and approval through a terminal you can see (never for high-risk
scripts). It runs the CLI you have installed (0.2.3 or later) and adds no checks of its
own. Search for "safe-install" in the Extensions view, or see
crossben/safe-install-vscode.
safe-install check # score every package in the lockfile
safe-install check --fail-on medium # exit 1 at medium risk or worse
safe-install check --format json # or sarif, markdown; --sarif-file x.sarif writes SARIF alongside text
safe-install check --summary-file summary.md # plus a short Markdown summary (for PR comments)
safe-install check --diff origin/main # only packages new or changed since a git ref (or an old lockfile)
safe-install check --diff origin/main --deep # …and download, verify and scan their codeNew versions must be at least --min-age old (default 72h; 0 disables). install
passes this to the package manager so fresh releases are not picked up, and check
flags any already in the lockfile.
Exit codes: 0 ok · 1 a package reached --fail-on (or, with --ci, an unapproved
high-risk script or high-risk monitor finding) · 3 tool error, such as registry
metadata that could not be fetched.
Besides the registry checks, check flags names that imitate popular packages, rarely
downloaded packages that run install scripts, and anything in the OSV
database. OSV's malicious-package entries (MAL-…) block; ordinary vulnerabilities
count one level below their advisory severity (npm audit covers those in depth).
safe-install explain lists every rule.
- uses: crossben/safe-install@v0.3.0
with:
working-directory: . # where package.json and the lockfile are
fail-on: high # low, medium, high, block, none
sarif: true # upload to code scanning (needs security-events: write)
comment: true # comment on the PR (needs pull-requests: write)The action downloads the release binary for the runner (checksum-verified; version:
picks a release, source builds from the action's checkout) and fails the job when a
package reaches fail-on. On pull requests it checks only the packages the PR adds or
upgrades (diff: auto, comparing with the base branch) and scans their code
(deep: auto); set diff: none to always check everything.
A short summary of the risky packages goes to the job summary on every run. With
comment: true, it is also posted on the pull request, but only when there is something
to report. Later runs edit that one comment instead of adding new ones. Package names and
messages are shown as code, so a package can't put links, images or @mentions in the
comment. On pull requests from forks the token is read-only, so the comment is skipped
with a warning.
safe-install reads registries and credentials where your package manager does: the
project's .npmrc, your user .npmrc (or $NPM_CONFIG_USERCONFIG), the project's
.yarnrc.yml (Yarn berry) and npm_config_registry; --registry overrides the default.
Scoped registries (@corp:registry=…) and per-host credentials (_authToken, _auth,
username / _password, with ${ENV} expansion) are supported:
@corp:registry=https://npm.corp.example.com/
//npm.corp.example.com/:_authToken=${NPM_TOKEN}A credential is sent only to the registry host and path it is configured for, never to
OSV or npm's download counts API, and never printed. Tarballs from any configured
registry count as the registry for SI-INT-002.
Registry metadata, code-scan results, the organization policy and the last update check are cached under
safe-install cache dir (SAFE_INSTALL_CACHE_DIR moves it). The cache is capped at 1 GB
(SAFE_INSTALL_CACHE_MAX, e.g. 500MB): after each command, the least recently used files
are removed once it is over the cap. safe-install cache clean [registry|codescan|org|update]
empties it.
No telemetry. safe-install talks to your configured package registry, the
OSV API (package names and versions, for advisories) and npm's download
counts API (only for rarely used packages with install scripts). --offline uses cached
registry data only and skips both.
At most once a day, in an interactive terminal, safe-install also asks GitHub's API for
the latest release and prints a one-line notice with the right update command when there is
a newer one. The request carries nothing about you or your project. It never runs in CI,
with --ci or --offline, or when SAFE_INSTALL_NO_UPDATE_CHECK=1 is set.
Found a bypass? Please report it privately, see SECURITY.md.
The release-age gate is inspired by safe-npm.
The popular-package list comes from npm-high-impact
(MIT, Titus Wormer); refresh it with scripts/update-popular.sh.