Skip to content

Make sandboxes pluggable and add a Lua sandbox - #15

Merged
dimamik merged 7 commits into
mainfrom
feat/add-lua-sandbox
Aug 10, 2026
Merged

Make sandboxes pluggable and add a Lua sandbox#15
dimamik merged 7 commits into
mainfrom
feat/add-lua-sandbox

Conversation

@dimamik

@dimamik dimamik commented Aug 5, 2026

Copy link
Copy Markdown
Member

Code execution becomes a Legion.Sandbox behaviour (check, execute, binding_names, prompt_info) - a sandbox owns static validation, evaluation, how bindings persist between executions, and the language-specific parts of the system prompt. The existing Elixir eval moves to Legion.Sandbox.Elixir, stays the default, and behaves as before.

Legion.Sandbox.Lua is the first alternative: Lua via lua, a Lua 5.3 VM written in pure Elixir. The Elixir sandbox has to deny-list its way around the whole language surface, and new escape vectors in that surface keep turning up. Lua inverts the model - nothing inside the VM can reach the host BEAM except the tool functions explicitly bridged in, which makes it the safer choice for less trusted code.

Tools are bridged as global Lua tables (EchoTool.add(1, 2)), with arguments and results converted between Lua tables and Elixir maps/lists. Bindings are the Lua state: globals persist across executions, and the state survives the store round-trip. Evaluation runs through the same Legion.Sandbox.Runner, so timeout, memory, and CPU limits behave identically in both languages. The VM has no state garbage collector yet, so dead tables from an eval stay in the state until upstream ships one; per-eval growth is bounded by the memory limit, and persisted snapshots are compressed.

Select per agent:

def config, do: %{sandbox: Legion.Sandbox.Lua}

or globally:

config :legion, :config, %{sandbox: Legion.Sandbox.Lua}

@dimamik
dimamik requested review from tom-ehh and a lite review from Copilot August 5, 2026 12:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors Legion’s code execution into a pluggable Legion.Sandbox behaviour, extracts shared resource-limiting execution into Legion.Sandbox.Runner, and introduces a new Lua sandbox (Legion.Sandbox.Lua) backed by luerl with a tool-bridging layer.

Changes:

  • Introduce Legion.Sandbox behaviour + move the existing Elixir implementation to Legion.Sandbox.Elixir and update executor/prompt plumbing to use sandbox.prompt_info/0, check/2, execute/5, and binding_names/1.
  • Add Legion.Sandbox.Lua with Lua constraints/prompt text, tool bridging, persistent Lua-state bindings, and tests for safety/bridge semantics.
  • Improve persistence footprint by compressing stored conversation snapshots in the Postgres store.

Reviewed changes

Copilot reviewed 25 out of 26 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
test/legion/sandbox/lua_test.exs Adds coverage for Lua eval, bindings, tool bridge conversions, and sandbox escape blocks.
test/legion/sandbox/elixir_test.exs Updates tests to target Legion.Sandbox.Elixir and adds bindings round-trip assertion.
test/legion/sandbox/ast_checker/rce_attack_vectors_test.exs Updates sandbox aliasing to point at Legion.Sandbox.Elixir.
test/legion/sandbox/ast_checker_test.exs Adds regression tests for struct-forging vectors via Map.* callback APIs.
test/legion/parallel_and_pipeline_test.exs Verifies AgentTool.parallel/2 accepts Lua-bridge list pairs.
test/legion/executor_test.exs Ensures sandbox selection affects schema/prompt and parse rejection handling.
test/legion/agent_server_test.exs Verifies Lua bindings persistence across turns in :conversation scope.
README.md Updates sandbox feature description and documents the new sandbox config key.
mix.lock Locks new dependencies for Lua support (lua, luerl).
mix.exs Adds the :lua dependency and includes the new Sandboxes guide in ExDoc extras.
lib/legion/tools/agent_tool.ex Normalizes [agent, task] pairs to tuples for Lua bridge compatibility.
lib/legion/store/postgres.ex Compresses persisted snapshots via :erlang.term_to_binary/2.
lib/legion/sandbox/runner.ex New shared runner enforcing timeout/heap/reduction limits consistently across sandboxes.
lib/legion/sandbox/lua/constraints.eex Lua-specific constraint text injected into the system prompt.
lib/legion/sandbox/lua.ex Implements the Lua sandbox: parse check, tool bridging, bindings as luerl state, GC sweep.
lib/legion/sandbox/elixir/constraints.eex Elixir-specific constraint text extracted for prompt injection.
lib/legion/sandbox/elixir.ex New Elixir sandbox module wrapping AST checks + Runner execution.
lib/legion/sandbox/ast_checker.ex Updates docs and tightens Map.* allowlist to prevent :__struct__ leakage routes.
lib/legion/sandbox.ex Converts prior module into the Legion.Sandbox behaviour contract + docs.
lib/legion/prompts/system_prompt.eex Generalizes prompt template to be language-agnostic and sandbox-driven.
lib/legion/executor.ex Routes validation/execution/binding display through the selected sandbox module.
lib/legion/agent.ex Documents the new sandbox configuration key for agents.
lib/legion/agent_server.ex Allows sandbox as a known config key.
lib/legion/agent_prompt.ex Pulls language/constraints/tool-usage from sandbox.prompt_info/0.
guides/sandboxes.md New guide explaining sandbox selection, tradeoffs, bridge semantics, and limits.
CHANGELOG.md Documents the new pluggable sandboxes + Lua sandbox addition.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread mix.exs
Comment thread lib/legion/sandbox/runner.ex Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 25 out of 26 changed files in this pull request and generated 1 comment.

