Repository navigation
Capped hosted scan (--max-new-patches 0) defers an already-pinned npm patch as NEW when a second lock entry of the same version is unpinned, so the vex remedy loops and that copy stays unpatched (regression from #1058) #1195
Description
[agent] Found by the scheduled npm bug-hunt routine (ledger #302).
Summary
Since #1058 (4d06019), the rollout's recorded view is built from HostedPin::all(discovery). npm lockfile discovery drops a pin as contested when another entry of the same name@version still resolves elsewhere: another entry of the same lock (for example an npm alias), or the twin lock of a npm-shrinkwrap.json + package-lock.json pair. So a patch that is already pinned in the project is classified NEW rather than ALREADY. Under --max-new-patches 0, SOCKET_MAX_NEW_PATCHES=0 or socket.yml patches.maxNewPatches: 0, the scan defers it (rollout_deferred), and the unpinned copy is never rewired.
vex then refuses with "another entry of the same lock … still resolves that version elsewhere … re-run socket-patch vendor / scan --mode hosted to rewire every copy". That re-run is exactly what defers it, so in an "upgrades only" CI job the remedy loops forever and npm ci keeps installing the unpatched copy.
Before #1058 (ef48495, its parent), the same re-scan classifies the row ALREADY, rewires the second entry, and npm ci installs both copies patched.
Impact
- A project with a hosted pin adds a second copy of the same version, which is a natural thing to do (
npm install mz@npm:ms@2.1.2resolves the new alias entry to the registry). From then on, a capped CI scan never repairs it, and the human output says1 new patch deferred … Next up: ms@2.1.2for a patch that is already applied. - The same happens with a dual lock where only
package-lock.jsoncarries the pin. npm ≤ 11 readsnpm-shrinkwrap.json, so it installs unpatched. - It fails closed (
vexexit 2, no attestation), but nothing the docs point to can fix it short of removing the cap.
Repro (Linux, npm 10.9.4, local mock patch API)
echo '{"name":"proj","version":"1.0.0","dependencies":{"ms":"2.1.2"}}' > package.json
npm install && git init -q && git add -A && git commit -qm init
socket-patch scan --mode hosted --yes # pins node_modules/ms
git add -A && git commit -qm hosted
npm install mz@npm:ms@2.1.2 # new alias entry -> registry.npmjs.org/ms/-/ms-2.1.2.tgz
socket-patch scan --mode hosted --yes --max-new-patches 0 --json | jq '.rollout.counts, .redirect.skipped'
# {"new":0,"deferred":1,"upgrade":0,"already":0}
# [{"purl":"pkg:npm/ms@2.1.2","uuid":"…","reason":"rollout_deferred","detail":"rank 1 in the rollout queue; a later scan adds it"}]
socket-patch vex -O v.json; echo $? # 2: "…another entry of the same lock, \"node_modules/mz\", still resolves that version elsewhere … re-run … scan --mode hosted…"
rm -rf node_modules && npm ci # node_modules/mz is unpatchedDual-lock variant: cp package-lock.json npm-shrinkwrap.json before the first scan, then restore only the shrinkwrap's pre-scan version (git checkout HEAD~1 -- npm-shrinkwrap.json) and re-scan with --max-new-patches 0. The result is the same: deferred: 1, nothing rewritten.
Expected vs actual
CLI_CONTRACT.md, "Per-run limit on new patches": NEW = "nothing recorded for the base purl in this project", and "When the lockfiles of a project pin a purl to several uuids, the recorded uuid is the selected one if it is among them". The --max-new-patches flag row says "Upgrades and already-applied patches are never capped", and docs/configuration.md says --max-new-patches 0 # upgrade existing patches only and "Updates of already patched packages do not consume the cap". The lock pins pkg:npm/ms@2.1.2 to the selected uuid, so the row should be ALREADY, uncapped, with every copy rewired, as it was before #1058.
Actual: NEW, deferred, nothing rewritten, 0 already applied.
Matrix (main 16106b1)
| OS | npm | Shape | --max-new-patches 0 re-scan |
Pre-#1058 ef48495 |
|---|---|---|---|---|
| Linux | 8.19.4 (lockfileVersion 2) | alias added after pin | deferred (fail) | — |
| Linux | 10.9.4 (×2) | alias added after pin | deferred (fail) | ALREADY, rewired, npm ci patched both |
| Linux | 12.2.0 / Node 24.21 (×2) | alias added after pin | deferred (fail) | — |
| Linux | 10.9.4 | dual lock, shrinkwrap unpinned | deferred (fail) | ALREADY, rewired shrinkwrap |
| Linux | 10.9.4 | socket.yml maxNewPatches: 0 / SOCKET_MAX_NEW_PATCHES=0 |
deferred (fail) | — |
| Linux | 10.9.4 | no cap | pass (rewires) | pass |
| Linux | 10.9.4 | fully pinned project, cap 0 | pass (ALREADY) | pass |
macOS and Windows weren't probed (no probe branches this run); the code path is OS-independent.
First bad commit
4d06019 "Decide whether a hosted patch is pinned through lockfile discovery alone (#1058)". Its parent ef48495 passes. #1058 deleted rollout::mark_pinned / mentioned_uuids, which previously marked these rows ALREADY.
Suspect code
crates/socket-patch-cli/src/commands/scan/mod.rs:1896: the recorded view takesHostedPin::all(ctx.discovery()), i.e. only attributable refs.crates/socket-patch-core/src/vex/discover/npm.rs:158(and the cross-lockcontested_byarm below it):push_uncontesteddrops a ref whose purl has an unwired twin entry. That's right for attestation, but the rollout's "is anything recorded for this purl" question needs the contested pins too. The bundled-copy arm atnpm.rs:143(the npm hosted pin next to a bundled copy can't be unwound: rollback/remove refuse it, and the vendored takeover skips the restore, so vendor --revert lands back on hosted and allow-remote=all stays #828 shape) presumably drops the same way.
Activity
mikolalysenko commentedon Oct 9, 2026
[agent] Claiming this issue (shared root cause: the capped rollout's recorded view is built from attributable hosted pins only, so a pin that discovery drops as contested reads as nothing recorded). Branch: agent/fix-rollout-contested-pins. Claim-ID: 2026-10-09T02:20:53Z-265bec
Generated by Claude Code
mikolalysenko commentedon Oct 9, 2026
[agent] Draft PR: #1211
Generated by Claude Code
Test capped re-scans of contested npm pins
Count shadowed hosted pins as already applied