The Tessera Book
Chapter 12

Capabilities in Depth

Every chapter so far has touched on capabilities. This one puts the pieces together: where capabilities come from, how a program grants them, how code narrows them before passing them on, how they stay visible in every signature, what the compiler can tell you from them, and what the operating system adds underneath.

The idea, once more

Most languages let any line of code do anything the process can: open any file, connect anywhere, run any program. Whether a function does those things is something you find out by reading it, and everything it calls.

Tessera turns that around. Code can only affect the outside world through capabilities, values that carry authority. There are no global functions for I/O; using a name like print or open is an error that tells you which capability to use instead. And the rules for capabilities are few:

  1. Programs can't create capabilities. They all come from root, which the runtime gives to main.
  2. Capabilities are passed as arguments. A function can use a capability only if it was handed one.
  3. Narrowing only narrows. You can make a capability that allows less than the one you hold, never more.
  4. Capabilities stay visible. Every type that holds one says so, and the compiler follows them through every struct, enum and list. They can't be hidden in dyn values or in global variables (there are none).

Together, these make a function's signature an honest description of what it can do, all the way down its call tree.

What root holds

root has one field per kind of capability:

FieldTypeLets code
root.stdoutStdoutprint
root.stderrStderrprint to standard error
root.stdinStdinread input
root.clockClockread the time, sleep, time out
root.randRandmake random numbers
root.envEnvread environment variables
root.fsFsread and write files under one directory
root.netNetconnect to hosts, make HTTP requests
root.listenListenaccept connections
root.execExecrun programs
root.foreignForeigncall C functions (chapter 15)

The first five are always granted. The rest are granted when the program starts, with --grant flags or the [grants] table of tessera.toml:

$ tessera run app.tsr --grant fs=data:ro --grant net=api.example.com --grant env=HOME
[grants]
fs = "data:ro"
net = ["api.example.com"]
env = ["HOME"]

fs takes one directory, :ro for read-only. net and listen take hosts, with an optional port. env and exec take names. * grants everything of a kind. A binary from tessera build takes the same flags.

An ungranted capability still exists, so code that receives it type-checks and runs. It just refuses: every operation returns Err(Denied(...)), and Env shows no variables.

fn main(root: Root) {
    let out = root.stdout
    out.print("fs read-only: {root.fs.is_read_only()}")
    out.print("HOME: {root.env.get("HOME") ?? "(not granted)"}")
    out.print("LANG: {root.env.get("LANG") ?? "(not granted)"}")
    match root.fs.read("index.html") {
        Ok(text) => out.print("index.html: {text.len()} bytes"),
        Err(e) => out.print("index.html: {e}"),
    }
    match root.fs.write("log.txt", "visited") {
        Ok(_) => out.print("wrote log.txt"),
        Err(e) => out.print("log.txt: {e}"),
    }
}
Listing 12-1: The same program with different grants examples/ch12-01-grants/main.tsr
$ LANG=en_US.UTF-8 tessera run main.tsr
fs read-only: true
HOME: (not granted)
LANG: (not granted)
index.html: Denied("fs: not granted (run with --grant fs=DIR)")
log.txt: Denied("fs: not granted (run with --grant fs=DIR)")
$ LANG=en_US.UTF-8 tessera run main.tsr --grant fs=site:ro --grant env=LANG
fs read-only: true
HOME: (not granted)
LANG: en_US.UTF-8
index.html: 12 bytes
log.txt: Denied("fs: this capability is read-only")
$ LANG=en_US.UTF-8 tessera run main.tsr --grant fs=site --grant env=LANG
fs read-only: false
HOME: (not granted)
LANG: en_US.UTF-8
index.html: 12 bytes
wrote log.txt

Notice that HOME is never visible, because it was never granted, even though the program's process has it in its environment.

Narrowing

A program usually holds more authority than any one part of it needs. Before passing a capability on, narrow it:

CapabilityNarrower version
Fsfs.under("dir"): only that subdirectory; fs.read_only(): no writing
Netnet.only("host") or net.only("host:port"): only that host
Execexec.only("program"): only that program
Foreignforeign.only("lib"): only that C library

A narrowed capability is a new value; the original is unchanged. Narrowing can only take away. Asking for a host you don't hold gives a Net that reaches nothing.

