Far eseguire codice agli agenti AI in sicurezza in Elixir con tv-labs/lua
Come tv-labs/lua dà agli agenti AI un runtime Lua in sandbox dentro Elixir: funzioni sicure esposte, limiti di esecuzione e integrazione nel loop dell’agente.
Se costruisci agenti AI in Elixir e vuoi che eseguano codice o chiamino tool senza poter danneggiare la tua piattaforma, usa tv-labs/lua. È un runtime Lua 5.3 che gira interamente sulla BEAM, in sandbox per impostazione predefinita, con un’API piccola e piacevole per esporre esattamente le funzioni Elixir che scegli tu. In Turn.io, dove sono responsabile delle funzionalità AI, ho collegato il nostro motore di app Lua all’Agent block, così gli agenti eseguono tool e codice personalizzato dentro una sandbox Lua. È così che i clienti collegano gli agenti a cartelle cliniche e API esterne senza poter rompere nulla. Usiamo questa libreria, funziona benissimo, e questo articolo è soprattutto un ringraziamento al team di tv-labs, con il codice che spiega perché.
In breve
- tv-labs/lua è una VM Lua 5.3 scritta in Elixir: niente NIF, niente C, niente da compilare.
Lua.new/1è in sandbox di default: nienteio, nienteos.execute, nienterequireoload.- Esponi le funzionalità con
use Lua.APIedeflua, e passi i segreti tramite uno storage privato che lo script non può leggere. :max_instructions,:max_call_depthe:max_string_byteslimitano il lavoro che uno script può fare; un processo monitorato aggiunge un timeout reale e un tetto di memoria.- Per un agente, “eseguire codice” diventa un solo tool: il modello scrive Lua, tu lo esegui con dei limiti e restituisci al modello i risultati o un errore leggibile.
Perché Lua è adatto ai tool degli agenti
Quando un agente deve fare più che chiamare un singolo endpoint, per esempio recuperare un record, filtrare una lista e calcolare una data, hai due strade. Puoi definire una decina di tool molto specifici e sperare che il modello li concateni bene, oppure puoi dargli un solo tool che esegue un breve script su un’API che controlli tu. La seconda strada spesso è più semplice e costa meno token, ma solo se il runtime è abbastanza sicuro da metterlo in mano a un modello.
Lua si presta bene:
- È piccolo. I modelli lo scrivono bene, la standard library è compatta e c’è poca superficie su cui ragionare.
- È facile da restringere. Uno script non ha accesso a filesystem, rete o variabili d’ambiente se non glielo dai tu. Ogni capacità è una funzione che inserisci tu nello stato.
- È prevedibile. Se metti in sandbox anche orologio e casualità (
os.time,os.clock,os.date,math.random), l’output di uno script dipende solo dal suo input e dalle funzioni che esponi. Le esecuzioni dei tool diventano riproducibili, e te ne accorgerai quando scriverai le eval. - Gira dentro la BEAM. Con tv-labs/lua la VM è Elixir puro, quindi uno script gira in un normale processo che puoi monitorare, limitare e terminare. Ogni valore
Luaè stato immutabile che passi esplicitamente, quindi tra due esecuzioni non trapela nulla, a meno che non sia tu a passare lo stato.
La libreria è nata come wrapper Elixir attorno a Luerl di Robert Virding, e il README gli riconosce generosamente il merito di averle aperto la strada. Dalla 1.0 è una reimplementazione completa di lexer, parser e VM di Lua 5.3 in Elixir, con messaggi di errore migliori e funzionalità pensate apposta per eseguire codice non fidato. Il README cita esplicitamente il codice scritto da agenti AI come uno dei casi d’uso principali.
Uno stato in sandbox e una prima eval
Aggiungi {:lua, "~> 1.0"} alle dipendenze. Lua.new/1 ti dà una VM in sandbox, e Lua.eval!/3 restituisce la lista dei valori di ritorno insieme allo stato aggiornato:
lua = Lua.new(max_instructions: 1_000_000, max_call_depth: 200)
{[4], _lua} = Lua.eval!(lua, "return 2 + 2")
Lua.eval!(lua, ~S[os.execute("ls")])
# ** (Lua.RuntimeException) Lua runtime error: os.execute(_) is sandboxed
La deny-list di default copre la libreria io, file, os.execute, os.exit, os.getenv, os.remove, os.rename, os.tmpname, package, require, load, loadfile, loadstring e dofile. Una funzione in sandbox esiste ancora, ma chiamarla solleva un errore. Puoi aprire eccezioni mirate con exclude:, oppure aggiungere altri percorsi con Lua.sandbox/2, per esempio Lua.sandbox(lua, [:os, :time]).
I limiti sono opzioni di Lua.new/1. :max_instructions è un budget di istruzioni per ogni valutazione, e quando si esaurisce viene sollevato "instruction budget exceeded". :max_call_depth trasforma una ricorsione fuori controllo in "stack overflow". La VM rifiuta anche le allocation bomb come string.rep("x", 1e15) prima di allocare, con un tetto che puoi abbassare tramite :max_string_bytes. Sono tutti normali errori Lua: uno script può intercettarli con pcall e il tuo codice può gestirli con un rescue attorno a eval!.
Esporre funzioni Elixir a Lua
Per un caso isolato, Lua.set!/3 accetta una funzione che riceve la lista degli argomenti e restituisce una lista di risultati:
lua = Lua.set!(Lua.new(), [:sum], fn args -> [Enum.sum(args)] end)
{[10], _lua} = Lua.eval!(lua, "return sum(1, 2, 3, 4)")
Per l’API di un agente preferisco un modulo con use Lua.API e deflua. L’opzione scope raggruppa le funzioni sotto un namespace, e la forma con state dà alla funzione lo stato Lua corrente:
defmodule MyApp.AgentTools.LuaAPI do
use Lua.API, scope: "records"
# Da Lua si chiama con records.get("123")
deflua get(id), state do
client = Lua.get_private!(state, :records_client)
case MyApp.Records.fetch(client, id) do
{:ok, record} -> Lua.encode!(state, record)
{:error, :not_found} -> {:error, "record #{id} not found"}
end
end
end
defmodule MyApp.AgentTools.Log do
use Lua.API
# Sostituisce print/1 di Lua: l'output viene raccolto invece di finire su stdout
@variadic true
deflua print(args), state do
line = Enum.map_join(args, "\t", &to_string/1)
{[], Lua.put_private(state, :output, [line | Lua.get_private!(state, :output)])}
end
end
Ci sono alcuni dettagli che vale la pena conoscere, e in tutti la libreria sta facendo attenzione al posto tuo:
- Lo storage privato (
Lua.put_private/3,Lua.get_private!/2) contiene valori che le tue funzioni Elixir possono leggere ma il codice Lua no. È lì che vanno il client dell’API e le sue credenziali. Il modello non li vede mai, e nessuno script può stamparli. - I valori restituiti devono essere codificati. Una
defluache restituisce una mappa semplice solleva un errore e ti dice di usareLua.encode!/2, che restituisce{encoded, state}. È proprio la forma chedefluaaccetta, quindiLua.encode!(state, record)può essere l’ultima espressione. {:error, reason}diventa un errore Lua. Lo script può intercettarlo conpcall, oppure lasciarlo risalire fino al tuo codice Elixir.- L’arità viene controllata. Chiamare
records.get("1", "2")fallisce con “expected 1 arguments, got 2”, un messaggio su cui il modello può agire.
Integrarlo nel loop dell’agente
Nell’agente, tutto questo è un solo tool. La definizione dice al modello cosa c’è nella sandbox:
{
"name": "run_lua",
"description": "Run a short Lua 5.3 script in a sandbox. records.get(id) returns a record as a table. Use print() for notes. Return the values you need; they are sent back to you as JSON.",
"input_schema": {
"type": "object",
"properties": { "code": { "type": "string" } },
"required": ["code"]
}
}
Quando il modello lo chiama, l’harness costruisce uno stato nuovo per quella chiamata, carica le API, inietta il client della conversazione ed esegue lo script in un processo separato con un timeout e un tetto sull’heap. Il wrapper del processo segue la guida Security & Sandboxing, una delle pagine più utili della documentazione:
defmodule MyApp.AgentTools.RunLua do
@heap_words 8_000_000
@timeout_ms 2_000
def execute(%{"code" => code}, %{records_client: client}) do
lua =
Lua.new(max_instructions: 5_000_000, max_call_depth: 200, max_string_bytes: 1_000_000)
|> Lua.load_api(MyApp.AgentTools.LuaAPI)
|> Lua.load_api(MyApp.AgentTools.Log)
|> Lua.put_private(:records_client, client)
|> Lua.put_private(:output, [])
case run_isolated(lua, code) do
{:ok, results, output} -> {:ok, %{"result" => Enum.map(results, &to_json/1), "output" => output}}
{:error, message} -> {:error, message}
end
end
defp run_isolated(lua, code) do
parent = self()
prev_trap = Process.flag(:trap_exit, true)
worker =
spawn_link(fn ->
# include_shared_binaries richiede OTP 27+
Process.flag(:max_heap_size, %{
size: @heap_words,
kill: true,
error_logger: false,
include_shared_binaries: true
})
result =
try do
{results, lua} = Lua.eval!(lua, code, source: "run_lua")
{:ok, results, lua |> Lua.get_private!(:output) |> Enum.reverse()}
rescue
e in [Lua.CompilerException, Lua.RuntimeException] -> {:error, Exception.message(e)}
end
send(parent, {:result, result})
end)
try do
receive do
{:result, result} -> result
{:EXIT, ^worker, :killed} -> {:error, "memory limit exceeded"}
{:EXIT, ^worker, reason} -> {:error, "crashed: #{inspect(reason)}"}
after
@timeout_ms ->
Process.exit(worker, :kill)
{:error, "timed out after #{@timeout_ms}ms"}
end
after
Process.flag(:trap_exit, prev_trap)
end
end
# Le tabelle Lua decodificate arrivano come liste di coppie {chiave, valore}
defp to_json(value) when is_list(value), do: Lua.Table.deep_cast(value)
defp to_json(value), do: value
end
Il loop poi trasforma entrambi i rami in un tool result:
case MyApp.AgentTools.RunLua.execute(call.input, ctx) do
{:ok, payload} ->
%{type: "tool_result", tool_use_id: call.id, content: Jason.encode!(payload)}
{:error, message} ->
%{type: "tool_result", tool_use_id: call.id, content: message, is_error: true}
end
Con uno script come questo, scritto dal modello:
local r = records.get("123")
print("visits:", #r.visits)
return { name = r.name, last_visit = r.visits[#r.visits].date }
il modello riceve {"output": ["visits:\t2"], "result": [{"last_visit": "2026-03-04", "name": "Test"}]} con i miei dati di test. Se scrive return nope.x, riceve attempt to index a nil value (global 'nope') (at run_lua:1). Se entra in un loop infinito, riceve instruction budget exceeded. Sono tutti messaggi che il modello può leggere e correggere al passo successivo, ed è esattamente quello che vuoi da un tool.
A cosa fare attenzione
Imposta ogni limite in modo esplicito. :max_instructions e :max_call_depth hanno :infinity come default, e il tetto sulle stringhe è di 256 MiB. Per il codice di un agente ti servono numeri piccoli, e ti serve un :max_string_bytes ben al di sotto del tetto sull’heap, come raccomanda la guida, così le string bomb vengono rifiutate in modo deterministico invece di dipendere dai tempi del garbage collector.
Il budget di istruzioni non è un orologio. Limita il lavoro che fa la VM, ed è ottimo perché è deterministico. Però non conta il tempo passato dentro le tue funzioni Elixir, e una chiamata lenta a un’API esterna è proprio dove un tool reale passa la maggior parte del tempo. Tieni il timeout reale, e dai anche ai tuoi client HTTP dei timeout propri.
Esponi capacità, non una cassetta degli attrezzi. Ogni deflua è superficie d’attacco, perché lo script lo scrive un modello che può essere manipolato da qualunque testo finisca nel suo contesto. records.get(id), limitato al client che hai iniettato per questa conversazione, va bene. http.request(url) no. Valida gli argomenti dentro ogni funzione e tratta tutto quello che torna da un’API esterna come dati. Ne ho scritto di più in come gli agenti passano la mano in sicurezza.
Restituisci gli errori al modello, non solo ai log. Intercetta Lua.CompilerException e Lua.RuntimeException e rimanda Exception.message/1 come tool result. È breve, senza codici ANSI, e include il nome della sorgente e il numero di riga, che di solito bastano al modello per correggere lo script. Se ti servono errori strutturati per i log o per una UI, Lua.RuntimeException.to_map/2 ti dà una mappa serializzabile in JSON.
Sappi cosa resta fuori. La libreria punta a Lua 5.3 senza coroutine, weak table e libreria debug completa, tutte indicate nel README come scelte deliberate. Nella mia esperienza nessuna di queste è mai servita per i tool degli agenti, ma è bene saperlo prima di promettere ai clienti “Lua completo”.
Un piccolo contributo
Mentre ospitavamo app Lua in sandbox abbiamo incontrato un bug: restituire una tabella che contiene sé stessa, come nel diffusissimo idioma di classe T.__index = T, mandava in ricorsione infinita il confine della eval. Ho mandato una piccola correzione che interrompe l’attraversamento quando incontra un ciclo, ed è uscita nella 1.0.2. È stata revisionata e fatta merge in giornata, il che dice molto su come viene gestito il progetto.
Grazie, tv-labs
Eseguire in sicurezza codice scritto da un modello è uno di quei problemi che sembrano semplici finché non elenchi tutto quello che può andare storto. tv-labs/lua si occupa di gran parte di quell’elenco: sandbox di default, limiti deterministici, errori con numero di riga e una documentazione che ti dice con onestà dove finiscono le garanzie della VM e dove iniziano le tue. Un enorme grazie al team di tv-labs per averla costruita e condivisa. Parti dalla documentazione su hexdocs, e leggi la guida Security & Sandboxing prima di andare in produzione.
Se stai costruendo agenti che devono chiamare sistemi reali senza metterli a rischio, aiuto i team a progettare agenti AI, i loro tool e le eval che dimostrano che funzionano.