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:
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 five attenuators
Each of the five non-Stdio non-Unsafe built-in capabilities has its own restrict_to:
net.restrict_to(host)· only reach this exact host.fs.restrict_to(prefix)· only paths under this prefix.env.restrict_to_keys([keys])· only these environment variable names.clock.restrict_to_after(t)· only return times aftert.random.with_seed(seed)· deterministic from this seed.
The first four narrow: they reduce what the capability can do. The last determinises: a seeded Random still has the full method surface, but the sequence it produces is fixed. The five together 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:
fun cache_user(net: Net, fs: Fs, id: String) -> Result<Unit, IoError>
let body = net.get("https://api.example.com/users/${id}")?
fs.write_string("/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:
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:
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. A library given a narrowed cap cannot escape, no matter what it does internally.
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:
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}")
$ capa --run hello.capa
denied: NetRestricted: attacker.com is not in the allowed host set
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 seven 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.