Skip to content

Fix gem crawl ignoring Bundler path.system (#915) - #916

Open
Mikola Lysenko (mikolalysenko) wants to merge 4 commits into
mainfrom
agent/fix-gem-path-system-default-root
Open

Mikola Lysenko (mikolalysenko) wants to merge 4 commits into
mainfrom
agent/fix-gem-path-system-default-root

Conversation

@mikolalysenko

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

Copy link
Copy Markdown
Collaborator

LLM Description written by Claude Code:claude-opus-5-5

Fixes #915

Summary

A project that moved from vendor/bundle back to system gems (bundle config set path.system true or BUNDLE_PATH__SYSTEM=true) usually still has the old, gitignored vendor/bundle. Bundler ignores it and loads the system copy. The gem crawler still counted it, though, and because it held gems it switched off the gem env homes. As a result:

  • agent apply patched only the unused copy and reported success, and
  • vex attested not_affected while the app ran the unpatched gem.

Now the crawler skips the default vendor/bundle root when the Bundler tier that decides the install path sets a truthy path.system. The system gem homes are crawled, patched and verified instead.

Root cause

RubyCrawler::discover_bundle_stores_impl pushed <cwd>/vendor/bundle unconditionally. A store there sets default_root_has_stores, which suppresses the gem env fallback in gem_paths_and_discovery. The crawler already parsed path.system, but only to drop the recorded BUNDLE_PATH. It never applied the setting to the implicit default root.

Changes

  • bundler_path_system reproduces Bundler's Settings#path tier selection: the first of the local app config, the environment, then the global config that sets path, path.system or disable_shared_gems. If that tier's path.system is truthy, the default root is not probed. As with the explicit roots, this only applies when --cwd holds a Bundler manifest.
  • bundler_truthy follows Bundler's to_bool coercion: anything except false/f/no/n/0/empty is true. I checked this against Bundler 4.0.18, where 1 and yes are true. parse_bundle_config_path uses it too; it used to accept only the exact string "true".
  • When path.system wins, an env BUNDLE_PATH that it shadows (or that sits in the same tier) is skipped too, so a value naming vendor/bundle can't bring the leftover store back. This was Bugbot's finding on de26452, fixed in ccebcd0.
  • The env BUNDLE_PATH__SYSTEM is threaded into discovery for every ambient caller: get_gem_paths, crawl_all_with_discovery, and discover_bundle_stores, which feeds vex and the hosted stale-install probe.
  • The CLI_CONTRACT.md "Gem install roots" section documents the rule.
  • With no path setting at all, the old leftover-vendor/bundle behavior is unchanged.

I checked the tier semantics against real Bundler 4.0.18 (Bundler.settings.path.use_system_gems? / Bundler.bundle_path):

  • local path.system beats an env BUNDLE_PATH;
  • an env BUNDLE_PATH beats a global path.system;
  • path.system: "1" counts as true.

CI note

main (9c43dfc) is red in coverage / test (macos|windows) on the utils::digest architecture guard. This PR carries #878's fix as a cherry-pick (de26452), which becomes a no-op once #878 merges.

Test evidence

Red without the fix (git stash of ruby_crawler.rs), green with it:

  • cargo test -p socket-patch-core --lib ruby_crawler: 4 failed before (local_path_system_drops_leftover_vendor_bundle, env_path_system_drops_leftover_vendor_bundle, global_path_system_drops_leftover_vendor_bundle, parse_bundle_config_path_contract), 81/81 pass after. The shadowed_path_system_keeps_vendor_bundle control passes both ways.
  • cargo test -p socket-patch-core --test crawler_ruby_e2e path_system: before the fix it failed with left: [".../app/vendor/bundle/ruby/3.3.0/gems"] right: [".../system-gem-home/gems"]; it passes after. This test uses the ambient env plus a fake gem env binary and covers the local-config, env and control cases, including find_each_by_purl locating the loaded copy.

Also run locally:

  • cargo clippy --workspace --all-features -- -D warnings: clean.
  • crawler_ruby_e2e: 28/28.
  • in_process_gem_apply: 11/11.
  • in_process_gem_multi_platform: 7/7.
  • e2e_redirect_gem_stale_install: 32/32.
  • cargo test -p socket-patch-core --lib: 5251 passed, 4 failed. The 4 failures are permission-denial tests (copy_tree symlinked root, vlt_heal unremovable lock, pypi_poetry / pypi_requirements write failure). They can't fail as intended when run as root in this sandbox, and they're in modules this PR doesn't touch. CI runs them as non-root.
  • A full cargo test --workspace --all-features ran out of sandbox disk, so CI is the record for the rest.
  • No wrapper changes are needed (npm/, pypi/, gem/ only dispatch to the binary).

Per-issue checklist

Why this cluster

This is a single-issue p1 picked ahead of older p1s because it produces a false VEX attestation and has a narrow, well-understood fix.

🤖 Generated with Claude Code


Note

Medium Risk
Changes Ruby gem root discovery and patching targets for Bundler path.system projects; incorrect tier logic could still miss or mis-target gems, but behavior is heavily tested and scoped to install-root selection.

Overview
Fixes #915: when Bundler’s winning install-path tier sets a truthy path.system (local .bundle/config, env BUNDLE_PATH__SYSTEM, or global config), gem discovery no longer probes the default vendor/bundle root or a shadowed env BUNDLE_PATH. Leftover gitignored stores there can’t suppress the gem env fallback, so apply, scan, and vex target the system copy Bundler actually loads.

Implementation mirrors Bundler’s tier order via bundler_path_system and bundler_truthy (Bundler to_bool, not only "true"). parse_bundle_config_path and config_path_system use the same coercion. Ambient discovery threads BUNDLE_PATH__SYSTEM through get_gem_paths, crawl_all, and discover_bundle_stores. CLI_CONTRACT.md documents the rule; unit and e2e tests cover local/env/global variants and shadowing controls.

Separately, Gradle cache / JVM jar / Maven sidecar code routes SHA-1/SHA-256 through crate::utils::digest instead of ad-hoc sha1/sha2 calls (cherry-pick alignment with #878).

Reviewed by Cursor Bugbot for commit ccebcd0. Configure here.


Generated by Claude Code

Assisted-by: Claude Code:claude-opus-5-5
A project that switched from vendor/bundle back to system gems
(`bundle config set path.system true`, or BUNDLE_PATH__SYSTEM=true)
usually still has the old gitignored vendor/bundle. The gem crawler
always counted that leftover store and, because it held gems, stopped
looking in the `gem env` homes. Agent apply then patched only the
unused copy, and vex attested not_affected while Bundler kept loading
the unpatched system gem.

The crawler now works out which Bundler settings tier decides the
install path (local config, then environment, then global config) and,
when that tier sets a truthy path.system, skips the default
vendor/bundle root so the system gem homes are crawled. path.system
values now follow Bundler's own boolean coercion, so "1" or "yes"
count as true too.

Fixes #915

Assisted-by: Claude Code:claude-opus-5-5
main has failed socket-patch-core's lib tests since Gradle support
(#646) and the digest helpers (#865) both landed. The guard test
production_digests_go_through_the_helpers flags three files #646 added
that still hash inline: crawlers/gradle_cache.rs, patch/jvm_jar.rs and
patch/sidecars/maven.rs. That breaks test, test-release and coverage on
every open PR.

Each inline sha1/sha256 call now goes through sha1_hex_of or
sha256_hex_of, which compute the same lowercase hex. Behaviour is
unchanged.

Assisted-by: Claude Code:claude-opus-5-5
(cherry picked from commit 659ac2c)
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

[agent] coverage and test (macos-latest) failed on 604790a, the empty start commit, which is identical to main (9c43dfc). socket-patch-core --lib fails there in the utils::digest architecture guard, and it is red on main too: three Gradle files hash inline instead of using the utils::digest helpers. That isn't this PR's change. The fix is #878, so I cherry-picked its commit here (659ac2c → de26452). It becomes a no-op once #878 lands.


Generated by Claude Code

@mikolalysenko
Mikola Lysenko (mikolalysenko) marked this pull request as ready for review October 6, 2026 05:06
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

BugBot review


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.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Env path ignores winning path.system
    • Moved uses_system_gems computation earlier and gated env BUNDLE_PATH addition with it, ensuring both env path and default root are skipped when path.system is truthy.

Create PR

Or push these changes by commenting:

@cursor push 204a52709d
Preview (204a52709d)
diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs
--- a/crates/socket-patch-cli/src/commands/list.rs
+++ b/crates/socket-patch-cli/src/commands/list.rs
@@ -431,7 +431,10 @@
                 detail: detail.clone(),
             });
         } else if !args.common.silent {
-            eprintln!("Warning: {}", crate::commands::rollback::capitalize_first(detail));
+            eprintln!(
+                "Warning: {}",
+                crate::commands::rollback::capitalize_first(detail)
+            );
         }
     }
     let vendor_state = crate::commands::vendor_state_lenient(&loaded.vendor, args.common.silent);
