Skip to content

[RFC] Make install scripts opt-in#868

Open
JamieMagee wants to merge 2 commits into
npm:mainfrom
JamieMagee:jamimagee/make-install-scripts-opt-in
Open

[RFC] Make install scripts opt-in#868
JamieMagee wants to merge 2 commits into
npm:mainfrom
JamieMagee:jamimagee/make-install-scripts-opt-in

Conversation

@JamieMagee
Copy link
Copy Markdown

Summary

Block dependency install scripts (preinstall, install, postinstall, and auto-detected node-gyp builds) by default during npm install. Projects opt in to running scripts for specific dependencies via a new allowScripts field in package.json. Two new CLI commands, npm approve-scripts and npm deny-scripts, help users build and maintain the allowlist.

npm is the only remaining major package manager that runs dependency install scripts by default. pnpm v10+, Yarn Berry, Bun, and Deno all block them.

Why now

Recent attacks:

  • Shai-Hulud worm (September 2025): self-replicating postinstall payload compromised 500+ npm packages by stealing maintainer tokens and republishing infected versions.
  • chalk, debug, and 17 other packages (September 2025): phished maintainer account used to inject Web3 wallet-draining code into packages with over 2B combined weekly downloads. Delivered via postinstall.
  • Axios (March 2026): hijacked lead maintainer published versions with a phantom dependency that existed only to trigger its postinstall hook, deploying a cross-platform RAT. The malicious package was never imported in Axios source code.

Install scripts run automatically the moment a package lands in the dependency tree. They don't require any require() or import from the application. A typo, a transitive dep change in a lockfile a reviewer didn't read, or a maintainer compromise becomes immediate code execution.

Relationship to other RFCs

  • Supersedes RFC #488 (the 2021 ancestor, 369 👍, rejected as too disruptive at the time). The phased migration plan in this RFC addresses that concern directly.
  • Supersedes RFC #861 per author's note.

Block dependency install scripts (preinstall, install, postinstall, and
auto-detected node-gyp builds) by default. Projects opt in to running
scripts for specific dependencies via a new allowScripts field in
package.json. Adds npm approve-scripts and npm deny-scripts commands.

Revives RFC npm#488 (rejected 2021 as too disruptive). Supersedes RFC npm#861.
@JamieMagee JamieMagee requested a review from a team as a code owner May 14, 2026 18:28
@JamieMagee JamieMagee changed the title Make install scripts opt-in [RFC] Make install scripts opt-in May 14, 2026
@brunoborges
Copy link
Copy Markdown

brunoborges commented May 14, 2026

I'm sure this was considered by the team, but it would be great if they can provide insights: what is the rationale for keeping install scripts feature in the first place? In other words: why not get rid of this feature completely?

@leobalter
Copy link
Copy Markdown

@brunoborges The short answer is that a small but legitimate tail of packages still relies on install scripts (canvas, sharp, sqlite3, and similar native addons), and --ignore-scripts today is all-or-nothing, which is why it isn't a practical substitute.

The ecosystem has largely shifted to prebuilt binaries via optionalDependencies (esbuild, SWC, Sharp, Rollup, Biome, etc.), so the remaining surface is small - but it's non-zero, and removing the feature would break those packages with no clean migration path.

The actual problem isn't that the feature exists, it's arbitrary execution from unaudited transitive deps, and that's addressable by making scripts opt-in with an explicit per-package allowlist. That's the direction this RFC takes, and it leaves room to tighten further in future majors.

@brunoborges
Copy link
Copy Markdown

@leobalter I am afraid the RFC does not address the root of the problem that libraries are allowed to have install scripts in the first place. It still leaves room for laziness as an attack vector.

The actual problem isn't that the feature exists, it's arbitrary execution from unaudited transitive deps, and that's addressable by making scripts opt-in with an explicit per-package allowlist.

I'd argue that the existence of the feature is the actual problem. Once the feature exists, any enhancement is an additional guardrail.

One approach to consider is to distinguish between executable tools and libraries as published packages.

@AllanOricil
Copy link
Copy Markdown

AllanOricil commented May 15, 2026

It would also be useful if --ignore-scripts combined with --verbose displayed the exact lifecycle scripts that would have been executed, including their resolved paths (real ones not symlinks) and content hashes.

With this output we can then edit the new --allowScripts flag with the exact output showed, including its sha, instead of just the package name

"allowScripts" : [
    "path/to/script.extension?sha256=foo"
]

The current allowScripts proposal is still too permissive if approvals are scoped only to package names.

A package can change its lifecycle scripts between versions — or even within the same version range — which means the developer may end up executing code that was never actually reviewed or approved.

Because of that, approvals should be bound to the exact script file being executed, not just the package identity.

JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
A pure isScriptAllowed(node, policy) helper in
workspaces/arborist/lib/script-allowed.js. Used by the install-time
warning walker and by the approve-scripts / deny-scripts commands.

Matching rules follow the RFC:

  - registry deps: name + optional semver (range or exact)
  - git deps: canonical ssh-url match plus short-SHA prefix
  - file / directory / remote tarball: exact resolved string match
  - alias spec keys are ignored entirely; a user must address the real
    package name, not the alias
  - matching uses node.packageName, never node.name, so an alias
    install cannot be approved by writing its alias name

Conflict resolution: any matching false wins over any matching true.
No match returns null (unreviewed).

Pure function, no I/O. 15 test cases cover alias safety and
omitLockfileRegistryResolved.

Refs: npm/rfcs#868
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
…tall scripts

