rubyrs is an embedding API. The host process gives a script some compute, some memory, and (optionally) some host functions. What the script can do with those resources is what this document is about.
The shortest summary: rubyrs is not a sandbox. It is a
hardening layer for one component of a sandbox. The cap
machinery described here defends against accidental and
moderately-adversarial scripts. For seriously hostile input
(rubygems.org gemspecs, paid tenants, network-sourced
plugins), you must combine rubyrs with OS-level sandboxing or
run the WebAssembly build inside wasmtime. The boundaries
below say exactly when each level applies.
| Level | Example workloads | Required configuration |
|---|---|---|
| Trusted | Internal scripts shipped by your team. CI tooling. A developer's own DSL files. | Config::default() is fine. |
| Semi-trusted | DSL files from a user you have a relationship with — Brewfile, Dangerfile, Bundler Gemfile, configuration shipped by a paying customer. Buggy more often than malicious, but a runaway loop is a real failure mode. |
Set every cap. fuel + heap-objects + frames + deadline + symbols + value-bytes. Keep ResourceExhausted uncatchable (the default). |
| Untrusted | Code that arrived over the wire. *.gemspec files from rubygems.org. Multi-tenant SaaS where one tenant's script must not affect others. |
Same caps as semi-trusted, plus an OS-level boundary: build rubyrs for wasm32-wasip1 and run inside wasmtime with --fuel and a deny-by-default filesystem. Do not rely on the rubyrs caps alone — they are not a substitute for an isolated address space. |
The intent of "semi-trusted" is "won't take down the host process when the script misbehaves." The intent of "untrusted" is "the host process is intact even if the script is actively hostile." Those are different commitments.
Every cap is Option<T> on Config; None (the default) means
unlimited. For the semi-trusted profile, set them like this:
use std::time::Duration;
use rubyrs::{Config, Runtime};
let mut rt = Runtime::with_config(Config {
// Bounded execution cost. ~1M ops finishes a small Brewfile
// with headroom. Adjust to your workload.
fuel: Some(1_000_000),
// Defends "build a giant data structure" attacks.
max_heap_objects: Some(10_000),
// Defends `def f; f; end; f` recursion before our Rust
// stack overflows.
max_frames: Some(256),
// Wall-clock budget — fuel measures ops, this catches
// "slow per op" cases like a host function that blocks.
deadline: Some(Duration::from_secs(2)),
// Cap the interner so `to_sym` in a loop can't grow the
// symbol table without bound. The preamble + your DSL's
// own method names already consume ~50–100 symbols, so
// size relative to `rt.symbol_count()` after a warmup eval.
max_symbols: Some(10_000),
// Per-value byte cap. Catches `"a" * 10_000_000` and
// friends. 1 MB is generous; tune to the maximum string /
// array you actually expect.
max_value_bytes: Some(1_048_576),
..Default::default()
});Each cap can be set independently. They compose: a script that
trips any of them gets a ResourceExhausted Trap and eval
returns.
ResourceExhausted is the trap raised by every cap. It is
deliberately rooted under Exception, not under
StandardError. That placement means:
- Bare
rescue => e(CRuby's defaultrescue StandardError => e) does not catch it. A hostile script can't loop in a rescue clause to burn through fuel. - Even an explicit
rescue Exception => edoes not catch it. The trap is a host-levelResult::Err(Trap)raised directly out ofVm::run— it bypassesunwind_with_exceptionentirely and never appears as a Ruby exception to the script. The trap is not recoverable from inside the Ruby layer by design. - The host can choose to retry — construct a fresh
Runtime::with_config(...)and re-evaluate. That's a host-side decision, where it belongs.
This is the same shape CRuby uses for SystemExit and
Interrupt. See ADR 0008
for the full reasoning.
The caps cover what they cover, but there are residual risks the host should be aware of.
| Attack | Defence in rubyrs | Host's responsibility |
|---|---|---|
CPU monopolisation (while true) |
fuel + deadline |
Use both. fuel is precise per-op; deadline catches a host-fn blocking on something else. |
| RAM monopolisation (one big string / array) | max_value_bytes |
Set it. The default-unlimited cap is convenient, not safe. |
| RAM monopolisation (many small objects) | max_heap_objects |
Set it. Combined with max_value_bytes covers both shapes. |
Symbol table growth (to_sym in a loop) |
max_symbols |
Set it. The interner is a global per-Runtime resource and never shrinks. |
| Stack growth (deep recursion) | max_frames |
Set it. Without this a runaway recursion overflows the host's Rust stack, which is fatal. |
| Slow per-op host functions | Not directly. deadline catches it eventually. |
If your register_fn callbacks make network calls or grab locks, they can stall the whole runtime. Either set short internal timeouts in the callback or use Tokio + a separate executor. |
| stdout blocking | None | If the script puts-floods to a set_stdout(Box::new(slow_writer)), the writer call blocks eval. Use a buffered or back-pressured writer. |
| HashMap iteration-order side channel | None | Our Hash iterates in insertion order (matching CRuby). A script can therefore learn the host's register_fn registration order via the global function namespace. Treat host-function names as semi-public. |
| Symbol name leak via backtraces | None | Trap::backtrace includes user-supplied filenames and method names. Sanitise before logging if those are sensitive. |
| Uncaught Ruby exception | RubyError::Uncaught { class_name, message } — the host gets a Trap, not a SIGABRT. |
Pattern-match Uncaught and decide whether to retry / continue / abort. The CLI's format_trap prints CRuby-style (script.rb:line: msg (ClassName)) before exiting. |
Runtime::new() constructs a runtime with no caps. That's
the right default for the most common case — a developer
running a script they wrote themselves on a workstation they
own. The semi-trusted and untrusted profiles are explicit
opt-in: a host that takes scripts from less-than-fully-trusted
sources has to think about it.
If you find yourself reaching for "I want safe defaults", you probably want the semi-trusted profile copy-pasted above.
Security-relevant bugs (anything in the table above that you
can reproduce, plus any panic reachable from a script the host
fed eval) should be filed on the rubyrs issue tracker or
emailed to the maintainer. Please don't disclose publicly until
a fix has shipped.
The current panic audit lives in
PANIC_AUDIT.md. A user-reachable panic is
treated as a security bug — see also
ADR 0008.