fn main(root: Root) {
    let out = root.stdout
    // the whole site directory is granted; the renderer gets only public/,
    // and only for reading
    let public = root.fs.under("public").read_only()
    out.print(render(public, "about.html"))
    out.print(render(public, "../secrets.txt"))
    out.print(render(public, "/etc/passwd"))
    match public.write("about.html", "defaced") {
        Ok(_) => out.print("wrote about.html"),
        Err(e) => out.print("write: {e}"),
    }
    // the full grant can still read it
    out.print("main reads secrets.txt: {root.fs.read("secrets.txt") ?? "?"}")
}

fn render(fs: Fs, page: Str) -> Str {
    match fs.read(page) {
        Ok(text) => "{page}: {text.trim()}",
        Err(e) => "{page}: {e}",
    }
}
Listing 12-2: A file renderer that can read one directory examples/ch12-02-attenuation/main.tsr
$ tessera run main.tsr --grant fs=site
about.html: <p>About us</p>
../secrets.txt: Denied("fs: path escapes the granted directory")
/etc/passwd: Denied("fs: absolute paths are not allowed; paths are relative to the granted directory")
write: Denied("fs: this capability is read-only")
main reads secrets.txt: the-admin-password

render receives public, which is limited to site/public and read-only. Whatever page name reaches it, from a URL or anywhere else, it can't climb out with .., can't use an absolute path, and can't write. main, which holds the whole grant, still can. Symlinks pointing outside the directory are refused the same way.

The network works the same way:

fn main(root: Root) {
    let out = root.stdout
    // a client for one API: it gets a Net that reaches one host
    let api = root.net.only("api.example.com")
    for url in ["http://tracker.example.net/collect", "file:///etc/passwd"] {
        match api.get(url) {
            Ok(r) => out.print("{url}: {r.status}"),
            Err(e) => out.print("{url}: {e}"),
        }
    }
    // narrowing to a host the grant does not hold gives an empty Net
    match root.net.only("other.example.org").get("http://other.example.org/") {
        Ok(r) => out.print("other: {r.status}"),
        Err(e) => out.print("other: {e}"),
    }
}
Listing 12-3: A Net limited to one host examples/ch12-03-net-only/main.tsr
$ tessera run main.tsr --grant net=api.example.com
http://tracker.example.net/collect: Denied("net: tracker.example.net:80 is not granted")
file:///etc/passwd: Failed("scheme `file` is not allowed, only http and https")
other: Denied("net: other.example.org:80 is not granted")

api can reach api.example.com and nothing else, whatever URL it's given. Only http and https are allowed, and redirects are not followed, so a server can't bounce a request to a host the capability doesn't cover.

Exec is narrowed by program name. Arguments go as a list, never through a shell, and the child sees only the environment variables that were granted:

fn main(root: Root) -> Result[(), IoError] {
    let echo = root.exec.only("echo")
    let r = echo.run("echo", ["hello", "from", "a child process"])?
    root.stdout.write(r.stdout)
    match echo.run("ls", ["/"]) {
        Ok(r) => root.stdout.print("ls exited with {r.code}"),
        Err(e) => root.stdout.print("ls: {e}"),
    }
    Ok(())
}
Listing 12-4: Running one program examples/ch12-06-exec/main.tsr
$ tessera run main.tsr --grant exec=echo --grant exec=ls
hello from a child process
ls: Denied("exec: `ls` is not granted")

Be careful what you grant here: granting a program grants everything that program can do. --grant exec=sh is as good as every other grant together.

Capabilities in structs

Capabilities can be passed, returned, captured by closures, and stored in struct and enum fields. A logger that holds the Stdout it writes to is one value instead of two:

// A logger holds the Stdout it writes to, so code that logs takes a
// Logger instead of a Stdout and a prefix.
struct Logger {
    prefix: Str,
    out: Stdout,
}

fn log(self: Logger, msg: Str) {
    self.out.print("{self.prefix} {msg}")
}

fn total(prices: List[Int]) -> Int {
    prices.sum()
}

fn checkout(log: Logger, prices: List[Int]) {
    log.log("total: {total(prices)}")
}

fn main(root: Root) {
    let log = Logger { prefix: "[shop]", out: root.stdout }
    checkout(log, [3, 4, 5])
}
Listing 12-5: A struct that holds a capability examples/ch12-05-fields/main.tsr
$ tessera run main.tsr
[shop] total: 12
$ tessera caps main.tsr
Logger::log  Stdout (via Logger.out)
total        pure
checkout     Stdout (via Logger.out)
main         Root

