16 Capabilities — power that is handed over
What to know first
input out cap io .Looking back
Chapter 15 said the effects line writes what an op does. What, then, can effects io alone not tell you?
A. What the input/output is with. Writing to standard output, opening a file and opening a connection are not told apart by the single word io. The capabilities of this chapter narrow that down — an op that received input fs cap file_system . can open files but not connections.
The need for this chapter, and its context
By the end of this chapter
The questions this chapter answers
- Passing capabilities as arguments — doesn’t that add an argument per call at run time and slow things down?
16.1 Kinds of capability#
A capability is received as input <name> cap <kind> .. The kind decides what can be done with it.
| Capability | What it opens |
|---|---|
cap io | Standard input and output |
cap file_system | Files and directories |
cap net | Making connections and exchanging data |
cap tty | Terminal size and modes |
cap clock | Asking the time |
cap random | Operating-system entropy |
cap args · cap env | Program arguments · environment variables |
cap allocator | Obtaining memory from a fixed window (does not grow) |
cap heap | Obtaining memory from a growing root (only on machines with an operating system) |
cap atomic | Atomic operations |
cap mmio | Device registers |
cap c | Calling C functions |
cap machine | Emitting machine instructions directly |
Table 16.1 — Capability kinds the processor gives meaning to
The reason for kinds is least privilege. With only one capability, the permission received to read files could also open connections, and the op’s head would no longer say “what this op can do”.
16.2 Capabilities travel down the chain#
Passing a capability on needs no special notation. Write the received name like any other argument.
examples/ch16/chain.low
module chain .
rem run: main
proc say input k cap io . input msg slice u8 . output u64 . effects io .
do
return write_out k 1 msg .
end
proc say_twice input k cap io . input msg slice u8 . output u64 . effects io .
do
let a u64 be say k msg .
let b u64 be say k msg .
return add a b .
end
proc main input k cap io . output u8 . effects io .
do
let n u64 be say_twice k "hi\n" .
guard eq n 6 . else return 1 .
return 0 .
end
Output
$ lowentc --run main chain.low
hi
hi
main() = 0
The chain looks like this — outside → main’s k → say_twice’s k → say’s k → write_out. Drawn together with effects, the two arrows run in opposite directions.
capability (permission) effect (what was done)
handed down from the top spreads up from the bottom
outside (OS)
│ cap io
▼
main k ─────────────┐ ▲ effects io
│ │ │
▼ ▼ │
say_twice k ─────────────┐ ▲ effects io
│ │ │
▼ ▼ │
say k ─────▶ write_out k 1 msg ▲ effects io ← io arises hereA capability must be handed down from above to be used; an effect arises below and is written in the head of every caller. So the head of any op shows both “what this op can do (effects)” and “who allowed it (capabilities)”. An op that received no capability cannot call say, because it has nothing to pass. So “can this program touch files?” can be answered by looking only at the entry point. No op deep inside can secretly open a file: without the capability it cannot, and with it, the chain is visible all the way back to the entry.
Q. Passing capabilities as arguments — doesn’t that add an argument per call at run time and slow things down?
A. It does not. A capability is a mark at translation time, not a run-time value. Ops that receive capabilities and ops that do not have the same signature at the C boundary. A capability asks “is this op allowed to be called?” at translation time, and nothing is left to ask at run time. The value that guards the boundary does not come back as a run-time cost.
16.3 Effects and capabilities are a pair#
The effects line writes what an op does; capability inputs write who allowed it. The same thing is written from two sides, and each checks the other.
| Effect | Authorising capability | When missing |
|---|---|---|
io | cap io · file_system · net · tty · clock · random | E-EFFECT-NO-CAP |
alloc | cap allocator (or a region) | E-ALLOC-NOCAP |
heap | cap heap | E-HEAP-NOCAP |
atomic | cap atomic | E-ATOMIC-NOCAP |
device | cap mmio | E-MMIO-NOCAP |
state · panic · wait · concurrent | written without a capability | — |
Table 16.2 — Effects and the capabilities that authorise them
examples/ch16/pairs.low
module pairs .
rem run: main
proc main input out cap io . input al cap allocator . output u8 . effects io alloc .
do
let g option mut slice u8 be alloc_bytes al capacity 16 .
guard is_some g . else return 1 .
let n u64 be write_out out 1 "hi\n" .
return 0 .
end
Output
$ lowentc --run main pairs.low
hi
main() = 0
main receives two capabilities. cap io authorises effects io, and cap allocator authorises effects alloc. alloc_bytes al capacity 16 asks the allocator for 16 bytes and gives an option, since it may fail.
There are three ways the pair can go wrong. Declaring an effect with no authorising capability is rejected.
examples/ch16/nocap.low
module nocap .
rem expect: E-EFFECT-NO-CAP
proc main output u8 . effects io .
do
let n u64 be write_out 1 "hello\n" .
return narrow u8 n .
end
Output
$ lowentc --check nocap.low
nocap.low:4:0 E-EFFECT-NO-CAP: this op declares the `io` effect but receives NO capability that authorizes it. `io` is a cap-effect (RFC-0007 §6.7): I/O is a RIGHT you are HANDED, not an ambient power — `input fs cap file_system .` (or another `cap …` input). An effect you declare but hold no capability for is a claim the signature cannot back
Allocation is the same: it declares using memory but not where the memory comes from.
examples/ch16/alloc_nocap.low
module alloc_nocap .
rem expect: E-ALLOC-NOCAP
proc scratch output u8 . effects alloc .
do
return 1 .
end
Output
$ lowentc --check alloc_nocap.low
alloc_nocap.low:4:0 E-ALLOC-NOCAP: this op declares the `alloc` effect but receives NO allocation capability. There is no ambient heap in this language (RFC-0043 D1): to allocate you must be HANDED the right — `input a cap allocator .` or `input r region <name> . .` (a region IS an arena allocator, RFC-0043 D7). An allocation nobody granted is exactly the hidden dependency this model removes
alloc_nocap.low:5:1 W-EFFECT-OVER: this op DECLARES `alloc` but never PERFORMS it. A declared effect is a cost the caller must budget for (a pure caller cannot call an `io` op). Drop it, or — if a future version will perform it — say so (RFC-0007)
Receiving a capability and never handing it to the place that uses it is rejected too. A builtin op that requires a capability takes it as its first operand.
examples/ch16/missing.low
module missing .
rem expect: E-CAP-MISSING
proc hello input out cap io . output u64 . effects io .
do
return write_out 1 "hello\n" .
end
Output
$ lowentc --check missing.low
missing.low:6:0 E-CAP-MISSING: `write_out` writes to the process output and needs the `cap io` value the entry received as its FIRST operand — output is a RIGHT you are handed (RFC-0030 D2), never ambient. There is no hidden stdout
There is no way to reach a capability without naming it — in other words, there is no hidden standard output.
16.4 Kind, not presence#
Holding some capability does not open every door.
examples/ch16/kind.low
module kind .
rem expect: E-CAP-KIND
proc stamp input out cap io . output u64 . effects none .
do
return time_now out .
end
Output
$ lowentc --check kind.low
kind.low:6:0 E-CAP-KIND: this host leaf was handed a capability of the WRONG KIND. Authority is by KIND, not by mere presence: an unrelated right (io, net, …) does not authorize the clock, the filesystem, the socket or the OS entropy (RFC-0083 L3 · RFC-0077 §P1-2). Take the right this leaf names — the FFI boundary has always been checked this way (E-FFI-CAPKIND); the host leaves used to be refused only at lowering, where a WRONG RIGHT was reported as an UNSUPPORTED FEATURE
time_now is the primitive that reads the clock. A cap io was handed over, so it is rejected as the wrong kind. Capabilities authorise by what they are.
A common misconception. Reading the time with cap clock requires declaring the io effect
clock.now_ms takes a cap clock but is effects none. A clock breaks determinism, so it needs a capability, but it leaves no trace outside. Capabilities and effects are not always one-to-one. The io row of the pair table is a rule in one direction: “to perform the io effect you must receive one of these”.Authors can name capabilities of their own. Receiving your own capability as a parameter, as in input logger cap audit ., lets you build ops that only code holding that capability can call. What the processor gives meaning to are the kinds in the table above, and work of those kinds requires those kinds.
16.5 What the entry point receives#
The op a program starts from receives only capabilities. It does not receive data, because nobody is there to hand it over.
examples/ch16/entry.low
module entry .
rem expect: E-ENTRY-PARAMS
proc main input out cap io . input n u64 . output u8 . effects io .
do
return narrow u8 n .
end
Output
$ lowentc --check entry.low
entry.low:4:0 E-ENTRY-PARAMS: the entry point takes CAPABILITY inputs only (`input a cap args .`). A data parameter here would be a magic argv — RFC-0030 D2 rejects that: arguments are a RIGHT the runtime hands you, queried via `count`/`arg` on the cap
entry.low:5:1 W-EFFECT-OVER: this op DECLARES `io` but never PERFORMS it. A declared effect is a cost the caller must budget for (a pure caller cannot call an `io` op). Drop it, or — if a future version will perform it — say so (RFC-0007)
There is no magic argument like C’s argv; program arguments are received through the cap args capability too. The capabilities an entry point may receive form a closed list — args, env, io, allocator, heap, file_system, net, tty, clock, random, atomic. Other capabilities (mmio, machine, c) are not something the executing environment can hand over, so an entry point requiring them is rejected with E-ENTRY-CAP. Such capabilities must be created where someone is entitled to grant them and flow in as arguments (chapters 29 and 30).
In practice. Finding out whether a dependency reaches the network
exported ops for any that take cap net (also look for cap c, which escapes into C, and cap machine, for machine code — the processor cannot see inside those). If there are none, the library cannot open connections. If there are, you must hand over cap net where you call them, so that place is the one to audit. A common supply-chain attack — imported code quietly connecting outward — becomes visible in the grammar.Environment variables and operating-system randomness are also received as capabilities.
examples/ch16/envrandom.low
module envrandom .
rem run: main
use random .
rem environment variables need cap env and the operating system's entropy needs cap random; both must be handed over
proc main input e cap env . input r cap random . input al cap allocator . output u8 . effects alloc .
do
let g option mut slice u8 . . be alloc_bytes al capacity 8 .
guard is_some g . else return 250 .
var buf mut slice u8 . be some_value g .
rem fill eight bytes with operating-system randomness; the value differs every run, so it is not returned
let n u64 be random.bytes r buf .
rem a missing variable gives none, an absence rather than a value
let v option slice u8 be env_get e "LOWENT_MANUAL_DEMO" .
guard is_some v . else return narrow u8 n .
return 99 .
end
Output
$ lowentc --run main envrandom.low
main() = 8
env_get e "…" needs cap env, and random.bytes r buf needs cap random. An environment variable is a channel that quietly changes behaviour from outside the program, and randomness is a channel that changes the answer for the same input. Both must be visible in the head so that the head alone tells whether “this program’s answer is decided by its inputs”. With no variable, env_get gives none and the example returns 8, the number of bytes filled. When tests need repeatable random numbers, use rng_next (the next number from a seed), which needs no capability.
16.6 Common mistakes#
Counter-example. Thinking a fn may print because it received a capability
examples/ch16/mistake_fncap.low
module mistake_fncap .
rem expect: E-EFFECT-CALC
rem ✘ receiving a capability does not let a `fn` produce output
fn say input out cap io . output u64 .
do
return write_out out 1 "hi\n" .
end
Output
$ lowentc --check mistake_fncap.low
mistake_fncap.low:6:1 E-EFFECT-CALC: this fn is declared pure but performs `io` — make it a `proc` with `effects …`, or remove the effect
A capability says “who allowed it”; an effect says “what it does”. Neither stands in for the other. Even with cap io, printing is the io effect, and a pure fn cannot perform effects, so this is E-EFFECT-CALC. Change it to proc say … effects io .. Both the permission (capability) and the action (effect) must be in the head, so that each checks the other.
Counter-example. Moving a received capability into a local name
examples/ch16/mistake_caplocal.low
module mistake_caplocal .
rem expect: E-CAP-LOCAL
proc say input out cap io . output u64 . effects io .
do
rem ✘ the capability is moved into a local name --- pass the received name directly
let k cap io be out .
return write_out k 1 "hi\n" .
end
Output
$ lowentc --check mistake_caplocal.low
mistake_caplocal.low:8:0 E-CAP-LOCAL: `k` is a LOCAL that a `cap io` was copied into. A capability is not a value you carry in a local name: only a parameter of this op names a right it was handed, so hand the parameter itself to the leaf (RFC-0030 D2)
A capability is passed down the chain under the name it was received with. Moving it into a local name makes the chain look broken, so it is rejected with E-CAP-LOCAL; the message names the local and the kind that was copied into it. Write the parameter name directly, as in write_out out 1 ….
Counter-example. Writing to standard output with a capability of another kind
examples/ch16/mistake_fscapout.low
module mistake_fscapout .
rem expect: E-CAP-KIND
rem ✘ tries to write to standard output with the file capability
proc main input fs cap file_system . output u8 . effects io .
do
let n u64 be write_out fs 1 "hi\n" .
return 0 .
end
Output
$ lowentc --check mistake_fscapout.low
mistake_fscapout.low:7:0 E-CAP-KIND: this leaf needs a `cap io`, and `fs` is a `cap file_system`. One capability never stands in for another: each names a different right, and holding one says nothing about the other (RFC-0011)
cap file_system opens files and directories; it is not the capability for standard output. Capabilities are authorised by kind, not by possession, so this is rejected with E-CAP-KIND, which prints the kind required beside the kind handed over. The fix: the entry receives input out cap io . and passes that name.
Counter-example. Passing a number where a capability belongs
examples/ch16/mistake_capforge.low
module mistake_capforge .
rem expect: E-CAP-FORGE
proc say input k cap io . output u64 . effects io .
do
return write_out k 1 "hi\n" .
end
rem ✘ receives no capability at all, yet prints by passing the number 0 where a capability belongs
proc start output u8 .
do
let n u64 be say 0 .
return 0 .
end
Output
$ lowentc --check mistake_capforge.low
mistake_capforge.low:12:0 E-CAP-FORGE: a LITERAL was passed where a capability is taken. A capability is handed over, never conjured: it can only be a name you were given (an `input … cap …`) or an actor's capability field (RFC-0030 D2). Passing a number here would let an op that received NO right reach the outside, and then the entry point no longer tells what the program can touch. The placeholder `0` means something only at the tool's door (`--run`)
start receives no capability at all, so it should not be able to print. Passing the number 0 in the cap io position of say looks like a way around that rule, and it is refused with E-CAP-FORGE. Only a name you were handed (input … cap …) or an actor’s capability field may stand in a capability position. Without that, the promise of this chapter — “the entry point tells you everything the program can reach” — would fall to a single line. The placeholder 0 that --run uses means something only at the tool’s door (chapter 31).
A common misconception. It is convenient to take capabilities and effects in advance, in case they are needed later
examples/ch16/capjustincase.low
module capjustincase .
rem expect: W-EFFECT-OVER
rem the capability and the effect are declared just in case they are needed later
proc area input out cap io . input w u64 . input h u64 . output u64 . effects io .
requires le w 1000 .
requires le h 1000 .
do
return mul w h .
end
Output
$ lowentc --check capjustincase.low
capjustincase.low:8:1 W-EFFECT-OVER: this op DECLARES `io` but never PERFORMS it. A declared effect is a cost the caller must budget for (a pure caller cannot call an `io` op). Drop it, or — if a future version will perform it — say so (RFC-0007)
area only multiplies, yet it declares cap io and effects io. Now every caller must find a cap io to pass, and pure code cannot call it at all. The tool warns with W-EFFECT-OVER. Take as few capabilities as possible — the list of received capabilities is “what this op can reach”, so a generous list tells a lie.
16.7 This chapter’s syntax at a glance#
| Shape | Meaning | Why |
|---|---|---|
input out cap io . | receive the standard I/O capability | no ambient authority — power is handed in as an argument |
input fs cap file_system . · cap net · cap clock … | each kind opens different things | least authority — the head says what it can reach |
say k msg | pass a received capability name as is | the chain is visible up to the entry point |
write_out out 1 "…" | builtins that use a capability take it as the first operand | no way to reach a capability without a name |
effects io + cap io | an effect and the capability that allows it | missing: E-EFFECT-NO-CAP |
effects alloc + cap allocator | the pair for fixed-window allocation | missing: E-ALLOC-NOCAP |
proc main input … cap … . output u8 . | the entry point receives only capabilities | nobody can hand it data |
input logger cap audit . | a capability named by the author | only ops that received it can call the guarded op |
env_get e "…" · random.bytes r buf | environment variable · OS randomness — cap env · cap random | outside channels that change the answer show in the head |
Table 16.3 — Capability syntax — shape · meaning · why it looks this way
Recap
input <name> cap <kind> ., and the received name is passed on as is. Looking at the entry point shows what outside a program can reach. Effects and capabilities are a pair: declaring an effect requires receiving an authorising capability, and builtin ops that use a capability take it as their first operand. Capabilities authorise by kind and cost nothing at run time. The entry point receives only capabilities from a closed list.