Skip to content

Stop attesting npm-family pins pnpm or a user patch overrides - #2010

Open
Mikola Lysenko (mikolalysenko) wants to merge 9 commits into
mainfrom
fix/npm-discovery-false-attestation
Open

Mikola Lysenko (mikolalysenko) wants to merge 9 commits into
mainfrom
fix/npm-discovery-false-attestation

Conversation

@mikolalysenko

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

Copy link
Copy Markdown
Collaborator

Problem

VEX discovery (vex/discover/) attested two kinds of npm-family pins that hosted and vendored mode both refuse. In both cases the install may not use the attested bytes.

Fix

  • N1 (revised per owner decision, 5df67ed): discovery withholds whenever any pnpm-lock.<branch>.yaml exists, at the root or in a workspace member that installs from its own lock. That holds whatever gitBranchLockfile or pnpm's lockfile setting says, so the result is deterministic and fails closed. The check reads the project's files only: no settings, no environment, nothing above the project. Rush is exempt, and the check runs only once a root or member pnpm lock has wired a patch. A new utils::pnpm_workspace::git_branch_lock_files lists the branch locks. When one exists, every ref of the root pnpm lock (or shrinkwrap.yaml) and of each member's own lock is marked UnattestedKind::PnpmGitBranchLock. This goes through UnwiredCopy { scope: <lock>, target: Any }, so only refs that survive the contests get marked. Hosted and vendored refs are both marked, because vendored mode also refuses this state.
  • N2: hosted's npm Hosted npm scan pins a package that npm 12's native patchedDependencies also patches, so every later npm ci / npm install fails EPATCHFAILED (and vendored refuses the lockfileVersion 4 lock with wrong advice) #711 gate is moved out of rewrite_npm_lock into patch::redirect::NpmUserPatches. It holds the package.json patchedDependencies keys plus the non-null patched records from any present npm lock. The hosted rewriter and npm discovery both use it now (no behaviour change for hosted). Bun discovery uses the rewriters' per-dependency rule, bun_lock_text::patched_dependency_key: for bun.lock it reads the manifest keys and the keys mirrored in the lock, the same as rewrite_bun_lock; for bun.lockb it reads the manifest keys only, the same as the hosted engine and vendor::bun_binary. Each ref is checked against its hosted URL or vendored tarball, so a key naming the pinned tarball itself is still not a conflict. Matching refs are marked UnattestedKind::UserPatchedDependency.
  • CLI: vex_sources.rs gets note arms vex_pnpm_git_branch_lockfile / vex_npm_patched_dependency. CLI_CONTRACT.md documents both: the Unattested references bullet, the code table, and a cross-link from the Hosted scan with pnpm gitBranchLockfile pins the stale pnpm-lock.yaml and reports success, while pnpm installs unpatched bytes from pnpm-lock.<branch>.yaml #556 paragraph.

Why keep the ref and mark it unattested, instead of a diagnostic and no ref

In both cases the writers treat the wiring as present but unchangeable, not as dead:

If the ref were dropped, the ledger liveness checks (hosted_claim / vendored_claim) would report the wiring as reverted. That's the same reasoning as #2005 / #2007, BundledCopy and NpmShrinkwrapOnly.

Choices worth a look

  • Discovery ignores environment variables for pnpm settings (sharedWorkspaceLockfile; since 5df67ed it does not read gitBranchLockfile at all). git_branch_locks_in(view, false), the files-only reading of the setting, stays available and reads a governing ancestor's files only from inside the project's git repository. Hosted and vendored also read npm_config_git_branch_lockfile / pnpm_config_git_branch_lockfile from the environment. I followed discovery's existing precedent for the lockfile setting (lockfile_disabled(view, false): "the hosted engine runs in the install's environment, vex need not"), so vex and the ledger checks don't depend on the scanner's shell.
  • Bun pins whose user patch Bun would drop are not attested either. For a registry-keyed bun patch over an earlier hosted pin, Bun silently drops the user's patch, so the installed bytes may in fact be the pure Socket bytes. I still don't attest them: hosted refuses and never confirms, and the owner rule makes the modes the ground truth.

Tests

