[agent] Found by the scheduled pnpm bug-hunt routine (ledger #303).
Summary
With pnpm's global virtual store (enableGlobalVirtualStore: true, pnpm 10.12+; in pnpm 10 it is ignored when CI is set), node_modules/<dep> is a symlink (a junction on Windows) into <store>/v10|v11/links/@/<name>/<version>/<hash>/node_modules/<name>. That directory is shared by every project on the machine that resolves the same dependency graph. Agent-mode apply follows the directory symlink and writes the patched file into that shared directory. The file-level copy-on-write rename (apply_file_patch_at) protects the content-addressable files/ store, but it doesn't protect the shared links/ directory. As a result:
- Another project is silently patched. Project C, which never ran socket-patch and has no
.socket/, is patched as soon as project B runs apply.
- Rollback in one project silently unpatches the others. Project A's
apply reports skipped (it's already patched through B). A's rollback then reports success and restores the original bytes into the shared directory. Project B still has the patch in its manifest, but its installed files are back to the vulnerable original. B's next vex omits the patch (not_applied), and nothing in B changed that anyone would notice. remove in A behaves the same way.
The CLI's own apply docs (crates/socket-patch-core/src/patch/apply.rs:414) state the intent: shared pnpm inodes are isolated so that a patch doesn't propagate outside the project.
Repro
# three projects, one store, global virtual store on
for p in a b c; do
mkdir $p && echo '{"name":"'$p'","version":"0.0.0","private":true,"dependencies":{"left-pad":"1.3.0"}}' > $p/package.json
printf 'enableGlobalVirtualStore: true\n' > $p/pnpm-workspace.yaml
(cd $p && pnpm install --store-dir ../store)
done
readlink a/node_modules/left-pad # ../../store/v11/links/@/left-pad/1.3.0/<hash>/node_modules/left-pad (same target for a, b, c)
# hand-stage .socket/manifest.json + before/after blobs for pkg:npm/[email protected] in a and b
(cd b && socket-patch apply --offline --json) # action: applied
head -c 18 c/node_modules/left-pad/index.js # /*SOCKET_PATCHED*/ <- c never ran socket-patch
(cd a && socket-patch apply --offline --json) # action: skipped
(cd a && socket-patch rollback --json) # status: success
head -c 18 b/node_modules/left-pad/index.js # "/* This program is" <- b unpatched; b/.socket/manifest.json still lists the patch
(cd b && socket-patch vex --offline --output v.json)
# Warning: omitting pkg:npm/[email protected] from VEX: the patched files still hold the original content (not_applied)
The full script (gvs_repro.sh) is in the probe workflow linked below.
Expected vs actual
- Expected: agent mode edits only the project it runs in. Shared store inodes are replaced by private files (apply.rs:414–418, "a symlink into a store is replaced by a private regular file instead of being written through"), and
rollback only touches what this project applied. Failing that, a warning or refusal should say the target is a store-wide directory.
- Actual: both writes land in
<store>/links/…, which pnpm links into every project with the same graph.
Matrix (current main f6b7fb9)
| OS |
pnpm 10.12.1 |
pnpm 10.34.5 |
pnpm 11.27.0 |
pnpm 12.8.1 |
| Linux (sandbox) |
fail |
fail |
fail |
fail |
| Linux (GH runner) |
untested |
not reproduced (pnpm 10 ignores the global virtual store under CI) |
fail |
fail |
| macOS (GH runner) |
untested |
not reproduced (same) |
fail |
fail |
| Windows (GH runner, junctions) |
untested |
not reproduced (same) |
fail |
fail |
Releases 3.3.0 and 4.0.0 behave identically, so this has been there since agent mode first supported pnpm.
Suspect code
crates/socket-patch-core/src/patch/apply.rs:429 (apply_file_patch_at): the atomic rename replaces the file entry inside whatever directory pkg_path resolves to. When pkg_path is a directory symlink into <store>/links, that directory is shared.
crates/socket-patch-core/src/crawlers/npm_crawler.rs accepts symlinked direct-dependency entries in importer trees (:1324, :1446), but doesn't check whether the link target is inside pnpm's global virtual store. node_modules/.modules.yaml records virtualStoreDir: <store>/v10/links.
Probe run (Linux, macOS and Windows): https://github.com/SocketDev/socket-patch/actions/runs/36759597534
[agent] Found by the scheduled pnpm bug-hunt routine (ledger #303).
Summary
With pnpm's global virtual store (
enableGlobalVirtualStore: true, pnpm 10.12+; in pnpm 10 it is ignored whenCIis set),node_modules/<dep>is a symlink (a junction on Windows) into<store>/v10|v11/links/@/<name>/<version>/<hash>/node_modules/<name>. That directory is shared by every project on the machine that resolves the same dependency graph. Agent-modeapplyfollows the directory symlink and writes the patched file into that shared directory. The file-level copy-on-write rename (apply_file_patch_at) protects the content-addressablefiles/store, but it doesn't protect the sharedlinks/directory. As a result:.socket/, is patched as soon as project B runsapply.applyreportsskipped(it's already patched through B). A'srollbackthen reportssuccessand restores the original bytes into the shared directory. Project B still has the patch in its manifest, but its installed files are back to the vulnerable original. B's nextvexomits the patch (not_applied), and nothing in B changed that anyone would notice.removein A behaves the same way.The CLI's own apply docs (
crates/socket-patch-core/src/patch/apply.rs:414) state the intent: shared pnpm inodes are isolated so that a patch doesn't propagate outside the project.Repro
The full script (
gvs_repro.sh) is in the probe workflow linked below.Expected vs actual
rollbackonly touches what this project applied. Failing that, a warning or refusal should say the target is a store-wide directory.<store>/links/…, which pnpm links into every project with the same graph.Matrix (current main f6b7fb9)
CI)Releases 3.3.0 and 4.0.0 behave identically, so this has been there since agent mode first supported pnpm.
Suspect code
crates/socket-patch-core/src/patch/apply.rs:429(apply_file_patch_at): the atomic rename replaces the file entry inside whatever directorypkg_pathresolves to. Whenpkg_pathis a directory symlink into<store>/links, that directory is shared.crates/socket-patch-core/src/crawlers/npm_crawler.rsaccepts symlinked direct-dependency entries in importer trees (:1324,:1446), but doesn't check whether the link target is inside pnpm's global virtual store.node_modules/.modules.yamlrecordsvirtualStoreDir: <store>/v10/links.Probe run (Linux, macOS and Windows): https://github.com/SocketDev/socket-patch/actions/runs/36759597534