Repository navigation
vendor and vendor --check run from an npm workspace member directory exit 0 without looking at the root lock: the eject says "nothing to vendor" and the check passes over a tampered artifact #1921
Description
[agent] Found by the scheduled npm bug-hunt routine (ledger #302).
Summary
In an npm workspace, the root package-lock.json installs every member, so a member directory (packages/a) has no lock or .socket/ of its own. When socket-patch vendor or socket-patch vendor --check runs from that member directory (or with --cwd packages/a), it looks only at the member directory, finds nothing, and exits 0:
vendor(the hosted → vendored eject) printsNo manifest found, nothing to vendor.(--json:"status": "noManifest"), exit 0, while the root lock holds hosted pins. Nothing is ejected.vendor --checkprints nothing (--json:"status": "success","verified": 0), exit 0, even when the root's committed.socket/vendor/npm/<uuid>/left-pad-1.3.0.tgzis tampered. The same check from the root exits 1 withvendor_sha256_mismatch.
Hosted scan from the same directory refuses with redirect_workspace_lockfile_elsewhere and names the workspace root. Vendored scan / get refuse too (#1904, being fixed in #1913). The eject and the check have no such guard.
Impact
vendor --check is the read-only audit meant for CI. A CI step or pre-commit hook that runs it from a member package directory (common in monorepos, where per-package jobs cd into the package) always passes, whatever state the root's vendored artifacts and wiring are in. The eject, run the same way, reports a calm success and leaves the project on hosted patches when the user asked to vendor them. Neither run writes anything, but both report success for work they never did.
Repro (Linux, Node 22, npm 8.19.4 / 10.9.9 / 12.2.1)
mkdir -p ws/packages/a && cd ws
echo '{"name":"root","version":"1.0.0","private":true,"workspaces":["packages/*"]}' > package.json
echo '{"name":"a","version":"1.0.0","dependencies":{"left-pad":"1.3.0"}}' > packages/a/package.json
npm install
socket-patch scan --mode hosted --yes # root lock now pins the hosted left-pad
(cd packages/a && socket-patch vendor --yes; echo "exit=$?")
# No manifest found, nothing to vendor.
# exit=0 <-- root lock still hosted
socket-patch vendor --yes # from the root: "Vendored 1 package."
printf x >> .socket/vendor/npm/*/left-pad-1.3.0.tgz # tamper the committed artifact
socket-patch vendor --check; echo "exit=$?" # root: vendor_sha256_mismatch, exit=1
(cd packages/a && socket-patch vendor --check; echo "exit=$?")
# (no output)
# exit=0 <-- same project, same tamperleft-pad@1.3.0 stands for any package with an available patch. I used a local mock of the patch API (--api-url, --org, --patch-server-url all pointing at it) serving a free patch for pkg:npm/left-pad@1.3.0. The member-dir behaviour happens before any patch-specific work. socket-patch vendor --check --cwd packages/a from the root behaves the same as running it from the member.
Expected vs actual
- Expected: docs/ecosystems.md says a hosted or vendored run from a workspace member "is refused (
redirect_workspace_lockfile_elsewhere,vendor_lockfile_missing) and names the root to run from" (vlt notes; hostedscandoes the same for npm).vendorandvendor --checkfrom a member should refuse the same way, exit non-zero, and name the workspace root. At the least,vendor --checkmust not report success when the governing root's ledger fails the check. - Actual: both exit 0.
vendorsays "nothing to vendor";vendor --checkverifies zero entries and says nothing.
vex from the member also misses the root's pins, but it fails closed (exit 1, "no hosted or vendored patch references were found … nothing to attest") with no pointer to the root. That's a diagnostics gap only.
OS × version
| OS | npm | lockfileVersion | main cbbbdb9 |
v5.0.1 | PR #1913 head |
|---|---|---|---|---|---|
| Linux | 8.19.4 | 2 | reproduces | — | — |
| Linux | 10.9.9 | 3 | reproduces | reproduces | reproduces |
| Linux | 12.2.1 | 3 | reproduces | — | — |
macOS and Windows weren't probed (probe branches are blocked for this routine). The code path doesn't depend on the OS. This isn't a regression: v5.0.1 behaves the same.
Suspect code
crates/socket-patch-cli/src/commands/vendor.rs:1015: the eject reads hosted pins withhosted_inventory(&args.common, &args.common.cwd), cwd only. With no pins and no manifest it falls through to thenoManifestexit-0 arm (vendor.rs:1054-1075). It never callshosted::governing_root::refusal, which hostedscanuses (commands/scan/hosted.rs:991).crates/socket-patch-cli/src/commands/vendor.rs:1253(run_check): loads the ledger fromargs.common.project_root(). An absent ledger is an empty one, so the check passes with nothing verified, and nothing asks whether a workspace root governs this directory.- PR Fix vendored member refusal not naming root (#1904) #1913 adds the root-naming text only to the vendored lock-flavor detection (
vendor/npm_flavor.rs), which neither path reaches. Yarn and Bun members probably behave the same, since the guard is missing before any per-manager code runs. I only tested npm.
Activity
mikolalysenko commentedon Nov 1, 2026
[agent] Triage: priority:p1 (npm). Related to #1904 (vendored scan/get member-dir refusal, being fixed in #1913), but a separate code path:
commands/vendor.rsreturns the calmnoManifestexit 0 (L2379) before any governing-root lookup, andvendor --checkverifies the member dir's empty ledger. The fix should reuse the governing-root resolution #1913 extends inhosted/governing_root.rs, so it is best taken after #1913 lands. Not a duplicate.Generated by Claude Code
mikolalysenko commentedon Nov 1, 2026
[agent] Claiming this issue (shared root cause:
vendor,vendor --revertandvendor --checknever ask whether an ancestor workspace root governs --cwd). Branch: agent/fix-vendor-member-governing-root. Claim-ID: 2026-11-01T10:21:05Z-af9379Generated by Claude Code
mikolalysenko commentedon Nov 1, 2026
[agent] Fix in progress: #1928
Generated by Claude Code
Expose the npm-family workspace root lookup
Refuse vendor runs from a workspace member
Narrow the vendor member refusal