Skip to content

fix: write registry allowScripts keys under install-strategy=linked - #9941

Open
manzoorwanijk wants to merge 1 commit into
npm:latestfrom
manzoorwanijk:fix/linked-allow-scripts-store-keys
Open

fix: write registry allowScripts keys under install-strategy=linked#9941
manzoorwanijk wants to merge 1 commit into
npm:latestfrom
manzoorwanijk:fix/linked-allow-scripts-store-keys

Conversation

@manzoorwanijk

Copy link
Copy Markdown
Contributor

Under install-strategy=linked, npm install-scripts approve <pkg> wrote verbose, duplicated file: entries pointing into node_modules/.store (one per incoming symlink depth) instead of name@version pins, and those store-path entries never matched at install time.

There are two root causes.
In findNodesForArgs (allow-scripts-cmd.js), positional args matched every Link pointing at the store package; each Link's relative file:.store/... resolved spec became its own policy key and could even strip the correct pin as stale.
In script-allowed.js, a store package has no edgesIn (they land on its incoming Links), so isRegistryNode refused registry keys, and ls, the post-install advisory, and prune treated a correct name@version entry as matching nothing.

The fix skips Link nodes when matching positional args, mirroring collectUnreviewedScripts and prune, so approvals key off the real package's trusted registry identity.
isRegistryNode and nameFromEdges now delegate edge-based checks to a link target's incoming Links, which also covers omit-lockfile-registry-resolved (approve by name, like the hoisted #9558 path).
resolvedSourceSpecs no longer fabricates file: specs from links into the store, so store packages are never keyed by store paths and prune cleans up the buggy entries while keeping the valid pin.

References

Fixes #9939

@manzoorwanijk
manzoorwanijk force-pushed the fix/linked-allow-scripts-store-keys branch from fc5be25 to 4b40b17 Compare September 1, 2026 09:56
@manzoorwanijk
manzoorwanijk marked this pull request as ready for review September 1, 2026 10:07
@manzoorwanijk
manzoorwanijk requested review from a team as code owners September 1, 2026 10:07
@manzoorwanijk

Copy link
Copy Markdown
Contributor Author

@reggi this probably needs a label for v11 backport.

@nikolawork

Copy link
Copy Markdown

Thanks for the quick fix! However it doesn't add the version number in allowScripts:

// expected:
"allowScripts": {
	"esbuild@0.28.1": true
}

// actual
"allowScripts": {
	"esbuild": true
}

I installed the fix locally using a local copy of the repo with this branch:

➜ npm -v
11.19.0

➜ node npm/bin/npm-cli.js -v
12.0.2

Then, both of these commands gave me the output above:

➜ node npm/bin/npm-cli.js install-script approve esbuild@0.28.1

➜ node npm/bin/npm-cli.js install-script approve esbuild

@manzoorwanijk

Copy link
Copy Markdown
Contributor Author

it doesn't add the version number in allowScripts:

It works perfectly fine

Screen.Recording.2026-09-01.at.3.15.02.PM.mov

npm-dev is an alias that I have created for local clone.

@nikolawork

Copy link
Copy Markdown

However it doesn't add the version number in allowScripts

Let's chalk it up to me not setting up the npm version from this PR properly

@nikolawork

Copy link
Copy Markdown

I see that #9940 has a fix solely for the deduping (same fix as you have in ‎lib/utils/allow-scripts-cmd.js) as well as some tests for that specific use case. Do we test for deduping in the current PR as well?

@manzoorwanijk

Copy link
Copy Markdown
Contributor Author

I see that #9940 has a fix solely for the deduping (same fix as you have in ‎lib/utils/allow-scripts-cmd.js) as well as some tests for that specific use case. Do we test for deduping in the current PR as well?

Yes, it covers many other cases as well.

