<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:thoughtbot="https://thoughtbot.com/feeds/">
  <title>Giant Robots Smashing Into Other Giant Robots</title>
  <subtitle>Written by thoughtbot, your expert partner for design and development.
</subtitle>
  <id>https://robots.thoughtbot.com/</id>
  <link href="https://thoughtbot.com/blog"/>
  <link href="https://feed.thoughtbot.com" rel="self"/>
  <updated>2026-04-21T00:00:00+00:00</updated>
  <author>
    <name>thoughtbot</name>
  </author>
<entry>
  <title>Let's enable MFA for all Ruby gems</title>
  <link rel="alternate" href="https://thoughtbot.com/blog/lets-enable-mfa-for-all-ruby-gems"/>
  <author>
    <name>Matheus Richard</name>
  </author>
  <id>https://thoughtbot.com/blog/lets-enable-mfa-for-all-ruby-gems</id>
  <published>2026-04-21T00:00:00+00:00</published>
  <updated>2026-04-20T15:02:27Z</updated>
  <content type="html">&lt;p&gt;A few weeks ago, Axios, the popular HTTP client for JavaScript, &lt;a href="https://socket.dev/blog/axios-npm-package-compromised"&gt;suffered a
supply chain attack on
NPM&lt;/a&gt;. An attacker
compromised the lead maintainer’s NPM account through social engineering and
published two backdoored versions that delivered a cross-platform remote access
trojan (RAT) to macOS, Windows, and Linux systems. Axios has over 100 million
weekly downloads. The blast radius was enormous.&lt;/p&gt;

&lt;p&gt;Not long before that, LiteLLM, a popular Python AI gateway, &lt;a href="https://docs.litellm.ai/blog/security-update-march-2026"&gt;had a similar
incident on
PyPI&lt;/a&gt;. Compromised
credentials were used to push malicious packages that harvested environment
variables, SSH keys, cloud credentials, and database passwords.&lt;/p&gt;

&lt;p&gt;Both attacks followed the same playbook: gain access to a maintainer’s account,
then push a new version with malicious code &lt;em&gt;outside&lt;/em&gt; of the normal release
process. No code review. No CI. Just a direct publish to the package registry.&lt;/p&gt;

&lt;p&gt;Ruby is equally vulnerable to this type of attack&lt;/p&gt;
&lt;h2 id="rubygems-is-not-immune"&gt;
  
    RubyGems is not immune
  
&lt;/h2&gt;

&lt;p&gt;RubyGems hasn’t had a major attack like this &lt;em&gt;yet&lt;/em&gt;, but we should be proactive
in securing the ecosystem before it happens. Nate Berkopec (maintainer of Puma
and a longtime voice in the Ruby community) &lt;a href="https://x.com/nateberkopec/status/2039805831399788876"&gt;estimates that 75% of the gems on
RubyGems are vulnerable to this type of
attack&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The fix is straightforward: gems can &lt;a href="https://guides.rubygems.org/mfa-requirement-opt-in/"&gt;require multi-factor authentication
(MFA)&lt;/a&gt; for all pushes by
adding a single line to their gemspec:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"rubygems_mfa_required"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"true"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;After releasing a new version with this set, RubyGems.org will reject any &lt;code&gt;gem push&lt;/code&gt; from an account that
doesn’t have MFA enabled. Even if an attacker compromises a maintainer’s
password or API key, they still can’t publish a new version without the second
factor. It doesn’t make the gem invulnerable, but it raises the bar
significantly.&lt;/p&gt;

&lt;aside class="info"&gt;
  &lt;p&gt;If you publish your gem from CI, you might be wondering how MFA fits into an automated release workflow. The answer is &lt;a href="https://guides.rubygems.org/trusted-publishing/"&gt;Trusted Publishing&lt;/a&gt;, which lets RubyGems.org authenticate your CI provider via OIDC instead of a long-lived API key. MFA and Trusted Publishing are complementary: MFA protects interactive pushes, and Trusted Publishing removes the need for shareable credentials in CI.&lt;/p&gt;
&lt;/aside&gt;
&lt;h2 id="what-you-can-do"&gt;
  
    What you can do
  
&lt;/h2&gt;

&lt;p&gt;The good thing about open source is that we can all help make it more secure.
Here’s what I propose:&lt;/p&gt;
&lt;h3 id="1-audit-your-app39s-gems"&gt;
  
    1. Audit your app’s gems
  
&lt;/h3&gt;

