Skip to content

Human scan --mode vendored --prune silently skips the vendored GC when no remaining package has a patch, so an npm uninstalled vendored entry is never reverted (exit 0), while --json reverts it and vendor --check keeps pointing at that same command #1127

Description

[agent] Found by the scheduled npm bug-hunt routine (ledger #302).

Summary

You vendor a patched npm dependency, then remove it with npm uninstall. vendor --check then exits 1 with dependency removed … run socket-patch scan --mode vendored --prune to revert the vendored entry. When none of the project's remaining packages has a patch, running that command in human mode prints No patches available for installed packages., exits 0, and does nothing. The ledger entry and .socket/vendor/npm/<uuid>/ stay, and vendor --check still exits 1. The same command with --json does revert the entry (gc.revertedVendoredEntries: ["pkg:npm/left-pad@1.3.0"]).

Impact

  • The documented cleanup command doesn't work in the most common case: you removed your only patched dependency, or the only one left is unpatched. vendor --check sends you to it, it exits 0, and vendor --check still fails. CI that gates on vendor --check stays red until someone uses --json (by accident) or vendor --revert.
  • Because --prune is set, the vendor_ledger_entry_unwired warning is suppressed (prune_reverts_unwired, scan/mod.rs:1843). The human run gives no hint that anything was left behind.
  • Human and --json output disagree about what the same command writes.

Repro (npm 10.9.4 / Node 22, also npm 12.2.0 / Node 24; local mock of the public patch proxy serving a free patch for left-pad@1.3.0)

mkdir p && cd p
echo '{"name":"p","version":"1.0.0","dependencies":{"left-pad":"1.3.0","ms":"2.1.3"}}' > package.json
npm install
socket-patch scan --mode vendored            # exit 0, left-pad vendored
npm uninstall left-pad                        # ms has no patch
socket-patch vendor --check; echo $?          # "dependency removed … run `socket-patch scan --mode vendored --prune`", 1
socket-patch scan --mode vendored --prune; echo $?
#   Found 1 package (1 npm)
#   No patches available for installed packages.
#   0
ls .socket/vendor/npm                         # 11111111-… still there; state.json still has left-pad
socket-patch vendor --check; echo $?          # still 1
socket-patch scan --mode vendored --prune --json | jq .gc.revertedVendoredEntries
#   ["pkg:npm/left-pad@1.3.0"]   ← JSON reverts it

Control: when a remaining package does have a patch (the mock also serves ms@2.1.3), the human run prints GC: reverted 1 vendored entry and vendor --check goes green. When no packages are left at all, the zero-package path runs gc::run_vendor_only_gc, so that case works too. Only "packages found, none patched" is broken.

Expected vs actual

  • Expected (CLI_CONTRACT.md, vendored mode paragraph): "an entry the lockfile in-use probe … proves unwired … A run without a non-hosted --prune reports it through the run-level vendor_ledger_entry_unwired warning; a --prune run reverts it in its GC and exits 0. That GC runs even when the crawl found no packages". Also the scan --prune paragraph: "(b) EVERY ledger entry whose dependency is no longer in the lockfile graph is reverted".
  • Actual: human mode, packages found but no patches: no GC, no warning, exit 0. --json: GC runs and reverts the entry.

Matrix (Linux)

npm human --prune reverts --json --prune reverts runs
10.9.4 (Node 22.22) no yes ×3 (incl. a workspace member npm uninstall -w a)
12.2.0 (Node 24) no n/a (not re-run) ×1
10.9.4, a patched package remains yes yes ×1 (control)

This is not OS-specific: it comes from scan control flow, not from the filesystem, so I didn't push a probe branch. It's not npm-specific either: any vendored ecosystem whose remaining packages have no patches should hit it. v4.0.0 can't vendor against this mock (a different vendoring flow), so there's no release bisect. main's history is grafted at 23fd62e, so the first bad commit can't be bisected.

Suspect code (main e2d9633)

  • crates/socket-patch-cli/src/commands/scan/mod.rs:2688: if all_packages_with_patches.is_empty() { … return finish_human(0).await; }
  • crates/socket-patch-cli/src/commands/scan/mod.rs:2673: finish_human runs the GC only if prune && !vendor && !hosted, because the vendored arm "runs its own". On this early return the vendored arm never runs, so nothing does.
  • The JSON path runs gc_json (scan/mod.rs:2633) regardless, which is why --json works.
  • scan/mod.rs:1843-1844: prune_reverts_unwired suppresses the vendor_ledger_entry_unwired warning on the assumption that the GC will run.

Backlog review — 2026-10-08

Priority: P1 → P2. The human scan early return skips vendored GC, leaving an obsolete entry and a failing vendor --check. This is a real cleanup/control-flow bug; --json or vendor --revert provides a workaround. The report establishes neither lost recovery data nor a false security attestation, so P2 is appropriate.

Activity

  1. mikolalysenko commented on Oct 8, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Triage: priority:p1 (npm-family vendored). I confirmed this in the code on main e2d9633. In the human path, finish_human (crates/socket-patch-cli/src/commands/scan/mod.rs:2673) runs gc::run_human_gc only when prune && !vendor && !hosted. The early all_packages_with_patches.is_empty() return at :2688 goes through finish_human before the vendored arm, so no GC runs for --mode vendored --prune. The JSON path calls gc_json unconditionally (:2632), which is why --json reverts the entry. I found no open or merged PR for this, and it isn't a duplicate. It also doesn't share a cause with #1063: that one reads a corrupt manifest as empty, which is a different code path.


    Generated by Claude Code

  2. added
    v5-blockerMust resolve before v5: public interface/migration or ordinary patch-install-undo failure.
    uxCLI commands, help, diagnostics, output consistency, or actionable recovery instructions.
    compatibilityPublic CLI/JSON, saved state, upgrades, or package-manager compatibility.
    and removed on Oct 8, 2026
  3. mikolalysenko commented on Oct 9, 2026

    @mikolalysenko
    CollaboratorAuthor

    v5 release blocker (P1). The suggested prune command must perform the same cleanup in human and JSON mode after an ordinary dependency removal.

    This follows the maintainer's release scope: one normally completing CLI instance, prioritizing valid-lockfile patch/install behavior, compatibility, and actionable CLI UX.

  4. mikolalysenko commented on Oct 9, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Pipenv bug-hunt routine (ledger #313): this isn't limited to the npm family. It reproduces the same way for vendored PyPI / Pipenv on main 2e0d17f (Linux, real Pipenv 2023.12.1 and 2026.8.0 on py3.11, local mock patch API serving a patched six 1.16.0 wheel). Reproduced 2/2.

    # Pipfile: [packages] six==1.16.0, [dev-packages] idna==3.7  (or six alone in a named [tests] category beside certifi)
    pipenv lock
    socket-patch scan --mode vendored --yes          # wires six -> ./.socket/vendor/pypi/<uuid>/six-…whl
    git add -A && git commit -qm vendored
    pipenv uninstall six                             # (or: pipenv uninstall six --categories tests)
    socket-patch vendor --check; echo $?             # 1: "dependency removed … run `socket-patch scan --mode vendored --prune`"
    socket-patch scan --mode vendored --yes --prune  # exit 0, "No patches available for installed packages." No GC line
    socket-patch vendor --check; echo $?             # still 1; .socket/vendor/pypi/<uuid>/ and the ledger entry stay
    socket-patch scan --mode vendored --yes --prune --json | jq .gc.revertedVendoredEntries   # ["pkg:pypi/six@1.16.0"]
    socket-patch vendor --check; echo $?             # 0
    Pipenv category human --prune --json --prune
    2023.12.1 [packages] no GC, check stays 1 reverted, check 0
    2023.12.1 named [tests] (other packages left) no GC, check stays 1 reverted, check 0
    2026.8.0 [packages] no GC, check stays 1 not run (same code path)

    The cause is the one in the triage note above. finish_human (now crates/socket-patch-cli/src/commands/scan/mod.rs:2802) skips the GC when vendor is set, and the empty-discovery return at :2825 exits before the vendored flow's own GC. So any ecosystem whose last vendored dependency was removed (while other packages remain, so the crawl isn't empty) takes this path.


    Generated by Claude Code

  5. mikolalysenko commented on Oct 9, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Claiming for v5 blocker burn-down (shared root cause: scan's human early return for 'no patches' runs finish_human without the vendored --prune GC). Branch: agent/v5-human-prune-vendored-gc. Claim-ID: 2026-10-09T16:41:47Z-588531

  6. added 2 commits that reference this issue on Oct 9, 2026
    e418780
    42853e3
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

    agent:claimedagent:triagedbugSomething isn't workingbughuntFound by a scheduled package-manager bug-hunt agentcompatibilityPublic CLI/JSON, saved state, upgrades, or package-manager compatibility.pm:npmnpmpriority:p1uxCLI commands, help, diagnostics, output consistency, or actionable recovery instructions.v5-blockerMust resolve before v5: public interface/migration or ordinary patch-install-undo failure.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions