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>(facet: &'static str, bound: Option<OrdBound<T>>, term: T) -> Option<FacetMismatch> {
82 let b = bound?;
83 if b.admits(term) {
84 return None;
85 }
86 let bound = match (b.min, b.max) {
87 (None, Some(hi)) => format!("<= {}", hi.as_str()),
88 (Some(lo), None) => format!(">= {}", lo.as_str()),
89 (Some(lo), Some(hi)) => format!("{}..={}", lo.as_str(), hi.as_str()),
90 (None, None) => "any".to_string(),
91 };
92 let admits_count = T::all().iter().filter(|t| b.admits(**t)).count();
93 Some(FacetMismatch { facet, actual: term.as_str(), bound, admits_count })
94}
95
96fn set_mismatch<T: PartialEq + Copy + FacetTerm>(facet: &'static str, set: Option<&[T]>, term: T) -> Option<FacetMismatch> {
97 let s = set?;
98 if s.contains(&term) {
99 return None;
100 }
101 let bound = format!("one of [{}]", s.iter().map(|t| t.as_str()).collect::<Vec<_>>().join(", "),);
102 Some(FacetMismatch { facet, actual: term.as_str(), bound, admits_count: s.len() })
103}
104
105#[derive(Clone, Debug, Default, PartialEq, Eq)]
110pub struct Clause {
111 pub operation: Option<Vec<Operation>>,
112 pub local_locus: Option<OrdBound<LocalLocus>>,
113 pub remote_reach: Option<OrdBound<RemoteReach>>,
114 pub remote_binding: Option<Vec<RemoteBinding>>,
115 pub provenance: Option<OrdBound<Provenance>>,
116 pub scale: Option<OrdBound<Scale>>,
117 pub retrieval: Option<OrdBound<RetrievalGranularity>>,
118 pub authority: Option<OrdBound<Authority>>,
119 pub isolation: Option<OrdBound<Isolation>>,
120 pub reversibility: Option<OrdBound<Reversibility>>,
121 pub persistence_level: Option<OrdBound<PersistenceLevel>>,
122 pub trigger_escape: Option<OrdBound<TriggerEscape>>,
123 pub trigger_kind: Option<Vec<TriggerKind>>,
124 pub disclosure_audience: Option<OrdBound<DisclosureAudience>>,
125 pub disclosure_channel: Option<Vec<Channel>>,
126 pub disclosure_principal: Option<Vec<Principal>>,
127 pub secret_level: Option<OrdBound<SecretLevel>>,
128 pub secret_channel: Option<Vec<Channel>>,
129 pub secret_principal: Option<Vec<Principal>>,
130 pub net_direction: Option<OrdBound<NetDirection>>,
131 pub net_destination: Option<OrdBound<NetDestination>>,
132 pub net_payload: Option<OrdBound<NetPayload>>,
133 pub execution_trust: Option<OrdBound<ExecutionTrust>>,
134 pub supply_source: Option<Vec<SupplySource>>,
135 pub pinning: Option<OrdBound<Pinning>>,
136 pub exec_surface: Option<Vec<ExecSurface>>,
137 pub cost: Option<OrdBound<Cost>>,
138}
139
140impl Clause {
141 pub fn admits(&self, cap: &Capability) -> bool {
143 self.check(cap, Role::Allow)
144 }
145
146 fn matches_as_deny(&self, cap: &Capability) -> bool {
153 self.check(cap, Role::Deny)
154 }
155
156 fn check(&self, cap: &Capability, role: Role) -> bool {
157 self.first_mismatch(cap, role).is_none()
158 }
159
160 #[cfg(test)]
173 pub(crate) fn first_mismatch_for_test(&self, cap: &Capability, deny_role: bool) -> Option<FacetMismatch> {
174 self.first_mismatch(cap, if deny_role { Role::Deny } else { Role::Allow })
175 }
176
177 #[cfg(test)]
178 pub(crate) fn matches_as_deny_for_test(&self, cap: &Capability) -> bool {
179 self.matches_as_deny(cap)
180 }
181
182 fn first_mismatch(&self, cap: &Capability, role: Role) -> Option<FacetMismatch> {
183 set_mismatch("operation", self.operation.as_deref(), cap.operation)
184 .or_else(|| ord_mismatch("locus.local", self.local_locus, cap.locus.local))
185 .or_else(|| ord_mismatch("locus.remote", self.remote_reach, cap.locus.remote))
186 .or_else(|| set_mismatch("locus.binding", self.remote_binding.as_deref(), cap.locus.binding))
187 .or_else(|| ord_mismatch("locus.provenance", self.provenance, cap.locus.provenance))
188 .or_else(|| ord_mismatch("scale", self.scale, cap.scale))
189 .or_else(|| ord_mismatch("retrieval", self.retrieval, cap.retrieval))
190 .or_else(|| ord_mismatch("authority", self.authority, cap.authority))
191 .or_else(|| ord_mismatch("isolation", self.isolation, cap.isolation))
192 .or_else(|| ord_mismatch("reversibility", self.reversibility, cap.reversibility))
193 .or_else(|| ord_mismatch("persistence.level", self.persistence_level, cap.persistence.level))
194 .or_else(|| ord_mismatch("persistence.trigger.escape", self.trigger_escape, cap.persistence.trigger.escape))
195 .or_else(|| set_mismatch("persistence.trigger.kind", self.trigger_kind.as_deref(), cap.persistence.trigger.kind))
196 .or_else(|| ord_mismatch("disclosure.audience", self.disclosure_audience, cap.disclosure.audience))
197 .or_else(|| set_mismatch("disclosure.channel", self.disclosure_channel.as_deref(), cap.disclosure.channel))
198 .or_else(|| set_mismatch("disclosure.principal", self.disclosure_principal.as_deref(), cap.disclosure.principal))
199 .or_else(|| ord_mismatch("secret.level", self.secret_level, cap.secret.level))
200 .or_else(|| set_mismatch("secret.channel", self.secret_channel.as_deref(), cap.secret.channel))
201 .or_else(|| set_mismatch("secret.principal", self.secret_principal.as_deref(), cap.secret.principal))
202 .or_else(|| ord_mismatch("network.direction", self.net_direction, cap.network.direction))
203 .or_else(|| ord_mismatch("network.destination", self.net_destination, cap.network.destination))
204 .or_else(|| ord_mismatch("network.payload", self.net_payload, cap.network.payload))
205 .or_else(|| ord_mismatch("execution.trust", self.execution_trust, cap.execution.trust))
206 .or_else(|| self.supply_chain_mismatch(cap.execution.supply_chain, role))
207 .or_else(|| ord_mismatch("cost", self.cost, cap.cost))
208 }
209
210 fn supply_chain_mismatch(&self, sc: Option<SupplyChain>, role: Role) -> Option<FacetMismatch> {
215 if self.supply_chain_admits(sc, role) {
216 return None;
217 }
218 let Some(sc) = sc else {
219 return Some(FacetMismatch {
223 facet: "execution.supply_chain",
224 actual: "absent",
225 bound: "this clause constrains the supply chain, which this capability has none of".to_string(),
226 admits_count: 0,
227 });
228 };
229 set_mismatch("supply_chain.source", self.supply_source.as_deref(), sc.source)
230 .or_else(|| ord_mismatch("supply_chain.pinning", self.pinning, sc.pinning))
231 .or_else(|| set_mismatch("supply_chain.exec_surface", self.exec_surface.as_deref(), sc.exec_surface))
232 }
233
234 fn supply_chain_admits(&self, sc: Option<SupplyChain>, role: Role) -> bool {
235 match sc {
236 None => match role {
237 Role::Allow => true,
238 Role::Deny => self.supply_source.is_none() && self.pinning.is_none() && self.exec_surface.is_none(),
239 },
240 Some(sc) => {
241 set_admits(self.supply_source.as_deref(), sc.source)
242 && ord_admits(self.pinning, sc.pinning)
243 && set_admits(self.exec_surface.as_deref(), sc.exec_surface)
244 }
245 }
246 }
247}
248
249#[derive(Copy, Clone, PartialEq, Eq)]
251enum Role {
252 Allow,
253 Deny,
254}
255
256#[derive(Clone, Debug, Default, PartialEq, Eq)]
259pub struct Level {
260 pub name: String,
261 pub allow: Vec<Clause>,
262 pub deny: Vec<Clause>,
263}
264
265impl Level {
266 pub fn new(name: impl Into<String>) -> Self {
269 Self { name: name.into(), allow: Vec::new(), deny: Vec::new() }
270 }
271
272 #[must_use]
274 pub fn allowing(mut self, clause: Clause) -> Self {
275 self.allow.push(clause);
276 self
277 }
278
279 #[must_use]
281 pub fn denying(mut self, clause: Clause) -> Self {
282 self.deny.push(clause);
283 self
284 }
285
286 pub fn nearest_miss(&self, cap: &Capability) -> Option<FacetMismatch> {
297 if self.allow.iter().any(|c| c.admits(cap)) {
298 return self.deny.iter().find(|c| c.matches_as_deny(cap)).map(|_| FacetMismatch {
300 facet: "deny-clause",
301 actual: "matched",
302 bound: "removed by an explicit deny clause".to_string(),
303 admits_count: 0,
304 });
305 }
306 if self.allow.is_empty() {
307 return Some(FacetMismatch {
308 facet: "level",
309 actual: "any capability",
310 bound: "nothing — this level declares no allow clause".to_string(),
311 admits_count: 0,
312 });
313 }
314 let on_topic = |c: &&Clause| c.operation.as_ref().is_none_or(|ops| ops.contains(&cap.operation));
315 let mut candidates: Vec<FacetMismatch> =
320 self.allow.iter().filter(on_topic).filter_map(|c| c.first_mismatch(cap, Role::Allow)).collect();
321 if candidates.is_empty() {
322 candidates = self.allow.iter().filter_map(|c| c.first_mismatch(cap, Role::Allow)).collect();
323 }
324 candidates.into_iter().max_by_key(|m| m.admits_count)
325 }
326
327 pub fn admits_capability(&self, cap: &Capability) -> bool {
328 self.allow.iter().any(|c| c.admits(cap)) && !self.deny.iter().any(|c| c.matches_as_deny(cap))
329 }
330
331 pub fn admits(&self, profile: &Profile) -> bool {
334 profile.capabilities.iter().all(|c| self.admits_capability(c))
335 }
336
337 #[must_use]
343 pub fn extend(base: &Level, name: impl Into<String>, extra_allow: Vec<Clause>) -> Level {
344 let mut allow = base.allow.clone();
345 allow.extend(extra_allow);
346 Level { name: name.into(), allow, deny: base.deny.clone() }
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use proptest::prelude::*;
354
355 fn cap(op: Operation) -> Capability {
356 Capability::new(op)
357 }
358
359 #[test]
360 fn empty_clause_admits_everything_empty_allow_admits_nothing() {
361 let all = Level::new("all").allowing(Clause::default());
362 let nothing = Level::new("nothing");
363 let destroy = Profile::of(vec![cap(Operation::Destroy)]);
364 assert!(all.admits(&destroy));
365 assert!(!nothing.admits(&destroy));
366 assert!(all.admits(&Profile::default()));
368 assert!(nothing.admits(&Profile::default()));
369 }
370
371 #[test]
377 fn nearest_miss_reports_the_clause_that_came_closest_not_the_first() {
378 let strict = Clause {
379 operation: Some(vec![Operation::Observe]),
380 local_locus: Some(OrdBound::at_most(LocalLocus::Temp)),
381 ..Default::default()
382 };
383 let loose = Clause {
384 operation: Some(vec![Operation::Observe]),
385 local_locus: Some(OrdBound::at_most(LocalLocus::WorktreeTrusted)),
386 ..Default::default()
387 };
388 let level = Level::new("two-reads").allowing(strict).allowing(loose);
390 let mut cap = Capability::new(Operation::Observe);
391 cap.locus.local = LocalLocus::Machine;
392
393 let m = level.nearest_miss(&cap).expect("machine locus exceeds both clauses");
394 assert_eq!(m.facet, "locus.local");
395 assert!(
396 m.bound.contains("worktree-trusted"),
397 "named the strictest clause (`{}`); the closest one allows <= worktree-trusted",
398 m.bound,
399 );
400 }
401
402 #[test]
403 fn read_local_admits_a_read_rejects_a_secret_and_a_destroy() {
404 let read_local = Level::new("read-local").allowing(Clause {
405 operation: Some(vec![Operation::Observe]),
406 local_locus: Some(OrdBound::at_most(LocalLocus::User)),
407 secret_level: Some(OrdBound::at_most(SecretLevel::UsesAmbient)),
408 net_direction: Some(OrdBound::at_most(NetDirection::Loopback)),
409 ..Default::default()
410 });
411
412 let plain_read = Profile::of(vec![{
413 let mut c = cap(Operation::Observe);
414 c.locus.local = LocalLocus::Worktree;
415 c
416 }]);
417 assert!(read_local.admits(&plain_read));
418
419 let secret_read = Profile::of(vec![{
420 let mut c = cap(Operation::Observe);
421 c.locus.local = LocalLocus::User;
422 c.secret.level = SecretLevel::Reads;
423 c
424 }]);
425 assert!(!read_local.admits(&secret_read), "cat ~/.ssh/id_rsa must not pass read-local");
426
427 let destroy = Profile::of(vec![cap(Operation::Destroy)]);
428 assert!(!read_local.admits(&destroy));
429 }
430
431 #[test]
432 fn yolo_deny_carves_out_the_catastrophe_corner() {
433 let yolo = Level::new("yolo").allowing(Clause::default()).denying(Clause {
434 operation: Some(vec![Operation::Destroy]),
435 reversibility: Some(OrdBound::at_least(Reversibility::Irreversible)),
436 scale: Some(OrdBound::at_least(Scale::Unbounded)),
437 ..Default::default()
438 });
439
440 let bounded = Profile::of(vec![{
442 let mut c = cap(Operation::Destroy);
443 c.scale = Scale::Bounded;
444 c.reversibility = Reversibility::Recoverable;
445 c
446 }]);
447 assert!(yolo.admits(&bounded));
448
449 let wipe = Profile::of(vec![{
451 let mut c = cap(Operation::Destroy);
452 c.scale = Scale::Unbounded;
453 c.reversibility = Reversibility::Irreversible;
454 c
455 }]);
456 assert!(!yolo.admits(&wipe));
457 }
458
459 #[test]
460 fn supply_chain_gates_network_sourced_code_and_is_vacuous_otherwise() {
461 let dev = Level::new("dev").allowing(Clause {
464 execution_trust: Some(OrdBound::at_most(ExecutionTrust::NetworkSourced)),
465 supply_source: Some(vec![
466 SupplySource::PublicRegistry,
467 SupplySource::SignedRepo,
468 SupplySource::PrivateRegistry,
469 SupplySource::Vendored,
470 ]),
471 ..Default::default()
472 });
473
474 let build = Profile::of(vec![{
476 let mut c = cap(Operation::Execute);
477 c.execution = Execution {
478 trust: ExecutionTrust::NetworkSourced,
479 supply_chain: Some(SupplyChain {
480 source: SupplySource::PublicRegistry,
481 pinning: Pinning::HashVerified,
482 exec_surface: ExecSurface::BuildScript,
483 }),
484 };
485 c
486 }]);
487 assert!(dev.admits(&build), "cargo build from a registry");
488
489 let curl_sh = Profile::of(vec![{
491 let mut c = cap(Operation::Execute);
492 c.execution = Execution {
493 trust: ExecutionTrust::NetworkSourced,
494 supply_chain: Some(SupplyChain {
495 source: SupplySource::UnverifiedUrl,
496 pinning: Pinning::Floating,
497 exec_surface: ExecSurface::RunArtifact,
498 }),
499 };
500 c
501 }]);
502 assert!(!dev.admits(&curl_sh), "curl | sh is an unverified-url source");
503
504 let plain = Profile::of(vec![cap(Operation::Observe)]);
506 assert!(dev.admits(&plain), "a command with no supply chain passes vacuously");
507 }
508
509 #[test]
510 fn a_supply_chain_deny_does_not_match_a_capability_without_one() {
511 let level = Level::new("x")
514 .allowing(Clause::default())
515 .denying(Clause { supply_source: Some(vec![SupplySource::UnverifiedUrl]), ..Default::default() });
516
517 let plain = Profile::of(vec![cap(Operation::Observe)]);
518 assert!(level.admits(&plain), "a supply-chain deny must not match a no-supply-chain cap");
519
520 let curl_sh = Profile::of(vec![{
522 let mut c = cap(Operation::Execute);
523 c.execution = Execution {
524 trust: ExecutionTrust::NetworkSourced,
525 supply_chain: Some(SupplyChain {
526 source: SupplySource::UnverifiedUrl,
527 pinning: Pinning::Floating,
528 exec_surface: ExecSurface::RunArtifact,
529 }),
530 };
531 c
532 }]);
533 assert!(!level.admits(&curl_sh), "the deny corner still catches the unverified-url source");
534 }
535
536 #[test]
537 fn extend_inherits_deny_and_adds_allow() {
538 let base = Level::new("base")
539 .allowing(Clause { operation: Some(vec![Operation::Observe]), ..Default::default() })
540 .denying(Clause { local_locus: Some(OrdBound::at_least(LocalLocus::Device)), ..Default::default() });
541 let child = Level::extend(
542 &base,
543 "child",
544 vec![Clause { operation: Some(vec![Operation::Create, Operation::Mutate]), ..Default::default() }],
545 );
546
547 assert!(child.admits(&Profile::of(vec![cap(Operation::Create)])), "added allow");
548 assert!(child.admits(&Profile::of(vec![cap(Operation::Observe)])), "inherited allow");
549
550 let device = Profile::of(vec![{
551 let mut c = cap(Operation::Mutate);
552 c.locus.local = LocalLocus::Device;
553 c
554 }]);
555 assert!(!child.admits(&device), "inherited deny still bites");
556 }
557
558 use crate::engine::testgen::{arb_clause, arb_level, arb_profile};
562
563 proptest! {
564 #[test]
567 fn totality(level in arb_level(), profile in arb_profile()) {
568 let first = level.admits(&profile);
569 prop_assert_eq!(first, level.admits(&profile));
570 }
571
572 #[test]
575 fn extends_is_a_superset(
576 base in arb_level(),
577 extra in prop::collection::vec(arb_clause(), 0..3),
578 profile in arb_profile(),
579 ) {
580 let extended = Level::extend(&base, "child", extra);
581 prop_assert!(!base.admits(&profile) || extended.admits(&profile));
582 }
583
584 #[test]
587 fn deny_only_shrinks(
588 level in arb_level(),
589 extra_deny in arb_clause(),
590 profile in arb_profile(),
591 ) {
592 let stricter = level.clone().denying(extra_deny);
593 prop_assert!(!stricter.admits(&profile) || level.admits(&profile));
594 }
595 }
596}