Skip to content

Revive RFC #488: Make install scripts opt-in#1

Open
JamieMagee wants to merge 8 commits into
mainfrom
jamimagee/revive-rfc-488
Open

Revive RFC #488: Make install scripts opt-in#1
JamieMagee wants to merge 8 commits into
mainfrom
jamimagee/revive-rfc-488

Conversation

@JamieMagee

Copy link
Copy Markdown
Owner

Rewrite the RFC from scratch. The original was from 2021 and predated pnpm blocking scripts by default, the prebuild pattern replacing node-gyp for most native addons, and several major postinstall attacks.

  • Replace 2018-2021 incident references with 2024-2026 attacks
  • Add allowScripts field in package.json (modeled on pnpm's allowBuilds)
  • Add npm approve-scripts interactive command
  • Add lockfile-based detection of unexpected script changes
  • Add Scope, Prior Art, and Migration Plan sections
  • Address all 6 review thread items from the original PR

}
```

A name-only entry (e.g., `"sharp": true`) allows all versions. A versioned entry (e.g., `"nx@21.6.4 || 21.6.5": true`) restricts the allowance to specific versions. If both a name-only entry and a versioned entry exist for the same package, the versioned entry takes precedence for matching versions.

@embetten embetten Apr 24, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the versioned entry allow ranges? Can we explicitly call this out in the design. ex:
nx@<21.6.4

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, only exact versions joined with || (e.g., nx@21.6.4 || 21.6.5). Semver ranges like <, ^, or ~ aren't supported. A range would automatically trust future versions you haven't reviewed, which is the thing we're trying to avoid. This matches how pnpm's allowBuilds works. I've updated the RFC to spell this out.

@JamieMagee JamieMagee force-pushed the jamimagee/revive-rfc-488 branch from 7282850 to 27adda7 Compare April 24, 2026 16:44
In a workspace (monorepo) context:

- The root `package.json` `allowScripts` field is the single source of truth for the entire workspace.
- Individual workspace `package.json` files do not have their own `allowScripts` fields. All script permissions are managed at the root.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if an individual workspace file does have an allowScripts, are they silently ignored or would there be a warning?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a warning makes sense here. If someone adds allowScripts to a workspace package.json, they probably expect it to do something. Silently ignoring it would be confusing. I'll update the RFC to say npm should warn when it encounters allowScripts in a non-root workspace package.json.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Pushed a bullet to the Workspaces section saying npm prints a warning and ignores the field if allowScripts shows up in a non-root workspace package.json. Silently dropping it would be confusing for anyone who put it there expecting it to do something.


### Detecting unexpected script changes

When a `package-lock.json` is present, npm already records the full metadata for each resolved package. The implementation should additionally track which packages had install scripts at lock time. If a subsequent `npm install` resolves a package version whose scripts differ from what was recorded in the lock file (e.g., a previously script-free package now has a `postinstall`), npm should treat this as an unreviewed package and block the script with a warning, even if a name-only `allowScripts` entry exists for that package.

@jasonpaulos jasonpaulos Apr 24, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My reading of this section is that it implies some things that I don't see mentioned anywhere else. Specifically:

  • package-lock.json should record the relevant scripts each dependency in the tree declares
  • An affirmative allowScripts entry for a package should be bypassed/overlooked if a new version of the dependency changes its scripts

I want to dive deeper on the implications here. The open questions as I see them:

  1. How exactly should package-lock.json persist information about a package's scripts? Should it record the mere presence of a script or the actual contents?
  2. Is the introduction of a new script the only mechanism which should trigger a bypass of allowScripts, or does a change in script contents also count?
  3. How does the allowScripts bypass interact with dangerously-allow-all-scripts?
  4. Without updating persistent state such as the package.json or package-lock.json, it seems like the allowScripts bypass when updating a package version only protects you during the update itself. Later installs would run the script if the package is listed without a version in allowScripts (assuming installing the new version of the package updates the package-lock.json to have current information about the package's scripts).

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are the right questions. I've expanded the TOFU section to address each one. Here's the short version:

  1. The lockfile already has what we need. package-lock.json (v3) stores a hasInstallScript boolean on each package entry. It's set by arborist during lockfile generation and currently used as a rebuild performance hint. We'd reuse it for the TOFU comparison rather than adding new fields. Presence only, not contents.
  2. Only new script introduction (false→true) triggers a block. If a package already had scripts and the contents change, that doesn't fire the TOFU check. Tracking content changes would mean storing script hashes in the lockfile, and the security gain is marginal: an attacker who already controls a package with install scripts can change any code in the package, not just the script body. That's better addressed by integrity/provenance checks.
  3. --dangerously-allow-all-scripts bypasses everything, including TOFU. But it doesn't update the lockfile baseline, so it won't silently approve scripts for future normal installs.
  4. You're right that the TOFU protection is one-shot for name-only entries. It fires during the version update, then the lockfile records the new state and subsequent installs pass through. I initially worried this required rearchitecting arborist's reify pipeline, but it turns out the ordering is already favorable: arborist runs scripts before writing the new lockfile (_build() runs before _saveIdealTree()). So the old hasInstallScript values are available via actualTree.meta when the check needs to run. The insertion point is rebuild.js's #runScripts(), which is the single chokepoint for all lifecycle scripts. Roughly ~15 lines of new code, no pipeline changes.

For teams that want stronger protection than one-shot TOFU, npm approve-scripts now defaults to version-pinned entries ("sharp@0.34.0": true), so a version bump naturally falls out of the allowlist and requires re-approval. Name-only entries are still available with --pin=false for teams that prefer convenience over strictness.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this makes a lot more sense to me now

@jasonpaulos jasonpaulos Apr 25, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, the more I think about this version of TOFU, the less it makes sense to me. If this is only a protection against a package that had no install scripts and now has some, why would the package be in the allow scripts map in the first place? I can only identify one case where this protection would kick in: if a package is in the allowed scripts without a version because it used to have install scripts, the install scripts got removed but the allowed scripts has not been updated, and now the package reintroduces install scripts. And even then, as you point out, this only protects you during the initial install. Subsequent installs/CI runs will happily run the package scripts without warning. Did I summarize this right?

If so, this doesn't sound like trust on first use so much as it is a regression protection, and only a temporary one at that.

The real TOFU is the concept of allowed scripts in the first place, as long as people aren't adding packages that have no scripts at all to the allow list.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped the lockfile-based TOFU mechanism entirely. Version-pinning by default already covers the narrow case the lockfile check was meant to. Replaced the section with a "Why not lockfile-based change detection?" rationale in Rationale and Alternatives that walks through the reasoning.

JamieMagee and others added 3 commits April 24, 2026 13:30
Rewrite the RFC from scratch. The original was from 2021 and predated
pnpm blocking scripts by default, the prebuild pattern replacing
node-gyp for most native addons, and several major postinstall attacks.

- Replace 2018-2021 incident references with 2024-2026 attacks
- Add allowScripts field in package.json (modeled on pnpm's allowBuilds)
- Add npm approve-scripts interactive command
- Add lockfile-based detection of unexpected script changes
- Add Scope, Prior Art, and Migration Plan sections
- Address all 6 review thread items from the original PR

Co-authored-by: Francisco Ryan Tolmasky I <tolmasky@gmail.com>
A range like nx@<21.6.4 would automatically trust future unreviewed
versions, which defeats the purpose of an allowlist. Only exact
versions with || disjunction are supported, matching pnpm's design.
Address reviewer questions about what the lockfile records, what
triggers a block, and how persistence works. The TOFU check fits
in rebuild.js's #runScripts() since scripts run before the lockfile
is rewritten, making the old hasInstallScript values available via
actualTree.meta.
@JamieMagee JamieMagee force-pushed the jamimagee/revive-rfc-488 branch from 27adda7 to f4a3b04 Compare April 24, 2026 20:35
Consider a name-only `allowScripts` entry (`"sharp": true`) and a version bump from 0.33.2 (no scripts) to 0.34.0 (adds `postinstall`):

1. First `npm install` resolving 0.34.0: The lockfile records `hasInstallScript: false` for sharp (from the previous resolution). The fetched 0.34.0 package has `postinstall`. The TOFU check fires and the script is blocked with a warning.
2. The user runs `npm approve-scripts sharp`. The lockfile is regenerated with `hasInstallScript: true` for sharp@0.34.0.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't the lockfile regenerated in the previous step? Since the allowScripts entry already has sharp: true (no pinned versions), will npm approve-scripts sharp have any effect?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moot now. The lockfile-based TOFU section is gone, replaced by a "Why not lockfile-based change detection?" rationale in Rationale and Alternatives. The persistence-problem walkthrough that prompted this question doesn't exist anymore.


@lavamoat/allow-scripts has the strongest TOFU model among existing tools. It stores decisions in `package.json` under `lavamoat.allowScripts` with version-pinned keys (`"sharp#0.33.2": true`). When a version changes, the old key no longer matches and the package is treated as unconfigured — the install fails and forces the user to re-run `allow-scripts auto`. This version-pinning approach means every version bump requires explicit re-approval, which is secure but creates friction during routine updates.

#### The persistence problem

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This example is clarifying, but as I understand, this isn't the golden path. The real benefit of TOFU happens when you pin a version, right? In that case there is no persistance problem. It may be worth going through that case as well to highlight the benefits

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Version-pinned is the documented default for npm approve-scripts now (--pin=true), so any version change naturally falls out of the allowlist and forces re-approval. The walkthrough you were responding to is gone with the rest of the TOFU section, since version-pin-by-default does the same work without a separate detection layer.

- The lockfile records `hasInstallScript: false` (or the field is absent) and the fetched package has install scripts. This is the "new script introduced" case.
- The package entry does not exist in the lockfile at all (new dependency). This is already handled by the normal `allowScripts` check.

A change in script *contents* for a package that already had scripts (lockfile says `hasInstallScript: true`, fetched package also has scripts but different ones) does not trigger a TOFU block. Content-level change detection would require storing script hashes in the lockfile, adding complexity with marginal security benefit — an attacker who controls a package that already has install scripts can change the script body within the existing script entry, but this attack vector is equivalent to changing any other code in the package and is better addressed by integrity checking and provenance attestations.

@jasonpaulos jasonpaulos Apr 24, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

an attacker who controls a package that already has install scripts can change the script body within the existing script entry, but this attack vector is equivalent to changing any other code in the package and is better addressed by integrity checking and provenance attestations.

Consider this a nit, but I believe this argument goes against the earlier discussion about how install scripts are a distinct threat class, specifically the part saying the attack vector is equivalent to changing any other code in the package and is better addressed by integrity checking and provenance attestations.

Integrity checking does nothing to guard against scripts that change in new versions, since we expect the digest of the package to change.

It has already been established that malicious scripts can be more dangerous than malicious code, since they run instantly, have different privileges, are often viral, etc.

I'd instead argue that the benefits of script content checking do not outweigh the cost & complexity of doing so as part of this RFC. There are still gaps with this approach, such as when packages redirect their script to a dedicated file, i.e. "postinstall": "node scripts/postinstall.js". I do think this can be a useful check, but it's not bulletproofed and it would be better addressed in a later RFC in my opinion.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The contradictory rationale is gone. The replacement argument in "Why not lockfile-based change detection?" is more honest: lockfile-based detection only fires on false -> true, doesn't catch the scripts-already-present-but-now-malicious case (which is the actually-dangerous pattern), and version-pin-by-default covers the narrow case it was meant to anyway. None of which leans on the "equivalent to any other code" line.


### The `npm ignored-scripts` command

Lists all dependencies whose install scripts were blocked during the last install:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This wording suggests that npm need to keep track of which scripts were blocked in the previous install.

I think the behavior you are describing is actually to list the packages which define install scripts but aren't explicitly addressed in the allowScripts map.

Perhaps changing to present tense or future tense would help here. E.g. list all dependencies whose install scripts are blocked during install

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed via the simpler route: npm ignored-scripts is gone, merged into npm approve-scripts --pending per @owlstronaut's adjacent suggestion. The new mode is described in present tense ("list packages whose install scripts are not yet covered by allowScripts") and explicitly notes that no state is persisted.


### The `npm approve-scripts` command

A new interactive command scans the dependency tree, identifies packages with install scripts, and prompts the user to approve or deny each one:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good to pin down the behavior of what should happen if you run npm approve-scripts in the following situations:

  1. package@a.b.c: true is present in the approved scripts map, but the currently installed version is different, x.y.z, and this was invoked with --pin=true. Should the result be package@a.b.c || x.y.z or just package@x.y.z?
  2. Same situation as above, but --pin=false. Should the result be just package?
  3. package: true is present in the approved scripts map and --pin=true was used. Do we upgrade the entry to package@{current version}?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My suggestions:

  1. package@x.y.z. This keeps the map clean and relevant to current dependency versions.
  2. package. By passing --pin=false, the user is asking for package-level granularity. It makes sense to update the package.json to reflect this and unblock the package's scripts.
  3. package@{current version}. Similarly, --pin=true shows the user's preference for granularity, so the version info should be added.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adopted your three rules. There's now a small table in the npm approve-scripts section covering the scenarios you outlined: rewrite (not union) on version mismatch under --pin=true, drop to name-only under --pin=false, and upgrade name-only to pinned when --pin=true is used on a previously name-only entry. With a note that anyone wanting a multi-version || allowance can still hand-edit the entry.


The `allowScripts` field is only read from the root project's `package.json` (or workspace root). `allowScripts` fields in dependency `package.json` files are ignored. This is a consumer-side policy, not a publisher declaration.

### Policy layering

@jasonpaulos jasonpaulos Apr 24, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Layering logic can become complex depending on the granularity with which we fall back to a lower-precedence layer.

I.e. if package.json defines an allow script map with package@[version not installed]: false but the .npmrc has allow-scripts = package, will the package be allowed to run scripts or not?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To limit complexity here, I suggest that lower-precedence layers are fallbacks only. As soon as a layer with higher precedence introduces allow script logic, it completely overrides anything defined by a lower-precedence layer.

So in the example from the original comment, the package would not be allowed to run a script, since the package.json doesn't allow the currently installed version of that package. The .npmrc allow script is irrelevant because it's never consulted.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Policy layering now explicitly says layers are not merged: the first layer with any allowScripts configuration wins for the entire install, lower-precedence layers aren't consulted. Worked example using your scenario (project package.json has sharp@0.34.0, installed version is 0.35.0 -> install fails regardless of .npmrc). Project-level .npmrc is also now in the chain at position 3, which addresses @ByAgenT's adjacent question.


### Related npm RFCs

- [RFC #861](https://github.com/npm/rfcs/pull/861): "Add option to require install script approval." Adds opt-in controls without changing defaults. This RFC supersedes #861 with a broader scope (default-deny).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The piece of npm#861 this doesn't supersede is the discussion around query selectors (i know this was a beginning discussion elsewhere).

I think the RFC should incorporate that direciton as part of the core design. If we ship the map now without selectors, we'll likely end up bolting them on later as a second field.

Maybe we can treat simple keys like sharp or sharp@0.34.0 as sugar that the engine interprests as #sharp or sharp@0.34.0 selectors internally while accepting full selector syntax.

@everett1992 everett1992 May 6, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I let that RFC rot a bit, I got pulled in another direction.

I explored query selectors and found a blocking issue. The selectors use data from untrusted sources.

  • Manifest Confusion allows packages to report a different name inside the tarball package.json served by the registry. AFAIK this hasn't been closed.
  • npm aliases allow packages to be installed under a different name, but the #id query matches the alias. trusted: npm:naughty matches both #trusted and #naughty selectors
  • #id and [name=] selectors could match the install location or self-reported package.json name of a git dependency.
  • [repository=] selector matches the self-reported repository, not the install location.

These issues probably aren't exclusive to query selectors - any allowlist needs to be careful about which fields they trust to provide the node name.

There's a gap in both proposals for matching http, git, tarball, folder nodes with trustworthy source.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both of you are basically pointing at the same problem and I think the answer's the same either way.

@owlstronaut: agreed on the don't-bolt-it-on instinct, and yeah, treating bare keys as sugar over a selector grammar is a clean way to keep the door open. That's a much nicer migration than having to add a parallel allowScriptsSelectors field in two years.

But @everett1992's blocking issue is the part I keep getting stuck on, and after reading the arborist source I think it's actually worse than the npm#861 risks section spells out. idType() literally does (name === node.name) || (name === node.package.name), so #sharp matches an aliased sharp@npm:malware install from either side. [name=...] only reads node.package.name, which is whatever the tarball claims, and is the manifest-confusion hole, still unfixed. There is no selector right now that pins a package down by how it was actually resolved.

And the part of @everett1992's reply I want to underline: this isn't a selector problem. The map has it too. Saying "sharp": true doesn't tell you which sharp. The map is just a degenerate selector with the same identity question hiding inside it.

So here's where I'm landing:

  • The RFC needs to spell out what the map keys actually match against, and the answer can't be node.package.name. My current intent is the resolver-facing identity: name@version for registry deps, the resolved URL for git/tarball/file, and aliases match the real package not the alias. Effectively the lockfile's resolved field, not the tarball's self-report.
  • I'll keep the surface syntax as plain keys but write the spec so an engine can later read them as sugar over a trustworthy-identity selector once arborist has one. That gives @owlstronaut the forward-compat handle without committing the RFC to ship a name match that's mechanically unsafe today.
  • I'll add an explicit "this is not node.name or node.package.name" callout, because otherwise an implementer will absolutely reach for the easy thing.

Realistically I think this RFC ships the map with the resolver-identity matching rule, and the trustworthy queryable identity field is a separate piece of work in arborist (versionFromTgz + hosted-git-info are most of what's needed). When that lands, bare keys fold neatly into a selector grammar and @owlstronaut's sugar
story works.

@everett1992 does that line up with where you're at in npm#861? Your "must not ship until trustworthy identity exists" sentence is what pushed me here, and it'd be nice if both RFCs converge on the same identity definition rather than each inventing one.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's exactly where I'm at. I'm happy to close my RFC in favor of this. I prefer the map syntax and support for package.json layerd on top of npmrc.

Effectively the lockfile's resolved field, not the tarball's self-report.

Yes - there needs to be some symmetry with package-spec and satisfies, so "npm/cli": true would match npm/cli#c12ea07 just like "npm" would match "npm@11.14.0"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Landed the package-spec / satisfies symmetry in the latest commit. The Identity matching section now explicitly says keys are parsed with npm-package-arg, and "npm/cli": true matches any commit of that repo just like "npm": true matches any version of the package. Selector-grammar forward-compat is captured as Unresolved Q8 for when arborist exposes a queryable trustworthy identity. Prior Art credits npm#861 as the source of the identity rule.

Comment on lines +367 to +380
### Phase 1: Tooling and advisory warnings (next minor release)

- Ship `npm approve-scripts` and `npm ignored-scripts` commands.
- Recognize the `allowScripts` field in `package.json`.
- Print advisory warnings when dependency install scripts run that are not covered by an `allowScripts` field.
- No change in default behavior: scripts still run.

### Phase 2: Default-deny (next major release)

- Dependency install scripts are blocked by default.
- Scripts for packages listed in `allowScripts` with `true` still run.
- Blocked scripts produce a warning with remediation instructions.
- `strict-script-builds=true` available for CI environments that want hard failures.
- `--dangerously-allow-all-scripts` available as an escape hatch.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I both understand the urgency and am anxious about it. npm 12 is imminent (this quarter). There isn't a lot of time for the warning in npm 11 vs release of npm 12. Is the default deny a npm 13 thing?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think given the tight timeline for npm 12 I think we're going to push the default deny to npm 13. I didn't want to put exact major versions on this for that reason.

3. `npm approve-scripts` (new command): Reads the current `node_modules` tree (from `package-lock.json` or disk), identifies packages with install scripts not yet in `allowScripts`, and writes decisions to `package.json`. Triggers `npm rebuild` for newly approved packages. This command has two modes:

- Non-interactive (works today): positional arguments (`npm approve-scripts canvas sharp`) and `--all` use only the existing `read` package and `proc-log` input primitives, which are already in the CLI.
- Interactive multi-select (new dependency required): the arrow-key/checkbox UI shown in the design section would require adding a terminal prompt library such as `enquirer` or `@inquirer/prompts`. The npm CLI does not currently bundle anything capable of multi-select prompts; its `read` package only handles single-line text input. pnpm solved this by depending on `enquirer`. The interactive mode could ship in a follow-up if adding a new dependency is contentious.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We try pretty hard to not add new dependencies. With bundled dependencies, every addition is a new maintenance load. Greatly prefer starting with a non-interactive mode

@JamieMagee JamieMagee May 8, 2026

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Interactive multi-select is dropped from this RFC. The approve-scripts command now ships only the command-line forms (positional arguments, --all, --pending), all of which work with the npm CLI's existing read/proc-log primitives. No new bundled dependency. Added a one-line Unresolved Question 9 noting that an interactive mode could ship in a follow-up RFC if a suitable already-bundled primitive becomes available.

Comment on lines +342 to +344
| `allow-scripts` | Comma-separated list | (empty) | Packages allowed to run install scripts (for global/npx context) |
| `strict-script-builds` | Boolean | `false` | When `true`, blocked scripts cause install to fail |
| `dangerously-allow-all-scripts` | Boolean | `false` | When `true`, all scripts run (escape hatch) |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naming here is tough. allow-scripts sounds like the opposite of the currently existing ignore-scripts - a per-package allow list vs a boolean toggle can be confusing. RFC npm#861 suggested install-script-policy and allowlist.. I don't know the exact right answer here, but I think we can tweak this to make it less confusing for users.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After thinking it through I'd like to keep allowScripts. Updated Unresolved Q1 to mark the question resolved with the rationale: --ignore-scripts is a boolean CLI flag (a global switch), allowScripts is a per-package map in package.json (a granular allowlist). They're different things at different config layers, and --ignore-scripts continues to take precedence when set. The collision is at the prefix level only, not in the actual semantics. Acknowledged the alternatives (allowBuilds, trustedDependencies, installScriptPolicy) in the same paragraph for the record.

Comment on lines +182 to +194
### The `npm ignored-scripts` command

Lists all dependencies whose install scripts were blocked during the last install:

```
$ npm ignored-scripts
The following packages had their install scripts blocked:

