Skip to content

Never patch a pnpm, yarn or vlt dependency the user patched - #2018

Draft
Mikola Lysenko (mikolalysenko) wants to merge 4 commits into
fix/npm-discovery-false-attestationfrom
fix/user-patched-deps-pnpm-yarn-vlt
Draft

Mikola Lysenko (mikolalysenko) wants to merge 4 commits into
fix/npm-discovery-false-attestationfrom
fix/user-patched-deps-pnpm-yarn-vlt

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #2010 (fix/npm-discovery-false-attestation).

Owner decision: by default, a dependency the user patched themselves is not patched in any mode, for any package manager. Only an explicit user override patches it.

What was already there (and what wasn't)

The CLI's consent gate (patch::user_patches::UserPatchIndex, #1646) already detects pnpm patchedDependencies, yarn berry patch: and patch-package for every npm-family project. Unless --allow-patch-conflicts is passed, it keeps scan (all modes), get, vendor, apply and the hosted/vendored writers from wiring those packages. Two consumers did not use that evidence:

  1. vex discovery attested a pnpm, yarn, vlt (or npm/Bun) pin of a self-patched package. That covers a pin written before the patch was declared, and a pin that an --allow-patch-conflicts run wrote.
  2. The in-memory hosted engine (Node addon / hosted-bundle) has no consent gate in front of it, so it rewired those packages.

One detector, three consumers

UserPatchIndex has a single walk over a ProjectView (disk, snapshot or memory). The CLI consent gate (discover()) runs it on disk and reads every ecosystem. Discovery and the hosted engine (npm_family_in()) run it on any view and read only the npm-family declarations.

Manager What is detected Where
pnpm patchedDependencies keys (name, name@version, name@range) package.json pnpm.patchedDependencies, pnpm-workspace.yaml patchedDependencies, each pnpm lock's top-level patchedDependencies map and (patch_hash=…) key suffixes (root, Rush, members, and a ref's own lock)
yarn berry user patch: descriptors (Yarn's builtin<compat/…> patches are excluded), every layer peeled, aliases resolved package.json dependency maps and resolutions; patch: locators that yarn.lock resolves, including one that wraps the hosted tarball (#962)
yarn classic no patch protocol of its own; patch-package patches/<name>+<version>.patch, or a --patch-dir that a package.json script names
vlt no patch mechanism (see below); patch-package same as yarn classic

The walk reads the project, its declared workspace members, and (on disk) the ancestor workspace settings up to the git repository root.

Repository bound, workspace runs only. In a workspace run the walk never reads outside the git repository root; in memory, the project is its own root. A workspace member or literal --patch-dir beyond that root is not read. Instead it protects every npm package with an "outside the git repository" diagnostic, in the gate too.

  • In a global run (--global / -g / --global-prefix) the walk has no repository bound. It reads members and patch directories wherever they are, and they protect only the packages they patch.
  • The scope is ReadScope { Workspace, Global }, chosen once from the CLI flags via GlobalArgs::read_scope() and passed through UserPatchIndex::discover_scoped. It has the same shape as Never read or rewrite project files outside the git repository root #2025's utils::repo_root::ReadScope (same variants, of_global) and is meant to be replaced by that type when the two branches meet.
  • A global run still inspects no project: since fix(cli)!: require consent before patching user-patched packages #1646, a local checkout does not govern the global tree. So today the Global scope is only reachable through the core API.
  • vex discovery and the in-memory engine only run on workspaces and keep the bound.

The npm-family scope leaves out npm's and Bun's own top-level patchedDependencies and npm's lock-entry patched records. No other package manager reads them, and #711, #367 and #2010 already judge them per resolution.

vlt evidence: vlt@1.6.2 (latest, published 2026-11-03) contains no patchedDependencies, no patch_hash and no patch command. Its vlt.json modifiers rewrite dependency specs, not package contents. A vlt project can therefore only patch through a postinstall tool such as patch-package, and that is what gets read.

Behavior per mode

  • Discovery: a new vex::discover::user_patched pass marks every npm ref the index protects as UnattestedKind::UserPatchedDependency. Like Stop attesting npm-family pins pnpm or a user patch overrides #2010's npm and Bun marks, each mark is an UnwiredCopy with CopyTarget::Ref. It therefore lands only on a ref that survives the lock contests, and a ref already marked keeps its first reason. The ref stays, so rollback, remove and list still find it.
    • How "is it Socket's bytes?" is decided: while a user patch is declared for that name@version, the package manager or its postinstall applies it on top of whatever tarball the lock names. The install therefore never runs the Socket patch alone, and the ref is never attested, with or without consent.
  • Bun and npm keys naming the pinned tarball (a patch made on top of the Socket pin, On Bun 1.4, a bun patch made on a hosted or vendored package is silently dropped by the next superseding re-run, takeover, rollback or revert (the #367 guard only matches name@version keys) #1019) are now marked too.
    • The npm and Bun extractors share one helper, Stop attesting npm-family pins pnpm or a user patch overrides #2010's npm::mark_user_patched (via user_patch_source). It now reads keys against no target, so any key for that name@version counts, and that case gets its own detail.
    • The Stop attesting npm-family pins pnpm or a user patch overrides #2010 test that asserted the old attested behavior now asserts the mark.
    • The hosted rewriter is unchanged. If the key names the URL this run would pin (an in-sync re-run), it keeps the pin and confirms it. If the key names an older URL, it refuses with redirect_bun_patched_dependency_skipped. Either way vex's plan omits the pin through the mark.
  • Hosted: engine::withhold_user_patched removes such candidates from every rewriter and records each as a skipped row with reason user_patch_conflict (the consent gate's code). In memory it also adds a warning.
    • The in-memory engine always applies this.
    • The CLI disk flow applies it unless --allow-patch-conflicts is passed. There it mostly duplicates the consent gate, but it also reads the Rush lock, which the gate does not.
  • Vendored: no change. Every vendored entry point already runs the consent gate:
    • vendor and the vendored writers refuse with user_patch_conflict.
    • scan --mode vendored and apply skip the package.

The override

The override is the existing invocation-only --allow-patch-conflicts. It has no env or policy binding, and no new surface is added.

  • It already let pnpm, yarn and patch-package packages through the consent gate. It now also lifts the engine gate.
  • With it, hosted and vendored mode wire the package, under the user's patch. vex still never attests the package while the patch is declared.
  • npm and Bun native patches stay refused even with consent, as before (CLI_CONTRACT: "Backend safety refusals still apply").

CLI_CONTRACT.md (Existing project patches, Unattested references, the vex_npm_patched_dependency row) and docs/usage.md are updated.

Tests

Verified to fail before:

  • vex::discover::user_patched::tests:
    • pnpm: workspace, package.json pnpm field, lock map, patch_hash key, range and bare keys.
    • yarn berry: package.json patch: wrapping the hosted URL; a yarn.lock patch locator.
    • patch-package: yarn classic, vlt hosted, vlt vendored and npm pins, including --patch-dir.
    • Workspace members, on disk and in memory, agree with the gate; a member beyond the repository root is not read.
    • A contested ref leaves no mark.
  • bun_user_patched_dependency_pin_is_not_attested (key for the pin's own tarball).
  • hosted_memory_engine::user_patched_npm_packages_are_never_pinned.
  • in_process_redirect_pnpm::hosted_pins_a_pnpm_patched_dependency_only_with_explicit_consent:
    • default: skipped;
    • --allow-patch-conflicts: pinned;
    • discovery: the pin stays a ref and is marked unattested.
  • patch::user_patches::tests::npm_family_view_index_agrees_with_the_consent_gate.

Runs:

  • cargo test -p socket-patch-core --lib -- vex::discover:: patch::redirect:: vendor::pnpm vendor::yarn vendor::vlt vendor::npm vendor::bun hosted:: formats::pnpm formats::yarn patch::user_patches: 2523 passed. This includes the new the_repository_bound_holds_for_workspace_runs_only test: in a workspace run, an out-of-repo member or patch dir protects everything; in a global run, it is read and protects only the packages it patches. This includes committed_fixture_corpus_matches_golden, with no golden changes.
  • Core integration targets: hosted_inventory 21, pnpm_hosted 6, vlt_locks 10, upstream_restore_golden 53.
  • CLI: in_process_redirect_pnpm 42, in_process_redirect 152, hosted_memory_engine 50, hosted_memory_parity 43, user_patches 27, vex 48, e2e_vex_redirect 53, and lib vex_sources / user_patches / scan::hosted 63. All pass.
  • cargo clippy --locked --workspace --all-features -- -D warnings and cargo fmt --all -- --check are clean.

Review findings (/code-review high)

All ten findings were confirmed. Each is fixed in 93709da82.

  1. pnpm member manifest selected presence-only: confirmed and fixed. Under named roots (projectRoots: [""]), a pnpm member with its own lock is not a root of its own. Its package.json was therefore selected presence-only, read as unknown metadata, and every npm candidate was withheld. With detected roots the member is a root and gets content, which is why the existing parity test missed it.
    • The rule now: selection asks for content for every file the walk can read.
    • A file the walk reads that the host supplied without content (oversize, LFS) still protects every npm package. A missing file must never silently disable protection.
    • The one safe not-applicable case is a --patch-dir that has no listing in memory. Selection supplies every *.patch, so such a directory holds none.
  2. --patch-dir contents never selected: confirmed and fixed. Selection now requests every *.patch file by name (presence-only is enough, since only the file names are read).
  3. patch-package files and npm/yarn member manifests never fetched: confirmed and fixed. This was failing before: an npm workspace member's patches/left-pad+1.3.0.patch → memory pinned left-pad. Selection now fetches every package.json, pnpm-workspace.yaml, pnpm-lock.yaml and yarn.lock, and every *.patch by name, outside node_modules, .git, .socket, target and .venv. That list is a constant (WALK_SKIP) shared with the walk.
    • Test: hosted_memory_parity::user_patches_are_selected_and_agree_with_the_disk_flow drives select_paths → memory project → engine for 8 cases (pnpm member manifest, npm member patches/, npm member patch: descriptor, root patches/, root --patch-dir, and three no-patch controls). Each case runs with both detected and named roots and is compared with the disk flow.
  4. Out-of-repo go.work member recorded under npm: confirmed and fixed. Every pending directory now carries the ecosystem whose declaration named it. An out-of-repo go.work use member protects Go modules (fail closed for Go), not npm packages. Test: an_out_of_repo_go_member_protects_go_not_npm.
  5. Ancestor markers checked on the lexical parent, read via ..: confirmed and fixed. Ancestors now come from utils::repo_root::search_dirs and are checked and read at the same canonical absolute path. Test: a_symlinked_root_reads_its_real_ancestors.
  6. Every absolute path counted as outside the repo: confirmed and fixed. An absolute path is now normalized lexically and accepted when it lies under the repository root. Test: an_absolute_patch_dir_inside_the_repository_is_read.
  7. The disk hosted flow re-walked the project: confirmed and fixed. The CLI now withholds by the Rush lock alone (UserPatchIndex::from_locks + engine::withhold_user_patched_by). The consent gate has already walked the project.
  8. Root locks parsed twice: confirmed and fixed. npm_family_in skips a passed pnpm-lock.yaml or yarn.lock whose directory the walk already read.
  9. vlt preflight downloaded before the withhold: confirmed and fixed. The CLI withhold now runs before vlt::artifact_preflight. The in-memory preflight is offline and downloads nothing.
  10. Hand-rolled git-root search and lexical_join: confirmed and fixed. They are replaced by find_git_repo / search_dirs (which honor GIT_CEILING_DIRECTORIES and the home rule) and normalize_lexically_keeping_escapes. The minimal ReadScope stays, to be replaced by Never read or rewrite project files outside the git repository root #2025's.

Two caveats on the tests:

  • Findings 1–3 were confirmed by running the new test against the previous commit. Findings 4–6 are confirmed by reading the previous code; each new test asserts behavior that code could not produce.
  • Selecting every nested package.json and lock adds fetches for large monorepos. That is the cost of reading members' declarations at all. The host's max_files limit still applies and fails the run closed.

Not done

  • No core-level vendored gate for pnpm or yarn. The CLI consent gate covers every vendored entry point. A core gate would need the override plumbed into the vendor backends.
  • Hosted still pins a package whose key names the pin's own tarball (see above). Only vex omits it.
  • The in-memory engine sees a member root's files only. It cannot read the workspace settings of an enclosing root that the host splits off.

🤖 Generated with Claude Code

A package the project patches itself is applied by the package manager
on top of whatever tarball the lock names, so a Socket pin of it either
fails the install or installs the user's diff stacked on the Socket
patch. The CLI's consent gate (patch::user_patches::UserPatchIndex, #1646)
already kept `scan` / `get` / `vendor` / `apply` from wiring such pnpm,
yarn berry and patch-package packages unless --allow-patch-conflicts, but
two consumers did not read that evidence:

- vex discovery attested a pnpm, yarn, vlt (or npm / Bun) pin of such a
  package: a pin written before the patch was declared, or one an
  --allow-patch-conflicts run wrote.
- the in-memory hosted engine (Node addon, hosted-bundle) has no gate in
  front of it and rewired them.

One detector, three consumers. UserPatchIndex's pnpm / yarn /
patch-package parse rules are factored into pure helpers (pnpm_yaml,
yarn_manifest, yarn_lock, patch_package_dirs, patch_file), which the
consent gate keeps using, plus UserPatchIndex::npm_family_in, which reads
the same declarations through a ProjectView (disk, snapshot or memory):
package.json's pnpm.patchedDependencies, yarn patch: descriptors and
patch-package dirs, pnpm-workspace.yaml's patchedDependencies, the
patches of the given pnpm locks (top-level map, patch_hash suffixes) and
yarn.lock's user patch: locators. It leaves out npm's and Bun's own
top-level patchedDependencies, which no other package manager reads and
which #711 / #367 / #2010 already judge per resolution.

- Discovery: a new vex::discover::user_patched pass marks every npm
  ref the index protects UnattestedKind::UserPatchedDependency (the ref
  stays, so rollback / remove / list still find it). The bytes the
  install runs are Socket's patch alone only when no user patch is
  declared, so a declared one always withholds the statement, with or
  without consent.
- Hosted: engine::withhold_user_patched drops such candidates from every
  rewriter and records them skipped `user_patch_conflict` (the consent
  gate's code), plus a warning in memory. The in-memory engine always
  applies it; the CLI disk flow applies it unless --allow-patch-conflicts
  (it adds the Rush lock the consent gate does not read).
- Vendored: unchanged. Every vendored entry point already runs the
  consent gate, which refuses (`vendor`, vendored writers) or skips
  (`scan`, `apply`) these packages.

Override: the existing --allow-patch-conflicts, invocation-only. It
already covered pnpm / yarn / patch-package at the consent gate and now
also lifts the engine gate; vex never attests while the patch is
declared. npm / Bun native patches stay refused even with consent, as
before.

vlt has no patch mechanism (vlt@1.6.2 has no patchedDependencies,
patch_hash or `patch` command; vlt.json modifiers rewrite specs), and
neither has yarn classic, so patch-package is what is read for both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two follow-ups under the rule "a user patch applied on top means never
attest".

Bun (and npm) keys naming the pinned tarball itself. #2010 kept attesting
a pin whose `patchedDependencies` key names the pin's own tarball (a `bun
patch` made on top of the Socket pin, #1019), because discovery reused
the hosted rewriter's per-dependency rule, where such a key is no
conflict. Bun applies that patch over the Socket bytes, so the install
never runs the Socket patch alone. The shared npm / Bun helper
(npm::user_patch_source, behind mark_user_patched) now reads the keys
against no target (any key for the name@version counts) and flags the
case where only a key for the ref's own tarball matches, which gets its
own detail; npm and Bun refs alike. bun_user_patched_dependency_pin_is_not_attested pinned the
old attested behavior and now asserts the mark. The hosted rewriter is
unchanged: it keeps such a pin (and confirms it on an in-sync re-run),
while vex's plan omits it through the unattested mark.

Workspace members. The consent gate walked workspace members and
ancestor workspace settings; discovery and the in-memory engine read the
project root only. UserPatchIndex's walk now runs over a ProjectView and
is shared: discover() (the gate) is the walk over the disk with every
ecosystem, npm_family_in() the same walk over any view with the
npm-family declarations (pnpm, yarn, patch-package), so members'
package.json pnpm.patchedDependencies, yarn patch: descriptors, patches/
folders and --patch-dir, and their pnpm/yarn files are read alike. The
member glob walk is rewritten over the view (same depth, skip list,
limits and link handling as the walkdir one), and an ancestor with any
workspace marker is walked in both scopes.

The walk never reads outside the git repository root (the nearest
ancestor holding .git, inclusive; in memory the project itself): the
ancestor walk stops there, as before, and a workspace member or literal
--patch-dir beyond it now protects every npm package with an "outside
the git repository" diagnostic instead of being read, in the gate too.
Without a .git the gate's home-directory rule still bounds the ancestor
walk. workspace_member_patches_agree_with_the_consent_gate checks
discovery and the gate agree on member cases, on disk and in memory, and
on a member beyond the repository root.

Contest survivors only. Like #2010's npm / Bun marks, the pnpm / yarn /
patch-package pass now records each mark as an UnwiredCopy for exactly
that ref (CopyTarget::Ref), so it lands only on a ref that survives the
lock contests and a ref an earlier extractor already marked keeps that
first reason; a_contested_user_patched_ref_leaves_no_mark covers it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner clarification: the repository-root bound is for workspaces; a
global run should be able to read anywhere on the system. The shared
user-patch walk now takes a ReadScope { Workspace, Global }, chosen once
from the CLI mode (GlobalArgs::read_scope, from --global / -g /
--global-prefix), and only a Workspace walk bounds its reads: a
workspace member or --patch-dir beyond the git repository root protects
every npm package instead of being read. A Global walk reads both
wherever they are and protects exactly what they patch. The ancestor
walk itself (stop at .git, never reach the home directory) is the same
in both scopes.

UserPatchIndex::discover stays the workspace walk;
UserPatchIndex::discover_scoped takes the scope, which the CLI's
inspect() now threads through. A global run still inspects no project
(a local checkout does not govern the global tree, #1646), so the
Global arm is reached through the core API today. vex discovery and the
in-memory hosted engine only run on workspaces and keep the bound.

ReadScope has the shape of #2025's utils::repo_root::ReadScope (same
variants and of_global), so it is swapped for that type when the two
branches meet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In-memory selection (the engine's view holds only what select_paths asks
the host for):
- A workspace member's package.json was selected presence-only (a pnpm
  member under named roots) or not at all (npm / yarn members), and
  patch-package files and a --patch-dir's contents never: the engine
  either withheld every npm package over a member it saw without
  content, or pinned a package a member or patches/ file patches.
  Selection now asks, outside node_modules / .git / .socket / target /
  .venv (WALK_SKIP, shared with the walk), for every package.json,
  pnpm-workspace.yaml, pnpm-lock.yaml and yarn.lock with content and
  every *.patch file by name. A file the walk reads that the host
  supplied without content still protects every npm package (never a
  silent miss); a required --patch-dir with no listing in memory holds
  no *.patch (selection supplies them all), so it no longer withholds
  everything. user_patches_are_selected_and_agree_with_the_disk_flow
  drives select_paths -> engine for each case, with detected and named
  roots, against the disk flow.

The walk:
- Ancestors come from utils::repo_root::search_dirs (git's lookup,
  honoring the home rule and GIT_CEILING_DIRECTORIES), checked and read
  at the same canonical absolute path, and the repository root from
  find_git_repo; the hand-rolled search and lexical_join are gone
  (utils::relpath::normalize_lexically_keeping_escapes).
- The bound resolves absolute paths: an absolute --patch-dir or member
  inside the repository is read.
- An out-of-repo directory is recorded under the ecosystem that named
  it: a go.work use member protects Go modules, not npm packages.
- npm_family_in parses a passed lock only when the walk did not already
  read that directory's lock.

Hosted disk flow: the engine gate no longer re-walks the project after
the consent gate; it withholds by the Rush lock alone
(UserPatchIndex::from_locks + engine::withhold_user_patched_by), and
before the vlt artifact preflight, so a withheld artifact is never
downloaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant