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, or start an actor with
actor(…)or apartition(it can still run concurrent work inside itself withspawn).
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. That holds on every way a value can travel: a sandbox's
initial state, a tell or ask message to it,
its reply, and sandbox.run's input and answer.
A refused value is a fault at the line that tried to send it, saying
what it was and where it sat:
only data crosses into or out of a sandbox, and argument 1 of `Ref` is an actor reference
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.