canvas (postinstall: node-gyp rebuild)
sharp (install: node install/libvips && ...)

To approve them, run: npm approve-scripts
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this has similar concerns over naming colliding with the --ignore-scripts option. Maybe we can avoid another command for this and reuse npm approve-scripts with a --pending option that can scan the current tree and show which packages have scripts that aren't in the allowlist. Then you just get the diff of what is installed vs what is approved

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, merged exactly as you suggested. npm ignored-scripts is gone; npm approve-scripts --pending is the read-only preview mode. Implementation now lists two modes (write, read-only preview) and explicitly says no state is persisted. It's a query of the present tree, not a record of past blocks. That also resolves @jasonpaulos's adjacent wording-bug thread.

Script permissions are resolved from multiple sources with the following precedence (highest to lowest):

1. CLI flags (`--allow-scripts`, `--no-scripts`, `--dangerously-allow-all-scripts`)
2. Project `package.json` (`allowScripts` field)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is project level .npmrc included in this chain? I assume npmrc files have same configuration options on every level, so would it be possible to set allow-scripts on project level, and if so, what would be its precedence over package.json?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, added project-level .npmrc to the precedence chain at position 3 (above user .npmrc, below the project package.json#allowScripts field). The new fallback-only rule from @jasonpaulos's adjacent thread applies, so project-level .npmrc is only consulted when there's no allowScripts field set in package.json at all.


The one-shot model is sufficient because the TOFU block fires at the moment it matters: when a dependency's install scripts change during a version update. After explicit approval, the lockfile baseline reflects the approved state, and the name-only `allowScripts` entry correctly permits the script on subsequent installs. The next TOFU event fires if scripts change again (e.g., another version adds a different script, or a package removes and re-adds scripts).

For teams that want stronger guarantees, version-pinned `allowScripts` entries (`"sharp@0.33.2": true`) are available. A version-pinned entry means any version change requires re-approval through the normal `allowScripts` mechanism (not TOFU), because the new version simply won't match the allowlist entry. `npm approve-scripts` should default to generating version-pinned entries, with a `--pin=false` flag for teams that prefer name-only entries:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do other tools also pin approvals to a specific version when command is used with default parameters?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly no. LavaMoat v5+ requires version pins (name#version keys) and treats name-only entries as invalid. pnpm and Bun both support name-only and version-suffixed keys but don't pin by default (pnpm approve-builds writes name-only entries unless given a versioned spec). The RFC's --pin=true default sits between: pinned by default for security, but --pin=false flips back to name-only for convenience. Closer to LavaMoat than to pnpm/Bun.


### Bundled dependencies

Bundled dependencies are packages shipped inside a parent package's tarball. The lockfile marks them with `inBundle: true`, but they have no independent `resolved` URL since they were never fetched on their own. Bundled deps with install scripts are treated as unreviewed and blocked with a warning. Users who want to allow them can add the bundled package's `name@version` to `allowScripts`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to specify how the bundled-deps escape hatch avoids reintroducing the manifest confusion. Parent qualified key anchored to the trusted parent identity? I don't know how we close that hole without adding a bunch of complexity but still allow bundled deps with install scripts in the allow list

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, you're right. That escape hatch as written did exactly the thing the rest of the RFC bans. Dropped the unsafe sentence and added Unresolved Q9 pointing at parent-qualified keys as the safe path (e.g. parent@1.2.3 > bundled-name). Trust would derive from the parent's verified identity, with the bundled name read from bundleDependencies (which is bound to the parent's integrity hash). The trust derivation is subtle enough to deserve its own RFC. For now, bundled deps with install scripts just can't be allowlisted here. It's a small gap in practice; bundled deps with scripts are rare.


```ini
; ~/.npmrc
allow-scripts = canvas, sharp, sqlite3

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how would blocking a package look in the .npmrc? Or allowing/denying specific version(s)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed below.

npm approve-scripts canvas sharp

# Deny specific packages
npm approve-scripts !core-js

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: is the ! standard practice? I don't think I've ever used the ! pattern in a cli tool before personally. What about a --block or having an npm block/deny-scripts alias?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

npm approve-scripts !core-js
zsh: event not found: core

In zsh ! causes shell history expansion (searching for commands starting with core)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Replaced !-prefix with npm deny-scripts as a sibling command. @everett1992's zsh perspective made this not-optional.


When an entry for a package already exists in `allowScripts` and the installed version differs, the command rewrites the entry rather than appending a `||` clause:

| Existing entry | Installed version | `--pin=true` (default) | `--pin=false` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few more behaviors I think are worth pinning down for this:

Existing entry Installed version --pin=true (default) --pin=false
pkg: true (none) ? ?
pkg@a.b.c: true (none) ? ?
pkg@a.b.c || d.e.f: true pkg@x.y.z ? ?
pkg@a.b.c || x.y.z: true pkg@x.y.z ? ?
(no entry) pkg@a.b.c and pkg@x.y.z ? write pkg: true

The core questions behind these are:

  1. Should the command erase entries which no longer apply to the package tree? (Rows 1 & 2)
  2. Should the command erase compound version information when a updating the version in a pinned entry? (Row 3) What about when no update is necessary but superfluous versions are present? (Rows 4)
  3. There can be multiple versions of the package installed in the tree. When the same package is present with multiple versions, should the command capture all the versions or only those that are direct dependencies? Taking this one step further, what if you have pkg@a.b.c and alias@npm:pkg@x.y.z installed as top level dependencies? (Rows 5)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taking into account the purpose of npm approve-scripts --all being to approve all packages with pending scripts, I suggest the following behaviors:

Existing entry Installed version --pin=true (default) --pin=false
pkg: true (none) no edit no edit
pkg@a.b.c: true (none) no edit no edit
pkg@a.b.c || d.e.f: true pkg@x.y.z add entry pkg@x.y.z: true add entry pkg: true
pkg@a.b.c || x.y.z: true pkg@x.y.z no edit no edit
(no entry) pkg@a.b.c and pkg@x.y.z write pkg@a.b.c: true and pkg@x.y.z: true write pkg: true

Reasoning/responses to the earlier questions:

  1. No, this command is only concerned with approving pending packages. Pruning entries is out of scope.
  2. The command should never edit multi-version statements, as it risks corrupting or erasing information that the user explicitly added. Instead, add a new entry with the appropriate pinning if the current version is not represented anywhere. Normalizing other entries is out of scope.
  3. The command should add entries for all installed versions of the package. These should be separate entries in the map rather than a single multi-version one. This simplifies operations, and if this command never writes multi-version statements, then it never needs to update them either (helps with the previous question).

@jasonpaulos jasonpaulos May 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, the more I think about this, the more I realize there's two distinct use cases in this space: initial onboarding of a project to approveScripts, and updating the approved scripts to capture package changes, especially when versions are pinned.

Consider the second case after a package change. With the currently defined behavior, there's a trap that's easy to fall into:

  1. The user runs npm approve-scripts !pkg to disallow scripts from a package. This adds pkg@1.2.3: false to the allowed scripts map.
  2. pkg is updated to version 3.4.5
  3. The user runs npm approve-scripts --all to sync allowed scripts. This results in pkg@3.4.5: true being added to the map. pkg now has permission to run scripts even though the user explicitly denied that permission before.

I think there are a few ways to address this that are worth discussing (these are not mutually excluse):

  1. Make --pinned a 3-state value: true, false, and a 3rd named allow-only or similar, which would be the default. allow-only would pin allowed package versions but use name-only references for denied packages. The brittleness of pinned version for denied packages works against the desire of this RFC.
  2. Define behavior for how npm approve-scripts --all handles false values in the map to avoid approving packages that are already denied under different versions.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adopted your defaults table verbatim, and closed the denied-pin trap by writing denials name-only regardless of --pin. There's a new paragraph spelling out why pinning is conservative for approve but permissive for deny (the asymmetry favours the safer default in both directions). The bonus pkg: false row in the table makes it explicit that --all never overrides an existing deny.

The primary enforcement point is in the `@npmcli/run-script` and `@npmcli/arborist` packages:

1. `@npmcli/arborist`: During the `reify` step, before running lifecycle scripts for each dependency, check the resolved package name and version against the root project's `allowScripts` field. If the package is not allowed, skip its scripts and record it in a "blocked scripts" list. The TOFU check also lives here: in `rebuild.js`'s `#runScripts()`, compare each node's `hasInstallScript` against the old lockfile's value (available via `actualTree.meta.get(path)` since scripts run before `_saveIdealTree()` writes the new lockfile). If the old value is `false` and the fetched package has scripts, block the script even if a name-only `allowScripts` entry exists.
1. `@npmcli/arborist`: During the `reify` step, before running lifecycle scripts for each dependency, check the resolved package name and version against the root project's `allowScripts` field. If the package is not allowed, skip its scripts and record it in a "blocked scripts" list.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wording implies that only the root project's allowScripts field is consulted here. Does the entire policy stack (CLI flags, .npmrc files) apply to this use case?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworded the description to reference the resolved policy and explicitly list the precedence stack.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see a change here, maybe it got left out of the commit?

}
```

A name-only entry (e.g., `"sharp": true`) allows all versions. A versioned entry (e.g., `"nx@21.6.4 || 21.6.5": true`) restricts the allowance to specific versions, using exact versions joined by `||`. Semver ranges like `^`, `~`, `>=`, or `<` are not supported. This is intentional: a range like `nx@<21.6.4` would automatically trust future versions that haven't been reviewed, which defeats the purpose of an allowlist. If both a name-only entry and a versioned entry exist for the same package, the versioned entry takes precedence for matching versions.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea having versioned entries take precedence for the specified versions if there is also a name-only entry.

Taking this idea further, we should define the behavior for when there are duplicate version entries. JSON key uniqueness helps, but there are still cases where this can happen. For example:

{
  "allowScripts": {
    "pkg@1.2.3 || 3.4.5": true,
    "pkg@3.4.5 || 5.6.7": false
  }
}

My suggestion: if any version-specific entry has a value of false, that package version's scripts are considered blocked, regardless of any other version-specific entries statuses. So this example would result in pkg@3.4.5 not running scripts. I think that's the safest way to interpret the situation.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a 'deny-wins' paragraph to the allowScripts field section covering the overlapping-versioned-entries case

allow-scripts = canvas, sharp, sqlite3
```

### The `npm approve-scripts` command

@jasonpaulos jasonpaulos May 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider renaming to allow-scripts now that ignore-scripts is not planned?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Want to make sure I'm reading this right: are you suggesting renaming the command (approve-scripts -> allow-scripts) or the field? Both names are already in the doc. Happy to scope a rename of either, just want to confirm what you had in mind first.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wrote this before you re-added npm deny-scripts.

I was suggesting that, if there is only going to be a single command, it should be called npm approve-scripts for consistency with the package.json field. If there is be 2 commands, then having them called approve/deny is good too.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, that lines up with where we landed. approve-scripts + deny-scripts it is.

@JamieMagee JamieMagee requested review from jasonpaulos and jpinz May 12, 2026 03:18
| `pkg@a.b.c \|\| d.e.f: true` | `pkg@x.y.z` | add `pkg@x.y.z: true` | add `pkg: true` |
| `pkg@a.b.c \|\| x.y.z: true` | `pkg@x.y.z` (already covered) | no edit | no edit |
| (no entry) | `pkg@a.b.c` and `pkg@x.y.z` | write both pinned | write `pkg: true` |
| `pkg: false` | any | no edit (existing deny wins) | no edit |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest adding this for completeness:

Existing entry Installed version --pin=true (default) --pin=false
pkg@a.b.c: false pkg@x.y.z no edit (existing deny wins) no edit (existing deny wins)

In words, this means pkg@x.y.z will remain unreviewed instead of explicitly allowed or denied (and perhaps a warning can be printed so the user is nudged to take a manual action). Since npm won't write a pinned deny statement by default, I think this is ok.

Writing pkg: false in both cases could also make sense, but IMO if we have npm approve-scripts and npm deny-scripts, the former should never write deny entries.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added with a sentence below the table explaining the unreviewed-with-warning outcome. Thanks for catching the gap.


A name-only entry (e.g., `"sharp": true`) allows all versions. A versioned entry (e.g., `"nx@21.6.4 || 21.6.5": true`) restricts the allowance to specific versions, using exact versions joined by `||`. Semver ranges like `^`, `~`, `>=`, or `<` are not supported. This is intentional: a range like `nx@<21.6.4` would automatically trust future versions that haven't been reviewed, which defeats the purpose of an allowlist. If both a name-only entry and a versioned entry exist for the same package, the versioned entry takes precedence for matching versions.

If overlapping versioned entries assign different values to the same resolved version (for example, `"pkg@1 || 2": true` and `"pkg@2 || 3": false` both match version `2`), the `false` value wins. Deny-wins is the safer default; users who want a specific version allowed despite an overlapping deny should narrow the deny entry.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this interrupts the discussion around version pinning. Suggest moving this 1 paragraph lower

@JamieMagee JamieMagee May 13, 2026

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the deny-wins paragraph after the pnpm comparison sentence so the version-pinning discussion isn't interrupted.

Comment on lines -238 to -240
| `pkg@a.b.c: true` | `pkg@x.y.z` | rewrite to `pkg@x.y.z: true` | rewrite to `pkg: true`|
| `pkg: true` | `pkg@x.y.z` | upgrade to `pkg@x.y.z: true` | leave as `pkg: true` |
| (no entry) | `pkg@x.y.z` | write `pkg@x.y.z: true` | write `pkg: true` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why you removed the prior table entries? I meant the new cases to extend these, not replace them.

IMO having the command mutate single-version pinned entries is a good thing. It handles the basic case of syncing the approved scripts after making package changes. If we don't provide an easy way to do that, pinning by default will be a headache.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, I misread your intent and dropped the original rows. Restored them. The merged table now covers both: single-version pins are auto-maintained when the installed version changes, multi-version statements are never touched, and existing false entries always win. Also tightened the explanation paragraph to spell out the actual rule. Sorry about the rough first pass.

npm deny-scripts core-js telemetry-pkg
```

Denied entries are always recorded name-only, regardless of `--pin`. See the [approve-scripts section](#the-npm-approve-scripts-command) for the reasoning.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be simplified by having the deny command default to --pin=false and the approve to default to --pin=true

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thought about this and leaning keep the asymmetric rule. Your default-flip is cleaner on first read, but it leaves an explicit-flag footgun: npm deny-scripts --pin=true core-js would still write the brittle pinned deny you flagged earlier. The asymmetric rule costs about 3 sentences of explanation in the doc but makes the footgun unreachable even with explicit flags. Could go either way honestly. Happy to revisit if you feel strongly?

allow-scripts = canvas, sharp, sqlite3
```

### The `npm approve-scripts` command

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wrote this before you re-added npm deny-scripts.

I was suggesting that, if there is only going to be a single command, it should be called npm approve-scripts for consistency with the package.json field. If there is be 2 commands, then having them called approve/deny is good too.

@JamieMagee JamieMagee requested a review from jasonpaulos May 13, 2026 05:01

@everett1992 everett1992 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

While I liked the earlier layered config I can work with this, and agree it's better for the average use case.

@JamieMagee

Copy link
Copy Markdown
Owner Author

Upstream RFC is now open at npm#868. This branch stays as the review history.

Thanks @owlstronaut, @everett1992, @wraithgar, @jasonpaulos, @embetten, @jpinz, @ByAgenT! The review here genuinely shaped the final proposal. The deny-scripts split, the asymmetric pin rule, the identity-matching section, the workspace cwd handling, and the bundled-deps deferral all came directly out of these threads.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants