Skip to content

Agent-mode apply writes through node_modules links into first-party source (npm workspace members, file: deps, npm link targets), overwriting the user's code, and rollback restores upstream bytes instead #626

Description

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

Summary

Agent-mode scan / apply follows a node_modules/<name> symlink into first-party source — an npm workspace member, a file: directory dependency, or an npm link target — whenever that local package's name@version matches a patched registry package. The local index.js doesn't match the patch's beforeHash, so the default mismatch policy overwrites the user's own source with the upstream patched file (only a warning), vex then attests not_affected, and rollback "restores" the upstream original file instead of the user's code. The user's source is gone in every case.

Vendored mode already recognizes this case and refuses (vendor_workspace_member: "is a workspace member of this project; patch the source directly instead of vendoring it"), and hosted mode correctly leaves the link: true lock entry alone (redirect_npm_entry_not_found). Only agent mode writes through the link.

Impact

  • Data loss in committed first-party code: a monorepo that keeps a fork of a dependency as a workspace (same name@version as upstream — a common way to carry a local fix) has the fork's files silently replaced by upstream content on socket-patch scan. rollback doesn't undo it.
  • Writes outside the project: with npm link left-pad, the link target is the developer's checkout of left-pad in another directory, and agent mode rewrites files there. docs/ecosystems.md uses the same reasoning to skip pnpm's global virtual store ("other projects on the machine load the same files, so patching it in place would patch them as well").
  • Misleading VEX: the attestation covers code that isn't the registry package the advisory is about.

Repro (real npm, local mock patch API)

# mock API: a patch for left-pad@1.3.0 that prepends a marker to index.js
mkdir -p ws/packages/left-pad && cd ws && git init -q .
echo '{"name":"left-pad","version":"1.3.0","main":"index.js"}' > packages/left-pad/package.json
echo 'module.exports = "first-party fork";' > packages/left-pad/index.js
echo '{"name":"root","version":"1.0.0","private":true,"workspaces":["packages/*"]}' > package.json
# or: "dependencies":{"left-pad":"file:packages/left-pad"}   (same result)
npm install && git add -A && git commit -qm init
ls -l node_modules/left-pad            # -> ../packages/left-pad (link: true in package-lock.json)

socket-patch scan --mode agent --api-url $MOCK --patch-server-url $MOCK --org o --api-token x
#   Warning: pkg:npm/left-pad@1.3.0 package/index.js did not match the patch's expected original
#   content; applied the full verified patched content instead (pass --strict to fail on mismatches)
#   Summary: 1 of 1 targeted patch applied          (exit 0)
git diff --stat -- packages            # packages/left-pad/index.js | 54 +++++-  (fork replaced by upstream)
socket-patch vex ...                   # exit 0, not_affected
socket-patch rollback ...              # exit 0
head -c 60 packages/left-pad/index.js  # "/* This program is free software…"  — upstream original, not the fork

npm link variant: cd ~/dev/left-pad && npm link; cd proj && npm link left-pad; socket-patch scan --mode agent → ~/dev/left-pad/index.js is rewritten.

Expected vs actual

  • Expected: a node_modules entry that is a link to a workspace member / file: directory / npm link target (the lock marks it "link": true) isn't an installed copy of the registry package, so agent mode skips it with a diagnostic, as vendored mode already does (vendor_workspace_member), and never writes through it. The default mismatch overwrite (crates/socket-patch-cli/CLI_CONTRACT.md, "mismatch-policy note"; apply.rs:45: "What tolerance can do is discard local modifications to the dependency file") is justified for an installed dependency that npm ci can restore. It isn't justified for first-party source that no reinstall brings back. docs/ecosystems.md also refuses to patch a store outside the project in place, which is the same hazard as an npm link target.
  • Actual: agent mode patches the link target (overwriting it on a hash mismatch), reports success, VEX attests it, and rollback writes the upstream original over the user's file.

Matrix (main 045d7ec, Node 22 / 24)

OS npm workspace member file: dir dep npm link
Linux 6.14.18 – (npm 6 has no workspaces) repro –
Linux 8.19.4 repro repro –
Linux 10.9.4 repro (2/2) repro repro
Linux 12.2.0 repro repro –
macOS (macos-latest, arm64) 10.9.7 repro (2/2, second run) repro –
Windows (latest) 8.19.4 / 10.9.7 / 12.2.0 repro (junction) repro –
Ubuntu (Actions) 8.19.4 / 10.9.7 / 12.2.0 repro repro –

--strict blocks it (exit 1, nothing written). Hosted: no rewrite, loud redirect_npm_entry_not_found. Vendored: refused, vendor_workspace_member.

First bad version: not a regression. Released v4.0.0 (apply against the same manifest) overwrites the fork the same way.

Suspect code

  • crates/socket-patch-core/src/crawlers/npm_crawler.rs:1933 acceptable_package_entry: importer trees accept file_type.is_symlink() for any entry (meant for pnpm/vlt store links, see the doc comment at :1772, which also lists "npm link targets"), with no check of where the link resolves to: into a store under node_modules, or into first-party source / outside the project.
  • Vendored's equivalent guard: crates/socket-patch-core/src/vendor/npm_lock.rs:459 (vendor_workspace_member).
  • Combined with the default MismatchPolicy::Warn (crates/socket-patch-core/src/patch/apply.rs:51), which overwrites on a mismatch.

Probe runs: https://github.com/SocketDev/socket-patch/actions/runs/37081541007 (ubuntu / macOS / Windows × npm 8 / 10 / 12; the first macOS jobs lost a startup race with the mock server, so scan exited 1 on a connect timeout) and https://github.com/SocketDev/socket-patch/actions/runs/37082844719 (macOS rerun with the server warmed: reproduces).

Activity

  1. added a commit that references this issue on Oct 3, 2026
  2. mikolalysenko commented on Oct 3, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Claiming this issue (root cause: the npm crawler accepts any node_modules symlink in an importer tree as an installed copy without checking where it resolves, so links to workspace members, file: dirs and npm link targets are patched in place). Branch: agent/fix-npm-agent-first-party-links. Claim-ID: 2026-10-03T01:21:03Z-cdb289


    Generated by Claude Code

  3. mikolalysenko commented on Oct 3, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Draft PR: #634


    Generated by Claude Code

  4. added 7 commits that reference this issue on Oct 3, 2026
    331c478
    f6baa30
    2feeaa5
    929db7e
    3ac1213
    f419eb5
    cf1b6e9
  5. mikolalysenko commented on Oct 3, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] pnpm cells from the scheduled pnpm bug-hunt routine (ledger #303), following the npm routine's hand-over. Main 045d7ec, Linux, Node 22.

    Setup: a first-party left-pad@1.3.0 (module.exports = "first-party fork";), reached either as a workspace member through workspace:* (packages/app/node_modules/left-pad -> ../../left-pad) or as a link:./fork dependency. A local .socket/manifest.json + blobs carries a patch that prepends a marker to upstream left-pad@1.3.0/index.js. Then socket-patch apply --offline --json, vex --offline and rollback --offline.

    pnpm workspace:* member link: dir file: dir
    7.33.7 repro repro pass (pnpm copies it into .pnpm/left-pad@file+fork, and the fork is untouched)
    8.15.9 repro repro pass
    9.15.9 repro (2/2) repro (2/2) pass
    10.28.0 repro – –
    11.28.3 repro repro pass
    12.8.1 repro (2/2) repro (2/2) pass

    In each repro: apply gives success, applied 1 (plus a spurious skipped 1, the duplicate visit from #633), and the committed fork now starts with /*SOCKET_PATCHED*/ followed by the upstream file. vex says not_affected. After rollback the file holds the upstream original (/* This program is free software…), so the user's code is gone.

    PR #634 head cf1b6e9, same fixtures on pnpm 9.15.9 and 12.8.1, workspace:* and link:: partialFailure with apply_failed ("Refusing to patch …/packages/left-pad: node_modules links to it, but it is outside every node_modules tree…"), and the fork is untouched. So the PR covers pnpm too. pnpm's file: shape doesn't need the guard, because it's a store copy.


    Generated by Claude Code

  6. mikolalysenko commented on Oct 4, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Bun cells from the scheduled Bun bug-hunt routine (ledger #306). Main 045d7ec, Linux, real Bun installs, local mock patch API.

    Setup: a first-party plain-pkg@1.0.0 (module.exports = "FIRST-PARTY SOURCE";) in packages/plain-pkg, committed. A sibling member depends on it via workspace:* (Bun lock: "plain-pkg": ["plain-pkg@workspace:packages/plain-pkg"]), or the root depends on it via link:plain-pkg / file:./packages/plain-pkg. The mock serves a patch for registry plain-pkg@1.0.0. Then scan --mode agent --json, vex, rollback --yes.

    Bun layout workspace:* member link: file: dir
    1.4.2 isolated (default for workspaces; member link packages/app2/node_modules/plain-pkg -> ../../plain-pkg) repro (2/2) repro pass (Bun copies it into node_modules, source untouched)
    1.4.2 linker = "hoisted" repro – –
    1.3.9 isolated repro – –
    1.2.23 hoisted repro – –
    1.1.45 hoisted, v0 text lock repro – –

    In each repro, scan --mode agent reports success, applied 2, exit 0. The committed packages/plain-pkg/index.js is replaced by the upstream patched file, and git status shows it modified. vex gives not_affected for pkg:npm/plain-pkg@1.0.0. After rollback the file holds the upstream original (module.exports = 'plain-pkg@1.0.0';), so the first-party source is lost. With --json, the envelope has no content_mismatch_overwritten event (apply.skipped: 0, warnings: null). The warning only appears on stderr in the human output.

    Hosted mode leaves the member alone (redirect_bun_entry_not_found, source untouched). Vendored mode refuses it as vendor_lock_entry_not_found (partial_failure, exit 1, source untouched). That's a less specific code than npm's vendor_workspace_member, but it's safe.

    I haven't checked whether PR #634 covers Bun's layouts (the isolated member link is packages/<member>/node_modules/<name>, not root node_modules). Worth adding a Bun isolated-workspace fixture to its tests.


    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

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions