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        //   approval — the operator decision *is* the safety gate; no
439        //     additional ProducerTrust dimension needed on top of it
440        // All are safe under any grant; adding mappings would be over-broad.
441        _ => Option::None,
442    }
443}
444
445/// Is this a network-egress effect (one whose blast radius is reaching
446/// a host on the network)? Kept aligned with the `Network`-dimension
447/// entries in [`effect_requirement`].
448pub fn is_net_effect(name: &str) -> bool {
449    matches!(name, "net" | "http" | "mcp" | "llm_cloud")
450}
451
452/// The host a net effect targets, if it is host-scoped (`net("host")`).
453/// A bare `[net]` returns `None`.
454fn net_effect_host(e: &EffectKind) -> Option<&str> {
455    match &e.arg {
456        Some(crate::types::EffectArg::Str(h)) => Some(h.as_str()),
457        _ => Option::None,
458    }
459}
460
461/// Match a target `host` against one allowlist `entry`. Entries may
462/// carry a `:port` suffix (ignored for host matching) and a leading
463/// `*.` wildcard matching any subdomain — `*.example.com` matches
464/// `api.example.com` and `example.com`. Host comparison is
465/// case-insensitive.
466pub fn host_matches(entry: &str, host: &str) -> bool {
467    let entry_host = entry.split(':').next().unwrap_or(entry);
468    match entry_host.strip_prefix("*.") {
469        Some(suffix) => {
470            host.eq_ignore_ascii_case(suffix)
471                || (host.len() > suffix.len() + 1
472                    && host[host.len() - suffix.len()..].eq_ignore_ascii_case(suffix)
473                    && host.as_bytes()[host.len() - suffix.len() - 1] == b'.')
474        }
475        None => entry_host.eq_ignore_ascii_case(host),
476    }
477}
478
479fn host_in_allowlist(host: &str, allowlist: &[String]) -> bool {
480    allowlist.iter().any(|e| host_matches(e, host))
481}
482
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    #[test]
489    fn level_total_order() {
490        assert!(Level::None.leq(Level::ReadOnly));
491        assert!(Level::ReadOnly.leq(Level::ReadWrite));
492        assert!(Level::ReadWrite.leq(Level::Full));
493        assert!(!Level::Full.leq(Level::ReadOnly));
494        // Aliases at the same rank compare equal-ish.
495        assert!(Level::Sandboxed.leq(Level::ReadOnly));
496        assert!(Level::ReadOnly.leq(Level::Sandboxed));
497        assert!(Level::Loopback.leq(Level::ReadOnly));
498    }
499
500    #[test]
501    fn level_join_meet() {
502        assert_eq!(Level::None.join(Level::Full).rank(), Level::Full.rank());
503        assert_eq!(Level::None.meet(Level::Full).rank(), Level::None.rank());
504        assert_eq!(
505            Level::ReadOnly.join(Level::ReadWrite).rank(),
506            Level::ReadWrite.rank()
507        );
508        assert_eq!(
509            Level::ReadOnly.meet(Level::ReadWrite).rank(),
510            Level::ReadOnly.rank()
511        );
512    }
513
514    #[test]
515    fn host_matching_exact_port_and_wildcard() {
516        assert!(host_matches("results.demo.internal", "results.demo.internal"));
517        assert!(host_matches("results.demo.internal:443", "results.demo.internal"));
518        assert!(!host_matches("results.demo.internal", "evil.com"));
519        assert!(host_matches("Results.Demo.Internal", "results.demo.internal"));
520        assert!(host_matches("*.example.com", "api.example.com"));
521        assert!(host_matches("*.example.com", "example.com"));
522        assert!(!host_matches("*.example.com", "example.com.evil.com"));
523        assert!(!host_matches("*.example.com", "notexample.com"));
524    }
525
526    #[test]
527    fn allowlist_permits_only_listed_host_under_none_network() {
528        // The demo grant: network none EXCEPT one host.
529        let grant = Grant::new(Level::ReadWrite, Level::None, Level::Full);
530        let allow = vec!["results.demo.internal:443".to_string()];
531
532        let mut ok = EffectSet::empty();
533        ok.concrete.insert(EffectKind::with_str("net", "results.demo.internal"));
534        assert!(grant.permits_effects_with_allowlist(&ok, &allow).is_ok());
535
536        let mut bad = EffectSet::empty();
537        bad.concrete.insert(EffectKind::with_str("net", "evil.com"));
538        match grant.permits_effects_with_allowlist(&bad, &allow).unwrap_err() {
539            TrustError::NetHostNotAllowed { host, allowed } => {
540                assert_eq!(host, "evil.com");
541                assert_eq!(allowed, 1);
542            }
543            other => panic!("unexpected: {other:?}"),
544        }
545    }
546
547    #[test]
548    fn unscoped_net_rejected_unless_full() {
549        let allow = vec!["results.demo.internal".to_string()];
550        let mut bare = EffectSet::empty();
551        bare.concrete.insert(EffectKind::bare("net"));
552
553        let g = Grant::new(Level::None, Level::Allowlist, Level::None);
554        assert!(matches!(
555            g.permits_effects_with_allowlist(&bare, &allow).unwrap_err(),
556            TrustError::NetUnscoped
557        ));
558        let full = Grant::new(Level::None, Level::Full, Level::None);
559        assert!(full.permits_effects_with_allowlist(&bare, &allow).is_ok());
560    }
561
562    #[test]
563    fn full_network_permits_any_host() {
564        let g = Grant::new(Level::None, Level::Full, Level::None);
565        let mut e = EffectSet::empty();
566        e.concrete.insert(EffectKind::with_str("net", "anything.example"));
567        assert!(g.permits_effects_with_allowlist(&e, &[]).is_ok());
568    }
569
570    #[test]
571    fn allowlist_check_still_gates_non_net_effects() {
572        let g = Grant::new(Level::ReadOnly, Level::Full, Level::None);
573        let mut e = EffectSet::empty();
574        e.concrete.insert(EffectKind::bare("fs_write"));
575        assert!(matches!(
576            g.permits_effects_with_allowlist(&e, &[]).unwrap_err(),
577            TrustError::EffectNotPermitted {
578                dimension: Dimension::Filesystem,
579                ..
580            }
581        ));
582    }
583
584    #[test]
585    fn grant_lattice_extremes() {
586        let b = Grant::bottom();
587        let t = Grant::top();
588        assert!(b.leq(&t));
589        assert!(!t.leq(&b));
590        // bottom is the identity for join, top for meet.
591        let g = Grant::new(Level::ReadOnly, Level::Loopback, Level::None);
592        assert_eq!(b.join(&g), g);
593        assert_eq!(t.meet(&g), g);
594    }
595
596    #[test]
597    fn narrowing_allowed() {
598        let parent = Grant::new(Level::ReadWrite, Level::Full, Level::Sandboxed);
599        let child = Grant::new(Level::ReadOnly, Level::None, Level::None);
600        assert_eq!(Grant::narrow(&parent, &child), Ok(child));
601    }
602
603    #[test]
604    fn widening_is_rejected() {
605        let parent = Grant::new(Level::ReadOnly, Level::None, Level::None);
606        // Child tries to widen network none -> full.
607        let child = Grant::new(Level::ReadOnly, Level::Full, Level::None);
608        let err = Grant::narrow(&parent, &child).unwrap_err();
609        assert_eq!(
610            err,
611            TrustError::Widens {
612                dimension: Dimension::Network,
613                parent: Level::None,
614                requested: Level::Full,
615            }
616        );
617    }
618
619    #[test]
620    fn narrowing_is_transitive_via_leq() {
621        let a = Grant::top();
622        let b = Grant::new(Level::ReadWrite, Level::Loopback, Level::None);
623        let c = Grant::new(Level::ReadOnly, Level::None, Level::None);
624        assert!(Grant::narrow(&a, &b).is_ok());
625        assert!(Grant::narrow(&b, &c).is_ok());
626        // …and the chain composes: c narrows a directly.
627        assert!(Grant::narrow(&a, &c).is_ok());
628    }
629
630    #[test]
631    fn effect_permitted_under_matching_grant() {
632        let read_only = Grant::new(Level::ReadOnly, Level::None, Level::None);
633        assert!(read_only.permits_effect(&EffectKind::bare("fs_read")));
634        // fs_write needs ReadWrite, denied under ReadOnly.
635        assert!(!read_only.permits_effect(&EffectKind::bare("fs_write")));
636        // net denied under network: none.
637        assert!(!read_only.permits_effect(&EffectKind::bare("net")));
638        // pure effects always allowed.
639        assert!(read_only.permits_effect(&EffectKind::bare("log")));
640        assert!(read_only.permits_effect(&EffectKind::bare("time")));
641    }
642
643    #[test]
644    fn effect_set_checked_against_grant() {
645        // The headline guarantee: a function that uses `net` does not
646        // satisfy a `network: none` grant.
647        let analyze_grant = Grant::new(Level::ReadOnly, Level::None, Level::None);
648        let mut effects = EffectSet::empty();
649        effects.concrete.insert(EffectKind::bare("fs_read"));
650        effects.concrete.insert(EffectKind::with_str("net", "evil.example"));
651        let err = analyze_grant.permits_effects(&effects).unwrap_err();
652        match err {
653            TrustError::EffectNotPermitted { dimension, required, granted, .. } => {
654                assert_eq!(dimension, Dimension::Network);
655                assert_eq!(required, Level::Allowlist);
656                assert_eq!(granted, Level::None);
657            }
658            other => panic!("unexpected error: {other:?}"),
659        }
660    }
661
662    #[test]
663    fn effect_set_fully_within_grant_ok() {
664        let grant = Grant::new(Level::ReadWrite, Level::Full, Level::Sandboxed);
665        let mut effects = EffectSet::empty();
666        effects.concrete.insert(EffectKind::bare("fs_read"));
667        effects.concrete.insert(EffectKind::bare("fs_write"));
668        effects.concrete.insert(EffectKind::bare("net"));
669        effects.concrete.insert(EffectKind::bare("proc"));
670        assert!(grant.permits_effects(&effects).is_ok());
671    }
672
673    #[test]
674    fn empty_effect_set_always_permitted() {
675        // A grant of bottom (deny-all) still permits the empty effect set —
676        // a function that does nothing satisfies any grant.
677        let bottom = Grant::bottom();
678        assert!(bottom.permits_effects(&EffectSet::empty()).is_ok());
679    }
680
681    #[test]
682    fn llm_local_requires_filesystem_read() {
683        // llm_local reads model weights from disk; it must be rejected
684        // under a filesystem: none grant.
685        let no_fs = Grant::new(Level::None, Level::Full, Level::None);
686        let mut effects = EffectSet::empty();
687        effects.concrete.insert(EffectKind::bare("llm_local"));
688        assert!(
689            no_fs.permits_effects(&effects).is_err(),
690            "llm_local should be denied under filesystem: none"
691        );
692        // But allowed under a read-only filesystem grant.
693        let read_only_fs = Grant::new(Level::ReadOnly, Level::Full, Level::None);
694        assert!(read_only_fs.permits_effects(&effects).is_ok());
695    }
696
697    #[test]
698    fn content_id_is_stable_and_alias_insensitive() {
699        // Sandboxed and ReadOnly share a rank, so an exec=Sandboxed
700        // grant and an exec=ReadOnly grant address identically.
701        let g1 = Grant::new(Level::None, Level::None, Level::Sandboxed);
702        let g2 = Grant::new(Level::None, Level::None, Level::ReadOnly);
703        assert_eq!(g1.content_id(), g2.content_id());
704        // Different authority -> different id.
705        assert_ne!(Grant::bottom().content_id(), Grant::top().content_id());
706        // Stable across calls.
707        assert_eq!(g1.content_id(), g1.content_id());
708        assert_eq!(g1.content_id().0.len(), 64);
709    }
710}