The Phase 1 advisory warning. No scripts are blocked. After arb.reify()
completes, reify-finish walks the actual tree for dependencies whose
install-relevant lifecycle scripts are not yet covered by the
allowScripts policy. The result is appended to the install output as
one grouped block, not one log line per package.

  - workspaces/arborist/lib/install-scripts.js: per-node helper that
    returns the install-relevant lifecycle scripts. Covers preinstall,
    install, postinstall, prepare (non-registry sources only), and the
    synthetic 'node-gyp rebuild' detected by isNodeGypPackage from
    @npmcli/node-gyp. The runtime fs check is needed because the
    lockfile's hasInstallScript field misses packages whose only
    install-time work is binding.gyp.
  - lib/utils/check-allow-scripts.js: walks arb.actualTree.inventory
    and filters to unreviewed nodes. Honours --ignore-scripts and
    --dangerously-allow-all-scripts as full opt-outs. Treats explicit
    deny entries as reviewed (no warning).
  - lib/utils/reify-finish.js: runs the walker and passes results to
    reify-output as an extras payload.
  - lib/utils/reify-output.js: prints the grouped summary after the
    funding and audit messages. JSON output puts the same data on
    summary.unreviewedScripts.

Refs: npm/rfcs#868
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
Both share an implementation in lib/utils/allow-scripts-cmd.js; the
files in lib/commands/ are thin shims that set verb = 'approve' or
'deny'.

  - npm approve-scripts <pkg>: writes 'pkg@version': true (pinned)
  - npm approve-scripts --no-pin <pkg>: writes 'pkg': true (name-only)
  - npm approve-scripts --all: approves every unreviewed install-script
    package in the resolved actual tree
  - npm approve-scripts --pending: read-only walk, lists unreviewed
    packages without modifying package.json
  - npm deny-scripts <pkg>: writes 'pkg': false. Always name-only,
    regardless of --pin, per the RFC's asymmetric-pin rule.
  - npm deny-scripts --all: denies every unreviewed install-script
    package

The shared writer in lib/utils/allow-scripts-writer.js implements the
RFC's pin-mismatch table as pure functions:
applyApprovalForPackage(existing, nodes, { pin }) and
applyDenyForPackage(existing, nodes). Grouping by package matters
because a per-node API can't tell a stale pin from a newly installed
version; a per-package one can.

Two new flags registered in workspaces/config/lib/definitions
(--all reuses the existing global definition):

  - --pending: read-only mode for approve-scripts
  - --pin: control pin behaviour for approve-scripts (default true)

Includes docs/lib/content/commands/{npm-approve-scripts,npm-deny-scripts}.md.

Refs: npm/rfcs#868
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
The allowScripts policy must live at the project root. A non-root
workspace declaring its own allowScripts field is almost always a
mistake: that policy would be silently ignored at install time.

reify-finish now walks the resolved actual tree after reify completes
and emits one warning per non-root workspace whose package.json has an
allowScripts field. Pure detection lives in
lib/utils/warn-workspace-allow-scripts.js; the tree walk piggybacks on
the inventory that's already loaded for the unreviewed-scripts summary.

Refs: npm/rfcs#868
@JamieMagee
Copy link
Copy Markdown
Author

JamieMagee commented May 15, 2026

It would also be useful if --ignore-scripts combined with --verbose displayed the exact lifecycle scripts that would have been executed, including their resolved paths (real ones not symlinks) and content hashes.

@AllanOricil I like this idea, but I think it's orthogonal to the rest of your proposal. A dry-run view of "here's what would actually run, with paths resolved through symlinks" is a useful diagnostic on its own, however approvals end up scoped. Worth its own RFC IMO.

With this output we can then edit the new --allowScripts flag with the exact output showed, including its sha, instead of just the package name

"allowScripts" : [
    "path/to/script.extension?sha256=foo"
]

The part I'm not sold on is hashing the script file itself. Lifecycle script values in package.json aren't file paths, they're shell command strings. A real postinstall looks like node scripts/setup.js && chmod +x ./bin/*. To meaningfully hash "what's about to run" you'd have to resolve every binary referenced on PATH, hash the transitive JS closure reachable from the entry script, and re-hash any time the Node.js or toolchain version changes. So every dependency update forces a full re-review of every script. In practice that's the dependency-approval equivalent of a default-deny TOFU prompt; people click through, and the signal disappears.

The current allowScripts proposal is still too permissive if approvals are scoped only to package names.

Agreed when it's name-only. The RFC's answer is that you can pin a version: "allowScripts": { "esbuild@0.20.0": true } only approves that exact version. Bumping to 0.20.1 puts esbuild back in npm approve-scripts --pending. The name-only form is there for convenience on packages whose install scripts you genuinely don't care to gate per-version.

A package can change its lifecycle scripts between versions — or even within the same version range — which means the developer may end up executing code that was never actually reviewed or approved.

Pinned entries handle the version-to-version case, range keys like ^0.20.0 are rejected, and same-version byte changes are caught by the lockfile's SHA-512 integrity.

Because of that, approvals should be bound to the exact script file being executed, not just the package identity.

name@version + lockfile integrity already binds approvals to the exact bytes. Per-script hashing only helps if integrity is wrong. Do you have a concrete attack in mind that name-plus-version-plus-integrity misses?

@JamieMagee
Copy link
Copy Markdown
Author

There's a Phase 1 implementation up at npm/cli#9360 for anyone who wants to look at it alongside the RFC.

It's advisory-only. Scripts still run, but install ends with a grouped warning listing packages whose install scripts haven't been approved via allowScripts. The matcher, the approve-scripts / deny-scripts commands, policy layering, and the three new configs are all wired in. The arb.rebuild() gate is what Phase 2 will flip.

Reviews on either thread welcome.

@sheplu
Copy link
Copy Markdown

