From 3a8821c22658bb185df1b556a6030d5b45764e6c Mon Sep 17 00:00:00 2001 From: Sanjays2402 <51058514+Sanjays2402@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:23:12 -0700 Subject: [PATCH 1/4] fix(arborist): skip extraneous orphans in strict-allow-scripts gate An extraneous registry node has no incoming dependency edge and is slated for pruning before reify can run any install script. buildIdealTree drops top-level orphans, but it can retain an orphan nested inside a workspace, in which case the strict-allow-scripts preflight false- positives on it (ESTRICTALLOWSCRIPTS) even though the install script will never execute. Skip extraneous nodes in collectUnreviewedScripts so the strict gate and the on-disk npm install-scripts ls listing agree. Fixes #9680 --- workspaces/arborist/lib/unreviewed-scripts.js | 10 ++++++ .../arborist/test/unreviewed-scripts.js | 31 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/workspaces/arborist/lib/unreviewed-scripts.js b/workspaces/arborist/lib/unreviewed-scripts.js index f7bed387845ec..540f34205cfb4 100644 --- a/workspaces/arborist/lib/unreviewed-scripts.js +++ b/workspaces/arborist/lib/unreviewed-scripts.js @@ -57,6 +57,16 @@ const collectUnreviewedScripts = async ({ // must not be flagged (npm/cli#9562). continue } + if (node.extraneous) { + // Extraneous = the node has no incoming dep edge from a real + // dependency, so it is slated for pruning before reify runs any + // install scripts. buildIdealTree drops top-level orphans, but can + // retain an orphan registry node nested inside a workspace's + // node_modules. In every case the script never executes, so the + // strict-allow-scripts gate must not surface it as unreviewed + // (npm/cli#9680). + continue + } const verdict = isScriptAllowed(node, resolvedPolicy) if (verdict === true || verdict === false) { diff --git a/workspaces/arborist/test/unreviewed-scripts.js b/workspaces/arborist/test/unreviewed-scripts.js index 34e4878b4625a..7dfa64eaeb6e3 100644 --- a/workspaces/arborist/test/unreviewed-scripts.js +++ b/workspaces/arborist/test/unreviewed-scripts.js @@ -29,6 +29,7 @@ const node = ({ isLink = false, inBundle = false, inert = false, + extraneous = false, resolved, } = {}) => ({ name, @@ -41,6 +42,7 @@ const node = ({ isLink, inBundle, inert, + extraneous, isRegistryDependency: true, package: { name, version, scripts }, }) @@ -92,6 +94,35 @@ t.test('collectUnreviewedScripts', async t => { t.strictSame(result, []) }) + t.test('skips extraneous (orphan) registry nodes', async t => { + // An extraneous node has no incoming edges and is slated to be pruned + // before reify runs any install scripts, so it must not gate the install + // under strict-allow-scripts even when the policy neither allows nor + // denies it. Buildup walks the ideal tree and can retain a nested orphan + // when nothing in the workspace depends on it (npm/cli#9680). + const result = await collectUnreviewedScripts({ + tree: tree([ + node({ name: 'orphan', scripts: { install: 'x' }, extraneous: true }), + ]), + policy: null, + }) + t.strictSame(result, []) + }) + + t.test('skips extraneous nodes even when policy denies by name', async t => { + // Same orphan, but the user has a name-only deny entry. An extraneous + // registry node typically has no resolved URL the matcher can verify + // against, so the deny would not match and the node would fall through + // to "unreviewed". It still must not gate the install (npm/cli#9680). + const orphan = node({ name: 'esbuild', scripts: { install: 'x' }, extraneous: true }) + orphan.isRegistryDependency = false + const result = await collectUnreviewedScripts({ + tree: tree([orphan]), + policy: { esbuild: false }, + }) + t.strictSame(result, []) + }) + t.test('skips nodes with no install-relevant scripts', async t => { const result = await collectUnreviewedScripts({ tree: tree([node({ scripts: { test: 'jest' } })]), From 735428d4e1bf45784f945ea68002b62ffaa8511b Mon Sep 17 00:00:00 2001 From: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:09:59 -0700 Subject: [PATCH 2/4] fix(arborist): tidy review comments to single-line per feedback Rewrite the extraneous-orphan explanation comments in unreviewed-scripts.js (lib and test) to be more concise and keep each sentence on a single line, addressing review feedback about verbosity and screen-reader-hostile mid-sentence newlines. No logic or test assertion changes. --- workspaces/arborist/lib/unreviewed-scripts.js | 9 ++------- workspaces/arborist/test/unreviewed-scripts.js | 13 ++++--------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/workspaces/arborist/lib/unreviewed-scripts.js b/workspaces/arborist/lib/unreviewed-scripts.js index 540f34205cfb4..1513a7fe347aa 100644 --- a/workspaces/arborist/lib/unreviewed-scripts.js +++ b/workspaces/arborist/lib/unreviewed-scripts.js @@ -58,13 +58,8 @@ const collectUnreviewedScripts = async ({ continue } if (node.extraneous) { - // Extraneous = the node has no incoming dep edge from a real - // dependency, so it is slated for pruning before reify runs any - // install scripts. buildIdealTree drops top-level orphans, but can - // retain an orphan registry node nested inside a workspace's - // node_modules. In every case the script never executes, so the - // strict-allow-scripts gate must not surface it as unreviewed - // (npm/cli#9680). + // Extraneous nodes are orphans pruned before reify runs any install script, so their scripts never execute. + // buildIdealTree drops top-level orphans but can retain one nested in a workspace's node_modules, so this gate must skip them (npm/cli#9680). continue } diff --git a/workspaces/arborist/test/unreviewed-scripts.js b/workspaces/arborist/test/unreviewed-scripts.js index 7dfa64eaeb6e3..db11680ec0688 100644 --- a/workspaces/arborist/test/unreviewed-scripts.js +++ b/workspaces/arborist/test/unreviewed-scripts.js @@ -95,11 +95,7 @@ t.test('collectUnreviewedScripts', async t => { }) t.test('skips extraneous (orphan) registry nodes', async t => { - // An extraneous node has no incoming edges and is slated to be pruned - // before reify runs any install scripts, so it must not gate the install - // under strict-allow-scripts even when the policy neither allows nor - // denies it. Buildup walks the ideal tree and can retain a nested orphan - // when nothing in the workspace depends on it (npm/cli#9680). + // An extraneous orphan is pruned before reify runs any install script, so it must not gate the install even when the policy neither allows nor denies it (npm/cli#9680). const result = await collectUnreviewedScripts({ tree: tree([ node({ name: 'orphan', scripts: { install: 'x' }, extraneous: true }), @@ -110,10 +106,9 @@ t.test('collectUnreviewedScripts', async t => { }) t.test('skips extraneous nodes even when policy denies by name', async t => { - // Same orphan, but the user has a name-only deny entry. An extraneous - // registry node typically has no resolved URL the matcher can verify - // against, so the deny would not match and the node would fall through - // to "unreviewed". It still must not gate the install (npm/cli#9680). + // Same orphan, but with a name-only deny entry. + // An extraneous registry node usually has no resolved URL for the matcher to verify, so the deny misses and it falls through to "unreviewed". + // It still must not gate the install (npm/cli#9680). const orphan = node({ name: 'esbuild', scripts: { install: 'x' }, extraneous: true }) orphan.isRegistryDependency = false const result = await collectUnreviewedScripts({ From 5e1c6da22bb32b7a8628d73973af97a2db165628 Mon Sep 17 00:00:00 2001 From: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:13:11 -0700 Subject: [PATCH 3/4] test: cover extraneous strict allow-scripts nodes --- test/lib/utils/strict-allow-scripts-preflight.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/lib/utils/strict-allow-scripts-preflight.js b/test/lib/utils/strict-allow-scripts-preflight.js index c67a6e4853a12..569d6ccce0fb1 100644 --- a/test/lib/utils/strict-allow-scripts-preflight.js +++ b/test/lib/utils/strict-allow-scripts-preflight.js @@ -9,6 +9,7 @@ const node = ({ name = 'pkg', version = '1.0.0', scripts = { install: 'node-gyp rebuild' }, + extraneous = false, } = {}) => ({ name, resolved: `https://registry.npmjs.org/${name}/-/${name}-${version}.tgz`, @@ -17,6 +18,7 @@ const node = ({ isProjectRoot: false, isWorkspace: false, isLink: false, + extraneous, package: { name, version, scripts }, }) @@ -89,6 +91,20 @@ t.test('passes when the only unreviewed node is inert (platform-incompatible opt t.pass('no error thrown for inert node') }) +t.test('passes when the only unreviewed node is an extraneous orphan', async t => { + // Lockfile-only workspace orphans are pruned before scripts run and must not + // make the command-level strict preflight reject an install (npm/cli#9680). + const orphan = node({ name: 'core-js', extraneous: true }) + orphan.isRegistryDependency = false + const arb = makeArb({ ideal: tree([orphan]) }) + await preflight({ + arb, + npm: { flatOptions: { strictAllowScripts: true } }, + idealTreeOpts: {}, + }) + t.pass('no error thrown for extraneous orphan') +}) + t.test('passes when all install-script nodes are explicitly approved', async t => { const arb = makeArb({ ideal: tree([node({ name: 'canvas' })]), From 6b196bc193fd94969a3d7107bf2ffbb7a608cc8e Mon Sep 17 00:00:00 2001 From: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:09:31 -0700 Subject: [PATCH 4/4] Limit extraneous script exclusions to pruning ideal-tree installs --- lib/utils/check-allow-scripts.js | 5 ++++- lib/utils/strict-allow-scripts-preflight.js | 4 +++- test/lib/commands/install-scripts.js | 13 +++++++++++++ test/lib/utils/strict-allow-scripts-preflight.js | 16 ++++++++++++++++ workspaces/arborist/lib/unreviewed-scripts.js | 7 ++++--- workspaces/arborist/test/unreviewed-scripts.js | 11 ++++++++++- .../lib/strict-allow-scripts-preflight.js | 1 + 7 files changed, 51 insertions(+), 6 deletions(-) diff --git a/lib/utils/check-allow-scripts.js b/lib/utils/check-allow-scripts.js index 47984803cde7b..6b7a9ae81bbe3 100644 --- a/lib/utils/check-allow-scripts.js +++ b/lib/utils/check-allow-scripts.js @@ -15,13 +15,16 @@ const { collectUnreviewedScripts } = require('@npmcli/arborist/lib/unreviewed-sc // `includeWhenIgnored` keeps listing unreviewed packages even when // ignore-scripts is set, so approve/deny can show what you'd move from a // blanket ignore-scripts to an allowlist. Execution callers leave it false. -const checkAllowScripts = async ({ arb, npm, tree, includeWhenIgnored = false }) => +const checkAllowScripts = async ({ + arb, npm, tree, includeWhenIgnored = false, pruneExtraneous = false, +}) => collectUnreviewedScripts({ tree: tree || arb.actualTree, policy: arb.options?.allowScripts || null, ignoreScripts: !!arb.options?.ignoreScripts, dangerouslyAllowAllScripts: !!npm?.flatOptions?.dangerouslyAllowAllScripts, includeWhenIgnored, + pruneExtraneous, }) module.exports = checkAllowScripts diff --git a/lib/utils/strict-allow-scripts-preflight.js b/lib/utils/strict-allow-scripts-preflight.js index 0c500018184c2..240881e9144c5 100644 --- a/lib/utils/strict-allow-scripts-preflight.js +++ b/lib/utils/strict-allow-scripts-preflight.js @@ -33,7 +33,9 @@ const strictAllowScriptsPreflight = async ({ arb, npm, idealTreeOpts }) => { tree = arb.actualTree } - const unreviewed = await checkAllowScripts({ arb, npm, tree }) + const unreviewed = await checkAllowScripts({ + arb, npm, tree, pruneExtraneous: idealTreeOpts !== undefined && arb.options?.prune !== false, + }) if (unreviewed.length === 0) { return } diff --git a/test/lib/commands/install-scripts.js b/test/lib/commands/install-scripts.js index cf7063b595715..d2419e467ebab 100644 --- a/test/lib/commands/install-scripts.js +++ b/test/lib/commands/install-scripts.js @@ -148,6 +148,19 @@ t.test('install-scripts ls with no unreviewed says so', async t => { t.match(joinedOutput(), /No packages with unreviewed install scripts/) }) +t.test('install-scripts ls includes extraneous actual-tree packages', async t => { + const prefixDir = setupProject({ withScripts: ['orphan'] }) + const pkg = JSON.parse(prefixDir['package.json']) + pkg.dependencies = {} + prefixDir['package.json'] = JSON.stringify(pkg) + const lock = JSON.parse(prefixDir['package-lock.json']) + lock.packages[''] = pkg + prefixDir['package-lock.json'] = JSON.stringify(lock) + const { npm, joinedOutput } = await mockNpm(t, { prefixDir }) + await npm.exec('install-scripts', ['ls']) + t.match(joinedOutput(), /orphan@1\.0\.0/) +}) + t.test('install-scripts ls rejects positional args', async t => { const { npm } = await mockNpm(t, { prefixDir: setupProject({ withScripts: ['canvas'] }), diff --git a/test/lib/utils/strict-allow-scripts-preflight.js b/test/lib/utils/strict-allow-scripts-preflight.js index 569d6ccce0fb1..dacd5c9bdc809 100644 --- a/test/lib/utils/strict-allow-scripts-preflight.js +++ b/test/lib/utils/strict-allow-scripts-preflight.js @@ -248,3 +248,19 @@ t.test('global error points at --allow-scripts, not approve-scripts', async t => } ) }) + +t.test('rebuild reviews extraneous actual-tree nodes', async t => { + const arb = makeArb({ actual: tree([node({ name: 'orphan', extraneous: true })]) }) + await t.rejects(preflight({ arb, npm: { flatOptions: { strictAllowScripts: true } } }), { + code: 'ESTRICTALLOWSCRIPTS', + message: /orphan@1\.0\.0/, + }) +}) + +t.test('reify without pruning reviews extraneous nodes', async t => { + const arb = makeArb({ ideal: tree([node({ name: 'orphan', extraneous: true })]) }) + arb.options.prune = false + await t.rejects(preflight({ + arb, npm: { flatOptions: { strictAllowScripts: true } }, idealTreeOpts: {}, + }), { code: 'ESTRICTALLOWSCRIPTS' }) +}) diff --git a/workspaces/arborist/lib/unreviewed-scripts.js b/workspaces/arborist/lib/unreviewed-scripts.js index 1513a7fe347aa..faf106783e30d 100644 --- a/workspaces/arborist/lib/unreviewed-scripts.js +++ b/workspaces/arborist/lib/unreviewed-scripts.js @@ -18,6 +18,7 @@ const collectUnreviewedScripts = async ({ ignoreScripts = false, dangerouslyAllowAllScripts = false, includeWhenIgnored = false, + pruneExtraneous = false, } = {}) => { // With ignore-scripts set, no scripts run, so execution callers bail out // here. approve/deny pass includeWhenIgnored so they keep listing @@ -57,9 +58,9 @@ const collectUnreviewedScripts = async ({ // must not be flagged (npm/cli#9562). continue } - if (node.extraneous) { - // Extraneous nodes are orphans pruned before reify runs any install script, so their scripts never execute. - // buildIdealTree drops top-level orphans but can retain one nested in a workspace's node_modules, so this gate must skip them (npm/cli#9680). + if (pruneExtraneous && node.extraneous) { + // Only a pruning reify removes orphans before scripts run. Actual-tree + // listing and rebuild must still review their scripts (npm/cli#9680). continue } diff --git a/workspaces/arborist/test/unreviewed-scripts.js b/workspaces/arborist/test/unreviewed-scripts.js index db11680ec0688..71e4c9a02eaf2 100644 --- a/workspaces/arborist/test/unreviewed-scripts.js +++ b/workspaces/arborist/test/unreviewed-scripts.js @@ -94,12 +94,13 @@ t.test('collectUnreviewedScripts', async t => { t.strictSame(result, []) }) - t.test('skips extraneous (orphan) registry nodes', async t => { + t.test('skips extraneous (orphan) registry nodes when pruning', async t => { // An extraneous orphan is pruned before reify runs any install script, so it must not gate the install even when the policy neither allows nor denies it (npm/cli#9680). const result = await collectUnreviewedScripts({ tree: tree([ node({ name: 'orphan', scripts: { install: 'x' }, extraneous: true }), ]), + pruneExtraneous: true, policy: null, }) t.strictSame(result, []) @@ -114,10 +115,18 @@ t.test('collectUnreviewedScripts', async t => { const result = await collectUnreviewedScripts({ tree: tree([orphan]), policy: { esbuild: false }, + pruneExtraneous: true, }) t.strictSame(result, []) }) + t.test('reviews extraneous nodes in the actual tree', async t => { + const orphan = node({ name: 'orphan', scripts: { install: 'x' }, extraneous: true }) + t.strictSame(await collectUnreviewedScripts({ tree: tree([orphan]) }), [ + { node: orphan, scripts: { install: 'x' } }, + ]) + }) + t.test('skips nodes with no install-relevant scripts', async t => { const result = await collectUnreviewedScripts({ tree: tree([node({ scripts: { test: 'jest' } })]), diff --git a/workspaces/libnpmexec/lib/strict-allow-scripts-preflight.js b/workspaces/libnpmexec/lib/strict-allow-scripts-preflight.js index 579fd0c0fddf2..ad054fc1504b6 100644 --- a/workspaces/libnpmexec/lib/strict-allow-scripts-preflight.js +++ b/workspaces/libnpmexec/lib/strict-allow-scripts-preflight.js @@ -21,6 +21,7 @@ const strictAllowScriptsPreflight = async (arb, opts) => { const unreviewed = await collectUnreviewedScripts({ tree: arb.idealTree, + pruneExtraneous: opts.prune !== false, policy: opts.allowScripts || null, ignoreScripts: opts.ignoreScripts, dangerouslyAllowAllScripts: opts.dangerouslyAllowAllScripts,