From ad9c81b4744e24f64a99cb2481afb32a35d8fbe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Caleb=20=E3=83=84=20Everett?= Date: Tue, 7 Apr 2026 16:28:37 -0700 Subject: [PATCH 1/2] Add option to require install script approval This is an attempt to revive https://github.com/npm/rfcs/pull/488 and control which packages can run install lifecycle scripts. --- accepted/0000-install-script-allowlist.md | 274 ++++++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 accepted/0000-install-script-allowlist.md diff --git a/accepted/0000-install-script-allowlist.md b/accepted/0000-install-script-allowlist.md new file mode 100644 index 00000000..1e25aa02 --- /dev/null +++ b/accepted/0000-install-script-allowlist.md @@ -0,0 +1,274 @@ +# Install Script Allowlist + +## Summary + +Add an opt-in mode where npm requires explicit approval before running install +lifecycle scripts (`preinstall`, `install`, `postinstall`, and auto-detected gyp +files). The default behavior is unchanged — scripts run as they do today. Users +who enable the feature choose between two enforcement modes: `ignore` (silently +skip unapproved scripts) and `error` (fail the install). Approved packages are +tracked in a JSON allowlist that can live in `package.json`, in one or more +standalone JSON files referenced by `.npmrc`, or both — all sources are merged +together. + +## Motivation + +This proposal builds on [npm/rfcs#488](https://github.com/npm/rfcs/pull/488) by +@tolmasky and the extensive community discussion there. The core security +argument from that RFC still holds and has only grown stronger: + +- Install lifecycle scripts execute arbitrary code at install time, before the + consuming project ever `require`s or `import`s the package. Only ~0.6% of + packages on npm use install lifecycle scripts (12,035 out of ~2 million + packages as of November 2021, per + [tolmasky's analysis in RFC #488](https://github.com/npm/rfcs/pull/488)), yet + they represent the easiest vector for supply-chain attacks. +- Real-world exploits continue to demonstrate the risk. The 2021 `coa`/`rc` + compromises, the 2025–2026 "Shai Hulud" worm, and numerous typosquatting + attacks all leveraged postinstall scripts to propagate. +- Alternatives to install lifecycle scripts have matured. N-API, + prebuild-install, and platform-specific optional dependencies + (`os`/`cpu`/`libc` fields) cover the many legitimate use cases that + historically required compilation at install time. + +The original RFC proposed changing the default so that scripts are off unless +explicitly allowed. That was rejected as too disruptive. This proposal takes a +different approach: **the default does not change**. Instead, npm gains a new +opt-in enforcement mode with an allowlist, giving security-conscious users and +organizations a first-class way to control which packages may run scripts — +without breaking anyone who doesn't opt in. + +For organizations managing large codebases, shared management of the allowlist +is essential. Organizations need to populate a shared allowlist with every +package whose scripts are known to be in use, then enable `error` mode globally +so that any _new_ unapproved script is caught immediately. The allowlist must be +shareable across repositories without requiring changes to each project's +`package.json`. See [Allowlist sources](#allowlist-sources) and +[bikeshedding](#unresolved-questions-and-bikeshedding) for how this could work. + +## Detailed Explanation + +### Enforcement mode + +A new `.npmrc` config value controls the feature: + +```ini +install-script-policy=off|ignore|error +``` + +- `off` (default): Current behavior. All install lifecycle scripts run + unconditionally. +- `ignore`: Unapproved scripts are silently skipped. At the end of + `node_modules` construction, npm prints a summary of every package whose + scripts were skipped. +- `error`: Unapproved scripts are skipped. At the end of `node_modules` + construction, npm exits non-zero and prints the list of unapproved packages + that had scripts. + +In both `ignore` and `error` modes, npm collects the full list of skipped +packages and reports them together at the end of the install, rather than +failing on the first one. This makes it practical to build an allowlist in one +shot. + +### Allowlist format + +The allowlist is a JSON object with a single top-level field: + +```json +{ + "approvedInstallScripts": { + "node-gyp-build": "allowed", + "esbuild": "allowed", + "esbuild@>=0.25.0": "allowed", + "sqlite3@4.x": "allowed", + "husky": "ignored", + "sponsorware-nag": "ignored" + } +} +``` + +Keys are `` or `@`. A bare package +name (no `@range`) is equivalent to `@*` and matches all versions. Semver ranges +in this list match pre-release versions. + +Values: + +- `"allowed"` — the package's install lifecycle scripts will run. +- `"ignored"` — the package's install lifecycle scripts will not run, and the + package will not be listed as an error in `error` mode. This is useful for + packages whose scripts are non-essential (telemetry, sponsorship messages, + optional native compilation with a JS fallback, etc.). + +### Allowlist sources + +We need allowlist entries to come from at least two places because npmrc's +config model flattens values — a project-level `.npmrc` replaces a global-level +value rather than merging with it. If the allowlist lived only in `.npmrc`, a +project that sets `install-script-allowlist=project-scripts.json` would silently +discard the global `install-script-allowlist=company-scripts.json`. That defeats +the central-management use case entirely. + +The proposed solution uses two complementary sources: + +1. **External JSON files referenced by `.npmrc`** — for shared and global + config. An organization sets this once in a global or user `.npmrc` and every + project inherits it. + + ```ini + install-script-allowlist[]=/etc/npm/approved-scripts.json + ``` + +2. **`package.json`** — for per-project entries that are tracked in source + control alongside the code. + + ```json + { + "name": "my-app", + "approvedInstallScripts": { + "esbuild": "allowed" + } + } + ``` + +All sources are merged (unioned). If the same key appears in multiple sources, +the most permissive value wins (`"allowed"` > `"ignored"`). The goal is to +reduce toil — an organization can maintain one shared list and have every +project inherit it automatically, while individual projects can still add their +own entries. Whether a project should also be able to _override_ a shared entry +(e.g. downgrade `"allowed"` to `"ignored"`) is an open question, but not a hard +requirement either way. + +This two-source approach is a concrete proposal, but we're open to better ideas +for achieving the same goal: a project-level list that coexists with a +centrally-managed list. If there's a cleaner way to get mergeable lists out of +npmrc, or a different file format that works better, we'd welcome that. + +### Interaction with `--ignore-scripts` + +`--ignore-scripts` continues to work as today — it disables all scripts +unconditionally, regardless of the allowlist. The allowlist only applies when +`install-script-policy` is `ignore` or `error` and `--ignore-scripts` is not +set. + +### Scope + +This feature applies to the install lifecycle scripts that run during +`npm install` for dependencies: + +- `preinstall` +- `install` +- `postinstall` +- Implicit `node-gyp rebuild` (when a `binding.gyp` is detected and no `install` + script is defined) +- `prepare` scripts of git dependencies (which run during install to build the + package from source) + +It does not affect: + +- Scripts in the root project and workspaces' `package.json` (the package being + developed). It's specifically for installed dependencies. +- `npm run` / `npm test` / `npm start` etc. + +## Rationale and Alternatives + +### Alternative 1: Change the default (tolmasky's original RFC #488) + +Making scripts opt-in by default is the ideal end state, but the npm team and +community rejected it as too disruptive in 2021–2022. This proposal provides the +same security benefit for users who opt in, without breaking anyone. It could +serve as a stepping stone toward eventually changing the default in a future +major version, once the ecosystem has had time to adapt. + +### Alternative 2: Use `@lavamoat/allow-scripts` or similar userland tools + +Tools like `@lavamoat/allow-scripts` and `can-i-ignore-scripts` exist and work +today. However, they require adding a dependency and a workflow change to every +project. A first-party npm feature would be more discoverable and require no +extra dependencies. + +### Alternative 3: Migrate to `pnpm` + +Package owners that would opt-in to install script allowlists could migrate to +pnpm instead. I know this would be a difficult migration for the codebase I +manage. + +## Implementation + +The implementation touches a few areas of the npm CLI: + +1. **Config**: Add `install-script-policy` (enum: `off`, `ignore`, `error`) and + `install-script-allowlist` (array of file paths) to the config schema. + +2. **Allowlist loading**: At the start of `npm install` / `npm ci`, read and + merge the allowlist from all configured sources (package.json + external + files). Build a lookup structure mapping `(package-name, version)` → + `allowed | ignored | unapproved`. + +3. **Script gating**: In the lifecycle script runner (likely in + `@npmcli/run-script` or the reification step in `@npmcli/arborist`), before + executing an install lifecycle script for a dependency, check the merged + allowlist. If the package+version is not approved, skip the script and record + it. + +4. **Reporting**: After reification completes, if any scripts were skipped, + print a summary. In `error` mode, exit non-zero if any skipped packages were + not marked `"ignored"`. + +## Prior Art + +- [npm/rfcs#488](https://github.com/npm/rfcs/pull/488) — @tolmasky's "Make npm + install scripts opt-in" RFC (2021). Extensive discussion, 369 👍, not merged + due to breaking-change concerns. +- [pnpm `onlyBuiltDependencies` / `allowBuilds`](https://pnpm.io/settings#allowbuilds) + — pnpm 10 blocks lifecycle scripts by default with an allowlist in + `package.json`. +- [`@lavamoat/allow-scripts`](https://www.npmjs.com/package/@lavamoat/allow-scripts) + — Userland tool that disables scripts via `--ignore-scripts` and selectively + re-runs them from an allowlist in `package.json`. +- [`can-i-ignore-scripts`](https://www.npmjs.com/package/can-i-ignore-scripts) — + Helps audit which dependencies actually need install lifecycle scripts. + +## Unresolved Questions and Bikeshedding + +### Config key names + +`install-script-policy`, `install-script-allowlist`, and the JSON field +`approvedInstallScripts` are suggestions. The names should be bikeshedded. What +matters is the semantics: a mode toggle, a list of external config files, and a +mergeable allowlist structure. + +I proposed using both package.json fields and npmrc because that allows package +and global config. Another option could be to use npmrc, but merge the arrays +across rc files instead of replace them. + +``` +# ~/.npmrc +install-script-allowlist[]=/etc/npm/shared-approved-scripts.json + +# /project/.npmrc +install-script-allowlist[]=./approved-scripts.json + +# runtime sees ['/etc/npm/shared-approved-scripts.json', './approved-scripts.json'] +``` + +I didn't propose this because it doesn't behave like other npm config arrays. + +### Should `npm install ` in interactive mode prompt the user? + +When adding a new dependency that has install lifecycle scripts, npm could +interactively ask whether to approve it, similar to how `apt` confirms disk +usage or how Composer prompts for plugin permissions. + +### Should there be an `npm approve-scripts` command? + +A helper command that scans `node_modules` (or the package tree) and +generates/updates the allowlist would make adoption much easier. Something like +`npm approve-scripts --init` to populate the allowlist with all +currently-installed packages that have scripts. + +### Interaction with workspaces + +In a monorepo, should each workspace have its own `approvedInstallScripts` in +its `package.json`, or should only the root's list apply? The external-file +approach naturally handles this (point all workspaces at the same file), but the +`package.json` story needs clarification. From c2c5b47f152c9ca4855eff5c4eed7c2f3e34ff57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Caleb=20=E3=83=84=20Everett?= Date: Wed, 29 Apr 2026 16:23:16 -0700 Subject: [PATCH 2/2] fix: update rfc to use allow-scripts query language --- accepted/0000-install-script-allowlist.md | 530 +++++++++++++--------- 1 file changed, 320 insertions(+), 210 deletions(-) diff --git a/accepted/0000-install-script-allowlist.md b/accepted/0000-install-script-allowlist.md index 1e25aa02..f310d462 100644 --- a/accepted/0000-install-script-allowlist.md +++ b/accepted/0000-install-script-allowlist.md @@ -1,274 +1,384 @@ -# Install Script Allowlist +# Allow Scripts ## Summary -Add an opt-in mode where npm requires explicit approval before running install -lifecycle scripts (`preinstall`, `install`, `postinstall`, and auto-detected gyp -files). The default behavior is unchanged — scripts run as they do today. Users -who enable the feature choose between two enforcement modes: `ignore` (silently -skip unapproved scripts) and `error` (fail the install). Approved packages are -tracked in a JSON allowlist that can live in `package.json`, in one or more -standalone JSON files referenced by `.npmrc`, or both — all sources are merged -together. +Add `--allow-scripts` to control which packages may run lifecycle scripts. +The value is a [dependency selector][dep-selectors] query — the same syntax +used by `npm query`. Root and workspace packages are always allowed. +`allow-scripts` is opt-in; when not specified all scripts are allowed. + +```ini +# .npmrc +allow-scripts = [name="esbuild"], [name="better-sqlite3"] +``` ## Motivation -This proposal builds on [npm/rfcs#488](https://github.com/npm/rfcs/pull/488) by -@tolmasky and the extensive community discussion there. The core security -argument from that RFC still holds and has only grown stronger: - -- Install lifecycle scripts execute arbitrary code at install time, before the - consuming project ever `require`s or `import`s the package. Only ~0.6% of - packages on npm use install lifecycle scripts (12,035 out of ~2 million - packages as of November 2021, per - [tolmasky's analysis in RFC #488](https://github.com/npm/rfcs/pull/488)), yet - they represent the easiest vector for supply-chain attacks. -- Real-world exploits continue to demonstrate the risk. The 2021 `coa`/`rc` - compromises, the 2025–2026 "Shai Hulud" worm, and numerous typosquatting - attacks all leveraged postinstall scripts to propagate. -- Alternatives to install lifecycle scripts have matured. N-API, - prebuild-install, and platform-specific optional dependencies - (`os`/`cpu`/`libc` fields) cover the many legitimate use cases that - historically required compilation at install time. - -The original RFC proposed changing the default so that scripts are off unless -explicitly allowed. That was rejected as too disruptive. This proposal takes a -different approach: **the default does not change**. Instead, npm gains a new -opt-in enforcement mode with an allowlist, giving security-conscious users and -organizations a first-class way to control which packages may run scripts — -without breaking anyone who doesn't opt in. - -For organizations managing large codebases, shared management of the allowlist -is essential. Organizations need to populate a shared allowlist with every -package whose scripts are known to be in use, then enable `error` mode globally -so that any _new_ unapproved script is caught immediately. The allowlist must be -shareable across repositories without requiring changes to each project's -`package.json`. See [Allowlist sources](#allowlist-sources) and -[bikeshedding](#unresolved-questions-and-bikeshedding) for how this could work. +Install lifecycle scripts execute arbitrary code before the consuming project +ever `require`s or `import`s the package. Only ~0.6 % of packages on npm use +them, yet they are the easiest vector for supply-chain attacks (`coa`/`rc` +2021, "Shai Hulud" 2025–2026, countless typosquats). + +[RFC #488][rfc-488] proposed making scripts opt-in by default. That was +rejected as too disruptive. This RFC takes a different approach: the default +does not change. Users who want control opt in with `--allow-scripts`. + +This design also addresses wraithgar's request for a unified pattern that +works across `--allow-git`, `--min-release-age`, and script control — all +three can use dependency selectors to identify which packages a policy +applies to. ## Detailed Explanation -### Enforcement mode +### The `--allow-scripts` config + +A single `.npmrc` / CLI config value: + +``` +allow-scripts = all | none | +``` + +| Value | Meaning | +|---|---| +| `all` (default) | Current behavior. All lifecycle scripts run. | +| `none` | No dependency scripts run. Root & workspace scripts still run. | +| *selector* | Only dependencies matching the selector may run scripts. Root & workspace scripts always run. | -A new `.npmrc` config value controls the feature: +The selector is any valid [dependency selector][dep-selectors] string — the +same syntax accepted by `npm query`. Multiple selectors are separated with +commas (CSS selector list syntax), not by repeating the flag. ```ini -install-script-policy=off|ignore|error +# .npmrc +allow-scripts = [name="esbuild"], [name="better-sqlite3"], [name="node-gyp-build"] ``` -- `off` (default): Current behavior. All install lifecycle scripts run - unconditionally. -- `ignore`: Unapproved scripts are silently skipped. At the end of - `node_modules` construction, npm prints a summary of every package whose - scripts were skipped. -- `error`: Unapproved scripts are skipped. At the end of `node_modules` - construction, npm exits non-zero and prints the list of unapproved packages - that had scripts. - -In both `ignore` and `error` modes, npm collects the full list of skipped -packages and reports them together at the end of the install, rather than -failing on the first one. This makes it practical to build an allowlist in one -shot. - -### Allowlist format - -The allowlist is a JSON object with a single top-level field: - -```json -{ - "approvedInstallScripts": { - "node-gyp-build": "allowed", - "esbuild": "allowed", - "esbuild@>=0.25.0": "allowed", - "sqlite3@4.x": "allowed", - "husky": "ignored", - "sponsorware-nag": "ignored" - } -} +```sh +# CLI +npm install --allow-scripts='[name="esbuild"], [name="better-sqlite3"]' ``` -Keys are `` or `@`. A bare package -name (no `@range`) is equivalent to `@*` and matches all versions. Semver ranges -in this list match pre-release versions. +### Root and workspace scripts always run -Values: +Regardless of the `--allow-scripts` value, scripts defined in the root +`package.json` and in workspace `package.json` files always run. These are +the user's own scripts — there is no security benefit to blocking them, and +`--ignore-scripts` already exists as an escape hatch for that use case +(e.g. `npm test --ignore-scripts` to skip posttest linting). -- `"allowed"` — the package's install lifecycle scripts will run. -- `"ignored"` — the package's install lifecycle scripts will not run, and the - package will not be listed as an error in `error` mode. This is useful for - packages whose scripts are non-essential (telemetry, sponsorship messages, - optional native compilation with a JS fallback, etc.). +### Interaction with `--ignore-scripts` -### Allowlist sources +`--ignore-scripts` continues to work as today — it disables *all* scripts +unconditionally, including root scripts. `--allow-scripts` only controls +dependency scripts and does not affect root/workspace scripts. -We need allowlist entries to come from at least two places because npmrc's -config model flattens values — a project-level `.npmrc` replaces a global-level -value rather than merging with it. If the allowlist lived only in `.npmrc`, a -project that sets `install-script-allowlist=project-scripts.json` would silently -discard the global `install-script-allowlist=company-scripts.json`. That defeats -the central-management use case entirely. +If both are set, `--ignore-scripts` wins. -The proposed solution uses two complementary sources: +### Scope: all lifecycle scripts -1. **External JSON files referenced by `.npmrc`** — for shared and global - config. An organization sets this once in a global or user `.npmrc` and every - project inherits it. +This applies to **all** lifecycle scripts run for dependencies, not just +install scripts: - ```ini - install-script-allowlist[]=/etc/npm/approved-scripts.json - ``` +- `preinstall`, `install`, `postinstall` +- Implicit `node-gyp rebuild` (when `binding.gyp` is detected) +- `prepare` scripts of git dependencies +- Any other lifecycle script triggered by `npm install`, `npm ci`, + `npm rebuild`, `npm update`, or `npx` -2. **`package.json`** — for per-project entries that are tracked in source - control alongside the code. +### Enforcement behavior - ```json - { - "name": "my-app", - "approvedInstallScripts": { - "esbuild": "allowed" - } - } - ``` +When `--allow-scripts` is set to `none` or a selector: -All sources are merged (unioned). If the same key appears in multiple sources, -the most permissive value wins (`"allowed"` > `"ignored"`). The goal is to -reduce toil — an organization can maintain one shared list and have every -project inherit it automatically, while individual projects can still add their -own entries. Whether a project should also be able to _override_ a shared entry -(e.g. downgrade `"allowed"` to `"ignored"`) is an open question, but not a hard -requirement either way. +1. After the dependency tree is resolved, npm builds the allowed set using + `querySelectorAll`, plus root and workspace nodes. +2. Before running any lifecycle script for a dependency, npm checks if the + node is in the allowed set. If not, the script is skipped and the package + is recorded as unapproved. +3. After install completes, if any scripts were unapproved, npm prints the + full list and exits non-zero. -This two-source approach is a concrete proposal, but we're open to better ideas -for achieving the same goal: a project-level list that coexists with a -centrally-managed list. If there's a cleaner way to get mergeable lists out of -npmrc, or a different file format that works better, we'd welcome that. +A dependency that has no lifecycle scripts is never reported — only packages +that actually attempted to run a script and were blocked. -### Interaction with `--ignore-scripts` +#### Error output -`--ignore-scripts` continues to work as today — it disables all scripts -unconditionally, regardless of the allowlist. The allowlist only applies when -`install-script-policy` is `ignore` or `error` and `--ignore-scripts` is not -set. +npm collects all unapproved packages and prints them at the end: -### Scope +``` +npm error Unapproved lifecycle scripts were blocked. +npm error The following packages have scripts that are not allowed to run: +npm error +npm error core-js@3.42.0 (postinstall) +npm error @biomejs/biome@1.9.0 (postinstall) +npm error evil-pkg@github:attacker/evil#abc1234 (preinstall, postinstall) +npm error +npm error To allow these scripts, add them to allow-scripts in .npmrc: +npm error allow-scripts = [name="core-js"], [name="@biomejs/biome"] +``` -This feature applies to the install lifecycle scripts that run during -`npm install` for dependencies: +Each line shows the package spec (name@version for registry deps, git URL +for git deps, path for file/directory deps) followed by the lifecycle events +that were blocked. -- `preinstall` -- `install` -- `postinstall` -- Implicit `node-gyp rebuild` (when a `binding.gyp` is detected and no `install` - script is defined) -- `prepare` scripts of git dependencies (which run during install to build the - package from source) +### Examples -It does not affect: +#### Block all dependency scripts -- Scripts in the root project and workspaces' `package.json` (the package being - developed). It's specifically for installed dependencies. -- `npm run` / `npm test` / `npm start` etc. +```ini +allow-scripts = none +``` -## Rationale and Alternatives +#### Allow specific packages by name -### Alternative 1: Change the default (tolmasky's original RFC #488) +```ini +allow-scripts = [name="esbuild"], [name="better-sqlite3"], [name="node-gyp-build"] +``` -Making scripts opt-in by default is the ideal end state, but the npm team and -community rejected it as too disruptive in 2021–2022. This proposal provides the -same security benefit for users who opt in, without breaking anyone. It could -serve as a stepping stone toward eventually changing the default in a future -major version, once the ecosystem has had time to adapt. +#### Allow specific packages at specific versions -### Alternative 2: Use `@lavamoat/allow-scripts` or similar userland tools +```ini +allow-scripts = [name="esbuild"]:semver(^0.25.0), [name="better-sqlite3"]:semver(11.9.1) +``` -Tools like `@lavamoat/allow-scripts` and `can-i-ignore-scripts` exist and work -today. However, they require adding a dependency and a workflow change to every -project. A first-party npm feature would be more discoverable and require no -extra dependencies. +#### Allow all direct production dependencies -### Alternative 3: Migrate to `pnpm` +```ini +allow-scripts = :root > .prod +``` -Package owners that would opt-in to install script allowlists could migrate to -pnpm instead. I know this would be a difficult migration for the codebase I -manage. +#### Allow all packages from a scope -## Implementation +```ini +allow-scripts = [name^="@myorg/"] +``` -The implementation touches a few areas of the npm CLI: +### Workspace behavior + +In a monorepo, `--allow-scripts` is set once at the root level (in the root +`.npmrc`). The selector runs against the full dependency tree. Workspace +packages themselves are always allowed (treated like root). Dependencies of +workspaces are subject to the selector like any other dependency. + +## Risks + +### The query language has no trustworthy package identity + +The dependency selector language queries properties of arborist `Node` +objects. Attribute selectors like `[name=...]`, `[version=...]`, and +`[_id=...]` read from `node.package` — the on-disk package.json extracted +from the installed tarball. The `#name` id selector is worse: it matches +`node.name` (the folder name, controllable via aliases) OR +`node.package.name` (the tarball name). **None of these fields reflect +where the package was actually fetched from, its source, or the spec that +resolved to it.** They are all self-reported by the tarball content or +derived from the dependency key, both of which are attacker-controllable. + +Two known attacks exploit this: + +**1. Transitive alias attack** (easy to execute): +A transitive dependency declares `"esbuild": "npm:malware@1.0.0"`. The +malware package is installed into a nested `node_modules/esbuild`. +`node.name` (the folder name) is `"esbuild"`. The `#esbuild` id selector +matches it — malware inherits esbuild's script approval. `:type(registry)` +does not help because the aliased malware IS a registry package. + +`[name="esbuild"]` does NOT match this attack because it checks +`node.package.name`, which is `"malware"` (the real package name from the +tarball). **`[name=...]` blocks the alias attack.** + +**2. Manifest confusion** (requires publishing a malicious package): +The npm registry does not validate that the `name` field inside a published +tarball matches the registry package name ([Manifest Confusion][manifest-confusion], +disclosed June 2023, unfixed as of 2026). A package published as `malware` +can include a tarball whose package.json says `name: "esbuild"`. After +install, `node.package.name = "esbuild"`, so `[name="esbuild"]` matches it. +**Neither `[name=...]` nor `#name` can distinguish this from the real +esbuild.** + +**What's queryable but not trustworthy:** + +| Selector | Reads from | Attack | +| --- | --- | --- | +| `[name=...]` | `node.package.name` (tarball) | Manifest confusion | +| `#name` | `node.name` (folder) OR `node.package.name` | Alias + manifest confusion | +| `[version=...]`, `[_id=...]` | Tarball package.json | Manifest confusion | +| `[repository=...]` | Tarball package.json | Self-reported | + +**What's trustworthy but not queryable:** + +| Property | Notes | +|---|---| +| `node.resolved` | Set during resolution/install, not from tarball. Not exposed to attribute selectors. | +| `node.integrity` | Cryptographic hash of the tarball. Not exposed to attribute selectors. | +| Edge `name` and `spec` | From the parent's package.json declaration. No selector for edge properties. | +| `:type(registry\|git\|...)` | Queryable, but only gives the type — not the package name. | + +No existing selector combines trustworthy source identity with name +matching. + +### Trustworthy logical identity — required before shipping + +Arborist nodes need a queryable field that represents the resolver-facing +identity of a package — how it was resolved and fetched, not what the +tarball claims about itself. Without this, `--allow-scripts` cannot +securely distinguish an approved package from a malicious one that claims +the same name. **This RFC should not ship until the query language can +match against a trustworthy identity.** + +This field should be a logical id matching how developers declare +dependencies: + +| Dep type | Logical id | +|---|---| +| Registry | `esbuild@0.25.0` | +| Scoped | `@babel/core@7.26.0` | +| Alias (`my-es → esbuild`) | `esbuild@0.25.0` (real identity, not the alias) | +| Git | `github:npm/npa#fbbf22...` | +| File | `file:../local-pkg` | + +The building blocks exist — `versionFromTgz` can extract name and version +from registry tarball URLs by parsing the URL path, `hosted-git-info` +converts git URLs to shortcut form. But the exact source of truth (the +actual fetch, the lockfile, the resolver) and where to persist it (on the +node, in the lockfile, both) are implementation details to be resolved +before this RFC can be implemented. + +### The query language is more expressive than needed + +Dependency selectors support structural queries (`:root > .prod`, +`.workspace:has(.peer)`), pseudo-classes (`:outdated`, `:vuln`), and +arbitrary attribute matching. For `--allow-scripts`, the practical use +cases are narrower: + +- **Match by name**: `[name="esbuild"]` — the primary use case +- **Match by name + version**: `[name="esbuild"]:semver(^0.25.0)` +- **Match by scope**: `[name^="@myorg/"]` +- **Match direct deps**: `:root > .prod` — useful as a broad starting + point, though it auto-approves any new direct dep that adds scripts + +Beyond these, it's hard to construct a realistic scenario where structural +or relational queries add value for script approval. You wouldn't approve +scripts based on `:outdated` or `:vuln` or tree depth. The expressiveness +is inherited from reusing the query language, not driven by requirements. + +This is fine — the extra expressiveness doesn't hurt, and reusing the +existing infrastructure avoids inventing a new DSL. But it means the +selector language is not the hard part of this feature. The hard part is +trustworthy identity (see above). + +### `#name@version` shorthand is not implemented + +The `#name@version` shorthand (e.g. `#lodash@^1.2.3`, documented as +equivalent to `[name="lodash"]:semver(^1.2.3)`) does not work. The CSS +parser interprets dots as class selectors, and even with escaped dots the +`idType` handler does a literal string match without semver splitting. +Use `[name="..."]:semver(...)` instead. -1. **Config**: Add `install-script-policy` (enum: `off`, `ignore`, `error`) and - `install-script-allowlist` (array of file paths) to the config schema. +## Rationale and Alternatives -2. **Allowlist loading**: At the start of `npm install` / `npm ci`, read and - merge the allowlist from all configured sources (package.json + external - files). Build a lookup structure mapping `(package-name, version)` → - `allowed | ignored | unapproved`. +### Why dependency selectors instead of a JSON allowlist? -3. **Script gating**: In the lifecycle script runner (likely in - `@npmcli/run-script` or the reification step in `@npmcli/arborist`), before - executing an install lifecycle script for a dependency, check the merged - allowlist. If the package+version is not approved, skip the script and record - it. +The previous version of this RFC proposed a JSON allowlist with +`"approvedInstallScripts": { "esbuild": "allowed" }`. Dependency selectors +are better because: -4. **Reporting**: After reification completes, if any scripts were skipped, - print a summary. In `error` mode, exit non-zero if any skipped packages were - not marked `"ignored"`. +Query syntax supports same features as an allowlist as well as structural queries +and category queries. This pattern can be extended to `--allow-git` and other +policy options. -## Prior Art +### Why not change the default? -- [npm/rfcs#488](https://github.com/npm/rfcs/pull/488) — @tolmasky's "Make npm - install scripts opt-in" RFC (2021). Extensive discussion, 369 👍, not merged - due to breaking-change concerns. -- [pnpm `onlyBuiltDependencies` / `allowBuilds`](https://pnpm.io/settings#allowbuilds) - — pnpm 10 blocks lifecycle scripts by default with an allowlist in - `package.json`. -- [`@lavamoat/allow-scripts`](https://www.npmjs.com/package/@lavamoat/allow-scripts) - — Userland tool that disables scripts via `--ignore-scripts` and selectively - re-runs them from an allowlist in `package.json`. -- [`can-i-ignore-scripts`](https://www.npmjs.com/package/can-i-ignore-scripts) — - Helps audit which dependencies actually need install lifecycle scripts. +Same reasoning as RFC #488's rejection: too disruptive. This is opt-in. -## Unresolved Questions and Bikeshedding +### Alternative: `pnpm` -### Config key names +pnpm 10 blocks lifecycle scripts by default with `onlyBuiltDependencies` / +`allowBuilds` in `package.json`. Users who can migrate to pnpm already have +this. This RFC brings equivalent control to npm. -`install-script-policy`, `install-script-allowlist`, and the JSON field -`approvedInstallScripts` are suggestions. The names should be bikeshedded. What -matters is the semantics: a mode toggle, a list of external config files, and a -mergeable allowlist structure. +## Implementation -I proposed using both package.json fields and npmrc because that allows package -and global config. Another option could be to use npmrc, but merge the arrays -across rc files instead of replace them. +1. **Config**: Add `allow-scripts` (type: String, default: `"all"`) to the + npm config schema. -``` -# ~/.npmrc -install-script-allowlist[]=/etc/npm/shared-approved-scripts.json +2. **Tree query**: After `Arborist` resolves the tree, if `allow-scripts` is + not `"all"`: + - If `"none"`: allowed set is empty. + - Otherwise: run `tree.querySelectorAll(allowScripts)` to get the allowed + set. + - Always add root and workspace nodes to the allowed set. -# /project/.npmrc -install-script-allowlist[]=./approved-scripts.json +3. **Script gating**: Before executing a lifecycle script for a dependency, + check if the node is in the allowed set. If not, skip and record. -# runtime sees ['/etc/npm/shared-approved-scripts.json', './approved-scripts.json'] -``` +4. **Reporting**: After reification, if any unapproved scripts were blocked, + print the full list with package specs and blocked lifecycle events, then + exit non-zero. -I didn't propose this because it doesn't behave like other npm config arrays. +5. **`npm query` integration**: Users can preview what would be allowed: + ```sh + npm query '[name="esbuild"], [name="better-sqlite3"]' + ``` -### Should `npm install ` in interactive mode prompt the user? +## Prior Art -When adding a new dependency that has install lifecycle scripts, npm could -interactively ask whether to approve it, similar to how `apt` confirms disk -usage or how Composer prompts for plugin permissions. +- [npm/rfcs#488][rfc-488] — "Make npm install scripts opt-in" (2021). + 369 👍, not merged due to breaking-change concerns. +- [pnpm `onlyBuiltDependencies` / `allowBuilds`][pnpm-allow] — pnpm 10 + blocks lifecycle scripts by default with an allowlist in `package.json`. +- [`@lavamoat/allow-scripts`][lavamoat] — Userland tool that disables + scripts via `--ignore-scripts` and selectively re-runs them. +- [`can-i-ignore-scripts`][ciis] — Helps audit which deps need install + scripts. +- [npm dependency selectors][dep-selectors] — The query syntax this RFC + builds on. +- [Manifest Confusion][manifest-confusion] — Registry does not validate + tarball metadata against the packument. + +## Unresolved Questions + +### Should there be a way to silence scripts without allowing them? + +Some packages have non-essential lifecycle scripts — `core-js` prints a +funding plea, `esbuild` has a postinstall that downloads a platform binary +but falls back to JS. Users may want to say "I know about these scripts, +skip them, and don't error about it." + +Options: +- A separate `--ignore-scripts-for=` config. Scripts matching this + selector are skipped silently (no error). Clear intent separation: "allow + to run" vs "known, don't run, don't complain." +- Overload `--allow-scripts` to mean "these packages are accounted for" + regardless of whether scripts run. Simpler (one config) but muddies the + semantics. + +### Should `npm install ` prompt interactively? + +When adding a new dependency that has lifecycle scripts, npm could prompt +whether to add it to the allow list, similar to how `apt` confirms actions. ### Should there be an `npm approve-scripts` command? -A helper command that scans `node_modules` (or the package tree) and -generates/updates the allowlist would make adoption much easier. Something like -`npm approve-scripts --init` to populate the allowlist with all -currently-installed packages that have scripts. +A helper that scans the tree and generates the selector string (or updates +`.npmrc`) would ease adoption. Deferred to a follow-up RFC. + +### Should unapproved scripts warn or error? + +This RFC proposes error (non-zero exit). An alternative is a +`--allow-scripts-policy=warn|error` flag, but that adds complexity. Starting +with error-only is simpler and more secure. + +### Exact versions vs ranges -### Interaction with workspaces +JamieMagee raised that `[name="esbuild"]:semver(>=0.25.0)` auto-approves +future compromised versions. Users who want maximum security can pin to +exact versions. Users who want convenience can use ranges. The RFC provides +the tools for both. -In a monorepo, should each workspace have its own `approvedInstallScripts` in -its `package.json`, or should only the root's list apply? The external-file -approach naturally handles this (point all workspaces at the same file), but the -`package.json` story needs clarification. +[rfc-488]: https://github.com/npm/rfcs/pull/488 +[dep-selectors]: https://docs.npmjs.com/cli/v11/using-npm/dependency-selectors +[arborist-qsa]: https://github.com/npm/cli/blob/latest/workspaces/arborist/lib/query-selector-all.js +[manifest-confusion]: https://blog.vlt.sh/blog/the-massive-hole-in-the-npm-ecosystem +[pnpm-allow]: https://pnpm.io/settings#allowbuilds +[lavamoat]: https://www.npmjs.com/package/@lavamoat/allow-scripts +[ciis]: https://www.npmjs.com/package/can-i-ignore-scripts