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,
440 }
441}
442
443pub fn is_net_effect(name: &str) -> bool {
447 matches!(name, "net" | "http" | "mcp" | "llm_cloud")
448}
449
450fn 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
459pub 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 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 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 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 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 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 assert!(!read_only.permits_effect(&EffectKind::bare("fs_write")));
634 assert!(!read_only.permits_effect(&EffectKind::bare("net")));
636 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 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 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 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 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 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 assert_ne!(Grant::bottom().content_id(), Grant::top().content_id());
704 assert_eq!(g1.content_id(), g1.content_id());
706 assert_eq!(g1.content_id().0.len(), 64);
707 }
708}