Packages

Legion is an Elixir-native framework for building AI agents

Current section

Files

Jump to
legion lib legion sandbox.ex
Raw

lib/legion/sandbox.ex

defmodule Legion.Sandbox do
@moduledoc """
Sandboxed code evaluation with AST-level safety checks.
Evaluates Elixir code strings in a spawned process with:
- **AST validation** — before evaluation, the code is parsed and walked to reject
dangerous forms (`defmodule`, `import`, `spawn`, `send`, `receive`, etc.)
and calls to modules not in the allow-list.
- **Module allow-list** — only built-in safe modules (Kernel, Enum, Map, String, …)
and explicitly passed modules may be called. If only some functions from a module
should be exposed, wrap them in a dedicated facade module.
- **Timeout** — evaluation runs in a monitored process that is killed if it exceeds
the deadline.
## Examples
iex> {:ok, {4, _}} = Legion.Sandbox.execute("2 + 2", 5_000)
iex> {:ok, {6, _}} = Legion.Sandbox.execute("Enum.sum([1, 2, 3])", 5_000)
iex> Legion.Sandbox.execute("System.halt()", 5_000)
{:error, "Module System is not allowed"}
iex> Legion.Sandbox.execute("import Enum", 5_000)
{:error, "import is not allowed"}
"""
alias Legion.Sandbox.ASTChecker
@doc """
Evaluates `code_string` in a sandboxed process.
`timeout_ms` controls the maximum execution time (`:infinity` to disable).
`allowed_modules` are aliased and made available to the evaluated code
(on top of the built-in safe modules).
Returns `{:ok, {result, new_bindings}}` on success, or `{:error, reason}` on
validation failure, runtime exception, crash, or timeout. The returned
`new_bindings` can be passed to subsequent calls to preserve variable scope.
"""
def execute(code_string, timeout_ms, allowed_modules \\ [], bindings \\ [])
when is_binary(code_string) and is_list(allowed_modules) and
(is_integer(timeout_ms) or timeout_ms == :infinity) do
with :ok <- ASTChecker.check(code_string, allowed_modules) do
code_string
|> append_aliases(allowed_modules)
|> eval(timeout_ms, bindings)
end
end
defp append_aliases(code_string, allowed_modules) do
aliases =
for module <- allowed_modules, into: "" do
"alias #{module}\n"
end
aliases <> code_string
end
# sobelow_skip ["RCE.CodeModule"]
defp eval(code_string, timeout_ms, bindings) do
parent = self()
{pid, ref} =
spawn_monitor(fn ->
result =
try do
{value, new_bindings} = Code.eval_string(code_string, bindings)
{:ok, {value, new_bindings}}
rescue
e -> {:error, e}
end
send(parent, {:result, self(), result})
end)
receive do
{:result, ^pid, result} ->
Process.demonitor(ref, [:flush])
result
{:DOWN, ^ref, :process, _pid, reason} ->
{:error, {:process_crashed, reason}}
after
timeout_ms ->
Process.demonitor(ref, [:flush])
Process.exit(pid, :kill)
{:error, :timeout}
end
end
end