Skip to main content

lex_types/
trust.rs

1//! Trust lattice: effect-narrowing as subtyping over a small fixed
2//! dimension set (filesystem, network, exec).
3//!
4//! This is the type-level half of the lex-os trust model (see the
5//! lex-os design doc §7). The key idea AgentSpec only *described* in a
6//! prose "trust block", Lex makes a **type property**:
7//!
8//! - Trust dimensions form a product **lattice**.
9//! - Manifest inheritance is **subtyping** over that lattice: a child
10//!   grant may only *narrow* (be ≤) its parent. Widening is a type
11//!   error, caught by construction rather than a hoped-for runtime
12//!   check ([`Grant::narrow`]).
13//! - The same grant that drives this static check also tells the
14//!   supervisor what OS sandbox to derive — the effects a function
15//!   uses ([`EffectSet`]) are checked against the grant with
16//!   [`Grant::permits_effects`], so code that calls a `net` effect
17//!   will not satisfy a `network: none` grant.
18//!
19//! The module is deliberately self-contained: it adds a lattice
20//! primitive that is useful to *any* Lex program reasoning about
21//! capabilities, not just the agent runtime, and it does not change
22//! the behaviour of the existing checker.
23
24use crate::types::{EffectKind, EffectSet};
25use serde::{Deserialize, Serialize};
26use sha2::{Digest, Sha256};
27use std::fmt;
28
29/// The three trust dimensions an effect can touch. Kept deliberately
30/// small and fixed (design doc §7.2): every consequential effect a box
31/// can have on the world reduces to filesystem reach, network reach, or
32/// the ability to spawn arbitrary executables.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
34pub enum Dimension {
35    Filesystem,
36    Network,
37    Exec,
38}
39
40impl Dimension {
41    pub const ALL: [Dimension; 3] = [Dimension::Filesystem, Dimension::Network, Dimension::Exec];
42
43    pub fn as_str(self) -> &'static str {
44        match self {
45            Dimension::Filesystem => "filesystem",
46            Dimension::Network => "network",
47            Dimension::Exec => "exec",
48        }
49    }
50}
51
52impl Dimension {
53    /// The levels that carry a meaning on this dimension.
54    ///
55    /// The vocabulary is shared across dimensions deliberately — it is
56    /// small and the ordering is what matters — but sharing a
57    /// vocabulary is not sharing a meaning. `Loopback` says nothing
58    /// about a filesystem, and `ReadWrite` says nothing about a
59    /// process. Accepting one anyway does not merely look untidy: it
60    /// ranks, so it narrows, satisfies effect checks, and resolves to a
61    /// sandbox — all while naming nothing.
62    pub fn levels(self) -> &'static [Level] {
63        match self {
64            Dimension::Filesystem => {
65                &[Level::None, Level::ReadOnly, Level::ReadWrite, Level::Full]
66            }
67            Dimension::Network => &[Level::None, Level::Loopback, Level::Allowlist, Level::Full],
68            Dimension::Exec => &[Level::None, Level::Sandboxed, Level::Full],
69        }
70    }
71
72    /// Does `level` mean anything on this dimension?
73    pub fn permits_level(self, level: Level) -> bool {
74        self.levels().contains(&level)
75    }
76}
77
78impl fmt::Display for Dimension {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        f.write_str(self.as_str())
81    }
82}
83
84/// Trust level along a single dimension. Levels are **totally ordered**
85/// from `None` (no authority) upward; the numeric discriminant *is* the
86/// order, so `<=`/`max`/`min` on the rank give the lattice operations.
87///
88/// The levels are shared across dimensions (a deliberately small
89/// vocabulary) but not every level is meaningful on every dimension —
90/// the canonical readings are:
91///
92/// | rank | Filesystem | Network   | Exec       |
93/// |------|------------|-----------|------------|
94/// | 0    | none       | none      | none       |
95/// | 1    | read-only  | loopback  | sandboxed  |
96/// | 2    | read-write | allowlist | *(none)*   |
97/// | 3    | full       | full      | full       |
98///
99/// `Sandboxed` aliases rank 1 for exec; `Allowlist` aliases rank 2 for
100/// network. They are distinct enum variants for legibility but compare
101/// purely by [`Level::rank`].
102///
103/// **Exec has no rank-2 level**, and this table used to claim rank 2
104/// read as `= full` there. It did not: nothing requires exec above
105/// rank 1 ([`effect_requirement`] maps `proc` to `Sandboxed`), so the
106/// only thing rank 2 changed was lex-os's isolation floor, which gives
107/// rank 2 a *gVisor* boundary while `Full` demands a microVM. An author
108/// following the old table asked for full-exec semantics and received a
109/// weaker boundary than full exec requires.
110///
111/// A level is therefore only accepted on a dimension that gives it a
112/// meaning — see [`Dimension::permits_level`] — so the gap is a refusal
113/// rather than a silently weaker box (alpibrusl/lex-lang#808).
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
115pub enum Level {
116    /// rank 0 — the effect is *physically absent* from the box.
117    None,
118    /// rank 1 — read-only / loopback-only / sandboxed-exec.
119    ReadOnly,
120    /// rank 1 — exec spelled for legibility (same rank as `ReadOnly`).
121    Sandboxed,
122    /// rank 1 — network loopback only.
123    Loopback,
124    /// rank 2 — read-write filesystem.
125    ReadWrite,
126    /// rank 2 — network restricted to an allowlist.
127    Allowlist,
128    /// rank 3 — unrestricted authority on the dimension.
129    Full,
130}
131
132impl Level {
133    /// The position of this level in the total order. Lattice
134    /// operations are defined on the rank.
135    pub fn rank(self) -> u8 {
136        match self {
137            Level::None => 0,
138            Level::ReadOnly | Level::Sandboxed | Level::Loopback => 1,
139            Level::ReadWrite | Level::Allowlist => 2,
140            Level::Full => 3,
141        }
142    }
143
144    /// `self` ≤ `other` in the trust order (self grants no more than
145    /// other). This is the per-dimension subtyping relation.
146    pub fn leq(self, other: Level) -> bool {
147        self.rank() <= other.rank()
148    }
149
150    /// Least upper bound (join): the tighter of two levels that still
151    /// covers both. Returns the higher-ranked level.
152    pub fn join(self, other: Level) -> Level {
153        if self.rank() >= other.rank() {
154            self
155        } else {
156            other
157        }
158    }
159
160    /// Greatest lower bound (meet): the most authority both allow.
161    /// Returns the lower-ranked level.
162    pub fn meet(self, other: Level) -> Level {
163        if self.rank() <= other.rank() {
164            self
165        } else {
166            other
167        }
168    }
169
170    pub fn as_str(self) -> &'static str {
171        match self {
172            Level::None => "none",
173            Level::ReadOnly => "read-only",
174            Level::Sandboxed => "sandboxed",
175            Level::Loopback => "loopback",
176            Level::ReadWrite => "read-write",
177            Level::Allowlist => "allowlist",
178            Level::Full => "full",
179        }
180    }
181}
182
183impl Level {
184    /// The spelling this level has **in a manifest**.
185    ///
186    /// Distinct from [`Level::as_str`], which is the lowercase prose
187    /// form used in messages about a grant. A refusal is read by
188    /// whoever is writing the JSON, so quoting `sandboxed` at them when
189    /// the parser wants `Sandboxed` would send them round again — the
190    /// exact loop these refusals exist to end.
191    pub fn json_name(self) -> &'static str {
192        match self {
193            Level::None => "None",
194            Level::ReadOnly => "ReadOnly",
195            Level::Sandboxed => "Sandboxed",
196            Level::Loopback => "Loopback",
197            Level::ReadWrite => "ReadWrite",
198            Level::Allowlist => "Allowlist",
199            Level::Full => "Full",
200        }
201    }
202}
203
204impl fmt::Display for Level {
205    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206        f.write_str(self.as_str())
207    }
208}
209
210/// A capability grant: one [`Level`] per [`Dimension`]. This is the
211/// trust manifest's core payload. As a product of totally-ordered
212/// dimensions it forms a **lattice** under componentwise ordering, with
213/// [`Grant::bottom`] (deny everything) and [`Grant::top`] (the most
214/// dangerous config — `sudo` + open internet, design doc §3) as the
215/// extremes.
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
217#[serde(try_from = "GrantWire")]
218pub struct Grant {
219    pub filesystem: Level,
220    pub network: Level,
221    pub exec: Level,
222}
223
224/// The deserialization shape of a [`Grant`], so that an authored grant
225/// is validated on the way in rather than trusted.
226///
227/// A manifest is written to be machine-checked, not read line by line,
228/// which puts the whole weight of catching a wrong grant here. Two
229/// things are refused: a key nobody defined (silently dropping it would
230/// mean an author believes they declared something they did not), and a
231/// level that means nothing on the dimension it names.
232#[derive(Deserialize)]
233#[serde(deny_unknown_fields)]
234struct GrantWire {
235    filesystem: Level,
236    network: Level,
237    exec: Level,
238}
239
240impl TryFrom<GrantWire> for Grant {
241    type Error = TrustError;
242
243    fn try_from(w: GrantWire) -> Result<Self, Self::Error> {
244        Grant::try_new(w.filesystem, w.network, w.exec)
245    }
246}
247
248/// Why a requested grant was refused. The runtime contract is
249/// *refuse, don't downgrade* (design doc §7.5): when a child manifest
250/// asks for more than its parent allows we return this error rather
251/// than silently clamping.
252#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
253pub enum TrustError {
254    #[error(
255        "trust widening on {dimension}: child requests `{requested}` but parent only grants `{parent}` (a child manifest may only narrow)"
256    )]
257    Widens {
258        dimension: Dimension,
259        parent: Level,
260        requested: Level,
261    },
262    #[error(
263        "effect `{effect}` needs {dimension} ≥ `{required}` but the grant only provides `{granted}`"
264    )]
265    EffectNotPermitted {
266        effect: String,
267        dimension: Dimension,
268        required: Level,
269        granted: Level,
270    },
271    #[error(
272        "net effect to `{host}` is not in the grant's egress allowlist ({allowed} host(s) allowed)"
273    )]
274    NetHostNotAllowed { host: String, allowed: usize },
275    #[error(
276        "unscoped `[net]` cannot be proven within the egress allowlist — scope it to a host, e.g. `net(\"results.demo.internal\")`"
277    )]
278    NetUnscoped,
279    #[error(
280        "`{level}` is not one of {dimension}'s levels — {dimension} accepts {allowed}"
281    )]
282    LevelNotOnDimension {
283        dimension: Dimension,
284        level: Level,
285        allowed: String,
286    },
287}
288
289impl Grant {
290    /// Construct a grant from any three levels, without checking that
291    /// each means something on the dimension it names.
292    ///
293    /// Deliberately unvalidated, and not an oversight. The lattice
294    /// properties the safety argument rests on — that `leq` is a partial
295    /// order, that `narrow` accepts exactly the narrowing pairs, that
296    /// narrowing never unlocks a rejected effect set — are properties of
297    /// the *ordering*, and `tests/trust_lattice.rs` proves them by
298    /// enumerating all 7x7x7 grants. Refusing the axis-inappropriate
299    /// ones here would leave that suite unable to state its own claim.
300    ///
301    /// So this is the structural constructor, for in-tree callers whose
302    /// levels are literals a reviewer can see. Anything **authored** —
303    /// parsed from a manifest, or built from values chosen at runtime —
304    /// goes through [`Grant::try_new`], which refuses a level that names
305    /// nothing on its dimension (#808); deserialization already does.
306    pub fn new(filesystem: Level, network: Level, exec: Level) -> Self {
307        Self { filesystem, network, exec }
308    }
309
310    /// Construct a grant, refusing any level that carries no meaning on
311    /// the dimension it names.
312    ///
313    /// `exec: Allowlist` is the case worth naming: it parsed, it ranked
314    /// above `Sandboxed`, it narrowed cleanly under `exec: Full`, and it
315    /// resolved to a *weaker* isolation floor than the full-exec reading
316    /// it looked like. Refusing beats every one of those (#808).
317    pub fn try_new(filesystem: Level, network: Level, exec: Level) -> Result<Self, TrustError> {
318        for (dim, level) in [
319            (Dimension::Filesystem, filesystem),
320            (Dimension::Network, network),
321            (Dimension::Exec, exec),
322        ] {
323            if !dim.permits_level(level) {
324                return Err(TrustError::LevelNotOnDimension {
325                    dimension: dim,
326                    level,
327                    allowed: dim
328                        .levels()
329                        .iter()
330                        .map(|l| format!("`{}`", l.json_name()))
331                        .collect::<Vec<_>>()
332                        .join(", "),
333                });
334            }
335        }
336        Ok(Self { filesystem, network, exec })
337    }
338
339    /// Deny everything — the lattice bottom. The default starting point
340    /// for the narrowest-possible grant (design doc §5.1): every
341    /// ungranted effect is physically absent.
342    pub fn bottom() -> Self {
343        Self::new(Level::None, Level::None, Level::None)
344    }
345
346    /// Grant everything — the lattice top. `sudo` + open internet; the
347    /// single most dangerous config. Never the default.
348    pub fn top() -> Self {
349        Self::new(Level::Full, Level::Full, Level::Full)
350    }
351
352    pub fn level(&self, dim: Dimension) -> Level {
353        match dim {
354            Dimension::Filesystem => self.filesystem,
355            Dimension::Network => self.network,
356            Dimension::Exec => self.exec,
357        }
358    }
359
360    /// `self` ≤ `other`: self grants no more authority than other on
361    /// *any* dimension. This is the subtyping relation over the trust
362    /// lattice — a narrower grant is a subtype of a wider one.
363    pub fn leq(&self, other: &Grant) -> bool {
364        Dimension::ALL
365            .iter()
366            .all(|&d| self.level(d).leq(other.level(d)))
367    }
368
369    /// Componentwise join (least upper bound).
370    pub fn join(&self, other: &Grant) -> Grant {
371        Grant::new(
372            self.filesystem.join(other.filesystem),
373            self.network.join(other.network),
374            self.exec.join(other.exec),
375        )
376    }
377
378    /// Componentwise meet (greatest lower bound).
379    pub fn meet(&self, other: &Grant) -> Grant {
380        Grant::new(
381            self.filesystem.meet(other.filesystem),
382            self.network.meet(other.network),
383            self.exec.meet(other.exec),
384        )
385    }
386
387    /// Narrowing-as-subtyping (design doc §7.1, "the narrowing
388    /// invariant becomes a type property"). A child manifest is only
389    /// well-formed if it narrows its parent on every dimension; any
390    /// widening is rejected here — the inheritance equivalent of a
391    /// type error. On success returns the (validated) child grant.
392    pub fn narrow(parent: &Grant, child: &Grant) -> Result<Grant, TrustError> {
393        for &d in &Dimension::ALL {
394            let p = parent.level(d);
395            let c = child.level(d);
396            if !c.leq(p) {
397                return Err(TrustError::Widens {
398                    dimension: d,
399                    parent: p,
400                    requested: c,
401                });
402            }
403        }
404        Ok(*child)
405    }
406
407    /// Does this grant permit a single effect? Effects are mapped to a
408    /// dimension and the minimum level they require via
409    /// [`effect_requirement`]; effects outside the trust vocabulary
410    /// (pure compute, logging, time, rng) are always permitted.
411    pub fn permits_effect(&self, effect: &EffectKind) -> bool {
412        match effect_requirement(&effect.name) {
413            Some((dim, required)) => required.leq(self.level(dim)),
414            None => true,
415        }
416    }
417
418    /// Check every concrete effect in a set against the grant. This is
419    /// the bridge that makes "code calling a `net` effect won't
420    /// type-check under a `network: none` grant" true (design doc §7).
421    /// Returns the first offending effect as a [`TrustError`].
422    pub fn permits_effects(&self, effects: &EffectSet) -> Result<(), TrustError> {
423        for e in &effects.concrete {
424            if let Some((dim, required)) = effect_requirement(&e.name) {
425                let granted = self.level(dim);
426                if !required.leq(granted) {
427                    return Err(TrustError::EffectNotPermitted {
428                        effect: e.pretty(),
429                        dimension: dim,
430                        required,
431                        granted,
432                    });
433                }
434            }
435        }
436        Ok(())
437    }
438
439    /// Like [`Self::permits_effects`] but resolves network egress
440    /// against an explicit host **allowlist** (the lex-os manifest's
441    /// egress rules — design doc demo grant `network: none EXCEPT
442    /// results.demo.internal`). The allowlist is authoritative for
443    /// network: a host-scoped `net("h")` effect is permitted iff the
444    /// grant's network is `Full`, **or** `h` matches an allowlist entry —
445    /// regardless of the coarse network level, so an allowlist can carve
446    /// exceptions into an otherwise-`none` network. An unscoped `[net]`
447    /// is permitted only under `Full` (it cannot be proven to stay
448    /// within the allowlist). Non-network effects use the same level
449    /// check as [`Self::permits_effects`].
450    pub fn permits_effects_with_allowlist(
451        &self,
452        effects: &EffectSet,
453        allowlist: &[String],
454    ) -> Result<(), TrustError> {
455        for e in &effects.concrete {
456            self.permit_one_with_allowlist(e, allowlist)?;
457        }
458        Ok(())
459    }
460
461    fn permit_one_with_allowlist(
462        &self,
463        e: &EffectKind,
464        allowlist: &[String],
465    ) -> Result<(), TrustError> {
466        if is_net_effect(&e.name) {
467            // Full network permits any host; otherwise the allowlist is
468            // the network policy.
469            if self.network == Level::Full {
470                return Ok(());
471            }
472            match net_effect_host(e) {
473                Some(host) if host_in_allowlist(host, allowlist) => Ok(()),
474                Some(host) => Err(TrustError::NetHostNotAllowed {
475                    host: host.to_string(),
476                    allowed: allowlist.len(),
477                }),
478                None => Err(TrustError::NetUnscoped),
479            }
480        } else if let Some((dim, required)) = effect_requirement(&e.name) {
481            let granted = self.level(dim);
482            if required.leq(granted) {
483                Ok(())
484            } else {
485                Err(TrustError::EffectNotPermitted {
486                    effect: e.pretty(),
487                    dimension: dim,
488                    required,
489                    granted,
490                })
491            }
492        } else {
493            Ok(())
494        }
495    }
496
497
498    /// Canonical one-line rendering, e.g.
499    /// `fs=read-only net=none exec=none`.
500    pub fn pretty(&self) -> String {
501        format!(
502            "fs={} net={} exec={}",
503            self.filesystem, self.network, self.exec
504        )
505    }
506
507    /// Content-addressed identity of the grant. The bytes hashed are a
508    /// stable canonical form (dimension order is fixed, ranks not enum
509    /// names), so a `GrantId` is reproducible across processes and
510    /// languages — the manifest stays hashable exactly as AgentSpec
511    /// required (design doc §7.4). Two grants with the same authority
512    /// hash identically even if spelled with different aliases
513    /// (`Sandboxed` vs `ReadOnly`).
514    pub fn content_id(&self) -> GrantId {
515        let mut hasher = Sha256::new();
516        hasher.update(b"lex.trust.grant.v1");
517        for &d in &Dimension::ALL {
518            hasher.update([d as u8, self.level(d).rank()]);
519        }
520        let digest = hasher.finalize();
521        GrantId(hex::encode(digest))
522    }
523}
524
525impl fmt::Display for Grant {
526    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
527        f.write_str(&self.pretty())
528    }
529}
530
531/// Content address of a [`Grant`] — a hex-encoded SHA-256 of its
532/// canonical form.
533#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
534pub struct GrantId(pub String);
535
536impl GrantId {
537    /// Short form for logs/diagnostics (first 12 hex chars).
538    pub fn short(&self) -> &str {
539        &self.0[..self.0.len().min(12)]
540    }
541}
542
543impl fmt::Display for GrantId {
544    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
545        write!(f, "grant:{}", self.short())
546    }
547}
548
549/// Map a Lex effect name to the trust dimension it touches and the
550/// minimum [`Level`] required to use it. Effects not listed are pure or
551/// otherwise outside the trust model and need no grant.
552///
553/// Keep this aligned with the builtin effect names in
554/// `crates/lex-types/src/builtins.rs`.
555pub fn effect_requirement(effect_name: &str) -> Option<(Dimension, Level)> {
556    use Dimension::*;
557    use Level::*;
558    match effect_name {
559        // Filesystem reach.
560        "fs_read" | "fs_walk" => Some((Filesystem, ReadOnly)),
561        "fs_write" => Some((Filesystem, ReadWrite)),
562        // Network egress. Any of these needs at least allowlisted net;
563        // a `network: none` or `loopback` grant rejects them.
564        "net" | "http" | "mcp" | "llm_cloud" => Some((Network, Allowlist)),
565        // Arbitrary process execution.
566        "proc" => Some((Exec, Sandboxed)),
567        // Local LLM inference reads model weights from disk.
568        "llm_local" => Some((Filesystem, ReadOnly)),
569        // Effects with no consequential reach outside the process:
570        //   io, time, rand, panic, budget — pure I/O primitives
571        //   log, kv, stream — in-process / structured output
572        //   env, sql, random — bounded local resources
573        //   chat, a2a, concurrent — inter-agent messaging, no OS boundary
574        //   crypto — hashing/signing, no external access
575        //   approval — the operator decision *is* the safety gate; no
576        //     additional ProducerTrust dimension needed on top of it
577        // All are safe under any grant; adding mappings would be over-broad.
578        _ => Option::None,
579    }
580}
581
582/// Is this a network-egress effect (one whose blast radius is reaching
583/// a host on the network)? Kept aligned with the `Network`-dimension
584/// entries in [`effect_requirement`].
585pub fn is_net_effect(name: &str) -> bool {
586    matches!(name, "net" | "http" | "mcp" | "llm_cloud")
587}
588
589/// The host a net effect targets, if it is host-scoped (`net("host")`).
590/// A bare `[net]` returns `None`.
591fn net_effect_host(e: &EffectKind) -> Option<&str> {
592    match &e.arg {
593        Some(crate::types::EffectArg::Str(h)) => Some(h.as_str()),
594        _ => Option::None,
595    }
596}
597
598/// Match a target `host` against one allowlist `entry`. Entries may
599/// carry a `:port` suffix (ignored for host matching) and a leading
600/// `*.` wildcard matching any subdomain — `*.example.com` matches
601/// `api.example.com` and `example.com`. Host comparison is
602/// case-insensitive.
603pub fn host_matches(entry: &str, host: &str) -> bool {
604    let entry_host = entry.split(':').next().unwrap_or(entry);
605    match entry_host.strip_prefix("*.") {
606        Some(suffix) => {
607            host.eq_ignore_ascii_case(suffix)
608                || (host.len() > suffix.len() + 1
609                    && host[host.len() - suffix.len()..].eq_ignore_ascii_case(suffix)
610                    && host.as_bytes()[host.len() - suffix.len() - 1] == b'.')
611        }
612        None => entry_host.eq_ignore_ascii_case(host),
613    }
614}
615
616fn host_in_allowlist(host: &str, allowlist: &[String]) -> bool {
617    allowlist.iter().any(|e| host_matches(e, host))
618}
619
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624
625    #[test]
626    fn level_total_order() {
627        assert!(Level::None.leq(Level::ReadOnly));
628        assert!(Level::ReadOnly.leq(Level::ReadWrite));
629        assert!(Level::ReadWrite.leq(Level::Full));
630        assert!(!Level::Full.leq(Level::ReadOnly));
631        // Aliases at the same rank compare equal-ish.
632        assert!(Level::Sandboxed.leq(Level::ReadOnly));
633        assert!(Level::ReadOnly.leq(Level::Sandboxed));
634        assert!(Level::Loopback.leq(Level::ReadOnly));
635    }
636
637    #[test]
638    fn level_join_meet() {
639        assert_eq!(Level::None.join(Level::Full).rank(), Level::Full.rank());
640        assert_eq!(Level::None.meet(Level::Full).rank(), Level::None.rank());
641        assert_eq!(
642            Level::ReadOnly.join(Level::ReadWrite).rank(),
643            Level::ReadWrite.rank()
644        );
645        assert_eq!(
646            Level::ReadOnly.meet(Level::ReadWrite).rank(),
647            Level::ReadOnly.rank()
648        );
649    }
650
651    #[test]
652    fn host_matching_exact_port_and_wildcard() {
653        assert!(host_matches("results.demo.internal", "results.demo.internal"));
654        assert!(host_matches("results.demo.internal:443", "results.demo.internal"));
655        assert!(!host_matches("results.demo.internal", "evil.com"));
656        assert!(host_matches("Results.Demo.Internal", "results.demo.internal"));
657        assert!(host_matches("*.example.com", "api.example.com"));
658        assert!(host_matches("*.example.com", "example.com"));
659        assert!(!host_matches("*.example.com", "example.com.evil.com"));
660        assert!(!host_matches("*.example.com", "notexample.com"));
661    }
662
663    #[test]
664    fn allowlist_permits_only_listed_host_under_none_network() {
665        // The demo grant: network none EXCEPT one host.
666        let grant = Grant::new(Level::ReadWrite, Level::None, Level::Full);
667        let allow = vec!["results.demo.internal:443".to_string()];
668
669        let mut ok = EffectSet::empty();
670        ok.concrete.insert(EffectKind::with_str("net", "results.demo.internal"));
671        assert!(grant.permits_effects_with_allowlist(&ok, &allow).is_ok());
672
673        let mut bad = EffectSet::empty();
674        bad.concrete.insert(EffectKind::with_str("net", "evil.com"));
675        match grant.permits_effects_with_allowlist(&bad, &allow).unwrap_err() {
676            TrustError::NetHostNotAllowed { host, allowed } => {
677                assert_eq!(host, "evil.com");
678                assert_eq!(allowed, 1);
679            }
680            other => panic!("unexpected: {other:?}"),
681        }
682    }
683
684    #[test]
685    fn unscoped_net_rejected_unless_full() {
686        let allow = vec!["results.demo.internal".to_string()];
687        let mut bare = EffectSet::empty();
688        bare.concrete.insert(EffectKind::bare("net"));
689
690        let g = Grant::new(Level::None, Level::Allowlist, Level::None);
691        assert!(matches!(
692            g.permits_effects_with_allowlist(&bare, &allow).unwrap_err(),
693            TrustError::NetUnscoped
694        ));
695        let full = Grant::new(Level::None, Level::Full, Level::None);
696        assert!(full.permits_effects_with_allowlist(&bare, &allow).is_ok());
697    }
698
699    #[test]
700    fn full_network_permits_any_host() {
701        let g = Grant::new(Level::None, Level::Full, Level::None);
702        let mut e = EffectSet::empty();
703        e.concrete.insert(EffectKind::with_str("net", "anything.example"));
704        assert!(g.permits_effects_with_allowlist(&e, &[]).is_ok());
705    }
706
707    #[test]
708    fn allowlist_check_still_gates_non_net_effects() {
709        let g = Grant::new(Level::ReadOnly, Level::Full, Level::None);
710        let mut e = EffectSet::empty();
711        e.concrete.insert(EffectKind::bare("fs_write"));
712        assert!(matches!(
713            g.permits_effects_with_allowlist(&e, &[]).unwrap_err(),
714            TrustError::EffectNotPermitted {
715                dimension: Dimension::Filesystem,
716                ..
717            }
718        ));
719    }
720
721    #[test]
722    fn grant_lattice_extremes() {
723        let b = Grant::bottom();
724        let t = Grant::top();
725        assert!(b.leq(&t));
726        assert!(!t.leq(&b));
727        // bottom is the identity for join, top for meet.
728        let g = Grant::new(Level::ReadOnly, Level::Loopback, Level::None);
729        assert_eq!(b.join(&g), g);
730        assert_eq!(t.meet(&g), g);
731    }
732
733    #[test]
734    fn narrowing_allowed() {
735        let parent = Grant::new(Level::ReadWrite, Level::Full, Level::Sandboxed);
736        let child = Grant::new(Level::ReadOnly, Level::None, Level::None);
737        assert_eq!(Grant::narrow(&parent, &child), Ok(child));
738    }
739
740    #[test]
741    fn widening_is_rejected() {
742        let parent = Grant::new(Level::ReadOnly, Level::None, Level::None);
743        // Child tries to widen network none -> full.
744        let child = Grant::new(Level::ReadOnly, Level::Full, Level::None);
745        let err = Grant::narrow(&parent, &child).unwrap_err();
746        assert_eq!(
747            err,
748            TrustError::Widens {
749                dimension: Dimension::Network,
750                parent: Level::None,
751                requested: Level::Full,
752            }
753        );
754    }
755
756    #[test]
757    fn narrowing_is_transitive_via_leq() {
758        let a = Grant::top();
759        let b = Grant::new(Level::ReadWrite, Level::Loopback, Level::None);
760        let c = Grant::new(Level::ReadOnly, Level::None, Level::None);
761        assert!(Grant::narrow(&a, &b).is_ok());
762        assert!(Grant::narrow(&b, &c).is_ok());
763        // …and the chain composes: c narrows a directly.
764        assert!(Grant::narrow(&a, &c).is_ok());
765    }
766
767    #[test]
768    fn effect_permitted_under_matching_grant() {
769        let read_only = Grant::new(Level::ReadOnly, Level::None, Level::None);
770        assert!(read_only.permits_effect(&EffectKind::bare("fs_read")));
771        // fs_write needs ReadWrite, denied under ReadOnly.
772        assert!(!read_only.permits_effect(&EffectKind::bare("fs_write")));
773        // net denied under network: none.
774        assert!(!read_only.permits_effect(&EffectKind::bare("net")));
775        // pure effects always allowed.
776        assert!(read_only.permits_effect(&EffectKind::bare("log")));
777        assert!(read_only.permits_effect(&EffectKind::bare("time")));
778    }
779
780    #[test]
781    fn effect_set_checked_against_grant() {
782        // The headline guarantee: a function that uses `net` does not
783        // satisfy a `network: none` grant.
784        let analyze_grant = Grant::new(Level::ReadOnly, Level::None, Level::None);
785        let mut effects = EffectSet::empty();
786        effects.concrete.insert(EffectKind::bare("fs_read"));
787        effects.concrete.insert(EffectKind::with_str("net", "evil.example"));
788        let err = analyze_grant.permits_effects(&effects).unwrap_err();
789        match err {
790            TrustError::EffectNotPermitted { dimension, required, granted, .. } => {
791                assert_eq!(dimension, Dimension::Network);
792                assert_eq!(required, Level::Allowlist);
793                assert_eq!(granted, Level::None);
794            }
795            other => panic!("unexpected error: {other:?}"),
796        }
797    }
798
799    #[test]
800    fn effect_set_fully_within_grant_ok() {
801        let grant = Grant::new(Level::ReadWrite, Level::Full, Level::Sandboxed);
802        let mut effects = EffectSet::empty();
803        effects.concrete.insert(EffectKind::bare("fs_read"));
804        effects.concrete.insert(EffectKind::bare("fs_write"));
805        effects.concrete.insert(EffectKind::bare("net"));
806        effects.concrete.insert(EffectKind::bare("proc"));
807        assert!(grant.permits_effects(&effects).is_ok());
808    }
809
810    #[test]
811    fn empty_effect_set_always_permitted() {
812        // A grant of bottom (deny-all) still permits the empty effect set —
813        // a function that does nothing satisfies any grant.
814        let bottom = Grant::bottom();
815        assert!(bottom.permits_effects(&EffectSet::empty()).is_ok());
816    }
817
818    #[test]
819    fn llm_local_requires_filesystem_read() {
820        // llm_local reads model weights from disk; it must be rejected
821        // under a filesystem: none grant.
822        let no_fs = Grant::new(Level::None, Level::Full, Level::None);
823        let mut effects = EffectSet::empty();
824        effects.concrete.insert(EffectKind::bare("llm_local"));
825        assert!(
826            no_fs.permits_effects(&effects).is_err(),
827            "llm_local should be denied under filesystem: none"
828        );
829        // But allowed under a read-only filesystem grant.
830        let read_only_fs = Grant::new(Level::ReadOnly, Level::Full, Level::None);
831        assert!(read_only_fs.permits_effects(&effects).is_ok());
832    }
833
834    #[test]
835    fn content_id_is_stable_and_addresses_authority_not_spelling() {
836        // This test used to demonstrate alias-insensitivity with
837        // `exec: ReadOnly` vs `exec: Sandboxed` — two spellings of rank
838        // 1 that addressed identically. `exec: ReadOnly` is no longer a
839        // grant anyone can write (#808), so the demonstration is gone
840        // along with the confusion it was accommodating: within each
841        // dimension every accepted level now has a distinct rank, which
842        // `every_valid_level_has_a_distinct_rank_on_its_dimension`
843        // pins. Since `content_id` hashes ranks, it is now injective
844        // over valid grants rather than merely stable.
845        let g = Grant::new(Level::None, Level::None, Level::Sandboxed);
846
847        // Different authority -> different id.
848        assert_ne!(Grant::bottom().content_id(), Grant::top().content_id());
849        // Stable across calls.
850        assert_eq!(g.content_id(), g.content_id());
851        assert_eq!(g.content_id().0.len(), 64);
852    }
853
854    /// The property that replaced aliasing, and the reason the old test
855    /// changed: a shared vocabulary is fine as long as each dimension
856    /// takes at most one spelling per rank. Two would mean a grant had
857    /// two names, and a name is what a record refers to.
858    #[test]
859    fn every_valid_level_has_a_distinct_rank_on_its_dimension() {
860        for d in Dimension::ALL {
861            let mut ranks: Vec<u8> = d.levels().iter().map(|l| l.rank()).collect();
862            let before = ranks.len();
863            ranks.sort_unstable();
864            ranks.dedup();
865            assert_eq!(
866                ranks.len(),
867                before,
868                "{d} accepts two levels of the same rank, so one grant has two spellings"
869            );
870        }
871    }
872}