Skip to content

fix(cli)!: require consent before patching user-patched packages - #1646

Open
Mikola Lysenko (mikolalysenko) wants to merge 7 commits into
mainfrom
fix/respect-user-package-patches
Open

Mikola Lysenko (mikolalysenko) wants to merge 7 commits into
mainfrom
fix/respect-user-package-patches

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

A project could already have a pnpm, Yarn or patch-package fix and still receive a Socket patch without specific consent. Native npm/Bun had backend-specific refusals, but there was no shared package-ownership check. Agent apply also overwrote unknown installed file contents with the verified Socket blob by default, potentially discarding a local fix.

This change protects project patches before patch selection or mutation, even when the two fixes touch different files or the user diff has not been installed yet.

  • Add a read-only detector for pnpm declarations and lock markers; Yarn user and plugin patch locators; patch-package filenames and literal custom directories; native npm/Bun declarations; Composer patch definitions and locks; Cargo overrides; and Go replacements. Inspect declared npm workspace members and ancestor workspace settings. Preserve Socket-owned wiring and distinguish Yarn's builtin compatibility patches from user patches.
  • Skip protected scan candidates with user_patch_conflict warnings in every mode. apply (including nested apply) leaves protected manifest entries out with the same warning and applies the rest, so a postinstall apply never fails an install over them. Explicit get and vendor refuse conflicts with exit 1; vendor reverts dropped entries first. Recheck ownership before hosted/vendored writes, including hosted-to-vendored eject, and retain the full crawl for pruning.
  • Add invocation-only --allow-user-patches, forwarded through nested apply and reported as user_patch_override. General --yes, UUID selection, policy settings, and --force do not approve declared patch composition. Existing backend safety refusals remain in force.
  • Reject unknown installed-file drift by default, except content an earlier Socket patch wrote (its hash names a stored .socket/blobs/ blob, as after a patch update), which is overwritten with the usual warning. Explicit consent restores the previous hash-verified overwrite behavior; --strict keeps rejecting drift even with consent. Existing apply --force remains an explicit override for file drift only. Consent is an attempt to combine changes, not an automatic merge.

The CLI contract and usage docs specify diagnostics, read-only/reversal exemptions, version matching, workspace behavior, and detection limits. The ecosystem documentation links the primary package-manager documentation and source used for the research. Regression coverage includes pnpm wildcard prereleases, nested Yarn patch locators in both directions, scoped/nested/sequenced patch-package names, malformed metadata, all CLI modes, nested opt-in forwarding, and preservation of user files.

Breaking change / release coordination: the stricter mismatch default and package eligibility belong in the next major release under the CLI semver policy. Version stamps and release notes remain the release train's responsibility.

Detection is conservative where metadata is unreadable or cannot be resolved without executing scripts. Arbitrary build hooks, external patch pipelines, global-install declarations, and unrecorded edits to files a Socket patch does not touch cannot be detected reliably. These limits are explicit in the contract. Scan exits 0 when a package is protected only because metadata could not be inspected; the warning names the file.

Validation passed locally:

  • cargo check --locked -p socket-patch-cli --tests
  • Core detector tests: 12 passed; CLI unit tests: 925 passed.
  • Full apply, get, scan, vendor, hosted eject, in-process vendor, user-patch, and affected global/parser integration suites.
  • cargo clippy --locked --workspace --all-features -- -D warnings
  • cargo fmt --all -- --check and git diff --check
  • A real pnpm 7.28.0 patch / patch-commit workflow with a local Socket test record targeting a different file: default apply and --force refused; --allow-user-patches applied the Socket fix while preserving the native diff and user-modified file.

/code-review high ran on 4b3d620; all 10 findings were fixed (the drift-default, apply-scope and scan-exit questions were decided with the maintainer as described above). A second /code-review high pass on e48ded2 found 10 more candidates; 7 are fixed in 6cb2793 and one is documented. The other three:

  • Unreadable metadata protects the whole ecosystem and scan/apply exit 0. This is the fail-safe direction and was decided with the maintainer (exit 0 with a warning naming the file). The false positives behind it (stub locks, cd in scripts, ** globs, big lockfiles) are fixed.
  • Discovery runs more than once per command (vendor, eject, hosted). The second read under the apply lock is the intended TOCTOU recheck; sharing one index across the rest is a perf follow-up, not a correctness issue.
  • Earlier Socket output needs its blob to be recognized. Documented in the contract: once the blob is pruned or not shared, apply --force replaces it.

Note

High Risk
Major behavior change to default apply/scan eligibility and file-overwrite policy on dependency trees, with new consent gates across mutating commands that can block or alter installs in projects using native patching workflows.

Overview
Breaking (major): Socket Patch now treats declared project patches as protected ownership and tightens in-place apply safety unless the user opts in per invocation.

Adds global --allow-user-patches (not env-bound; --yes, --force, and UUID selection do not substitute). A new detector scans manifests/locks for pnpm, Yarn, patch-package, npm/Bun, Composer, Cargo, and Go patch/replace evidence. scan withholds protected packages in every mode with user_patch_conflict warnings; apply skips those manifest entries but still patches others (postinstall-friendly exit 0); get / vendor / hosted & vendored writers refuse with user_patch_conflict before mutation (with a lock-time recheck on hosted writes). Consent emits user_patch_override and is forwarded through nested apply.