sheplu commented May 15, 2026

+1 on having something around that
do you want to include part of this RFC something similar for the allow-git config?

@AllanOricil
Copy link
Copy Markdown

AllanOricil commented May 15, 2026

@JamieMagee One case I was thinking about that name@version + lock file integrity does not fully cover is when lifecycle scripts fetch remote content dynamically.

For example, a postinstall script may download an executable from a stable URL:

{
  "postinstall": "node scripts/install.js"
}

where install.js does something like:

https.get("https://example.com/tool/latest")

In that scenario:

  • the package version remains unchanged,
  • the lockfile integrity remains valid,
    but the actual executed payload can still change server-side at any time.

So name@version + integrity guarantees the integrity of the npm package itself, but not necessarily the integrity of the code ultimately executed during install.

That is partly why I was thinking about approvals at the executable/script level rather than only at the package level, although I agree it still would not completely solve dynamic remote execution.

@AllanOricil
Copy link
Copy Markdown

AllanOricil commented May 15, 2026

I will open a RFC for the other idea of outputing the list of all lifecycle scripts that would have run, if that isn't shown already. Maybe that info should be displayed as a warning, in a parsable way. We could use the output to manually analyze each executable or use a consensus of many LLMs analysis to better warn developers.

Maybe NPM should do this LLM analysis at publish time before making a package available?

JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
…low-all-scripts configs

Three new configs to support the install-script opt-in policy. None
of them affect install behaviour yet; they're read by approve-scripts,
deny-scripts, and the install-time walker in later commits.

  - allow-scripts: comma-separated package list. Used as a fallback
    when the root package.json has no allowScripts field. Flattens
    to flatOptions.allowScripts.
  - strict-script-builds: boolean. Reserved for a future release that
    will turn blocked-script warnings into errors. No-op for now.
  - dangerously-allow-all-scripts: boolean escape hatch for that same
    future release. No-op for now.

Refs: npm/rfcs#868
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
…I configs

A precedence resolver reads the install-time allowScripts policy from
the layered sources and threads it through install/ci into arborist.

  - lib/utils/resolve-allow-scripts.js: pure resolver. Reads from
    npm.prefix so workspace sub-installs still pick up the project
    root. Returns { policy, source }. Strict fallback: package.json
    wins over flat config; lower layers are silently ignored, with
    one warn when a lower setting is being suppressed.
  - install.js / ci.js: await the resolver before constructing
    arborist opts, then pass policy through opts.allowScripts. Add
    the three new params to each command's static params list.
  - workspaces/arborist/lib/arborist/index.js: accept
    options.allowScripts and store it on this.options. No enforcement
    yet; read in later commits.

Also tightened the flatten function for the new allow-scripts config:
nopt wraps single comma-separated strings in arrays for [String, Array]
types, so each array entry needs splitting on commas before use.

Refs: npm/rfcs#868
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
A pure isScriptAllowed(node, policy) helper in
workspaces/arborist/lib/script-allowed.js. Used by the install-time
warning walker and by the approve-scripts / deny-scripts commands.

Matching rules follow the RFC:

  - registry deps: name + optional semver (range or exact)
  - git deps: canonical ssh-url match plus short-SHA prefix
  - file / directory / remote tarball: exact resolved string match
  - alias spec keys are ignored entirely; a user must address the real
    package name, not the alias
  - matching uses node.packageName, never node.name, so an alias
    install cannot be approved by writing its alias name

Conflict resolution: any matching false wins over any matching true.
No match returns null (unreviewed).

Pure function, no I/O. 15 test cases cover alias safety and
omitLockfileRegistryResolved.

Refs: npm/rfcs#868
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
…tall scripts

The Phase 1 advisory warning. No scripts are blocked. After arb.reify()
completes, reify-finish walks the actual tree for dependencies whose
install-relevant lifecycle scripts are not yet covered by the
allowScripts policy. The result is appended to the install output as
one grouped block, not one log line per package.

  - workspaces/arborist/lib/install-scripts.js: per-node helper that
    returns the install-relevant lifecycle scripts. Covers preinstall,
    install, postinstall, prepare (non-registry sources only), and the
    synthetic 'node-gyp rebuild' detected by isNodeGypPackage from
    @npmcli/node-gyp. The runtime fs check is needed because the
    lockfile's hasInstallScript field misses packages whose only
    install-time work is binding.gyp.
  - lib/utils/check-allow-scripts.js: walks arb.actualTree.inventory
    and filters to unreviewed nodes. Honours --ignore-scripts and
    --dangerously-allow-all-scripts as full opt-outs. Treats explicit
    deny entries as reviewed (no warning).
  - lib/utils/reify-finish.js: runs the walker and passes results to
    reify-output as an extras payload.
  - lib/utils/reify-output.js: prints the grouped summary after the
    funding and audit messages. JSON output puts the same data on
    summary.unreviewedScripts.

Refs: npm/rfcs#868
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
Both share an implementation in lib/utils/allow-scripts-cmd.js; the
files in lib/commands/ are thin shims that set verb = 'approve' or
'deny'.

  - npm approve-scripts <pkg>: writes 'pkg@version': true (pinned)
  - npm approve-scripts --no-pin <pkg>: writes 'pkg': true (name-only)
  - npm approve-scripts --all: approves every unreviewed install-script
    package in the resolved actual tree
  - npm approve-scripts --pending: read-only walk, lists unreviewed
    packages without modifying package.json
  - npm deny-scripts <pkg>: writes 'pkg': false. Always name-only,
    regardless of --pin, per the RFC's asymmetric-pin rule.
  - npm deny-scripts --all: denies every unreviewed install-script
    package

