Information-flow control
Capabilities decide which effects a function may use. Information-flow control decides where its data may go. This chapter is the second axis: mark a value @secret and the compiler proves it cannot reach a log line or a network call unless you let it through an audited bridge.
The problem
A capability lets a function do something powerful, like read environment variables or print to the console. But having the authority to read a secret and the authority to print are, on their own, two separate permissions. Nothing yet stops a function with both from doing this:
fun leak(env: Env, stdio: Stdio)
match env.get("API_KEY")
Some(key) -> stdio.println(key)
None -> stdio.println("no key")
That prints the API key to standard output, where it lands in logs, terminal scrollback, and CI output. It is the single most common way secrets leak. Capability discipline alone does not catch it: both Env and Stdio were legitimately granted.
Save that as leak.capa and check it:
$ capa --check leak.capa
warning: information-flow: a @secret value reaches Stdio.println
(argument 1), a public sink that sends data out of the program.
Route it through declassify(value, reason: "...") if this
disclosure is intended.
The compiler already flagged it, with no annotation from you. The next sections explain why, and how to fix it properly.
Secret by default at the source
Environment variables are where API keys, tokens, and credentials live, so Env.get is a secret source: its result is @secret automatically. The value pulled out of the Some arm inherits that label, and printing it is what raised the warning. You did not have to mark anything; the headline leak is caught out of the box.
Labelling your own data
For data that does not come from a built-in source, you say so. A two-point lattice carries the label: @public (the default) sits below @secret. The annotation attaches to a type, on a parameter, a binding, a return type, or a struct field:
fun charge(card_number: @secret String, stdio: Stdio)
// card_number is @secret for the whole function body
stdio.println(card_number) // warning: secret reaches a public sink
The label is a compile-time property only. It does not change the value or cost anything at runtime; it just travels with the data through the type checker.
How the label travels
A value's label is the join of the labels that flow into it: if any input is @secret, the result is too. This holds through arithmetic, string interpolation, field reads, and function calls (a call with a secret argument returns a secret). So none of these escape the net:
let tagged = "key=${card_number}" // @secret: interpolation carries it
let first6 = card_number.substring(0, 6) // @secret: derived from a secret
It also follows data into and out of containers: a secret put in a struct, list, tuple, or a mutable Map / Set / List, or iterated over in a for loop, stays secret. You cannot launder it by stashing it somewhere and reading it back.
The sinks
A sink is a point where data leaves the program. The built-in public sinks are:
Stdio.print,Stdio.println,Stdio.eprintlnNet.get,Net.postFs.writeDb.exec,Db.query
A @secret value reaching any of them is an information-flow violation. By default it is a warning, so existing code keeps compiling while labelled code surfaces its disclosures. To make it a hard error, opt the function into strict mode:
@strict_ifc()
fun charge(card_number: @secret String, stdio: Stdio)
stdio.println(card_number) // now a compile ERROR, not a warning
Strict mode also turns on implicit-flow checking: a sink inside a branch whose condition is secret leaks one bit (whether the branch was taken) even if the printed value is a constant, and strict mode reports that too.
Declassify: the one way across
Sometimes you genuinely must disclose a derived form of a secret: the last four digits of a card on a receipt, a salted hash of a password for storage. The single sanctioned bridge from @secret to @public is declassify, and it demands a reason:
fun mask(card_number: @secret String) -> String
let n = card_number.length()
let last4 = card_number.substring(n - 4, n)
return declassify(
"**** **** **** ${last4}",
reason: "PCI DSS 3.4: display only the last four digits"
)
The reason must be a plain string literal. declassify is the identity function at runtime (it returns its value unchanged); its whole job is at compile time, where it relabels the result @public. Declassifying a value that was not secret is reported as a no-op, so a stray bridge cannot hide.
Why the reason matters: the SBOM
Every declassify call is recorded in the manifest. Ask for it:
$ capa --manifest app.capa | jq '.summary.declassification_sites'
1
$ capa --manifest app.capa | jq '.functions[] | select(.declassifications | length > 0) | .declassifications'
[ { "reason": "PCI DSS 3.4: display only the last four digits",
"value": "...", "pos": "10:12" } ]
This is the regulatory payoff. The SBOM does not just say what a program does; it lists exactly where, and why, it discloses sensitive data, generated by the compiler rather than asserted in a document. An auditor reads the list, not your data-handling policy.
The honest boundaries
The analysis is deliberately simple, and it is worth knowing its edges:
- Intra-procedural. A secret crossing a function boundary needs an explicit
@secretparameter. This is by design: a signature stays honest about what it receives, the same philosophy as capabilities. - Whole-aggregate granularity. A secret field taints its whole struct, rather than tracking each field separately. Per-field precision is future work.
These keep the feature ergonomic and the diagnostics low-noise, which is the lesson IFC research learned the hard way: a checker that cries wolf gets turned off.
Where you go next
One chapter remains: chapter 14 turns everything you have learned into audit artefacts, generating the manifest, the SBOMs, the VEX, and the provenance attestation from one small program. The language reference has the full IFC rules, and the capa_paymentguard showcase puts capabilities, information-flow control, and a generated EU Cyber Resilience Act conformity pack together on a payment-security core, the compiler proves a card number cannot reach a log or the network unmasked.
If you build something with Capa, send it. Real use is the best feedback.