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.

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