Sometimes the code you need to run came from somewhere else: a mod a player wrote, a rule an administrator typed into a form, a tool a model generated. You want to run it without giving it your files, your network, or your program's state.
sandbox compiles a source string into a
fresh partition that holds nothing.
import sandbox
match sandbox.run("fn main(input)\n return input * 2\nend\n", 21)
Ok(answer) -> print(answer)
Error(e) -> print(`the code failed: {e.kind}`)
end
$ scua --allow-sandbox double.scua
42
Creating a sandbox needs the sandbox capability, so a
program can only do this if you ran it with --allow-sandbox
(or the host granted it). The sandboxed code itself gets
nothing — not that capability, and not any other one your
program holds.
#What the code inside can and cannot do
It can compute: arithmetic, strings, arrays, records,
match, its own functions, and the pure parts of the
standard library.
It cannot:
- read or write files, reach the network, read environment variables, or read the clock;
- see your program's variables, functions, or state — it has its own globals and its own heap;
- receive or return a function, or an actor reference (see Why only data);
- run forever, or allocate without limit;
- create a sandbox of its own.
None of that is a setting you can turn off. There is no "trusted" flag — the isolation is decided by which function you called, so there is nothing to misconfigure.
#Two shapes
sandbox.run(source, input?) is
one-shot: compile, call the source's fn main(input), take
the answer, throw the partition away. Use it for a computed answer.
sandbox.actor(source, state?) loads
code you'll call repeatedly — a mod, an entity behaviour, a rule that
keeps score. The source defines fn handler(state, msg), and
you get back an ordinary actor you tell and
ask:
let mod_source =
"fn handler(state, msg)\n" ..
" match msg\n" ..
" Add([n]) -> do\n" ..
" reply(state + n)\n" ..
" return state + n\n" ..
" end\n" ..
" _ -> return state\n" ..
" end\n" ..
"end\n"
match sandbox.actor(mod_source, 100, { name = "adder" })
Ok(mod) -> do
tell mod.Add(5)
print(ask mod.Add(7) timeout 2s)
sandbox.stop(mod)
end
Error(e) -> print(`rejected: {e.kind}`)
end
A message arrives as a tagged value whose payload is an
array, which is why the pattern is
Add([n]) rather than Add(n).
Stop what you loaded. sandbox.stop(mod)
releases its partition and its compiled code. A long-running program
that loads mods and never stops them keeps every one it has ever
loaded.
#Limits
Pass any of these in a trailing record. The defaults are sized for something called every frame, not for a script that runs for a minute:
sandbox.actor(source, state, { name = "adder", fuel = 200000, memory = 4194304 })
| Option | Default | What it bounds |
|---|---|---|
fuel |
200000 |
Work per turn. Cannot be caught from inside — code that overruns is stopped. |
memory |
4 MiB | Live memory in the sandbox's own heap. |
max_source |
256 KiB | Source size, checked before anything is compiled. |
max_protos |
2048 |
How many functions the source may define. |
name |
"" |
Yours, for reporting. It comes back in every failure. |
#When it fails
Failures are a record, not a message, because the four cases want four different responses:
match sandbox.run(source, input, { name = "report" })
Ok(answer) -> use(answer)
Error(e) -> match e.kind
"compile_failed" -> print(`{e.name} line {e.line}: {e.message}`) -- the code is wrong
"out_of_fuel" -> print("too slow — needs a different approach")
"out_of_memory" -> print("tried to hold too much at once")
"faulted" -> print(`{e.name} failed at line {e.line}: {e.message}`)
_ -> print(`{e.name}: {e.kind}`)
end
end
kind is one of compile_failed,
no_handler, source_too_large,
too_many_prototypes, out_of_fuel,
out_of_memory, faulted. line and
message locate it; name is whatever you
passed, so you can say which mod.
#Why only data
A sandbox runs its own compiled program. Inside it, a function is a number — an index into its code — and that number means something completely different in yours. So a function cannot cross the boundary in either direction, and neither can an actor reference, which would let the code inside send messages using your program's permissions.
What crosses is data: numbers, strings, booleans, arrays, records, tagged values. That's the trade, and it's the whole reason this is safe to point at a stranger's code.
#What this does not protect
Whatever you expose, the code can use. If you're embedding SCUA and you register your own functions on a sandbox partition, the sandbox can call them — and it has whatever permissions those functions have. A sandbox with your save-file function registered on it has your save file. Register deliberately, or don't register at all and have the code return a description of what it wants:
-- the mod says what it would like to happen…
match sandbox.run(mod_source, world_summary)
Ok(action) -> match action.kind
"spawn" -> spawn_entity(action.what, action.x, action.y) -- …and you decide whether to do it
"log" -> print(action.text)
_ -> print(`ignoring unknown action {action.kind}`)
end
Error(e) -> print(`mod failed: {e.kind}`)
end
That pattern costs you a match, and in exchange every
effect a mod can have is one you wrote down.
A sandbox is not saved. Hibernating a program does not carry its sandboxes: their code came from a string that lives outside the save, so restoring one would mean trusting that the string is still what it was. Reload the source and re-create them after a restore.
#See also
examples/sandbox.scua— every case above, runnable.- Concurrency —
tell,askand actors, whichsandbox.actorgives you. - The CLI —
--allow-sandboxand the other capability flags.