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_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
end

The 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. Keep include_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 with badarg, 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 on Schooner.Environment.new/1; default sandboxes have no wall-clock access. (schooner debug) (Schooner.Debug) is opt-in in the same way: its trace and print send text to a sink the embedder chooses, and default sandboxes have no such output.
  • 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 — a closure's environment refers to globals held in the process dictionary, so a closure returned by eval is valid only in the process that evaluated it. 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
  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
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.