Kobako
Kobako is a Ruby gem that embeds a Wasm-isolated mruby interpreter inside your application, so you can execute untrusted Ruby scripts (LLM-generated code, user formulas, student submissions, third-party plugins) in-process without giving them access to host memory, files, network, or credentials.
The host (wasmtime) runs a precompiled kobako.wasm guest containing mruby and an RPC client. The only way a guest script can reach the outside world is through Host App-declared Services — named Ruby objects you explicitly inject into the sandbox.
Features
- In-process Wasm sandbox — no subprocess, no container. Each
Sandbox#runis a synchronous Ruby call. - Capability injection via Services — guest scripts can only call Ruby objects you explicitly
bindunder a two-levelGroup::Membernamespace. - Structured outcome —
#runreturns the deserialized last expression of the guest script as a normal Ruby value. - Three-class error taxonomy — every failure is exactly one of
TrapError(Wasm engine),SandboxError(script / wire fault), orServiceError(Service capability fault), so you can route errors without inspecting messages. - Per-run state reset — Handles issued during one
#runare invalidated before the next; Service bindings remain. - Separated stdout / stderr capture — guest
puts/warnoutput is buffered (1 MiB default cap, configurable, with a[truncated]marker on overflow) and is independent of the RPC channel. - Capability Handles — Services may return stateful host objects; the guest receives an opaque token it can use as the target of follow-up RPC calls, with no way to dereference it.
Requirements
- Ruby ≥ 3.3.0
- Rust / Cargo at install time — the native extension compiles from source via
rb_sys - Linux or macOS — Windows is not supported
The precompiled kobako.wasm Guest Binary ships inside the gem, so end users do not need a WASI toolchain. (The toolchain is only required if you build the gem from a source checkout — see Development.)
Installation
Add Kobako to your Gemfile:
bundle add kobako
Or install it directly:
gem install kobako
Quick Start
require "kobako"
sandbox = Kobako::Sandbox.new
result = sandbox.run(<<~RUBY)
1 + 2
RUBY
result # => 3
sandbox.stdout # => ""
The script executes inside the Wasm guest. It cannot read your filesystem, open sockets, or touch your ENV.
Injecting Services
Guest scripts reach host resources only through Services. Declare a Group, then bind named Members on it — each member can be any Ruby object that responds to the methods the guest will call.
sandbox = Kobako::Sandbox.new
sandbox.define(:KV).bind(:Lookup, ->(key) { redis.get(key) })
sandbox.define(:Log).bind(:Sink, ->(msg) { logger.info(msg) })
sandbox.run(<<~RUBY)
Log::Sink.call("starting")
KV::Lookup.call("user_42")
RUBY
# => "..." (the redis value)
Names must match the Ruby constant pattern /\A[A-Z]\w*\z/. Services declared before the first #run remain active across subsequent runs.
Keyword arguments
Keyword keys travel as Symbols and reach the host method as keyword arguments:
sandbox.define(:Geo).bind(:Lookup, ->(name:, region:) { "#{region}/#{name}" })
sandbox.run('Geo::Lookup.call(name: "alice", region: "us")')
# => "us/alice"
Capturing stdout and stderr
Guest output is captured into per-run buffers and exposed independently from the return value:
sandbox = Kobako::Sandbox.new
result = sandbox.run(<<~RUBY)
puts "hello"
warn "be careful"
42
RUBY
result # => 42
sandbox.stdout # => "hello\n"
sandbox.stderr # => "be careful\n"
Each #run clears the buffers at start. Output past the per-channel cap is truncated; the buffer ends with [truncated] and #run still returns normally.
Kobako::Sandbox.new(stdout_limit: 64 * 1024, stderr_limit: 64 * 1024)
Error handling
Every #run either returns a value or raises exactly one of three classes:
begin
sandbox.run(script)
rescue Kobako::TrapError => e
# Wasm engine crashed: OOM, stack overflow, corrupted guest runtime.
# The Sandbox is unrecoverable — discard and recreate it.
rescue Kobako::ServiceError => e
# A Service call failed and the script did not rescue it.
# Treat like any other downstream-service failure in your app.
rescue Kobako::SandboxError => e
# The script itself raised, failed to compile, or produced an
# unrepresentable value. A script-level fault, not infrastructure.
end
SandboxError and ServiceError carry structured fields (origin, klass, backtrace_lines, details) when the guest produced a panic envelope.
Kobako::ServiceError::Disconnected is a named subclass raised when an RPC target Handle has been invalidated. Kobako::HandleTableExhausted is a named SandboxError subclass raised when the per-run Handle counter reaches its cap (2³¹ − 1).
Capability Handles
When a Service returns a stateful host object (anything beyond nil / Boolean / Integer / Float / String / Symbol / Array / Hash), the wire layer transparently allocates an opaque Handle. The guest receives a Kobako::Handle proxy it can use as the target of further RPC calls — but cannot dereference, forge from an integer, or smuggle across runs.
class Greeter
def initialize(name) = @name = name
def greet = "hi, #{@name}"
end
sandbox.define(:Factory).bind(:Make, ->(name) { Greeter.new(name) })
sandbox.run(<<~RUBY)
g = Factory::Make.call("Bob") # g is a Kobako::Handle proxy
g.greet # second RPC, routed to the Greeter
RUBY
# => "hi, Bob"
Handles are scoped to a single #run — a Handle obtained in run N is invalid in run N+1, even on the same Sandbox.
Setup-once, run-many
A single Sandbox can serve many script executions. Service bindings persist; capability state (Handles, stdout, stderr) resets between runs.
sandbox = Kobako::Sandbox.new
sandbox.define(:Data).bind(:Fetch, ->(id) { records[id] })
sandbox.run('Data::Fetch.call("a")') # => "..."
sandbox.run('Data::Fetch.call("b")') # => "..." (same bindings, fresh state)
For workloads that must be isolated from each other (e.g., one Sandbox per tenant, per student submission), construct a fresh Kobako::Sandbox per scope. wasmtime's Engine and the compiled Module are cached at process scope, so additional Sandboxes amortize cold-start cost automatically.
Development
After checking out the repo:
bin/setup # install dependencies
bundle exec rake # default: compile + test + rubocop
Building from source requires a WASI-capable Rust toolchain in addition to the standard host toolchain. The first compile walks the full vendor / mruby / wasm chain:
bundle exec rake compile # build the native extension
bundle exec rake wasm:build # rebuild data/kobako.wasm (requires vendor:setup + mruby:build)
bundle exec rake test # run the Ruby test suite
bin/console opens an IRB session with the gem preloaded for experimentation.
To install the local checkout as a gem:
bundle exec rake install
Performance
Headline numbers from the current baseline (macOS arm64, Ruby 3.4.7 — full results in benchmark/results/):
| What | Cost |
|---|---|
First Sandbox.new in a fresh process (Engine init + Module compile) |
~410 ms one-time |
Subsequent Sandbox.new (cache warm) |
~90 µs |
Reusing a Sandbox for one #run("nil") |
~67 µs |
| Fresh Sandbox per request — the tenant-isolation pattern | ~175 µs (+110 µs versus reuse) |
Per-RPC cost amortized across many calls in one #run |
~5.4 µs |
| 100 000-iteration integer XOR loop in mruby | ~44 ms |
| One-time process memory for wasmtime Engine + Module | ~110 MB |
| Memory per additional Sandbox after the first | ~200 KB |
| 1 000 isolated tenants in one process (1 Sandbox each) | ~340 MB total |
| Aggregate throughput across N Threads | GVL-bound — wasm execution is serialized, modest scaling from Ruby-side overlap |
Practical implications:
- Pre-warm at boot. The 410 ms first-Sandbox cost is paid once per process; every subsequent Sandbox amortizes to micro-, not milliseconds. Construct one Sandbox at boot before serving requests.
- Tenant isolation is affordable. Per-request Sandbox construction adds ~110 µs of overhead; per-tenant RSS budget is ~200 KB plus one-time ~110 MB for the engine. 1 000 isolated tenants in a single Sidekiq / Puma worker is well within typical RSS limits.
- Batch RPCs inside one
#run. A single Service call costs ~76 µs because each#runcarries ~67 µs of setup; 1 000 calls inside one#runreduce the per-call cost to ~5.4 µs.
A +10% regression on any of the five SPEC-mandated benchmarks blocks release. See benchmark/README.md for the full per-suite breakdown, rake task reference, and known measurement caveats (guest String size cap, GVL bounds, allocator retention).
bundle exec rake bench # five gated regression benchmarks (≤ 1 MiB payloads, ~5-7 min)
Contributing
Bug reports and pull requests are welcome at https://github.com/elct9620/kobako. Please open an issue before starting on non-trivial changes so we can align on scope.
License
Kobako is released under the Apache License 2.0.