Schooner is designed for running Scheme scripts you do not fully trust — config DSLs, user-supplied rules, plugin scripts. This guide is the safety reference: which entry point to use for which trust posture, how to bound resource use, what crosses the host boundary and what doesn't.
Trust posture: pick your entry point carefully
The run and eval families differ in what's in scope when
the script starts. The naming is the opposite of what reflex
suggests:
| Family | Auto-imports | Trust posture |
|---|---|---|
Schooner.run/1, run!/1 | every shipped standard library, when the script declares no imports | Not sandbox-safe |
Schooner.eval/2,3, eval!/2,3 | none — bindings come from the env and the script's own (import ...) | Sandbox-safe |
For untrusted input, always use eval/2. A script that
declares no imports cannot reach any primitive — even + is
unbound:
{:error, %Schooner.Eval.Error{}} = Schooner.eval("(+ 1 2)", Schooner.Env.new())To run, the script must declare exactly which libraries it needs:
{:ok, 3} = Schooner.eval("(import (only (scheme base) +)) (+ 1 2)", Schooner.Env.new())
# `sin` is not in scope — the script asked only for `+` from `(scheme base)`,
# and didn't import `(scheme inexact)` at all.
{:error, _} = Schooner.eval("(import (only (scheme base) +)) (sin 0)", Schooner.Env.new())The embedder thus controls the surface, the script controls the exact bindings it pulls from that surface.
Locking down which libraries exist
By default, Schooner.Env.new() makes every shipped standard
library available for the script to import. To restrict the menu
itself, use Schooner.Environment.new/1:
env =
Schooner.Environment.new(
standard_libraries: [:base, :char] # only these two are importable
)
# Script can import (scheme base), but (scheme inexact) is not in the registry.
{:error, %Schooner.Library.NotFoundError{}} =
Schooner.eval("(import (scheme inexact)) (sin 0)", env):standard_libraries accepts:
:default— every shipped library (the default).:none— empty registry.- a list mixing atom shortcuts (
:base,:char,:inexact,:complex,:cxr,:write,:read,:lazy,:case_lambda) and canonical names like["scheme", "base"].
Resource limits — bound the BEAM, not the interpreter
Schooner does not implement its own CPU / memory counters. The recommended pattern is to run untrusted scripts inside a short-lived process bounded by the standard BEAM tools:
def run_untrusted(source, env_opts) do
task =
Task.Supervisor.async_nolink(MyApp.TaskSupervisor, fn ->
# Measured in words: 6_250_000 words is about 50 MB on a 64-bit VM.
Process.flag(:max_heap_size, %{
size: 6_250_000,
kill: true,
error_logger: false,
include_shared_binaries: true
})
env = Schooner.Environment.new(env_opts)
Schooner.eval(source, env)
end)
case Task.yield(task, 5_000) || Task.shutdown(task, :brutal_kill) do
{:ok, result} -> result # {:ok, value} | {:error, _}
{:exit, :killed} -> {:error, :resource_limit}
nil -> {:error, :timeout}
end
endThe environment is built inside the task because its globals live
in the process dictionary of the process that creates it; an
environment built in the caller cannot be used from the task. The
heap limit is also set from inside the task, with Process.flag/2:
Task.Supervisor.async_nolink/3 does not take spawn options, and
passing it :max_heap_size has no effect.
Three settings bound the task:
:max_heap_size— when the spawned task's heap exceeds the cap, the BEAM kills it. A runaway(make-vector 1000000000)allocates a single BEAM tuple, hits the cap, and goes down. The host process is unaffected. Keepinclude_shared_binaries: true: Scheme strings and bytevectors are binaries, and large ones live off the process heap, so without it a script can allocate an arbitrarily large(make-bytevector 1000000000)without reaching the cap. The option needs OTP 26 or later; OTP 25 rejects it withbadarg, so on OTP 25 drop the key and bound memory another way, for example with an OS-level limit on the node.Task.yield(task, timeout)— bounds wall-clock time. A script that loops forever doesn't return on its own; the yield window expires and we move to shutdown.Task.shutdown(task, :brutal_kill)— kills the task if the yield window expires without a result.
Set max_heap_size and the yield timeout to suit your
workload. The same pattern works with plain spawn + monitoring
if you don't want a Task supervisor.
What the script can and cannot do
Even with the standard surface fully imported, an eval/2
script:
- Cannot do file I/O.
(scheme file),(scheme load),(scheme repl),(scheme process-context),(scheme eval)are not shipped at all — see Deviations.(scheme time)ships as an opt-in host library (Schooner.Time) and is unreachable until the embedder lists it onSchooner.Environment.new/1; default sandboxes have no wall-clock access. - Cannot mutate. Schooner's value model has no
destructive operations.
set!,set-car!,set-cdr!,string-set!,vector-set!,bytevector-u8-set!, record mutators,string-fill!,vector-fill!,list-set!are not defined. - Cannot escape the BEAM process budget. The host's
:max_heap_sizeand timeout bounds apply to anything the script does inside the eval task. - Cannot inspect host data passed through
Schooner.Host.foreign/1. Foreign values are opaque to Scheme — no constructor, no accessor,writeredacts to#<foreign>.
Host-boundary continuation barrier
A Scheme callback that captures a call/cc continuation, then
tries to escape via that continuation after the host call has
returned, is unsupported in v1. Schooner's call/cc is
escape-only — a continuation invoked outside its dynamic extent
already raises Schooner.Eval.Error, and the host-boundary
case fires the same guard.
The practical rule:
A continuation captured inside a callback is valid only within the dynamic extent of the host call that invoked the callback. Use
(raise ...)andwith-exception-handlerfor long-lived non-local exit instead.
Exceptions cross the host boundary cleanly through the existing handler stack — see Host Functions for the patterns.
This rule is forward-compatible with v2.0's first-class
call/cc, where it becomes a hard runtime barrier.
What doesn't cross the host boundary
Some values cannot be marshalled out of Scheme back into idiomatic
Elixir. Schooner.eval/2 may return them unchanged, but writing
host code that manipulates them is unsupported:
- Closures — a closure's environment refers to globals held
in the process dictionary, so a closure returned by
evalis valid only in the process that evaluated it. Treat it as a handle, invoke it viaSchooner.apply/2, don't pass it to other processes. - Records — opaque from the host side unless your host code
knows the type. Use
Schooner.Host.foreign/1to wrap host data you want to pass through Scheme; don't define record types in Scheme expecting the host to use them as Elixir structs. - Parameters — the
Schooner.Value.parameter_v/0shape is an internal detail. Treat them like closures: invoke viaSchooner.apply/2, don't move across processes. - Continuations — already covered above. Even within v1's
escape-only model, do not try to capture and re-invoke a
continuation from outside its original
call/ccsite.
For exposing host data to Scheme (the opposite direction),
use Schooner.Host.foreign/1. The wrapped term is fully
opaque from the script side and round-trips through any
value-shaped position (variables, pairs, vectors, closures).
A minimal sandboxed entrypoint
Putting it together — what a host-side wrapper might look like:
defmodule MyApp.Sandbox do
def run(source, opts \\ []) do
timeout = Keyword.get(opts, :timeout, 5_000)
# In words: about 50 MB on a 64-bit VM.
heap = Keyword.get(opts, :max_heap_size, 6_250_000)
task =
Task.Supervisor.async_nolink(MyApp.TaskSupervisor, fn ->
Process.flag(:max_heap_size, %{
size: heap,
kill: true,
error_logger: false,
include_shared_binaries: true
})
Schooner.eval(source, sandbox_env())
end)
case Task.yield(task, timeout) || Task.shutdown(task, :brutal_kill) do
{:ok, {:ok, value}} -> {:ok, value}
{:ok, {:error, error}} -> {:error, {:script, error}}
{:exit, :killed} -> {:error, :resource_limit}
nil -> {:error, :timeout}
end
end
# Built inside the task on every run: an environment belongs to the
# process that creates it.
defp sandbox_env do
Schooner.Environment.new(
standard_libraries: [:base, :char, :write],
pre_imports: [["scheme", "base"]]
)
end
endThe script gets (scheme base) for free (the :pre_imports),
can additionally import (scheme char) or (scheme write), and
runs inside a heap-limited time-bounded Task. Anything else —
file I/O, network, the host's data — is invisible until the
host registers a library that exposes it.