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 a Transport proxy. 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; the guest sees each one as a proxy that forwards calls back to the host over the Transport wire.
Host process Wasm guest
┌──────────────────────┐ ┌──────────────────────┐
│ Kobako::Sandbox │ ─eval─▶ │ mruby interpreter │
│ │ ─run──▶ │ │
│ Services │ ◀─call─ │ KV::Lookup.call(k) │
│ KV::Lookup │ ─resp─▶ │ │
│ │ │ │
│ stdout / stderr buf │ ◀─pipe─ │ puts / warn │
│ │ │ │
│ return value │ ◀─last─ │ last expression │
└──────────────────────┘ └──────────────────────┘
trusted untrusted
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
bundle add kobako
# or
gem install kobako
Quick Start
require "kobako"
sandbox = Kobako::Sandbox.new
result = sandbox.eval(<<~RUBY)
1 + 2
RUBY
result # => 3
The script executes inside the Wasm guest. It cannot read your filesystem, open sockets, or touch your ENV.
Usage
Injecting Services
Declare a Namespace, then bind any Ruby object as a Member; the guest reaches it as a <Namespace>::<Member> proxy and invokes its public methods through the Transport wire. See docs/behavior.md B-07..B-12.
class User
attr_reader :name
def initialize(name:)
@name = name
end
end
sandbox.define(:Project).bind(:User, User.new(name: "alice"))
sandbox.define(:KV) .bind(:Lookup, ->(key) { redis.get(key) })
sandbox.eval(<<~RUBY)
Project::User.name # => "alice"
KV::Lookup.call("user_42") # => "..."
RUBY
Names must match /\A[A-Z]\w*\z/. Symbol kwargs travel transparently to the host method's keyword arguments. The registry seals at the first invocation; later #define raises ArgumentError.
Yielding to guest blocks
A Service method can accept a guest-supplied block via &blk and yield into it. The block body runs inside the Wasm guest; break / next / exceptions follow normal Ruby semantics, scoped to the single dispatch. See docs/behavior.md B-23..B-30.
sandbox.define(:Seq).bind(:Map, ->(items, &blk) { items.map(&blk) })
sandbox.eval('Seq::Map.call([1, 2, 3]) { |x| x * 2 }')
# => [2, 4, 6]
Per-invocation caps
Each invocation enforces a wall-clock timeout and a per-invocation linear-memory memory_limit; exhaustion raises a TrapError subclass. Pass nil to timeout / memory_limit to disable that cap. Read Sandbox#usage after the call — populated on every outcome including traps — for actual consumption (docs/behavior.md B-35).
sandbox = Kobako::Sandbox.new(
timeout: 5.0, # seconds, default 60.0
memory_limit: 10 * 1024 * 1024, # bytes, default 1 MiB
stdout_limit: 64 * 1024, # bytes, default 1 MiB
stderr_limit: 64 * 1024
)
| Cap | Raises | Default |
|---|---|---|
timeout |
Kobako::TimeoutError |
60.0 s |
memory_limit |
Kobako::MemoryLimitError |
1 MiB |
stdout_limit |
output clipped (no raise) | 1 MiB |
stderr_limit |
output clipped (no raise) | 1 MiB |
memory_limit covers the per-invocation memory.grow delta from the entry baseline, so a Sandbox reused across invocations does not silently accumulate against a global budget.
Capturing stdout / stderr
Guest writes through puts / print / p / $stdout / $stderr are buffered per-channel and exposed independently of the return value (docs/behavior.md B-04). Buffers clear at the start of each invocation; overflow is clipped at the cap and flagged by #stdout_truncated? / #stderr_truncated?.
result = sandbox.eval(<<~RUBY)
puts "hello"
warn "be careful"
42
RUBY
result # => 42
sandbox.stdout # => "hello\n"
sandbox.stderr # => "be careful\n"
Error handling
Every invocation either returns a value or raises exactly one of three classes, so you can route faults without inspecting messages. The full taxonomy lives in lib/kobako/errors.rb.
begin
sandbox.eval(script)
rescue Kobako::TrapError
# Wasm engine fault or cap exhaustion. Discard the Sandbox.
rescue Kobako::ServiceError
# A host Service call failed and the script did not rescue it.
rescue Kobako::SandboxError
# The script raised, failed to compile, or returned an unrepresentable value.
end
| Class | Parent | Trigger |
|---|---|---|
Kobako::TimeoutError |
TrapError |
Per-invocation timeout exhausted |
Kobako::MemoryLimitError |
TrapError |
Per-invocation memory_limit exhausted |
Kobako::HandlerExhaustedError |
SandboxError |
Handle counter reached its 2³¹ − 1 cap |
Kobako::BytecodeError |
SandboxError |
#preload(binary:) failed RITE validation at replay |
SandboxError and ServiceError carry structured origin / klass / backtrace_lines / details fields when the guest produced a panic envelope.
Capability Handles
A non-wire-representable host object — returned from a Service (B-14), passed to #run (B-34), or handed back from the guest (B-37) — crosses the boundary as an opaque Kobako::Handle proxy and is restored to the original object before host code sees it; any other unrepresentable value raises Kobako::SandboxError. Handles are scoped to a single invocation (docs/behavior.md B-13..B-21, B-34, B-37).
class Greeter
def initialize(name) = @name = name
def greet = "hi, #{@name}"
end
sandbox.define(:Factory).bind(:Make, ->(name) { Greeter.new(name) })
sandbox.eval('Factory::Make.call("Bob").greet') # => "hi, Bob" (Handle round-trip inside guest)
sandbox.eval('Factory::Make.call("Bob")') # => #<Greeter @name="Bob"> (B-37 restoration)
A break value from a guest block is the one exception: it unwinds back to the guest Member call rather than to host code, so a Handle in it stays a Handle — restoring would just re-wrap the same object into a new id on the return trip.
Setup-once, run-many
One Sandbox serves many invocations. Service bindings and preloaded snippets persist across calls; capability state (Handles, stdout, stderr, memory delta) resets between them.
───────────── setup phase (mutable) ─────────────
sandbox = Kobako::Sandbox.new
sandbox.define(:KV).bind(:Lookup, ...)
sandbox.preload(code: ..., name: :Adder)
sandbox.preload(code: ..., name: :Greeter)
│
▼
═════════════════ seal point ═════════════════
First #eval or #run freezes the Service registry
and snippet table. Further define / preload now
raise ArgumentError.
│
▼
──────────────── invocation N ───────────────────
1. allocate fresh mrb_state
2. replay snippets (in insertion order):
:Adder → defines Adder
:Greeter → defines Greeter
3. dispatch: eval(source) or run(:Target, *args)
4. return value to host
5. discard mrb_state; reset per-invocation state:
· Handles invalidated
· stdout / stderr buffers cleared
· memory delta zeroed
Services + snippets persist; invocation N+1 repeats.
For workloads that must be isolated from each other (one Sandbox per tenant, per student submission, per agent session), 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.
Preloaded snippets and entrypoint dispatch
Sandbox#preload registers named mruby snippets that replay against the fresh mrb_state before every invocation; Sandbox#run(:Target, *args, **kwargs) dispatches into a top-level Object constant defined by those snippets (docs/behavior.md B-31..B-33).
sandbox = Kobako::Sandbox.new
sandbox.preload(code: "Adder = ->(a, b) { a + b }", name: :Adder)
sandbox.preload(code: 'Greeter = ->(name:) { "hello, #{name}" }', name: :Greeter)
sandbox.run(:Adder, 2, 3) # => 5
sandbox.run(:Greeter, name: "world") # => "hello, world"
per-invocation replay (every #eval / #run, snippets in insertion order):
fresh mrb_state
│
├──▶ replay :Adder (defines Adder)
│
├──▶ replay :Greeter (defines Greeter)
│
└──▶ eval(source) -or- run(:Target, *args, **kwargs)
│
▼
return value, then mrb_state discarded
#preload accepts two payload forms:
| Form | Signature | Snippet name source | Validation timing |
|---|---|---|---|
| Source | preload(code: "...", name: :Const) |
The name: keyword |
Trial-compiled at preload; compile errors raise immediately |
| Bytecode | preload(binary: bytes) |
Read from the bytecode's debug_info |
Deferred to first invocation; failure raises Kobako::BytecodeError |
Use the source form for snippets authored in your repo (compile errors fail fast at #preload); use the bytecode form when snippets ship as build artifacts from a separate mrbc pipeline. Both replay through the same per-invocation path.
Performance
Order-of-magnitude figures on macOS arm64, Ruby 3.4.7, YJIT off. Absolute values vary by hardware but ratios are stable across machines. Full numbers, methodology, and the +10%-regression gate live in benchmark/README.md.
| Phase | Cost |
|---|---|
First Sandbox.new in a fresh process (Engine + Module JIT) |
~600 ms one-time |
Subsequent Sandbox.new (Engine cache warm) |
~125 µs |
Warm #eval("nil") on a reused Sandbox |
~135 µs |
Warm #run(:Entrypoint, ...) dispatch |
~165 µs |
| Service call amortized inside one invocation | ~6.7 µs |
| Snippet replay per invocation | ~7-9 µs each |
| Per additional Sandbox (RSS) | ~570 KB |
Construct one Sandbox at boot so the ~600 ms JIT cost lands off the request hot path. ext/ does not release the GVL during wasmtime execution, so wasm work is GVL-serialized: aggregate throughput stays around 7-8k #eval/s regardless of Thread count, though Ruby-side #eval setup still overlaps. A +10% regression on any of the six SPEC-mandated benchmarks blocks release.
bundle exec rake bench # six gated regression benchmarks (~5-8 min)
Development
After checking out the repo:
bin/setup # install dependencies
bundle exec rake # default: compile + test + rubocop + steep
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. See CLAUDE.md for the rake task map and pipeline layout. bin/console opens an IRB session with the gem preloaded; bundle exec rake install installs the local checkout as a gem.
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.