&lt;p&gt;Nate wrote an &lt;a href="https://gist.github.com/nateberkopec/ab12bbc2ddf39868c4633422904475af"&gt;audit
script&lt;/a&gt;
you can run in your project to see which gems in your Gemfile don’t require MFA
on push. Run it. The results might surprise you.&lt;/p&gt;
&lt;h3 id="2-open-prs-on-gems-you-use"&gt;
  
    2. Open PRs on gems you use
  
&lt;/h3&gt;

&lt;p&gt;Pick one or a few gems that don’t require MFA and open a PR adding the line
above to their gemspec. The change is a one-liner and the PR description mostly
writes itself. You can link to this post or to the Axios incident and explain
why it matters.&lt;/p&gt;
&lt;h3 id="3-enable-mfa-on-gems-you-maintain"&gt;
  
    3. Enable MFA on gems you maintain
  
&lt;/h3&gt;

&lt;p&gt;If you maintain any gems, add &lt;code&gt;rubygems_mfa_required&lt;/code&gt; to your gemspec and make
sure all owners on RubyGems.org have MFA enabled on their accounts.&lt;/p&gt;
&lt;h2 id="it-takes-a-community"&gt;
  
    It takes a community
  
&lt;/h2&gt;

&lt;p&gt;I’ve been opening PRs on &lt;a href="https://github.com/thoughtbot/factory_bot/pull/1814"&gt;thoughtbot
gems&lt;/a&gt; and &lt;a href="https://github.com/excid3/noticed/pull/578"&gt;other open
source projects&lt;/a&gt;, and I encourage
you to do the same. Maintainers have been receptive, so these PRs tend to get
merged quickly. I also got &lt;a href="https://github.com/ruby/rubygems/pull/9487"&gt;a PR merged on
Bundler&lt;/a&gt; adding a commented-out
&lt;code&gt;rubygems_mfa_required&lt;/code&gt; line to the &lt;code&gt;bundle gem&lt;/code&gt; template to nudge authors of
new gems to enable this.&lt;/p&gt;

&lt;p&gt;If we all do our part, we can make the Ruby ecosystem safer for everyone. Let’s
get to work!&lt;/p&gt;

&lt;aside class="related-articles"&gt;&lt;h2&gt;If you enjoyed this post, you might also like:&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://thoughtbot.com/blog/the-journey-to-ruby-1-9"&gt;The Journey to Ruby 1.9 &lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://thoughtbot.com/blog/fetching-source-index-for-http-rubygems-org"&gt;Fetching Source Index for http://rubygems.org/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://thoughtbot.com/blog/this-week-in-open-source-3"&gt;This week in open source&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;&lt;/aside&gt;
</content>
  <summary>Supply chain attacks are getting more common. RubyGems might be next. Here's how to help the ecosystem be safer.</summary>
  <thoughtbot:auto_social_share>true</thoughtbot:auto_social_share>
</entry>
<entry>
  <title>PII filtering for RubyLLM with Top Secret</title>
  <link rel="alternate" href="https://thoughtbot.com/blog/ruby-llm-top-secret"/>
  <author>
    <name>Steve Polito</name>
  </author>
  <id>https://thoughtbot.com/blog/ruby-llm-top-secret</id>
  <published>2026-04-17T00:00:00+00:00</published>
  <updated>2026-04-16T18:23:15Z</updated>
  <content type="html">&lt;p&gt;If you’re building LLM-powered features in a regulated industry, sending
unfiltered PII to a third-party provider isn’t just risky, it may violate
compliance requirements like HIPAA or GDPR.&lt;/p&gt;

&lt;p&gt;That’s why we originally built &lt;a href="https://thoughtbot.com/blog/top-secret"&gt;Top Secret&lt;/a&gt;. However, when we first
released it, &lt;a href="https://rubyllm.com"&gt;RubyLLM&lt;/a&gt; was still in its early days, and I found I was
working with provider APIs directly, such as &lt;a href="https://github.com/alexrudall/ruby-openai"&gt;Ruby OpenAI&lt;/a&gt; or
&lt;a href="https://github.com/openai/openai-ruby"&gt;OpenAI Ruby&lt;/a&gt;. This meant I needed to manually orchestrate the
filtering and restoration process, which looked something like this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"openai"&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"top_secret"&lt;/span&gt;

&lt;span class="n"&gt;openai&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;OpenAI&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;

