Skip to main content

lex_types/
authority.rs

1//! Authority derivation: the least [`Grant`] a program provably needs,
2//! and what a change did to it.
3//!
4//! [`trust`](crate::trust) answers "does this effect fit that grant?".
5//! This answers the other direction — **what grant does this code
6//! require?** — which is a question only an effect system can answer
7//! soundly. The declared effect rows are a static over-approximation of
8//! every path through the body, and [`crate::check_program`] has already
9//! rejected any row that lies about its body, so folding those rows into
10//! a grant yields authority the program cannot exceed. A dynamic trace
11//! reports what one run touched; this reports what every run could.
12//!
13//! Two consequences fall out, and both are processes rather than
14//! checks:
15//!
16//! - **A sandbox can be derived instead of written.** The fold is
17//!   minimal by construction — the level on a dimension is the join over
18//!   the effects that touch it — so nothing in the result is there
19//!   because someone was being careful. [`Authority::minimality_witness`]
20//!   makes that checkable rather than claimed: for each dimension it
21//!   names the effect that the next rank down would reject.
22//! - **A change has an authority delta.** [`diff`] classifies two
23//!   derivations as [`Verdict::Widening`], [`Verdict::Narrowing`] or
24//!   [`Verdict::Unchanged`]. A source diff says what the code now does;
25//!   this says what it may now *reach*, which is the reviewable form of
26//!   the same change.
27//!
28//! ## The honest limits, stated once
29//!
30//! The trust lattice ranks three dimensions. Plenty of effects sit
31//! outside it — `env`, `sql`, `approval`, `chat`, `kv` — because
32//! [`effect_requirement`] maps them to no dimension, so *no* grant
33//! refuses them. A grant-only comparison would therefore show nothing
34//! when a program starts reading environment variables.
35//! [`Authority::off_lattice`] and [`AuthorityDiff::off_lattice_added`]
36//! report them separately: present in the review, while being honest
37//! that no perimeter is what stops them.
38//!
39//! Network reach has a second limit. `std.net.get` carries a *bare*
40//! `[net]` — its URL is a runtime value — so the type level binds no
41//! host. [`Authority::unscoped_net`] records that, and the static answer
42//! narrows to "may reach the network at all"; *which* host is a
43//! perimeter question. Consumers must not narrow an egress allowlist
44//! against a derivation carrying it.
45
46use crate::trust::{
47    effect_requirement, is_net_effect, Dimension, Grant, GrantId, Level, TrustError,
48};
49use crate::types::{EffectArg, EffectKind};
50use crate::EffectSet;
51use serde::{Deserialize, Serialize};
52use std::collections::BTreeSet;
53
54/// The authority a program requires, derived from its own types.
55///
56/// A function of the source alone: the same source derives the same
57/// `Authority`, and nothing in it comes from a policy file.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct Authority {
60    /// The least grant in the trust lattice that permits every declared
61    /// effect. Minimal by construction — see
62    /// [`Authority::minimality_witness`].
63    pub grant: Grant,
64    /// The minimal egress allowlist: every host a `net("host")` effect
65    /// names, sorted.
66    pub egress: Vec<String>,
67    /// A bare `[net]` is present somewhere, so the set of hosts reached
68    /// is not bound at the type level. Tracked separately because
69    /// losing host precision is a widening even when the coarse network
70    /// level does not move.
71    pub unscoped_net: bool,
72    /// Path scopes named by `fs_read` / `fs_walk` effects, sorted.
73    pub fs_read: Vec<String>,
74    /// Path scopes named by `fs_write` effects, sorted.
75    pub fs_write: Vec<String>,
76    /// Every declared effect kind, sorted — the lattice's and the rest.
77    pub effects: Vec<String>,
78    /// Declared effects [`effect_requirement`] maps to no dimension,
79    /// sorted. No grant refuses these.
80    pub off_lattice: Vec<String>,
81}
82
83impl Authority {
84    /// Content address of the derived grant — a stable id for "this
85    /// exact authority", so an approval can be bound to it.
86    pub fn grant_id(&self) -> GrantId {
87        self.grant.content_id()
88    }
89
90    /// Evidence that the derived grant is *tight*: for every dimension
91    /// above `none`, the next level down rejects at least one declared
92    /// effect.
93    ///
94    /// Computed rather than asserted, so a caller can print it and a
95    /// test can check it. An empty vector means the grant is
96    /// [`Grant::bottom`] — the program needs no authority at all, which
97    /// is as tight as it gets.
98    pub fn minimality_witness(&self, effects: &EffectSet) -> Vec<MinimalityWitness> {
99        let mut out = Vec::new();
100        for dim in Dimension::ALL {
101            let level = self.grant.level(dim);
102            let Some(lowered_to) = next_level_down(dim, level) else {
103                continue; // already `none` on this dimension
104            };
105            let mut probe = self.grant;
106            set_level(&mut probe, dim, lowered_to);
107            if let Err(TrustError::EffectNotPermitted { effect, .. }) =
108                probe.permits_effects(effects)
109            {
110                out.push(MinimalityWitness {
111                    dimension: dim,
112                    level,
113                    lowered_to,
114                    rejected_effect: effect,
115                });
116            }
117        }
118        out
119    }
120}
121
122/// One dimension's proof that the derived level is not a rank too
123/// generous: at `lowered_to`, `rejected_effect` no longer type-checks.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct MinimalityWitness {
126    pub dimension: Dimension,
127    pub level: Level,
128    pub lowered_to: Level,
129    pub rejected_effect: String,
130}
131
132/// Set one dimension of a grant. Kept here rather than on [`Grant`]
133/// because a grant with a dimension replaced is not necessarily a
134/// meaningful grant — this is only ever used to build a probe that is
135/// expected to fail.
136fn set_level(g: &mut Grant, dim: Dimension, level: Level) {
137    match dim {
138        Dimension::Filesystem => g.filesystem = level,
139        Dimension::Network => g.network = level,
140        Dimension::Exec => g.exec = level,
141    }
142}
143
144/// The level one rank below `level` on `dim`, or `None` if `level` is
145/// already the bottom of that dimension's ladder.
146pub fn next_level_down(dim: Dimension, level: Level) -> Option<Level> {
147    let ladder = dim.levels();
148    let idx = ladder.iter().position(|l| l.rank() == level.rank())?;
149    idx.checked_sub(1).map(|i| ladder[i])
150}
151
152/// Derive the least authority an [`EffectSet`] requires.
153///
154/// Each effect names a dimension and the minimum level it needs; the
155/// grant's level on a dimension is the join over every effect touching
156/// it. Minimal by construction: drop any dimension a rank and the
157/// effect that pushed it there stops being permitted.
158///
159/// The caller is responsible for having type-checked the program first
160/// — a dishonest effect row makes every conclusion here unsound, which
161/// is why nothing in this module parses.
162pub fn derive_from_effects(effects: &EffectSet) -> Result<Authority, TrustError> {
163    let (mut filesystem, mut network, mut exec) = (Level::None, Level::None, Level::None);
164    let mut egress = BTreeSet::new();
165    let mut fs_read = BTreeSet::new();
166    let mut fs_write = BTreeSet::new();
167    let mut kinds = BTreeSet::new();
168    let mut off_lattice = BTreeSet::new();
169    let mut unscoped_net = false;
170
171    for e in &effects.concrete {
172        kinds.insert(e.name.clone());
173        match effect_requirement(&e.name) {
174            Some((Dimension::Filesystem, required)) => filesystem = filesystem.join(required),
175            Some((Dimension::Network, required)) => network = network.join(required),
176            Some((Dimension::Exec, required)) => exec = exec.join(required),
177            None => {
178                off_lattice.insert(e.name.clone());
179            }
180        }
181        if is_net_effect(&e.name) {
182            match scope_arg(e) {
183                Some(host) => {
184                    egress.insert(host.to_string());
185                }
186                None => unscoped_net = true,
187            }
188        }
189        match (e.name.as_str(), scope_arg(e)) {
190            ("fs_read" | "fs_walk", Some(p)) => {
191                fs_read.insert(p.to_string());
192            }
193            ("fs_write", Some(p)) => {
194                fs_write.insert(p.to_string());
195            }
196            _ => {}
197        }
198    }
199
200    Ok(Authority {
201        grant: Grant::try_new(filesystem, network, exec)?,
202        egress: egress.into_iter().collect(),
203        unscoped_net,
204        fs_read: fs_read.into_iter().collect(),
205        fs_write: fs_write.into_iter().collect(),
206        effects: kinds.into_iter().collect(),
207        off_lattice: off_lattice.into_iter().collect(),
208    })
209}
210
211/// The string argument of a scoped effect (`net("host")`,
212/// `fs_read("/path")`), if it has one.
213fn scope_arg(e: &EffectKind) -> Option<&str> {
214    match &e.arg {
215        Some(EffectArg::Str(s)) => Some(s.as_str()),
216        _ => None,
217    }
218}
219
220// ---------------------------------------------------------------- diff
221
222/// How a change moved a program's authority.
223#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
224#[serde(rename_all = "lowercase")]
225pub enum Verdict {
226    /// The head needs exactly what the base needed.
227    Unchanged,
228    /// The head needs strictly less. Safe to apply without asking: the
229    /// type checker has proved the removed authority unreachable.
230    Narrowing,
231    /// The head reaches somewhere the base could not. Any widening
232    /// dominates any narrowing in the same change.
233    Widening,
234}
235
236impl Verdict {
237    pub fn as_str(self) -> &'static str {
238        match self {
239            Verdict::Unchanged => "unchanged",
240            Verdict::Narrowing => "narrowing",
241            Verdict::Widening => "widening",
242        }
243    }
244}
245
246/// One dimension's movement between two derivations.
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248pub struct DimensionDelta {
249    pub dimension: Dimension,
250    pub from: Level,
251    pub to: Level,
252}
253
254impl DimensionDelta {
255    pub fn widens(&self) -> bool {
256        self.to.rank() > self.from.rank()
257    }
258}
259
260/// The authority delta between two versions of a program — the artifact
261/// a reviewer reads next to the source diff.
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
263pub struct AuthorityDiff {
264    /// Dimensions whose level moved, in [`Dimension::ALL`] order.
265    pub dimensions: Vec<DimensionDelta>,
266    pub egress_added: Vec<String>,
267    pub egress_removed: Vec<String>,
268    pub fs_read_added: Vec<String>,
269    pub fs_read_removed: Vec<String>,
270    pub fs_write_added: Vec<String>,
271    pub fs_write_removed: Vec<String>,
272    /// Effect kinds outside the trust lattice the head declares and the
273    /// base did not. No grant refuses these, which is exactly why a
274    /// review should see them.
275    pub off_lattice_added: Vec<String>,
276    pub off_lattice_removed: Vec<String>,
277    /// The head carries a bare `[net]` where the base's network reach
278    /// was real and fully host-scoped: the same coarse level, less
279    /// static precision, so it counts as a widening.
280    pub lost_net_precision: bool,
281    pub verdict: Verdict,
282}
283
284impl AuthorityDiff {
285    /// True when nothing moved at all.
286    pub fn is_empty(&self) -> bool {
287        self.verdict == Verdict::Unchanged
288    }
289}
290
291/// Compare two derivations.
292pub fn diff(base: &Authority, head: &Authority) -> AuthorityDiff {
293    let mut dimensions = Vec::new();
294    for dim in Dimension::ALL {
295        let (from, to) = (base.grant.level(dim), head.grant.level(dim));
296        if from.rank() != to.rank() {
297            dimensions.push(DimensionDelta {
298                dimension: dim,
299                from,
300                to,
301            });
302        }
303    }
304
305    let egress_added = added(&base.egress, &head.egress);
306    let egress_removed = added(&head.egress, &base.egress);
307    let fs_read_added = added(&base.fs_read, &head.fs_read);
308    let fs_read_removed = added(&head.fs_read, &base.fs_read);
309    let fs_write_added = added(&base.fs_write, &head.fs_write);
310    let fs_write_removed = added(&head.fs_write, &base.fs_write);
311    let off_lattice_added = added(&base.off_lattice, &head.off_lattice);
312    let off_lattice_removed = added(&head.off_lattice, &base.off_lattice);
313    // Only a *loss* of precision counts: the base must have had network
314    // reach, and had it fully bound to hosts. No network at all → a bare
315    // `[net]` is already reported as a dimension widening, and saying it
316    // twice would read as two findings.
317    let lost_net_precision = head.unscoped_net && !base.unscoped_net && !base.egress.is_empty();
318
319    let widens = dimensions.iter().any(DimensionDelta::widens)
320        || !egress_added.is_empty()
321        || !fs_read_added.is_empty()
322        || !fs_write_added.is_empty()
323        || !off_lattice_added.is_empty()
324        || lost_net_precision;
325    let narrows = dimensions.iter().any(|d| !d.widens())
326        || !egress_removed.is_empty()
327        || !fs_read_removed.is_empty()
328        || !fs_write_removed.is_empty()
329        || !off_lattice_removed.is_empty()
330        || (base.unscoped_net && !head.unscoped_net);
331
332    let verdict = if widens {
333        Verdict::Widening
334    } else if narrows {
335        Verdict::Narrowing
336    } else {
337        Verdict::Unchanged
338    };
339
340    AuthorityDiff {
341        dimensions,
342        egress_added,
343        egress_removed,
344        fs_read_added,
345        fs_read_removed,
346        fs_write_added,
347        fs_write_removed,
348        off_lattice_added,
349        off_lattice_removed,
350        lost_net_precision,
351        verdict,
352    }
353}
354
355/// Entries of `b` not present in `a`.
356fn added(a: &[String], b: &[String]) -> Vec<String> {
357    let have: BTreeSet<&str> = a.iter().map(String::as_str).collect();
358    b.iter()
359        .filter(|x| !have.contains(x.as_str()))
360        .cloned()
361        .collect()
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    fn effects(rows: &[(&str, Option<&str>)]) -> EffectSet {
369        let mut set = EffectSet::empty();
370        for (name, arg) in rows {
371            let kind = match arg {
372                Some(v) => EffectKind::with_str(name.to_string(), v.to_string()),
373                None => EffectKind::bare(name.to_string()),
374            };
375            set.concrete.insert(kind);
376        }
377        set
378    }
379
380    #[test]
381    fn pure_code_needs_nothing() {
382        let e = effects(&[]);
383        let a = derive_from_effects(&e).unwrap();
384        assert_eq!(a.grant, Grant::bottom());
385        assert!(a.minimality_witness(&e).is_empty());
386    }
387
388    #[test]
389    fn off_lattice_effects_claim_no_dimension() {
390        // `io`, `env` and `sql` rank nowhere: no grant refuses them, so
391        // inventing filesystem authority for them would be over-broad.
392        let e = effects(&[("io", None), ("env", None), ("sql", None)]);
393        let a = derive_from_effects(&e).unwrap();
394        assert_eq!(a.grant, Grant::bottom());
395        assert_eq!(a.off_lattice, vec!["env", "io", "sql"]);
396        Grant::bottom().permits_effects(&e).expect("permitted");
397    }
398
399    #[test]
400    fn the_level_is_the_join_over_the_effects_touching_a_dimension() {
401        // fs_read alone → read-only; adding fs_write raises it, and
402        // nothing lowers it back.
403        let read = derive_from_effects(&effects(&[("fs_read", None)])).unwrap();
404        assert_eq!(read.grant.filesystem, Level::ReadOnly);
405        let both = derive_from_effects(&effects(&[("fs_read", None), ("fs_write", None)])).unwrap();
406        assert_eq!(both.grant.filesystem, Level::ReadWrite);
407    }
408
409    /// The claim the whole process rests on.
410    #[test]
411    fn derived_grants_are_minimal() {
412        for rows in [
413            vec![],
414            vec![("io", None)],
415            vec![("fs_read", Some("/etc/hosts"))],
416            vec![("fs_write", Some("/tmp/out")), ("net", None)],
417            vec![("proc", None), ("llm_cloud", None)],
418            vec![("net", Some("api.example.com")), ("fs_walk", None)],
419        ] {
420            let e = effects(&rows);
421            let a = derive_from_effects(&e).unwrap();
422            a.grant
423                .permits_effects(&e)
424                .expect("permits what it derived");
425            for dim in Dimension::ALL {
426                if let Some(lower) = next_level_down(dim, a.grant.level(dim)) {
427                    let mut probe = a.grant;
428                    set_level(&mut probe, dim, lower);
429                    assert!(
430                        probe.permits_effects(&e).is_err(),
431                        "{dim} at {} is a rank too generous for {rows:?}",
432                        a.grant.level(dim)
433                    );
434                }
435            }
436            let non_none = Dimension::ALL
437                .iter()
438                .filter(|d| a.grant.level(**d) != Level::None)
439                .count();
440            assert_eq!(a.minimality_witness(&e).len(), non_none);
441        }
442    }
443
444    #[test]
445    fn scopes_are_collected_per_kind() {
446        let a = derive_from_effects(&effects(&[
447            ("net", Some("a.example")),
448            ("net", Some("b.example")),
449            ("fs_read", Some("/in")),
450            ("fs_write", Some("/out")),
451        ]))
452        .unwrap();
453        assert_eq!(a.egress, vec!["a.example", "b.example"]);
454        assert_eq!(a.fs_read, vec!["/in"]);
455        assert_eq!(a.fs_write, vec!["/out"]);
456        assert!(!a.unscoped_net, "every net effect named a host");
457    }
458
459    #[test]
460    fn a_bare_net_is_recorded_as_unscoped() {
461        let a = derive_from_effects(&effects(&[("net", None)])).unwrap();
462        assert!(a.unscoped_net);
463        assert!(a.egress.is_empty());
464    }
465
466    #[test]
467    fn adding_a_host_is_a_widening_and_removing_one_is_a_narrowing() {
468        let one = derive_from_effects(&effects(&[("net", Some("a.example"))])).unwrap();
469        let two = derive_from_effects(&effects(&[
470            ("net", Some("a.example")),
471            ("net", Some("b.example")),
472        ]))
473        .unwrap();
474        assert_eq!(diff(&one, &two).verdict, Verdict::Widening);
475        assert_eq!(diff(&two, &one).verdict, Verdict::Narrowing);
476        assert_eq!(diff(&one, &one).verdict, Verdict::Unchanged);
477    }
478
479    #[test]
480    fn losing_host_precision_is_a_widening_even_at_the_same_level() {
481        let scoped = derive_from_effects(&effects(&[("net", Some("a.example"))])).unwrap();
482        let bare =
483            derive_from_effects(&effects(&[("net", Some("a.example")), ("net", None)])).unwrap();
484        assert_eq!(scoped.grant, bare.grant, "same coarse level");
485        let d = diff(&scoped, &bare);
486        assert!(d.lost_net_precision);
487        assert_eq!(d.verdict, Verdict::Widening);
488    }
489
490    #[test]
491    fn no_network_to_a_bare_net_is_not_reported_twice() {
492        let none = derive_from_effects(&effects(&[])).unwrap();
493        let bare = derive_from_effects(&effects(&[("net", None)])).unwrap();
494        let d = diff(&none, &bare);
495        assert_eq!(d.verdict, Verdict::Widening);
496        assert!(
497            !d.lost_net_precision,
498            "the dimension widening already says it"
499        );
500    }
501
502    #[test]
503    fn a_widening_dominates_a_narrowing_in_the_same_change() {
504        let base = derive_from_effects(&effects(&[("fs_write", None)])).unwrap();
505        let head = derive_from_effects(&effects(&[("fs_read", None), ("net", None)])).unwrap();
506        let d = diff(&base, &head);
507        assert!(d.dimensions.iter().any(|x| !x.widens()), "filesystem fell");
508        assert!(d.dimensions.iter().any(|x| x.widens()), "network rose");
509        assert_eq!(d.verdict, Verdict::Widening);
510    }
511
512    #[test]
513    fn off_lattice_movement_is_reported_though_no_grant_would_catch_it() {
514        let base = derive_from_effects(&effects(&[])).unwrap();
515        let head = derive_from_effects(&effects(&[("env", None)])).unwrap();
516        let d = diff(&base, &head);
517        assert_eq!(d.verdict, Verdict::Widening);
518        assert_eq!(d.off_lattice_added, vec!["env"]);
519        assert!(d.dimensions.is_empty(), "no dimension moved");
520    }
521}