Four AI Stack Risks bundler-audit Won't Flag
Many of the Ruby on Rails apps we work on now have an AI layer of some kind. Sometimes it’s a single RubyLLM chat in a background job, sometimes it’s an MCP client wired into a support tool, and on most teams there’s a coding agent running in somebody’s editor. That layer gets the same security process as the rest of the app, which usually means bundler-audit in CI and a Ruby upgrade when somebody gets to it.
The last few weeks produced four separate problems in that layer. None of them has an advisory against a Ruby gem, so a dependency scanner stays green for all four. Two come from reading the source of ruby_llm-mcp, a community MCP client gem, one is a version constraint, and one is a compromised editor extension that isn’t a Ruby problem at all, but runs on the machines where our Rails credentials live.
In this article, we’ll go through each one: what happened, how to tell whether it affects your app, and what to do about it.
Your MCP Client Compiles Someone Else’s Regexes
On October 23, Ruby released fixes for memory corruption bugs in its regexp parser and compiler, covering four CVEs (CVE-2026-89078, CVE-2026-93577, CVE-2026-97229 and CVE-2026-110264) with three changes in Ruby. They were found by GitLab, which compiles regexes from CI/CD configuration, but the bugs live in Ruby itself. On October 29, CISA added CVE-2026-97229 to its Known Exploited Vulnerabilities catalog with a due date of November 1 for federal agencies, and the entry notes that “other products can potentially be affected by the same underlying Ruby/Onigmo Integer Overflow issue in CI/CD Regular Expression Compiler.”
The Ruby advisory is precise about who is exposed: “These bugs are reached only when an application compiles a regular expression from a string that an attacker controls, for example by passing it to Regexp.new, Regexp.union or Regexp.compile, or by interpolating it into a regexp literal such as /#{input}/. Applications that only use regular expressions written by their own developers are not affected.” It is just as precise about the mitigations that don’t work: “Regexp.timeout does not protect against these bugs,” and “Limiting the length of patterns does not reliably protect against them either, because some of them can be triggered by short patterns.”
So the question for a Rails team is where your app compiles a regex it didn’t write. In a Ruby AI app, one answer is the JSON Schema pattern keyword.
An MCP server describes its tools in its tools/list response, and each tool carries an inputSchema (and optionally an outputSchema) as a JSON Schema document. We looked at the server side of this in Running a Ruby MCP Server in Production . On the client side, if you connect to a remote MCP server, those schemas are written by whoever runs it. The community ruby_llm-mcp gem validates them with json_schemer . This is its SchemaValidator module at v1.0.1, trimmed to the parts that matter here:
module RubyLLM
module MCP
module SchemaValidator
module_function
def valid_schema?(schema)
return true if schema.nil?
schemer(schema)
true
rescue StandardError
false
end
def schemer(schema)
JSONSchemer.schema(schema)
end
end
end
end
valid_schema? never validates anything against the schema. It only builds it. That matters because json_schemer does its parsing eagerly. The code is spread across a few files in json_schemer 2.5.0, the newest version ruby_llm-mcp’s ~> 2.4 constraint allows. Each keyword in a schema becomes a JSONSchemer::Keyword, and Keyword#initialize ends with @parsed = parse. For the pattern keyword, Pattern#parse is root.resolve_regexp(value). The default configuration sets regexp_resolver to 'ruby', which json_schemer resolves with Regexp.new(pattern) . So constructing the schema object is the regex compile, and no data needs to go through it.
RubyLLM::MCP::Tool#initialize runs the server’s inputSchema through normalize_if_invalid , which walks every nested hash in the schema and calls SchemaValidator.valid_schema? at each level. That happens when the tool object is built from the server’s response, before a model has been asked anything and before any tool is called. The outputSchema goes through json_schemer again when a tool result with structured content comes back .
The rescue StandardError looks like a guard, and it does catch a malformed pattern that raises RegexpError. It can’t do anything about memory corruption inside the compiler, and when it does catch something, it hides it by returning false.
This chain has limits, and they matter. There is no advisory against ruby_llm-mcp or json_schemer, and the CVEs are GitLab’s against Ruby. We established the path by reading the code at the v1.0.1 tag, not by crashing anything, and we haven’t confirmed that a crafted pattern reaches the vulnerable code in the compiler after json_schemer’s handling. What we can say is that the precondition Ruby describes, compiling a pattern you didn’t author, is met.
The fix doesn’t depend on any of that. Upgrade to Ruby 4.0.9, 3.4.12 or 3.3.13, which contain all of these fixes. Then, if you want to know where else you compile untrusted patterns, this grep is a reasonable starting point:
grep -rnE "JSONSchemer\.schema|JSON::Validator|Regexp\.(new|union|compile)" app lib config
For each hit, ask who wrote the string. A schema you wrote by hand is in Ruby’s “not affected” bucket. A schema that came over the network, from a user, or from a database column that users can edit is not.
It’s also worth knowing that RubyLLM 2.1 ships its own MCP client. Its gemspec has no json_schemer dependency, and we didn’t find anywhere in its lib/ruby_llm/mcp directory that compiles a regex from a server-supplied string (every regex in that directory is a literal written by the gem’s authors, in files like resource_template.rb and http.rb). That’s a narrow observation about this one class of problem, not an audit. The RubyLLM ecosystem page notes that ruby_llm-mcp “defines the same RubyLLM::MCP constant, so an application uses one or the other,” so moving to the built-in client means replacing ruby_llm-mcp rather than adding a second gem. The two can’t share a bundle anyway: ruby_llm-mcp 1.0.1 depends on ruby_llm ~> 1.9, so it won’t install alongside any RubyLLM 2.x release, let alone 2.1.
The Bearer Token in Your Debug Logs
The second issue is in the same gem and is much less exotic. In ruby_llm-mcp 1.0.1, the streamable HTTP transport builds headers for every outbound request in build_common_headers . When an OAuth provider is configured, these are the two lines that apply the token:
headers["Authorization"] = token.to_header
RubyLLM::MCP.logger.debug "Applied OAuth authorization header: #{token.to_header}"
The second line logs the complete Bearer ... string on every request to the MCP server.
It’s only written at debug level, and the defaults are reasonable: the gem’s logger writes to $stdout at INFO. It switches to DEBUG when the RUBYLLM_MCP_DEBUG environment variable is set, or when you configure log_level or pass in your own logger at debug level. Think about when someone sets that variable, though. It’s usually when an OAuth connection to an MCP server isn’t working, which is exactly when a token is in play. On Heroku and most container platforms, $stdout goes straight to your log drain, so the token ends up in whatever aggregator, error tracker or support bundle reads those logs, and it stays there for as long as they keep logs.
There’s no advisory or maintainer statement about this, and 1.0.1 (released July 21) is still the newest version, so there isn’t an upgrade to reach for. What you can do:
- Check whether
RUBYLLM_MCP_DEBUGis set in any environment, including staging and review apps, and whether any initializer sets the gem’slog_levelto debug or points its logger atRails.loggerwhile that logger is at debug. - Search your retained logs for the literal string
Applied OAuth authorization header: Bearer. The SSE transport logs the same prefix without the token, so matching on the full string avoids false alarms. - Rotate any token that shows up, and revoke the grant on the MCP server’s side if it supports that.
A log statement next to a header assignment is worth a look in any HTTP client code, not just MCP code. In the codebases we’ve encountered, a debug line like this one is a far more common way for a credential to leak than a subtle protocol mistake.
Version Constraints on RubyLLM
The third one isn’t a vulnerability, but it decides which version of your main AI dependency you’re actually running, and therefore which fixes you get.
RubyLLM released 2.1.0 on October 8, 2.1.1 on October 11 and 2.2.0 on November 10. When 2.1 came out, two popular gems that build on it constrained it differently:
- ai-agents 0.13.0 depends on
ruby_llm ~> 2.0.0, which means>= 2.0.0, < 2.1.0. - raix 3.0.0 depends on
ruby_llm ~> 2.0, which means>= 2.0, < 3.0.
The difference is a single .0, and it made ai-agents 0.13.0 incompatible with every RubyLLM 2.1 release. Staying on the previous version didn’t help either: ai-agents 0.12.0 depends on ruby_llm ~> 1.14. For almost a month, there was no ai-agents release that installed alongside RubyLLM 2.1.x. And ruby_llm-mcp, the gem from the first two sections, is pinned harder still: 1.0.1 depends on ruby_llm ~> 1.9, so anyone using it is on RubyLLM 1.x and gets none of the 2.x releases.
How a pin like that shows up depends on where you start. If your app is already on a RubyLLM release the pin excludes, adding ai-agents fails with a resolution error, which at least tells you something. If you add ai-agents first, or update both together, the resolver has to pick an older ruby_llm for the whole bundle (2.0.x with ai-agents 0.13.0, or 1.x with 0.12.0), including for any other gem that would have been happy with the newer one. Nothing fails, and the only place it shows up is the lockfile.
It turns out the narrow constraint was doing its job. A Chatwoot maintainer opened a pull request to allow RubyLLM 2.1 on October 26, and its description explains why the pin was there: RubyLLM 2.1 calls tool.class.deferred? when a chat registers tools, ai-agents wraps tools in a class that isn’t a RubyLLM::Tool, so every agent with a tool raised NoMethodError and handoffs failed. The PR fixed that and widened the constraint to >= 2.0, < 2.2. It was merged on November 2 and released in ai-agents 0.14.0 on November 4.
That lasted six days. RubyLLM 2.2.0 came out on November 10, and < 2.2 excludes it, so ai-agents is again holding the bundle one minor version behind, while raix’s ~> 2.0 already allows 2.2.0. The upper bound looks deliberate given what happened with 2.1, and it means the same cycle can repeat on every RubyLLM minor release. Before adding or upgrading any gem in this part of the stack, check what each one asks for:
grep -n "ruby_llm (" Gemfile.lock
Gemfile.lock lists every gem’s dependencies with their constraints, so this shows you which gem is holding the version down. In a stack where the base gem releases roughly monthly and several wrappers sit on top of it, the tightest constraint in the bundle is the version you actually run. If you want both ai-agents and RubyLLM’s built-in MCP client from the first section, ai-agents 0.14.0 gets you there on RubyLLM 2.1.x, but not yet on 2.2. Relaxing the constraint yourself is a gamble, since 0.13.0 broke on 2.1 for the reason the PR describes, and the next minor release could need a similar change.
The Coding Agent Extension That Shipped a Backdoor
The last one isn’t about Ruby, but it lands on the laptops where our master.key, Heroku tokens and production database URLs live. We covered a related exposure, editor configuration running code the moment you open a repo, in Opening a Repo Is Now an Execution Event .
On October 29, a tampered build of Anthropic’s Claude Code editor extension was distributed through Cursor’s extension marketplace from about 2:27am to 1:35pm PT. According to Cursor’s security notice , the affected versions were 2.1.312 and 2.1.313 installed from that marketplace, and “These malicious versions of the extension were not developed by Anthropic.” Microsoft’s analysis states that the Visual Studio Marketplace and npm packages were not affected.
The malicious build tries to add an attacker’s Ed25519 key to ~/.ssh/authorized_keys with the comment svc-telemetry, opens a reverse SSH tunnel to 134.209.82.225, and sets up persistence through a macOS LaunchAgent, a Linux crontab @reboot entry, or a Windows scheduled task. It also beacons host details and, in Microsoft’s words, “matching environment-variable names, not values.” That’s not much comfort on its own, because anyone with an SSH session on the machine can read the values themselves.
Cursor’s notice does narrow the exposure. Turning on the local SSH service “requires admin rights, which the malicious extension does not have on most macOS and Windows hosts,” and on those hosts “the attacker could not have logged in through the tunnel.” So the machines most at risk are the ones where Remote Login was already on, or where Cursor was running with admin rights. Cursor still asks everyone who installed one of the affected versions to check, and there are two details worth knowing before you do.
First, the version number only catches half of it. Nothing official ever shipped a 2.1.313: npm’s registry keeps a timestamp for every published version, including unpublished ones, and it has no entry for that number. The legitimate release is 2.1.312, published to npm on October 27 and still listed on Open VSX. So looking for 2.1.313 flags the hosts that took the fake version and silently passes the ones that took a tampered copy of the real one.
Second, Cursor’s notice lists the affected platforms as Windows and macOS, but its own IOC list includes a Linux cron entry (@reboot /bin/sh "<HOME>/.config/.smt"), and Microsoft describes Linux crontab persistence too. Check Linux machines as well.
Searching for the indicator rather than the version works everywhere. On macOS and Linux:
grep -n "svc-telemetry" ~/.ssh/authorized_keys
ls -la ~/.config/.smt ~/.config/.smtk /tmp/.stk_* "$TMPDIR"/.stk_* 2>/dev/null
crontab -l 2>/dev/null | grep -n ".smt"
If anything turns up, follow the remediation steps in Cursor’s notice, which include platform scripts. It also warns that uninstalling the extension may not be enough, because the persistence scripts can keep running on their own. Then rotate everything that was reachable from that machine, working from a different one: Rails credentials and the master.key, cloud and Heroku tokens, GitHub tokens and SSH keys, and any API keys in .env files. We still don’t know how the attacker got publishing access to Cursor’s marketplace.
Conclusion
The AI layer of a Rails app now pulls in input and tooling that our usual checks were never built to see. A remote MCP server’s schemas reach Ruby’s regexp compiler before any tool is called, a debug line in an MCP client writes bearer tokens to stdout, a wrapper gem’s version constraint decides which RubyLLM you run, and a coding agent extension on a developer’s laptop shipped an SSH backdoor. None of them produced an advisory that bundler-audit could match.
For most teams, the most urgent step is the Ruby upgrade, since CISA has listed one of those bugs as exploited in the wild (in GitLab). After that, it’s worth reading your AI gems with the same suspicion you’d apply to any code that handles data from outside your app, because many of them are young and moving fast. For the supply-chain side of the same problem, The Hidden Dangers in Your Gemfile covers recent RubyGems attacks and how to protect against them.
Is your team behind on Ruby versions, or not sure what your AI gems are pulling into the bundle? Our Bonsai for Ruby monthly maintenance service keeps Ruby and your dependencies current, so fixes like these land without a fire drill.