Packages
Composable, validated actions for Elixir applications with built-in AI tool integration
Current section
Files
Jump to
Current section
Files
lib/jido_tools/lua_eval.ex
defmodule Jido.Tools.LuaEval do
@moduledoc """
Execute a Lua code string in a sandboxed VM and return the values.
## Features
- **Safe by default:** Uses tv-labs/lua's sandbox defaults to disable unsafe functions
- **Timeout protection:** Configurable timeout with task isolation
- **Global injection:** Pass Elixir values into the Lua environment
- **Flexible returns:** Return all values or just the first one
## Security
By default, the following unsafe Lua libraries are disabled:
- `os` (getenv, execute, exit, etc.)
- `io` and `file`
- `package`, `require`, `dofile`
- `load`, `loadfile`, `loadstring`
Safe libraries remain enabled:
- `string`, `math`, `table`
- Basic Lua operations
Set `enable_unsafe_libs: true` to disable sandboxing (use with caution).
## Examples
# Simple arithmetic
iex> Jido.Tools.LuaEval.run(%{code: "return 2 + 2"}, %{})
{:ok, %{results: [4]}}
# With globals
iex> Jido.Tools.LuaEval.run(%{
code: "return x + 5",
globals: %{"x" => 10}
}, %{})
{:ok, %{results: [15]}}
# Return first value only
iex> Jido.Tools.LuaEval.run(%{
code: "return 1, 2, 3",
return_mode: :first
}, %{})
{:ok, %{result: 1}}
# Timeout protection
iex> Jido.Tools.LuaEval.run(%{
code: "while true do end",
timeout_ms: 50
}, %{})
{:error, %Jido.Action.Error.TimeoutError{timeout: 50}}
"""
use Private
alias Jido.Action.Error
use Jido.Action,
name: "lua_eval",
description: "Execute a Lua code string in a sandboxed VM and return the values.",
category: "scripting",
vsn: "0.1.0",
schema: [
code: [
type: :string,
required: true,
doc: "Lua code to execute (typically ends with `return ...` to produce values)."
],
globals: [
type: :map,
default: %{},
doc: "Map of global variables to inject into Lua state. Keys can be atoms or strings."
],
return_mode: [
type: {:in, [:list, :first]},
default: :list,
doc:
"Shape of output: :list returns all values under `results`, :first returns only the first under `result`."
],
enable_unsafe_libs: [
type: :boolean,
default: false,
doc: "Enable unsafe libs (os/package/require/etc.) by disabling sandboxing."
],
timeout_ms: [
type: :non_neg_integer,
default: 1000,
doc: "Execution timeout in milliseconds."
],
max_heap_bytes: [
type: :non_neg_integer,
default: 0,
doc: "Per-process heap limit in bytes (0 = disabled)."
]
],
output_schema: []
@impl true
def run(params, _context) do
if Code.ensure_loaded?(Lua) do
timeout_ms = Map.get(params, :timeout_ms, 1000)
parent = self()
ref = make_ref()
{:ok, pid} =
Task.Supervisor.start_child(Jido.Action.TaskSupervisor, fn ->
send(parent, {:lua_eval_result, ref, do_run(params)})
end)
monitor_ref = Process.monitor(pid)
case await_lua_result(ref, pid, monitor_ref, timeout_ms) do
{:ok, result} ->
cleanup_lua_task(ref, monitor_ref)
result
{:exit, reason} ->
cleanup_lua_task(ref, monitor_ref)
return_error(:lua_error, "Lua task exited: #{inspect(reason)}")
:timeout ->
_ = Process.exit(pid, :kill)
wait_for_lua_down(monitor_ref, pid, 100)
cleanup_lua_task(ref, monitor_ref)
timeout_error(timeout_ms)
end
else
msg =
"Lua library (:lua) is not available. Add {:lua, \"~> 0.3\"} to your deps and run mix deps.get"
return_error(:dependency_error, msg)
end
end
private do
defp await_lua_result(ref, pid, monitor_ref, timeout_ms) do
receive do
{:lua_eval_result, ^ref, result} ->
{:ok, result}
{:DOWN, ^monitor_ref, :process, ^pid, :normal} ->
wait_for_lua_result(ref, 100)
{:DOWN, ^monitor_ref, :process, ^pid, reason} ->
{:exit, reason}
after
timeout_ms ->
:timeout
end
end
defp wait_for_lua_result(ref, wait_ms) do
receive do
{:lua_eval_result, ^ref, result} -> {:ok, result}
after
wait_ms -> {:exit, :normal}
end
end
defp wait_for_lua_down(monitor_ref, pid, wait_ms) do
receive do
{:DOWN, ^monitor_ref, :process, ^pid, _reason} -> :ok
after
wait_ms -> :ok
end
end
defp cleanup_lua_task(ref, monitor_ref) do
Process.demonitor(monitor_ref, [:flush])
flush_lua_results(ref)
end
defp flush_lua_results(ref) do
receive do
{:lua_eval_result, ^ref, _result} ->
flush_lua_results(ref)
after
0 -> :ok
end
end
end
defp do_run(params) do
code = Map.fetch!(params, :code)
globals = Map.get(params, :globals, %{})
return_mode = Map.get(params, :return_mode, :list)
enable_unsafe_libs = Map.get(params, :enable_unsafe_libs, false)
max_heap_bytes = Map.get(params, :max_heap_bytes, 0)
if is_integer(max_heap_bytes) and max_heap_bytes > 0 do
:erlang.process_flag(:max_heap_size, %{size: max_heap_bytes, kill: true})
end
lua =
if enable_unsafe_libs do
# Explicitly disable sandboxing to allow unsafe libs
Lua.new(sandboxed: [])
else
# Defaults sandbox unsafe functions (os/package/require/load/io/file/etc.)
Lua.new()
end
lua =
Enum.reduce(globals || %{}, lua, fn {k, v}, acc ->
{encoded, acc2} = Lua.encode!(acc, v)
path = if is_list(k), do: k, else: [k]
Lua.set!(acc2, path, encoded)
end)
try do
{values, _state} = Lua.eval!(lua, code)
result =
case return_mode do
:first -> %{result: List.first(values)}
_ -> %{results: values}
end
{:ok, result}
rescue
e in Lua.CompilerException ->
return_error(:compile_error, Exception.message(e))
e in Lua.RuntimeException ->
return_error(:lua_error, Exception.message(e))
e ->
return_error(:lua_error, Exception.message(e))
end
end
defp return_error(type, message) do
{:error,
Error.execution_error(message, %{
type: type,
reason: %{type: type, message: message}
})}
end
defp timeout_error(timeout_ms) do
{:error,
Error.timeout_error("Lua execution timed out after #{timeout_ms}ms", %{
timeout: timeout_ms,
reason: %{type: :timeout, timeout_ms: timeout_ms}
})}
end
end