Status: mechanised. This document specifies the formal core of Capa's capability discipline. It states the syntax, typing rules, and small-step semantics of a minimal lambda calculus λ_cap that captures the three layers of the discipline, plus the soundness theorems the discipline implies. The proofs are mechanised in Agda at
proofs/CapaSoundness.agdawith nopostulateremaining; CI typechecks them on every change toproofs/.Audience: reviewers who want a reference-quality account of the discipline plus a mechanised soundness argument they can re-check independently.
Capa-the-language has more shapes than this document covers
(structs, sum types, traits, pattern matching, attenuation
chains, the ? operator, generic instantiation, ...). Most of
them are orthogonal to the capability discipline: they are
standard data-shape machinery whose soundness has been worked
out elsewhere. λ_cap focuses on the part where Capa says
something new:
- which terms are allowed to invoke a capability,
- under what static conditions an invocation is sound,
- how the dynamic trace of capability invocations relates to the static signature of the program's entry point.
The unmodelled shapes can be reintroduced as standard extensions to λ_cap with no impact on the soundness arguments.
e ::= x variable
| λx:T. e abstraction
| e1 e2 application
| let x = e1 in e2 let binding
| cap[c] capability value of class c
| attn(e, ρ) attenuate e with restriction ρ
| invoke(e, op) invoke operation op on capability e
| consume(e) consume capability e (linear use)
| () unit value
v ::= λx:T. e lambda value
| cap[c, ρ] concrete capability, class c, restriction ρ
| () unit
Each capability class c ranges over a fixed finite set
𝒞 = { Stdio, Fs, Net, Env, Clock, Random, Proc, Db, Unsafe }. A
restriction ρ ⊆ Σ_c is a finite subset of the scope set
for class c (host names for Net, path prefixes for Fs,
key names for Env, etc.). ρ = ⊤ denotes the unattenuated
authority. The lattice (𝒫(Σ_c), ⊆) is the attenuation
ordering: ρ ⊑ ρ' iff ρ ⊆ ρ'. attn produces the
greatest lower bound:
attn(cap[c, ρ], ρ') ≡ cap[c, ρ ∩ ρ']
For each class c we postulate a fixed set of operations
Ops(c). For Stdio, Ops = { print, println, eprintln, read_line }; for Net, Ops = { get, ... }; and so on.
Every op ∈ Ops(c) carries a scope predicate
α_op : Σ_c → Bool that determines whether a given attenuation
permits the operation. For Net.get(url) the predicate is
α_get(host(url)) ⇔ host(url) ∈ ρ.
The omitted machinery (numbers, strings, arithmetic, control flow) is added by the standard rules of a simply-typed lambda calculus.
T ::= τ base type (Int, String, ...)
| Cap[c] capability type, class c
| T1 → T2 function type
| T1 -∘ T2 linear (consuming) function type
| Unit
The capability type Cap[c] does not index the attenuation:
restrictions are runtime values, not types. The static rule
only ensures every invoke(e, op) has e : Cap[c] for some
class c known statically and op ∈ Ops(c).
A linear function type T1 -∘ T2 represents the consume
construct: a parameter of consuming type may be referenced
exactly once on every execution path. The structural and flow
layers use the ordinary arrow →.
The judgement is Γ ⊢ e : T. The context Γ is split into a
non-linear part Γ_∞ (variables of ordinary type, usable any
number of times) and a linear part Γ_1 (variables of consuming
type, usable exactly once). Where rules split the context they
split it disjointly: Γ_1 = Γ_1' ⊎ Γ_1''.
(T-Var)
───────────────────── ─────────────────────
Γ_∞, x:T ⊢ x : T x:T ⊢ x : T (linear)
Γ_∞, x:T1; Γ_1 ⊢ e : T2 (T-Abs)
────────────────────────────────────
Γ_∞; Γ_1 ⊢ λx:T1. e : T1 → T2
Γ_∞; Γ_1 ⊢ e1 : T1 → T2 Γ_∞; Γ_1' ⊢ e2 : T1 (T-App)
─────────────────────────────────────────────────
Γ_∞; Γ_1 ⊎ Γ_1' ⊢ e1 e2 : T2
Γ_∞; Γ_1 ⊢ e : Cap[c] (T-Attn)
─────────────────────────────────
Γ_∞; Γ_1 ⊢ attn(e, ρ) : Cap[c]
Γ_∞; Γ_1 ⊢ e : Cap[c] op ∈ Ops(c) (T-Invoke)
──────────────────────────────────────────────
Γ_∞; Γ_1 ⊢ invoke(e, op) : Unit
Γ_∞; Γ_1, x:Cap[c] ⊢ e : T x ∈ FV(e), occurs once (T-Consume)
──────────────────────────────────────────────────────────
Γ_∞; Γ_1 ⊢ consume(λx:Cap[c]. e) : Cap[c] -∘ T
The capability discipline is encoded structurally in T-Var
and T-Invoke:
-
Structural layer:
cap[c]is not a term-former in source programs. The only way to obtain aCap[c]is viaT-Var: read a variable whose type isCap[c]from the context. The only way for that variable to exist in the context is for it to have been bound by aT-Abswhose parameter type wasCap[c]. The only way for that abstraction to ever be invoked is if a caller supplies aCap[c]value, transitively, from the program's initial environment. There is no path from a closed program with empty initial capability environment to aCap[c]value. -
Flow layer:
T-Consume(and the linear-use side conditionoccurs once) materialise the must-use rule and the at-most-once rule for consuming parameters. The rest of the flow analysis (every capability parameter must be referenced on every branch) is enforced by an auxiliary pre-pass at the analyser level; it can be folded into a more refinedT-Absrule but is not load-bearing for the soundness theorems below. -
Linear layer: the split
Γ_1 ⊎ Γ_1'inT-Appis what makes linearity work: a linear variable handed toe1cannot also appear ine2. The standard linear-types arguments carry over without modification.
The reduction relation is (e, τ) → (e', τ') where τ is a
trace: a finite sequence of triples (c, ρ, op) recording
each capability invocation in execution order. Reductions
through pure expressions leave τ unchanged; only T-Invoke
appends to it.
(E-AppL)
(e1, τ) → (e1', τ')
─────────────────────────────────────
(e1 e2, τ) → (e1' e2, τ')
(e2, τ) → (e2', τ') (E-AppR)
─────────────────────────────────────
(v1 e2, τ) → (v1 e2', τ')
((λx:T. e) v, τ) → ([v/x] e, τ) (E-Beta)
(let x = v in e, τ) → ([v/x] e, τ) (E-Let)
(attn(cap[c, ρ], ρ'), τ) → (cap[c, ρ ∩ ρ'], τ) (E-Attn)
α_op(ρ) holds (E-Invoke-Allow)
──────────────────────────────────────────────────
(invoke(cap[c, ρ], op), τ) → ((), τ · (c, ρ, op))
¬ α_op(ρ) holds (E-Invoke-Deny)
──────────────────────────────────────────────────
(invoke(cap[c, ρ], op), τ) → ((), τ · (c, ρ, deny[op]))
(consume(λx:Cap[c]. e), τ) → (E-Consume)
(λx:Cap[c]. e, τ)
The denial rule is what makes attenuation fail closed: when
the runtime restriction does not permit the operation, the
operation is recorded as a denial in the trace and execution
continues. This matches what Fs.exists on a denied path does
in the runtime (returns False) and what Net.get on a
denied host does (returns Err(IoError(...))).
Let e be a closed term well-typed under an initial
environment Γ_init = { x_1 : Cap[c_1], …, x_n : Cap[c_n] }
with each x_i bound to cap[c_i, ⊤] in the initial store.
Let C_init = { c_1, …, c_n } be the set of capability
classes accessible to the program. If
(e, ε) →* (e', τ)
for any final or intermediate state, then every
(c, ρ, op) ∈ τ satisfies c ∈ C_init.
Sketch. By induction on the length of the reduction
sequence, with the inductive invariant "every free cap[c, ρ]
occurrence in the current term has c ∈ C_init". The base case
holds because e is closed and its only free capabilities are
the substitutions of x_i for cap[c_i, ⊤]. The inductive
step considers each reduction rule:
E-Beta,E-Let,E-AppL,E-AppR: substitution preserves the invariant by the standard lemma (substituting a value of typeCap[c]for a variable of typeCap[c]cannot introduce a new class).E-Attn: producescap[c, ρ ∩ ρ']whose class is the same as the source capability, so the invariant is preserved.E-Invoke-Allow/E-Invoke-Deny: appends(c, ρ, op)(or(c, ρ, deny[op])) to the trace wherecis the class of the receiver capability, which by the invariant is inC_init. Soc ∈ C_initas required.E-Consume: the consume operation produces a value of function type; no new capability is created.
The structural layer's type system forbids any other path to a
Cap[c] value. ∎
Let e be a closed term with initial environment Γ_init and
manifest M(e) = { c | x : Cap[c] ∈ Γ_init }. Then for every
reduction sequence (e, ε) →* (e', τ), the multiset of
capability classes appearing in τ is a subset of M(e).
Sketch. Direct corollary of Theorem 1: every c in the
trace is in C_init, and C_init = M(e) by construction. ∎
The interesting direction of Manifest Completeness is the
non-emptiness counterpart: if c ∈ M(e) then there exists an
execution path in which c appears in the trace. This is not a
theorem of the static system; it is a conservative-approximation
property. A reviewer who pushes for it has to accept that "the
manifest is an upper bound on the dynamic capability surface"
is the load-bearing claim, not "the manifest is exactly the
dynamic capability surface".
The sketch above is sufficient to anchor the future paper but deliberately leaves four things for the full writeup:
-
The branch / loop discipline (the fork-merge and dry-run-redo rules of the linear layer). These belong in a more refined
T-If,T-Match, andT-Whilefamily of rules that lift the linear consume bookkeeping over control flow. The implementation incapa/analyzer/_statements.pyhas the algorithmic version; the calculus version is a straightforward standard-style adaptation but needs the space of a full paper. -
Attenuation completeness. The
attnoperation is semantically the intersection of restriction sets. A completeness result for attenuation says "given any sound attenuation chain that producescap[c, ρ], the sameρis computed byattn". This is closer to a property of the lattice than a property of the calculus. -
The
Unsafeboundary.py_importandpy_invokecross out of λ_cap's reasoning by construction. The clean way is to type them asUnsafe → (Args → Result)and not model their bodies; the soundness theorem is then stated relative to "everything except behaviour past anUnsafeinvocation". The full writeup will state this precisely. -
The translation from full Capa to λ_cap. The mapping is conservative: data-shape machinery (structs, sums, traits) compiles to ordinary terms; the capability surface compiles to the rules above. The full writeup contains the translation lemma plus the simulation theorem that says "if full-Capa typing accepts
e, then λ_cap typing accepts its translation, and the small-step reductions agree on the trace".
Two things:
-
A referee-tractable target: any reviewer who asks "where's the formal core?" can be pointed at this document. It states the calculus, the rules, the theorems, and the proof obligations.
-
A mechanically-checked soundness argument. The reduction rules are mechanised in
proofs/CapaSoundness.agda(with the syntax layer inproofs/CapaSyntax.agda). Progress, Preservation, Capability Soundness, and a multi-step Manifest Completeness theorem are stated as real Agda definitions with nopostulatedeclarations remaining. CI typechecks the proofs on every change toproofs/, so the positioning document's claim that "the type system is sound, not just convenient" is independently re-checkable rather than aspirational.
What still belongs in a future writeup:
- the translation lemma from full Capa (structs, sum types,
attenuation chains, the
?operator, generic instantiation) down to λ_cap, with the simulation theorem; - prose around the case studies in
examples/cve_*.capashowing the rules block specific classes of CVE.
These are the workshop-paper deliverables; the underlying mechanisation they would cite is already in tree.
Status: machine-checked. Section 9 is now mechanised in Agda (
proofs/CapaIF.agdaandproofs/CapaNoninterference.agda, under--safe, no postulates): Lemma 1, Lemma 2, Theorem 3 (the declassify-free fragment) AND Theorem 4 (delimited release, withdeclassify) are all proved and typechecked in CI. Sections 1 to 8 above describe the capability calculus λ_cap, whose soundness is mechanised in the same directory. Section 9 is a different property about a different calculus: termination-insensitive noninterference for a store-based imperative core λ_if that models Capa's information-flow control (the analyser incapa/analyzer/_ifc.pyover the two-point lattice incapa/_labels.py). The prose below states the rules and proofs; the Agda is the artefact a referee re-checks. It uses the same honesty conventions asproofs/README.md: the calculus is what is proved sound; fidelity between λ_if and the Python analyser is argued informally (Section 9.8), and we do not claim the Python analyser is verified. Three encoding deviations (D1 total stores, D2 inductive big-step semantics, D3 the release-log form of the Theorem 4 agreement hypothesis) are documented inproofs/README.mdand inline in the Agda.
The result is the standard Volpano-Smith / Sabelfeld-Myers
noninterference theorem for a flow-sensitive type system,
specialised to Capa's two-point lattice and extended with an
explicit declassification primitive (delimited release in the
Sabelfeld-Sands sense). The lineage is cited in the references;
nothing here is novel as a theorem. What is load-bearing is
that the rules of λ_if are a faithful core of what
capa/analyzer/_ifc.py actually
does, so that a referee reading "Capa's IFC is sound" has a
precise statement to check.
λ_if is a small imperative language: pure expressions, and
statements over a mutable store, with one secret source
(env_get), one explicit downgrade (declassify), and one
public observable (sink).
e ::= n integer / base literal
| x variable
| e1 ⊕ e2 binary operation (⊕ ranges over a fixed set)
| env_get() the single secret source
| declassify(e) explicit downgrade to PUBLIC
s ::= skip no-op
| x := e assignment
| s1 ; s2 sequencing
| if e then s1 else s2 conditional
| while e do s loop
| sink(e) the single public observable
A store σ : Var ⇀ Val is a finite partial map from variables
to values. A public output trace o : List Val records, in
order, the values emitted by sink. The only modelled base type
is integers (Booleans encoded as 0 / non-0); adding strings
and further base types is orthogonal and changes none of the
arguments.
env_get() abstracts the analyser's single modelled secret
source ("Env", "get") (the _SECRET_SOURCES set in
_ifc.py). sink(e) abstracts the public-exfiltration sinks
_PUBLIC_SINKS (Stdio.println / print / eprintln,
Net.get / post, Fs.write, Db.exec / query). e1 ⊕ e2
abstracts every label-joining expression form in _compute_label
(BinOp, interpolation, indexing, aggregate literals, pure call
results): each is a join of operand labels, so a single n-ary
join former is representative.
The lattice is exactly the two-point lattice of
capa/_labels.py:
PUBLIC ⊑ SECRET (PUBLIC is bottom, SECRET is top)
with join ⊔ = least upper bound. Writing L for {PUBLIC, SECRET} and ranking rank(PUBLIC) = 0, rank(SECRET) = 1:
ℓ1 ⊔ ℓ2 = the ℓi with the larger rank (L.join)
ℓ1 ⊑ ℓ2 ⇔ rank(ℓ1) ≤ rank(ℓ2) (L.flows_to)
⊔ is commutative, associative, idempotent, with PUBLIC as
unit; (L, ⊑) is a two-element bounded lattice. These are the
algebraic facts the proofs use; they are immediate from the
rank table and mirror join / join_all / flows_to in
_labels.py.
A label environment Γ : Var ⇀ L assigns a security label to
each variable. It is flow-sensitive: statement typing updates
Γ as it goes (mirroring Symbol.label being raised in place
by _label_binding, _ifc_field_store,
_check_ifc_container_mutation). A distinguished program-counter
label pc ∈ L tracks the confidentiality of the control-flow
context (the analyser's self._pc_label, raised by _pc_raise).
The judgement Γ ⊢ e : ℓ reads "under Γ, expression e has
security label ℓ". The rules are exactly _compute_label:
(L-Lit)
─────────────────
Γ ⊢ n : PUBLIC
Γ(x) = ℓ (L-Var)
─────────────────
Γ ⊢ x : ℓ
Γ ⊢ e1 : ℓ1 Γ ⊢ e2 : ℓ2 (L-Op)
─────────────────────────────────
Γ ⊢ e1 ⊕ e2 : ℓ1 ⊔ ℓ2
(L-Env)
─────────────────────
Γ ⊢ env_get() : SECRET
Γ ⊢ e : ℓ (L-Declassify)
─────────────────────────
Γ ⊢ declassify(e) : PUBLIC
L-Lit is the literal case of _compute_label; L-Var is the
A.Ident case reading Symbol.label; L-Op is the
join-of-operands core shared by BinOp / interpolation / index /
aggregate / pure-call; L-Env is the _SECRET_SOURCES case;
L-Declassify is the _is_declassify_call override that returns
PUBLIC regardless of the argument's label (_compute_label,
the declassify branch). Labelling is deterministic and total: a
unique ℓ exists for every e under a Γ defined on e's free
variables (immediate by structural induction).
The judgement pc ⊢ Γ { s } Γ' reads "under program-counter
label pc, statement s transforms label environment Γ into
Γ'". It is flow-sensitive (Γ may change) and carries pc.
(T-Skip)
──────────────────────
pc ⊢ Γ { skip } Γ
Γ ⊢ e : ℓ (T-Assign)
──────────────────────────────────────────────
pc ⊢ Γ { x := e } Γ[x ↦ ℓ ⊔ pc]
pc ⊢ Γ { s1 } Γ1 pc ⊢ Γ1 { s2 } Γ2 (T-Seq)
──────────────────────────────────────────────
pc ⊢ Γ { s1 ; s2 } Γ2
Γ ⊢ e : ℓ
(pc ⊔ ℓ) ⊢ Γ { s1 } Γ1
(pc ⊔ ℓ) ⊢ Γ { s2 } Γ2 (T-If)
──────────────────────────────────────────────
pc ⊢ Γ { if e then s1 else s2 } (Γ1 ⊔ Γ2)
Γ ⊢ e : ℓ (pc ⊔ ℓ) ⊢ Γ { s } Γ
Γ a fixpoint of the body (T-While)
──────────────────────────────────────────────
pc ⊢ Γ { while e do s } Γ
Γ ⊢ e : ℓ ℓ ⊔ pc ⊑ PUBLIC (T-Sink)
──────────────────────────────────────────────
pc ⊢ Γ { sink(e) } Γ
Notes on the rules and their analyser counterparts:
-
T-Assign sets
Γ(x) := label(e) ⊔ pc. The⊔ pcconjunct is the implicit-flow guard: a public expression assigned under a secretpcstill yields a SECRET variable. The explicit-flow half is the label join of the RHS value (_join_decl_and_value_labelat alet/varbinding, the reassignment branch of_check_assignforx = e, and_ifc_field_storefor ap.f = efield store). The⊔ pcimplicit-flow half is_join_pc_if_strict, the single helper every one of those three assignment sites routes its result through, which joins the current_pc_labelinto the assigned label. This⊔ pcjoin is the@strict_ifcregime that λ_if models:_join_pc_if_strictis a no-op unless@strict_ifcis active, so the default warn tier deliberately does not fold the pc into assigned labels and therefore does not enforce this implicit flow (Section 9.8, item 6). -
T-If / T-While check the body under
pc' = pc ⊔ ℓwhereℓis the condition's label. This is exactly_pc_raise, which joins the condition labels intoself._pc_labelfor the duration of the guarded body and restores it afterwards._check_ifraises the pc over each branch by the conditions that select it, and_check_while/_check_forraise it over the loop body by the controlling condition / iterated collection (_pc_raise(s.cond)and_pc_raise(s.iter)respectively, restored in afinallyso the raise scopes to the body only). The raised pc only becomes an enforced rejection under@strict_ifc, via the implicit-flow clause of T-Sink below; this is the regime λ_if models. -
T-While requires
Γto be a fixpoint of the body: the body, checked fromΓ, must returnΓunchanged. This is the monotone-join-to-fixpoint that_ifc.pyrealises by raising labels in place with monotonic joins (the sameSymbolobject is carried across iterations, and labels only ever rise), so a loop's post-environment is the least fixpoint above its entry environment. Existence is guaranteed becauseLhas finite height (2) and the body transfer function is monotone, so iterating from the entryΓreaches a fixpoint in finitely many steps; in the rule we take that fixpoint as the typingΓ. -
T-If joins the two branch environments pointwise (
(Γ1 ⊔ Γ2)(x) = Γ1(x) ⊔ Γ2(x)). This is the post-branch merge of the in-place monotonic raises across the two arms. -
T-Sink is well-typed only if
label(e) ⊔ pc ⊑ PUBLIC, i.e. both the argument and the control-flow context are PUBLIC. Thelabel(e) ⊑ PUBLIChalf is_check_ifc_sink's explicit-flow rule (a SECRET argument to a sink position is a violation); thepc ⊑ PUBLIChalf is the@strict_ifcimplicit-flow clause in_check_ifc_sink(a sink running under a SECRETpcis a violation). λ_if models the@strict_ifcregime, where both are hard errors; the default warn-only tier of the analyser is a deliberate usability relaxation discussed in Section 9.8.
A program s is well-typed (under Γ_0) if PUBLIC ⊢ Γ_0 { s } Γ' for some Γ', i.e. it type-checks from the public initial
pc.
Big-step over configurations (σ, s), producing a final store
and an output trace. The judgement (σ, s) ⇓ (σ', o) reads
"from store σ, statement s terminates in store σ' emitting
output trace o". Expression evaluation σ ⊢ e ⇓ v is the
obvious total function on a store defined over e's free
variables, with the two nonstandard cases:
σ ⊢ env_get() ⇓ κ (κ the ambient secret value)
σ ⊢ declassify(e) ⇓ v if σ ⊢ e ⇓ v (identity on the value)
declassify is the identity on values; it changes only the
label, never the value, matching _check_declassify ("declassify
is identity on the value; only its security label changes"). The
secret value read by env_get() is an ambient parameter κ of
the run; two runs may differ in κ (this is precisely what
noninterference quantifies over).
(E-Skip)
─────────────────────────
(σ, skip) ⇓ (σ, ε)
σ ⊢ e ⇓ v (E-Assign)
─────────────────────────────────
(σ, x := e) ⇓ (σ[x ↦ v], ε)
(σ, s1) ⇓ (σ1, o1) (σ1, s2) ⇓ (σ2, o2) (E-Seq)
──────────────────────────────────────────────────
(σ, s1 ; s2) ⇓ (σ2, o1 · o2)
σ ⊢ e ⇓ v v ≠ 0 (σ, s1) ⇓ (σ', o) (E-IfT)
──────────────────────────────────────────────────
(σ, if e then s1 else s2) ⇓ (σ', o)
σ ⊢ e ⇓ v v = 0 (σ, s2) ⇓ (σ', o) (E-IfF)
──────────────────────────────────────────────────
(σ, if e then s1 else s2) ⇓ (σ', o)
σ ⊢ e ⇓ v v = 0 (E-WhileF)
──────────────────────────────────────────────────
(σ, while e do s) ⇓ (σ, ε)
σ ⊢ e ⇓ v v ≠ 0
(σ, s) ⇓ (σ1, o1)
(σ1, while e do s) ⇓ (σ2, o2) (E-WhileT)
──────────────────────────────────────────────────
(σ, while e do s) ⇓ (σ2, o1 · o2)
σ ⊢ e ⇓ v (E-Sink)
─────────────────────────────────
(σ, sink(e)) ⇓ (σ, [v])
ε is the empty trace and · is trace concatenation. sink(e)
appends the value of e to the public output trace; nothing
else affects it. The semantics is termination-insensitive by
construction: ⇓ is only defined for terminating runs, and the
theorem below quantifies only over runs that do converge.
The observer sees PUBLIC-labelled variables and the public
output trace. Low-equivalence is taken with respect to a label
environment Γ.
Definition (low-equivalence of stores). For label environment
Γ, two storesσandσ'are low-equivalent, writtenσ ≈_Γ σ', iff for every variablexwithΓ(x) = PUBLICandx ∈ dom(σ) ∩ dom(σ')we haveσ(x) = σ'(x).
SECRET-labelled variables may differ freely; PUBLIC-labelled variables must agree.
Theorem 3 (Noninterference, declassify-free fragment). Let
sbe a well-typed statement of λ_if that contains nodeclassify, withPUBLIC ⊢ Γ_0 { s } Γ'. Letσ_1 ≈_{Γ_0} σ_2be two low-equivalent initial stores. Fix two arbitrary secret source valuesκ_1,κ_2for the two runs. If both runs converge,(σ_1, s) ⇓ (σ_1', o_1) and (σ_2, s) ⇓ (σ_2', o_2),then the final stores are low-equivalent at the final environment,
σ_1' ≈_{Γ'} σ_2', and the public output traces are identical,o_1 = o_2.
In words: a well-typed declassify-free program reveals nothing
about the secret source κ (nor about the initial values of
SECRET variables) through either its PUBLIC variables or its
public output. The quantification over arbitrary κ_1 ≠ κ_2 is
what makes this a confidentiality statement about env_get().
Termination-insensitivity is the honest match. The theorem
says nothing about runs that diverge, and a SECRET-conditioned
while can diverge on one κ and converge on the other. This
is deliberate and faithful: the static analyser in _ifc.py
performs no termination reasoning whatsoever (it has no progress
measure, no ranking functions, it only raises labels), so it
cannot and does not rule out the termination channel. Claiming
termination-sensitive noninterference would over-state what
the implementation enforces. Termination-insensitive
noninterference is exactly the guarantee a Volpano-Smith-style
flow type system delivers, and exactly what Capa's analyser
delivers. The progress / timing channels are out of scope here
and are partially addressed by the separate @constant_time
discipline (_ct_reject, _check_ct_index, _check_ct_arith
in _ifc.py), which is not modelled by λ_if.
The proof is the textbook two-lemma structure: an expression
soundness lemma for the explicit-flow case, and a confinement
lemma for the implicit-flow (high-pc) case. Both are stated so
they transcribe directly to Agda (structural induction on the
labelling derivation and on the typing derivation respectively).
Lemma 1 (Expression label soundness). If
Γ ⊢ e : PUBLICandσ_1 ≈_Γ σ_2, and bothσ_1 ⊢ e ⇓ v_1andσ_2 ⊢ e ⇓ v_2(with the same ambient secret values for whicheverenv_getoccurrences appear), thenv_1 = v_2. Here the declassify-free restriction is in force, soecontains nodeclassify.
Proof. Induction on the derivation of Γ ⊢ e : PUBLIC.
L-Lit:e = n. Thenv_1 = n = v_2.L-Var:e = xandΓ(x) = PUBLIC. Byσ_1 ≈_Γ σ_2,σ_1(x) = σ_2(x), sov_1 = v_2.L-Op:e = e1 ⊕ e2 : PUBLIC. ByL-Opthe label isℓ1 ⊔ ℓ2 = PUBLIC, and since PUBLIC is the bottom and⊔is a least upper bound,ℓ1 = ℓ2 = PUBLIC. The IH applies toe1ande2, giving equal sub-values;⊕is a (pure, deterministic) function, so the results are equal.L-Env: cannot occur, sinceL-Envgives label SECRET, not PUBLIC.L-Declassify: cannot occur in the declassify-free fragment.
∎
Lemma 2 (Confinement / high-pc). If
SECRET ⊢ Γ { s } Γ'(the statement is typed under a SECRET program-counter) and(σ, s) ⇓ (σ', o), then: (i)o = ε(the run emits no public output); and (ii) for everyxwithΓ(x) = PUBLIC,σ'(x) = σ(x)and moreoverΓ'(x) = PUBLIC ⟹ Γ(x) = PUBLICwith the value unchanged. Equivalently, a statement typed under SECRETpcassigns only to variables whose resulting label is SECRET, and emits nothing.
Proof. Induction on the derivation of (σ, s) ⇓ (σ', o),
inverting the typing derivation SECRET ⊢ Γ { s } Γ' at each
step.
- E-Skip (
s = skip,T-Skip):σ' = σ,o = ε. Both parts hold trivially. - E-Assign (
s = x := e,T-Assign):o = ε, discharging (i). For (ii),Γ' = Γ[x ↦ ℓ ⊔ pc]withpc = SECRET, soΓ'(x) = ℓ ⊔ SECRET = SECRET. Thus the only variable whose value changed,x, hasΓ'(x) = SECRET, not PUBLIC. Every PUBLIC-labelled variable underΓ'is≠ x, hence unchanged in the store and already PUBLIC underΓ. - E-Seq (
T-SeqwithSECRET ⊢ Γ { s1 } Γ1andSECRET ⊢ Γ1 { s2 } Γ2): apply the IH to each sub-derivation. The first giveso1 = εand that no PUBLIC variable changed value throughs1, with each surviving-PUBLIC variable still PUBLIC inΓ1; the second giveso2 = εlikewise fromΓ1. Composing,o = o1 · o2 = ε, and no PUBLIC variable changed across the sequence. - E-IfT / E-IfF (
T-If): the chosen branchsiis typed under(pc ⊔ ℓ)withpc = SECRET, so under SECRET. The IH on the branch's evaluation giveso = εand no PUBLIC change for that branch; the post-environment isΓ1 ⊔ Γ2and a variable PUBLIC there is PUBLIC in bothΓ1andΓ2, hence (by the IH applied to whichever branch ran) unchanged. - E-WhileF (
T-While): zero iterations,σ' = σ,o = ε. - E-WhileT (
T-While): the body is typed under(pc ⊔ ℓ) = SECRETand the loop is a fixpoint (Γin,Γout). The IH on the body giveso1 = εand no PUBLIC value change; the IH on the recursive while-evaluation (same SECRET typing, same fixpointΓ) giveso2 = εand no PUBLIC change. Compose. - E-Sink (
s = sink(e),T-Sink):T-Sinkrequireslabel(e) ⊔ pc ⊑ PUBLIC. Withpc = SECRETthis demandsSECRET ⊑ PUBLIC, which is false. Sosink(e)is not typable under SECRETpc; this case is vacuous. (This is the one place confinement leans on thepcconjunct of T-Sink, i.e. the@strict_ifcimplicit-flow clause.)
∎
Theorem 3 (restated) and its proof.
Proof of Theorem 3. We prove the stronger statement by
induction on the shape of the two evaluation derivations run in
lock-step, generalised over the typing pc ⊢ Γ { s } Γ' and
the invariant: for low-equivalent inputs σ_1 ≈_Γ σ_2, if both
runs converge then σ_1' ≈_{Γ'} σ_2' and o_1 = o_2. The
top-level theorem is the pc = PUBLIC, Γ = Γ_0 instance.
Proceed by induction on the first run's derivation (σ_1, s) ⇓ (σ_1', o_1), case-splitting on whether pc = PUBLIC or pc = SECRET where the cases need it.
-
skip: both runs use E-Skip; stores unchanged, both traces
ε.σ_1 ≈_Γ σ_2andΓ' = Γclose it. -
x := e (
T-Assign,Γ' = Γ[x ↦ ℓ ⊔ pc]): both runs use E-Assign, no output, soo_1 = o_2 = ε. For low-equivalence atΓ', take anyywithΓ'(y) = PUBLIC.- If
y ≠ x:Γ'(y) = Γ(y) = PUBLICand the store value ofyis unchanged in both runs, soσ_1'(y) = σ_1(y) = σ_2(y) = σ_2'(y)by the hypothesisσ_1 ≈_Γ σ_2. - If
y = x:Γ'(x) = ℓ ⊔ pc = PUBLICforcesℓ = PUBLICandpc = PUBLIC. ThenΓ ⊢ e : PUBLIC, and by Lemma 1 (usingσ_1 ≈_Γ σ_2) the assigned values agree:σ_1'(x) = σ_2'(x).
- If
-
s1 ; s2 (
T-Seq): IH ons1fromσ_1 ≈_Γ σ_2givesσ_{1,1} ≈_{Γ1} σ_{2,1}and equal first outputs; IH ons2from there givesσ_1' ≈_{Γ2} σ_2'and equal second outputs. Concatenated outputs are equal. -
if e then s1 else s2 (
T-If, condition labelℓ,Γ' = Γ1 ⊔ Γ2). Two sub-cases onℓ.ℓ = PUBLIC(low branch). By Lemma 1 the condition evaluates equally in both runs, so both take the same branchsi. That branch is typed underpc ⊔ ℓ = pc. Apply the IH tosifromσ_1 ≈_Γ σ_2: equal outputs, andσ_1' ≈_{Γi} σ_2'. SinceΓ' = Γ1 ⊔ Γ2and a variable PUBLIC inΓ'is PUBLIC inΓi, low-equivalence lifts fromΓitoΓ'.ℓ = SECRET(high branch). The two runs may take different branches. But each branch is typed underpc ⊔ ℓ = SECRET. Apply Confinement (Lemma 2) to whichever branch run each side took: each emitso = ε(soo_1 = ε = o_2, equal) and changes no PUBLIC-at-Γ'variable's value (a variable PUBLIC inΓ' = Γ1 ⊔ Γ2is PUBLIC in bothΓ1andΓ2, hence within scope of Lemma 2(ii) for whichever branch ran). Therefore each run's PUBLIC variables equal their initial values, which agreed byσ_1 ≈_Γ σ_2; soσ_1' ≈_{Γ'} σ_2'. (Note this high case can only arise whenpc = PUBLICat entry butℓ = SECRETraises it; whenpcis already SECRET the whole statement is governed by Lemma 2 directly and the theorem's conclusion is immediate.)
-
while e do s (
T-While, fixpointΓ,Γ' = Γ). Sub-case on the condition labelℓ.ℓ = PUBLIC. By Lemma 1 the guard evaluates equally in both runs, so both take the same number of iterations and in lock-step (a standard induction on the number of iterations, using the IH on the body, which is typed underpc ⊔ ℓ = pcand returns the same fixpointΓ). Each iteration preservesσ_1 ≈_Γ σ_2and equal accumulated output; the result follows.ℓ = SECRET. The body is typed underpc ⊔ ℓ = SECRET, and the two runs may iterate different numbers of times. Apply Confinement (Lemma 2) to each iteration of each run: every iteration emitsεand changes no PUBLIC variable's value. Hence both whole loops emitε(equal) and leave every PUBLIC variable at its initial, agreeing value, soσ_1' ≈_Γ σ_2'. (Termination-insensitivity is used exactly here: if one run loops forever it has no⇓derivation and is outside the theorem's hypothesis; we only relate runs that both converge.)
-
sink(e) (
T-Sink, requireslabel(e) ⊔ pc ⊑ PUBLIC). Well-typedness forceslabel(e) = PUBLICandpc = PUBLIC. By Lemma 1 the emitted value agrees:v_1 = v_2, soo_1 = [v_1] = [v_2] = o_2. The store is unchanged, preservingσ_1 ≈_Γ σ_2 = σ_1' ≈_{Γ'} σ_2'.
Every case preserves both conjuncts; the induction closes. ∎
With declassify reintroduced, strict noninterference is by
design false: declassify is a deliberate downgrade. The
honest statement is delimited release / relaxed
noninterference in the Sabelfeld-Sands sense: the program
leaks nothing beyond the values it explicitly declassifies.
Let D(s) be the (multiset of) sub-expressions appearing inside
declassify(·) positions in s. Given a run, write ⟦D(s)⟧_σ^κ
for the tuple of values those declassified expressions evaluate
to during that run.
Theorem 4 (Relaxed noninterference, delimited release). Let
sbe well-typed withPUBLIC ⊢ Γ_0 { s } Γ'. Letσ_1 ≈_{Γ_0} σ_2with ambient secretsκ_1,κ_2. If both runs converge and additionally the two runs agree on every declassified value, i.e.⟦D(s)⟧_{σ_1}^{κ_1} = ⟦D(s)⟧_{σ_2}^{κ_2},then
σ_1' ≈_{Γ'} σ_2'ando_1 = o_2.
Proof. Identical to the proof of Theorem 3, with one extra labelling case to discharge in Lemma 1:
L-Declassify:e = declassify(e_0) : PUBLIC. We must show the two runs assign it equal values. By the hypothesis of Theorem 4, the two runs agree on every declassified value, ande_0 ∈ D(s); thereforev_1 = ⟦e_0⟧_{σ_1}^{κ_1} = ⟦e_0⟧_{σ_2}^{κ_2} = v_2directly. (We do not recurse intoe_0; the agreement is assumed, which is precisely what "released modulo the declassified expressions" means.)
Every other case of Lemma 1, and of the main induction, is
unchanged. The escape hatch is contained to L-Declassify: a
SECRET value can become PUBLIC only by passing through a
declassify, and the theorem then relates only those runs that
already agree on what was released. ∎
Mechanisation note (encoding of the agreement hypothesis, deviation D3). Theorems 3 and 4 are now machine-checked in Agda (
proofs/CapaNoninterference.agda,noninterferenceandtheorem4, under--safe, no postulates). The Agda development encodes the agreement hypothesis⟦D(s)⟧_{σ_1}^{κ_1} = ⟦D(s)⟧_{σ_2}^{κ_2}not as one flat multiset equality but in two structural forms (inproofs/CapaIF.agda):EAgreeon expressions ("the two runs agree on every declassified value insidee") andAgreeon the two big-step derivations ("...along the actual execution paths"). A concrete per- expression release logreleases κ σ eis also defined (the sequence of declassified values an expression produces, thedeclassifyanalogue of thesinkoutput traceo), andEAgreeis proved EQUIVALENT to release-log equalityreleases κ_1 σ_1 e = releases κ_2 σ_2 e(eagree->releq/releq->eagree). So the Agda hypothesis IS the release-set agreement above, only phrased per declassify position so theL-OpandL-Declassifycases decompose without list-append reasoning. The derivation-indexedAgreeis the faithful operational reading of⟦D(s)⟧evaluated at the store each declassify is actually reached in; where the two runs diverge under a SECRET guard it demands agreement only on the guard's releases, since the divergent declassifies are confined by Lemma 2 exactly as in the proof above. This keeps the hypothesis non-vacuous: a worked example in the Agda file (sink(declassify(env_get))) is covered by Theorem 4 but excluded from Theorem 3, and its agreement hypothesis reduces to secret equalityκ_1 = κ_2, so Theorem 4 does not collapse into Theorem 3.
This matches the analyser's declassify(value, reason: "..."):
the label drops to PUBLIC (_compute_label), the value is
unchanged (_check_declassify), and the reason string is the
audit record that, in the deployed system, is what an auditor
inspects to decide whether the released values were appropriate.
λ_if models the flow effect of declassify; the SBOM audit
trail is orthogonal and not part of the calculus.
λ_if is a faithful core, not a transcription, of
capa/analyzer/_ifc.py. The
fidelity between λ_if and the analyser is argued informally; in
the honesty convention of proofs/README.md,
the calculus is what is proved, and we do not claim the Python
analyser is verified. What λ_if deliberately abstracts away:
-
Per-field struct precision and escape analysis. The analyser tracks a per-field label map per struct binding (
_record_field_map,_precise_field_label,_ifc_field_store) plus an escape set (_escaped_struct_syms,_mark_struct_escape) so a public field of a struct that also holds a secret is not over-tainted. λ_if has only scalar variables. The analyser's per-field map is a precision refinement that always falls back to the sound whole-value join on escape / aliasing / unknown shape, so λ_if's whole-value treatment is the sound over-approximation the analyser degrades to. λ_if does not prove the per-field refinement itself sound; the analyser's own comments record two known false negatives there that remain abstracted away (cross-function self-mutation, and embed-then-mutate staleness of an embedded struct binding). The implicit-flow case (a public field assigned under a secret pc) is not among them under@strict_ifc:_ifc_field_storefolds the pc into the stored field's label via_join_pc_if_strict(the struct analogue of the scalar implicit-assign rule of T-Assign), so the strict tier λ_if models enforces it. -
Mutable-container taint and reference aliasing.
_check_ifc_container_mutation(List.push / Set.add / Map.set) and_ifc_alias_link/_struct_aliasesmodel secrets injected into mutable containers and shared through reference aliases. λ_if's single n-ary join expression (L-Op) and scalar store abstract these as the same join-of-operands rule; the aliasing bookkeeping is not in the calculus. -
Cross-function summaries.
_ifc_summary,_check_ifc_call_summary,_check_ifc_method_call_summarycompute, to a fixpoint, which parameters of each function / method reach a sink, including a sound union over dynamic (trait / capability) dispatch. λ_if is intra-procedural: it has no function calls at the statement level;e1 ⊕ e2stands in for the join-of-arguments rule a pure call uses. -
Constant-time / timing channels.
_ct_reject,_check_ct_index,_check_ct_method_index,_check_ct_arithenforce a separate@constant_timediscipline (no secret branch conditions, no secret indices, no variable-time arithmetic on secrets). This is a timing property, distinct from noninterference over the value/trace observation that λ_if models, and it is not part of λ_if. The termination-insensitivity of Theorem 3 is the matching statement: λ_if proves nothing about timing or progress. -
The real AST and type system. λ_if has integers and one binary-op former; Capa has the full type system of Sections 1 to 4, interpolated strings, pattern matching (
_label_pattern_binds), the?operator (A.Try), and so on. Each maps to anL-Op-style join in the analyser; λ_if takes the representative case. -
Warn-then-enforce tiering. The analyser emits IFC findings as non-fatal warnings by default and as hard errors only under
@strict_ifc(_check_ifc_sink,_emit_ifc_call_leak). λ_if models the@strict_ifcregime, where T-Sink is a hard typing requirement. Under the default tier a program that violates T-Sink still compiles (with a warning), so Theorem 3 characterises the guarantee of@strict_ifccode specifically, not of every program the compiler accepts.
Mechanisation status. Section 9 is now mechanised. The Agda
development for λ_if parallels
proofs/CapaSoundness.agda:
proofs/CapaIF.agda has the syntax, the
labelling and statement-typing relations, the big-step semantics,
and the release-log machinery; and
proofs/CapaNoninterference.agda
has Lemmas 1 and 2 and Theorems 3 and 4. The two-point lattice,
finite loop-fixpoint, and lock-step induction made it a PLFA-scale
development comparable to the existing capability proof. The
noninterference claim is therefore now a machine-checked result
(under --safe, no postulates), not a hand proof.
Section 9.8 argues the λ_if-versus-analyser fidelity informally.
tests/test_ifc_fidelity.py adds
machine-run evidence for it: a differential, property-based
harness that cross-checks the real analyser
(capa/analyzer/_ifc.py) against an
independent, executable model of λ_if
(tests/_lambda_if_ref.py) on the
fragment λ_if models.
The reference module is a hand transcription of
proofs/CapaIF.agda - the two-point
lattice, the expression labelling judgement (L-Lit / L-Var /
L-Op / L-Env / L-Declassify), the flow-sensitive, pc-carrying
statement typing (T-Skip / T-Assign / T-Seq / T-If /
T-While / T-Sink, with the T-Sink side condition
(l ⊔ pc) flows PUBLIC as the single hard constraint of the
@strict_ifc regime), and the big-step semantics with a public
output trace. The value carrier is the one implementation choice
Section 9 leaves open (CapaIF.agda takes op as "any deterministic
binary function"): the harness fixes it to strings with op =
concatenation, so env-get maps onto env.get(...).unwrap_or(...)
with no parsing glue and the model trace is byte-comparable with
stdio.println output. Every IFC-relevant rule is transcribed
unchanged; only the carrier is chosen.
A Hypothesis generator emits small λ_if-fragment programs (scalar
variables, env-get as the secret source, declassify, assignment,
sequencing, if / while, sink) in both well-typed and ill-typed
(leaky) shapes, a renderer turns each into the equivalent
@strict_ifc Capa program 1:1, and two differential checks run:
- Typing agreement. The reference's well-typed verdict must
equal the analyser's "accepts under
@strict_ifc" verdict on every program. Because the Agda proof guarantees the λ_if verdict enforces noninterference, a disagreement is a fidelity finding - an analyser soundness gap (it accepts what λ_if rejects) or an over-restriction (it rejects what λ_if accepts). - Runtime agreement. For every well-typed program, the analyser-
backed Capa runtime and the λ_if reference interpreter must produce
the same public output trace on the same secret input; run twice
with differing secrets, a well-typed program's trace must also be
independent of the secret (excluding
declassify, which reveals by design - there the model and Capa still agree on the revealed value). A mismatch is a semantics-fidelity finding.
The harness reports non-vacuity counters confirming both the accept
and the reject branches are reached and that runtime agreement runs
on real (non-discarded) programs. The counters also confirm the
confinement frontier is exercised: a SECRET-guarded if / while
whose body assigns but sinks nothing (pc raised, assignment tainted,
nothing released) is well-typed in λ_if by confinement and the
analyser must - and does - accept it, so this is the case where the
reference and analyser reason most differently yet must reach the
same verdict.
Honest caveats. This is evidence, not proof, and it does not close the model-versus-implementation gap of Section 9.8:
- it is coverage-bounded - Hypothesis sampling, not an exhaustive check of all programs;
- it is fragment-only - it covers exactly what λ_if models
(scalar variables,
env-get,declassify, assign, seq,if,while,sink) and not the analyser features Section 9.8 lists as abstracted away (per-field struct precision and escape analysis, mutable-container taint and reference aliasing, cross-function summaries, constant-time / timing, the full AST and type system); - it shares the same value carrier choice as a deliberate simplification, so it tests the IFC verdict and the value/trace observation, not Capa's full value semantics.
What it does buy: independent, re-runnable confirmation that on the modelled fragment the analyser's accept/reject decisions and runtime traces match the proven-safe calculus, narrowing the informal gap to the explicitly-listed unmodelled features.
- Wadler, Linear types can change the world. The linear layer here is a textbook special case.
- Maranget, Compiling pattern matching to good decision trees. For the future treatment of pattern exhaustiveness alongside the capability layers.
- Naur-style operational semantics traditions (Plotkin, Wright-Felleisen). The progress/preservation framing in Theorem 1 is the standard one.
- WebAssembly Component Model and WIT. Adjacent system at module rather than function granularity; the comparison belongs in the related-work section of the paper.
- Volpano, Smith, Irvine, A sound type system for secure flow analysis. The flow type system and the termination-insensitive noninterference theorem of Section 9 are this lineage.
- Sabelfeld, Myers, Language-based information-flow security. The survey framing for the lattice, low-equivalence, and noninterference statement used in Section 9.
- Sabelfeld, Sands, Declassification: dimensions and
principles, and Sabelfeld, Myers, A model for delimited
release. The relaxed-noninterference / delimited-release
treatment of
declassifyin Section 9.7.1.
(Full references list to be assembled in the workshop-paper submission.)