Running Untrusted Scheme

Copy Markdown View Source

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:

FamilyAuto-importsTrust posture
Schooner.run/1, run!/1every shipped standard library, when the script declares no importsNot sandbox-safe
Schooner.eval/2,3, eval!/2,3none — 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) do
  task =
    Task.Supervisor.async_nolink(
      MyApp.TaskSupervisor,
      fn -> Schooner.eval(source, env) end,
      max_heap_size: %{
        size: 50_000_000,         # ~50 MB heap
        kill: true,
        error_logger: false
      }
    )

  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
end

Three knobs are doing the work:

  • :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.
  • 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) — guarantees the task is gone after the timeout, even if it was busy executing pure Elixir/native code that would not check reductions.

Adjust max_heap_size and the yield timeout to whatever budget makes sense for 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 on Schooner.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_size and 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, write redacts 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 ...) and with-exception-handler for 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 — the captured env carries a process-dictionary globals slot, so a closure returned by eval is valid only in the calling Elixir process. Treat it as a handle, invoke it via Schooner.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/1 to 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/0 shape is an internal detail. Treat them like closures: invoke via Schooner.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/cc site.

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
  @sandbox_env Schooner.Environment.new(
                 standard_libraries: [:base, :char, :write],
                 pre_imports: [["scheme", "base"]]
               )

  def run(source, opts \\ []) do
    timeout = Keyword.get(opts, :timeout, 5_000)
    heap = Keyword.get(opts, :max_heap_size, 50_000_000)

    task =
      Task.async(fn ->
        Process.flag(:max_heap_size, %{size: heap, kill: true, error_logger: false})
        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
end

The 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.