@@ -773,12 +776,18 @@
         let listings = HostedListing::from_pins(
             &[
                 pin("pkg:npm/minimist@1.2.2", &record.uuid),
-                pin("pkg:npm/other@1.0.0", "33333333-3333-4333-8333-333333333333"),
+                pin(
+                    "pkg:npm/other@1.0.0",
+                    "33333333-3333-4333-8333-333333333333",
+                ),
             ],
             Some(&legacy),
         );
         assert_eq!(listings[0].record, record);
-        assert_eq!(listings[1].record.uuid, "33333333-3333-4333-8333-333333333333");
+        assert_eq!(
+            listings[1].record.uuid,
+            "33333333-3333-4333-8333-333333333333"
+        );
         assert!(listings[1].record.vulnerabilities.is_empty());
         assert_eq!(listings[1].lockfiles, vec!["yarn.lock".to_string()]);
     }

diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs
--- a/crates/socket-patch-cli/src/commands/mod.rs
+++ b/crates/socket-patch-cli/src/commands/mod.rs
@@ -1,7 +1,7 @@
 pub mod apply;
 pub(crate) mod bun_preflight;
+pub(crate) mod composer_hints;
 pub(crate) mod context;
-pub(crate) mod composer_hints;
 pub(crate) mod fetch_stage;
 pub mod get;
 pub mod hosted_bundle;
@@ -9,11 +9,11 @@
 pub(crate) mod lock_cli;
 pub mod remove;
 pub mod repair;
-pub(crate) mod vendored_backend;
 pub mod rollback;
 pub mod scan;
 pub mod update;
 pub mod vendor;
+pub(crate) mod vendored_backend;
 pub mod vex;
 pub(crate) mod vex_consumed;
 pub(crate) mod vex_sources;
@@ -141,9 +141,11 @@
     common: &crate::args::GlobalArgs,
     root: &Path,
 ) -> socket_patch_core::patch::redirect::RedirectState {
-    hosted_state_from_pins(&socket_patch_core::patch::redirect::upstream::HostedPin::all(
-        &discover_wiring(common, root).await,
-    ))
+    hosted_state_from_pins(
+        &socket_patch_core::patch::redirect::upstream::HostedPin::all(
+            &discover_wiring(common, root).await,
+        ),
+    )
 }
 
 /// [`hosted_state_from_lockfiles`] over already-discovered pins. A purl
