[agent] Found by the scheduled Bundler (RubyGems) bug-hunt routine (ledger #316).
Summary
Hosted mode accepts a gem declared inside a group … do block. It wraps the declaration in a source "<patch registry>" do … end block inside the group, and the frozen install is patched. Vendored mode refuses an indented declaration with gemfile_declaration_not_editable, which is fail-closed by design.
Taking over such a gem with scan --mode vendored or get <purl> --mode vendored goes wrong in this order:
- The takeover restores the hosted pin to upstream rubygems.org (
Gemfile + Gemfile.lock, CHECKSUMS back to the upstream sha256) and writes that restore.
- The gem vendored backend then reaches
plan_gemfile_edit and refuses the indented declaration with gemfile_declaration_not_editable.
The run exits 1 / partial_failure, but the restore has already been written. The gem is now neither hosted nor vendored, and the next frozen bundle install installs and loads the unpatched upstream bytes. The --dry-run of the same command gives no warning: it exits 0 with vendor.patches[].action: "would_vendor", with no vendor_would_revert_redirect and no refusal preview.
socket-patch vendor (the eject path) on the same project does the right thing. It reports eject_rolled_back + gemfile_declaration_not_editable, and the hosted pin stays byte-identical.
Impact
group :development do … end / group :test do … end is one of the most common Gemfile shapes. A user who switches from hosted to vendored with scan --mode vendored loses an active security patch. The working tree now shows a clean upstream Gemfile / Gemfile.lock diff that is easy to commit, and CI's next BUNDLE_FROZEN=true bundle install installs the vulnerable gem with exit 0.
Repro (Linux, Ruby 3.3.6, Bundler 4.0.17, main 045d7ec)
A local mock of the patch API and patch registry (the run-13 mock from the ledger: it serves pkg:gem/colorize@0.8.1 with a rebuilt patched .gem) runs on $MOCK. Real rubygems.org is the upstream.
SPA="--api-url $MOCK --patch-server-url $MOCK --org org --api-token fake"
mkdir proj && cd proj
printf 'source "https://rubygems.org"\n\ngem "rake"\n\ngroup :development do\n gem "colorize", "~> 0.8.1"\nend\n' > Gemfile
printf 'vendor/bundle/\n' > .gitignore
bundle config set --local path vendor/bundle && bundle lock
git init -q . && git add -A && git commit -qm init
socket-patch scan --mode hosted --json --yes $SPA # exit 0, redirected 1 (Gemfile, Gemfile.lock)
bundle install && git add -A && git commit -qm hosted # installs the patched gem
socket-patch scan --mode vendored --json --yes --dry-run $SPA # exit 0, "would_vendor"
socket-patch scan --mode vendored --json --yes $SPA # exit 1, partial_failure:
# skipped vendor_takeover_reverted_redirect "pkg:gem/colorize@0.8.1 was hosted; restored its upstream registry entry (Gemfile, Gemfile.lock) before vendoring (mode takeover)"
# failed gemfile_declaration_not_editable "the `gem \"colorize\"` declaration is indented (inside a group/conditional block)"
git diff --stat # Gemfile and Gemfile.lock: source block, patch-registry GEM section and patched CHECKSUMS gone
grep -c patch-registry Gemfile.lock # 0
grep -c socket/vendor Gemfile.lock # 0
# fresh checkout of the committed files:
BUNDLE_FROZEN=true bundle install # exit 0
bundle exec ruby -e 'puts File.read(Gem.loaded_specs["colorize"].full_gem_path + "/lib/colorize.rb")[-20..]' # upstream bytes, no patch marker
get pkg:gem/colorize@0.8.1 --mode vendored behaves the same way.
Expected vs actual
- Expected: a takeover that vendored mode will refuse leaves the hosted wiring in place. CLI_CONTRACT.md, "Takeover reconciliation", describes the Bun preflight in these terms: "a hosted purl on a lock the vendored backend refuses … is reported
failed <code> with the hosted wiring … byte-untouched (exit 1 / partial_failure): the package stays hosted-patched instead of being un-hosted and then refused", and --dry-run "previews that same failed code (exit-code parity with the wet run …)". The gem backend's own doc comment on gem_manifest_refusal (crates/socket-patch-core/src/vendor/gem.rs:144-146) says the takeover asks first "so a refused gem keeps its hosted wiring instead of ending up unpatched in both modes". The vendor eject path already rolls back (eject_rolled_back).
- Actual: the pre-restore gem gate at
crates/socket-patch-cli/src/commands/vendor.rs:2398-2410 only checks the manifest/lock pair (gem_manifest_refusal: a gems.rb twin or BUNDLE_GEMFILE). The Gemfile declaration gate (plan_gemfile_edit → gemfile_declaration_not_editable, gem.rs:409-427) only runs inside the backend, after restore_upstream (vendor.rs:2458) has written. Nothing puts the hosted pin back, and the dry run does not preview the refusal.
| OS |
Ruby / Bundler |
scan --mode vendored |
get --mode vendored |
vendor (eject) |
| Linux |
3.3.6 / 4.0.17 (CHECKSUMS) |
un-hosted + refused, frozen install unpatched (2/2 runs) |
un-hosted + refused, unpatched |
rolled back, still patched (correct) |
| Linux |
3.3.6 / 2.6.9 (--add-checksums) |
un-hosted + refused, unpatched |
not run |
not run |
| Linux |
3.3.6 / 2.4.22 (no CHECKSUMS, converged) |
un-hosted + refused, unpatched |
not run |
not run |
The logic is OS-independent (the gate is pure Gemfile text, and no bundler process is spawned on this path), so no macOS/Windows probe was run.
First bad version: on this path, the released v4.0.0 stops earlier with no_local_source and leaves the hosted pin intact, so the un-hosting is new in v5 main (045d7ec).
Side note: with a top-level declaration, the same takeover also un-hosts first and then fails vendor_prebuilt_required when the service has no prebuilt artifact (my mock serves none). That's the same "restore written before the vendor step can fail" shape, as in #659 / #688 for npm. The group-block case above is deterministic and gem-specific: a pure Gemfile-text check that can run before the restore, on the Gemfile text the restore would produce.
Suspect code
[agent] Found by the scheduled Bundler (RubyGems) bug-hunt routine (ledger #316).
Summary
Hosted mode accepts a gem declared inside a
group … doblock. It wraps the declaration in asource "<patch registry>" do … endblock inside the group, and the frozen install is patched. Vendored mode refuses an indented declaration withgemfile_declaration_not_editable, which is fail-closed by design.Taking over such a gem with
scan --mode vendoredorget <purl> --mode vendoredgoes wrong in this order:Gemfile+Gemfile.lock, CHECKSUMS back to the upstream sha256) and writes that restore.plan_gemfile_editand refuses the indented declaration withgemfile_declaration_not_editable.The run exits 1 /
partial_failure, but the restore has already been written. The gem is now neither hosted nor vendored, and the next frozenbundle installinstalls and loads the unpatched upstream bytes. The--dry-runof the same command gives no warning: it exits 0 withvendor.patches[].action: "would_vendor", with novendor_would_revert_redirectand no refusal preview.socket-patch vendor(the eject path) on the same project does the right thing. It reportseject_rolled_back+gemfile_declaration_not_editable, and the hosted pin stays byte-identical.Impact
group :development do … end/group :test do … endis one of the most common Gemfile shapes. A user who switches from hosted to vendored withscan --mode vendoredloses an active security patch. The working tree now shows a clean upstreamGemfile/Gemfile.lockdiff that is easy to commit, and CI's nextBUNDLE_FROZEN=true bundle installinstalls the vulnerable gem with exit 0.Repro (Linux, Ruby 3.3.6, Bundler 4.0.17, main
045d7ec)A local mock of the patch API and patch registry (the run-13 mock from the ledger: it serves
pkg:gem/colorize@0.8.1with a rebuilt patched.gem) runs on$MOCK. Real rubygems.org is the upstream.get pkg:gem/colorize@0.8.1 --mode vendoredbehaves the same way.Expected vs actual
failed <code>with the hosted wiring … byte-untouched (exit 1 /partial_failure): the package stays hosted-patched instead of being un-hosted and then refused", and--dry-run"previews that samefailedcode (exit-code parity with the wet run …)". The gem backend's own doc comment ongem_manifest_refusal(crates/socket-patch-core/src/vendor/gem.rs:144-146) says the takeover asks first "so a refused gem keeps its hosted wiring instead of ending up unpatched in both modes". Thevendoreject path already rolls back (eject_rolled_back).crates/socket-patch-cli/src/commands/vendor.rs:2398-2410only checks the manifest/lock pair (gem_manifest_refusal: agems.rbtwin orBUNDLE_GEMFILE). The Gemfile declaration gate (plan_gemfile_edit→gemfile_declaration_not_editable,gem.rs:409-427) only runs inside the backend, afterrestore_upstream(vendor.rs:2458) has written. Nothing puts the hosted pin back, and the dry run does not preview the refusal.scan --mode vendoredget --mode vendoredvendor(eject)--add-checksums)The logic is OS-independent (the gate is pure Gemfile text, and no bundler process is spawned on this path), so no macOS/Windows probe was run.
First bad version: on this path, the released v4.0.0 stops earlier with
no_local_sourceand leaves the hosted pin intact, so the un-hosting is new in v5 main (045d7ec).Side note: with a top-level declaration, the same takeover also un-hosts first and then fails
vendor_prebuilt_requiredwhen the service has no prebuilt artifact (my mock serves none). That's the same "restore written before the vendor step can fail" shape, as in #659 / #688 for npm. The group-block case above is deterministic and gem-specific: a pure Gemfile-text check that can run before the restore, on the Gemfile text the restore would produce.Suspect code
crates/socket-patch-cli/src/commands/vendor.rs:2398-2410: the gem pre-restore gate covers onlygem_manifest_refusal.crates/socket-patch-core/src/vendor/gem.rs:409-427(gem_edits→plan_gemfile_edit/refuse_append_of_direct_dependency): the declaration refusal that needs to run in the preflight, against the restored Gemfile text.vendor.rs:1399-1416(the eject snapshot rollback,eject_rolled_back), and npm lockfileVersion 1: scan/get --mode vendored un-host a hosted patch and then refuse to vendor it, so the project silently goes back to unpatched (vendor eject rolls back correctly) #659 / npm vendored refuses a registry package with vendor_workspace_member whenever a local file: directory (or workspace member) has the same name@version, and the hosted→vendored takeover then un-hosts it, leaving it unpatched #688 / Hosted → vendored takeover on yarn berry reverts the hosted redirect before a per-package vendor refusal, leaving the package unpatched in both modes #369 (same class for npm and yarn berry).