1use crate::trust::{
47 effect_requirement, is_net_effect, Dimension, Grant, GrantId, Level, TrustError,
48};
49use crate::types::{EffectArg, EffectKind};
50use crate::EffectSet;
51use serde::{Deserialize, Serialize};
52use std::collections::BTreeSet;
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct Authority {
60 pub grant: Grant,
64 pub egress: Vec<String>,
67 pub unscoped_net: bool,
72 pub fs_read: Vec<String>,
74 pub fs_write: Vec<String>,
76 pub effects: Vec<String>,
78 pub off_lattice: Vec<String>,
81}
82
83impl Authority {
84 pub fn grant_id(&self) -> GrantId {
87 self.grant.content_id()
88 }
89
90 pub fn minimality_witness(&self, effects: &EffectSet) -> Vec<MinimalityWitness> {
99 let mut out = Vec::new();
100 for dim in Dimension::ALL {
101 let level = self.grant.level(dim);
102 let Some(lowered_to) = next_level_down(dim, level) else {
103 continue; };
105 let mut probe = self.grant;
106 set_level(&mut probe, dim, lowered_to);
107 if let Err(TrustError::EffectNotPermitted { effect, .. }) =
108 probe.permits_effects(effects)
109 {
110 out.push(MinimalityWitness {
111 dimension: dim,
112 level,
113 lowered_to,
114 rejected_effect: effect,
115 });
116 }
117 }
118 out
119 }
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct MinimalityWitness {
126 pub dimension: Dimension,
127 pub level: Level,
128 pub lowered_to: Level,
129 pub rejected_effect: String,
130}
131
132fn set_level(g: &mut Grant, dim: Dimension, level: Level) {
137 match dim {
138 Dimension::Filesystem => g.filesystem = level,
139 Dimension::Network => g.network = level,
140 Dimension::Exec => g.exec = level,
141 }
142}
143
144pub fn next_level_down(dim: Dimension, level: Level) -> Option<Level> {
147 let ladder = dim.levels();
148 let idx = ladder.iter().position(|l| l.rank() == level.rank())?;
149 idx.checked_sub(1).map(|i| ladder[i])
150}
151
152pub fn derive_from_effects(effects: &EffectSet) -> Result<Authority, TrustError> {
163 let (mut filesystem, mut network, mut exec) = (Level::None, Level::None, Level::None);
164 let mut egress = BTreeSet::new();
165 let mut fs_read = BTreeSet::new();
166 let mut fs_write = BTreeSet::new();
167 let mut kinds = BTreeSet::new();
168 let mut off_lattice = BTreeSet::new();
169 let mut unscoped_net = false;
170
171 for e in &effects.concrete {
172 kinds.insert(e.name.clone());
173 match effect_requirement(&e.name) {
174 Some((Dimension::Filesystem, required)) => filesystem = filesystem.join(required),
175 Some((Dimension::Network, required)) => network = network.join(required),
176 Some((Dimension::Exec, required)) => exec = exec.join(required),
177 None => {
178 off_lattice.insert(e.name.clone());
179 }
180 }
181 if is_net_effect(&e.name) {
182 match scope_arg(e) {
183 Some(host) => {
184 egress.insert(host.to_string());
185 }
186 None => unscoped_net = true,
187 }
188 }
189 match (e.name.as_str(), scope_arg(e)) {
190 ("fs_read" | "fs_walk", Some(p)) => {
191 fs_read.insert(p.to_string());
192 }
193 ("fs_write", Some(p)) => {
194 fs_write.insert(p.to_string());
195 }
196 _ => {}
197 }
198 }
199
200 Ok(Authority {
201 grant: Grant::try_new(filesystem, network, exec)?,
202 egress: egress.into_iter().collect(),
203 unscoped_net,
204 fs_read: fs_read.into_iter().collect(),
205 fs_write: fs_write.into_iter().collect(),
206 effects: kinds.into_iter().collect(),
207 off_lattice: off_lattice.into_iter().collect(),
208 })
209}
210
211fn scope_arg(e: &EffectKind) -> Option<&str> {
214 match &e.arg {
215 Some(EffectArg::Str(s)) => Some(s.as_str()),
216 _ => None,
217 }
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
224#[serde(rename_all = "lowercase")]
225pub enum Verdict {
226 Unchanged,
228 Narrowing,
231 Widening,
234}
235
236impl Verdict {
237 pub fn as_str(self) -> &'static str {
238 match self {
239 Verdict::Unchanged => "unchanged",
240 Verdict::Narrowing => "narrowing",
241 Verdict::Widening => "widening",
242 }
243 }
244}
245
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248pub struct DimensionDelta {
249 pub dimension: Dimension,
250 pub from: Level,
251 pub to: Level,
252}
253
254impl DimensionDelta {
255 pub fn widens(&self) -> bool {
256 self.to.rank() > self.from.rank()
257 }
258}
259
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
263pub struct AuthorityDiff {
264 pub dimensions: Vec<DimensionDelta>,
266 pub egress_added: Vec<String>,
267 pub egress_removed: Vec<String>,
268 pub fs_read_added: Vec<String>,
269 pub fs_read_removed: Vec<String>,
270 pub fs_write_added: Vec<String>,
271 pub fs_write_removed: Vec<String>,
272 pub off_lattice_added: Vec<String>,
276 pub off_lattice_removed: Vec<String>,
277 pub lost_net_precision: bool,
281 pub verdict: Verdict,
282}
283
284impl AuthorityDiff {
285 pub fn is_empty(&self) -> bool {
287 self.verdict == Verdict::Unchanged
288 }
289}
290
291pub fn diff(base: &Authority, head: &Authority) -> AuthorityDiff {
293 let mut dimensions = Vec::new();
294 for dim in Dimension::ALL {
295 let (from, to) = (base.grant.level(dim), head.grant.level(dim));
296 if from.rank() != to.rank() {
297 dimensions.push(DimensionDelta {
298 dimension: dim,
299 from,
300 to,
301 });
302 }
303 }
304
305 let egress_added = added(&base.egress, &head.egress);
306 let egress_removed = added(&head.egress, &base.egress);
307 let fs_read_added = added(&base.fs_read, &head.fs_read);
308 let fs_read_removed = added(&head.fs_read, &base.fs_read);
309 let fs_write_added = added(&base.fs_write, &head.fs_write);
310 let fs_write_removed = added(&head.fs_write, &base.fs_write);
311 let off_lattice_added = added(&base.off_lattice, &head.off_lattice);
312 let off_lattice_removed = added(&head.off_lattice, &base.off_lattice);
313 let lost_net_precision = head.unscoped_net && !base.unscoped_net && !base.egress.is_empty();
318
319 let widens = dimensions.iter().any(DimensionDelta::widens)
320 || !egress_added.is_empty()
321 || !fs_read_added.is_empty()
322 || !fs_write_added.is_empty()
323 || !off_lattice_added.is_empty()
324 || lost_net_precision;
325 let narrows = dimensions.iter().any(|d| !d.widens())
326 || !egress_removed.is_empty()
327 || !fs_read_removed.is_empty()
328 || !fs_write_removed.is_empty()
329 || !off_lattice_removed.is_empty()
330 || (base.unscoped_net && !head.unscoped_net);
331
332 let verdict = if widens {
333 Verdict::Widening
334 } else if narrows {
335 Verdict::Narrowing
336 } else {
337 Verdict::Unchanged
338 };
339
340 AuthorityDiff {
341 dimensions,
342 egress_added,
343 egress_removed,
344 fs_read_added,
345 fs_read_removed,
346 fs_write_added,
347 fs_write_removed,
348 off_lattice_added,
349 off_lattice_removed,
350 lost_net_precision,
351 verdict,
352 }
353}
354
355fn added(a: &[String], b: &[String]) -> Vec<String> {
357 let have: BTreeSet<&str> = a.iter().map(String::as_str).collect();
358 b.iter()
359 .filter(|x| !have.contains(x.as_str()))
360 .cloned()
361 .collect()
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 fn effects(rows: &[(&str, Option<&str>)]) -> EffectSet {
369 let mut set = EffectSet::empty();
370 for (name, arg) in rows {
371 let kind = match arg {
372 Some(v) => EffectKind::with_str(name.to_string(), v.to_string()),
373 None => EffectKind::bare(name.to_string()),
374 };
375 set.concrete.insert(kind);
376 }
377 set
378 }
379
380 #[test]
381 fn pure_code_needs_nothing() {
382 let e = effects(&[]);
383 let a = derive_from_effects(&e).unwrap();
384 assert_eq!(a.grant, Grant::bottom());
385 assert!(a.minimality_witness(&e).is_empty());
386 }
387
388 #[test]
389 fn off_lattice_effects_claim_no_dimension() {
390 let e = effects(&[("io", None), ("env", None), ("sql", None)]);
393 let a = derive_from_effects(&e).unwrap();
394 assert_eq!(a.grant, Grant::bottom());
395 assert_eq!(a.off_lattice, vec!["env", "io", "sql"]);
396 Grant::bottom().permits_effects(&e).expect("permitted");
397 }
398
399 #[test]
400 fn the_level_is_the_join_over_the_effects_touching_a_dimension() {
401 let read = derive_from_effects(&effects(&[("fs_read", None)])).unwrap();
404 assert_eq!(read.grant.filesystem, Level::ReadOnly);
405 let both = derive_from_effects(&effects(&[("fs_read", None), ("fs_write", None)])).unwrap();
406 assert_eq!(both.grant.filesystem, Level::ReadWrite);
407 }
408
409 #[test]
411 fn derived_grants_are_minimal() {
412 for rows in [
413 vec![],
414 vec![("io", None)],
415 vec![("fs_read", Some("/etc/hosts"))],
416 vec![("fs_write", Some("/tmp/out")), ("net", None)],
417 vec![("proc", None), ("llm_cloud", None)],
418 vec![("net", Some("api.example.com")), ("fs_walk", None)],
419 ] {
420 let e = effects(&rows);
421 let a = derive_from_effects(&e).unwrap();
422 a.grant
423 .permits_effects(&e)
424 .expect("permits what it derived");
425 for dim in Dimension::ALL {
426 if let Some(lower) = next_level_down(dim, a.grant.level(dim)) {
427 let mut probe = a.grant;
428 set_level(&mut probe, dim, lower);
429 assert!(
430 probe.permits_effects(&e).is_err(),
431 "{dim} at {} is a rank too generous for {rows:?}",
432 a.grant.level(dim)
433 );
434 }
435 }
436 let non_none = Dimension::ALL
437 .iter()
438 .filter(|d| a.grant.level(**d) != Level::None)
439 .count();
440 assert_eq!(a.minimality_witness(&e).len(), non_none);
441 }
442 }
443
444 #[test]
445 fn scopes_are_collected_per_kind() {
446 let a = derive_from_effects(&effects(&[
447 ("net", Some("a.example")),
448 ("net", Some("b.example")),
449 ("fs_read", Some("/in")),
450 ("fs_write", Some("/out")),
451 ]))
452 .unwrap();
453 assert_eq!(a.egress, vec!["a.example", "b.example"]);
454 assert_eq!(a.fs_read, vec!["/in"]);
455 assert_eq!(a.fs_write, vec!["/out"]);
456 assert!(!a.unscoped_net, "every net effect named a host");
457 }
458
459 #[test]
460 fn a_bare_net_is_recorded_as_unscoped() {
461 let a = derive_from_effects(&effects(&[("net", None)])).unwrap();
462 assert!(a.unscoped_net);
463 assert!(a.egress.is_empty());
464 }
465
466 #[test]
467 fn adding_a_host_is_a_widening_and_removing_one_is_a_narrowing() {
468 let one = derive_from_effects(&effects(&[("net", Some("a.example"))])).unwrap();
469 let two = derive_from_effects(&effects(&[
470 ("net", Some("a.example")),
471 ("net", Some("b.example")),
472 ]))
473 .unwrap();
474 assert_eq!(diff(&one, &two).verdict, Verdict::Widening);
475 assert_eq!(diff(&two, &one).verdict, Verdict::Narrowing);
476 assert_eq!(diff(&one, &one).verdict, Verdict::Unchanged);
477 }
478
479 #[test]
480 fn losing_host_precision_is_a_widening_even_at_the_same_level() {
481 let scoped = derive_from_effects(&effects(&[("net", Some("a.example"))])).unwrap();
482 let bare =
483 derive_from_effects(&effects(&[("net", Some("a.example")), ("net", None)])).unwrap();
484 assert_eq!(scoped.grant, bare.grant, "same coarse level");
485 let d = diff(&scoped, &bare);
486 assert!(d.lost_net_precision);
487 assert_eq!(d.verdict, Verdict::Widening);
488 }
489
490 #[test]
491 fn no_network_to_a_bare_net_is_not_reported_twice() {
492 let none = derive_from_effects(&effects(&[])).unwrap();
493 let bare = derive_from_effects(&effects(&[("net", None)])).unwrap();
494 let d = diff(&none, &bare);
495 assert_eq!(d.verdict, Verdict::Widening);
496 assert!(
497 !d.lost_net_precision,
498 "the dimension widening already says it"
499 );
500 }
501
502 #[test]
503 fn a_widening_dominates_a_narrowing_in_the_same_change() {
504 let base = derive_from_effects(&effects(&[("fs_write", None)])).unwrap();
505 let head = derive_from_effects(&effects(&[("fs_read", None), ("net", None)])).unwrap();
506 let d = diff(&base, &head);
507 assert!(d.dimensions.iter().any(|x| !x.widens()), "filesystem fell");
508 assert!(d.dimensions.iter().any(|x| x.widens()), "network rose");
509 assert_eq!(d.verdict, Verdict::Widening);
510 }
511
512 #[test]
513 fn off_lattice_movement_is_reported_though_no_grant_would_catch_it() {
514 let base = derive_from_effects(&effects(&[])).unwrap();
515 let head = derive_from_effects(&effects(&[("env", None)])).unwrap();
516 let d = diff(&base, &head);
517 assert_eq!(d.verdict, Verdict::Widening);
518 assert_eq!(d.off_lattice_added, vec!["env"]);
519 assert!(d.dimensions.is_empty(), "no dimension moved");
520 }
521}