File mismatch policy flips: unknown local drift on apply paths fails by default instead of warn-and-overwrite. The exception is content whose hash matches a stored .socket/blobs/ entry (prior Socket patch output on update), which can still be overwritten with content_mismatch_overwritten. --allow-user-patches restores hash-verified overwrite for other drift; --strict rejects drift even with consent; apply --force still overrides file-content checks but not declared project patches. Core adds MismatchPolicy::SocketContent to implement this.

Docs (CLI_CONTRACT, usage, ecosystems) and broad CLI/core tests cover the new gates, diagnostics, and policy behavior.

Reviewed by Cursor Bugbot for commit 6cb2793. Configure here.


Generated by Claude Code

Detect project patch ownership across pnpm, Yarn, patch-package, npm, Bun, Composer, Cargo and Go. Skip protected scan candidates and refuse explicit mutations unless --allow-user-patches is passed. Preserve native backend refusals and require explicit consent before overwriting unknown installed file contents.

BREAKING CHANGE: declared project patches are protected by default and agent apply now rejects unknown file-content drift. Document the next-major-release requirement, detection limits and invocation-only consent in the CLI contract.
@mikolalysenko
Mikola Lysenko (mikolalysenko) marked this pull request as ready for review October 20, 2026 22:24

@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.

Comment thread crates/socket-patch-cli/src/commands/apply.rs Outdated
Comment thread crates/socket-patch-core/src/patch/user_patches.rs
Comment thread crates/socket-patch-core/src/patch/user_patches.rs
Comment thread crates/socket-patch-cli/src/commands/scan/mod.rs
The url-spelled crates-io [patch] table and ancestor .cargo/config
[patch] fixtures are now caught earlier by the new UserPatchIndex,
which refuses with user_patch_conflict before the cargo-specific
guards run. Pass --allow-user-patches so these tests keep exercising
the guards they were written for (cargo_manifest_patch_source_alias
and user_authored_patch_entry); the default refusal is covered by
tests/user_patches.rs.

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

Copy link
Copy Markdown
Collaborator Author

Burn-down agent: CI on 98d2edd had two separate failures.

Fixed (29dd054): e2e_vendor_cargo_build cargo_url_spelled_crates_io_patch_table_is_refused and cargo_ancestor_config_patch_cannot_silently_shadow_the_vendored_copy. The new UserPatchIndex now refuses these fixtures with user_patch_conflict before the cargo-specific guards run. The tests now pass --allow-user-patches, so they still exercise cargo_manifest_patch_source_alias / user_authored_patch_entry. Both pass locally.

Needs a decision (coverage job): 5 tests in tests/in_process_redirect_pnpm.rs fail: hosted_scan_from_package_json_workspace_member_refuses, hosted_scan_from_npm_member_with_stray_lock_refuses, hosted_scan_from_bun_or_vlt_member_with_stray_lock_refuses, hosted_scan_from_brace_or_class_glob_workspace_member_refuses, and hosted_run_from_berry_picomatch_workspace_member_refuses. No user patch is declared in any of these fixtures. The detector fails closed anyway:

  • package-lock.json / bun.lock (cannot inspect patch metadata: expected value at line 1 column 1): the fixture lock is a non-JSON placeholder.
  • (workspace outside the inspection root): the berry packages/b/../a glob.

scan --mode hosted then drops the package with a user_patch_conflict warning and exits 0 / status success. Before this PR it exited 1 / status error, and the tests assert exactly that ("a found-but-unpinnable patch is not success").

Question: when the protection comes only from an inspection failure (unparseable lockfile, workspace glob outside the root) and not from real patch evidence, should scan still exit 0 and report success? Or should that case stay an error (exit 1)? If exit 0 is intended, the five tests need new expectations. If not, scan (or UserPatchIndex) needs to tell inspection failures apart from declared patches. I didn't pick one, because either choice changes the PR's breaking-change contract.


Generated by Claude Code

- apply: release apply.lock before reporting a user_patch_conflict, like
  the manifest-load failures above it, so telemetry can't block siblings.
- core: match Cargo [patch]/[replace] names ignoring case and -/_, since
  crates.io treats those as the same crate.
- core: follow go.work `use` members so a member go.mod replace protects
  its module from the workspace root too.
- scan: hide user_patch_conflict/override warnings under --silent, matching
  user_patches::check; the JSON envelope still carries them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The #480 [patch] override shapes are now caught first by the
user-patch gate (user_patch_conflict), so they no longer reach
redirect_cargo_dep_overridden. Scan with --allow-user-patches so the
shapes keep exercising the redirect guards, and ignore the resulting
user_patch_override notice; tests/user_patches.rs covers the gate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
From /code-review high on #1646:

- A `cd` after patch-package (`patch-package && cd ios && pod install`)
  no longer marks the patch directory unresolvable and blocks all npm.
