Skip to content

feat: Phase 1 of allowScripts opt-in install-script policy#9360

Open
JamieMagee wants to merge 7 commits into
npm:latestfrom
JamieMagee:jamiemagee/install-scripts-phase-1
Open

feat: Phase 1 of allowScripts opt-in install-script policy#9360
JamieMagee wants to merge 7 commits into
npm:latestfrom
JamieMagee:jamiemagee/install-scripts-phase-1

Conversation

@JamieMagee
Copy link
Copy Markdown

Implements Phase 1 of 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

…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
…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
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
…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
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
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
More tests for the Phase 1 install-script work, plus a handful of
/* istanbul ignore next */ markers for defensive fallbacks that can't
be hit from realistic inputs (e.g. nodes with neither packageName nor
name set, npa() failing on an already-validated string).

  - reify-output: JSON path with unreviewedScripts and the node.name
    fallback when packageName is absent
  - approve-scripts: single-package wording, no-op when already at the
    right pin, --all with no unreviewed packages
  - deny-scripts: --all empty, global rejection, --json output, no-op
    on already-denied
  - allow-scripts-writer: null node guards, non-hosted git URL, absolute
    path resolved field, deny entries on file/remote deps, alias-typed
    policy keys, unparseable existing entries
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.

1 participant