fix(gem): crawl bundle-path roots in bundler precedence order, patch every coexisting copy, contain config-sourced roots - #222
Merged
Mikola Lysenko (mikolalysenko) merged 2 commits intoAug 20, 2026
Conversation
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Gem apply patches global gem homes
- Changed early-return condition to prevent adding global gem homes when any bundle stores exist, avoiding unwanted patching of machine-wide gem installations.
Or push these changes by commenting:
@cursor push 81f3fac8b4
Preview (81f3fac8b4)
diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs
--- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs
+++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs
@@ -77,16 +77,16 @@
)
.await;
- // Historic early-return, kept ONLY for the implicit project-local
- // `vendor/bundle` probe: a deployment-style install is the
- // project's one gem source, so the ambient gem homes don't apply.
- // Stores found via an env/config root do NOT suppress the fallback
- // below: default gems (rexml, json, …) never live in a bundle path
- // — they ship with ruby in the DEFAULT/system gem homes — so an
- // env-`BUNDLE_PATH` project still needs the `gem env` homes to see
- // them (the explicit-roots feature briefly suppressed that
- // pre-existing fallback).
- if discovery.default_root_has_stores {
+ // When ANY bundle stores are found (vendor/bundle, env, or config),
+ // return ONLY those stores without appending `gem env` homes. A
+ // deployment-style install or an env/config-rooted store is the
+ // project's gem source, so the ambient gem homes must not become
+ // apply patch targets — patching a machine-wide global gem home
+ // (which `bundle exec` doesn't load) risks permission failures and
+ // the nuget-style global-cache hazard this otherwise avoids.
+ // Default gems (rexml, json, …) never in a bundle path also aren't
+ // in the manifest, so skipping the fallback here is safe.
+ if !discovery.stores.is_empty() {
return Ok(discovery.stores);
}You can send follow-ups to the cloud agent here.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit af59554. Configure here.
Mikola Lysenko (mikolalysenko)
enabled auto-merge (squash)
August 20, 2026 13:07
Wenxin Jiang (Wenxin-Jiang)
approved these changes
Aug 20, 2026
…every coexisting copy, contain config-sourced roots Post-merge audit follow-up on #218 (gem crawler flat-BUNDLE_PATH discovery), mirroring the #216 npm multi-copy precedent (0433bcb). 1. MULTI-COPY (most severe): bundler's scoped `<engine>/<abi>/gems` and flat `gems/` stores coexist under one root, each holding a REAL physical copy of the same gem@version — exactly the state #218's tests create. First-wins merging resolved the purl to ONE path, so apply patched one store and reported success while the other bundler loaded pristine (vulnerable) bytes. Fix mirrors #216: `find_all_packages_for_purls` now routes the release-variant ecosystems through an accumulating `merge_variant_copies` (reusing `push_path`, base-PURL keyed, precedence order kept) and apply's variant branch fans out per copy for gem — per-copy Applied events, `summary.applied` counts each copy, a copy matching no variant fails loudly. Rollback already carried every copy via `merge_qualified` and its per-path grouping; the new in-process suite pins both directions. PyPI/Maven deliberately keep the one-representative contract (the nuget `already_patched` double-patch regression from #216's second commit), as do all collapsing consumers (vendor/vex/setup/get/repair-vendor). scan --sync patches every copy too (it runs the real nested apply); scan's inventory stays purl-level, byte-identical to npm's crawl_all purl-dedup precedent. 2. PRECEDENCE: roots probed local-config > env > default vendor/bundle (bundler's real precedence; the old order was inverted), so first-representative consumers pick the copy bundler actually loads. 3. REGRESSION vs pre-#218: env/config roots no longer trip the `gem env` fallback early-return — only the historic project-local vendor/bundle probe keeps it. Default gems (rexml/json) live only in the DEFAULT/system gem homes, so an env-BUNDLE_PATH project gets those homes appended (deduped) again. 4. SECURITY: a config-sourced BUNDLE_PATH (committed .bundle/config = attacker-authored input, and a scan/apply WRITE-target root) must now, after ~ expansion and lexical normalization, stay contained in the project root — otherwise the root is skipped with a `gem_bundle_config_path_ignored` stderr warning naming the value. Windows rooted forms (`\evil`, `C:evil`) take the strict branch. Env-sourced BUNDLE_PATH stays trusted (user's own environment) but is normalized for dedup. `normalize_lexically` is hoisted from the composer crawler into utils::fs and shared. 5. `~` EXPANSION: a leading `~`/`~/...` in BUNDLE_PATH expands against home (bundler File.expand_path), env-injectable via the _with_env seams for hermetic tests. 6. BUNDLE_PATH__SYSTEM: `"true"` makes bundler ignore the recorded path — the config entry now parses as unset and the fallback finds the system gem homes. 7. TEST HERMETICITY: the six crawler_ruby_e2e tests that read ambient BUNDLE_PATH/BUNDLE_APP_CONFIG now route through the new `get_gem_paths_with_env` seam; a new e2e pins env-root + gem-env fallback coexistence (finding 3). 8. CLI_CONTRACT.md: the stale "gem inspects only <cwd>/vendor/bundle" claim replaced with the real root model, the containment policy, and the multi-copy behavior. TDD evidence (red -> green): dispatch-level `find_all_packages_for_purls_carries_every_gem_store_copy`, crawler `bundle_roots_probe_in_bundler_precedence_order`, and the new `in_process_gem_multicopy.rs` (real binary apply/rollback over a coexisting two-store tree) all failed on base — the flat copy stayed byte-for-byte VULNERABLE while apply reported success — and pass now. Live era-image proof (docker run --rm, socket-patch-test-gem-b1:gemx, bundler 1.17.3): the baked pre-fix binary on a coexist fixture reports status=success/applied=1 while the flat store's copy — loaded via real GEM_HOME resolution in the container — still evaluates VULNERABLE; the fixed binary reports applied=2 and both stores load FIXED. Gates: touched files rustfmt-clean; cargo clippy --workspace --all-features -D warnings clean; core --lib 2413 passed; cli --lib 430 passed; crawler_ruby_e2e 25, crawler_composer_e2e 32, crawlers_empty_paths_e2e 13; e2e_gem hermetic 8; in-process gem+npm multicopy suites; cli_gem_variant_mismatch_policy 6; docker_e2e_gem, docker_e2e_vendor_gem, docker_e2e_pypi, docker_e2e_maven all green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mikola Lysenko (mikolalysenko)
force-pushed
the
fix/gem-crawler-root-precedence
branch
from
August 20, 2026 14:21
af59554 to
7e20227
Compare
…lass-split fallback-home failure semantics Review-round fixes for the two verified Bugbot findings on #222. Finding 1 (Medium): the containment guard's `gem_bundle_config_path_ignored` was a bare eprintln inside the crawler — it never reached any --json `warnings[]` and printed under --silent, violating the repo-wide warning conventions (#219/#220 + the #223 omnibus silent fixes). The crawler is now print-free: the refusal is RECORDED on `BundleStoreDiscovery.skipped_config_path`, and a shared `config_path_ignored_warning(value)` builder feeds the CLI channels — scan pushes it onto the same run-level channel as the PnP layout refusals (JSON `warnings[]` on both the zero-package and >=1-package envelopes; one stderr line gated on !json && !silent), and apply carries it in its Envelope `warnings[]` plus one gated stderr line. Scoped like the crawl that hit it: local mode, gem in --ecosystems/manifest scope. Finding 2 (High, with nuance): with an env/config bundle root, get_gem_paths appends the gem-env fallback homes and the multi-copy fan-out patched EVERY copy with per-copy loud-fail — so a gem present in both the bundle store and a shared home (rvm @global, root-owned system dir) failed the WHOLE run on a permission failure or variant mismatch THERE, even though the copy bundler loads patched fine. Patching a shared home's copy is not itself wrong (plain apply always patched GEM_HOME when no store existed); the defect was failure semantics crossing store classes. Fix: discovery's store list is exposed (`RubyCrawler::discover_bundle_stores`, fs-probes only) and apply's gem fan-out classes each copy — bundle-path store copies stay PRIMARY (loud-fail, unchanged); gem-env fallback-home copies become BEST-EFFORT once at least one store copy applied: a variant mismatch or write failure there is a per-copy non-fatal Skipped event (`gem_fallback_home_skipped`, detail names the path and reason; gated stderr twin), never a run failure. Parity edge kept: with NO bundle-store copy (the historic fallback-only layout, and every --global run) the home copy IS primary and keeps loud-fail exactly as pre-#218 apply. TDD evidence (red -> green on the rebased tip): scan/apply --json missing the warnings[] entry and the --silent leak (3 tests, in_process_gem_config_warning.rs); mismatched-home-copy exit 1 and Failed-event-on-strict-refusal (in_process_gem_fallback_home.rs), with fallback-only loud parity and both-copies-patched pinned green throughout. CLI_CONTRACT.md documents the copy classes and the warning channels. Gates: touched files rustfmt-clean; cargo clippy --workspace --all-features -D warnings clean; core --lib 2535, cli --lib 436; crawlers::ruby 50, crawler_ruby_e2e 25; gem+npm multicopy 2+2; apply_network 11, apply_invariants 4, cli_gem_variant_mismatch_policy 6, cli_apply_silent 2, e2e_gem hermetic 8 (6 shown +cache selftests), e2e_scan, cli_scan_silent, docker_e2e_gem — all green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mikola Lysenko (mikolalysenko)
deleted the
fix/gem-crawler-root-precedence
branch
August 20, 2026 14:42
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Follow-up fix for the verified post-merge audit findings on #218 (gem crawler flat-
BUNDLE_PATHdiscovery, squash 3e51259). Mirrors the multi-copy machinery #216 (squash 0433bcb) built for npm rather than inventing a parallel mechanism.Audit findings and fixes
MULTI-COPY silent false "applied" (most severe). When the same
gem@versionexists in two discovered stores under one root — scopedvendor/bundle/<engine>/<abi>/gemsbeside flatvendor/bundle/gems, a real coexist state fix(gem): discover flat BUNDLE_PATH gem layouts — bundler-1 env-var installs were invisible to scan/get/apply #218's tests create — first-wins merging resolved the purl to ONE path:applypatched that copy and reportedsuccesswhile the bundler major that loads the other store ran pristine (vulnerable) bytes.Fix (the fix(apply): patch every on-disk copy of a duplicated package (multi-copy silent partial P0) #216 pattern, reused):
find_all_packages_for_purlsnow routes the release-variant ecosystems through an accumulatingmerge_variant_copies(built on the samepush_paththe npm merge uses; base-PURL keyed; store-precedence order preserved), and apply's variant branch fans out per copy for gem — per-copyappliedevents,summary.appliedcounts each copy (the signal a second copy exists), a failure on any copy fails the run, and a copy that matches no variant fails loudly instead of hiding behind a sibling's success. Rollback already carried every copy post-fix(apply): patch every on-disk copy of a duplicated package (multi-copy silent partial P0) #216 (merge_qualifiedaccumulates + per-path grouping); the new in-process suite pins both directions.Scope decisions: PyPI/Maven keep the one-representative contract — their crawlers resolve one install dir per version, and fanning their per-root aliases out is exactly the
already_patcheddouble-patch regression fix(apply): patch every on-disk copy of a duplicated package (multi-copy silent partial P0) #216's second commit reverted for nuget. All collapsing consumers (vendor/vex/setup/get/repair vendor) keep the first (highest-precedence) copy. On the audit's scan clause:scan --syncpatches every copy (it runs the real nestedapply::run), and scan's inventory stays purl-level — deliberately byte-identical to the npm precedent, whosecrawl_allkeeps the same purl dedup (check_package); emitting duplicate purl rows would diverge from npm and inflatescannedPackagescounts.PRECEDENCE. Root probe order was
vendor/bundle→ env →.bundle/config, inverting bundler's real precedence. Roots are now probed local config → env → defaultvendor/bundle(Bundler::Settingsorder), so wherever one representative is still chosen, it is the copy bundler actually loads. Pinned bybundle_roots_probe_in_bundler_precedence_order(RED on base).REGRESSION vs pre-fix(gem): discover flat BUNDLE_PATH gem layouts — bundler-1 env-var installs were invisible to scan/get/apply #218 (gem-env fallback suppressed). The new env/config roots tripped the pre-existing early-return in
get_gem_paths, so an env-BUNDLE_PATHproject lost thegem envfallback that surfaces DEFAULT/system gem homes (default gems like rexml/json never live in a bundle path). Restored: only the historic project-localvendor/bundleprobe keeps its early-return; env/config-rooted stores get the gem-env homes appended, deduped. New e2eget_gem_paths_env_root_still_includes_gempath_homesextends the "local includes every gempath home" pin to the env-root case.SECURITY: config-sourced roots contained-or-skipped. A repo-committed
.bundle/configBUNDLE_PATHis attacker-authored input that became a scan/apply WRITE-target root with no guard (absolute values verbatim,..unnormalized — a malicious clone could directapplyinto/usr/localor a sibling checkout). Policy now: after~expansion and lexical normalization, a CONFIG-sourced root must be contained within the project root, else it is skipped with a loud stderr warning naming the config value (gem_bundle_config_path_ignored— the crawler has no structured warning channel, so the established stderr path). Windows rooted forms (\evil,C:evil) take the strict branch —Path::joinwould otherwise substitute them into the base. ENV-sourcedBUNDLE_PATHstays trusted (the user's own environment) but is..-normalized for dedup.normalize_lexicallyis hoisted from the composer crawler'sinstall-pathguard intoutils::fsand shared (same containment posture as thesetup/gemplugin-index cleanup). Tests: absolute-outside skipped,../siblingskipped, contained relative (incl..//..detours) accepted, plus a pure containment contract.~expansion.resolve_bundle_pathtreated~/storeas relative (cwd.join); bundlerFile.expand_paths it against$HOME. A leading bare-~component now expands against home, injected through the_with_envseams so tests stay hermetic (~useris left as before — it needs bundler's passwd lookup).BUNDLE_PATH__SYSTEM.BUNDLE_PATH__SYSTEM: "true"makes bundler ignore the recorded path entirely; the config entry now parses as unset (exact-"true"coercion, either key order), and finding 3's fallback surfaces the system gem homes.Test hermeticity. The six
crawler_ruby_e2e.rstests that laid a Gemfile and asserted exact equality onget_gem_pathswhile reading ambientBUNDLE_PATH/BUNDLE_APP_CONFIGnow route through the new publicget_gem_paths_with_envseam — machines with bundler configured no longer go red. The new in-process suite additionally scrubsBUNDLE_*from the child env.CLI_CONTRACT.md. The stale "gem — the crawler inspects only the project rooted at
--cwd" claim replaced with the truthful model: envBUNDLE_PATHhonored (user-controlled, may point out of tree), config-sourced paths contained-or-skipped per finding 4, bundler-precedence root order, gem-env fallback semantics, and the multi-copy apply/rollback behavior.TDD evidence (red → green)
All three headline tests failed on base with the exact defect (flat copy byte-for-byte VULNERABLE while apply reported success / one path carried):
ecosystem_dispatch::find_all_packages_for_purls_carries_every_gem_store_copy(dispatch carries both copies, scoped first; collapsing wrapper keeps one)ruby_crawler::bundle_roots_probe_in_bundler_precedence_order(config → env → default)tests/in_process_gem_multicopy.rs(real binary: apply patches BOTH copies,summary.applied == 2, twoappliedevents; rollback restores BOTH)Live era-image proof (docker run --rm only)
socket-patch-test-gem-b1:gemx(aarch64, ruby 3.1.7, bundler 1.17.3), coexist fixture, oracle loaded via realGEM_HOMErubygems resolution inside the container:status: success,applied: 1— flat store loadsVULNERABLE, scoped loadsFIXED→ the silent partial, live.applied: 2— flatFIXED, scopedFIXED.Gates (verbatim results)
rustfmt --check: clean (cargo fmtrepo-wide was deliberately not applied — main carries pre-existing drift in unrelated test files)cargo clippy --workspace --all-features -- -D warnings: cleancargo test -p socket-patch-core --lib: 2413 passed; 0 failed; 4 ignoredcargo test -p socket-patch-cli --lib: 430 passed; 0 failed; 1 ignoredcrawler_ruby_e2e: 25 passed ·crawler_composer_e2e: 32 passed ·crawlers_empty_paths_e2e: 13 passede2e_gem(hermetic): 8 passed ·in_process_gem_multicopy: 2 ·in_process_npm_multicopy: 2 ·cli_gem_variant_mismatch_policy: 6 ·apply_invariants: 7 ·apply_network: 9 ·cli_apply_silent: 5 ·cli_rollback_silent: 5 ·e2e_scan: 5docker_e2e_gem: 2 ·docker_e2e_vendor_gem: 2 ·docker_e2e_pypi: 1 ·docker_e2e_maven: 5 — all greenDocumented follow-ups (deliberately NOT in this PR)
~/.bundle/configBUNDLE_PATH(unscopedbundle config set path, the default on bundler >= 2.1) still unsupported.ruby_crawlerandsetup/gem— suggest autils::bundlerextraction.seen/seen_rootsdouble-dedup readability in the store discovery loop.🤖 Generated with Claude Code
Note
High Risk
Changes which on-disk gem trees
applywrites and adds security containment for attacker-controlled.bundle/configpaths; incorrect behavior could leave vulnerable copies unpatched or allow writes outside the project.Overview
Fixes a silent partial apply for gems: when the same
gem@versionlives in both Bundler’s scoped and flatgems/stores, discovery now keeps every physical path (merge_variant_copiesin apply resolution), andapplypatches each gem copy (mirroring npm multi-copy), with per-copy JSON events and failures if any copy is left unmatched or unpatched. PyPI/Maven and single-path commands still use one representative install dir.The Ruby crawler now probes install roots in Bundler precedence (
.bundle/config→ envBUNDLE_PATH→vendor/bundle), honors env~expansion, treatsBUNDLE_PATH__SYSTEM: "trueas “no config path,” and only uses thevendor/bundleearly-return so env/config roots still getgem envhomes for default gems. Committed.bundle/configBUNDLE_PATHvalues must stay inside the project root after lexical normalization; out-of-tree roots are skipped with a stderr warning—envBUNDLE_PATHremains trusted. Sharednormalize_lexicallymoves toutils::fs(composer + ruby). CLI_CONTRACT.md documents the gem discovery and multi-copy behavior.Reviewed by Cursor Bugbot for commit af59554. Configure here.
Review round (Bugbot findings — both verified, both fixed in 460b1bf)
Branch note: rebased onto post-omnibus main (
a2f07e0, #223) with the ruby_crawler test interleave resolved; the review-round commit sits on that tip.1. Config-skip warning was stderr-only and ignored
--silent(Medium — fixed).gem_bundle_config_path_ignoredwas a bareeprintln!insidediscover_bundle_stores_with_env— invisible to every--jsonconsumer and printed under--silent, violating the repo-wide warning conventions (#219/#220 + #223's silent fixes). The crawler is now print-free: the refusal is recorded onBundleStoreDiscovery.skipped_config_path, and a sharedconfig_path_ignored_warning(value)builder feeds the CLI channels — scan pushes it onto the same run-level channel as the PnP layout refusals (JSONwarnings[]on both the zero-package and ≥1-package envelopes; one stderr line gated on!json && !silent), and apply carries it in its envelopewarnings[]plus one gated stderr line. Scoped like the crawl that hit it: local mode, gem in--ecosystems/manifest scope. RED-first pins:tests/in_process_gem_config_warning.rs(scan JSON, apply JSON, silent gate with loud control — all three failed on the pre-fix tip).2. Fallback-home copies made shared-home failures fail the run (High — fixed, with one nuance kept deliberate). With an env/config bundle root,
get_gem_pathsappends thegem envfallback homes and the fan-out patched every copy with per-copy loud-fail — so a gem present in both the bundle store and a shared home (rvm@global, root-owned system dir) failed the whole run on a permission failure or variant mismatch there, even though the copy bundler loads patched fine. Patching the shared copy is not itself wrong (plain apply always patched GEM_HOME when no store existed); the defect was failure semantics crossing store classes. Fix: discovery's store list is exposed (RubyCrawler::discover_bundle_stores, fs probes only) and apply's gem fan-out classes each copy — bundle-path store copies stay primary (loud-fail, unchanged); gem-env fallback-home copies become best-effort once at least one store copy applied: a variant mismatch or write failure there is a per-copy non-fatalskippedevent (errorCode: gem_fallback_home_skipped, detail names the path and reason; gated stderr twin), never a run failure. Parity edge kept: with no bundle-store copy (the historic fallback-only layout, and every--globalrun) the home copy IS primary and keeps loud-fail exactly as pre-#218 apply. RED-first pins:tests/in_process_gem_fallback_home.rs— mismatched-home nonfatal (was exit 1), strict-refusal-on-home nonfatal (was Failed event), fallback-only loud parity, both-copies-patched. CLI_CONTRACT.md documents the copy classes and warning channels.Review-round gates: touched-files rustfmt clean;
cargo clippy --workspace --all-features -- -D warningsclean; core--lib2535, cli--lib436;crawlers::ruby50,crawler_ruby_e2e25; gem+npm multicopy 2+2;apply_network11,apply_invariants4,cli_gem_variant_mismatch_policy6,cli_apply_silent2,e2e_gemhermetic 8,e2e_scan,cli_scan_silent,docker_e2e_gem— all green.