A struct that holds a capability holds authority, and so does anything that holds that struct: an enum with it in a payload, a list of them, a struct with one in a field. The compiler works this out for every type, so authority never disappears into a struct. tessera caps shows where it comes from: checkout takes a Logger, and the Stdout comes through its out field. A dependency can't get around tessera.toml this way either: its functions are checked against their capabilities wherever those sit in their parameters.

A few things don't work with values that hold authority, because each would let authority out of sight:

  • They can't become dyn. A dyn value says which interface it has, not which type is underneath, so it could carry a capability that no signature shows (chapter 10). Use a type parameter, as in [T: Named], where the type stays visible.
  • They can't be map keys, and == and != don't compare them: two capabilities with the same grants aren't the same value, so the answer would mean nothing.
  • Functions that take them are never pure. That matters for tessera caps and for the compiler, below.
This program does not compile
interface Named {
    fn name(self) -> Str
}

struct Logger {
    prefix: Str,
    out: Stdout,
}

fn name(self: Logger) -> Str {
    self.prefix
}

fn main(root: Root) {
    let things: List[dyn Named] = [Logger { prefix: "[shop]", out: root.stdout }]
    root.stdout.print(things.len().to_str())
}
Listing 12-6: A capability can't hide in a dyn value examples/ch12-09-no-dyn/main.tsr
$ tessera check main.tsr
error[E0112]: `Logger` holds capability `Stdout` through `Logger.out`, so it cannot become `dyn Named`
  --> main.tsr:15:36
    |
 15 |     let things: List[dyn Named] = [Logger { prefix: "[shop]", out: root.stdout }]
    |                                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  = note: authority must stay visible in types; pass it with a type parameter, as in `[T: Named]`

1 error

Function fields and channels still can't be stored in structs. And there are no global variables, so every capability a function can reach arrived through its parameters.

Asking the compiler: tessera caps

Since every capability arrives as a parameter, the compiler knows what every function can do. tessera caps prints it:

fn main(root: Root) -> Result[(), IoError] {
    let config = load_config(root.fs.read_only())?
    let names = fetch_names(root.net.only("api.example.com"), config)?
    report(root.stdout, names)
    Ok(())
}

fn load_config(fs: Fs) -> Result[Str, IoError] {
    fs.read("org.txt")
}

fn fetch_names(net: Net, org: Str) -> Result[List[Str], IoError] {
    let resp = net.get("https://api.example.com/orgs/{org.trim()}/repos")?
    Ok(parse_names(resp.body))
}

// One name per line of the body.
fn parse_names(body: Str) -> List[Str] {
    body.lines().map(|l| l.trim()).filter(|l| l != "")
}

fn report(out: Stdout, names: List[Str]) {
    for n in names.sort() {
        out.print(n)
    }
}

fn retry[T](times: Int, f: fn() -> Result[T, IoError]) -> Result[T, IoError] {
    let mut last = f()
    for _ in 1..<times {
        if last.is_ok() {
            return last
        }
        last = f()
    }
    last
}
Listing 12-7: A small program in layers examples/ch12-04-caps/main.tsr
$ tessera caps main.tsr
main         Root
load_config  Fs
fetch_names  Net
parse_names  pure
report       Stdout
retry        pure itself, runs callbacks

load_config can read files, fetch_names can use the network, report can print. parse_names is pure: no parameter of it holds a capability, so it can only compute, and that holds for everything it calls, since it has nothing to pass them. retry is pure itself but calls a function it was given, so what it can do depends on that function; tessera caps says so. tessera caps --json gives the same in a form tools can read, for example to fail a build when a module that should be pure isn't.

This is also what lets tessera.toml limit dependencies (chapter 7): the compiler applies the same reasoning to every function a dependency exports.

Purity is useful to the compiler too. A pure call with the same arguments always gives the same result, so the native backend can reuse a result instead of calling again, for instance a pure call inside a loop whose arguments don't change. Nothing your program can observe changes: same results, same output, same panics at the same place.

Files that are part of the program

A program often ships with files of its own: templates, pages, a logo. Reading them at run time would need an Fs. Instead, embed, embed_bytes and embed_dir read them when the program is compiled, and make them part of it:

