1use super::facet::*;
14
15#[derive(Copy, Clone, Debug, PartialEq, Eq)]
19pub struct OrdBound<T> {
20 pub min: Option<T>,
21 pub max: Option<T>,
22}
23
24impl<T: Ord + Copy> OrdBound<T> {
25 pub fn at_most(ceiling: T) -> Self {
27 Self { min: None, max: Some(ceiling) }
28 }
29 pub fn at_least(floor: T) -> Self {
31 Self { min: Some(floor), max: None }
32 }
33 pub fn exactly(exact: T) -> Self {
35 Self { min: Some(exact), max: Some(exact) }
36 }
37 pub fn admits(self, term: T) -> bool {
39 self.min.is_none_or(|lo| lo <= term) && self.max.is_none_or(|hi| term <= hi)
40 }
41}
42
43fn ord_admits<T: Ord + Copy>(bound: Option<OrdBound<T>>, term: T) -> bool {
44 bound.is_none_or(|b| b.admits(term))
45}
46
47fn set_admits<T: Eq + Copy>(set: Option<&[T]>, term: T) -> bool {
48 set.is_none_or(|s| s.contains(&term))
49}
50
51#[derive(Clone, Debug, PartialEq, Eq)]
58pub struct FacetMismatch {
59 pub facet: &'static str,
61 pub actual: &'static str,
63 pub bound: String,
65 pub admits_count: usize,
73}
74
75impl std::fmt::Display for FacetMismatch {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 write!(f, "{} = {} (allowed: {})", self.facet, self.actual, self.bound)
78 }
79}
80
81fn ord_mismatch<T: Ord + Copy + FacetTerm>(
82 facet: &'static str,
83 bound: Option<OrdBound<T>>,
84 term: T,
85) -> Option<FacetMismatch> {
86 let b = bound?;
87 if b.admits(term) {
88 return None;
89 }
90 let bound = match (b.min, b.max) {
91 (None, Some(hi)) => format!("<= {}", hi.as_str()),
92 (Some(lo), None) => format!(">= {}", lo.as_str()),
93 (Some(lo), Some(hi)) => format!("{}..={}", lo.as_str(), hi.as_str()),
94 (None, None) => "any".to_string(),
95 };
96 let admits_count = T::all().iter().filter(|t| b.admits(**t)).count();
97 Some(FacetMismatch { facet, actual: term.as_str(), bound, admits_count })
98}
99
100fn set_mismatch<T: PartialEq + Copy + FacetTerm>(
101 facet: &'static str,
102 set: Option<&[T]>,
103 term: T,
104) -> Option<FacetMismatch> {
105 let s = set?;
106 if s.contains(&term) {
107 return None;
108 }
109 let bound = format!(
110 "one of [{}]",
111 s.iter().map(|t| t.as_str()).collect::<Vec<_>>().join(", "),
112 );
113 Some(FacetMismatch { facet, actual: term.as_str(), bound, admits_count: s.len() })
114}
115
116#[derive(Clone, Debug, Default, PartialEq, Eq)]
121pub struct Clause {
122 pub operation: Option<Vec<Operation>>,
123 pub local_locus: Option<OrdBound<LocalLocus>>,
124 pub remote_reach: Option<OrdBound<RemoteReach>>,
125 pub remote_binding: Option<Vec<RemoteBinding>>,
126 pub provenance: Option<OrdBound<Provenance>>,
127 pub scale: Option<OrdBound<Scale>>,
128 pub retrieval: Option<OrdBound<RetrievalGranularity>>,
129 pub authority: Option<OrdBound<Authority>>,
130 pub isolation: Option<OrdBound<Isolation>>,
131 pub reversibility: Option<OrdBound<Reversibility>>,
132 pub persistence_level: Option<OrdBound<PersistenceLevel>>,
133 pub trigger_escape: Option<OrdBound<TriggerEscape>>,
134 pub trigger_kind: Option<Vec<TriggerKind>>,
135 pub disclosure_audience: Option<OrdBound<DisclosureAudience>>,
136 pub disclosure_channel: Option<Vec<Channel>>,
137 pub disclosure_principal: Option<Vec<Principal>>,
138 pub secret_level: Option<OrdBound<SecretLevel>>,
139 pub secret_channel: Option<Vec<Channel>>,
140 pub secret_principal: Option<Vec<Principal>>,
141 pub net_direction: Option<OrdBound<NetDirection>>,
142 pub net_destination: Option<OrdBound<NetDestination>>,
143 pub net_payload: Option<OrdBound<NetPayload>>,
144 pub execution_trust: Option<OrdBound<ExecutionTrust>>,
145 pub supply_source: Option<Vec<SupplySource>>,
146 pub pinning: Option<OrdBound<Pinning>>,
147 pub exec_surface: Option<Vec<ExecSurface>>,
148 pub cost: Option<OrdBound<Cost>>,
149}
150
151impl Clause {
152 pub fn admits(&self, cap: &Capability) -> bool {
154 self.check(cap, Role::Allow)
155 }
156
157 fn matches_as_deny(&self, cap: &Capability) -> bool {
164 self.check(cap, Role::Deny)
165 }
166
167 fn check(&self, cap: &Capability, role: Role) -> bool {
168 self.first_mismatch(cap, role).is_none()
169 }
170
171 #[cfg(test)]
184 pub(crate) fn first_mismatch_for_test(
185 &self,
186 cap: &Capability,
187 deny_role: bool,
188 ) -> Option<FacetMismatch> {
189 self.first_mismatch(cap, if deny_role { Role::Deny } else { Role::Allow })
190 }
191
192 #[cfg(test)]
193 pub(crate) fn matches_as_deny_for_test(&self, cap: &Capability) -> bool {
194 self.matches_as_deny(cap)
195 }
196
197 fn first_mismatch(&self, cap: &Capability, role: Role) -> Option<FacetMismatch> {
198 set_mismatch("operation", self.operation.as_deref(), cap.operation)
199 .or_else(|| ord_mismatch("locus.local", self.local_locus, cap.locus.local))
200 .or_else(|| ord_mismatch("locus.remote", self.remote_reach, cap.locus.remote))
201 .or_else(|| set_mismatch("locus.binding", self.remote_binding.as_deref(), cap.locus.binding))
202 .or_else(|| ord_mismatch("locus.provenance", self.provenance, cap.locus.provenance))
203 .or_else(|| ord_mismatch("scale", self.scale, cap.scale))
204 .or_else(|| ord_mismatch("retrieval", self.retrieval, cap.retrieval))
205 .or_else(|| ord_mismatch("authority", self.authority, cap.authority))
206 .or_else(|| ord_mismatch("isolation", self.isolation, cap.isolation))
207 .or_else(|| ord_mismatch("reversibility", self.reversibility, cap.reversibility))
208 .or_else(|| ord_mismatch("persistence.level", self.persistence_level, cap.persistence.level))
209 .or_else(|| ord_mismatch("persistence.trigger.escape", self.trigger_escape, cap.persistence.trigger.escape))
210 .or_else(|| set_mismatch("persistence.trigger.kind", self.trigger_kind.as_deref(), cap.persistence.trigger.kind))
211 .or_else(|| ord_mismatch("disclosure.audience", self.disclosure_audience, cap.disclosure.audience))
212 .or_else(|| set_mismatch("disclosure.channel", self.disclosure_channel.as_deref(), cap.disclosure.channel))
213 .or_else(|| set_mismatch("disclosure.principal", self.disclosure_principal.as_deref(), cap.disclosure.principal))
214 .or_else(|| ord_mismatch("secret.level", self.secret_level, cap.secret.level))
215 .or_else(|| set_mismatch("secret.channel", self.secret_channel.as_deref(), cap.secret.channel))
216 .or_else(|| set_mismatch("secret.principal", self.secret_principal.as_deref(), cap.secret.principal))
217 .or_else(|| ord_mismatch("network.direction", self.net_direction, cap.network.direction))
218 .or_else(|| ord_mismatch("network.destination", self.net_destination, cap.network.destination))
219 .or_else(|| ord_mismatch("network.payload", self.net_payload, cap.network.payload))
220 .or_else(|| ord_mismatch("execution.trust", self.execution_trust, cap.execution.trust))
221 .or_else(|| self.supply_chain_mismatch(cap.execution.supply_chain, role))
222 .or_else(|| ord_mismatch("cost", self.cost, cap.cost))
223 }
224
225 fn supply_chain_mismatch(&self, sc: Option<SupplyChain>, role: Role) -> Option<FacetMismatch> {
230 if self.supply_chain_admits(sc, role) {
231 return None;
232 }
233 let Some(sc) = sc else {
234 return Some(FacetMismatch {
238 facet: "execution.supply_chain",
239 actual: "absent",
240 bound: "this clause constrains the supply chain, which this capability has none of"
241 .to_string(),
242 admits_count: 0,
243 });
244 };
245 set_mismatch("supply_chain.source", self.supply_source.as_deref(), sc.source)
246 .or_else(|| ord_mismatch("supply_chain.pinning", self.pinning, sc.pinning))
247 .or_else(|| set_mismatch("supply_chain.exec_surface", self.exec_surface.as_deref(), sc.exec_surface))
248 }
249
250 fn supply_chain_admits(&self, sc: Option<SupplyChain>, role: Role) -> bool {
251 match sc {
252 None => match role {
253 Role::Allow => true,
254 Role::Deny => {
255 self.supply_source.is_none()
256 && self.pinning.is_none()
257 && self.exec_surface.is_none()
258 }
259 },
260 Some(sc) => {
261 set_admits(self.supply_source.as_deref(), sc.source)
262 && ord_admits(self.pinning, sc.pinning)
263 && set_admits(self.exec_surface.as_deref(), sc.exec_surface)
264 }
265 }
266 }
267}
268
269#[derive(Copy, Clone, PartialEq, Eq)]
271enum Role {
272 Allow,
273 Deny,
274}
275
276#[derive(Clone, Debug, Default, PartialEq, Eq)]
279pub struct Level {
280 pub name: String,
281 pub allow: Vec<Clause>,
282 pub deny: Vec<Clause>,
283}
284
285impl Level {
286 pub fn new(name: impl Into<String>) -> Self {
289 Self { name: name.into(), allow: Vec::new(), deny: Vec::new() }
290 }
291
292 #[must_use]
294 pub fn allowing(mut self, clause: Clause) -> Self {
295 self.allow.push(clause);
296 self
297 }
298
299 #[must_use]
301 pub fn denying(mut self, clause: Clause) -> Self {
302 self.deny.push(clause);
303 self
304 }
305
306 pub fn nearest_miss(&self, cap: &Capability) -> Option<FacetMismatch> {
317 if self.allow.iter().any(|c| c.admits(cap)) {
318 return self.deny.iter().find(|c| c.matches_as_deny(cap)).map(|_| FacetMismatch {
320 facet: "deny-clause",
321 actual: "matched",
322 bound: "removed by an explicit deny clause".to_string(),
323 admits_count: 0,
324 });
325 }
326 if self.allow.is_empty() {
327 return Some(FacetMismatch {
328 facet: "level",
329 actual: "any capability",
330 bound: "nothing — this level declares no allow clause".to_string(),
331 admits_count: 0,
332 });
333 }
334 let on_topic = |c: &&Clause| {
335 c.operation.as_ref().is_none_or(|ops| ops.contains(&cap.operation))
336 };
337 let mut candidates: Vec<FacetMismatch> = self
342 .allow
343 .iter()
344 .filter(on_topic)
345 .filter_map(|c| c.first_mismatch(cap, Role::Allow))
346 .collect();
347 if candidates.is_empty() {
348 candidates = self
349 .allow
350 .iter()
351 .filter_map(|c| c.first_mismatch(cap, Role::Allow))
352 .collect();
353 }
354 candidates.into_iter().max_by_key(|m| m.admits_count)
355 }
356
357 pub fn admits_capability(&self, cap: &Capability) -> bool {
358 self.allow.iter().any(|c| c.admits(cap)) && !self.deny.iter().any(|c| c.matches_as_deny(cap))
359 }
360
361 pub fn admits(&self, profile: &Profile) -> bool {
364 profile.capabilities.iter().all(|c| self.admits_capability(c))
365 }
366
367 #[must_use]
373 pub fn extend(base: &Level, name: impl Into<String>, extra_allow: Vec<Clause>) -> Level {
374 let mut allow = base.allow.clone();
375 allow.extend(extra_allow);
376 Level { name: name.into(), allow, deny: base.deny.clone() }
377 }
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383 use proptest::prelude::*;
384
385 fn cap(op: Operation) -> Capability {
386 Capability::new(op)
387 }
388
389 #[test]
390 fn empty_clause_admits_everything_empty_allow_admits_nothing() {
391 let all = Level::new("all").allowing(Clause::default());
392 let nothing = Level::new("nothing");
393 let destroy = Profile::of(vec![cap(Operation::Destroy)]);
394 assert!(all.admits(&destroy));
395 assert!(!nothing.admits(&destroy));
396 assert!(all.admits(&Profile::default()));
398 assert!(nothing.admits(&Profile::default()));
399 }
400
401 #[test]
407 fn nearest_miss_reports_the_clause_that_came_closest_not_the_first() {
408 let strict = Clause {
409 operation: Some(vec![Operation::Observe]),
410 local_locus: Some(OrdBound::at_most(LocalLocus::Temp)),
411 ..Default::default()
412 };
413 let loose = Clause {
414 operation: Some(vec![Operation::Observe]),
415 local_locus: Some(OrdBound::at_most(LocalLocus::WorktreeTrusted)),
416 ..Default::default()
417 };
418 let level = Level::new("two-reads").allowing(strict).allowing(loose);
420 let mut cap = Capability::new(Operation::Observe);
421 cap.locus.local = LocalLocus::Machine;
422
423 let m = level.nearest_miss(&cap).expect("machine locus exceeds both clauses");
424 assert_eq!(m.facet, "locus.local");
425 assert!(
426 m.bound.contains("worktree-trusted"),
427 "named the strictest clause (`{}`); the closest one allows <= worktree-trusted",
428 m.bound,
429 );
430 }
431
432 #[test]
433 fn read_local_admits_a_read_rejects_a_secret_and_a_destroy() {
434 let read_local = Level::new("read-local").allowing(Clause {
435 operation: Some(vec![Operation::Observe]),
436 local_locus: Some(OrdBound::at_most(LocalLocus::User)),
437 secret_level: Some(OrdBound::at_most(SecretLevel::UsesAmbient)),
438 net_direction: Some(OrdBound::at_most(NetDirection::Loopback)),
439 ..Default::default()
440 });
441
442 let plain_read = Profile::of(vec![{
443 let mut c = cap(Operation::Observe);
444 c.locus.local = LocalLocus::Worktree;
445 c
446 }]);
447 assert!(read_local.admits(&plain_read));
448
449 let secret_read = Profile::of(vec![{
450 let mut c = cap(Operation::Observe);
451 c.locus.local = LocalLocus::User;
452 c.secret.level = SecretLevel::Reads;
453 c
454 }]);
455 assert!(!read_local.admits(&secret_read), "cat ~/.ssh/id_rsa must not pass read-local");
456
457 let destroy = Profile::of(vec![cap(Operation::Destroy)]);
458 assert!(!read_local.admits(&destroy));
459 }
460
461 #[test]
462 fn yolo_deny_carves_out_the_catastrophe_corner() {
463 let yolo = Level::new("yolo").allowing(Clause::default()).denying(Clause {
464 operation: Some(vec![Operation::Destroy]),
465 reversibility: Some(OrdBound::at_least(Reversibility::Irreversible)),
466 scale: Some(OrdBound::at_least(Scale::Unbounded)),
467 ..Default::default()
468 });
469
470 let bounded = Profile::of(vec![{
472 let mut c = cap(Operation::Destroy);
473 c.scale = Scale::Bounded;
474 c.reversibility = Reversibility::Recoverable;
475 c
476 }]);
477 assert!(yolo.admits(&bounded));
478
479 let wipe = Profile::of(vec![{
481 let mut c = cap(Operation::Destroy);
482 c.scale = Scale::Unbounded;
483 c.reversibility = Reversibility::Irreversible;
484 c
485 }]);
486 assert!(!yolo.admits(&wipe));
487 }
488
489 #[test]
490 fn supply_chain_gates_network_sourced_code_and_is_vacuous_otherwise() {
491 let dev = Level::new("dev").allowing(Clause {
494 execution_trust: Some(OrdBound::at_most(ExecutionTrust::NetworkSourced)),
495 supply_source: Some(vec![
496 SupplySource::PublicRegistry,
497 SupplySource::SignedRepo,
498 SupplySource::PrivateRegistry,
499 SupplySource::Vendored,
500 ]),
501 ..Default::default()
502 });
503
504 let build = Profile::of(vec![{
506 let mut c = cap(Operation::Execute);
507 c.execution = Execution {
508 trust: ExecutionTrust::NetworkSourced,
509 supply_chain: Some(SupplyChain {
510 source: SupplySource::PublicRegistry,
511 pinning: Pinning::HashVerified,
512 exec_surface: ExecSurface::BuildScript,
513 }),
514 };
515 c
516 }]);
517 assert!(dev.admits(&build), "cargo build from a registry");
518
519 let curl_sh = Profile::of(vec![{
521 let mut c = cap(Operation::Execute);
522 c.execution = Execution {
523 trust: ExecutionTrust::NetworkSourced,
524 supply_chain: Some(SupplyChain {
525 source: SupplySource::UnverifiedUrl,
526 pinning: Pinning::Floating,
527 exec_surface: ExecSurface::RunArtifact,
528 }),
529 };
530 c
531 }]);
532 assert!(!dev.admits(&curl_sh), "curl | sh is an unverified-url source");
533
534 let plain = Profile::of(vec![cap(Operation::Observe)]);
536 assert!(dev.admits(&plain), "a command with no supply chain passes vacuously");
537 }
538
539 #[test]
540 fn a_supply_chain_deny_does_not_match_a_capability_without_one() {
541 let level = Level::new("x").allowing(Clause::default()).denying(Clause {
544 supply_source: Some(vec![SupplySource::UnverifiedUrl]),
545 ..Default::default()
546 });
547
548 let plain = Profile::of(vec![cap(Operation::Observe)]);
549 assert!(level.admits(&plain), "a supply-chain deny must not match a no-supply-chain cap");
550
551 let curl_sh = Profile::of(vec![{
553 let mut c = cap(Operation::Execute);
554 c.execution = Execution {
555 trust: ExecutionTrust::NetworkSourced,
556 supply_chain: Some(SupplyChain {
557 source: SupplySource::UnverifiedUrl,
558 pinning: Pinning::Floating,
559 exec_surface: ExecSurface::RunArtifact,
560 }),
561 };
562 c
563 }]);
564 assert!(!level.admits(&curl_sh), "the deny corner still catches the unverified-url source");
565 }
566
567 #[test]
568 fn extend_inherits_deny_and_adds_allow() {
569 let base = Level::new("base")
570 .allowing(Clause { operation: Some(vec![Operation::Observe]), ..Default::default() })
571 .denying(Clause {
572 local_locus: Some(OrdBound::at_least(LocalLocus::Device)),
573 ..Default::default()
574 });
575 let child = Level::extend(
576 &base,
577 "child",
578 vec![Clause {
579 operation: Some(vec![Operation::Create, Operation::Mutate]),
580 ..Default::default()
581 }],
582 );
583
584 assert!(child.admits(&Profile::of(vec![cap(Operation::Create)])), "added allow");
585 assert!(child.admits(&Profile::of(vec![cap(Operation::Observe)])), "inherited allow");
586
587 let device = Profile::of(vec![{
588 let mut c = cap(Operation::Mutate);
589 c.locus.local = LocalLocus::Device;
590 c
591 }]);
592 assert!(!child.admits(&device), "inherited deny still bites");
593 }
594
595 use crate::engine::testgen::{arb_clause, arb_level, arb_profile};
599
600 proptest! {
601 #[test]
604 fn totality(level in arb_level(), profile in arb_profile()) {
605 let first = level.admits(&profile);
606 prop_assert_eq!(first, level.admits(&profile));
607 }
608
609 #[test]
612 fn extends_is_a_superset(
613 base in arb_level(),
614 extra in prop::collection::vec(arb_clause(), 0..3),
615 profile in arb_profile(),
616 ) {
617 let extended = Level::extend(&base, "child", extra);
618 prop_assert!(!base.admits(&profile) || extended.admits(&profile));
619 }
620
621 #[test]
624 fn deny_only_shrinks(
625 level in arb_level(),
626 extra_deny in arb_clause(),
627 profile in arb_profile(),
628 ) {
629 let stricter = level.clone().denying(extra_deny);
630 prop_assert!(!stricter.admits(&profile) || level.admits(&profile));
631 }
632 }
633}