- Yarn alias locators (`alias@npm:real@1.0.0`) protect `real`, the name
  the crawler reports, instead of a name that never matches.
- Cargo [patch] paths use vendor's path_is_socket_owned, so a user path
  like `.socket/vendor/../../forks/x` is no longer treated as Socket's.
- yarn.lock is scanned line by line for patch resolutions instead of
  parsed (every Berry lock has compat patches); pnpm locks parse with a
  budget large enough for big monorepos; package-lock/bun.lock are only
  parsed when they mention `patched`, so a stub or broken lock with no
  patch evidence no longer protects every npm package.
- Workspace globs walk directories only, take only dirs with a
  package.json as members, flag a symlink only when it would be a
  member, and resolve `..` lexically (re-rooting `../shared/*`).
- Approved user_patch_override warnings print once: check() no longer
  prints, callers do, and a nested apply leaves them to its caller.
- The hosted re-check refuses through the hosted module's refuse(), so
  the scan envelope is kept and telemetry precedes the JSON error.
- A scan whose every package is protected says so instead of "No
  packages found. Run your package manager's install first."
- Go hosted/build tests for replace guards now pass --allow-user-patches
  so they keep testing the guards behind the consent gate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Two review findings on #1646, resolved as decided with the maintainer:

- apply no longer refuses a whole run over one protected manifest entry
  (a postinstall apply would fail every install). Protected records sit
  the run out with a user_patch_conflict warning, everything else
  applies, and skips alone exit 0. The manifest on disk is unchanged,
  and skipped Go entries are kept out of the stale-redirect reconcile.
  `apply --check` stays read-only and verifies every entry. Explicit
  `get` and `vendor` still refuse; vendor now reverts dropped entries
  before its check so a protected record can't block that cleanup.
- The default mismatch policy is the new MismatchPolicy::SocketContent:
  Strict, except a file whose hash names a stored .socket/blobs blob
  (another Socket patch's output, as after an update to a newer patch
  for the same release) is overwritten with the usual
  content_mismatch_overwritten warning. --strict stays fully strict.

Contract and usage docs updated to match.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A nested apply under `get` now reports the older manifest entries it
  skips for project patches (stderr and the JSON envelope via the run
  report); `get` checks only its own selection. `scan` already reports
  every protected installed package, so its nested apply stays quiet.
- vendor: a conflict found after reconcile_dropped already wrote is
  recorded as per-package `failed` events instead of a top-level error,
  which the contract reserves for runs where nothing happened.
- patch-package `cd` detection matches `cd` as a command, not the tail
  of a word such as `abcd`.
- The ancestor workspace walk never reaches the home directory or above,
  so a checkout without `.git` doesn't read `~/.cargo/config.toml` or a
  stray `~/patches/` as project evidence.
- Discovery is skipped when no in-scope purl is in an ecosystem the
  detector reads (an empty or pypi-only manifest on postinstall).
- npm/Bun locks are read once instead of twice.
- The scan message for protected packages no longer claims they were
  the only reason the scan came up empty.
- Contract: a pruned or unshared blob means earlier Socket output reads
  as drift; `apply --force` is the explicit way through.

Co-Authored-By: Claude Opus 5.5 (1M context) <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.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is ON, but none of the reported issues met your autofix severity threshold.

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

Reviewed by Cursor Bugbot for commit 6cb2793. Configure here.

nested: Some(super::apply::NestedApply {
caller_json: json,
report_user_patch_skips,
}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Skipped apply still reported applied

Medium Severity

Nested apply now drops a protected manifest entry and still exits 0. Agent scan/get then record every saved selection as applied. Hosted rechecks under the write lock for this window; agent no longer fails the run, and scan also suppresses the skip warning, so a package added to project patch metadata after selection can be saved and reported patched without a write.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6cb2793. Configure here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Traced it. In get/scan, the selection already runs the same user-patch detector (with the same allow_user_patches) before anything is recorded. So a recorded purl is skipped by the nested apply's skip_protected only if the project's patch metadata (a pnpm/Yarn/patch-package/Composer/Cargo patch entry) is added between selection and the nested apply in the same run. In that window, record_apply_outcome sees code == 0 and records applied for a package that was left unwritten. The race is real but narrow. Fixing it means adding a user_patched list to ApplyRunReport and mapping covered purls to skipped in record_apply_outcome, about 20 lines plus a test. I'm leaving this thread open for the maintainer to decide whether that's worth adding to this PR.


Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Burn-down agent: CI is green on 6cb2793 (34/34, 19 skipped) and it merges cleanly with main. Bugbot reviewed this head and left one Medium finding: Skipped apply still reported applied (agent_download.rs:1449). I checked it, and it only happens if a project patch entry is added between selection and the nested apply in the same run. I replied on the thread with the traced path and the fix (~20 lines).

Question for a human: should this PR fix that race now, by mapping purls that apply skipped as user-patched to skipped events instead of applied, or accept it as is? Reply here and I'll implement or resolve it, then label this PR Ready for review.


Generated by Claude Code

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants