Learn · Chapter 9 of 14

Attenuating capabilities

Narrowing a capability before passing it down. The same Net that can reach the whole internet, restricted to a single host. The same Fs that can read anywhere, restricted to one directory. Monotonic by construction.

The motivation

A function called fetch_users in a library probably talks to one specific API. It does not need authority over the entire network; it needs authority over api.example.com. If you pass it the full Net, you are trusting it not to misuse what it does not need.

The fix is to narrow the capability at the boundary. Capa's built-in capabilities each carry an restrict_to method that returns a fresh, narrower copy:

capa
fun main(net: Net, stdio: Stdio)
    let api = net.restrict_to("api.example.com")
    // `api` can only reach api.example.com.
    fetch_users(api, stdio)

The api value is a Net, exactly the same type as what main received. From the function-signature perspective, fetch_users takes a Net and there is no way to tell that it was narrowed. The discipline is at the value level: the narrowed instance carries an internal allow-list, and method calls that fall outside it fail at runtime before any system call.

The attenuators

Every built-in capability except Stdio and Unsafe has its own attenuator:

  • net.restrict_to(host) · only address this exact host. See the redirect caveat below: what the request reaches is not currently bounded by this set.
  • fs.restrict_to(prefix) · only paths under this prefix.
  • db.restrict_to(prefix) · only database files under this prefix.
  • proc.restrict_to(cmd) · only commands whose basename starts with this.
  • serve.restrict_to("addr:port") · only bind this address and port.
  • env.restrict_to_keys([keys]) · only these environment variable names.
  • clock.restrict_to_after(t) · only return times after t.
  • random.with_seed(seed) · deterministic from this seed.

All but the last narrow: they reduce what the capability can do. with_seed determinises: a seeded Random still has the full method surface, but the sequence it produces is fixed. Together they let you say, in code, exactly what a downstream function is allowed to touch.

A worked example

Consider a small program that fetches a user's record and writes it to a local file:

capa
fun cache_user(net: Net, fs: Fs, id: String) -> Result<Unit, IoError>
    let body = net.get("https://api.example.com/users/${id}")?
    fs.write("/var/cache/users/${id}.json", body)?
    return Ok(())

fun main(net: Net, fs: Fs, stdio: Stdio)
    match cache_user(net, fs, "42")
        Ok(_) -> stdio.println("cached")
        Err(e) -> stdio.eprintln("error: ${e}")

This works, but cache_user is over-authorised: it has full Net and full Fs. Narrow at the boundary:

capa
fun main(net: Net, fs: Fs, stdio: Stdio)
    let api = net.restrict_to("api.example.com")
    let cache_dir = fs.restrict_to("/var/cache/users/")
    match cache_user(api, cache_dir, "42")
        Ok(_) -> stdio.println("cached")
        Err(e) -> stdio.eprintln("error: ${e}")

The signature of cache_user did not change. The authority it received did. If a future modification adds net.get("https://attacker.com") inside cache_user, the narrowed Net rejects it at runtime before any HTTP request happens; the program emits a structured error and continues.

This is the part of Capa's discipline that the type system alone does not give you. Two functions with the same signature can hold different authority surfaces; the difference is who built the value passed in.

Monotonic by construction

The most important property of restrict_to is what it cannot do. Once narrowed, a capability cannot be widened back:

capa
fun main(net: Net, stdio: Stdio)
    let a = net.restrict_to("api.example.com")
    let b = a.restrict_to("different.example.com")
    // b is the intersection: { api.example.com } & { different.example.com } = empty.
    // b is a Net that can reach NOTHING.

The implementation: a narrowed Net carries an internal "allowed hosts" set. restrict_to on a narrowed cap takes the intersection of the existing set with the newly requested host. Intersections only shrink. There is no method that grows the set. The fresh, unrestricted Net that main receives represents the universal allow-list; every narrowing only constrains it.

Monotonic narrowing is a structural property: any chain of restrict_to calls always ends at something at least as narrow as where it started. There is no method that grows the set, so a library handed a narrowed capability cannot widen it, no matter what it does internally.

Redirects and the allowed-host set

Since 1.20.0, an HTTP redirect is re-checked against the same allowed-host set on every hop. Net.get and Net.post check allows(host) against the host in the URL you passed, and re-check it on every 3xx hop: a hop to a permitted host is followed, and any other hop returns the ordinary host-deny Err(IoError) instead of being fetched. The scheme is bounded to http / https on the first request and on every hop, so a 302 Location: file: / data: / ftp: is refused too. The Python backend and the capa:host Wasm bridge share the one Net object and behave identically. --wasi was never affected: that mode does not follow redirects and takes its scheme, host and path from a compile-time literal. Before 1.20.0 a redirect from a permitted host was followed with no re-check; see the security advisory.

Two things are worth separating here: the algebra and the enforcement point.

The algebra holds. The allowed-host set only ever shrinks, and nothing reachable from Capa code widens it. That was attacked directly and did not move. The same monotonic attenuation on Fs, Db, Proc and Serve was attacked and held. restrict_to is not theatre.

The enforcement point now sees every hop. Net checks the set against the host you named and again against every host a 3xx redirect steers the request to, so a narrowed Net bounds which host the bytes may reach, not only which host your program addresses. One residual is stated for honesty: a redirect between two permitted hosts is followed, since both are in the set, so keep the set as narrow as the program needs.

The two error modes

First: a runtime denial. The compiler does not (and cannot) statically check that every URL or path your code uses falls inside the narrowed set; it would need to know the values at compile time. Instead, the check happens at runtime, before any system call:

capa
fun main(net: Net, stdio: Stdio)
    let api = net.restrict_to("api.example.com")
    match api.get("https://attacker.com/")
        Ok(body) -> stdio.println(body)
        Err(e) -> stdio.eprintln("denied: ${e}")
terminal
$ capa --run hello.capa
denied: Net capability does not permit access to host 'attacker.com': current restrictions: ['api.example.com']

No request is made; the runtime returns an Err the program handles like any other failure. Fail-closed: a denied call never reaches the network.

Second (a confusion, not a compiler error): trying to call restrict_to on Stdio or Unsafe. Neither has an attenuator. Stdio writes to one stream; there is no narrower version. Unsafe is the escape hatch and explicitly does not narrow.

Try this. Write fun fetch_users(net: Net) -> Result<String, IoError> that fetches from api.example.com/users. In main, narrow net to "api.example.com" before passing it in. Add a second call inside fetch_users to "evil.example.com" and confirm the runtime denies it.

Where you are now

You understand the two halves of Capa's capability discipline: the type-level part (a capability must be in the function's signature to be in scope) and the value-level part (a capability can be narrowed before being passed on, monotonically and fail-closed).

The next chapter scales the idea up: instead of using only the ten built-in capabilities, you define your own. capability SendEmail turns a library's contract into a value of a type that the discipline applies to uniformly.

Keep going