0.0
No release in over 3 years
Embed the Janet programming language in Ruby apps via WebAssembly. Safely execute user-authored scripts with configurable resource limits, and pluggable DSL modules. Ideal for business logic, dynamic rules, and user-extensible workflows.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 13.0
~> 3.12
~> 1.50

Runtime

>= 28.0, < 43.0
 Project Readme

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  #=> 50

Configurable 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)
end

Execution 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 string

The 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)
end

Model Concern

class ScriptRule < ApplicationRecord
  include JanetSandbox::Rails::ScriptConcern

  # Specify DSLs for this model
  janet_dsls :form_helpers, :validation_helpers

  belongs_to :form
end

Expected 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_h

Security

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/spawn removed
  • 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
end

Custom 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'
end

Development

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:

  1. Download wasi-sdk from the releases page
  2. Extract it and set the environment variable:
export WASI_SDK_PATH=~/wasi-sdk-22.0

Build:

cd wasm
./build.sh

Or via rake:

rake janet_sandbox:build_wasm

The build script downloads Janet source files automatically if not present.

Thread Safety

  • Engine is thread-safe and should be shared (one per app)
  • Sandbox is 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.