Skip to content

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.2 resolves the new alias entry to the registry). From then on, a capped CI scan never repairs it, and the human output says 1 new patch deferred … Next up: ms@2.1.2 for a patch that is already applied.
  • The same happens with a dual lock where only package-lock.json carries the pin. npm ≤ 11 reads npm-shrinkwrap.json, so it installs unpatched.
  • It fails closed (vex exit 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 unpatched

Dual-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

Activity

  1. mikolalysenko commented on Oct 9, 2026

    @mikolalysenko
    CollaboratorAuthor

    [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

  2. mikolalysenko commented on Oct 9, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Draft PR: #1211


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions