Repository navigation
Hosted gem scan of a Gemfile with no lock still pins shared-gem-home versions: gem "x", "~> 2.0" becomes the older patched "1.0.0", and a gem the project never declared is appended as a new dependency #1125
Description
[agent] Found by the scheduled Bundler (RubyGems) bug-hunt routine (ledger #316).
Summary
#1060 (the fix for #1055) skips a crawled gem version that the project's lock doesn't resolve (redirect_gem_version_not_locked). The check only runs when a lock exists, though: let locked = files.get(lock_name).map(...) is None for a project with a Gemfile / gems.rb and no Gemfile.lock / gems.locked, and if let Some(specs) = &locked then skips the guard completely. Library repos usually don't commit their lock, so this is the normal state of a fresh clone before bundle install.
In that state, a hosted scan on a machine whose shared gem home holds another project's copy of a patched gem does what #1055 described, and adds one more failure:
- The constraint is overwritten.
gem "vuln-gem", "~> 2.0"is rewritten tosource "<patch registry>" do gem "vuln-gem", "1.0.0" end. The nextbundle installinstalls 1.0.0, where the untouched Gemfile installs 2.0.0. - An unrelated gem is added. A Gemfile that never mentions
vuln-gem(gem "tiny-dep"only) gets a newsource … do gem "vuln-gem", "1.0.0" endblock appended through the "genuinely undeclared (a transitive dep)" branch. The nextbundle installaddsvuln-gem (= 1.0.0)!to the project's dependencies. Without the scan the project installs onlytiny-dep.
Both runs exit 0 and report redirected: 1 / action: "pinned". The only warning is redirect_gem_stale_install about the shared-home copy, which doesn't say that the project's declared dependencies changed. rollback / remove can't undo it either: a Gemfile-only pin isn't a reference without a lock (CLI_CONTRACT.md "Lockless pins"), so the user has to edit the Gemfile by hand.
Impact
- The project's dependencies change silently: it downgrades below the user's constraint, or it picks up a gem it never used.
- With
--vex, the scan exits 1 because of the stale warning, but plainscan --mode hosted/ CI runs exit 0 and leave the rewritten Gemfile for the user to commit.
Repro (real Bundler; hermetic mock upstream + patch registry, the e2e_redirect_gem_build.rs fixtures)
# upstream mock serves tiny-dep 1.0.0 and vuln-gem 1.0.0 + 2.0.0; the patch registry serves patched vuln-gem 1.0.0
export GEM_HOME=$TMP/shared-home GEM_PATH=$TMP/shared-home:<system gem dir>
# project A (another project on the same machine) puts vuln-gem 1.0.0 into the shared home
(cd a && printf 'source "<upstream>"\ngem "vuln-gem", "1.0.0"\n' > Gemfile && bundle install)
# project B: fresh clone, Gemfile only, no lock
cd b && printf 'source "<upstream>"\n\ngem "vuln-gem", "~> 2.0"\n' > Gemfile
socket-patch scan --mode hosted --json --yes --api-url <mock> --org test-org --api-token fake
# exit 0, redirect.patches = [{purl: pkg:gem/vuln-gem@1.0.0, action: pinned}]
cat Gemfile
# source "<upstream>"
# source "<patch registry>/" do
# gem "vuln-gem", "1.0.0"
# end
bundle install # → Gemfile.lock: vuln-gem (1.0.0) / DEPENDENCIES vuln-gem (= 1.0.0)!
# control: the same Gemfile without the scan → "Installing vuln-gem 2.0.0"
# project C: Gemfile declares only `gem "tiny-dep"`, no lock
socket-patch scan --mode hosted … # exit 0; appends `source "<patch registry>" do gem "vuln-gem", "1.0.0" end`
bundle install # DEPENDENCIES gains `vuln-gem (= 1.0.0)!`; the control installs only tiny-dep
Expected vs actual
- Expected (CLI_CONTRACT.md,
redirect_gem_version_not_locked): hosted mode "re-points the version the lock resolves; it never picks a version", and "the user's declared constraint and the locked version are never overwritten". With no lock there's nothing resolved to re-point. The scan should skip these gems with a warning (the way cargo refuses a lockless project withredirect_cargo_lockless_dependents), or at least never append a gem the manifest doesn't declare and never replace a declared requirement that the crawled version doesn't satisfy. - Actual: the crawled shared-home version is pinned as an exact top-level requirement in both shapes, and the scan reports success.
Matrix
| OS | Ruby | Bundler | Shape | Result |
|---|---|---|---|---|
| Linux | 3.3.6 | 2.5.22 | lockless ~> 2.0 declaration |
fails (downgrade to 1.0.0) |
| Linux | 3.3.6 | 2.5.22 | lockless, gem not declared | fails (gem appended and installed) |
| Linux | 3.3.6 | 4.0.18 | both shapes | fails (same, CHECKSUMS lock written by the install) |
| Linux | 3.3.6 | 4.0.18 | same shapes with a lock (the #1055 control) | pass (redirect_gem_version_not_locked, repo e2e suite) |
| macOS / Windows | — | — | — | untested; the logic is OS-independent |
Main b96a785, which includes #1060. Both shapes were reproduced twice, on two Bundler versions. I didn't bisect: the lockless path is outside the #1060 change, so this predates it.
Suspect code
crates/socket-patch-core/src/patch/redirect/mod.rs:6098:lockedisNonewithout a lock.crates/socket-patch-core/src/patch/redirect/mod.rs:6205:if let Some(specs) = &locked, so the Hosted gem scan pins a version that only another project installed into the shared gem home, rewritinggem "x", "~> 1.0"to the older patched"0.8.1", so the prescribedbundle installdowngrades the project's locked gem #1055 guard is skipped when there's no lock.crates/socket-patch-core/src/patch/redirect/mod.rs:6387(in-place rewrite to the exact version) and:6445(the "genuinely undeclared (a transitive dep): append a block" branch, which without a lock can't tell a transitive dep from an unrelated shared-home gem).
No probe runs: found and confirmed on Linux only.
Activity
bundler: run 32 ledger (#1125, #1092/#952 comments)
mikolalysenko commentedon Oct 8, 2026
[agent] Triage: priority:p1 (RubyGems/Bundler). Confirmed in code on main: the #1060 guard that emits
redirect_gem_version_not_lockedsits insideif let Some(specs) = &locked(crates/socket-patch-core/src/patch/redirect/mod.rs:6205). With noGemfile.lock/gems.locked,lockedisNone, so a crawled shared-home version falls through to the exact-pin rewrite and to the append-a-block branch. No open or merged PR covers it. It isn't a duplicate: #1055 was the locked case, and #1060 fixed only that. It isn't clustered with #1098 / #1109 either, because those are about VEX and the stale-install guard judging an unused gem-home copy, which is a different code path.Generated by Claude Code
mikolalysenko commentedon Oct 9, 2026
v5 release blocker (P1). A first hosted Gemfile scan must not add unrelated cached gems or downgrade declared ranges when no lock exists. Require a resolve/lock step with a clear remedy.
This follows the maintainer's release scope: one normally completing CLI instance, prioritizing valid-lockfile patch/install behavior, compatibility, and actionable CLI UX.
mikolalysenko commentedon Oct 9, 2026
[agent] Claiming for v5 blocker burn-down (shared root cause: #1060 version-not-locked guard only runs when a Gemfile.lock exists; lockless Gemfile falls through to pin/append shared-home versions). Branch: agent/v5-gem-lockless-pin. Claim-ID: 2026-10-09T16:41:34Z-6810f9
Skip hosted gem pins when no lock exists