Raxol.REPL.Sandbox (Raxol v2.8.0)

View Source

AST-based safety checker for REPL code evaluation.

Not a security boundary

This is a MITIGATION. It raises the cost of the obvious attempts; it does not confine anything. A gap in it reaches the OS user the BEAM node runs as, with that node's full authority: its file handles, its network, its signing keys if it has any. Read the trust-boundary section of Raxol.REPL.Evaluator before putting this in front of input you do not trust.

Why it cannot be a boundary: every clause decides safety from a module NAME, resolved statically from the submitted AST. Anything that changes which module a name reaches at runtime sits underneath the check rather than inside it, which is why import, alias, require and use are refused outright rather than resolved (#1045). Each such form found so far has been a CVE-class hole on an anonymous surface, and the checker's own history is the argument against trusting it: computed receivers, Module.concat/1 alias resolution, the bare-name capture forms of apply and spawn, maps forged with a __struct__ key, sorters naming a runtime module (Enum.sort(l, m) calls m.compare/2), and raise, calendar, time zone database and Map.from_struct/1 arguments holding one (DateTime.now(tz, m) calls m.time_zone_period_from_utc_iso_days/2) were each :ok at :strict until they were not.

A forged struct is the same hole reached through data rather than a name. A map whose __struct__ key names a module is protocol-dispatched (Collectable, Enumerable, String.Chars, Inspect, Access) to that module's implementations, so collecting into %{__struct__: File.Stream, ...} writes a file without File ever appearing in a call. :strict refuses every form it knows can set that key where a map is built: a :__struct__ or computed map key, a struct literal outside a short allowlist, and the calls that write keys or values from runtime data (Map.put/3 with a computed key, Map.new/1 or Enum.into/2 into a map from a computed input, put_in/3 with a computed path, ...). It also refuses every __-prefixed function or macro of an allowlisted module, called or captured: the generated URI.__struct__/1 writes a caller's :__struct__ pair as readily as a map literal does. That closes what the source shows, not what runtime data can hide: a form this list does not name reaches the same implementations, and only the OS-isolated peer node of #1231 closes the class.

What actually keeps an untrusted caller away from the evaluator is the deployment flag, Raxol.Core.Boundary.Evaluation.exposed?/0. A node with that flag set is a node you have decided may run submitted code.

Levels

  • :none -- allow everything (explicit trusted-local launcher opt-in)

  • :standard -- deny destructive and process-creation operations (check/1 default)

  • :strict -- whitelist-only (the level every served launch gets)

    iex> Sandbox.check("Enum.map([1,2], & &1 * 2)") :ok

    iex> match?({:error, _}, Sandbox.check(~s[System.cmd("rm", ["-rf", "/"])])) true

Summary

Functions

Checks code for safety violations at the given strictness level.

Types

level()

@type level() :: :none | :standard | :strict

Functions

check(code, level \\ :standard)

@spec check(String.t(), level()) :: :ok | {:error, [String.t()]}

Checks code for safety violations at the given strictness level.

Returns :ok if safe, or {:error, [violation_message]} if violations found. Raises ArgumentError for a level other than :none, :standard or :strict.