The shared writer in lib/utils/allow-scripts-writer.js implements the
RFC's pin-mismatch table as pure functions:
applyApprovalForPackage(existing, nodes, { pin }) and
applyDenyForPackage(existing, nodes). Grouping by package matters
because a per-node API can't tell a stale pin from a newly installed
version; a per-package one can.

Two new flags registered in workspaces/config/lib/definitions
(--all reuses the existing global definition):

  - --pending: read-only mode for approve-scripts
  - --pin: control pin behaviour for approve-scripts (default true)

Includes docs/lib/content/commands/{npm-approve-scripts,npm-deny-scripts}.md.

Refs: npm/rfcs#868
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
The allowScripts policy must live at the project root. A non-root
workspace declaring its own allowScripts field is almost always a
mistake: that policy would be silently ignored at install time.

reify-finish now walks the resolved actual tree after reify completes
and emits one warning per non-root workspace whose package.json has an
allowScripts field. Pure detection lives in
lib/utils/warn-workspace-allow-scripts.js; the tree walk piggybacks on
the inventory that's already loaded for the unreviewed-scripts summary.

Refs: npm/rfcs#868
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
A pure isScriptAllowed(node, policy) helper in
workspaces/arborist/lib/script-allowed.js. Used by the install-time
warning walker and by the approve-scripts / deny-scripts commands.

Matching rules follow the RFC:

  - registry deps: name + optional semver (range or exact)
  - git deps: canonical ssh-url match plus short-SHA prefix
  - file / directory / remote tarball: exact resolved string match
  - alias spec keys are ignored entirely; a user must address the real
    package name, not the alias
  - matching uses node.packageName, never node.name, so an alias
    install cannot be approved by writing its alias name

Conflict resolution: any matching false wins over any matching true.
No match returns null (unreviewed).

Pure function, no I/O. 15 test cases cover alias safety and
omitLockfileRegistryResolved.

Refs: npm/rfcs#868
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
…tall scripts

The Phase 1 advisory warning. No scripts are blocked. After arb.reify()
completes, reify-finish walks the actual tree for dependencies whose
install-relevant lifecycle scripts are not yet covered by the
allowScripts policy. The result is appended to the install output as
one grouped block, not one log line per package.

  - workspaces/arborist/lib/install-scripts.js: per-node helper that
    returns the install-relevant lifecycle scripts. Covers preinstall,
    install, postinstall, prepare (non-registry sources only), and the
    synthetic 'node-gyp rebuild' detected by isNodeGypPackage from
    @npmcli/node-gyp. The runtime fs check is needed because the
    lockfile's hasInstallScript field misses packages whose only
    install-time work is binding.gyp.
  - lib/utils/check-allow-scripts.js: walks arb.actualTree.inventory
    and filters to unreviewed nodes. Honours --ignore-scripts and
    --dangerously-allow-all-scripts as full opt-outs. Treats explicit
    deny entries as reviewed (no warning).
  - lib/utils/reify-finish.js: runs the walker and passes results to
    reify-output as an extras payload.
  - lib/utils/reify-output.js: prints the grouped summary after the
    funding and audit messages. JSON output puts the same data on
    summary.unreviewedScripts.

Refs: npm/rfcs#868
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
Both share an implementation in lib/utils/allow-scripts-cmd.js; the
files in lib/commands/ are thin shims that set verb = 'approve' or
'deny'.

  - npm approve-scripts <pkg>: writes 'pkg@version': true (pinned)
  - npm approve-scripts --no-pin <pkg>: writes 'pkg': true (name-only)
  - npm approve-scripts --all: approves every unreviewed install-script
    package in the resolved actual tree
  - npm approve-scripts --pending: read-only walk, lists unreviewed
    packages without modifying package.json
  - npm deny-scripts <pkg>: writes 'pkg': false. Always name-only,
    regardless of --pin, per the RFC's asymmetric-pin rule.
  - npm deny-scripts --all: denies every unreviewed install-script
    package

The shared writer in lib/utils/allow-scripts-writer.js implements the
RFC's pin-mismatch table as pure functions:
applyApprovalForPackage(existing, nodes, { pin }) and
applyDenyForPackage(existing, nodes). Grouping by package matters
because a per-node API can't tell a stale pin from a newly installed
version; a per-package one can.

Two new flags registered in workspaces/config/lib/definitions
(--all reuses the existing global definition):

  - --pending: read-only mode for approve-scripts
  - --pin: control pin behaviour for approve-scripts (default true)

Includes docs/lib/content/commands/{npm-approve-scripts,npm-deny-scripts}.md.

Refs: npm/rfcs#868
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
The allowScripts policy must live at the project root. A non-root
workspace declaring its own allowScripts field is almost always a
mistake: that policy would be silently ignored at install time.

reify-finish now walks the resolved actual tree after reify completes
and emits one warning per non-root workspace whose package.json has an
allowScripts field. Pure detection lives in
lib/utils/warn-workspace-allow-scripts.js; the tree walk piggybacks on
the inventory that's already loaded for the unreviewed-scripts summary.

Refs: npm/rfcs#868
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 23, 2026
…tall scripts

