use crate::types::{EffectKind, EffectSet};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Dimension {
Filesystem,
Network,
Exec,
}
impl Dimension {
pub const ALL: [Dimension; 3] = [Dimension::Filesystem, Dimension::Network, Dimension::Exec];
pub fn as_str(self) -> &'static str {
match self {
Dimension::Filesystem => "filesystem",
Dimension::Network => "network",
Dimension::Exec => "exec",
}
}
}
impl fmt::Display for Dimension {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Level {
None,
ReadOnly,
Sandboxed,
Loopback,
ReadWrite,
Allowlist,
Full,
}
impl Level {
pub fn rank(self) -> u8 {
match self {
Level::None => 0,
Level::ReadOnly | Level::Sandboxed | Level::Loopback => 1,
Level::ReadWrite | Level::Allowlist => 2,
Level::Full => 3,
}
}
pub fn leq(self, other: Level) -> bool {
self.rank() <= other.rank()
}
pub fn join(self, other: Level) -> Level {
if self.rank() >= other.rank() {
self
} else {
other
}
}
pub fn meet(self, other: Level) -> Level {
if self.rank() <= other.rank() {
self
} else {
other
}
}
pub fn as_str(self) -> &'static str {
match self {
Level::None => "none",
Level::ReadOnly => "read-only",
Level::Sandboxed => "sandboxed",
Level::Loopback => "loopback",
Level::ReadWrite => "read-write",
Level::Allowlist => "allowlist",
Level::Full => "full",
}
}
}
impl fmt::Display for Level {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Grant {
pub filesystem: Level,
pub network: Level,
pub exec: Level,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum TrustError {
#[error(
"trust widening on {dimension}: child requests `{requested}` but parent only grants `{parent}` (a child manifest may only narrow)"
)]
Widens {
dimension: Dimension,
parent: Level,
requested: Level,
},
#[error(
"effect `{effect}` needs {dimension} ≥ `{required}` but the grant only provides `{granted}`"
)]
EffectNotPermitted {
effect: String,
dimension: Dimension,
required: Level,
granted: Level,
},
#[error(
"net effect to `{host}` is not in the grant's egress allowlist ({allowed} host(s) allowed)"
)]
NetHostNotAllowed { host: String, allowed: usize },
#[error(
"unscoped `[net]` cannot be proven within the egress allowlist — scope it to a host, e.g. `net(\"results.demo.internal\")`"
)]
NetUnscoped,
}
impl Grant {
pub fn new(filesystem: Level, network: Level, exec: Level) -> Self {
Self { filesystem, network, exec }
}
pub fn bottom() -> Self {
Self::new(Level::None, Level::None, Level::None)
}
pub fn top() -> Self {
Self::new(Level::Full, Level::Full, Level::Full)
}
pub fn level(&self, dim: Dimension) -> Level {
match dim {
Dimension::Filesystem => self.filesystem,
Dimension::Network => self.network,
Dimension::Exec => self.exec,
}
}
pub fn leq(&self, other: &Grant) -> bool {
Dimension::ALL
.iter()
.all(|&d| self.level(d).leq(other.level(d)))
}
pub fn join(&self, other: &Grant) -> Grant {
Grant::new(
self.filesystem.join(other.filesystem),
self.network.join(other.network),
self.exec.join(other.exec),
)
}
pub fn meet(&self, other: &Grant) -> Grant {
Grant::new(
self.filesystem.meet(other.filesystem),
self.network.meet(other.network),
self.exec.meet(other.exec),
)
}
pub fn narrow(parent: &Grant, child: &Grant) -> Result<Grant, TrustError> {
for &d in &Dimension::ALL {
let p = parent.level(d);
let c = child.level(d);
if !c.leq(p) {
return Err(TrustError::Widens {
dimension: d,
parent: p,
requested: c,
});
}
}
Ok(*child)
}
pub fn permits_effect(&self, effect: &EffectKind) -> bool {
match effect_requirement(&effect.name) {
Some((dim, required)) => required.leq(self.level(dim)),
None => true,
}
}
pub fn permits_effects(&self, effects: &EffectSet) -> Result<(), TrustError> {
for e in &effects.concrete {
if let Some((dim, required)) = effect_requirement(&e.name) {
let granted = self.level(dim);
if !required.leq(granted) {
return Err(TrustError::EffectNotPermitted {
effect: e.pretty(),
dimension: dim,
required,
granted,
});
}
}
}
Ok(())
}
pub fn permits_effects_with_allowlist(
&self,
effects: &EffectSet,
allowlist: &[String],
) -> Result<(), TrustError> {
for e in &effects.concrete {
self.permit_one_with_allowlist(e, allowlist)?;
}
Ok(())
}
fn permit_one_with_allowlist(
&self,
e: &EffectKind,
allowlist: &[String],
) -> Result<(), TrustError> {
if is_net_effect(&e.name) {
if self.network == Level::Full {
return Ok(());
}
match net_effect_host(e) {
Some(host) if host_in_allowlist(host, allowlist) => Ok(()),
Some(host) => Err(TrustError::NetHostNotAllowed {
host: host.to_string(),
allowed: allowlist.len(),
}),
None => Err(TrustError::NetUnscoped),
}
} else if let Some((dim, required)) = effect_requirement(&e.name) {
let granted = self.level(dim);
if required.leq(granted) {
Ok(())
} else {
Err(TrustError::EffectNotPermitted {
effect: e.pretty(),
dimension: dim,
required,
granted,
})
}
} else {
Ok(())
}
}
pub fn pretty(&self) -> String {
format!(
"fs={} net={} exec={}",
self.filesystem, self.network, self.exec
)
}
pub fn content_id(&self) -> GrantId {
let mut hasher = Sha256::new();
hasher.update(b"lex.trust.grant.v1");
for &d in &Dimension::ALL {
hasher.update([d as u8, self.level(d).rank()]);
}
let digest = hasher.finalize();
GrantId(hex::encode(digest))
}
}
impl fmt::Display for Grant {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.pretty())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct GrantId(pub String);
impl GrantId {
pub fn short(&self) -> &str {
&self.0[..self.0.len().min(12)]
}
}
impl fmt::Display for GrantId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "grant:{}", self.short())
}
}
pub fn effect_requirement(effect_name: &str) -> Option<(Dimension, Level)> {
use Dimension::*;
use Level::*;
match effect_name {
"fs_read" | "fs_walk" => Some((Filesystem, ReadOnly)),
"fs_write" => Some((Filesystem, ReadWrite)),
"net" | "http" | "mcp" | "llm_cloud" => Some((Network, Allowlist)),
"proc" => Some((Exec, Sandboxed)),
"llm_local" => Some((Filesystem, ReadOnly)),
_ => Option::None,
}
}
pub fn is_net_effect(name: &str) -> bool {
matches!(name, "net" | "http" | "mcp" | "llm_cloud")
}
fn net_effect_host(e: &EffectKind) -> Option<&str> {
match &e.arg {
Some(crate::types::EffectArg::Str(h)) => Some(h.as_str()),
_ => Option::None,
}
}
pub fn host_matches(entry: &str, host: &str) -> bool {
let entry_host = entry.split(':').next().unwrap_or(entry);
match entry_host.strip_prefix("*.") {
Some(suffix) => {
host.eq_ignore_ascii_case(suffix)
|| (host.len() > suffix.len() + 1
&& host[host.len() - suffix.len()..].eq_ignore_ascii_case(suffix)
&& host.as_bytes()[host.len() - suffix.len() - 1] == b'.')
}
None => entry_host.eq_ignore_ascii_case(host),
}
}
fn host_in_allowlist(host: &str, allowlist: &[String]) -> bool {
allowlist.iter().any(|e| host_matches(e, host))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn level_total_order() {
assert!(Level::None.leq(Level::ReadOnly));
assert!(Level::ReadOnly.leq(Level::ReadWrite));
assert!(Level::ReadWrite.leq(Level::Full));
assert!(!Level::Full.leq(Level::ReadOnly));
assert!(Level::Sandboxed.leq(Level::ReadOnly));
assert!(Level::ReadOnly.leq(Level::Sandboxed));
assert!(Level::Loopback.leq(Level::ReadOnly));
}
#[test]
fn level_join_meet() {
assert_eq!(Level::None.join(Level::Full).rank(), Level::Full.rank());
assert_eq!(Level::None.meet(Level::Full).rank(), Level::None.rank());
assert_eq!(
Level::ReadOnly.join(Level::ReadWrite).rank(),
Level::ReadWrite.rank()
);
assert_eq!(
Level::ReadOnly.meet(Level::ReadWrite).rank(),
Level::ReadOnly.rank()
);
}
#[test]
fn host_matching_exact_port_and_wildcard() {
assert!(host_matches("results.demo.internal", "results.demo.internal"));
assert!(host_matches("results.demo.internal:443", "results.demo.internal"));
assert!(!host_matches("results.demo.internal", "evil.com"));
assert!(host_matches("Results.Demo.Internal", "results.demo.internal"));
assert!(host_matches("*.example.com", "api.example.com"));
assert!(host_matches("*.example.com", "example.com"));
assert!(!host_matches("*.example.com", "example.com.evil.com"));
assert!(!host_matches("*.example.com", "notexample.com"));
}
#[test]
fn allowlist_permits_only_listed_host_under_none_network() {
let grant = Grant::new(Level::ReadWrite, Level::None, Level::Full);
let allow = vec!["results.demo.internal:443".to_string()];
let mut ok = EffectSet::empty();
ok.concrete.insert(EffectKind::with_str("net", "results.demo.internal"));
assert!(grant.permits_effects_with_allowlist(&ok, &allow).is_ok());
let mut bad = EffectSet::empty();
bad.concrete.insert(EffectKind::with_str("net", "evil.com"));
match grant.permits_effects_with_allowlist(&bad, &allow).unwrap_err() {
TrustError::NetHostNotAllowed { host, allowed } => {
assert_eq!(host, "evil.com");
assert_eq!(allowed, 1);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn unscoped_net_rejected_unless_full() {
let allow = vec!["results.demo.internal".to_string()];
let mut bare = EffectSet::empty();
bare.concrete.insert(EffectKind::bare("net"));
let g = Grant::new(Level::None, Level::Allowlist, Level::None);
assert!(matches!(
g.permits_effects_with_allowlist(&bare, &allow).unwrap_err(),
TrustError::NetUnscoped
));
let full = Grant::new(Level::None, Level::Full, Level::None);
assert!(full.permits_effects_with_allowlist(&bare, &allow).is_ok());
}
#[test]
fn full_network_permits_any_host() {
let g = Grant::new(Level::None, Level::Full, Level::None);
let mut e = EffectSet::empty();
e.concrete.insert(EffectKind::with_str("net", "anything.example"));
assert!(g.permits_effects_with_allowlist(&e, &[]).is_ok());
}
#[test]
fn allowlist_check_still_gates_non_net_effects() {
let g = Grant::new(Level::ReadOnly, Level::Full, Level::None);
let mut e = EffectSet::empty();
e.concrete.insert(EffectKind::bare("fs_write"));
assert!(matches!(
g.permits_effects_with_allowlist(&e, &[]).unwrap_err(),
TrustError::EffectNotPermitted {
dimension: Dimension::Filesystem,
..
}
));
}
#[test]
fn grant_lattice_extremes() {
let b = Grant::bottom();
let t = Grant::top();
assert!(b.leq(&t));
assert!(!t.leq(&b));
let g = Grant::new(Level::ReadOnly, Level::Loopback, Level::None);
assert_eq!(b.join(&g), g);
assert_eq!(t.meet(&g), g);
}
#[test]
fn narrowing_allowed() {
let parent = Grant::new(Level::ReadWrite, Level::Full, Level::Sandboxed);
let child = Grant::new(Level::ReadOnly, Level::None, Level::None);
assert_eq!(Grant::narrow(&parent, &child), Ok(child));
}
#[test]
fn widening_is_rejected() {
let parent = Grant::new(Level::ReadOnly, Level::None, Level::None);
let child = Grant::new(Level::ReadOnly, Level::Full, Level::None);
let err = Grant::narrow(&parent, &child).unwrap_err();
assert_eq!(
err,
TrustError::Widens {
dimension: Dimension::Network,
parent: Level::None,
requested: Level::Full,
}
);
}
#[test]
fn narrowing_is_transitive_via_leq() {
let a = Grant::top();
let b = Grant::new(Level::ReadWrite, Level::Loopback, Level::None);
let c = Grant::new(Level::ReadOnly, Level::None, Level::None);
assert!(Grant::narrow(&a, &b).is_ok());
assert!(Grant::narrow(&b, &c).is_ok());
assert!(Grant::narrow(&a, &c).is_ok());
}
#[test]
fn effect_permitted_under_matching_grant() {
let read_only = Grant::new(Level::ReadOnly, Level::None, Level::None);
assert!(read_only.permits_effect(&EffectKind::bare("fs_read")));
assert!(!read_only.permits_effect(&EffectKind::bare("fs_write")));
assert!(!read_only.permits_effect(&EffectKind::bare("net")));
assert!(read_only.permits_effect(&EffectKind::bare("log")));
assert!(read_only.permits_effect(&EffectKind::bare("time")));
}
#[test]
fn effect_set_checked_against_grant() {
let analyze_grant = Grant::new(Level::ReadOnly, Level::None, Level::None);
let mut effects = EffectSet::empty();
effects.concrete.insert(EffectKind::bare("fs_read"));
effects.concrete.insert(EffectKind::with_str("net", "evil.example"));
let err = analyze_grant.permits_effects(&effects).unwrap_err();
match err {
TrustError::EffectNotPermitted { dimension, required, granted, .. } => {
assert_eq!(dimension, Dimension::Network);
assert_eq!(required, Level::Allowlist);
assert_eq!(granted, Level::None);
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn effect_set_fully_within_grant_ok() {
let grant = Grant::new(Level::ReadWrite, Level::Full, Level::Sandboxed);
let mut effects = EffectSet::empty();
effects.concrete.insert(EffectKind::bare("fs_read"));
effects.concrete.insert(EffectKind::bare("fs_write"));
effects.concrete.insert(EffectKind::bare("net"));
effects.concrete.insert(EffectKind::bare("proc"));
assert!(grant.permits_effects(&effects).is_ok());
}
#[test]
fn empty_effect_set_always_permitted() {
let bottom = Grant::bottom();
assert!(bottom.permits_effects(&EffectSet::empty()).is_ok());
}
#[test]
fn llm_local_requires_filesystem_read() {
let no_fs = Grant::new(Level::None, Level::Full, Level::None);
let mut effects = EffectSet::empty();
effects.concrete.insert(EffectKind::bare("llm_local"));
assert!(
no_fs.permits_effects(&effects).is_err(),
"llm_local should be denied under filesystem: none"
);
let read_only_fs = Grant::new(Level::ReadOnly, Level::Full, Level::None);
assert!(read_only_fs.permits_effects(&effects).is_ok());
}
#[test]
fn content_id_is_stable_and_alias_insensitive() {
let g1 = Grant::new(Level::None, Level::None, Level::Sandboxed);
let g2 = Grant::new(Level::None, Level::None, Level::ReadOnly);
assert_eq!(g1.content_id(), g2.content_id());
assert_ne!(Grant::bottom().content_id(), Grant::top().content_id());
assert_eq!(g1.content_id(), g1.content_id());
assert_eq!(g1.content_id().0.len(), 64);
}
}