&lt;span class="n"&gt;original_messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="s2"&gt;"Ralph lives in Boston."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;"You can reach them at ralph@thoughtbot.com or 877-976-2687"&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# Filter all messages&lt;/span&gt;
&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;TopSecret&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;original_messages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;user_messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;role: &lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;content: &lt;/span&gt;&lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;output&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;user_messages&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;chat_completion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:,&lt;/span&gt; &lt;span class="ss"&gt;model: :"gpt-5"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chat_completion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;last&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;content&lt;/span&gt;

&lt;span class="c1"&gt;# Restore the response from the mapping&lt;/span&gt;
&lt;span class="n"&gt;mapping&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mapping&lt;/span&gt;
&lt;span class="n"&gt;restored_response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;TopSecret&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;FilteredText&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;restore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mapping&lt;/span&gt;&lt;span class="p"&gt;:).&lt;/span&gt;&lt;span class="nf"&gt;output&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Even with RubyLLM, there was no way to filter and restore conversation
history, since &lt;code&gt;Chat#ask&lt;/code&gt; handles the message life cycle internally.&lt;/p&gt;
&lt;h2 id="enter-rubyllmtopsecret"&gt;
  
    Enter RubyLLM::TopSecret
  
&lt;/h2&gt;

&lt;p&gt;The introduction of &lt;a href="https://github.com/thoughtbot/ruby_llm-top_secret"&gt;RubyLLM::TopSecret&lt;/a&gt; aims to make
filtering sensitive information from RubyLLM chats as simple as possible. All
you need to do is wrap your existing in-memory chats with
&lt;code&gt;RubyLLM::TopSecret.with_filtering&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;RubyLLM&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;TopSecret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with_filtering&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;chat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;RubyLLM&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;
  &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"My name is Ralph and my email is ralph@thoughtbot.com"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="c1"&gt;# The provider receives: "My name is [PERSON_1] and my email is [EMAIL_1]"&lt;/span&gt;
  &lt;span class="c1"&gt;# The response comes back with placeholders restored:&lt;/span&gt;
  &lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;content&lt;/span&gt;
  &lt;span class="c1"&gt;# =&amp;gt; "Nice to meet you, Ralph!"&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Working with &lt;a href="https://rubyllm.com/rails/"&gt;ActiveRecord-backed chats&lt;/a&gt; is even easier. All
you need to do is add the &lt;code&gt;acts_as_filtered_chat&lt;/code&gt; class macro, and the gem will
take care of the rest.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Chat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="n"&gt;acts_as_chat&lt;/span&gt;
  &lt;span class="n"&gt;acts_as_filtered_chat&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;If you want more granular control, you can simply pass an &lt;code&gt;if:&lt;/code&gt;
condition. For example, you might only need to filter customer-facing
chats that process health data, but not internal admin conversations. A
simple way to model this would be to add a &lt;code&gt;filtered&lt;/code&gt; boolean column to
the &lt;code&gt;chats&lt;/code&gt; table:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Chat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="n"&gt;acts_as_chat&lt;/span&gt;
  &lt;span class="n"&gt;acts_as_filtered_chat&lt;/span&gt; &lt;span class="ss"&gt;if: :filtered?&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;In either case, the goal is to create an opt-in approach that just works. If you want to see it in action, check out this &lt;a href="https://youtu.be/gYz78UYYoLU"&gt;live demo&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you’re interested in exploring the gem yourself, follow the
&lt;a href="https://github.com/thoughtbot/ruby_llm-top_secret?tab=readme-ov-file#installation"&gt;installation&lt;/a&gt; guide and refer to the &lt;a href="https://github.com/thoughtbot/ruby_llm-top_secret?tab=readme-ov-file#usage"&gt;usage&lt;/a&gt; section for
a more detailed overview.&lt;/p&gt;

&lt;aside class="related-articles"&gt;&lt;h2&gt;If you enjoyed this post, you might also like:&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://thoughtbot.com/blog/anonymizing-user-company-and-location-data-using"&gt;Anonymizing User, Company, and Location Data Using Faker&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://thoughtbot.com/blog/switching-from-env-files-to-rails-credentials"&gt;Switching from ENV files to Rails Credentials&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://thoughtbot.com/blog/how-to-use-chatgpt-to-find-custom-software-consultants"&gt;How to Use ChatGPT to Find Custom Software Consultants&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;&lt;/aside&gt;
</content>
  <summary>Automatically filter sensitive information from your RubyLLM conversations before it reaches third-party providers.</summary>
  <thoughtbot:auto_social_share>true</thoughtbot:auto_social_share>
</entry>
</feed>
