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}