The Phase 1 advisory warning. No scripts are blocked. After arb.reify()
completes, reify-finish walks the actual tree for dependencies whose
install-relevant lifecycle scripts are not yet covered by the
allowScripts policy. The result is appended to the install output as
one grouped block, not one log line per package.

  - workspaces/arborist/lib/install-scripts.js: per-node helper that
    returns the install-relevant lifecycle scripts. Covers preinstall,
    install, postinstall, prepare (non-registry sources only), and the
    synthetic 'node-gyp rebuild' detected by isNodeGypPackage from
    @npmcli/node-gyp. The runtime fs check is needed because the
    lockfile's hasInstallScript field misses packages whose only
    install-time work is binding.gyp.
  - lib/utils/check-allow-scripts.js: walks arb.actualTree.inventory
    and filters to unreviewed nodes. Honours --ignore-scripts and
    --dangerously-allow-all-scripts as full opt-outs. Treats explicit
    deny entries as reviewed (no warning).
  - lib/utils/reify-finish.js: runs the walker and passes results to
    reify-output as an extras payload.
  - lib/utils/reify-output.js: prints the grouped summary after the
    funding and audit messages. JSON output puts the same data on
    summary.unreviewedScripts.

Refs: npm/rfcs#868
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 23, 2026
Both share an implementation in lib/utils/allow-scripts-cmd.js; the
files in lib/commands/ are thin shims that set verb = 'approve' or
'deny'.

  - npm approve-scripts <pkg>: writes 'pkg@version': true (pinned)
  - npm approve-scripts --no-pin <pkg>: writes 'pkg': true (name-only)
  - npm approve-scripts --all: approves every unreviewed install-script
    package in the resolved actual tree
  - npm approve-scripts --pending: read-only walk, lists unreviewed
    packages without modifying package.json
  - npm deny-scripts <pkg>: writes 'pkg': false. Always name-only,
    regardless of --pin, per the RFC's asymmetric-pin rule.
  - npm deny-scripts --all: denies every unreviewed install-script
    package

The shared writer in lib/utils/allow-scripts-writer.js implements the
RFC's pin-mismatch table as pure functions:
applyApprovalForPackage(existing, nodes, { pin }) and
applyDenyForPackage(existing, nodes). Grouping by package matters
because a per-node API can't tell a stale pin from a newly installed
version; a per-package one can.

Two new flags registered in workspaces/config/lib/definitions
(--all reuses the existing global definition):

  - --pending: read-only mode for approve-scripts
  - --pin: control pin behaviour for approve-scripts (default true)

Includes docs/lib/content/commands/{npm-approve-scripts,npm-deny-scripts}.md.

Refs: npm/rfcs#868
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 23, 2026
The allowScripts policy must live at the project root. A non-root
workspace declaring its own allowScripts field is almost always a
mistake: that policy would be silently ignored at install time.

reify-finish now walks the resolved actual tree after reify completes
and emits one warning per non-root workspace whose package.json has an
allowScripts field. Pure detection lives in
lib/utils/warn-workspace-allow-scripts.js; the tree walk piggybacks on
the inventory that's already loaded for the unreviewed-scripts summary.

Refs: npm/rfcs#868
@AllanOricil
Copy link
Copy Markdown

AllanOricil commented May 25, 2026

@JamieMagee can you enforce the approval of a script to be required at least once per host? I don't want Dev B to blindly accept Dev's A trust policy when he never touched the codebase, specially because of dynamic scripts that can bypass integrity hashes. For example, given a list of allowScripts, each script listed will require a review and approval on every new host before it can ever be executed. Could map host approval/acknowledgement to package, version and integrity and read/write it from ~/.npm ?

@bakkot
Copy link
Copy Markdown

bakkot commented May 25, 2026

@AllanOricil You seem to want this to be addressing a threat model it's just not for. This is about addressing the case where one of your transitive dependencies is compromised and a malicious build script is added, a situation we have seen several times recently. It is not and should not be about addressing threats from other maintainers of a package you're building.

@AllanOricil
Copy link
Copy Markdown

It is not and should not be about addressing threats from other maintainers of a package you're building.

That's a fair read of what I wrote — my framing wasn't clear. But it's not what I meant. I'm not talking about maintainers of my own package, and I'm not distrusting a specific person.

The concern is that allowScripts turns one developer's review decision about a transitive dependency's script into a committed artifact everyone inherits. Reviews are fallible. A developer can approve a script by mistake — for example, allowlisting one whose install step fetches and runs code from a remote server, without realizing that makes it a live compromise vector. Once that approval is in allowScripts, every colleague who clones and installs runs that script on the strength of a single, possibly-mistaken review — including after the original reviewer has left the org.

So this is inside the compromised-transitive-dependency model you described, not outside it. allowScripts defends against that case by relying on human review to decide which scripts are safe. My point is that human review is fallible, and the proposal propagates one person's review — mistakes included — to the whole org without anyone else looking.

The mitigation: require each host to review each allowlisted script at least once before it runs there (acknowledgment keyed to package + version + integrity, stored in ~/.npm). A single reviewer's mistake then isn't silently inherited — each developer gets a chance to catch it, the way independent review catches errors one reviewer misses. It doesn't make any individual review infallible; it stops one fallible review from becoming an org-wide fact.

If this is better as a follow-up RFC than in scope here, that's a fine answer — I wanted it clear it's a supply-chain concern about inherited, fallible review, not a complaint about any maintainer.

@bakkot
Copy link
Copy Markdown

bakkot commented May 25, 2026

In order for one developer's decision to allowlist a script to affect another, it must be committed and pushed to a shared package.json. Teams which care about security are going to require a different person to review that before it lands. So it will not be relying on a single person unless your team is already in the habit of landing unreviewed changes or approving changes without actually reviewing them, in which case forcing every member of the team to personally approve everything in allowScripts will not save you.

@AllanOricil
Copy link
Copy Markdown

Teams which care about security are going to require a different person to review that before it lands. So it will not be relying on a single person unless your team is already in the habit of landing unreviewed changes or approving changes without actually reviewing them, in which case forcing every member of the team to personally approve everything in allowScripts will not save you.

Fair — PR review does add a second reviewer. On a team that requires it, the allowScripts change gets reviewed before it lands, so I'll drop the "single reviewer" framing.

