RC RANDOM CHAOS

Cooldown does not fix the resolver

Bundler 4.0.13's cooldown filter refuses to resolve gem versions newer than N days, closing the supply-chain window while other defenses catch a bad release.

· 5 min read
Cooldown does not fix the resolver

Bundler 4.0.13 ships a filter called cooldown that refuses to resolve to any gem version until it has been public for at least N days. The attack it targets is narrow: an account gets compromised, a malicious version ships, and every bundle install in the minutes that follow resolves straight to it. Holding new releases out of reach for a few days closes that window without anyone having to catch the attack in real time. The bet is that someone else does the catching during the wait.

That framing is worth keeping in mind, because cooldown does not verify anything. A gem that ages past your window and is still malicious resolves exactly as before. What cooldown buys is time for the registry’s other defenses, and for the rest of the ecosystem, to notice a bad version before your resolver reaches it. It is an opt-in delay on top of an unchanged resolver, not a replacement for one.

How it works

Cooldown reads the per-version created_at timestamp that rubygems.org’s v2 compact index now serves. During resolution, any version newer than the window is passed over in favor of one that has aged past it. The feature is unset by default, so a project that does nothing keeps resolving to the newest versions.

The timestamp dependency is the important caveat. A version whose source does not expose created_at - older gem servers, historical entries from before the v2 cutover, private registries still on the v1 format - is treated as outside the window and stays resolvable. Cooldown only holds back versions it can prove are too new, and it never blocks resolution silently. That cuts both ways: it will not filter a malicious version served from a registry that publishes no timestamps, but it also will not wedge your build against one.

Resolution is the only place it acts. An existing Gemfile.lock is honored as-is, so adding a cooldown never disturbs versions you have already locked. It takes effect when there is no lockfile yet (bundle install) or when you re-resolve against one (bundle update).

Setup

If you are on an earlier Bundler, update in place and pin the same version so the whole team moves together:

$ gem update --system # or: gem install bundler -v 4.0.13
$ bundle update --bundler=4.0.13

Then declare the window on your source. Committing it to the Gemfile is the right default for most teams, because every developer and CI run then enforces the same policy with no extra setup:

source "https://rubygems.org", cooldown: 7
gem "rails"
gem "puma"

The same value can be applied across every source through configuration or the environment instead:

$ bundle config set cooldown 7 # this project, stored in .bundle/config
$ bundle config set --global cooldown 7 # every project for this user
$ BUNDLE_COOLDOWN=7 bundle install # no lockfile yet, e.g. a fresh CI checkout

Or pass --cooldown for a one-off run of install, update, add, or outdated. When more than one is present they resolve in a fixed order: command-line flag beats the configuration setting, which beats a per-source cooldown: in the Gemfile. The value is always a non-negative integer number of days; a string, float, negative number, or array is rejected with an InvalidOption error.

Mixing sources and the escape hatch

The flag and the setting apply uniformly to every source, so the per-source keyword is what you reach for when policy differs by registry - cooling down public gems while trusting an internal one you operate:

source "https://rubygems.org", cooldown: 7
source "https://gems.internal.example.com", cooldown: 0 do
  gem "internal-tool"
end

cooldown: 0 keeps the private source permanently exempt while public gems still age. One thing to watch: a command-line --cooldown N overrides every source for that run, including the exempt one.

Passing 0 disables cooldown for a run, which is the case that matters most. When a fix lands for an actively exploited 0-day, or a disclosure tells you to upgrade now, the freshest release is exactly the one you want. --cooldown 0 lets you take it without removing the policy for everyone else:

$ bundle install --cooldown 0
$ BUNDLE_COOLDOWN=0 bundle update rails

You will also hit this when every candidate version of a gem is still inside the window. Rather than quietly fall back to a too-new release, Bundler stops, reports how many versions the cooldown excluded, and points you at --cooldown 0 to proceed. And bundle outdated is cooldown-aware, annotating versions that are newer but still waiting with the days left before they resolve:

$ bundle outdated --cooldown 7
Gem Current Latest Requested Groups
aws-partitions 1.1251.0 1.1256.0 (cooldown 3d) = 1.1251.0 default

That keeps “up to date” distinct from “deliberately waiting,” which is the distinction that makes a cooldown policy legible to the person reading the diff.

Where it sits

Cooldown only works because rubygems.org now publishes created_at through its v2 compact index, delivered via a dual-write migration and a flagged cutover (v2 is an internal format version, not a public API change). The client-side filter is thin on top of that groundwork.

It is one layer among several the registry is building. Gem contents are validated at push time. Logins are checked against Have I Been Pwned so compromised passwords cannot be reused. A dedicated team runs AI-assisted vulnerability scanning against the most critical gems, backed by Alpha Omega and Anthropic. Trusted publishing and mandatory 2FA already raise the bar for who can push a release at all. Cooldown covers the gap between a bad version shipping and those defenses catching it, and it only pays off if you are resolving against a registry where that catching actually happens.

Share

Keep Reading

Latest on the Wire

Full wire →

New signal daily · RSS

Stay in the loop

New writing delivered when it's ready. No schedule, no spam.