Each new test fails on the code before this change (checked by switching the three new marking paths off: all three panic at their unattested assertions) and passes after:

  • vex::discover::npm::tests::pnpm_git_branch_lockfile_marks_the_lock_pins_unattested: root lock via pnpm-workspace.yaml and via .npmrc, plus a sharedWorkspaceLockfile: false member with its own branch lock. Controls: no branch lock, and the setting off.
  • vex::discover::npm::tests::npm_user_patched_dependency_pin_is_not_attested: a manifest key, a v4 lock-entry patched record, a record in the sibling shrinkwrap only, and a key for another package or version as the control.
  • vex::discover::bun::tests::bun_user_patched_dependency_pin_is_not_attested: hosted bun.lock with versioned and bare keys; a key naming the pinned tarball (no conflict); a vendored ref keyed through bun.lock's mirrored keys; hosted bun.lockb.
  • commands::vex_sources::tests::npm_family_unattested_kinds_have_their_own_tags.

pnpm_git_branch_lockfile_marks_the_lock_pins_unattested now covers the owner decision. A branch lock still withholds when the setting is true, false, missing, or turned off in .npmrc, and when pnpm's lockfile setting is off. The same holds for a member's branch lock with no setting. With no branch lock (before it is written, and after it is deleted), the pin attests.

Runs (after 5df67ed):

  • cargo test -j4 -p socket-patch-core --lib -- vex::discover:: patch::redirect:: vendor::npm vendor::bun vendor::pnpm hosted:: utils::pnpm_workspace: 2179 passed.
  • vex::discover::testing::golden::tests::committed_fixture_corpus_matches_golden passes; no golden changes.
  • cargo test -j4 -p socket-patch-cli --lib vex_sources: 15 passed.
  • cargo test -p socket-patch-cli --test in_process_redirect_pnpm: 41 passed.
  • cargo clippy --locked --workspace --all-features -- -D warnings and cargo fmt --all -- --check are clean.

Not done

  • pnpm's own patchedDependencies (pnpm.patchedDependencies / pnpm-workspace.yaml), yarn berry patch: and vlt are not covered. They're out of scope for N2.

Review findings

/code-review high produced 10 findings. I confirmed all 10 and fixed them in 76bb103, a new commit on top; nothing is refuted. Where a finding is a behaviour bug, a new test failed before the fix (each panicked at its target assertion) and passes after.

  1. Confirmed, fixed. git_branch_locks_in(view, false) still read npm_config_ / pnpm_config_shared_workspace_lockfile through workspace_members → view_setting. Discovery's existing member_locks call did too. view_setting and workspace_members now take env, and discovery calls a new member_locks_in(view, false). Discovery reads no environment variables anywhere. Test: pnpm_git_branch_lockfile_discovery_reads_no_environment (serial, EnvGuard).
  2. Confirmed, fixed. UserPatchedDependency marks were pushed before contest_across_locks, so a dropped ref kept a stale reason. Marks are now UnwiredCopys for exactly one ref (new CopyTarget::Ref { purl, uuid }), and they land only if that ref survives the contests. Tests: a_contested_user_patched_ref_leaves_no_mark (npm) and a_contested_user_patched_bun_ref_leaves_no_mark.
  3. Confirmed, fixed. NpmUserPatches::add_mirrored_keys records keys that only bun.lock mirrors, and the detail now says "bun.lock patchedDependencies has …". Test: an added assertion in bun_user_patched_dependency_pin_is_not_attested.
  4. Confirmed, fixed. The vex_npm_patched_dependency remedy now names socket-patch rollback <uuid> for a pre-Hosted npm scan pins a package that npm 12's native patchedDependencies also patches, so every later npm ci / npm install fails EPATCHFAILED (and vendored refuses the lockfileVersion 4 lock with wrong advice) #711 pin that fails npm ci with EPATCHFAILED. Test: an assertion in npm_family_unattested_kinds_have_their_own_tags.
  5. Confirmed, fixed (perf). An npm lock is walked for patched records only when its bytes contain "patched", as rewrite_npm_lock does.
  6. Confirmed, fixed (perf). git_branch_locks_in runs only after a root lock, shrinkwrap.yaml or member pnpm lock has produced a ref.
  7. Confirmed, fixed. npm and Bun now share one npm::mark_user_patched (with user_patch_source). Bun's copy of the rule is gone.
  8. Confirmed, fixed. A ref with neither url nor artifact_rel is matched with an empty target, so its name@version / bare-name key still applies and every tarball key counts. Test: a_ref_without_a_target_is_still_matched_by_name_and_version.
  9. Confirmed, fixed (docs). CLI_CONTRACT.md now says the "another tarball" key rule applies to npm and Bun alike.
  10. Confirmed, fixed. The mark names the branch lock in the marked lock's own directory and falls back to the first one. Test: pnpm_git_branch_lock_mark_names_the_locks_own_branch_lock.

