hermetic
hermetic — the missing Ruby sandbox. Pick your isolation strength (gVisor, a Firecracker microVM, a hosted sandbox, or hardened Docker) behind one
runcall, and run it off your app host so an escape can't reach your database or secrets.
Ruby today has exactly one honest option for running untrusted or model-generated
code: a locked-down docker run. $SAFE was removed in Ruby 3.0; there is no Ruby
client for E2B/Daytona/Modal and no Ruby wrapper for Firecracker or gVisor.
hermetic fills that whitespace as an ecosystem building block: one interface,
swappable isolation backends, zero runtime dependencies (stdlib only).
Install
gem "hermetic"
Usage
box = Hermetic.hosted(:e2b, api_key: ENV["E2B_API_KEY"])
result = box.run("python -c 'print(2+2)'",
files: { "data.txt" => "..." }, # materialized in the workdir
stdin: nil,
env: { "MODE" => "batch" }, # guest env is default-deny otherwise
network: :none, # default deny; opt-in egress allowlist
timeout: 10, # host-enforced wall clock
limits: { memory: "512m", cpus: "1", pids: 256 })
result.stdout # => "4\n"
result.success? # => true
result.timed_out? # => false (a timeout maps to exit_status 124)
result.trust # => :vendor
A String command runs via /bin/sh -c; an Array argv execs directly — no shell,
no injection surface.
Backend matrix
| Backend | Constructor | Isolation | Trust | Off-host? |
|---|---|---|---|---|
| Hosted (E2B) | Hermetic.hosted(:e2b, api_key:) |
vendor microVM, in the vendor's cloud | :vendor |
yes, by construction |
| Remote | Hermetic.wrap(executor:, token:) or any local constructor + executor: |
whatever the executor runs (gVisor/Firecracker/Docker) | :remote |
yes — dedicated sandbox host |
| Firecracker | Hermetic.firecracker(kernel:, rootfs:) |
own guest kernel via KVM | :vm |
no (pair with executor:) |
| gVisor | Hermetic.gvisor(image:) |
user-space kernel (runsc), real syscall boundary |
:host |
no (pair with executor:) |
| Docker (hardened) | Hermetic.docker(image:) |
container namespaces + caps/seccomp | :host |
no — dev default |
| Null | Hermetic.null |
none — disabled, fails loud on run |
:none |
no |
Isolation is not the same thing as a trust boundary
Two independent axes, never conflated:
- Isolation strength — namespaces < gVisor < microVM. (Which backend.)
- Trust domain — does the guest run next to your
RAILS_MASTER_KEY, database, and job queue, or in a separate security context?
A container escape on your app host lands next to everything you care about, however
strong the container was. So every box (and every Result) carries the posture:
box.trust # => :vendor | :remote | :vm | :host | :none
box.off_host? # => true only for :vendor and :remote
And the one-line guard that keeps it honest:
raise "untrusted code requires an off-host sandbox" unless box.off_host?
Getting off-host is either free (hosted vendors) or one keyword: any local
constructor takes executor: + token: and becomes a client for a dedicated
sandbox host that holds no app secrets (see EXECUTOR.md for the server contract):
box = Hermetic.gvisor(image: "ruby:3.3-slim",
executor: "https://sbx.internal:8443", token: ENV["SBX_TOKEN"])
box.off_host? # => true — same isolation tech, different blast radius
Credentials: the model never sees raw tokens
box = Hermetic.hosted(:e2b, api_key: ENV["E2B_API_KEY"],
credentials: { "GITHUB_TOKEN" => -> { CredentialVault.fetch(:github) } })
Credentials are callables resolved lazily per run and injected into the guest's environment at exec time — never printed into the model-visible command, never cached on the box, never part of the idempotency key.
Silas drop-in
Hermetic::Result is a superset of Silas::Sandbox::Result (stdout / stderr /
exit_status / success?), and every box implements the run / enabled? duck
type Silas's config.sandbox already accepts — zero Silas changes:
Silas.configure do |c|
c.sandbox = Hermetic.gvisor(image: "ruby:3.3-slim",
executor: "https://sbx.internal:8443",
token: ENV["SBX_TOKEN"])
end
Running Silas? Also require "hermetic/silas" — an optional shim that refuses to
execute a sandbox run inside a ledger transaction (a sandbox exec is a
non-replayable external effect; run_code stays at_most_once!).
In-doubt runs (remote/hosted)
If the wire fails after a request may have executed guest-side, hermetic raises
Hermetic::TransportError with in_doubt?: true rather than silently retrying.
Every run carries a deterministic Idempotency-Key, so a deliberate retry of the
same logical run can be deduplicated executor-side. v1 never retries automatically.
Honest v1 scope
Shipped: Null, hardened Docker, gVisor, Hosted::E2B, Firecracker (pure
config/jailer builders + injectable single-shot cold boot), Remote executor client
with EXECUTOR.md, full run contract (files:/stdin:/env:/network:/
timeout:/limits:), :env credentials, trust/off_host?, the Silas shim.
Deferred (v1.1+):
:proxycredential mode (guest gets no token at all; egress proxy attaches auth)- Firecracker orchestration: warm pools/snapshots, vsock I/O, tap+nftables egress, rootfs builder — v1's default Firecracker runner refuses to boot; inject your own
- Hosted Daytona/Modal (constructors name them and fail with the roadmap)
- The executor server daemon itself (the client + wire contract ship now)
- Persistent sandboxes, streaming stdout, artifact read-back
Known caveat: Hosted::E2B's endpoint/body shapes are a best-effort design-time
approximation, to be pinned against the live API via the :hosted smoke layer
before first real use.
Testing
The default suite is hermetic (the name is a promise): pure argv/VM-config/HTTP-spec builders plus injected fake runners and transports — no Docker, no network, no KVM.
bundle exec rspec # unit layer only, runs anywhere
HERMETIC_SMOKE=docker bundle exec rspec # + real `docker run` round-trips
HERMETIC_SMOKE=gvisor bundle exec rspec # + proves runsc boots (needs runsc runtime)
HERMETIC_SMOKE=hosted bundle exec rspec # + E2B round-trip (needs E2B_API_KEY)
Smoke specs are tagged :smoke, excluded unless HERMETIC_SMOKE is set, and skip
themselves when their infrastructure is absent.
License
MIT.