[BUG] pnpm lifecycle-script spawn rejected with EPERM under macOS sandbox; Node-direct spawn of same shell succeeds
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
Under the Claude Code macOS sandbox, any spawn syscall originating from the pnpm binary itself fails with EPERM. This breaks pnpm run <script>, pnpm test, pnpm <any-lifecycle-script>, and pnpm -r run <task> — and by extension turbo, which shells out to pnpm run <task> per workspace when orchestrating tasks. The denial is specific to pnpm's spawn invocation shape: a Node process spawned by pnpm can turn around and spawn the same shell successfully. So it's not the shell, not the target binary, and not Node's child_process that's blocked — it's the particular posix_spawn shape pnpm uses to launch lifecycle scripts.
What Should Happen?
pnpm run <script>, pnpm test, pnpm check, and any turbo task that fans out via pnpm run should execute successfully in the sandbox — at minimum, they should be no more restricted than the equivalent spawn made from a Node child process of pnpm, which already succeeds. Functionally: pnpm test should run the project's test script, not crash with spawn EPERM.
Error Messages/Logs
Stack trace from pnpm test (representative — every failure mode is identical):
$ turbo run test
node:internal/child_process:441
throw new ErrnoException(err, 'spawn');
^
Error: spawn EPERM
at ChildProcess.spawn (node:internal/child_process:441:11)
at spawn (node:child_process:796:9)
at spawn2 (file:///.../pnpm/11.0.0/dist/pnpm.mjs:52310:15)
at runCmd_ (file:///.../pnpm/11.0.0/dist/pnpm.mjs:75257:16)
at runCmd (file:///.../pnpm/11.0.0/dist/pnpm.mjs:75197:5)
at runPackageLifecycle (file:///.../pnpm/11.0.0/dist/pnpm.mjs:75171:3)
at next2 (file:///.../pnpm/11.0.0/dist/pnpm.mjs:75149:9)
at lifecycle_ (file:///.../pnpm/11.0.0/dist/pnpm.mjs:75155:3)
at file:///.../pnpm/11.0.0/dist/pnpm.mjs:75107:9
at file:///.../pnpm/11.0.0/dist/pnpm.mjs:75166:12 {
errno: -1,
code: 'EPERM',
syscall: 'spawn'
}
No filesystem path is reported in the error — this is a spawn syscall denial, not a read/write permission denial.
Steps to Reproduce
In any pnpm-based project, inside a Claude Code session on macOS:
- Confirm pnpm's lifecycle path fails:
pnpm exec sh -c "echo ok"
- → fails with spawn EPERM (raised through ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL).
- Confirm the same shell, same target, but spawned by Node, succeeds:
pnpm exec node -e "require('node:child_process').spawnSync('/bin/sh', ['-c', 'echo ok'], { stdio: 'inherit' })"
- → prints ok and exits 0.
- Confirm pnpm exec <bin> (direct binary) is unaffected:
pnpm exec vitest --version
- → succeeds.
- Confirm turbo fails for the same reason (turbo orchestrates by shelling out to pnpm run <task>):
./node_modules/.bin/turbo run test
- → fails with the same spawn EPERM from runPackageLifecycle per workspace.
The pair in steps 1–2 is the key reproducer: same parent process, same target binary, same shell command — only the calling code path differs. pnpm's direct spawn is denied; Node's spawn of the same shell is permitted.
Claude Model
Opus
Is this a regression?
I don't know
Last Working Version
_No response_
Claude Code Version
2.1.153 (Claude Code)
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
Terminal.app (macOS)
Additional Information
Diagnosis: the denial is scoped to pnpm's own spawn syscalls, not to the shell or the target binary. Evidence:
┌─────────────────────────────────────────────────────────┬────────────────┐
│ Command │ Result │
├─────────────────────────────────────────────────────────┼────────────────┤
│ ./node_modules/.bin/vitest run (direct binary) │ ✅ works │
├─────────────────────────────────────────────────────────┼────────────────┤
│ pnpm exec vitest run │ ✅ works │
├─────────────────────────────────────────────────────────┼────────────────┤
│ pnpm exec node -e "spawnSync('/bin/sh', …)" │ ✅ works │
├─────────────────────────────────────────────────────────┼────────────────┤
│ pnpm exec sh -c "echo ok" │ ❌ spawn EPERM │
├─────────────────────────────────────────────────────────┼────────────────┤
│ pnpm test / pnpm run <any> / pnpm -r run <task> │ ❌ spawn EPERM │
├─────────────────────────────────────────────────────────┼────────────────┤
│ ./node_modules/.bin/turbo run <task> (via pnpm fan-out) │ ❌ spawn EPERM │
└─────────────────────────────────────────────────────────┴────────────────┘
This rules out:
- Filesystem read-allowlist gaps (no path appears in the error; equivalent spawns from Node succeed without changing the allowlist).
- A blanket block on shell execution (Node spawning /bin/sh -c … works fine).
- Turbo being at fault (pnpm -r run fails identically with no turbo in the chain).
The remaining hypothesis is that pnpm's lifecycle runner uses a posix_spawn invocation shape — likely with shell: true, a non-absolute command path (sh rather than /bin/sh), or a specific posix_spawnattr_t flag set — that the sandbox profile rejects, while Node's child_process.spawn uses a shape the sandbox permits.
Impact: every pnpm-driven workflow is broken using turbo, the entire orchestration layer isblocked, because turbo fans out via pnpm run. Workaround is to bypass pnpm at the entry point and invoke
node_modules/.bin/<bin> directly (e.g., via aeach workspace), but that gives up turbo'scaching and parallelism.
Suggested fix direction: align the sandbox policy so pnpm-originated spawns are treated like Node-originated spawns —
they have equivalent capability in practice (ng via a Node child), so denying the directpath doesn't add security, just breaks tooling. No new schema is needed; this looks like a sandbox-profile rule
alignment, not a missing feature.
Reproducer is package-manager-specific: the sunder npm or yarn may or may not show the samefailure — we have not tested those paths. The reproducer above is specifically pnpm 11.0.0 on Node 24.16.0.
pnpm version: 11.0.0
Node version: v24.16.0
Project context: pnpm monorepo with turbo 2.x orchestrating typecheck/lint/test/build across workspaces. Repo uses Vite, Vitest, Biome, TanStack Router — none of whicthe failure; the spawn dies before any of themare invoked.