@@ -153,10 +155,8 @@
 ) -> socket_patch_core::patch::redirect::RedirectState {
     let mut state = socket_patch_core::patch::redirect::RedirectState::new();
     for pin in pins {
-        state
-            .records
-            .entry(pin.purl.clone())
-            .or_insert_with(|| socket_patch_core::manifest::schema::PatchRecord {
+        state.records.entry(pin.purl.clone()).or_insert_with(|| {
+            socket_patch_core::manifest::schema::PatchRecord {
                 uuid: pin.uuid.clone(),
                 exported_at: String::new(),
                 files: Default::default(),
@@ -164,7 +164,8 @@
                 description: String::new(),
                 license: String::new(),
                 tier: String::new(),
-            });
+            }
+        });
     }
     state
 }
@@ -191,4 +192,3 @@
         }
     }
 }
-

diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs
--- a/crates/socket-patch-cli/src/commands/scan/discovery.rs
+++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs
@@ -168,29 +168,32 @@
     }
     // `(ledger key, base purl, entry)`; the artifact fallback has no
     // entries to probe, so it never reports unwired keys.
-    let candidates: Vec<(String, String, Option<&socket_patch_core::vendor::VendorEntry>)> =
-        match state {
-            Ok(state) => state
-                .entries
-                .iter()
-                .map(|(key, entry)| {
-                    (
-                        key.clone(),
-                        strip_purl_qualifiers(&entry.base_purl).to_string(),
-                        Some(entry),
-                    )
-                })
-                .collect(),
-            // Corrupt/unreadable ledger (a MISSING file is Ok(empty) above):
-            // recover the vendored set from the committed artifacts, or
-            // `scan --prune` (whose ledger exemption also degrades to empty)
-            // would delete still-vendored packages' manifest entries and blobs.
-            Err(_) => vendored_purls_from_artifacts(common)
-                .await
-                .into_iter()
-                .map(|base| (base.clone(), base, None))
-                .collect(),
-        };
+    let candidates: Vec<(
+        String,
+        String,
+        Option<&socket_patch_core::vendor::VendorEntry>,
+    )> = match state {
+        Ok(state) => state
+            .entries
+            .iter()
+            .map(|(key, entry)| {
+                (
+                    key.clone(),
+                    strip_purl_qualifiers(&entry.base_purl).to_string(),
+                    Some(entry),
+                )
+            })
+            .collect(),
+        // Corrupt/unreadable ledger (a MISSING file is Ok(empty) above):
+        // recover the vendored set from the committed artifacts, or
+        // `scan --prune` (whose ledger exemption also degrades to empty)
+        // would delete still-vendored packages' manifest entries and blobs.
+        Err(_) => vendored_purls_from_artifacts(common)
+            .await
+            .into_iter()
+            .map(|base| (base.clone(), base, None))
+            .collect(),
+    };
     // Composer by release identity: a ledger `@3.0.2.0` is the crawled
     // `@3.0.2`, not a second package to supplement.
     let key = |p: &str| composer_purl_identity(p).unwrap_or_else(|| normalize_purl(p).into_owned());
@@ -1045,7 +1048,9 @@
             ..GlobalArgs::default()
         };
         let state = socket_patch_core::vendor::load_state(root).await;
-        vendored_ledger_supplement(&args, crawled, &state).await.packages
+        vendored_ledger_supplement(&args, crawled, &state)
+            .await
+            .packages
     }
 
     /// A ledger entry vendored as `@3.0.2.0` is the crawled composer
@@ -1080,7 +1085,9 @@
             out.iter().map(|p| &p.purl).collect::<Vec<_>>()
         );
 
-        let out = vendored_ledger_supplement(&args, &[], &Ok(state)).await.packages;
+        let out = vendored_ledger_supplement(&args, &[], &Ok(state))
+            .await
+            .packages;
         assert_eq!(
             out.iter().map(|p| p.purl.as_str()).collect::<Vec<_>>(),
             vec!["pkg:composer/psr/log@3.0.2.0"]
@@ -1183,7 +1190,10 @@
             let state = npm_ledger_with_lock(tmp.path(), lock.as_deref()).await;
             let out = vendored_ledger_supplement(&args, &[], &state).await;
             assert_eq!(
-                out.packages.iter().map(|p| p.purl.as_str()).collect::<Vec<_>>(),
+                out.packages
+                    .iter()
+                    .map(|p| p.purl.as_str())
+                    .collect::<Vec<_>>(),
                 vec!["pkg:npm/left-pad@1.3.0"],
                 "lock={lock:?}"
             );

diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs
--- a/crates/socket-patch-cli/src/commands/scan/hosted.rs
+++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs
@@ -982,7 +982,8 @@
             socket_patch_core::utils::fs::read_regular_to_string_sync(path).ok()
         })
     };
