Skip to content

feat: Phase 2 of allowScripts opt-in install-script policy#9424

Draft
JamieMagee wants to merge 1 commit into
npm:latestfrom
JamieMagee:jamiemagee/install-scripts-phase-2
Draft

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

Conversation

@JamieMagee
Copy link
Copy Markdown
Contributor

Implements Phase 2 of npm/rfcs#868. Default-deny is on: dependency install scripts are blocked unless allowScripts lists them.

  • Arborist gates preinstall / install / postinstall / prepare queues on the resolved allowScripts policy. Bin linking is not gated.
  • Blocked optional / devOptional deps are silently skipped (verbose log only), same shape as a failed optional install.
  • npm rebuild <pkg> opts the named positionals in for that run (with a log.warn); bare npm rebuild still honours the gate.
  • libnpmexec adds a strict-mode preflight so --strict-allow-scripts fails fast under npm exec / npx.
  • Post-install summary text changed from "not yet covered" to "blocked because they are not covered".
  • strict-allow-scripts config doc updated; "Phase 1" wording removed from comments and docs.

Bypasses: --ignore-scripts (global), --dangerously-allow-all-scripts (per-install), and bypassedAllowScripts for npm rebuild <pkg>.

Refs: npm/rfcs#868, follows #9360.

node.isLink ||
node.isWorkspace ||
bypassed?.has(node) ||
isScriptAllowed(node, this.options.allowScripts) === true
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.

Matcher gap for --install-strategy=linked: script-allowed.js:34 reads node.inBundle, but IsolatedNode (workspaces/arborist/lib/isolated-classes.js) doesn't define it. A bundled child added via IsolatedReifier.#createBundledTree whose resolved parses as a registry URL can be allowlisted by name here — hoisted mode would correctly return null and block.

Likely low real-world risk (needs an attacker package bundling a child with a forged registry-URL resolved, plus the user's allowScripts listing the colliding name, plus --install-strategy=linked), but worth either defining inBundle on IsolatedNode or covering it in workspaces/arborist/test/script-allowed.js.

Comment thread lib/commands/rebuild.js
// it. `#addToBuildSet` checks `bypassedAllowScripts`. Log the
// bypassed names so it shows up in `--loglevel warn`.
const bypassedAllowScripts = new Set(nodes)
arb.options.bypassedAllowScripts = bypassedAllowScripts
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.

Mutating arb.options works for the CLI's one-shot usage, but leaks the bypass set to any subsequent arb.rebuild() if the same Arborist is reused (programmatic callers). Cleaner to pass via arb.rebuild({ nodes, bypassedAllowScripts }) and read from the call site.

// silently skips those scripts; this turns the silent skip into a
// hard failure for CI. Bypassed by `--ignore-scripts` and
// `--dangerously-allow-all-scripts`.
const strictAllowScriptsPreflight = async (arb, opts) => {
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.

Near-verbatim copy of lib/utils/strict-allow-scripts-preflight.js — same skips (isProjectRoot/isWorkspace/isLink), same ESTRICTALLOWSCRIPTS code, just inlined instead of using checkAllowScripts. The two will drift. Move the shared logic into the arborist workspace so both callers consume one impl?

Comment thread lib/utils/reify-output.js
// `npm approve-scripts --allow-scripts-pending` still shows them
// through the unfiltered checkAllowScripts walker.
const visible = unreviewedScripts.filter(
({ node }) => !node.optional && !node.devOptional
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.

Filtering optional/devOptional out of the summary makes "why didn't my optional thing build?" harder to debug — the verbose log doesn't distinguish a policy skip from a network/build failure. Include the reason (unreviewed vs denied) in the verbose line, or keep optional skips at info?

Comment thread lib/commands/rebuild.js
const nodes = tree.inventory.filter(node => this.isNode(specs, node))
const nodes = [...tree.inventory.filter(node => this.isNode(specs, node))]

// Naming a package on `npm rebuild` opts that package's install
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.

Why? Also, why is this change going into phase 2? I thought the idea was that phase 2 was just changing the default.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the feedback. I opened this in draft and didn't expect anyone to review this yet 😅 . I'll be rolling this back before I switch the PR to ready for review (and spend a little more time on the semantics of the optional deps handling too).

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.

3 participants