But there's a population PR review doesn't reach at all: everyone who clones an open-source project and runs npm install. Contributors, people building from source, downstream projects, forks — none of them took part in the maintainer's review. They inherit the maintainer's allowScripts decision without ever seeing it.

That's where this stops being a team-trust question. For a public repo, three things line up:

  • the allowScripts list is committed and public, so an attacker can read exactly which scripts every cloner will run;
  • if one of those scripts is dynamic — fetches and executes remote code — its payload can change server-side, with no version bump, no integrity change, nothing for PR review or the lockfile to catch;
  • everyone who builds the project after that change runs the new payload, on a review they never made.

So a popular project's public allowScripts becomes a targeting list: compromise one listed package's remote payload, hit everyone who builds the project. And the timeline is the whole problem — the review happens at time T, when the script is benign; the payload turns malicious at T+N; everyone who builds between T+N and the day someone finally notices runs the compromised payload, on a review made before the threat existed. PR review is point-in-time: it covers T, not T+N, and it never involved the cloners at all.

To be clear about what I'm not claiming: this isn't allowScripts making things worse than today — today those scripts run on every clone with no review at all. The point is that allowScripts records trust in a package's identity (version + integrity), while the risk lives in behavior the reviewer couldn't see and the cloner never reviewed. Another approval step doesn't change that; surfacing what the script does — e.g. "this fetches and executes remote code" — at the point it would run at least lets someone decline the pattern.

Probably its own follow-up rather than in scope here, but it's why I don't think identity-based approval is sufficient on its own.

@bakkot
Copy link
Copy Markdown

bakkot commented May 25, 2026

All review is point in time. If your threat model includes install scripts changing behavior after review, requiring each person to review makes little difference. The compromise could just as well happen after any given person has already reviewed. And surfacing information like whether a script is fetching remote code - given that many scripts are running arbitrary shell commands anyway - is not feasible without completely redoing how scripts execute.

This conversation really gives me the feeling of arguing with someone who is just copy pasting LLM output. Regardless of whether that's true I don't think it will be productive for me to continue discussing this with you so I'm not going to respond further.

@AllanOricil
Copy link
Copy Markdown

This conversation really gives me the feeling of arguing with someone who is just copy pasting LLM output. Regardless of whether that's true I don't think it will be productive for me to continue discussing this with you so I'm not going to respond further.

Calling my argument LLM output is a convenient ad hominem exit, but the threat model is real. "Stale trust" when developers leave a company is a massive enterprise security risk. But since you've checked out, let's leave it there.

@BrunoBernardino
Copy link
Copy Markdown

BrunoBernardino commented May 26, 2026

@AllanOricil I'm nobody, but I think your request makes sense as a separate RFC, since this is focusing on the described threat model which is a subset of what seems to be yours, and this one would already be an amazing improvement to npm. It definitely feels like "scope creep" to me, even if it's a valid concern and security improvement.

StevenMaude added a commit to opensafely-core/job-server that referenced this pull request May 26, 2026
Following lots of supply chain attacks, `npm` is moving to disabling
setup scripts by default:

npm/rfcs#868

It's perhaps less obvious, but Python packages that use source distributions may
may also run arbitrary scripts at install time, in addition to doing the more
obvious thing of copying package files to the correct location.

It's also maybe less obvious that this feature can be disabled for `pip`:

https://pip.pypa.io/en/stable/topics/secure-installs/

> Disallow source distributions, by passing `--only-binary :all:`

https://pip.pypa.io/en/stable/cli/pip_download/#cmdoption-only-binary

> Do not use source packages. Can be supplied multiple times, and each
time adds to the existing value. Accepts either “:all:” to disable all
source packages, “:none:” to empty the set, or one or more package names
with commas between them. Packages without binary distributions will
fail to install when this option is used on them.

We're using `uv` which has comparable options, albeit these options
with different option names:

* `no-build` sets a default of not allowing packages unless they are
  available as wheels
* `no-binary-package` forbids using binary wheels, and is necessary when
  packages are not available as wheels

See the `uv` documentation:

* https://docs.astral.sh/uv/reference/settings/#no-build
* https://docs.astral.sh/uv/reference/settings/#no-binary-package

The `no-binary-package` setting included here is necessary to get the
application to run.
StevenMaude added a commit to opensafely-core/job-server that referenced this pull request May 26, 2026
Following lots of supply chain attacks, `npm` is moving to disabling
setup scripts by default:

npm/rfcs#868

It's perhaps less obvious, but Python packages that use source distributions may
may also run arbitrary scripts at install time, in addition to doing the more
obvious thing of copying package files to the correct location.

It's also maybe less obvious that this feature can be disabled for `pip`:

https://pip.pypa.io/en/stable/topics/secure-installs/

> Disallow source distributions, by passing `--only-binary :all:`

https://pip.pypa.io/en/stable/cli/pip_download/#cmdoption-only-binary

> Do not use source packages. Can be supplied multiple times, and each
time adds to the existing value. Accepts either “:all:” to disable all
source packages, “:none:” to empty the set, or one or more package names
with commas between them. Packages without binary distributions will
fail to install when this option is used on them.

We're using `uv` which has comparable options, albeit these options
with different option names:

* `no-build` sets a default of not allowing packages unless they are
  available as wheels
* `no-binary-package` forbids using binary wheels, and is necessary when
  packages are not available as wheels

See the `uv` documentation:

* https://docs.astral.sh/uv/reference/settings/#no-build
* https://docs.astral.sh/uv/reference/settings/#no-binary-package

