Skip to content

[RFC] Make install scripts opt-in#868

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

[RFC] Make install scripts opt-in#868
JamieMagee wants to merge 1 commit 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 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
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 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
@AllanOricil
Copy link
Copy Markdown

AllanOricil commented May 15, 2026

Someone else can write the RFC for the output I mentioned above. I'm too occupied and won't have time for that until I finish my other project. I think the list of scripts can be available when install is combined with --dry-run (not sure if it exists for install) and --verbose.

Like I said above, I don't believe allow scripts alone will protect users when scripts can be dynamic without changing the integrity hash. A human, or AI, review must be done and for this to happen it would be necessary to have a list of all scripts that would have run so that a manual review can be performed.

@AllanOricil
Copy link
Copy Markdown

AllanOricil commented May 15, 2026

Our friend Claude wrote this script to let us know all packages that would have run during installation. (needs review)

#!/usr/bin/env bash
# install-script-audit.sh
# Lists every script that would have run during install, so each can be audited.
# Run your install with `--ignore-scripts` first, then run this.

set -euo pipefail

ROOT="${1:-.}"
cd "$ROOT"

[ -d node_modules ] || { echo "node_modules not found in $ROOT" >&2; exit 1; }

TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT

# 1. Declared lifecycle scripts in every package.json under node_modules.
find -L node_modules -name package.json -type f 2>/dev/null \
| while IFS= read -r pj; do
    jq -r --arg path "$pj" '
      select(.scripts != null) | . as $r |
      ["preinstall","install","postinstall","prepare"][] as $h |
      select($r.scripts[$h] != null) |
      [$r.name // "?", $r.version // "?", $h, $r.scripts[$h], $path] | @tsv
    ' "$pj" 2>/dev/null || true
  done > "$TMP/declared.tsv"

# 2. Packages with binding.gyp -> implicit `node-gyp rebuild` on install.
find -L node_modules -name binding.gyp -type f 2>/dev/null \
| while IFS= read -r gyp; do
    pj="$(dirname "$gyp")/package.json"
    [ -f "$pj" ] || continue
    jq -r --arg path "$gyp" '
      [.name // "?", .version // "?", "binding.gyp",
       "(implicit: node-gyp rebuild)", $path] | @tsv
    ' "$pj" 2>/dev/null || true
  done > "$TMP/native.tsv"

# 3. Merge, dedupe by name@version + hook, sort.
cat "$TMP/declared.tsv" "$TMP/native.tsv" \
| awk -F'\t' 'NF>=4 && !seen[$1"@"$2"|"$3]++' \
| sort -t$'\t' -k1,1 -k2,2 -k3,3 \
> install-script-audit.tsv

# 4. Cross-check against package-lock.json if present.
if [ -f package-lock.json ]; then
  jq -r '
    .packages // {} | to_entries[] |
    select(.value.hasInstallScript == true) | .key
  ' package-lock.json | sort -u > install-script-audit.lockfile.txt
fi

total=$(wc -l < install-script-audit.tsv | tr -d ' ')
pkgs=$(awk -F'\t' '{print $1"@"$2}' install-script-audit.tsv | sort -u | wc -l | tr -d ' ')
prepares=$(awk -F'\t' '$3=="prepare"' install-script-audit.tsv | wc -l | tr -d ' ')

echo "Wrote install-script-audit.tsv"
echo "  $total entries across $pkgs unique packages"
[ "$prepares" -gt 0 ] && echo "  $prepares 'prepare' entries — only run for git/file deps; skip for registry deps."
[ -f install-script-audit.lockfile.txt ] && {
  echo "  Cross-check: install-script-audit.lockfile.txt"
  echo "  Anything in the lockfile set not in the audit list is suspicious."
}
echo
echo "Columns: name  version  hook  command  path"
echo "View:  column -t -s \$'\\t' install-script-audit.tsv | less -S"

With this script, the process for a human or AI audit would be as follows

npm install --ignore-scripts
chmod +x install-script-audit.sh
./install-script-audit.sh
column -t -s $'\t' install-script-audit.tsv | less -S

With this output people can manually, or using AI, review each entry to determine (open the file and read to determine its whole flow) if it has dangerous executables, like dynamic ones that wouldn't have been caught even if the lock integrity hash hasn't changed.

I do believe NPM should do this analysis for us using LLM consensus before making packages visible. NPM can track false positives until the system becomes reliable. Maybe you guys already have access to Mythos. Cant it be used for this?

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.

5 participants