Suppressed comments (3)

lib/legion/tools/agent_tool.ex:216

  • pipeline/1 has the same silent-shape problem as parallel/2: the for {agent, _} <- steps comprehension skips non-tuples, so invalid step entries can bypass validation and fail later inside Legion.pipeline/1. Validating step shapes up front yields clearer errors and avoids surprising behavior.
  def pipeline(steps) when is_list(steps) do
    steps = Enum.map(steps, &normalize_pair/1)
    for {agent, _} <- steps, do: check_allowed!(agent)
    Legion.pipeline(steps)

lib/legion/tools/agent_tool.ex:204

  • parallel/2 normalizes [agent, task] pairs, but the subsequent comprehension only checks items that match {agent, _task}; any malformed element is silently skipped and then forwarded to Legion.parallel/2, which can fail later with a harder-to-debug error. It’s better to validate every entry and raise a clear ArgumentError when an entry isn’t a {module, task} pair.

This issue also appears on line 213 of the same file.

  def parallel(tasks, timeout \\ :infinity) when is_list(tasks) do
    tasks = Enum.map(tasks, &normalize_pair/1)
    for {agent, _task} <- tasks, do: check_allowed!(agent)
    Legion.parallel(tasks, timeout)
  end

lib/legion/agent.ex:45

  • The Agent config docs describe Legion.Sandbox.Lua as running in a “pure-Erlang VM”, but this PR adds :lua (tv-labs/lua), which is a Lua VM written in pure Elixir. Updating this avoids confusing readers about the dependency and threat model.
      - `sandbox` — a `Legion.Sandbox` module that validates and evaluates the
        code the agent writes. `Legion.Sandbox.Elixir` (the default) evaluates
        Elixir behind an AST allowlist; `Legion.Sandbox.Lua` evaluates Lua in a
        pure-Erlang VM where only bridged tool functions can reach the host
        (default: `Legion.Sandbox.Elixir`)

Comment thread lib/legion/sandbox/lua.ex
@dimamik
dimamik force-pushed the feat/add-lua-sandbox branch from 7eb0d8b to 0a645a8 Compare August 10, 2026 09:56
@dimamik
dimamik requested a balanced review from Copilot August 10, 2026 09:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Comment thread guides/sandboxes.md
| | `Legion.Sandbox.Elixir` | `Legion.Sandbox.Lua` |
|---|---|---|
| Language | Elixir minus denied forms | Lua 5.3 semantics |
| Stdlib | Allowlisted `Enum`, `String`, `Map`, `Date`/`DateTime`, `Regex`, `JSON`, `URI`, `:math`, ... | Lua's `string`, `table`, `math`; `os.time`/`os.date` (`io`, `file`, `os.getenv`/`os.execute`, `require`, `load`, `print` are blocked) |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: is block the right word here? more like "not allowed"?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think not allowed === blocked ;)

Comment thread guides/sandboxes.md
Comment on lines +84 to +85
Both sandboxes run under the same `Legion.Sandbox.Runner` (timeout,
`max_heap` plus off-heap binary polling, `max_reductions`, priority), so

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: why are some parameters specified as codeblocks, while others are normal text?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Priority is not configurable by the user - that's the reason.

Comment thread guides/sandboxes.md Outdated
@dimamik
dimamik force-pushed the feat/add-lua-sandbox branch from 0a645a8 to 868fab8 Compare August 10, 2026 10:19
@dimamik
dimamik requested a review from tom-ehh August 10, 2026 10:31
Comment thread lib/legion/sandbox/ast_checker.ex Outdated

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be moved under Legion.Sandbox.Elixir? I don't see any uses inside lua sandbox; and the doc references Elixir sandbox only

Comment thread lib/legion/sandbox/elixir.ex Outdated
def execute(code_string, timeout_ms, allowed_modules \\ [], bindings \\ [], limits \\ [])
when is_binary(code_string) and is_list(allowed_modules) and is_list(limits) and
(is_integer(timeout_ms) or timeout_ms == :infinity) do
with :ok <- ASTChecker.check(code_string, allowed_modules) do

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not use check/2?

Comment thread lib/legion/store/postgres.ex Outdated
Comment on lines 47 to 48
conversation, upserted on every save. Step snapshots therefore require no additional migration.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: formatting

Suggested change
conversation, upserted on every save. Step snapshots therefore require no additional migration.
Agent ids must be strings. Snapshots are stored as compressed
`:erlang.term_to_binary/2` blobs - readable only from Elixir, one row
per conversation, upserted on every save. Step snapshots therefore require
no additional migration.

Comment thread README.md Outdated
@tom-ehh

tom-ehh commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

A guard sees the code an agent wrote and decides whether the sandbox may
evaluate it. It runs after the AST checker accepts the code and before the
eval process is spawned, so it is the place for policy the sandbox cannot
express: "never loop over checkout", "no bulk export of the orders table".

We shouldn't mention ASTChecker, since it's not present in general (only in Elixir sandbox)

@tom-ehh tom-ehh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Bar some small nits

@dimamik
dimamik merged commit af9d913 into main Aug 10, 2026
1 check passed
@dimamik
dimamik deleted the feat/add-lua-sandbox branch August 10, 2026 11:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants