Raxol.REPL.Evaluator (Raxol v2.8.0)

View Source

Elixir code evaluator with timeout protection, IO capture, and persistent bindings.

Each evaluator maintains its own binding context across evaluations, so variables defined in one eval call are available in the next.

evaluator = Evaluator.new()
{:ok, result, evaluator} = Evaluator.eval(evaluator, "x = 1 + 2")
{:ok, result, evaluator} = Evaluator.eval(evaluator, "x * 10")
result.value  #=> 30

Trust boundary

There is none here. eval/3 hands the code to Code.eval_string/3 with base_env/0, which is __ENV__ with :file and :line overridden: an unrestricted Macro.Env. No AST is inspected and no module is denied. Raxol.REPL.Sandbox is a separate check a caller makes BEFORE calling eval/3 -- the anonymously served playground demo does (Raxol.Playground.Demos.ReplDemo gates on Sandbox.check(code, :strict)) -- and nothing this module does contributes to it.

The caps bound one process's resource use:

  • :timeout -- how long the single evaluation process may run
  • :max_heap_bytes -- that process's heap, via :max_heap_size with kill: true and include_shared_binaries: true, so off-heap (refc) binaries count too (without it String.duplicate("x", 200_000_000) returned under ReplDemo's 8 MB cap). The VM checks this cap at garbage collection, so it bounds what the process HOLDS, not one allocation: a builtin that allocates its whole result first (IO.iodata_to_binary/1 over a large iolist, Tuple.duplicate/2) builds it before the kill, and one the allocator cannot satisfy aborts the node. Raxol.REPL.Sandbox refuses the ones measured so far at :strict; only OS-level limits close the class (issue #1231)
  • :max_result_bytes -- the value plus bindings plus output it may hand back (refused when over), and the error message (truncated when over)
  • Raxol.REPL.CaptureIO's limit -- bytes of captured output retained, and the wait on an io_lib expansion run on the evaluation's behalf

They bound nothing the evaluation creates or reaches. Filesystem reads and writes, :os.cmd/1, Port.open/2, Node.connect/1 and :erlang.halt/0 resolve exactly as any other call does. The timeout and the heap cap are both enforced by the VM: stop_eval/3 signals :kill, the one reason an evaluation that sets Process.flag(:trap_exit, true) cannot intercept, so no evaluation outlives its own timeout. It signals ONE pid, though -- a process the evaluation spawned is not linked to it and keeps running with full node authority after eval/3 has returned {:error, "Evaluation timed out after ...", evaluator}.

So code handed to this evaluator runs with the full authority of the node's OS user. Expose the surface only to a principal already trusted with that uid. An AST allowlist standing in front of it is the whole of the boundary on an anonymous surface, and a gap in the allowlist is an escape; real confinement wants separate OS uids or containers, and this is one BEAM, one uid. The open design question -- stop evaluating untrusted code on the exposed surfaces, or add per-tenant OS isolation -- is issue #1033.

Summary

Functions

Returns the list of current variable bindings as [{name, value}].

Clears evaluation history, keeping bindings.

Evaluates code in the evaluator's binding context.

Returns evaluation history as [{code, result}], newest first.

Creates a new evaluator with an empty binding context.

Resets all bindings, keeping history.

Enable VFS helpers for this evaluator.

Types

result()

@type result() :: %{value: term(), output: String.t(), formatted: String.t()}

t()

@type t() :: %Raxol.REPL.Evaluator{
  bindings: keyword(),
  env: Macro.Env.t(),
  history: [{String.t(), result()}],
  prelude: String.t()
}

Functions

bindings(evaluator)

@spec bindings(t()) :: keyword()

Returns the list of current variable bindings as [{name, value}].

clear_history(evaluator)

@spec clear_history(t()) :: t()

Clears evaluation history, keeping bindings.

eval(evaluator, code, opts \\ [])

@spec eval(t(), String.t(), keyword()) ::
  {:ok, result(), t()} | {:error, String.t(), t()}

Evaluates code in the evaluator's binding context.

Returns {:ok, result, new_evaluator} on success or {:error, reason, evaluator} on failure. Bindings persist across calls. IO output is captured separately from the return value.

history(evaluator)

@spec history(t()) :: [{String.t(), result()}]

Returns evaluation history as [{code, result}], newest first.

new(opts \\ [])

@spec new(keyword()) :: t()

Creates a new evaluator with an empty binding context.

reset_bindings(evaluator)

@spec reset_bindings(t()) :: t()

Resets all bindings, keeping history.

with_vfs(evaluator)

@spec with_vfs(t()) :: t()

Enable VFS helpers for this evaluator.

Seeds a vfs binding with a fresh virtual filesystem and auto-imports Raxol.REPL.VfsHelpers so shell-like commands are available directly:

evaluator = Evaluator.new() |> Evaluator.with_vfs()
{:ok, _, evaluator} = Evaluator.eval(evaluator, "vfs = mkdir(vfs, \"/docs\")")
{:ok, _, evaluator} = Evaluator.eval(evaluator, "vfs = ls(vfs)")