@martinrrm martinrrm self-assigned this Sep 3, 2026
const found = []
for (const node of arb.actualTree.inventory.values()) {
if (node.isProjectRoot || node.isWorkspace || node.inBundle) {
if (node.isProjectRoot || node.isWorkspace || node.isLink || node.inBundle) {

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.

We should keep Links available for positional matching. For a dependency such as "foo": "file:./local-foo", the Link is named foo, but its target is named local-foo. Skipping the Link makes approve foo  fail with ENOMATCH.

The change should be match first, then dereference and deduplicate. Keep the existing name/version matching and make these replacements:

const found = new Set()
...
const target = node.isLink ? node.target : node
if (!target ||
    target.isProjectRoot ||
    target.isWorkspace ||
    target.inBundle) {
    continue
  }
  found.add(target)
}

Please also update the comment that currently says Links are skipped.

The regression case should use this local package layout:

package.json: dependencies = { "foo": "file:./local-foo" }
local-foo/package.json: includes an install script
node_modules/foo -> ../local-foo

Both npm install-scripts approve foo and deny foo should still find that dependency.

return true
}
// A link target carries no edges of its own; they land on the incoming Links (e.g. the linked strategy's store packages, npm/cli#9939), so delegate the edge-based check to them.
if (node.edgesIn?.size === 0 && node.linksIn?.size > 0) {

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.

An empty edgesIn plus incoming Links is not sufficient to identify a registry store package: ordinary local symlink targets can have the same topology.

Please add the store check to the delegation block in isRegistryNode:

if (
  isStoreBacked(node) &&
  node.edgesIn?.size === 0 &&
  node.linksIn?.size > 0
) {
  return [...node.linksIn].every(link => link.isRegistryDependency)
}

const nameFromEdges = (node) => {
if (!node.edgesIn || typeof node.edgesIn[Symbol.iterator] !== 'function') {
const name = nameFromEdgeSet(node?.edgesIn)
if (name) {

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.

The same restriction needs to apply to nameFromEdges, because the writer calls getTrustedRegistryIdentity directly. Restricting only isRegistryNode would protect matching but still permit incorrect registry keys to be written.

Keep the existing nameFromEdgeSet parser, and change nameFromEdges to:

const nameFromEdges = (node) => {
  const name = nameFromEdgeSet(node.edgesIn)
  if (name || !isStoreBacked(node) || node.edgesIn?.size !== 0) {
    return name
  }

  let linkName = null
  if (node.linksIn && typeof node.linksIn[Symbol.iterator] === 'function') {
    for (const link of node.linksIn) {
      if (!link.isRegistryDependency) {
        return null
      }
      const name = nameFromEdgeSet(link.edgesIn)
      if (!name || (linkName && linkName !== name)) {
        return null
      }
      linkName = name
    }
  }
  return linkName
}

This preserves direct-edge behavior and refuses to infer a name when an incoming Link is non-registry, unidentified, or disagrees with another Link.

Please update the positive unit fixtures to represent actual store targets; for example, set target.isInStore = true. Add the inverse case outside .store: even with an incoming registry Link, both the registry policy match and trusted registry identity should remain null.

}

// True when the node lives in the linked install strategy's `node_modules/.store` directory; such registry-managed packages must never derive identity from the store's internal `file:` link specs.
const isStoreBacked = (node) => {

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.

Please anchor the path fallback to the current tree’s .store directory instead of accepting node_modules/.store anywhere in the path.

This matters because we’re now using this helper to decide whether incoming Links can supply registry identity. When isInStore is absent, a path inside an unrelated project’s .store should not qualify merely because it contains that directory name.

Keep the isInStore fast path, and replace the existing fallback as follows:

- const { sep } = require('node:path')
+ const { resolve, sep } = require('node:path')
- const real = node?.realpath || node?.path
- return typeof real === 'string' && real.includes(`${sep}node_modules${sep}.store${sep}`)
+ // Nodes loaded from the hidden lockfile do not retain isInStore.
+ const paths = [node?.path, node?.realpath].filter(p => typeof p === 'string')
+ const roots = [node?.root?.path, node?.root?.realpath]
+   .filter(p => typeof p === 'string')
+ for (const root of roots) {
+   const store = resolve(root, 'node_modules', '.store')
+   if (paths.some(path => path.startsWith(`${store}${sep}`))) {
+     return true
+   }
+ }
+ return false

Please cover these fallback cases without setting isInStore:

Target path Expected classification
Inside the current root’s .store Store-backed
Inside the root’s resolved physical .store when the root is symlinked Store-backed
Ordinary local directory Not store-backed
Inside another project’s .store, outside both root paths Not store-backed

@twitschvimeo-gif

twitschvimeo-gif commented Sep 4, 2026 via email

Copy link
Copy Markdown

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Verbose and duplicate entries in allowScripts created when install-strategy=linked

4 participants