// The files under public/ are part of the program: read when it is
// compiled, so it needs no Fs to serve them.
fn main(root: Root) {
    let site = embed_dir("public")
    for (path, data) in site.entries() {
        root.stdout.print("{path}: {data.len()} bytes")
    }
    let notice = embed("notice.txt")
    root.stdout.print(notice.trim())
}
Listing 12-8: Embedding files at compile time examples/ch12-08-embed/main.tsr
$ tessera run main.tsr
css/site.css: 20 bytes
index.html: 17 bytes
Built with Tessera.
$ tessera caps main.tsr
main  Root
embeds public/css/site.css (20 bytes)
embeds public/index.html (17 bytes)
embeds notice.txt (20 bytes)

The path is relative to the source file and must stay inside its package, so a dependency can't embed your private files into its code. tessera caps lists every embedded file.

Sometimes the files a program needs live next to it rather than inside it, such as a shared directory of templates in a larger repository. The program's own tessera.toml can name extra directories it may embed from:

[embed]
roots = ["../shared/templates"]

The paths are relative to tessera.toml and must be existing directories. Only the program's package can embed from them, and a dependency's tessera.toml can't declare roots at all, so the rule above still holds for every dependency. tessera caps lists the roots with the embedded files. The Tessera compiler written in Tessera works this way: it embeds the standard library and its runtime headers from the compiler's source tree, so the compiled program needs no access to those sources when it runs. The server for this book works this way: every page is embedded, so it runs with just two grants, one to listen and one to read PORT.

What the operating-system sandbox adds

Tessera's checks cover Tessera code. Two things are outside them: C code called through foreign (chapter 15), which runs with the full authority of the process, and any bug in the runtime itself. To cover those too, every program also runs in an operating-system sandbox built from its grants. It is on by default, in both backends.

// C code is outside Tessera's checks: with the foreign grant it could write
// any file the process can. The operating-system sandbox still stops it.
foreign "c" {
    fn fopen(path: Str, mode: Str) -> Handle
    fn fclose(f: Handle) -> Int32
}

fn main(root: Root) {
    let f = fopen(root.foreign, "escape.txt", "w")
    if f.addr == 0 {
        root.stdout.print("C could not open escape.txt for writing")
    } else {
        let _ = fclose(root.foreign, f)
        root.stdout.print("C wrote escape.txt")
    }
}
Listing 12-9: C code trying to write a file examples/ch12-07-sandbox/main.tsr
$ tessera run main.tsr --grant foreign=c
tessera: warning: --grant foreign=c is full access: C code can do anything this process can, past every other grant
C could not open escape.txt for writing
$ tessera run main.tsr --grant foreign=c --no-sandbox
tessera: warning: --grant foreign=c is full access: C code can do anything this process can, past every other grant
C wrote escape.txt

With the foreign grant, the C function fopen can try to open any path, and Tessera can't see what it does. But the program was given no Fs, so the kernel refuses the write. Only with --no-sandbox, which exists for debugging, does the file get written.

What the kernel enforces, from the grants:

Allowed
Writing filesunder the Fs directory (unless read-only), and the terminal
Reading filesunder the Fs directory, and system locations such as libraries, time zones and certificates; not home directories
Networknothing, unless Net or Listen is granted
Starting programsnothing, unless Exec is granted

The sandbox is coarser than the capabilities: once Net is granted, the kernel allows every host, and Tessera's own checks decide which ones. And it can't protect the program from itself: C code with the foreign grant can still read and corrupt the program's own memory. The two layers together mean that Tessera code is limited precisely, and everything else in the process is limited to roughly what was granted.

On macOS the sandbox is a sandbox profile; on Linux it is Landlock and a seccomp filter. A system without either runs the program unsandboxed.

Designing with capabilities

A few habits follow naturally from all this:

  • Decide authority in main. main is where grants become capabilities. Narrow them there, and pass each part of the program the least it needs.
  • Keep the core pure. Parsing, formatting, validation and business rules rarely need a capability. Pure functions are the easiest to test and to trust, and tessera caps proves they are pure.
  • Take interfaces in code you want to test. A function that takes [C: Ticker] instead of Clock can be tested with a fake; one that takes a narrowed Fs can be tested with a temporary directory.
  • Read signatures in review. A change that adds a Net parameter to a function deep in the program is visible in the diff, and in tessera caps.

Summary

All authority comes from root, granted with --grant or tessera.toml. Capabilities are passed as arguments or held in structs, narrowed with under, read_only, only, and never hidden in dyn values; the compiler follows them through every type, so signatures say what code can do and tessera caps reports it. Files can be embedded instead of read, from the package or from roots the program declares. Underneath, the operating-system sandbox limits everything Tessera can't see to the same grants.

Next: doing several things at once.