JanetSandbox
Safely embed Janet scripting in your Ruby application via WebAssembly. Execute user-authored scripts with configurable sandboxing, resource limits, and pluggable DSL modules.
Note that janet.c in wasm/build/janet is pulled from the Janet Repo. The JSON module (wasm/json.c) is pulled from spork to provide json/encode and json/decode for Ruby/Janet interop.
Features
- Secure sandboxing via WebAssembly isolation
- Resource limits - fuel-based execution limits prevent infinite loops
- Memory caps - bounded WASM linear memory
- Pluggable DSLs - register domain-specific Janet functions for your use case
- Rails integration - optional Railtie and ActiveRecord concern
Installation
Add to your Gemfile:
gem 'janet_sandbox'The gem ships with a prebuilt janet.wasm binary, so it works out of the box.
Quick Start
require 'janet_sandbox'
# Simple evaluation
result = JanetSandbox.evaluate(
source: '(+ 1 2 3)',
context: {}
)
result.raw #=> 6
# With context
result = JanetSandbox.evaluate(
source: '(* (get ctx :quantity) (get ctx :price))',
context: { quantity: 5, price: 10 }
)
result.raw #=> 50Configurable DSLs
Unlike traditional embedded scripting, JanetSandbox doesn't impose a fixed DSL. Instead, you register your own domain-specific functions:
JanetSandbox.configure do |config|
# Register a DSL module
config.register_dsl(:form_helpers, <<~'JANET')
(defn get-field [name]
(get-in ctx [:values (keyword name)]))
(defn show [field]
{:action :show :field (keyword field)})
(defn hide [field]
{:action :hide :field (keyword field)})
JANET
# Enable it by default
config.enable_dsl(:form_helpers)
end
# Now use it
result = JanetSandbox.evaluate(
source: '(if (= (get-field "role") "admin") (show "budget") (hide "budget"))',
context: { values: { role: "admin" } }
)Per-Evaluation DSL Override
# Use specific DSLs for this evaluation
result = JanetSandbox.evaluate(
source: code,
context: ctx,
dsls: [:form_helpers, :validation_helpers]
)
# Or disable all DSLs
result = JanetSandbox.evaluate(
source: '(+ 1 2)',
context: {},
dsls: []
)Example DSLs
The gem includes example DSLs in examples/dsls/:
- form_helpers.janet - Form visibility, validation, computed fields
- validation_helpers.janet - Email, phone, length validators
- math_helpers.janet - Safe arithmetic, tax/discount calculations
Copy these to your project and customize as needed.
Configuration
JanetSandbox.configure do |config|
# Execution limits (apply only to user code, not setup/DSL loading)
config.fuel_limit = 10_000_000 # Instruction budget for user code
config.epoch_interval = 100 # Wall-clock check interval (ms)
config.epoch_deadline = 10 # Max intervals (~1 second timeout)
config.max_result_size = 64 * 1024 # 64KB max JSON output
# Path to WASM binary
config.wasm_path = '/path/to/janet.wasm'
# Block additional symbols
config.blocked_symbols << 'some/dangerous-fn'
# Register DSLs
config.register_dsl(:my_dsl, janet_source, description: "My custom DSL")
config.enable_dsl(:my_dsl)
endExecution Limits
JanetSandbox uses two complementary limits:
- Fuel (instruction counting) - Catches CPU-intensive computation
- Epochs (wall-clock time) - Catches blocking operations like sleep
Set epoch_interval to nil to disable wall-clock timeouts.
Result Object
Evaluations return a JanetSandbox::Result:
result = JanetSandbox.evaluate(
source: '{:status "ok" :total 150 :items [{:name "A"} {:name "B"}]}',
context: {}
)
result.raw #=> {status: "ok", total: 150, items: [{name: "A"}, {name: "B"}]}
result[:status] #=> "ok"
result[:total] #=> 150
result.to_h #=> Same as raw for hashes, or {value: raw} for primitives
result.to_json #=> JSON stringThe Result object is intentionally minimal - your DSL defines the structure, not the gem.
Rails Integration
Setup
The gem auto-loads Rails integration when Rails is detected.
# config/initializers/janet_sandbox.rb
JanetSandbox.configure do |config|
config.register_dsl(:form_helpers, File.read(Rails.root.join('lib/janet_dsls/form_helpers.janet')))
config.enable_dsl(:form_helpers)
endModel Concern
class ScriptRule < ApplicationRecord
include JanetSandbox::Rails::ScriptConcern
# Specify DSLs for this model
janet_dsls :form_helpers, :validation_helpers
belongs_to :form
endExpected schema:
-
janet_source(text) - Required -
active(boolean) - Optional, for scoping -
priority(integer) - Optional, for ordering
Usage
# Single rule
rule = ScriptRule.find(1)
result = rule.evaluate({ values: params, user: current_user.attributes })
# All rules for a form, merged
result = ScriptRule.evaluate_all(
form.script_rules,
context: { values: params, user: current_user.attributes }
)
render json: result.to_hSecurity
Scripts run in a WASM sandbox with:
-
No filesystem access -
file/*functions removed -
No network access -
net/*functions removed -
No process spawning -
os/execute,os/spawnremoved - No eval/compile - Prevents meta-evaluation escapes
- Fuel-limited execution - Instruction budget terminates runaway computation
- Epoch-limited execution - Wall-clock timeout catches blocking operations
- Memory-capped - WASM linear memory has a hard ceiling
- Result size limit - Prevents memory bombs via output
Error Handling
begin
result = JanetSandbox.evaluate(source: user_code, context: ctx)
rescue JanetSandbox::TimeoutError
# Script exceeded fuel limit
rescue JanetSandbox::MemoryError
# Script exceeded memory limit
rescue JanetSandbox::EvaluationError => e
# Script had a syntax or runtime error
puts e.janet_error
rescue JanetSandbox::ResultError
# Result was too large or couldn't be parsed
rescue JanetSandbox::DSLNotFoundError => e
# Referenced DSL is not registered
endCustom WASM Binary
To use a different janet.wasm (e.g., a custom build or newer version):
JanetSandbox.configure do |config|
config.wasm_path = '/custom/path/janet.wasm'
endDevelopment
Rebuilding janet.wasm
The gem ships with a prebuilt janet.wasm, but you can rebuild it to update Janet or modify the build.
Dependencies:
- wasi-sdk - WASI-enabled Clang/LLVM toolchain
-
curl- for downloading Janet source files
Setup:
- Download wasi-sdk from the releases page
- Extract it and set the environment variable:
export WASI_SDK_PATH=~/wasi-sdk-22.0Build:
cd wasm
./build.shOr via rake:
rake janet_sandbox:build_wasmThe build script downloads Janet source files automatically if not present.
Thread Safety
-
Engineis thread-safe and should be shared (one per app) -
Sandboxis per-evaluation with isolated WASM memory - Configuration should be set at boot time
API Reference
JanetSandbox.evaluate(source:, context:, dsls: nil, **opts)
Evaluate Janet code. Returns Result.
JanetSandbox.configure { |config| }
Configure the gem.
JanetSandbox.register_dsl(name, source, description: nil)
Register a DSL module.
JanetSandbox::Configuration
-
#register_dsl(name, source)- Register a DSL -
#unregister_dsl(name)- Remove a DSL -
#enable_dsl(name)- Enable by default -
#disable_dsl(name)- Disable from defaults -
#dsl_registered?(name)- Check if registered -
#dsl_names- List all registered DSLs
JanetSandbox::Result
-
#raw- Full parsed result -
#[key]- Bracket access to hash keys (symbol or string) -
#to_h- Hash representation -
#to_json- JSON string
License
MIT License. See LICENSE.txt.