Repository navigation
Never patch a pnpm, yarn or vlt dependency the user patched - #2018
Draft
Mikola Lysenko (mikolalysenko) wants to merge 4 commits into
Draft
Mikola Lysenko (mikolalysenko) wants to merge 4 commits into
Mikola Lysenko (mikolalysenko) wants to merge 4 commits into
Conversation
Mikola Lysenko (mikolalysenko)
force-pushed
the
fix/user-patched-deps-pnpm-yarn-vlt
branch
from
November 5, 2026 19:32
bbd39aa to
9677ca9
Compare
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>
Mikola Lysenko (mikolalysenko)
force-pushed
the
fix/user-patched-deps-pnpm-yarn-vlt
branch
from
November 5, 2026 19:47
9677ca9 to
3f076f6
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 pnpmpatchedDependencies, yarn berrypatch:and patch-package for every npm-family project. Unless--allow-patch-conflictsis passed, it keepsscan(all modes),get,vendor,applyand the hosted/vendored writers from wiring those packages. Two consumers did not use that evidence:--allow-patch-conflictsrun wrote.hosted-bundle) has no consent gate in front of it, so it rewired those packages.One detector, three consumers
UserPatchIndexhas a single walk over aProjectView(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.patchedDependencieskeys (name,name@version,name@range)package.jsonpnpm.patchedDependencies,pnpm-workspace.yamlpatchedDependencies, each pnpm lock's top-levelpatchedDependenciesmap and(patch_hash=…)key suffixes (root, Rush, members, and a ref's own lock)patch:descriptors (Yarn'sbuiltin<compat/…>patches are excluded), every layer peeled, aliases resolvedpackage.jsondependency maps andresolutions;patch:locators thatyarn.lockresolves, including one that wraps the hosted tarball (#962)patches/<name>+<version>.patch, or a--patch-dirthat apackage.jsonscript namesThe 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-dirbeyond that root is not read. Instead it protects every npm package with an "outside the git repository" diagnostic, in the gate too.--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.ReadScope { Workspace, Global }, chosen once from the CLI flags viaGlobalArgs::read_scope()and passed throughUserPatchIndex::discover_scoped. It has the same shape as Never read or rewrite project files outside the git repository root #2025'sutils::repo_root::ReadScope(same variants,of_global) and is meant to be replaced by that type when the two branches meet.The npm-family scope leaves out npm's and Bun's own top-level
patchedDependenciesand npm's lock-entrypatchedrecords. 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 nopatchedDependencies, nopatch_hashand nopatchcommand. Itsvlt.jsonmodifiersrewrite 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
vex::discover::user_patchedpass marks every npm ref the index protects asUnattestedKind::UserPatchedDependency. Like Stop attesting npm-family pins pnpm or a user patch overrides #2010's npm and Bun marks, each mark is anUnwiredCopywithCopyTarget::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, sorollback,removeandliststill find it.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 patchmade on a hosted or vendored package is silently dropped by the next superseding re-run, takeover, rollback or revert (the #367 guard only matchesname@versionkeys) #1019) are now marked too.npm::mark_user_patched(viauser_patch_source). It now reads keys against no target, so any key for thatname@versioncounts, and that case gets its own detail.redirect_bun_patched_dependency_skipped. Either way vex's plan omits the pin through the mark.engine::withhold_user_patchedremoves such candidates from every rewriter and records each as askippedrow with reasonuser_patch_conflict(the consent gate's code). In memory it also adds a warning.--allow-patch-conflictsis passed. There it mostly duplicates the consent gate, but it also reads the Rush lock, which the gate does not.vendorand the vendored writers refuse withuser_patch_conflict.scan --mode vendoredandapplyskip 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.CLI_CONTRACT.md (Existing project patches, Unattested references, the
vex_npm_patched_dependencyrow) and docs/usage.md are updated.Tests
Verified to fail before:
vex::discover::user_patched::tests:package.jsonpnpmfield, lock map,patch_hashkey, range and bare keys.package.jsonpatch:wrapping the hosted URL; ayarn.lockpatch locator.--patch-dir.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:--allow-patch-conflicts: pinned;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 newthe_repository_bound_holds_for_workspace_runs_onlytest: 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 includescommitted_fixture_corpus_matches_golden, with no golden changes.hosted_inventory21,pnpm_hosted6,vlt_locks10,upstream_restore_golden53.in_process_redirect_pnpm42,in_process_redirect152,hosted_memory_engine50,hosted_memory_parity43,user_patches27,vex48,e2e_vex_redirect53, and libvex_sources/user_patches/scan::hosted63. All pass.cargo clippy --locked --workspace --all-features -- -D warningsandcargo fmt --all -- --checkare clean.Review findings (
/code-review high)All ten findings were confirmed. Each is fixed in
93709da82.projectRoots: [""]), a pnpm member with its own lock is not a root of its own. Itspackage.jsonwas 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.--patch-dirthat has no listing in memory. Selection supplies every*.patch, so such a directory holds none.--patch-dircontents never selected: confirmed and fixed. Selection now requests every*.patchfile by name (presence-only is enough, since only the file names are read).patches/left-pad+1.3.0.patch→ memory pinnedleft-pad. Selection now fetches everypackage.json,pnpm-workspace.yaml,pnpm-lock.yamlandyarn.lock, and every*.patchby name, outsidenode_modules,.git,.socket,targetand.venv. That list is a constant (WALK_SKIP) shared with the walk.hosted_memory_parity::user_patches_are_selected_and_agree_with_the_disk_flowdrivesselect_paths→ memory project → engine for 8 cases (pnpm member manifest, npm memberpatches/, npm memberpatch:descriptor, rootpatches/, root--patch-dir, and three no-patch controls). Each case runs with both detected and named roots and is compared with the disk flow.go.workmember recorded under npm: confirmed and fixed. Every pending directory now carries the ecosystem whose declaration named it. An out-of-repogo.workusemember protects Go modules (fail closed for Go), not npm packages. Test:an_out_of_repo_go_member_protects_go_not_npm...: confirmed and fixed. Ancestors now come fromutils::repo_root::search_dirsand are checked and read at the same canonical absolute path. Test:a_symlinked_root_reads_its_real_ancestors.an_absolute_patch_dir_inside_the_repository_is_read.UserPatchIndex::from_locks+engine::withhold_user_patched_by). The consent gate has already walked the project.npm_family_inskips a passedpnpm-lock.yamloryarn.lockwhose directory the walk already read.vlt::artifact_preflight. The in-memory preflight is offline and downloads nothing.lexical_join: confirmed and fixed. They are replaced byfind_git_repo/search_dirs(which honorGIT_CEILING_DIRECTORIESand the home rule) andnormalize_lexically_keeping_escapes. The minimalReadScopestays, to be replaced by Never read or rewrite project files outside the git repository root #2025's.Two caveats on the tests:
package.jsonand lock adds fetches for large monorepos. That is the cost of reading members' declarations at all. The host'smax_fileslimit still applies and fails the run closed.Not done
🤖 Generated with Claude Code