Skip to content

Agent-mode apply and rollback on a pnpm project with enableGlobalVirtualStore patch (and unpatch) every other project that shares the store #361

Description

[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:

  1. 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.
  2. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions