Skip to main content

safe_chains/engine/
level.rs

1//! Safety levels as predicates over profiles (v1.4 §4.1).
2//!
3//! A [`Level`] is a disjunction of allow [`Clause`]s plus an allow-only set of deny
4//! clauses (the `yolo` subtractive primitive, §4.1). A [`Clause`] is a conjunction of
5//! per-facet bounds — an ordinal ceiling/floor ([`OrdBound`]) or a categorical set —
6//! and an omitted facet is unconstrained. A capability is admissible iff **some**
7//! allow clause admits every one of its facets and **no** deny clause matches it; a
8//! profile passes iff every capability is admissible.
9//!
10//! Nothing here parses TOML or ships a default level yet — this is the algebra and
11//! its contract. Level authoring (TOML → `Level`) and the default set arrive next.
12
13use super::facet::*;
14
15/// An ordinal bound: `min ≤ term ≤ max`, either side optional. `at_most` is a
16/// ceiling (the common risk-facet form); `at_least` a floor (trust facets like
17/// pinning). An omitted side is unconstrained.
18#[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    /// `term ≤ ceiling`.
26    pub fn at_most(ceiling: T) -> Self {
27        Self { min: None, max: Some(ceiling) }
28    }
29    /// `term ≥ floor`.
30    pub fn at_least(floor: T) -> Self {
31        Self { min: Some(floor), max: None }
32    }
33    /// `term == exact`.
34    pub fn exactly(exact: T) -> Self {
35        Self { min: Some(exact), max: Some(exact) }
36    }
37    /// Whether `term` falls within the bound.
38    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/// A conjunction of per-facet constraints. A default (all-`None`) clause admits every
52/// capability. Each ordinal facet takes an [`OrdBound`]; each categorical facet an
53/// allowed set. Fields are flattened per axis — a compound facet is never a single
54/// constraint (the R25 discipline).
55#[derive(Clone, Debug, Default, PartialEq, Eq)]
56pub struct Clause {
57    pub operation: Option<Vec<Operation>>,
58    pub local_locus: Option<OrdBound<LocalLocus>>,
59    pub remote_reach: Option<OrdBound<RemoteReach>>,
60    pub remote_binding: Option<Vec<RemoteBinding>>,
61    pub provenance: Option<OrdBound<Provenance>>,
62    pub scale: Option<OrdBound<Scale>>,
63    pub retrieval: Option<OrdBound<RetrievalGranularity>>,
64    pub authority: Option<OrdBound<Authority>>,
65    pub isolation: Option<OrdBound<Isolation>>,
66    pub reversibility: Option<OrdBound<Reversibility>>,
67    pub persistence_level: Option<OrdBound<PersistenceLevel>>,
68    pub trigger_escape: Option<OrdBound<TriggerEscape>>,
69    pub trigger_kind: Option<Vec<TriggerKind>>,
70    pub disclosure_audience: Option<OrdBound<DisclosureAudience>>,
71    pub disclosure_channel: Option<Vec<Channel>>,
72    pub disclosure_principal: Option<Vec<Principal>>,
73    pub secret_level: Option<OrdBound<SecretLevel>>,
74    pub secret_channel: Option<Vec<Channel>>,
75    pub secret_principal: Option<Vec<Principal>>,
76    pub net_direction: Option<OrdBound<NetDirection>>,
77    pub net_destination: Option<OrdBound<NetDestination>>,
78    pub net_payload: Option<OrdBound<NetPayload>>,
79    pub execution_trust: Option<OrdBound<ExecutionTrust>>,
80    pub supply_source: Option<Vec<SupplySource>>,
81    pub pinning: Option<OrdBound<Pinning>>,
82    pub exec_surface: Option<Vec<ExecSurface>>,
83    pub cost: Option<OrdBound<Cost>>,
84}
85
86impl Clause {
87    /// Whether this clause **admits** `cap` (allow-clause semantics).
88    pub fn admits(&self, cap: &Capability) -> bool {
89        self.check(cap, Role::Allow)
90    }
91
92    /// Whether this clause **matches** `cap` for removal (deny-clause semantics). Differs
93    /// from `admits` only on the optional `supply_chain`: a deny constrained on a
94    /// supply-chain facet does **not** match a capability that has no supply chain (it is
95    /// not in the denied corner), whereas an allow treats the absence as vacuously
96    /// satisfied. Sharing one body with a naive vacuous-true rule would make a
97    /// supply-chain-only deny wrongly match every non-supply-chain capability.
98    fn matches_as_deny(&self, cap: &Capability) -> bool {
99        self.check(cap, Role::Deny)
100    }
101
102    fn check(&self, cap: &Capability, role: Role) -> bool {
103        set_admits(self.operation.as_deref(), cap.operation)
104            && ord_admits(self.local_locus, cap.locus.local)
105            && ord_admits(self.remote_reach, cap.locus.remote)
106            && set_admits(self.remote_binding.as_deref(), cap.locus.binding)
107            && ord_admits(self.provenance, cap.locus.provenance)
108            && ord_admits(self.scale, cap.scale)
109            && ord_admits(self.retrieval, cap.retrieval)
110            && ord_admits(self.authority, cap.authority)
111            && ord_admits(self.isolation, cap.isolation)
112            && ord_admits(self.reversibility, cap.reversibility)
113            && ord_admits(self.persistence_level, cap.persistence.level)
114            && ord_admits(self.trigger_escape, cap.persistence.trigger.escape)
115            && set_admits(self.trigger_kind.as_deref(), cap.persistence.trigger.kind)
116            && ord_admits(self.disclosure_audience, cap.disclosure.audience)
117            && set_admits(self.disclosure_channel.as_deref(), cap.disclosure.channel)
118            && set_admits(self.disclosure_principal.as_deref(), cap.disclosure.principal)
119            && ord_admits(self.secret_level, cap.secret.level)
120            && set_admits(self.secret_channel.as_deref(), cap.secret.channel)
121            && set_admits(self.secret_principal.as_deref(), cap.secret.principal)
122            && ord_admits(self.net_direction, cap.network.direction)
123            && ord_admits(self.net_destination, cap.network.destination)
124            && ord_admits(self.net_payload, cap.network.payload)
125            && ord_admits(self.execution_trust, cap.execution.trust)
126            && self.supply_chain_admits(cap.execution.supply_chain, role)
127            && ord_admits(self.cost, cap.cost)
128    }
129
130    /// Supply-chain constraints apply only to network-sourced code. For an **allow**, a
131    /// capability with no supply chain (`None`) satisfies them vacuously. For a **deny**,
132    /// a clause that constrains the supply chain does **not** match a `None` capability —
133    /// it is not in the denied corner — while an unconstrained deny is unaffected.
134    fn supply_chain_admits(&self, sc: Option<SupplyChain>, role: Role) -> bool {
135        match sc {
136            None => match role {
137                Role::Allow => true,
138                Role::Deny => {
139                    self.supply_source.is_none()
140                        && self.pinning.is_none()
141                        && self.exec_surface.is_none()
142                }
143            },
144            Some(sc) => {
145                set_admits(self.supply_source.as_deref(), sc.source)
146                    && ord_admits(self.pinning, sc.pinning)
147                    && set_admits(self.exec_surface.as_deref(), sc.exec_surface)
148            }
149        }
150    }
151}
152
153/// Which side a clause is evaluated on — the two differ only on absent `supply_chain`.
154#[derive(Copy, Clone, PartialEq, Eq)]
155enum Role {
156    Allow,
157    Deny,
158}
159
160/// A safety level: a name, its allow clauses (disjunction), and its deny clauses
161/// (allow-only subtractive corners, §4.1).
162#[derive(Clone, Debug, Default, PartialEq, Eq)]
163pub struct Level {
164    pub name: String,
165    pub allow: Vec<Clause>,
166    pub deny: Vec<Clause>,
167}
168
169impl Level {
170    /// An empty level (admits only the empty profile). Build it up with
171    /// [`Level::allowing`] / [`Level::denying`].
172    pub fn new(name: impl Into<String>) -> Self {
173        Self { name: name.into(), allow: Vec::new(), deny: Vec::new() }
174    }
175
176    /// Add an allow clause (widens the admissible region).
177    #[must_use]
178    pub fn allowing(mut self, clause: Clause) -> Self {
179        self.allow.push(clause);
180        self
181    }
182
183    /// Add a deny clause (subtracts a corner; never grants).
184    #[must_use]
185    pub fn denying(mut self, clause: Clause) -> Self {
186        self.deny.push(clause);
187        self
188    }
189
190    /// Whether a single capability is admissible: some allow clause admits it and no
191    /// deny clause matches it.
192    pub fn admits_capability(&self, cap: &Capability) -> bool {
193        self.allow.iter().any(|c| c.admits(cap)) && !self.deny.iter().any(|c| c.matches_as_deny(cap))
194    }
195
196    /// Whether a whole profile passes: every capability is admissible. The empty
197    /// profile passes vacuously.
198    pub fn admits(&self, profile: &Profile) -> bool {
199        profile.capabilities.iter().all(|c| self.admits_capability(c))
200    }
201
202    /// Author a level by extending `base` (R27: `extends` only loosens). The result
203    /// inherits `base`'s allow *and* deny clauses unchanged and adds only allow
204    /// clauses — it cannot drop an allow or add a deny, so `extends ⇒ superset` holds
205    /// by construction. `yolo`'s subtractive `deny` is authored directly, never via
206    /// `extend`.
207    #[must_use]
208    pub fn extend(base: &Level, name: impl Into<String>, extra_allow: Vec<Clause>) -> Level {
209        let mut allow = base.allow.clone();
210        allow.extend(extra_allow);
211        Level { name: name.into(), allow, deny: base.deny.clone() }
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use proptest::prelude::*;
219
220    fn cap(op: Operation) -> Capability {
221        Capability::new(op)
222    }
223
224    #[test]
225    fn empty_clause_admits_everything_empty_allow_admits_nothing() {
226        let all = Level::new("all").allowing(Clause::default());
227        let nothing = Level::new("nothing");
228        let destroy = Profile::of(vec![cap(Operation::Destroy)]);
229        assert!(all.admits(&destroy));
230        assert!(!nothing.admits(&destroy));
231        // both admit the empty profile vacuously
232        assert!(all.admits(&Profile::default()));
233        assert!(nothing.admits(&Profile::default()));
234    }
235
236    #[test]
237    fn read_local_admits_a_read_rejects_a_secret_and_a_destroy() {
238        let read_local = Level::new("read-local").allowing(Clause {
239            operation: Some(vec![Operation::Observe]),
240            local_locus: Some(OrdBound::at_most(LocalLocus::User)),
241            secret_level: Some(OrdBound::at_most(SecretLevel::UsesAmbient)),
242            net_direction: Some(OrdBound::at_most(NetDirection::Loopback)),
243            ..Default::default()
244        });
245
246        let plain_read = Profile::of(vec![{
247            let mut c = cap(Operation::Observe);
248            c.locus.local = LocalLocus::Worktree;
249            c
250        }]);
251        assert!(read_local.admits(&plain_read));
252
253        let secret_read = Profile::of(vec![{
254            let mut c = cap(Operation::Observe);
255            c.locus.local = LocalLocus::User;
256            c.secret.level = SecretLevel::Reads;
257            c
258        }]);
259        assert!(!read_local.admits(&secret_read), "cat ~/.ssh/id_rsa must not pass read-local");
260
261        let destroy = Profile::of(vec![cap(Operation::Destroy)]);
262        assert!(!read_local.admits(&destroy));
263    }
264
265    #[test]
266    fn yolo_deny_carves_out_the_catastrophe_corner() {
267        let yolo = Level::new("yolo").allowing(Clause::default()).denying(Clause {
268            operation: Some(vec![Operation::Destroy]),
269            reversibility: Some(OrdBound::at_least(Reversibility::Irreversible)),
270            scale: Some(OrdBound::at_least(Scale::Unbounded)),
271            ..Default::default()
272        });
273
274        // rm -rf ./node_modules — destroy·bounded·recoverable → admitted
275        let bounded = Profile::of(vec![{
276            let mut c = cap(Operation::Destroy);
277            c.scale = Scale::Bounded;
278            c.reversibility = Reversibility::Recoverable;
279            c
280        }]);
281        assert!(yolo.admits(&bounded));
282
283        // rm -rf / — destroy·unbounded·irreversible → denied by the corner
284        let wipe = Profile::of(vec![{
285            let mut c = cap(Operation::Destroy);
286            c.scale = Scale::Unbounded;
287            c.reversibility = Reversibility::Irreversible;
288            c
289        }]);
290        assert!(!yolo.admits(&wipe));
291    }
292
293    #[test]
294    fn supply_chain_gates_network_sourced_code_and_is_vacuous_otherwise() {
295        // a developer-shaped clause: network-sourced execution is fine, but only from a
296        // recognized registry (not an unverified URL).
297        let dev = Level::new("dev").allowing(Clause {
298            execution_trust: Some(OrdBound::at_most(ExecutionTrust::NetworkSourced)),
299            supply_source: Some(vec![
300                SupplySource::PublicRegistry,
301                SupplySource::SignedRepo,
302                SupplySource::PrivateRegistry,
303                SupplySource::Vendored,
304            ]),
305            ..Default::default()
306        });
307
308        // cargo build — network-sourced from a public registry
309        let build = Profile::of(vec![{
310            let mut c = cap(Operation::Execute);
311            c.execution = Execution {
312                trust: ExecutionTrust::NetworkSourced,
313                supply_chain: Some(SupplyChain {
314                    source: SupplySource::PublicRegistry,
315                    pinning: Pinning::HashVerified,
316                    exec_surface: ExecSurface::BuildScript,
317                }),
318            };
319            c
320        }]);
321        assert!(dev.admits(&build), "cargo build from a registry");
322
323        // curl | sh — network-sourced from an unverified URL
324        let curl_sh = Profile::of(vec![{
325            let mut c = cap(Operation::Execute);
326            c.execution = Execution {
327                trust: ExecutionTrust::NetworkSourced,
328                supply_chain: Some(SupplyChain {
329                    source: SupplySource::UnverifiedUrl,
330                    pinning: Pinning::Floating,
331                    exec_surface: ExecSurface::RunArtifact,
332                }),
333            };
334            c
335        }]);
336        assert!(!dev.admits(&curl_sh), "curl | sh is an unverified-url source");
337
338        // cat file — no downloaded code, so the supply-chain constraint is vacuous
339        let plain = Profile::of(vec![cap(Operation::Observe)]);
340        assert!(dev.admits(&plain), "a command with no supply chain passes vacuously");
341    }
342
343    #[test]
344    fn a_supply_chain_deny_does_not_match_a_capability_without_one() {
345        // "deny unverified-url sources" must NOT accidentally deny `cat file` (no supply
346        // chain) — the vacuous-truth asymmetry between allow and deny (review finding #1).
347        let level = Level::new("x").allowing(Clause::default()).denying(Clause {
348            supply_source: Some(vec![SupplySource::UnverifiedUrl]),
349            ..Default::default()
350        });
351
352        let plain = Profile::of(vec![cap(Operation::Observe)]);
353        assert!(level.admits(&plain), "a supply-chain deny must not match a no-supply-chain cap");
354
355        // but curl|sh (network-sourced, unverified-url) IS still caught by the deny corner
356        let curl_sh = Profile::of(vec![{
357            let mut c = cap(Operation::Execute);
358            c.execution = Execution {
359                trust: ExecutionTrust::NetworkSourced,
360                supply_chain: Some(SupplyChain {
361                    source: SupplySource::UnverifiedUrl,
362                    pinning: Pinning::Floating,
363                    exec_surface: ExecSurface::RunArtifact,
364                }),
365            };
366            c
367        }]);
368        assert!(!level.admits(&curl_sh), "the deny corner still catches the unverified-url source");
369    }
370
371    #[test]
372    fn extend_inherits_deny_and_adds_allow() {
373        let base = Level::new("base")
374            .allowing(Clause { operation: Some(vec![Operation::Observe]), ..Default::default() })
375            .denying(Clause {
376                local_locus: Some(OrdBound::at_least(LocalLocus::Device)),
377                ..Default::default()
378            });
379        let child = Level::extend(
380            &base,
381            "child",
382            vec![Clause {
383                operation: Some(vec![Operation::Create, Operation::Mutate]),
384                ..Default::default()
385            }],
386        );
387
388        assert!(child.admits(&Profile::of(vec![cap(Operation::Create)])), "added allow");
389        assert!(child.admits(&Profile::of(vec![cap(Operation::Observe)])), "inherited allow");
390
391        let device = Profile::of(vec![{
392            let mut c = cap(Operation::Mutate);
393            c.locus.local = LocalLocus::Device;
394            c
395        }]);
396        assert!(!child.admits(&device), "inherited deny still bites");
397    }
398
399    // ── the algebra contract ──────────────────────────────────────────────────────
400    //
401    // Generators live in `super::super::testgen` (shared with the authoring tests).
402    use crate::engine::testgen::{arb_clause, arb_level, arb_profile};
403
404    proptest! {
405        /// Totality: every level yields a decision on every profile, deterministically,
406        /// never a panic (the worst-case rule guarantees a total function).
407        #[test]
408        fn totality(level in arb_level(), profile in arb_profile()) {
409            let first = level.admits(&profile);
410            prop_assert_eq!(first, level.admits(&profile));
411        }
412
413        /// extends ⇒ superset: a level built by `extend` admits everything its base
414        /// admits (R27, encoded structurally).
415        #[test]
416        fn extends_is_a_superset(
417            base in arb_level(),
418            extra in prop::collection::vec(arb_clause(), 0..3),
419            profile in arb_profile(),
420        ) {
421            let extended = Level::extend(&base, "child", extra);
422            prop_assert!(!base.admits(&profile) || extended.admits(&profile));
423        }
424
425        /// deny-monotonicity: adding a deny clause can only *shrink* the admitted set —
426        /// a deny never grants. This is what makes the subtractive primitive safe.
427        #[test]
428        fn deny_only_shrinks(
429            level in arb_level(),
430            extra_deny in arb_clause(),
431            profile in arb_profile(),
432        ) {
433            let stricter = level.clone().denying(extra_deny);
434            prop_assert!(!stricter.admits(&profile) || level.admits(&profile));
435        }
436    }
437}