The `no-binary-package` setting included here is necessary to get the
application to run.
StevenMaude added a commit to opensafely-core/job-server that referenced this pull request May 26, 2026
Following lots of supply chain attacks, `npm` is moving to disabling
setup scripts by default:

npm/rfcs#868

It's perhaps less obvious, but Python packages that use source distributions may
may also run arbitrary scripts at install time, in addition to doing the more
obvious thing of copying package files to the correct location.

It's also maybe less obvious that this feature can be disabled for `pip`:

https://pip.pypa.io/en/stable/topics/secure-installs/

> Disallow source distributions, by passing `--only-binary :all:`

https://pip.pypa.io/en/stable/cli/pip_download/#cmdoption-only-binary

> Do not use source packages. Can be supplied multiple times, and each
time adds to the existing value. Accepts either “:all:” to disable all
source packages, “:none:” to empty the set, or one or more package names
with commas between them. Packages without binary distributions will
fail to install when this option is used on them.

We're using `uv` which has comparable options, albeit these options
with different option names:

* `no-build` sets a default of not allowing packages unless they are
  available as wheels
* `no-binary-package` forbids using binary wheels, and is necessary when
  packages are not available as wheels

See the `uv` documentation:

* https://docs.astral.sh/uv/reference/settings/#no-build
* https://docs.astral.sh/uv/reference/settings/#no-binary-package

The `no-binary-package` setting included here is necessary to get the
application to run.
StevenMaude added a commit to opensafely-core/job-server that referenced this pull request May 26, 2026
Following lots of supply chain attacks, `npm` is moving to disabling
setup scripts by default:

npm/rfcs#868

It's perhaps less obvious, but Python packages that use source distributions
may also run arbitrary scripts at install time, in addition to doing the more
obvious thing of copying package files to the correct location.

It's also maybe less obvious that this feature can be disabled for `pip`:

https://pip.pypa.io/en/stable/topics/secure-installs/

> Disallow source distributions, by passing `--only-binary :all:`

https://pip.pypa.io/en/stable/cli/pip_download/#cmdoption-only-binary

> Do not use source packages. Can be supplied multiple times, and each
time adds to the existing value. Accepts either “:all:” to disable all
source packages, “:none:” to empty the set, or one or more package names
with commas between them. Packages without binary distributions will
fail to install when this option is used on them.

We're using `uv` which has comparable options, albeit these options
have different names:

* `no-build` sets a default of not allowing packages unless they are
  available as wheels
* `no-binary-package` forbids using binary wheels, and is necessary when
  packages are not available as wheels

See the `uv` documentation:

* https://docs.astral.sh/uv/reference/settings/#no-build
* https://docs.astral.sh/uv/reference/settings/#no-binary-package

The `no-binary-package` setting included here is necessary to get the
application to run.
@npm npm deleted a comment from AllanOricil May 26, 2026
@owlstronaut
Copy link
Copy Markdown
Contributor

Moderation note: I've removed a comment in this thread for tone. The substantive concerns about stale trust and dynamic-fetch payloads are real and worth pursuing, and the community is welcome to open an RFC for further discussion of that. For this RFC, let's keep discussion focused on the mechanism's herein and civil per the Code of Conduct

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

### The `npm approve-scripts` command
Copy link
Copy Markdown

@manzoorwanijk manzoorwanijk May 26, 2026

Choose a reason for hiding this comment

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

IMHO the syntax here is brought in from pnpm approve-builds, and we should probably use what npm uses already.
So it could be npm scripts approve and npm scripts deny, which matches other npm commands like npm teams <subcommand>, and npm cache <subcommand>.

