Backports the TypeScript portion of 51300fd41c (#12872) to release/10.
Two advisories:
- GHSA-c59q-g84q-2gj5: lockfile depPath name -> isolated linker + PnP.
- GHSA-vq4v-j7r6-jq4m: dependency manifest `name` from the fetched
tarball (e.g. @x/../../../path) -> resolver/linker.
Apply safeJoinModulesDir at lockfileToDepGraph (deps/graph-builder) and
the PnP packageLocation (lockfile-to-pnp); validate manifest names in
resolve-dependencies (extendGraph + resolvePeers); contain the
global-virtual-store slot dir by rejecting a traversal version segment
in iterateHashedGraphNodes (calc-dep-state), the single point every GVS
slot path funnels through.
The pacquet and env-installer changes from the original commit do not
apply to v10 (no pacquet stack; installConfigDeps has no GVS subdep
installation and config-dependency names are already validated by
assertValidConfigDepName from #12504).
All rejections surface ERR_PNPM_INVALID_DEPENDENCY_NAME.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Backports three merged security fixes from main to release/10:
- Contain hoisted dependency aliases so a crafted lockfile alias cannot
escape node_modules or overwrite pnpm-owned layout. The hoisted graph
builder now validates each alias at the safeJoinModulesDir sink.
(GHSA-fr4h-3cph-29xv, #12343)
- Contain pnpm patch-remove deletions to the configured patches
directory. (#12341)
- Reject path-traversal config dependency names and versions from
pnpm-workspace.yaml before they are used to build filesystem paths.
(GHSA-qrv3-253h-g69c, #12470)
Written by an agent (Claude Code, claude-opus-4-8).
* fix(security): harden build-approval artifact identities on v10
Port of pnpm/pnpm#12294 (commit bf1b731ee6) to release/10.
Package-name entries in onlyBuiltDependencies (and allowBuilds) no longer
approve lifecycle scripts for artifacts whose identity a name cannot pin:
git, git-hosted tarball, direct tarball, and local directory dependencies.
To approve such an artifact explicitly, use its peer-suffix-free lockfile
depPath as the key — the GIT_DEP_PREPARE_NOT_ALLOWED hint, pnpm
ignored-builds, and pnpm approve-builds print exactly that key.
- AllowBuild policy functions identify packages by DepPath instead of
caller-supplied name/version. Identity trust is derived from the depPath
shape: a registry-style depPath (name@semver) is a trusted identity.
- The trust is sound because lockfile entries are structurally checked
wherever they are materialized into fetchable resolutions
(pkgSnapshotToResolution) and before rebuild runs scripts: a
registry-style key backed by a git, directory, or git-hosted tarball
resolution is rejected with ERR_PNPM_RESOLUTION_SHAPE_MISMATCH.
- preparePackage requires a pkgResolutionId and gates on the synthesized
name@<resolution id> depPath; scp-style git URLs are normalized to
ssh:// form in resolution ids and the git fetcher reuses
createGitHostedPkgId from the resolver.
- isGitHostedTarballUrl matches case-insensitively and is shared from
@pnpm/lockfile.utils; new removePeersSuffix() in @pnpm/dependency-path
and allowBuildKeyFromIgnoredBuild() in @pnpm/builder.policy.
- pnpm rebuild and approve-builds accept depPath specs for selecting and
approving artifact builds; installs rebuild ignored builds approved by
depPath keys.
- shell-quote is overridden to 1.8.4 (GHSA-w7jw-789q-3m8p /
CVE-2026-9277).
Differences from main: v10 has no lockfile resolution verifier, so the
structural pass lives in pkgSnapshotToResolution and the rebuild loop; the
AllowBuild policy keeps v10's boolean return; the revoked-approval
detection and global-virtual-store hash changes have no v10 counterpart.
* test: serve the direct-tarball fixture from an off-registry origin
The registry mock binds only localhost, so a hard-coded 127.0.0.1 URL is
refused in CI, and a tarball URL on the registry's own origin resolves as
a registry package, which defeats the direct-tarball identity the test
needs. An in-test HTTP server redirecting to the mock provides a separate
origin whose binding the test controls.
* test: approve the git-protocol licenses fixture by its depPath
A git-hosted artifact has an untrusted package identity, so the
ajv-keywords build has to be approved by its depPath. Pin the fixture to
the commit the depPath names.
* fix(package-bins): reject reserved manifest bin names
Manifest bin keys "", ".", "..", and scoped forms such as "@scope/.."
passed the bin-name guard because encodeURIComponent leaves them
unchanged. When joined to the global bin directory during global
remove/update/add operations, "." resolves to the bin directory itself
and ".." to its parent, which removeBin then recursively deletes.
Reject empty, ".", and ".." bin names after scope stripping.
Backport of pnpm/pnpm#12289 to v10.
* fix: block untrusted request destination env expansion
Makes environment expansion trust-aware for registry/auth config and
request destinations:
- Stops project and workspace .npmrc files from expanding ${...}
placeholders in registry/proxy request destinations, URL-scoped keys,
and registry credential values.
- Stops repository-controlled pnpm-workspace.yaml from expanding
${...} placeholders in the registry setting.
- Preserves env expansion for trusted user/global/CLI/env config so
existing token and registry setup flows continue to work.
Backport of pnpm/pnpm#12291 (CAND-PNPM-122 / GHSA-3qhv-2rgh-x77r) to v10.
* fix(security): verify npm registry signature before spawning a package-manager binary
The packageManager field (and pnpm self-update) makes pnpm download and
run a specific pnpm version. The staged install's bytes were trusted
based on lockfile integrity alone, which proves nothing when the inputs
are repository-controlled.
pnpm now verifies the npm registry signature of the engine it is about
to spawn, over the installed integrity, against npm's public signing
keys embedded in the pnpm CLI (exactly as corepack does):
- verifyPnpmEngineIdentity() checks pnpm/@pnpm/exe and the materialized
platform binaries of the staged install before it is linked into the
tools directory.
- Fails closed: any verification failure, including an unreachable
registry, refuses the version switch rather than running an unverified
binary. Runs only on a tools-directory cache miss (an actual
download).
- The embedded keys live in a generated file kept in sync with npm's
keys endpoint by scripts/update-npm-signing-keys.mjs; the release
workflow runs the check as a gate so a key rotation cannot silently
break verification.
Backport of pnpm/pnpm#12292 (CAND-PNPM-097) to v10.
* fix: harden package-manager bootstrap metadata
Resolve package-manager bootstrap traffic through trusted user/CLI
registries and trusted network config, defaulting to the public npm
registry instead of project/workspace registry settings:
- getConfig() now computes packageManagerRegistries and
packageManagerNetworkConfig from trusted config sources only (CLI
options, env config, user and global .npmrc) — never the repository's
project/workspace .npmrc or pnpm-workspace.yaml.
- switchCliVersion() applies that bootstrap config when installing and
verifying the wanted pnpm version, so repository .npmrc
proxy/TLS/registry values cannot steer package-manager bootstrap
traffic.
Backport of pnpm/pnpm#12296 to v10. The v11 env-lockfile validation
parts do not apply: v10 bootstraps the wanted version through a staged
child install instead of an env lockfile.
* fix(security): verify Node.js runtime SHASUMS OpenPGP signature
When a repository requests a Node.js runtime (useNodeVersion or an
execution env), pnpm downloads and then executes a Node binary. The
download mirror is repository-configurable via node-mirror:<channel> in
project .npmrc, and the integrity came from SHASUMS256.txt fetched from
that same mirror — a circular check a malicious mirror can satisfy with
a tampered binary and matching hashes.
pnpm now fetches SHASUMS256.txt.sig and verifies its detached OpenPGP
signature against the Node.js release team's public keys, embedded in
the pnpm CLI, before trusting the hashes:
- @pnpm/crypto.shasums-file: new fetchVerifiedNodeShasums /
fetchVerifiedNodeShasumsFile verify the signature via openpgp against
the embedded keys (generated src/nodeReleaseKeys.ts, mirrored from
the canonical nodejs/release-keys list).
- @pnpm/node.fetcher verifies the configurable-mirror SHASUMS for the
release channel; pre-release channels (rc, nightly, ...) are unsigned
by Node and remain unverified.
- scripts/update-node-release-keys.mjs keeps the keys current
(pnpm run check:node-release-keys / update:node-release-keys), and
the release workflow runs the check as a gate.
Backport of pnpm/pnpm#12295 to v10 (without the pacquet Rust port,
which does not exist on this branch).
* test(env): sign the SHASUMS fixture for Node.js download tests
The Node.js download tests exercise the release channel, whose
SHASUMS256.txt is now signature-verified. Sign the fixture with a
generated OpenPGP key and trust it through the new
trustedNodeReleaseKeys test seam (threaded from plugin-commands-env via
@pnpm/node.fetcher to fetchVerifiedNodeShasums), so the tests keep
exercising the verification path instead of bypassing it.
* fix(self-updater): redact registry credentials from engine identity errors
Registry URLs may legally embed basic-auth credentials
(https://user:pass@host/). verifyPnpmEngineIdentity() interpolated the
packument URL and registry URL into PnpmError messages, and the
unreachable-registry path surfaced fetch-layer error messages that embed
the request URL — all of which land in terminal output and CI logs.
Strip URL credentials from every error message and truncate the non-200
response body.
* fix: update vulnerable transitive dependencies
Override shell-quote to >=1.8.4 (GHSA-w7jw-789q-3m8p, critical, pulled
in via concurrently) so the audit workflow passes again. The advisory
was published after the last release/10 audit run; it is unrelated to
the security backports on this branch.
* fix(config): pin unscoped per-registry settings to their source's registry at load time
Each .npmrc / auth.ini / CLI source's unscoped per-registry settings
(_authToken, _auth, username/_password, tokenHelper, inline cert/key)
are rewritten to their URL-scoped equivalent during load, using the
same source's registry= value (or the npmjs default if none). After
this rewrite the merged config contains only URL-scoped settings, so a
later layer overriding registry= (workspace .npmrc, pnpm-workspace.yaml,
CLI --registry) cannot rebind a credential or client certificate to a
different host.
Each rescope emits a deprecation warning naming the source and the URL
the setting was pinned to. ca/cafile are intentionally not rescoped —
they're trust anchors, not credentials, and corporate MITM-proxy setups
rely on them applying globally.
Ported from #11953. Reported by JUNYI LIU.
---
Written by an agent (Claude Code, claude-opus-4-7).
* chore(changeset): bump @pnpm/network.auth-header to major
Removing the unscoped credential fallback in getAuthHeadersFromConfig
is a breaking change to the package's contract — callers passing
unscoped _authToken/_auth/username+_password/tokenHelper alongside a
default registry no longer get a header back. Matches the major bump
in #11953.
---
Written by an agent (Claude Code, claude-opus-4-7).
Backport of #11968 to release/10. Treats tarball-integrity mismatches
against the lockfile as a hard failure by default; `--update-checksums`
is the only opt-in. `--force` and `pnpm update` deliberately do not
bypass the integrity check.
🤖 Written by an agent (Claude Code, claude-opus-4-7).
A transitive registry package can use a dependency-alias key like
`@x/../../../../../.git/hooks` to make `pnpm install` create a symlink
outside the intended `node_modules` directory, since pnpm passes the
alias straight into `path.join(modulesDir, alias)` without checking
that the joined path stays inside `modulesDir`.
Reject aliases that aren't valid npm package names at manifest-read
time (both the importer's manifest and every transitive package
manifest) and re-check at the symlink layer as defense in depth.
Backport of #11954 to the release/10 line.
---
Written by an agent (Claude Code, claude-opus-4-7).
Cherry-pick of #11481 from main, adapted to the v10 layout.
For git-hosted tarballs (codeload.github.com / gitlab.com / bitbucket.org)
the fetcher dropped the integrity it computed while downloading, so the
lockfile only stored the URL. A compromised git host or man-in-the-middle
could serve a substituted tarball on subsequent installs and pnpm would
install it without lockfile changes.
This pins the SHA-512 SRI of the raw tarball in the lockfile in the same
sha512-<base64> form npm-registry tarballs use; subsequent installs verify
the download against that integrity in the worker.
A new optional gitHosted: boolean field is recorded on TarballResolution
so every store-key consumer can route by a single typed read instead of
re-deriving the routing from the URL. Lockfiles written by older pnpm
versions are enriched on load (URL fallback) so the field can be relied
on uniformly.
🤖 Cherry-picked by Claude (claude-opus-4-7) on behalf of @zkochan
* fix(installation): skip pnpm exe when no Node.js is on PATH
When pnpm is installed as @pnpm/exe (a Single Executable Application
that bundles Node.js into the pnpm binary) and the user has no separate
Node.js on PATH, `which('node')` fails and `getNodeExecPath` used to
fall back to `process.execPath` - which in @pnpm/exe is the pnpm binary
itself, not a Node binary. That path got baked into generated global
bin shims via nodeExecPath, so running any globally-installed CLI
invoked pnpm with the target script as its first positional arg,
producing `ERR_PNPM_NO_IMPORTER_MANIFEST_FOUND` from the current
working directory.
Detect the @pnpm/exe case via `detectIfCurrentPkgIsExecutable` and
return undefined from the fallback so the shim emits a plain
`exec node <target>` instead.
Closes#11291
Refs #4645
* Update pkg-manager/plugin-commands-installation/src/nodeExecPath.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Allow skipping the lockfile settings validation during frozen and
preferFrozenLockfile installs. When enabled, pnpm proceeds as if the
lockfile settings are up to date.
When enabled, peer dependency suffixes use version-only identifiers
(name@version) instead of full dep paths, eliminating nested suffixes
like (foo@1.0.0(bar@2.0.0)). Transitive peers are still tracked but
identified by version instead of full dep path.
Backport of #11071 to v10.
Closes#11070
Global Virtual Store (#10939)
Content-verified skip in GVS mode, tolerate EPERM during
bin creation on Windows, handle EPERM in DLX cache symlink.
(cherry picked from commit 62f760ec3d)
Cherry-pick of 2b14c742e from main, adapted for v10:
- Use CJS module type for @pnpm/yaml.document-sync
- Use ramda/src/equals import style (v10 CJS)
- Remove GLOBAL_CONFIG_YAML_FILENAME (v11-only)
- Replace write-yaml-file with yaml + write-file-atomic + patchDocument
Co-Authored-By: Brandon Cheng <gluxon@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Use Object.keys() instead of Map.keys() for Record<string, string> filenames
- Use tempy.directory() instead of temporaryDirectory() (v10 CJS style)
- Remove storeDir from ClientOptions (not in v10 API)
- Add missing WorkspaceState imports and mocks in test
- Fix lint: use commas in type literals, fix require import style
- Fix spellcheck in changeset
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When lockfile-include-tarball-url is explicitly set to false, tarball URLs
are now always excluded from the lockfile. Previously, packages hosted under
non-standard tarball URLs would still have their tarball field written to the
lockfile even when the setting was false, causing flaky and inconsistent
behavior across environments.
The fix makes the option tri-state internally:
- true: always include tarball URLs
- false: never include tarball URLs
- undefined (not set): use the existing heuristic that includes tarball URLs
only for packages with non-standard registry URLs
close#6667
When CI=true, pnpm automatically enables frozen-lockfile mode. Previously,
this could only be overridden via .npmrc files or CLI flags because the
code checked rawLocalConfig (which excludes env vars and hook changes).
Now checks the fully resolved config values (frozenLockfile and
preferFrozenLockfile) instead of rawLocalConfig, allowing:
- Environment variables (pnpm_config_frozen_lockfile=false)
- updateConfig hook in .pnpmfile.cjs
- .npmrc files (already worked)
- CLI flags (already worked)
Fixes#9861
fs.realpathSync uses a JS-only implementation that only resolves symlinks,
not Windows 8.3 short names (e.g., RUNNER~1). Switch to fs.promises.realpath
which uses the native uv_fs_realpath (GetFinalPathNameByHandleW on Windows)
to properly resolve 8.3 short paths to their long form.
On Windows, temporaryDirectory() may return 8.3 short paths (e.g.,
RUNNER~1) but getBinNodePaths resolves via fs.realpath, returning long
paths (e.g., runneradmin). Use realpathSync to normalize expected paths.
Fixed "input line too long" error on Windows when running lifecycle scripts with the
global virtual store enabled. The `NODE_PATH` in command shims no longer includes all
paths from `Module._nodeModulePaths()`. Instead, it includes only the package's
bundled dependencies directory (e.g., `.pnpm/pkg@version/node_modules/pkg/node_modules`),
the package's sibling dependencies directory (e.g., `.pnpm/pkg@version/node_modules`),
and the hoisted `node_modules` directory. These paths are needed so that tools like
`import-local` (used by jest, eslint, etc.) which resolve from CWD can find the correct
dependency versions.
* fix: respect peer dep range in hoistPeers when preferred versions exist
Previously, hoistPeers used semver.maxSatisfying(versions, '*') which
picked the highest preferred version from the lockfile regardless of the
peer dep range. This caused overrides that narrow a peer dep range to be
ignored when a stale version existed in the lockfile.
Now hoistPeers first tries semver.maxSatisfying(versions, range) to find
a preferred version that satisfies the actual peer dep range. If none
satisfies it and autoInstallPeers is enabled, it falls back to the range
itself so pnpm resolves a matching version from the registry.
* fix: only fall back to exact-version range for overrides, handle workspace: protocol
- When no preferred version satisfies the peer dep range, only use the
range directly if it is an exact version (e.g. "4.3.0" from an override).
For semver ranges (e.g. "1", "^2.0.0"), fall back to the old behavior
of picking the highest preferred version for deduplication.
- Guard against workspace: protocol ranges that would cause
semver.maxSatisfying to throw.
- Add unit tests for hoisting deduplication and workspace: ranges.
* fix: only apply range-constrained peer selection for exact versions
The previous approach used semver.maxSatisfying(versions, range) for all
peer dep ranges, which broke aliased-dependency deduplication — e.g. when
three aliases of @pnpm.e2e/peer-c existed at 1.0.0, 1.0.1, and 2.0.0,
range ^1.0.0 would pick 1.0.1 instead of 2.0.0.
Now the range-aware logic only activates when the range is an exact
version (semver.valid), which is the override case (e.g. "4.3.0").
Regular semver ranges fall back to picking the highest preferred version.
This fixes an issue where pnpm fetch would fail in Docker builds when
local directory dependencies (file: protocol) were not available.
The fix adds an ignoreLocalPackages option that is passed from the fetch
command to skip local dependencies during graph building, since pnpm
fetch only downloads packages from the registry and doesn't need local
packages that won't be available in Docker builds.
close#10460