SCUA

How-to

Run code in a sandbox

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 a partition (it can still run concurrent work inside itself with spawn).

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