That way the docs reference stays on a single page, and those are not two different commands.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is a good point, while the inverse commands aren't totally unprecedented here either. I'm landing what we have for phase 1 now. I think we could consider making the approve-scripts and deny-scripts aliases and add the scripts namespace for phase 2. @JamieMagee do you haver a gut reaction?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yeah, I mention it in various places throughout the RFC that pnpm was inspiration for the design of this functionality. I can certainly implement more npm-like commands (npm scripts approve and npm scripts deny) alongside the existing proposals (npm approve-scripts and npm deny-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.

Yes, I also did the same for native dependency patching proposal - #862 and then updated it to use npm style sub command approach.

I think once we ship this feature, it’s probably time to handle #862 😁

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I've had a little time to think on this more, and my worry is that an npm scripts command may be confused with the scripts block in package.json. They do related, but different, things.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

--ignore-scripts is similar too, and I think that is fine.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

But I think instead of npm scripts, we could call it npm trust, with subcommands like

  • npm trust add lefthook
  • npm trust rm lefthook
  • npm trust ls
  • npm trust clear

Other candidates are

  • npm install-scripts
    • npm install-scripts approve lefthook
    • npm install-scripts deny lefthook
    • npm install-scripts ls
  • npm hooks
  • npm lifecycle

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 like npm install-scripts. A tiny bit more verbose, but it's much clearer.

owlstronaut pushed a commit to npm/cli that referenced this pull request May 27, 2026
Implements Phase 1 of
[npm/rfcs#868](npm/rfcs#868), which makes
dependency install scripts opt-in.

**Install behaviour is unchanged.** Scripts still run as they always
have. The only Phase 1 user-visible change is one advisory block at the
end of `npm install` listing packages whose install scripts haven't been
reviewed via the new `allowScripts` field in `package.json`. A future
release will turn that advisory into an actual block.

### What landed

- `allowScripts` field in `package.json`, read at install time
- Three new configs: `allow-scripts`, `strict-script-builds`,
`dangerously-allow-all-scripts`. The latter two are no-ops in this
release. They're registered so projects can pin them in tooling ahead of
the release that flips the default.
- `npm approve-scripts` and `npm deny-scripts` commands, with the RFC's
asymmetric pin rule (approves can pin, denies are always name-only)
- Advisory warning during `npm install`, `ci`, `update`, and `rebuild`.
`npm exec` / `npx` consult only the user/global `.npmrc` layer per the
RFC, with the policy threaded through libnpmexec for Phase 2
enforcement.
- Identity matcher in `@npmcli/arborist` covering registry, git, file,
and remote tarballs. Registry identity is derived from the lockfile's
resolved URL (via `versionFromTgz`), never from `node.packageName` or
`node.version`. Those getters read the installed tarball's
`package.json` and can be forged.
- Aliases match against the underlying registered package, not the alias
name. `trusted@npm:naughty@1.0.0` is approved by writing `naughty`, not
`trusted`. Holds even under `omitLockfileRegistryResolved`, where the
install location alone (`node_modules/trusted`) would be misleading. The
underlying name is derived from the incoming edge's alias `subSpec`.
- Bundled deps with install scripts are flagged as unreviewed and
filtered out of `npm approve-scripts --all` and positional matches. Per
RFC they cannot be allowlisted in Phase 1.
- Warning when a non-root workspace declares its own `allowScripts`

### What's deliberately deferred

- Actual blocking. The matcher exists and the policy is threaded through
to arborist, but `arb.rebuild()`'s build set still runs everything.
Phase 2 will gate `#addToBuildSet` on the matcher.
- A safe allowlist syntax for bundled deps. The RFC notes a candidate
`parent@1.2.3 > bundled-name` form for a follow-up.

Refs: npm/rfcs#868
owlstronaut pushed a commit to npm/cli that referenced this pull request May 27, 2026
Implements Phase 1 of
[npm/rfcs#868](npm/rfcs#868), which makes
dependency install scripts opt-in.

**Install behaviour is unchanged.** Scripts still run as they always
have. The only Phase 1 user-visible change is one advisory block at the
end of `npm install` listing packages whose install scripts haven't been
reviewed via the new `allowScripts` field in `package.json`. A future
release will turn that advisory into an actual block.

- `allowScripts` field in `package.json`, read at install time
- Three new configs: `allow-scripts`, `strict-script-builds`,
`dangerously-allow-all-scripts`. The latter two are no-ops in this
release. They're registered so projects can pin them in tooling ahead of
the release that flips the default.
- `npm approve-scripts` and `npm deny-scripts` commands, with the RFC's
asymmetric pin rule (approves can pin, denies are always name-only)
- Advisory warning during `npm install`, `ci`, `update`, and `rebuild`.
`npm exec` / `npx` consult only the user/global `.npmrc` layer per the
RFC, with the policy threaded through libnpmexec for Phase 2
enforcement.
- Identity matcher in `@npmcli/arborist` covering registry, git, file,
and remote tarballs. Registry identity is derived from the lockfile's
resolved URL (via `versionFromTgz`), never from `node.packageName` or
`node.version`. Those getters read the installed tarball's
`package.json` and can be forged.
- Aliases match against the underlying registered package, not the alias
name. `trusted@npm:naughty@1.0.0` is approved by writing `naughty`, not
`trusted`. Holds even under `omitLockfileRegistryResolved`, where the
install location alone (`node_modules/trusted`) would be misleading. The
underlying name is derived from the incoming edge's alias `subSpec`.
- Bundled deps with install scripts are flagged as unreviewed and
filtered out of `npm approve-scripts --all` and positional matches. Per
RFC they cannot be allowlisted in Phase 1.
- Warning when a non-root workspace declares its own `allowScripts`

- Actual blocking. The matcher exists and the policy is threaded through
to arborist, but `arb.rebuild()`'s build set still runs everything.
Phase 2 will gate `#addToBuildSet` on the matcher.
- A safe allowlist syntax for bundled deps. The RFC notes a candidate
`parent@1.2.3 > bundled-name` form for a follow-up.

Refs: npm/rfcs#868
(cherry picked from commit 7068d42)
@JamieMagee
Copy link
Copy Markdown
Author

Phase 1 has been completed and published 🎉


### 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. Allowlisting them is deferred to a follow-up RFC: matching by `name@version` from the bundled tarball would reintroduce manifest confusion, and a safe parent-qualified syntax needs its own design.
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 was thinking more about bundled dependencies. I know the code base I manage has a few instances of packages that depend on bundlers that have scripts. These instances won't be able to use strict-allow-scripts because the unreviewed (unreviewable) bundled dependency will fail.

What do you think about approvals for the bundler to apply to the bundled dependencies?

"allowScripts": {
  "a-builder@1.0.0": true,
},
"dependencies": {
  "a-builder": "^1.0.0"
}

all scripts in packages bundled by the approved a-builder are allowed to run.

  • When a package@version is approved we're protected by integrity / immutability. a-bundler can't add new dependencies with scripts, so we've approved an explicit set
  • If a whole package name is approved there are more risks, an update could pull in compromised packages,
    you're not only exposed to the approved package being compromised.

I'm still happy with the RFC and deferring this for a followup.


### 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. Allowlisting them is deferred to a follow-up RFC: matching by `name@version` from the bundled tarball would reintroduce manifest confusion, and a safe parent-qualified syntax needs its own design.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bundled dependencies are also the packages that will be included in a packages own tarball when published.

I can see two ways to address those:

  • Do not allow those scripts to be allowlisted, because consumers would not be able to allowlist them. Make that explicit in the errors.
  • Allow the package to approve the scripts, the trust issues outlined here don't apply, these packages were fetched from the registry.

I think the phase-1 implemenation merged into latest doesn't distinguish between packages that were bundled and packages that will be bundled.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.