· 5 min read

Packages should arrive inert

npm 12 separates fetching a dependency from granting its install script permission to execute, and records that permission where a reviewer can see it.

A person leans into a camera on a tripod while inspecting a distant scene.
Lav Varshney, CC0

In May, one compromised npm account published malicious versions across hundreds of packages in an automated 22-minute burst. Each version added a preinstall hook. According to the OSV record, that hook went hunting for cloud credentials, package tokens, SSH keys, database strings, and whatever else the machine had made convenient.

Your application never had to import any of it. Installing was enough. You could pin the version, verify the integrity hash, and still faithfully run the attacker's exact bytes before your own program started.

npm 12, released on July 8, finally makes dependency install scripts opt-in. About time. A package should be allowed to arrive on disk before it is allowed to act.

Install had two jobs#

Package installation has always looked like one operation at the prompt. Underneath, npm was doing two jobs with different consequences: resolving and unpacking code, then passing lifecycle commands to /bin/sh or cmd.exe. The official scripts documentation lists preinstall, install, and postinstall, plus an implicit node-gyp rebuild when a package ships binding.gyp without its own install command.

Those scripts have legitimate work. Native modules compile for the target machine; packages download platform-specific binaries or generate assets. They can also execute any program the shell accepts, and npm's own documentation points out that the program need not be JavaScript. Why did unpacking a library carry that authority by default?

npm 12 changes the default. Dependency lifecycle scripts and implicit native builds stay quiet unless the project explicitly allows them. Git dependencies and remote tarball URLs now need separate permission too, closing two more routes where a dependency declaration could smuggle execution into an ordinary install.

The permission belongs in git#

The useful part survives the prompt asking whether you trust sharp today. npm writes the answer into the project's package.json, where another developer and CI receive the same policy. The review commands are deliberately boring:

Reviewing dependency install scriptssh
npm install-scripts lsnpm install-scripts approve canvas sharpnpm install-scripts deny telemetry-pkg
  1. 1List dependency scripts that npm skipped because the project has not made a decision about them.
  2. 2Record approvals in package.json, pinned to the installed versions by default.
  3. 3Write a name-wide false entry that survives a later blanket approval.
All three decisions are written into package.json rather than stored in a local prompt history.

By default, approval is pinned to the installed version. Upgrade that package and the old permission stops matching, so npm skips the new script and puts the package back on the review list. A denial travels the other way: it is name-wide, because denying only one version would quietly allow the next one. That asymmetry is my favorite part of the design — the decision that can hurt you expires on every upgrade, and the one that protects you sticks around.

This will break real builds. Native dependencies that used to compile invisibly now require a manifest change, and the current command is unaware of workspaces. I cannot estimate the migration cost for a large monorepo from the release notes. The inconvenience is still the point: a repository that needs code execution during installation should carry the decision in a reviewable file instead of relying on every developer to remember a flag.

There is an escape hatch named --dangerously-allow-all-scripts, which is admirably unwilling to pretend. Global installs and npx remain awkward as well because they have no project manifest; their approvals live in a command-line option or user configuration. A repository can at least keep its own decision beside the dependency list.

Origin cannot grant permission#

A provenance attestation answers who produced the release. PyPI's publish-attestation specification can bind a file to a particular CI identity, while carefully saying this raises confidence in integrity and does not necessarily establish trustworthiness. A compromised maintainer or approved workflow can still publish harmful behavior. Install-script policy records the separate decision about what that release may do on your machine.

Registry reputation only accumulates after the tarball is public. GitHub now gives ordinary Dependabot version updates a three-day cooldown, and its advisory database began ingesting the OpenSSF malicious-package feed across more ecosystems in July. Both changes buy time for somebody to notice the bad release. Default-deny install scripts protect the first machine in line, before an advisory exists.

The next version of a legitimate native package may still stop a clean install. npm will leave its files in node_modules, print the skipped script, and wait. The durable result is a small diff in package.json, where a reviewer has to add a versioned true before that dependency can touch the shell.