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 fmt::Display for Dimension {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        f.write_str(self.as_str())
55    }
56}
57
58/// Trust level along a single dimension. Levels are **totally ordered**
59/// from `None` (no authority) upward; the numeric discriminant *is* the
60/// order, so `<=`/`max`/`min` on the rank give the lattice operations.
61///
62/// The levels are shared across dimensions (a deliberately small
63/// vocabulary) but not every level is meaningful on every dimension —
64/// the canonical readings are:
65///
66/// | rank | Filesystem | Network   | Exec       |
67/// |------|------------|-----------|------------|
68/// | 0    | none       | none      | none       |
69/// | 1    | read-only  | loopback  | sandboxed  |
70/// | 2    | read-write | allowlist | (= full)   |
71/// | 3    | full       | full      | full       |
72///
73/// `Sandboxed` aliases rank 1 for exec; `Allowlist` aliases rank 2 for
74/// network. They are distinct enum variants for legibility but compare
75/// purely by [`Level::rank`].
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
77pub enum Level {
78    /// rank 0 — the effect is *physically absent* from the box.
79    None,
80    /// rank 1 — read-only / loopback-only / sandboxed-exec.
81    ReadOnly,
82    /// rank 1 — exec spelled for legibility (same rank as `ReadOnly`).
83    Sandboxed,
84    /// rank 1 — network loopback only.
85    Loopback,
86    /// rank 2 — read-write filesystem.
87    ReadWrite,
88    /// rank 2 — network restricted to an allowlist.
89    Allowlist,
90    /// rank 3 — unrestricted authority on the dimension.
91    Full,
92}
93
94impl Level {
95    /// The position of this level in the total order. Lattice
96    /// operations are defined on the rank.
97    pub fn rank(self) -> u8 {
98        match self {
99            Level::None => 0,
100            Level::ReadOnly | Level::Sandboxed | Level::Loopback => 1,
101            Level::ReadWrite | Level::Allowlist => 2,
102            Level::Full => 3,
103        }
104    }
105
106    /// `self` ≤ `other` in the trust order (self grants no more than
107    /// other). This is the per-dimension subtyping relation.
108    pub fn leq(self, other: Level) -> bool {
109        self.rank() <= other.rank()
110    }
111
112    /// Least upper bound (join): the tighter of two levels that still
113    /// covers both. Returns the higher-ranked level.
114    pub fn join(self, other: Level) -> Level {
115        if self.rank() >= other.rank() {
116            self
117        } else {
118            other
119        }
120    }
121
122    /// Greatest lower bound (meet): the most authority both allow.
123    /// Returns the lower-ranked level.
124    pub fn meet(self, other: Level) -> Level {
125        if self.rank() <= other.rank() {
126            self
127        } else {
128            other
129        }
130    }
131
132    pub fn as_str(self) -> &'static str {
133        match self {
134            Level::None => "none",
135            Level::ReadOnly => "read-only",
136            Level::Sandboxed => "sandboxed",
137            Level::Loopback => "loopback",
138            Level::ReadWrite => "read-write",
139            Level::Allowlist => "allowlist",
140            Level::Full => "full",
141        }
142    }
143}
144
145impl fmt::Display for Level {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        f.write_str(self.as_str())
148    }
149}
150
151/// A capability grant: one [`Level`] per [`Dimension`]. This is the
152/// trust manifest's core payload. As a product of totally-ordered
153/// dimensions it forms a **lattice** under componentwise ordering, with
154/// [`Grant::bottom`] (deny everything) and [`Grant::top`] (the most
155/// dangerous config — `sudo` + open internet, design doc §3) as the
156/// extremes.
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
158pub struct Grant {
159    pub filesystem: Level,
160    pub network: Level,
161    pub exec: Level,
162}
163
164/// Why a requested grant was refused. The runtime contract is
165/// *refuse, don't downgrade* (design doc §7.5): when a child manifest
166/// asks for more than its parent allows we return this error rather
167/// than silently clamping.
168#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
169pub enum TrustError {
170    #[error(
171        "trust widening on {dimension}: child requests `{requested}` but parent only grants `{parent}` (a child manifest may only narrow)"
172    )]
173    Widens {
174        dimension: Dimension,
175        parent: Level,
176        requested: Level,
177    },
178    #[error(
179        "effect `{effect}` needs {dimension} ≥ `{required}` but the grant only provides `{granted}`"
180    )]
181    EffectNotPermitted {
182        effect: String,
183        dimension: Dimension,
184        required: Level,
185        granted: Level,
186    },
187    #[error(
188        "net effect to `{host}` is not in the grant's egress allowlist ({allowed} host(s) allowed)"
189    )]
190    NetHostNotAllowed { host: String, allowed: usize },
191    #[error(
192        "unscoped `[net]` cannot be proven within the egress allowlist — scope it to a host, e.g. `net(\"results.demo.internal\")`"
193    )]
194    NetUnscoped,
195}
196
197impl Grant {
198    pub fn new(filesystem: Level, network: Level, exec: Level) -> Self {
199        Self { filesystem, network, exec }
200    }
201
202    /// Deny everything — the lattice bottom. The default starting point
203    /// for the narrowest-possible grant (design doc §5.1): every
204    /// ungranted effect is physically absent.
205    pub fn bottom() -> Self {
206        Self::new(Level::None, Level::None, Level::None)
207    }
208
209    /// Grant everything — the lattice top. `sudo` + open internet; the
210    /// single most dangerous config. Never the default.
211    pub fn top() -> Self {
212        Self::new(Level::Full, Level::Full, Level::Full)
213    }
214
215    pub fn level(&self, dim: Dimension) -> Level {
216        match dim {
217            Dimension::Filesystem => self.filesystem,
218            Dimension::Network => self.network,
219            Dimension::Exec => self.exec,
220        }
221    }
222
223    /// `self` ≤ `other`: self grants no more authority than other on
224    /// *any* dimension. This is the subtyping relation over the trust
225    /// lattice — a narrower grant is a subtype of a wider one.
226    pub fn leq(&self, other: &Grant) -> bool {
227        Dimension::ALL
228            .iter()
229            .all(|&d| self.level(d).leq(other.level(d)))
230    }
231
232    /// Componentwise join (least upper bound).
233    pub fn join(&self, other: &Grant) -> Grant {
234        Grant::new(
235            self.filesystem.join(other.filesystem),
236            self.network.join(other.network),
237            self.exec.join(other.exec),
238        )
239    }
240
241    /// Componentwise meet (greatest lower bound).
242    pub fn meet(&self, other: &Grant) -> Grant {
243        Grant::new(
244            self.filesystem.meet(other.filesystem),
245            self.network.meet(other.network),
246            self.exec.meet(other.exec),
247        )
248    }
249
250    /// Narrowing-as-subtyping (design doc §7.1, "the narrowing
251    /// invariant becomes a type property"). A child manifest is only
252    /// well-formed if it narrows its parent on every dimension; any
253    /// widening is rejected here — the inheritance equivalent of a
254    /// type error. On success returns the (validated) child grant.
255    pub fn narrow(parent: &Grant, child: &Grant) -> Result<Grant, TrustError> {
256        for &d in &Dimension::ALL {
257            let p = parent.level(d);
258            let c = child.level(d);
259            if !c.leq(p) {
260                return Err(TrustError::Widens {
261                    dimension: d,
262                    parent: p,
263                    requested: c,
264                });
265            }
266        }
267        Ok(*child)
268    }
269
270    /// Does this grant permit a single effect? Effects are mapped to a
271    /// dimension and the minimum level they require via
272    /// [`effect_requirement`]; effects outside the trust vocabulary
273    /// (pure compute, logging, time, rng) are always permitted.
274    pub fn permits_effect(&self, effect: &EffectKind) -> bool {
275        match effect_requirement(&effect.name) {
276            Some((dim, required)) => required.leq(self.level(dim)),
277            None => true,
278        }
279    }
280
281    /// Check every concrete effect in a set against the grant. This is
282    /// the bridge that makes "code calling a `net` effect won't
283    /// type-check under a `network: none` grant" true (design doc §7).
284    /// Returns the first offending effect as a [`TrustError`].
285    pub fn permits_effects(&self, effects: &EffectSet) -> Result<(), TrustError> {
286        for e in &effects.concrete {
287            if let Some((dim, required)) = effect_requirement(&e.name) {
288                let granted = self.level(dim);
289                if !required.leq(granted) {
290                    return Err(TrustError::EffectNotPermitted {
291                        effect: e.pretty(),
292                        dimension: dim,
293                        required,
294                        granted,
295                    });
296                }
297            }
298        }
299        Ok(())
300    }
301
302    /// Like [`Self::permits_effects`] but resolves network egress
303    /// against an explicit host **allowlist** (the lex-os manifest's
304    /// egress rules — design doc demo grant `network: none EXCEPT
305    /// results.demo.internal`). The allowlist is authoritative for
306    /// network: a host-scoped `net("h")` effect is permitted iff the
307    /// grant's network is `Full`, **or** `h` matches an allowlist entry —
308    /// regardless of the coarse network level, so an allowlist can carve
309    /// exceptions into an otherwise-`none` network. An unscoped `[net]`
310    /// is permitted only under `Full` (it cannot be proven to stay
311    /// within the allowlist). Non-network effects use the same level
312    /// check as [`Self::permits_effects`].
313    pub fn permits_effects_with_allowlist(
314        &self,
315        effects: &EffectSet,
316        allowlist: &[String],
317    ) -> Result<(), TrustError> {
318        for e in &effects.concrete {
319            self.permit_one_with_allowlist(e, allowlist)?;
320        }
321        Ok(())
322    }
323
324    fn permit_one_with_allowlist(
325        &self,
326        e: &EffectKind,
327        allowlist: &[String],
328    ) -> Result<(), TrustError> {
329        if is_net_effect(&e.name) {
330            // Full network permits any host; otherwise the allowlist is
331            // the network policy.
332            if self.network == Level::Full {
333                return Ok(());
334            }
335            match net_effect_host(e) {
336                Some(host) if host_in_allowlist(host, allowlist) => Ok(()),
337                Some(host) => Err(TrustError::NetHostNotAllowed {
338                    host: host.to_string(),
339                    allowed: allowlist.len(),
340                }),
341                None => Err(TrustError::NetUnscoped),
342            }
343        } else if let Some((dim, required)) = effect_requirement(&e.name) {
344            let granted = self.level(dim);
345            if required.leq(granted) {
346                Ok(())
347            } else {
348                Err(TrustError::EffectNotPermitted {
349                    effect: e.pretty(),
350                    dimension: dim,
351                    required,
352                    granted,
353                })
354            }
355        } else {
356            Ok(())
357        }
358    }
359
360
361    /// Canonical one-line rendering, e.g.
362    /// `fs=read-only net=none exec=none`.
363    pub fn pretty(&self) -> String {
364        format!(
365            "fs={} net={} exec={}",
366            self.filesystem, self.network, self.exec
367        )
368    }
369
370    /// Content-addressed identity of the grant. The bytes hashed are a
371    /// stable canonical form (dimension order is fixed, ranks not enum
372    /// names), so a `GrantId` is reproducible across processes and
373    /// languages — the manifest stays hashable exactly as AgentSpec
374    /// required (design doc §7.4). Two grants with the same authority
375    /// hash identically even if spelled with different aliases
376    /// (`Sandboxed` vs `ReadOnly`).
377    pub fn content_id(&self) -> GrantId {
378        let mut hasher = Sha256::new();
379        hasher.update(b"lex.trust.grant.v1");
380        for &d in &Dimension::ALL {
381            hasher.update([d as u8, self.level(d).rank()]);
382        }
383        let digest = hasher.finalize();
384        GrantId(hex::encode(digest))
385    }
386}
387
388impl fmt::Display for Grant {
389    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
390        f.write_str(&self.pretty())
391    }
392}
393
394/// Content address of a [`Grant`] — a hex-encoded SHA-256 of its
395/// canonical form.
396#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
397pub struct GrantId(pub String);
398
399impl GrantId {
400    /// Short form for logs/diagnostics (first 12 hex chars).
401    pub fn short(&self) -> &str {
402        &self.0[..self.0.len().min(12)]
403    }
404}
405
406impl fmt::Display for GrantId {
407    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
408        write!(f, "grant:{}", self.short())
409    }
410}
411
412/// Map a Lex effect name to the trust dimension it touches and the
413/// minimum [`Level`] required to use it. Effects not listed are pure or
414/// otherwise outside the trust model and need no grant.
415///
416/// Keep this aligned with the builtin effect names in
417/// `crates/lex-types/src/builtins.rs`.
418pub fn effect_requirement(effect_name: &str) -> Option<(Dimension, Level)> {
419    use Dimension::*;
420    use Level::*;
421    match effect_name {
422        // Filesystem reach.
423        "fs_read" | "fs_walk" => Some((Filesystem, ReadOnly)),
424        "fs_write" => Some((Filesystem, ReadWrite)),
425        // Network egress. Any of these needs at least allowlisted net;
426        // a `network: none` or `loopback` grant rejects them.
427        "net" | "http" | "mcp" | "llm_cloud" => Some((Network, Allowlist)),
428        // Arbitrary process execution.
429        "proc" => Some((Exec, Sandboxed)),
430        // Local LLM inference reads model weights from disk.
431        "llm_local" => Some((Filesystem, ReadOnly)),
432        // Effects with no consequential reach outside the process:
433        //   io, time, rand, panic, budget — pure I/O primitives
434        //   log, kv, stream — in-process / structured output
435        //   env, sql, random — bounded local resources
436        //   chat, a2a, concurrent — inter-agent messaging, no OS boundary
437        //   crypto — hashing/signing, no external access
438        // All are safe under any grant; adding mappings would be over-broad.
439        _ => Option::None,
440    }
441}
442
443/// Is this a network-egress effect (one whose blast radius is reaching
444/// a host on the network)? Kept aligned with the `Network`-dimension
445/// entries in [`effect_requirement`].
446pub fn is_net_effect(name: &str) -> bool {
447    matches!(name, "net" | "http" | "mcp" | "llm_cloud")
448}
449
450/// The host a net effect targets, if it is host-scoped (`net("host")`).
451/// A bare `[net]` returns `None`.
452fn net_effect_host(e: &EffectKind) -> Option<&str> {
453    match &e.arg {
454        Some(crate::types::EffectArg::Str(h)) => Some(h.as_str()),
455        _ => Option::None,
456    }
457}
458
459/// Match a target `host` against one allowlist `entry`. Entries may
460/// carry a `:port` suffix (ignored for host matching) and a leading
461/// `*.` wildcard matching any subdomain — `*.example.com` matches
462/// `api.example.com` and `example.com`. Host comparison is
463/// case-insensitive.
464pub fn host_matches(entry: &str, host: &str) -> bool {
465    let entry_host = entry.split(':').next().unwrap_or(entry);
466    match entry_host.strip_prefix("*.") {
467        Some(suffix) => {
468            host.eq_ignore_ascii_case(suffix)
469                || (host.len() > suffix.len() + 1
470                    && host[host.len() - suffix.len()..].eq_ignore_ascii_case(suffix)
471                    && host.as_bytes()[host.len() - suffix.len() - 1] == b'.')
472        }
473        None => entry_host.eq_ignore_ascii_case(host),
474    }
475}
476
477fn host_in_allowlist(host: &str, allowlist: &[String]) -> bool {
478    allowlist.iter().any(|e| host_matches(e, host))
479}
480
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    #[test]
487    fn level_total_order() {
488        assert!(Level::None.leq(Level::ReadOnly));
489        assert!(Level::ReadOnly.leq(Level::ReadWrite));
490        assert!(Level::ReadWrite.leq(Level::Full));
491        assert!(!Level::Full.leq(Level::ReadOnly));
492        // Aliases at the same rank compare equal-ish.
493        assert!(Level::Sandboxed.leq(Level::ReadOnly));
494        assert!(Level::ReadOnly.leq(Level::Sandboxed));
495        assert!(Level::Loopback.leq(Level::ReadOnly));
496    }
497
498    #[test]
499    fn level_join_meet() {
500        assert_eq!(Level::None.join(Level::Full).rank(), Level::Full.rank());
501        assert_eq!(Level::None.meet(Level::Full).rank(), Level::None.rank());
502        assert_eq!(
503            Level::ReadOnly.join(Level::ReadWrite).rank(),
504            Level::ReadWrite.rank()
505        );
506        assert_eq!(
507            Level::ReadOnly.meet(Level::ReadWrite).rank(),
508            Level::ReadOnly.rank()
509        );
510    }
511
512    #[test]
513    fn host_matching_exact_port_and_wildcard() {
514        assert!(host_matches("results.demo.internal", "results.demo.internal"));
515        assert!(host_matches("results.demo.internal:443", "results.demo.internal"));
516        assert!(!host_matches("results.demo.internal", "evil.com"));
517        assert!(host_matches("Results.Demo.Internal", "results.demo.internal"));
518        assert!(host_matches("*.example.com", "api.example.com"));
519        assert!(host_matches("*.example.com", "example.com"));
520        assert!(!host_matches("*.example.com", "example.com.evil.com"));
521        assert!(!host_matches("*.example.com", "notexample.com"));
522    }
523
524    #[test]
525    fn allowlist_permits_only_listed_host_under_none_network() {
526        // The demo grant: network none EXCEPT one host.
527        let grant = Grant::new(Level::ReadWrite, Level::None, Level::Full);
528        let allow = vec!["results.demo.internal:443".to_string()];
529
530        let mut ok = EffectSet::empty();
531        ok.concrete.insert(EffectKind::with_str("net", "results.demo.internal"));
532        assert!(grant.permits_effects_with_allowlist(&ok, &allow).is_ok());
533
534        let mut bad = EffectSet::empty();
535        bad.concrete.insert(EffectKind::with_str("net", "evil.com"));
536        match grant.permits_effects_with_allowlist(&bad, &allow).unwrap_err() {
537            TrustError::NetHostNotAllowed { host, allowed } => {
538                assert_eq!(host, "evil.com");
539                assert_eq!(allowed, 1);
540            }
541            other => panic!("unexpected: {other:?}"),
542        }
543    }
544
545    #[test]
546    fn unscoped_net_rejected_unless_full() {
547        let allow = vec!["results.demo.internal".to_string()];
548        let mut bare = EffectSet::empty();
549        bare.concrete.insert(EffectKind::bare("net"));
550
551        let g = Grant::new(Level::None, Level::Allowlist, Level::None);
552        assert!(matches!(
553            g.permits_effects_with_allowlist(&bare, &allow).unwrap_err(),
554            TrustError::NetUnscoped
555        ));
556        let full = Grant::new(Level::None, Level::Full, Level::None);
557        assert!(full.permits_effects_with_allowlist(&bare, &allow).is_ok());
558    }
559
560    #[test]
561    fn full_network_permits_any_host() {
562        let g = Grant::new(Level::None, Level::Full, Level::None);
563        let mut e = EffectSet::empty();
564        e.concrete.insert(EffectKind::with_str("net", "anything.example"));
565        assert!(g.permits_effects_with_allowlist(&e, &[]).is_ok());
566    }
567
568    #[test]
569    fn allowlist_check_still_gates_non_net_effects() {
570        let g = Grant::new(Level::ReadOnly, Level::Full, Level::None);
571        let mut e = EffectSet::empty();
572        e.concrete.insert(EffectKind::bare("fs_write"));
573        assert!(matches!(
574            g.permits_effects_with_allowlist(&e, &[]).unwrap_err(),
575            TrustError::EffectNotPermitted {
576                dimension: Dimension::Filesystem,
577                ..
578            }
579        ));
580    }
581
582    #[test]
583    fn grant_lattice_extremes() {
584        let b = Grant::bottom();
585        let t = Grant::top();
586        assert!(b.leq(&t));
587        assert!(!t.leq(&b));
588        // bottom is the identity for join, top for meet.
589        let g = Grant::new(Level::ReadOnly, Level::Loopback, Level::None);
590        assert_eq!(b.join(&g), g);
591        assert_eq!(t.meet(&g), g);
592    }
593
594    #[test]
595    fn narrowing_allowed() {
596        let parent = Grant::new(Level::ReadWrite, Level::Full, Level::Sandboxed);
597        let child = Grant::new(Level::ReadOnly, Level::None, Level::None);
598        assert_eq!(Grant::narrow(&parent, &child), Ok(child));
599    }
600
601    #[test]
602    fn widening_is_rejected() {
603        let parent = Grant::new(Level::ReadOnly, Level::None, Level::None);
604        // Child tries to widen network none -> full.
605        let child = Grant::new(Level::ReadOnly, Level::Full, Level::None);
606        let err = Grant::narrow(&parent, &child).unwrap_err();
607        assert_eq!(
608            err,
609            TrustError::Widens {
610                dimension: Dimension::Network,
611                parent: Level::None,
612                requested: Level::Full,
613            }
614        );
615    }
616
617    #[test]
618    fn narrowing_is_transitive_via_leq() {
619        let a = Grant::top();
620        let b = Grant::new(Level::ReadWrite, Level::Loopback, Level::None);
621        let c = Grant::new(Level::ReadOnly, Level::None, Level::None);
622        assert!(Grant::narrow(&a, &b).is_ok());
623        assert!(Grant::narrow(&b, &c).is_ok());
624        // …and the chain composes: c narrows a directly.
625        assert!(Grant::narrow(&a, &c).is_ok());
626    }
627
628    #[test]
629    fn effect_permitted_under_matching_grant() {
630        let read_only = Grant::new(Level::ReadOnly, Level::None, Level::None);
631        assert!(read_only.permits_effect(&EffectKind::bare("fs_read")));
632        // fs_write needs ReadWrite, denied under ReadOnly.
633        assert!(!read_only.permits_effect(&EffectKind::bare("fs_write")));
634        // net denied under network: none.
635        assert!(!read_only.permits_effect(&EffectKind::bare("net")));
636        // pure effects always allowed.
637        assert!(read_only.permits_effect(&EffectKind::bare("log")));
638        assert!(read_only.permits_effect(&EffectKind::bare("time")));
639    }
640
641    #[test]
642    fn effect_set_checked_against_grant() {
643        // The headline guarantee: a function that uses `net` does not
644        // satisfy a `network: none` grant.
645        let analyze_grant = Grant::new(Level::ReadOnly, Level::None, Level::None);
646        let mut effects = EffectSet::empty();
647        effects.concrete.insert(EffectKind::bare("fs_read"));
648        effects.concrete.insert(EffectKind::with_str("net", "evil.example"));
649        let err = analyze_grant.permits_effects(&effects).unwrap_err();
650        match err {
651            TrustError::EffectNotPermitted { dimension, required, granted, .. } => {
652                assert_eq!(dimension, Dimension::Network);
653                assert_eq!(required, Level::Allowlist);
654                assert_eq!(granted, Level::None);
655            }
656            other => panic!("unexpected error: {other:?}"),
657        }
658    }
659
660    #[test]
661    fn effect_set_fully_within_grant_ok() {
662        let grant = Grant::new(Level::ReadWrite, Level::Full, Level::Sandboxed);
663        let mut effects = EffectSet::empty();
664        effects.concrete.insert(EffectKind::bare("fs_read"));
665        effects.concrete.insert(EffectKind::bare("fs_write"));
666        effects.concrete.insert(EffectKind::bare("net"));
667        effects.concrete.insert(EffectKind::bare("proc"));
668        assert!(grant.permits_effects(&effects).is_ok());
669    }
670
671    #[test]
672    fn empty_effect_set_always_permitted() {
673        // A grant of bottom (deny-all) still permits the empty effect set —
674        // a function that does nothing satisfies any grant.
675        let bottom = Grant::bottom();
676        assert!(bottom.permits_effects(&EffectSet::empty()).is_ok());
677    }
678
679    #[test]
680    fn llm_local_requires_filesystem_read() {
681        // llm_local reads model weights from disk; it must be rejected
682        // under a filesystem: none grant.
683        let no_fs = Grant::new(Level::None, Level::Full, Level::None);
684        let mut effects = EffectSet::empty();
685        effects.concrete.insert(EffectKind::bare("llm_local"));
686        assert!(
687            no_fs.permits_effects(&effects).is_err(),
688            "llm_local should be denied under filesystem: none"
689        );
690        // But allowed under a read-only filesystem grant.
691        let read_only_fs = Grant::new(Level::ReadOnly, Level::Full, Level::None);
692        assert!(read_only_fs.permits_effects(&effects).is_ok());
693    }
694
695    #[test]
696    fn content_id_is_stable_and_alias_insensitive() {
697        // Sandboxed and ReadOnly share a rank, so an exec=Sandboxed
698        // grant and an exec=ReadOnly grant address identically.
699        let g1 = Grant::new(Level::None, Level::None, Level::Sandboxed);
700        let g2 = Grant::new(Level::None, Level::None, Level::ReadOnly);
701        assert_eq!(g1.content_id(), g2.content_id());
702        // Different authority -> different id.
703        assert_ne!(Grant::bottom().content_id(), Grant::top().content_id());
704        // Stable across calls.
705        assert_eq!(g1.content_id(), g1.content_id());
706        assert_eq!(g1.content_id().0.len(), 64);
707    }
708}