1use crate::types::{EffectKind, EffectSet};
25use serde::{Deserialize, Serialize};
26use sha2::{Digest, Sha256};
27use std::fmt;
28
29#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
77pub enum Level {
78 None,
80 ReadOnly,
82 Sandboxed,
84 Loopback,
86 ReadWrite,
88 Allowlist,
90 Full,
92}
93
94impl Level {
95 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 pub fn leq(self, other: Level) -> bool {
109 self.rank() <= other.rank()
110 }
111
112 pub fn join(self, other: Level) -> Level {
115 if self.rank() >= other.rank() {
116 self
117 } else {
118 other
119 }
120 }
121
122 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#[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#[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 pub fn bottom() -> Self {
206 Self::new(Level::None, Level::None, Level::None)
207 }
208
209 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 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 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 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 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 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 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 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 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 pub fn pretty(&self) -> String {
364 format!(
365 "fs={} net={} exec={}",
366 self.filesystem, self.network, self.exec
367 )
368 }
369
370 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#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
397pub struct GrantId(pub String);
398
399impl GrantId {
400 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
412pub fn effect_requirement(effect_name: &str) -> Option<(Dimension, Level)> {
419 use Dimension::*;
420 use Level::*;
421 match effect_name {
422 "fs_read" | "fs_walk" => Some((Filesystem, ReadOnly)),
424 "fs_write" => Some((Filesystem, ReadWrite)),
425 "net" | "http" | "mcp" | "llm_cloud" => Some((Network, Allowlist)),
428 "proc" => Some((Exec, Sandboxed)),
430 "llm_local" => Some((Filesystem, ReadOnly)),
432 _ => Option::None,
442 }
443}
444
445pub fn is_net_effect(name: &str) -> bool {
449 matches!(name, "net" | "http" | "mcp" | "llm_cloud")
450}
451
452fn 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
461pub 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 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 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 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 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 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 assert!(!read_only.permits_effect(&EffectKind::bare("fs_write")));
636 assert!(!read_only.permits_effect(&EffectKind::bare("net")));
638 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 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 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 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 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 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 assert_ne!(Grant::bottom().content_id(), Grant::top().content_id());
706 assert_eq!(g1.content_id(), g1.content_id());
708 assert_eq!(g1.content_id().0.len(), 64);
709 }
710}