The sandbox

A script has no IO, no network, no clock, no environment and no foreign functions by default, and only the caller of Sandbox.load can grant more, in a block that names exactly what is granted.

Diese Seite gibt es noch nicht auf Deutsch, deshalb steht sie hier auf Englisch. Zur englischen Seite

Draft. It runs in the VM: a program torb run runs loads and applies a script, and so does the toolchain for a project.trb; a native binary runs its scripts in the VM of the script host it links, and the value crosses into that VM and back through the derived Encode and Decode of the receiver (Receiver Scripts and the Sandbox).

A receiver script is untrusted code from outside the program. What it can touch is not a setting inside the script or its own project.trb; it is a capability the caller of Sandbox.load grants at the one place that is not the script itself.

Example#

use Sandbox from "std/sandbox"

type ServerConfig {
  var host: String = "localhost"
}

fn loadConfig(path: String) {
  Sandbox.load<ServerConfig>(path) {
    modules "std/text", "std/time", "std/network"
    files readOnly: "./config"
    environment "APP_*"
    network connect: "api.example.test:443"
    limits steps: 1_000_000, memory: 64.megabytes(), time: 2.seconds()
  }
}

64.megabytes() is a ByteSize and 2.seconds() a Duration, and neither needs an import: std/number owns Int64, so its megabytes is in scope everywhere, and the prelude re-exports seconds from std/time.

Syntax#

Sandbox.load<Value>(path, capabilities: (var self: SandboxCapabilities) => Void = {}): Result<Script<Value>, SandboxError>
Sandbox.loadText<Value>(text, name, capabilities: (var self: SandboxCapabilities) => Void = {}): Result<Script<Value>, SandboxError>

SandboxCapabilities.modules(...names: String)
SandboxCapabilities.files(readOnly: String = "", readWrite: String = "")
SandboxCapabilities.environment(...patterns: String)
SandboxCapabilities.network(connect: String = "", listen: String = "")
SandboxCapabilities.limits(steps: Int = 1_000_000, memory: ByteSize = 64.megabytes(), time: Duration = 2.seconds(), workers: Int = 1)

Rules#

  1. Left at the defaults, a script has no IO, no network, no clock, no environment and no foreign functions. The file scope is the prelude, the receiver and what the script imports - and a receiver script's use lines are what the modules capability decides, not the script's own choice: an import the grant does not name fails the load.

  2. Every capability is granted at the call site of Sandbox.load, never in the script or its project.trb. A script that could grant itself a capability would not be a sandbox; the trailing block on load is a receiver closure over SandboxCapabilities, configured the same way any other builder is.

  3. modules names additional packages of the standard library the script may use. A script has no other way to reach one, because a receiver script's imports are checked against exactly this list.

  4. files names the file system roots a script may reach, one side at a time. readOnly and readWrite each default to nothing; a side left out stays closed, so a script granted readOnly alone cannot write anywhere.

  5. network names the hosts and ports a script may reach, and the addresses it may listen on. Each pattern is host:port: the host a name, *. and a name for every name below it, *, localhost or an address (IPv6 in brackets), the port a number or *. A connect pattern lets the script resolve the names it matches and connect to the addresses it matches or that a matched name resolved to; a listen pattern lets it listen on or bind an address. Anything else - an address the script writes itself included - stops the script, as does asking for the system's name servers, and a malformed pattern fails the load. TLS and HTTP add nothing: an HTTPS request is a resolve and a connect. The script needs modules "std/network" as well.

  6. limits guards against a script that never stops. steps, memory and time each have a default (1,000,000 steps, 64 megabytes, 2 seconds), so a script that does not ask for more still cannot run forever. memory is a ByteSize, in either family of units: 64.megabytes() is 64,000,000 bytes, 64.mebibytes() 67,108,864.

  7. Script.apply reports a limit or a panic as a value, not as a crash of the host. The one panic in the language that can be recovered from is the one inside a script the host's own VM is interpreting - a sandbox whose failures aborted the caller would not be a sandbox.

  8. Foreign functions can never be granted to a script. There is no capability for them in SandboxCapabilities, because a foreign declaration reaches outside the process the sandbox itself runs in.

  9. The VM enforces all of it, and only the VM runs a script. A program torb run runs loads scripts under these capabilities and limits - from a literal path, from one it computes or from text it holds - and so does the toolchain for a project.trb (torb manifest, see project.trb), and so does a native binary that torb build --embed-vm built, and so does every other native binary, in the VM of the script host it links. A relative root is read against the directory the program runs in.

What this is not#

A capability is not a permission the script requests. There is no line inside a .trb script that asks for a file or an environment variable; every capability is decided once, by the code that calls Sandbox.load, before the script is even read.

use Sandbox from "std/sandbox"

type ServerConfig {
  var host: String = "localhost"
}

fn loadConfig(path: String) {
  Sandbox.load<ServerConfig>(path) {
    files readOnly: "./config"
  }
}
Kompiliert nicht
use Sandbox from "std/sandbox"

type ServerConfig {
  var host: String = "localhost"
}

fn loadConfig(path: String) {
  Sandbox.load<ServerConfig>(path) {
    grant "files", "./config"
  }
}
// error: Cannot find `grant` here
  • Receiver scripts - what is loaded and checked before a capability ever matters.
  • Foreign functions - the one capability a sandbox can never grant.
  • Packages - the same "visible from the imports alone" idea, for a whole dependency graph.
Esc