[agent] Found by the scheduled PDM bug-hunt routine (ledger #312).
Summary
On PDM 2.0 – 2.12 with install.cache = true, the symlink cache method (the default method) installs each top-level package as a directory symlink into PDM's global cache: site-packages/urllib3 -> ~/.cache/pdm/packages/urllib3-1.26.18-py2.py3-none-any/lib/urllib3. Agent mode (scan --mode agent / apply) stages and renames the patched file inside that directory. The directory is the shared cache directory, so the patch lands in PDM's cache and not in the project.
The rename-over write in apply.rs protects a file symlink or hardlink. It does not protect a symlinked parent directory.
Impact
- Every other project on the machine that shares the PDM cache silently gets the patched bytes, on its next
pdm sync and in any environment already linked. Those projects have no .socket/ state, no manifest record and no VEX trail.
rollback in one project un-patches all the others, because they share the same inode tree.
- PDM's cache entry no longer matches the wheel it was unpacked from. PDM does not re-verify the cache, so the corrupted entry persists until
pdm cache clear.
- This contradicts the stated invariant in
crates/socket-patch-core/src/patch/apply.rs:412-418: "a symlink into a store is replaced by a private regular file instead of being written through".
PDM 2.15+ (and 2.x hardlink) link individual files, so the rename breaks the link and the cache stays intact there. The pth method (2.10.4) keeps the files in the cache only; agent apply fails closed with File not found, which is fine.
Repro (Linux, real PDM, mock patch API serving urllib3/response.py = original + marker line)
uv venv -p 3.11 pdmenv && VIRTUAL_ENV=$PWD/pdmenv uv pip install pdm==2.12.4
export HOME=$PWD/home PDM_CHECK_UPDATE=false
PDM=$PWD/pdmenv/bin/pdm
$PDM config install.cache true; $PDM config install.cache_method symlink; $PDM config venv.in_project true
for p in a b; do mkdir $p; printf '[project]\nname="proj-%s"\nversion="0.1.0"\nrequires-python=">=3.8"\ndependencies=["urllib3==1.26.18"]\n[tool.pdm]\ndistribution=false\n' $p > $p/pyproject.toml; done
(cd a && $PDM lock && $PDM sync)
ls -la a/.venv/lib/python3.11/site-packages/ | grep urllib3 # urllib3 -> $HOME/.cache/pdm/packages/.../lib/urllib3
(cd a && socket-patch scan --mode agent --json --yes --api-url http://127.0.0.1:18183 --api-token fake --org test)
grep -c SOCKET-MOCK-MARKER $HOME/.cache/pdm/packages/urllib3-1.26.18-py2.py3-none-any/lib/urllib3/response.py # 1 <- cache patched
(cd b && cp ../a/pdm.lock . && $PDM sync)
grep -c SOCKET-MOCK-MARKER b/.venv/lib/python3.11/site-packages/urllib3/response.py # 1 <- never-scanned project patched
ls b/.socket # does not exist
(The mock serves /patches/batch, /patches/view/<uuid> and /patches/blob/<hash>. No Socket token was used.) A self-contained script (probe.py: a mock API in a thread, two projects and the checks) is embedded in the probe workflow below. Locally it reproduced on 5 of 5 fresh runs. The probe reproduced it on ubuntu-latest, macos-latest and windows-latest.
Expected vs actual
- Expected: agent mode patches only the project's own environment. The docs say shared stores are never written through (pnpm, bun, uv and the Go module cache are all covered by the copy-on-write rename, and vlt's shared store is called out in README). A symlinked package directory should be materialised as a private copy first, or refused with an error.
- Actual: the write follows the directory symlink into
~/.cache/pdm/packages/…. scan exits 0 with applied: 1.
OS × PDM matrix (install.cache=true)
| PDM |
cache_method |
Linux |
macOS |
Windows |
| 2.0.3 |
symlink (dir) |
writes through (local) |
— |
— |
| 2.8.2 |
symlink (dir) |
writes through (local + CI) |
writes through |
writes through |
| 2.10.4 |
symlink (dir) |
writes through (local) |
— |
— |
| 2.11.2 |
symlink (dir) |
writes through (local) |
— |
— |
| 2.12.4 |
symlink (dir) |
writes through (local + CI) |
writes through |
writes through |
| 2.15.4 / 2.20.1 / 2.26.9 / 2.29.2 |
symlink (per-file) |
OK |
OK (2.15.4, 2.29.2) |
OK |
| 2.12.4 – 2.29.2 |
hardlink |
OK |
OK |
OK |
| 2.10.4 |
pth |
fails closed (File not found), OK |
— |
— |
PDM 1.x (feature.install_cache) was not tested.
Suspect code
crates/socket-patch-core/src/patch/apply.rs:496-513: the stage-and-rename is done in filepath.parent(), which resolves through the symlinked site-packages/<pkg> directory.
crates/socket-patch-core/src/utils/fs.rs:457 (atomic_write_bytes)
Neither checks whether any ancestor between the package root (site-packages) and the file is a symlink.
Probe run: https://github.com/SocketDev/socket-patch/actions/runs/36735829162 (tested on main f6b7fb9, latest release v4.0.0).
[agent] Found by the scheduled PDM bug-hunt routine (ledger #312).
Summary
On PDM 2.0 – 2.12 with
install.cache = true, thesymlinkcache method (the default method) installs each top-level package as a directory symlink into PDM's global cache:site-packages/urllib3 -> ~/.cache/pdm/packages/urllib3-1.26.18-py2.py3-none-any/lib/urllib3. Agent mode (scan --mode agent/apply) stages and renames the patched file inside that directory. The directory is the shared cache directory, so the patch lands in PDM's cache and not in the project.The rename-over write in
apply.rsprotects a file symlink or hardlink. It does not protect a symlinked parent directory.Impact
pdm syncand in any environment already linked. Those projects have no.socket/state, no manifest record and no VEX trail.rollbackin one project un-patches all the others, because they share the same inode tree.pdm cache clear.crates/socket-patch-core/src/patch/apply.rs:412-418: "a symlink into a store is replaced by a private regular file instead of being written through".PDM 2.15+ (and 2.x
hardlink) link individual files, so the rename breaks the link and the cache stays intact there. Thepthmethod (2.10.4) keeps the files in the cache only; agent apply fails closed withFile not found, which is fine.Repro (Linux, real PDM, mock patch API serving
urllib3/response.py= original + marker line)(The mock serves
/patches/batch,/patches/view/<uuid>and/patches/blob/<hash>. No Socket token was used.) A self-contained script (probe.py: a mock API in a thread, two projects and the checks) is embedded in the probe workflow below. Locally it reproduced on 5 of 5 fresh runs. The probe reproduced it on ubuntu-latest, macos-latest and windows-latest.Expected vs actual
~/.cache/pdm/packages/….scanexits 0 withapplied: 1.OS × PDM matrix (
install.cache=true)File not found), OKPDM 1.x (
feature.install_cache) was not tested.Suspect code
crates/socket-patch-core/src/patch/apply.rs:496-513: the stage-and-rename is done infilepath.parent(), which resolves through the symlinkedsite-packages/<pkg>directory.crates/socket-patch-core/src/utils/fs.rs:457(atomic_write_bytes)Neither checks whether any ancestor between the package root (
site-packages) and the file is a symlink.Probe run: https://github.com/SocketDev/socket-patch/actions/runs/36735829162 (tested on main
f6b7fb9, latest release v4.0.0).