-    let rewrite_options = || RewriteOptions {
+    let rewrite_options = || {
+        RewriteOptions {
         dry_run: common.dry_run,
         targets_pipenv_lock,
         pipenv_major,
@@ -994,6 +995,7 @@
         npm_allow_remote_config: !common.no_npm_allow_remote_config,
         npm_outer: &npm_outer,
         blocking: true,
+    }
     };
     // The rollout gate plans again without its deferred rows: keep what
     // the second pass needs.
@@ -2431,13 +2433,19 @@
 /// artifacts, then verify with `vex`. After a vendored→hosted takeover
 /// (`vendored_removed`) the commit also has to carry the deleted vendored
 /// ledger entries and artifacts.
-fn format_next_steps(files: &[String], edits: &[socket_patch_core::patch::redirect::FileEdit], vendored_removed: bool) -> Vec<String> {
+fn format_next_steps(
+    files: &[String],
+    edits: &[socket_patch_core::patch::redirect::FileEdit],
+    vendored_removed: bool,
+) -> Vec<String> {
     if files.is_empty() && !vendored_removed {
         return Vec::new();
     }
     let mut commit: Vec<String> = Vec::new();
     if vendored_removed {
-        commit.push(".socket/vendor/ (the removed vendored ledger entries and artifacts)".to_string());
+        commit.push(
+            ".socket/vendor/ (the removed vendored ledger entries and artifacts)".to_string(),
+        );
     }
     commit.extend(files.iter().cloned());
     let npm = files
@@ -4624,19 +4632,43 @@
         use super::npm_allow_remote_one_line;
         let hosts = ["patch.socket.dev"];
         let cases = [
-            (npm_allow_remote_configured_detail(&hosts, true, false), "Note: set"),
-            (npm_allow_remote_configured_detail(&hosts, false, false), "Note: set"),
-            (npm_allow_remote_configured_detail(&hosts, true, true), "Note: would set"),
-            (npm_allow_remote_already_detail(&hosts), "Note: .npmrc already"),
-            (npm_allow_remote_user_set_detail(&hosts, "none"), "Warning: npm >=12"),
-            (npm_allow_remote_env_set_detail(&hosts, "npm_config_allow_remote", "none"), "Warning: npm >=12"),
+            (
+                npm_allow_remote_configured_detail(&hosts, true, false),
+                "Note: set",
+            ),
+            (
+                npm_allow_remote_configured_detail(&hosts, false, false),
+                "Note: set",
+            ),
+            (
+                npm_allow_remote_configured_detail(&hosts, true, true),
+                "Note: would set",
+            ),
+            (
+                npm_allow_remote_already_detail(&hosts),
+                "Note: .npmrc already",
+            ),
+            (
+                npm_allow_remote_user_set_detail(&hosts, "none"),
+                "Warning: npm >=12",
+            ),
+            (
+                npm_allow_remote_env_set_detail(&hosts, "npm_config_allow_remote", "none"),
+                "Warning: npm >=12",
+            ),
             (npm_allow_remote_manual_detail(&hosts), "Warning: npm >=12"),
-            (npm_allow_remote_unreadable_detail(&hosts, "is a symlink"), "Warning: npm >=12"),
+            (
+                npm_allow_remote_unreadable_detail(&hosts, "is a symlink"),
+                "Warning: npm >=12",
+            ),
         ];
         for (detail, start) in cases {
             let line = npm_allow_remote_one_line(&detail);
             assert!(line.starts_with(start), "{line}");
-            assert!(!line.contains('\n') && line.ends_with("(details: --verbose)."), "{line}");
+            assert!(
+                !line.contains('\n') && line.ends_with("(details: --verbose)."),
+                "{line}"
+            );
         }
     }
 }

diff --git a/crates/socket-patch-cli/src/commands/scan/policy.rs b/crates/socket-patch-cli/src/commands/scan/policy.rs
--- a/crates/socket-patch-cli/src/commands/scan/policy.rs
+++ b/crates/socket-patch-cli/src/commands/scan/policy.rs
@@ -11,9 +11,9 @@
 use socket_patch_core::api::types::PatchSearchResult;
 use socket_patch_core::manifest::schema::PatchManifest;
 use socket_patch_core::policy::{
-    canon, find_repo_root_with_warnings, policy_block, FilteredEntry, RetainedEntry, patch_severity_order, repo_relative_checked, sanitize, severity_name,
-    DiskPolicyFs, FilterReason, Offers, PolicyError, PolicySource, PolicyWarning, Root, SelectionPolicy,
-    PATCHES_DISABLED,
+    canon, find_repo_root_with_warnings, patch_severity_order, policy_block, repo_relative_checked,
+    sanitize, severity_name, DiskPolicyFs, FilterReason, FilteredEntry, Offers, PolicyError,
+    PolicySource, PolicyWarning, RetainedEntry, Root, SelectionPolicy, PATCHES_DISABLED,
 };
 use socket_patch_core::utils::purl::normalize_purl;
 
@@ -42,12 +42,18 @@
 /// Load the policy for `args` (4.5): `--global` scans have no repo and read
 /// no file; everything else reads the repo root's socket.yml.
 pub(crate) fn load_invocation_policy(args: &ScanArgs) -> Result<InvocationPolicy, PolicyLoadError> {
-    let overrides = args.socket_yml.overrides().map_err(PolicyLoadError::Usage)?;
+    let overrides = args
+        .socket_yml
+        .overrides()
+        .map_err(PolicyLoadError::Usage)?;
     let cwd = std::fs::canonicalize(&args.common.cwd).unwrap_or_else(|_| args.common.cwd.clone());
     if args.common.is_global() {
-        let policy = SelectionPolicy::load(&socket_patch_core::policy::MemoryPolicyFs::default(), &overrides)
-            .map_err(PolicyLoadError::Policy)?
-            .0;
+        let policy = SelectionPolicy::load(
+            &socket_patch_core::policy::MemoryPolicyFs::default(),
+            &overrides,
+        )
+        .map_err(PolicyLoadError::Policy)?
+        .0;
         return Ok(InvocationPolicy {
             policy,
             repo_root: cwd,
@@ -56,8 +62,8 @@
         });
     }
     let (repo_root, mut warnings) = find_repo_root_with_warnings(&cwd);
-    let (policy, load_warnings) =
-        SelectionPolicy::load(&DiskPolicyFs::new(&repo_root), &overrides).map_err(PolicyLoadError::Policy)?;
+    let (policy, load_warnings) = SelectionPolicy::load(&DiskPolicyFs::new(&repo_root), &overrides)
+        .map_err(PolicyLoadError::Policy)?;
     warnings.extend(load_warnings);
     Ok(InvocationPolicy {
         policy,
@@ -138,7 +144,12 @@
 
 impl ScanPolicy {
     /// The policy for the project rooted at `root_dir`.
-    pub(crate) fn for_root(invocation: &InvocationPolicy, root_dir: &Path, explicit: bool, global: bool) -> Self {
+    pub(crate) fn for_root(
+        invocation: &InvocationPolicy,
+        root_dir: &Path,
+        explicit: bool,
+        global: bool,
+    ) -> Self {
         let root_dir = std::fs::canonicalize(root_dir).unwrap_or_else(|_| root_dir.to_path_buf());
         let project = repo_relative_checked(&invocation.repo_root, &root_dir).unwrap_or_default();
         let root_verdict = if global {
@@ -171,7 +182,9 @@
                 severity: None,
             });
         }
-        let announce_warnings = !invocation.warned.swap(true, std::sync::atomic::Ordering::Relaxed);
+        let announce_warnings = !invocation
+            .warned
+            .swap(true, std::sync::atomic::Ordering::Relaxed);
         Self {
             policy: invocation.policy.clone(),
             warnings,
@@ -224,7 +237,10 @@
     /// exclude stays in the query (so `upgradeAvailable` can be reported)
     /// but joins the retained set, which never reaches a writer.
     pub(crate) fn admit_crawled(&self, purl: &str) -> bool {
-        let verdict = self.root_verdict.clone().and_then(|()| self.policy.admits_purl(purl));
+        let verdict = self
+            .root_verdict
+            .clone()
+            .and_then(|()| self.policy.admits_purl(purl));
         let reason = match verdict {
             Ok(()) => return true,
             Err(reason) => reason,
@@ -334,7 +350,8 @@
             // (not when a lower-ranked admitted patch simply wins).
             let top_withheld = self.policy.admits_severity(patch_severity_order(&group[0]));
             if let Err(reason) = top_withheld {
-                let upgrade_withheld = chosen.is_some() && chosen == recorded_at && recorded_at != Some(0);
+                let upgrade_withheld =
+                    chosen.is_some() && chosen == recorded_at && recorded_at != Some(0);
                 if chosen.is_none() || upgrade_withheld {
                     report.filtered.push(FilteredEntry {
                         purl: Some(canon(&purl)),
@@ -522,17 +539,20 @@
         let verdict = if !policy.enabled() {
             Err(FilterReason::Disabled)
         } else {
-            root_verdict.clone().and_then(|()| policy.admits_purl(purl)).and_then(|()| {
-                // The floor only hides a package when none of its patches pass.
-                match group
-                    .iter()
-                    .map(|p| policy.admits_severity(patch_severity_order(p)))
-                    .find(Result::is_ok)
-                {
-                    Some(ok) => ok,
-                    None => policy.admits_severity(patch_severity_order(group[0])),
-                }
-            })
+            root_verdict
+                .clone()
+                .and_then(|()| policy.admits_purl(purl))
+                .and_then(|()| {
+                    // The floor only hides a package when none of its patches pass.
+                    match group
+                        .iter()
+                        .map(|p| policy.admits_severity(patch_severity_order(p)))
+                        .find(Result::is_ok)
+                    {
+                        Some(ok) => ok,
+                        None => policy.admits_severity(patch_severity_order(group[0])),
+                    }
+                })
         };
         if let Err(reason) = verdict {
             out.push((

diff --git a/crates/socket-patch-cli/src/commands/scan/rollout.rs b/crates/socket-patch-cli/src/commands/scan/rollout.rs
--- a/crates/socket-patch-cli/src/commands/scan/rollout.rs
+++ b/crates/socket-patch-cli/src/commands/scan/rollout.rs
@@ -4,8 +4,10 @@
 
 use std::collections::{BTreeMap, BTreeSet, HashSet};
 
-use socket_patch_core::rollout::{canonical_base_purl, severity_label, MaxNew, MaxNewSource, Recorded, RolloutPlan};
 pub(crate) use socket_patch_core::rollout::stage::*;
+use socket_patch_core::rollout::{
+    canonical_base_purl, severity_label, MaxNew, MaxNewSource, Recorded, RolloutPlan,
+};
 
 use super::discovery::UpdateInfo;
 
@@ -208,11 +210,11 @@
 mod tests {
     use super::*;
     use socket_patch_core::api::types::PatchSearchResult;
+    use socket_patch_core::api::types::VulnerabilityResponse;
     use socket_patch_core::manifest::schema::PatchManifest;
-    use std::path::Path;
-    use socket_patch_core::api::types::VulnerabilityResponse;
     use socket_patch_core::manifest::schema::PatchRecord;
     use std::collections::HashMap;
+    use std::path::Path;
 
     fn offer(purl: &str, uuid: &str, published: &str, severities: &[&str]) -> PatchSearchResult {
         PatchSearchResult {
@@ -357,13 +359,21 @@
         let stored = manifest(&[("pkg:composer/psr/log@3.0.2.0", "old")]);
         let recorded = RecordedIndex::new(Some(&stored), &[]);
         let offers = offers_from_results(
-            &[offer("pkg:composer/psr/log@v3.0.2", "new", "2026-02-01T00:00:00Z", &["high"])],
+            &[offer(
+                "pkg:composer/psr/log@v3.0.2",
+                "new",
+                "2026-02-01T00:00:00Z",
+                &["high"],
+            )],
             false,
         );
         let rows = classify(&offers, &recorded, "");
         let plan = socket_patch_core::rollout::plan_rollout(
             rows.into_iter().map(|row| row.candidate).collect(),
-            &MaxNew { value: Some(0), source: MaxNewSource::Flag },
+            &MaxNew {
+                value: Some(0),
+                source: MaxNewSource::Flag,
+            },
             false,
             &BTreeSet::new(),
         );

diff --git a/crates/socket-patch-cli/src/commands/scan/rollout_args.rs b/crates/socket-patch-cli/src/commands/scan/rollout_args.rs
--- a/crates/socket-patch-cli/src/commands/scan/rollout_args.rs
+++ b/crates/socket-patch-cli/src/commands/scan/rollout_args.rs
@@ -1,7 +1,6 @@
 //! `scan --max-new-patches` (see the rollout guide,
 //! `docs/configuration.md#gradual-rollout`).
 
-
 use clap::Args;
 pub(crate) use socket_patch_core::rollout::stage::RolloutCarry;
 use socket_patch_core::rollout::{resolve_max_new, MaxNew};
@@ -77,7 +76,6 @@
     }
 }
 
-
 #[cfg(test)]
 mod tests {
     use super::*;

diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs
--- a/crates/socket-patch-cli/src/commands/vendor.rs
+++ b/crates/socket-patch-cli/src/commands/vendor.rs
@@ -283,10 +283,7 @@
 /// entry (fail-safe): ecosystems other than npm, cargo and pypi (whose
 /// probe covers the requirements flavor only) have no in-use probe yet,
 /// and a missing/unreadable lockfile proves nothing.
-pub(crate) async fn dispatch_in_use_one(
-    entry: &VendorEntry,
-    project_root: &Path,
-) -> Option<bool> {
+pub(crate) async fn dispatch_in_use_one(entry: &VendorEntry, project_root: &Path) -> Option<bool> {
     match entry.ecosystem.as_str() {
         "npm" => vendor::npm_flavor::vendored_entry_in_use(entry, project_root).await,
         // Cargo probes the lock entry's shape: detached + `[patch]` pointing

diff --git a/crates/socket-patch-cli/tests/apply/apply_network.rs b/crates/socket-patch-cli/tests/apply/apply_network.rs
--- a/crates/socket-patch-cli/tests/apply/apply_network.rs
+++ b/crates/socket-patch-cli/tests/apply/apply_network.rs
@@ -940,7 +940,10 @@
         "a legacy package archive must not cover the patch; stdout={stdout}\nstderr={stderr}"
     );
     let content = std::fs::read(tmp.path().join("node_modules/pkgcache/index.js")).unwrap();
-    assert_eq!(content, before, "the file must not be patched from the legacy archive");
+    assert_eq!(
+        content, before,
+        "the file must not be patched from the legacy archive"
+    );
 
     let requests = mock.received_requests().await.unwrap_or_default();
     let blob_path = format!("/v0/orgs/{ORG_SLUG}/patches/blob/{after_hash}");
@@ -1043,10 +1046,7 @@
         v["summary"]["applied"], 1,
         "the drifted nested copy must be warn-overwritten.\nstdout={v:#}"
     );
-    assert_eq!(
-        v["summary"]["failed"], 0,
-        "no copy may fail.\nstdout={v:#}"
-    );
+    assert_eq!(v["summary"]["failed"], 0, "no copy may fail.\nstdout={v:#}");
 
     // The nested copy's blob was fetched on demand…
     let requests = mock.received_requests().await.unwrap();

diff --git a/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs b/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs
--- a/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs
+++ b/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs
@@ -201,7 +201,9 @@
         "non-silent stderr must carry the {CODE} warning; got:\n{stderr}"
     );
     assert_eq!(
-        stderr.matches("Warning: bundler app config BUNDLE_PATH").count(),
+        stderr
+            .matches("Warning: bundler app config BUNDLE_PATH")
+            .count(),
         1,
         "exactly ONE warning line (not one per discovery call); got:\n{stderr}"
     );

diff --git a/crates/socket-patch-cli/tests/cli/covgap_output.rs b/crates/socket-patch-cli/tests/cli/covgap_output.rs
--- a/crates/socket-patch-cli/tests/cli/covgap_output.rs
+++ b/crates/socket-patch-cli/tests/cli/covgap_output.rs
@@ -168,9 +168,8 @@
         .expect("spawn socket-patch in PTY");
     drop(pair.slave);
 
-    let reader_handle = crate::pty_io::PtyOutput::spawn(
-        pair.master.try_clone_reader().expect("clone reader"),
-    );
+    let reader_handle =
+        crate::pty_io::PtyOutput::spawn(pair.master.try_clone_reader().expect("clone reader"));
 
     // Watchdog: detached kill after `timeout`; a no-op if the child exits
     // naturally first.
@@ -261,7 +260,10 @@
         "\n",
         Duration::from_secs(15),
     );
-    assert_eq!(code, 0, "remove with bare Enter must succeed; got: {output}");
+    assert_eq!(
+        code, 0,
+        "remove with bare Enter must succeed; got: {output}"
+    );
     // The interactive confirm MUST have run — otherwise this test passes
     // vacuously against a regression that drops the TTY gate and
     // auto-proceeds. Match the distinctive prompt verbatim (the loose

diff --git a/crates/socket-patch-cli/tests/cli/interactive_prompts_e2e.rs b/crates/socket-patch-cli/tests/cli/interactive_prompts_e2e.rs
--- a/crates/socket-patch-cli/tests/cli/interactive_prompts_e2e.rs
+++ b/crates/socket-patch-cli/tests/cli/interactive_prompts_e2e.rs
@@ -112,9 +112,8 @@
     // closed. The previous design used a chunked read+mpsc loop
     // because it interleaved with a try_wait poll; the simplified
     // design serializes wait → drop master → read_to_end joins.
-    let reader_handle = crate::pty_io::PtyOutput::spawn(
-        pair.master.try_clone_reader().expect("clone reader"),
-    );
+    let reader_handle =
+        crate::pty_io::PtyOutput::spawn(pair.master.try_clone_reader().expect("clone reader"));
 
     // Watchdog: detach a thread that kills the child after `timeout`.
     // The cloned ChildKiller is independent of the main `child`

diff --git a/crates/socket-patch-cli/tests/cli_config_fallback.rs b/crates/socket-patch-cli/tests/cli_config_fallback.rs
--- a/crates/socket-patch-cli/tests/cli_config_fallback.rs
+++ b/crates/socket-patch-cli/tests/cli_config_fallback.rs
@@ -59,8 +59,7 @@
     let mut cmd = Command::new(BINARY);
     // Human mode: core's proxy advisory (the oracle below) is muted under
     // `--json`/`--silent`.
-    cmd.args(["scan", "-e", "npm", "--cwd"])
-        .arg(project);
+    cmd.args(["scan", "-e", "npm", "--cwd"]).arg(project);
     for (key, _) in std::env::vars_os() {
         let name = key.to_string_lossy();
         if name.starts_with("SOCKET_") {
@@ -298,7 +297,9 @@
     json_cmd.arg("--json");
     let json_out = run(json_cmd);
     assert!(
-        json_out.stderr.contains("could not parse socket-cli config"),
+        json_out
+            .stderr
+            .contains("could not parse socket-cli config"),
         "the parse warning must reach stderr under --json too; got:\n{}",
         json_out.stderr
     );

diff --git a/crates/socket-patch-cli/tests/cli_get_silent.rs b/crates/socket-patch-cli/tests/cli_get_silent.rs
--- a/crates/socket-patch-cli/tests/cli_get_silent.rs
+++ b/crates/socket-patch-cli/tests/cli_get_silent.rs
@@ -25,10 +25,7 @@
     for var in GLOBAL_ARG_ENV_VARS {
         cmd.env_remove(var);
     }
-    for var in [
-        "SOCKET_SAVE_ONLY",
-        "SOCKET_ALL_RELEASES",
-    ] {
+    for var in ["SOCKET_SAVE_ONLY", "SOCKET_ALL_RELEASES"] {
         cmd.env_remove(var);
     }
     cmd.env("SOCKET_TELEMETRY_DISABLED", "1");

diff --git a/crates/socket-patch-cli/tests/cli_parse_list.rs b/crates/socket-patch-cli/tests/cli_parse_list.rs
--- a/crates/socket-patch-cli/tests/cli_parse_list.rs
+++ b/crates/socket-patch-cli/tests/cli_parse_list.rs
@@ -370,7 +370,11 @@
     let out = run_list_binary(tmp.path(), &["--json"]);
     let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim())
         .expect("stdout must be valid JSON envelope");
-    assert_eq!(out.status.code(), Some(0), "missing manifest is an empty list");
+    assert_eq!(
+        out.status.code(),
+        Some(0),
+        "missing manifest is an empty list"
+    );
     assert_eq!(v["status"], "success", "envelope: {v}");
     assert_eq!(v["summary"]["discovered"], 0, "envelope: {v}");
 }
@@ -1313,7 +1317,10 @@
     assert_eq!(v["status"], "success", "envelope={v}");
     let warnings = v["warnings"].as_array().expect("warnings[] present");
     assert_eq!(warnings.len(), 1, "envelope={v}");
-    assert_eq!(warnings[0]["code"], "redirect_ledger_corrupt", "envelope={v}");
+    assert_eq!(
+        warnings[0]["code"], "redirect_ledger_corrupt",
+        "envelope={v}"
+    );
     assert!(
         out.stderr.is_empty(),
         "--json must keep stderr clean: {}",

diff --git a/crates/socket-patch-cli/tests/cli_parse_rollback.rs b/crates/socket-patch-cli/tests/cli_parse_rollback.rs
--- a/crates/socket-patch-cli/tests/cli_parse_rollback.rs
+++ b/crates/socket-patch-cli/tests/cli_parse_rollback.rs
@@ -366,7 +366,11 @@
 /// relied on the rejection get a test-visible flip instead of a silent one.
 #[test]
 fn multiple_targets_parse_in_order() {
-    let args = parse_rollback(&["pkg:npm/foo@1", "packages/api/**", "b0630680-4da6-45f9-bba8-b888e0ffd58c"]);
+    let args = parse_rollback(&[
+        "pkg:npm/foo@1",
+        "packages/api/**",
+        "b0630680-4da6-45f9-bba8-b888e0ffd58c",
+    ]);
     assert_eq!(
         args.targets,
         vec![

diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs
--- a/crates/socket-patch-cli/tests/cli_parse_scan.rs
+++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs
@@ -898,7 +898,11 @@
         ("NONE", None),
     ] {
         let args = parse_scan(&["--max-new-patches", raw]);
-        assert_eq!(args.rollout.max_new_patches, Some(MaxNewPatches(want)), "{raw}");
+        assert_eq!(
+            args.rollout.max_new_patches,
+            Some(MaxNewPatches(want)),
+            "{raw}"
+        );
     }
 }
 
@@ -989,20 +993,33 @@
     assert_eq!(parse_scan(&[]).socket_yml.min_severity, None);
     assert_eq!(overrides(&[], &[]).unwrap().min_severity, None);
     assert_eq!(
-        overrides(&["--min-severity", "High"], &[]).unwrap().min_severity,
+        overrides(&["--min-severity", "High"], &[])
+            .unwrap()
+            .min_severity,
         Some((Some(1), OverrideSource::Flag))
     );
     assert_eq!(
-        overrides(&["--min-severity", "none"], &[("SOCKET_MIN_SEVERITY", "critical")]).unwrap().min_severity,
+        overrides(
+            &["--min-severity", "none"],
+            &[("SOCKET_MIN_SEVERITY", "critical")]
+        )
+        .unwrap()
+        .min_severity,
         Some((None, OverrideSource::Flag))
     );
     assert_eq!(
-        overrides(&[], &[("SOCKET_MIN_SEVERITY", "moderate")]).unwrap().min_severity,
+        overrides(&[], &[("SOCKET_MIN_SEVERITY", "moderate")])
+            .unwrap()
+            .min_severity,
         Some((Some(2), OverrideSource::Env))
     );
-    assert_eq!(overrides(&[], &[("SOCKET_MIN_SEVERITY", "")]).unwrap().min_severity, None);
+    assert_eq!(
+        overrides(&[], &[("SOCKET_MIN_SEVERITY", "")])
+            .unwrap()
+            .min_severity,
+        None
+    );
     assert!(overrides(&[], &[("SOCKET_MIN_SEVERITY", "severe")]).is_err());
     assert!(try_parse_scan(&["--min-severity", "severe"]).is_err());
     assert!(overrides(&["--no-socket-yml"], &[]).unwrap().bypass);
 }
-

diff --git a/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs b/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs
--- a/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs
+++ b/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs
@@ -149,7 +149,9 @@
     );
     let chatter = stderr_chatter(&stderr);
     assert!(
-        chatter.iter().any(|l| l.contains("could not be downloaded")),
+        chatter
+            .iter()
+            .any(|l| l.contains("could not be downloaded")),
         "--silent must keep the download-failure error (errors only, \
          never nothing); stderr was: {stderr:?}"
     );

diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs
--- a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs
+++ b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs
@@ -566,8 +566,7 @@
         "the human skipped line must name purl + reason; stderr=\n{stderr}"
     );
     assert!(
-        stderr.contains("Warning: ")
-            && stderr.contains("could not be reverted"),
+        stderr.contains("Warning: ") && stderr.contains("could not be reverted"),
         "the takeover pre-warning must reach human stderr; stderr=\n{stderr}"
     );
 }
@@ -814,7 +813,10 @@
     let lock_before = std::fs::read(root.join("package-lock.json")).unwrap();
 
     let assert_ignored = |code: i32, doc: &Value, label: &str| {
-        assert_eq!(code, 0, "{label}: a pre-v5 ledger is never an error: {doc:#}");
+        assert_eq!(
+            code, 0,
+            "{label}: a pre-v5 ledger is never an error: {doc:#}"
+        );
         assert_eq!(doc["status"], "success", "{label}: {doc:#}");
         assert!(
             !doc.to_string().contains("redirect-state.json"),
@@ -1017,7 +1019,10 @@
 
     for extra in [&[][..], &["--silent"][..]] {
         let (code, stdout, stderr) = scan_hosted(root, &server.uri(), extra, &[]);
-        assert_eq!(code, 0, "{extra:?}: an empty discovery exits 0; stderr=\n{stderr}");
+        assert_eq!(
+            code, 0,
+            "{extra:?}: an empty discovery exits 0; stderr=\n{stderr}"
+        );
         if extra.is_empty() {
             assert!(
                 stdout.contains("No patches available for installed packages."),
@@ -1404,16 +1409,22 @@
         ],
... diff truncated: showing 800 of 6626 lines

You can send follow-ups to the cloud agent here.

Comment thread crates/socket-patch-core/src/crawlers/ruby_crawler.rs
When the Bundler tier that wins sets path.system, Bundler also ignores
an env BUNDLE_PATH below it or beside it. If that value named the
leftover vendor/bundle, the crawler still probed it as the default
root and hid the system gem homes again, so apply and vex kept
targeting the unused copy. The env root is now skipped in that case
too.

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

BugBot review


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 ccebcd0. Configure here.

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

Copy link
Copy Markdown
Collaborator Author

Burn-down agent: labeled Ready for review at ccebcd0 (ccebcd007ddd686062e20410f55aa243bd1306c2), 0 commits behind main, mergeable.

  • CI: 406/406 check runs green on this head (6 skipped by matrix rule), none failing or pending.
  • Bugbot: reviewed ccebcd0 and found no new issues. Its one earlier finding (env BUNDLE_PATH ignored when path.system wins) was confirmed and fixed in ccebcd0, and the thread is resolved.
  • For reviewers: the main change is in crates/socket-patch-core/src/crawlers/ruby_crawler.rs, where the gem crawl now skips the default vendor/bundle root when the winning Bundler config tier sets path.system: true. The e2e coverage is in tests/crawler_ruby_e2e.rs.

Slack announcement is still pending because this run's Slack connector has no send tool, so the slack-announced marker is left off for a later run to retry.


Generated by Claude Code

This branch has not been deployed

No deployments
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

3 participants