Owner rule: no reads outside the git repository root. Discovery's files-only mode now reads a governing ancestor pnpm-workspace.yaml / .npmrc only when that ancestor is at or below the nearest directory with a .git entry. The check only stats paths. Outside a repository, discovery reads no ancestor at all. Hosted and vendored (env mode) are unchanged. Test: utils::pnpm_workspace::tests::the_files_only_reading_stays_inside_the_git_repository.

Note for the stacked #2018: it changes the same code (the Bun tarball-key rule, a shared project walk). Its rebase will need to pick up CopyTarget::Ref / mark_user_patched and NpmUserPatches::add_mirrored_keys.

Review findings, round 2 (/code-review high on 76bb103..ac92b08, cloud routine)

  1. OWNER DECISION NEEDED: hosted reads the environment for sharedWorkspaceLockfile, discovery doesn't. Discovery now lists member locks without reading the environment, per owner decision 8 ("discovery reads no env"). The hosted engine and the vendor lock inventory still read pnpm_config_shared_workspace_lockfile / npm_config_…. If hosted ran with that variable exported and no file setting, it pinned member locks that later discoveries never read. Those refs then disappear for list, rollback and remove, and the liveness gates may call the live wiring dead. Discovery did read these locks before this PR. I left it alone because it's a binding-decision conflict. Recommendation: make hosted and the inventory env-free for the member-lock listing too (planner work for Plan pnpm rewires with one core for both modes (lock-model 2b) #2029), rather than letting discovery read env again.
  2. The PnpmGitBranchLock doc was stale: fixed. The kind is applied on a branch lock's mere presence, and when members can't be listed, not "while the setting is on". The CLI note also names the remedy for unlisted members. The detail text already named the real cause.

Declined:

Tests (cloud run): cargo test -p socket-patch-core --lib -- vex::discover:: utils::pnpm_workspace gives 478 passed, golden included. CLI --lib vex_sources passes. clippy and fmt are clean.

Main merged (burn-down agent)

941dc7a4 merges main at b69fc03. The conflicts with #2011 (Go workspace override) are resolved by keeping both sides: the UnattestedKind variants (GoWorkspaceOverride and its file_scoped() included), both sets of vex_sources notes and tests, and both CLI_CONTRACT.md bullets (the unattested-references bullet now counts seven cases). cargo test -p socket-patch-core --lib -- vex::discover:: utils::pnpm_workspace: 490 passed (golden included); -p socket-patch-cli --lib vex_sources: 17 passed; cargo check -p socket-patch-cli --all-targets clean. Note: #2005 also edits that bullet's count, so whichever of the two lands second will need a one-line merge.


Note

Medium Risk
Changes VEX attestation and discovery semantics for pnpm/npm/Bun lockfiles; incorrect marking could falsely withhold or emit attestations, but behavior is fail-closed and aligned with existing scan/vendor refusals.

Overview
VEX no longer attests npm-family pins when installs may bypass Socket wiring—the same cases hosted and vendored modes already refuse.

Discovery marks pins unattested (refs stay for rollback / ledgers) when any pnpm-lock.<branch>.yaml exists at the root or on per-member locks (#556), using project files only and ignoring env/settings. It also marks refs when the project applies npm patch / Bun patch via patchedDependencies, lock patched records, or keys mirrored in bun.lock (#711 / #367).

Implementation pulls user-patch detection into shared NpmUserPatches, adds pnpm helpers like git_branch_lock_files with file-only vs install-time config reads, and uses CopyTarget::Ref so unattested reasons drop when cross-lock contests remove the ref. CLI_CONTRACT.md and vex_sources document vex_pnpm_git_branch_lockfile and vex_npm_patched_dependency.

Reviewed by Cursor Bugbot for commit f665796. Configure here.


Generated by Claude Code

VEX discovery attested two kinds of npm-family pins that the hosted and
vendored writers refuse, so the statement could cover bytes the install
never uses.

N1, pnpm gitBranchLockfile (#556): with the setting on and a
pnpm-lock.<branch>.yaml present, pnpm installs the branch from that lock.
Hosted withholds the root and member locks and refuses every pnpm pin;
vendored refuses the project. Discovery now runs the same detection
(utils::pnpm_workspace::git_branch_locks, via a new git_branch_locks_in
that skips the environment, as discovery already does for the lockfile
setting) and, only while the lockfile setting is on and outside Rush,
marks every ref of the root and member pnpm locks
UnattestedKind::PnpmGitBranchLock.

N2, user-patched npm / Bun packages (#711, #367): hosted leaves a package
the project patches itself unwired and never confirms it; vendored
refuses. Hosted's npm gate is factored into patch::redirect::NpmUserPatches
(package.json patchedDependencies keys plus non-null lock-entry `patched`
records in any present npm lock), which the hosted rewriter and npm
discovery now share. Bun discovery applies the rewriters' per-dependency
rule (bun_lock_text::patched_dependency_key over the manifest keys, plus
bun.lock's mirrored keys for the text lock) against each ref's hosted URL
or vendored tarball. Matching refs are marked
UnattestedKind::UserPatchedDependency.

Both keep the ref (rollback / remove / list still find a pre-fix pin and
the ledgers keep the wiring live, since no re-run can clear the evidence)
and only vex omits it, as vex_pnpm_git_branch_lockfile /
vex_npm_patched_dependency. CLI_CONTRACT.md documents both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mikola Lysenko (mikolalysenko) added a commit that referenced this pull request Nov 4, 2026
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. Discovery now reads the keys against
no target (any key for the name@version counts) and marks such a ref
UserPatchedDependency with its own detail; npm discovery reads its keys
the same way. 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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Discovery never reads the environment for pnpm settings: the
  files-only flag now reaches `sharedWorkspaceLockfile` too
  (view_setting / workspace_members / new member_locks_in), and
  discovery's member-lock listing uses it.
- A governing ancestor pnpm-workspace.yaml is read in files-only mode
  only from inside the project's git repository (stat-only check), so
  discovery never reads project files above the repository root.
- User-patched marks are deferred as an UnwiredCopy for exactly that ref
  (new CopyTarget::Ref), so a ref a later contest drops keeps no stale
  reason. npm and Bun share one helper (mark_user_patched).
- NpmUserPatches records keys only bun.lock mirrors, and the detail
  names that file instead of package.json.
- A ref with no url or vendored artifact is still matched by
  name@version.
- npm locks are walked for `patched` records only when their bytes
  spell `"patched"`; gitBranchLockfile detection runs only once a root
  or member pnpm lock wired a patch.
- The branch-lock mark names the branch lock in the marked lock's own
  directory.
- The vex_npm_patched_dependency remedy names `socket-patch rollback
  <uuid>`; CLI_CONTRACT.md says the tarball-key rule applies to npm and
  Bun alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mikola Lysenko (mikolalysenko) added a commit that referenced this pull request Nov 5, 2026
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>
Mikola Lysenko (mikolalysenko) added a commit that referenced this pull request Nov 5, 2026
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 decision for #556 in VEX discovery: a `pnpm-lock.<branch>.yaml` at
the root, or in a workspace member that installs from its own lock,
withholds every root and member pnpm lock pin from attestation whatever
`gitBranchLockfile` (or pnpm's `lockfile` setting) says. The check is
deterministic and fail-closed, reads the project's files only (no
environment, no settings, nothing above the project), and runs only once
one of those locks wired a patch.

New `utils::pnpm_workspace::git_branch_lock_files` lists the branch
locks without reading the setting. The mark keeps the
`PnpmGitBranchLock` kind and still names the branch lock in the marked
lock's own directory. The detail, the vex remedy and CLI_CONTRACT.md now
say a branch lock's presence withholds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mikola Lysenko (mikolalysenko) added a commit that referenced this pull request Nov 5, 2026
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>
Mikola Lysenko (mikolalysenko) added a commit that referenced this pull request Nov 5, 2026
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>
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

bugbot run


Generated by Claude Code

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

git_branch_lock_files ignored Members::Unresolved, so with the shared
lock off and no readable member list, discovery attested the root pin
even though a member's pnpm-lock.<branch>.yaml could not be ruled out.
Hosted mode already treats that case as a possible branch lock; return
the reason and withhold the same way (fail-closed). Addresses the
Bugbot finding on pnpm_workspace.rs.

Co-Authored-By: Claude <noreply@anthropic.com>
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

bugbot run


Generated by Claude Code

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

@mikolalysenko
Mikola Lysenko (mikolalysenko) marked this pull request as ready for review November 6, 2026 12:12
@mikolalysenko Mikola Lysenko (mikolalysenko) added the Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review label Nov 6, 2026
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Ready for review at ac92b08.

  • CI: 30/32 non-skipped checks green. The only failures are the main-wide e2e … pdm:: --ignored leg (module 'hishel' has no attribute 'SyncSqliteStorage', also red on main; Fix pdm e2e red on every PR: pin hishel<2 #2028 fixes it) and the ci-ok aggregate that follows from it.
  • Bugbot: reviewed ac92b08; no unresolved findings.
  • Promoted from draft by the burn-down agent this run. No Slack announcement was sent this run (the Slack send action wasn't available), so there's no slack-announced marker yet.

Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Final review brief (for the human approver)

What it does: VEX discovery stops attesting npm-family pins that hosted and vendored mode refuse.

  • pnpm branch locks: when any pnpm-lock.<branch>.yaml exists at the root or in a member with its own lock, every root and member pnpm ref is marked PnpmGitBranchLock. The lock's presence alone decides it, as the owner chose.
  • User patches: npm and Bun refs of packages the project patches itself are marked UserPatchedDependency. This goes through a new shared NpmUserPatches, extracted from the hosted npm rewriter.

Risk: low. Every change either withholds an attestation or is a refactor that keeps hosted behaviour the same. I compared the hosted rewrite_npm_lock gate before and after: same keys, same record filter, same precedence. Nothing new is written.

Look here

Verified

  • I read the full diff against the description, and they match.
  • The marks are applied after contest_across_locks, so they land only on surviving refs.
  • patched_dependency_key targets line up between discovery and the rewriters.
  • The new tests assert new marks and include controls. CRLF and Windows separators are handled. CHANGELOG.md is untouched.
  • CI: 30/32 non-skipped checks green. The only failures are the main-wide pdm/hishel leg and the ci-ok aggregate; Fix pdm e2e red on every PR: pin hishel<2 #2028 fixes both.
  • Bugbot: clean on this head.

Changes I made: none.

Open questions (non-blocking)

  • Ledger effect: discovery now lists member locks with member_locks_in(view, false) instead of reading sharedWorkspaceLockfile from the environment. If that setting comes only from the environment, hosted member pins become non-refs, not unattested refs, so the ledger may read them as reverted. Is that intended?
  • Stale doc comments: they still say the pnpm setting must be on: mod.rs:609-612 and npm.rs:731-733.
  • Dead code: git_branch_locks_in(view, false) and governing_file_in_git_repository are now called only from tests.
  • Fail-open path: branch_lock_names (pnpm_workspace.rs:990) treats a list_dir error as "no branch locks". That is unlikely in practice.

Auto-merge is not armed yet, because CI is red on the main-wide pdm failure. Once #2028 lands and this goes green, the next final-reviewer run arms it, and approving then sends it straight to the merge queue.


Generated by Claude Code

Second review round. The `PnpmGitBranchLock` doc said the mark applies
"while gitBranchLockfile is on and a branch lock exists"; discovery
marks on a branch lock's mere presence (it reads no environment) and
when the members that could hold one cannot be listed. The doc and the
CLI note now say so, and the note names the remedy for unlisted members.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mikolalysenko Mikola Lysenko (mikolalysenko) removed the Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review label Nov 7, 2026
Conflicts with #2011 (Go workspace override): keep both sides'
UnattestedKind variants (PnpmGitBranchLock, UserPatchedDependency and
GoWorkspaceOverride with its file_scoped()), both sets of vex_sources
notes and tests, and both CLI_CONTRACT bullets (the unattested-references
bullet now counts seven cases).

Co-Authored-By: Claude <noreply@anthropic.com>
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

bugbot run


Generated by Claude Code

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

# Conflicts:
#	crates/socket-patch-cli/CLI_CONTRACT.md
#	crates/socket-patch-cli/src/commands/vex_sources.rs
#	crates/socket-patch-core/src/vex/discover/mod.rs
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

bugbot run


Generated by Claude Code

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Main's #2007 added the vex_nuget_lock_other_version note beside this
branch's npm-family notes; keep both, and name NuGet's case next to
Gradle's in the contract's Eight-cases sentence as main now does.

Co-Authored-By: Claude <noreply@anthropic.com>
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

bugbot run


Generated by Claude Code

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

…se-attestation

# Conflicts:
#	crates/socket-patch-cli/CLI_CONTRACT.md
#	crates/socket-patch-cli/src/commands/vex_sources.rs
#	crates/socket-patch-core/src/vex/discover/mod.rs
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Burn-down agent: merged main (c2be854) into this branch to fix the merge conflict. New head is f665796. #2008 added sbt unattested kinds in the same three places this PR adds the pnpm branch-lock and user-patched kinds: CLI_CONTRACT.md, the vex_sources.rs note constants and match arms, and the UnattestedKind enum. Every conflict was a pure addition, so I kept both sides. Local checks: workspace cargo check --all-targets and clippy pass, CLI unit tests pass (866), and core vex tests pass (787).

bugbot run


Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

bugbot run


Generated by Claude Code

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit f665796. Configure here.

@mikolalysenko Mikola Lysenko (mikolalysenko) added the Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review label Nov 7, 2026
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Labeled Ready for review by the burn-down agent.


Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Final review brief (updated for head f665796)

What it does: VEX discovery stops attesting npm-family pins that hosted and vendored mode both refuse. If any pnpm-lock.<branch>.yaml exists (at the root or in a member with its own lock), the pnpm refs are marked PnpmGitBranchLock. If the project patches a package itself (patchedDependencies, npm's lock patched record, bun patch), its npm and Bun refs are marked UserPatchedDependency, through a shared NpmUserPatches taken out of the hosted npm rewriter.

Risk: low. Every change either withholds an attestation or is a refactor that keeps hosted behaviour the same. Nothing new is written to user projects.

Look here

Verified

  • Since my brief at ac92b08, one commit changed the code: ab96d63. It edits only the PnpmGitBranchLock doc comment and the CLI note text, and it fixes the stale-doc question from that brief. The other changes are 4 merges from main.
  • I checked the conflict resolution in f665796 (CLI_CONTRACT.md, vex_sources.rs, UnattestedKind). It keeps both sides: Require sbt resolution evidence before attesting vendored pins #2008's sbt kinds next to this PR's two kinds.
  • Local run: core vex::discover:: patch::redirect:: utils::pnpm_workspace gave 1373 passed. The 1 failure is vlt_heal::an_unremovable_hidden_lock_keeps_every_store_entry, a permission test that can't hold when run as root in my container. This PR doesn't touch it, and it passes in CI. CLI vex_sources gave 17 passed.
  • CI is green on the head (ci-ok and clippy included). Bugbot reviewed f665796 and found nothing new. No review threads are unresolved. CHANGELOG.md is untouched.

Changes I made: none.

Open questions (non-blocking, for the approver)

  • Ledger effect (round-2 finding 1): discovery lists member locks from files only (member_locks_in(view, false)) and doesn't read sharedWorkspaceLockfile from the environment. If that setting is set only in the environment, hosted member pins become non-refs instead of unattested refs, so the ledger may read them as reverted. Is that acceptable?
  • Dead code: git_branch_locks_in(view, false) and governing_file_in_git_repository are now called only from tests.
  • Draft Never patch a pnpm, yarn or vlt dependency the user patched #2018 is stacked on this branch.

Auto-merge is armed, so approving sends this straight to the merge queue.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants