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/// The single facet constraint a capability failed, named in the taxonomy's own vocabulary.
52///
53/// Exists because the engine was write-only: `admits` answered yes/no and nothing reported WHICH
54/// axis said no, so both a user asking "why was this denied" and an author debugging a resolver had
55/// to bisect by editing facets and re-running. That is not a hypothetical cost — it is how an
56/// incomplete loopback delta got mistaken for a flawed approach.
57#[derive(Clone, Debug, PartialEq, Eq)]
58pub struct FacetMismatch {
59    /// Dotted facet path as the taxonomy spells it (`network.payload`, `locus.remote`).
60    pub facet: &'static str,
61    /// The capability's term (`sends-host-data`).
62    pub actual: &'static str,
63    /// The constraint it failed (`<= fetches`, `one of [pinned, na]`).
64    pub bound: String,
65    /// How many of that axis's terms the bound DOES admit — how close this clause came.
66    ///
67    /// Used to rank clauses, not shown. A level inherits its ancestors' clauses (`developer`
68    /// carries `paranoid`'s `observe` clause), so several can match on operation and the first is
69    /// often the strictest. Reporting that one names a bound the author never intended for this
70    /// capability: a `cat` of a machine-locus path was blamed on `<= temp` (paranoid's read floor)
71    /// when the read bound that actually matters is `<= worktree-trusted`.
72    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/// A conjunction of per-facet constraints. A default (all-`None`) clause admits every
117/// capability. Each ordinal facet takes an [`OrdBound`]; each categorical facet an
118/// allowed set. Fields are flattened per axis — a compound facet is never a single
119/// constraint (the R25 discipline).
120#[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    /// Whether this clause **admits** `cap` (allow-clause semantics).
153    pub fn admits(&self, cap: &Capability) -> bool {
154        self.check(cap, Role::Allow)
155    }
156
157    /// Whether this clause **matches** `cap` for removal (deny-clause semantics). Differs
158    /// from `admits` only on the optional `supply_chain`: a deny constrained on a
159    /// supply-chain facet does **not** match a capability that has no supply chain (it is
160    /// not in the denied corner), whereas an allow treats the absence as vacuously
161    /// satisfied. Sharing one body with a naive vacuous-true rule would make a
162    /// supply-chain-only deny wrongly match every non-supply-chain capability.
163    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    /// The first facet constraint `cap` fails, or `None` if this clause admits it.
172    ///
173    /// `check` is defined in terms of THIS rather than the other way round. A parallel
174    /// "explain" walk beside a separate `&&` chain would drift the moment a facet is added, and a
175    /// diagnostic that disagrees with the predicate is worse than none — it would send an author
176    /// chasing the wrong axis. `clause_diagnostic_agrees_with_admits` pins the equivalence.
177    ///
178    /// Order is the taxonomy's axis order, not severity: it reports A failing facet, not the most
179    /// important one, since a clause is a conjunction and every failure is disqualifying.
180    /// Test-only view of `first_mismatch`. Takes the ROLE: the allow/deny asymmetry on the supply
181    /// chain means an allow-only equivalence proptest cannot see a deny-side inversion (it did not
182    /// — a `?` that reported "no complaint" for a definitely-failing deny got through it).
183    #[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    /// Supply-chain constraints apply only to network-sourced code. For an **allow**, a
226    /// capability with no supply chain (`None`) satisfies them vacuously. For a **deny**,
227    /// a clause that constrains the supply chain does **not** match a `None` capability —
228    /// it is not in the denied corner — while an unconstrained deny is unaffected.
229    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            // A DENY constrained on the supply chain does not match a capability that has none.
235            // `?` here would report "no complaint", which `check` reads as ADMITS — inverting the
236            // rule for exactly the case the asymmetry exists to handle.
237            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/// Which side a clause is evaluated on — the two differ only on absent `supply_chain`.
270#[derive(Copy, Clone, PartialEq, Eq)]
271enum Role {
272    Allow,
273    Deny,
274}
275
276/// A safety level: a name, its allow clauses (disjunction), and its deny clauses
277/// (allow-only subtractive corners, §4.1).
278#[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    /// An empty level (admits only the empty profile). Build it up with
287    /// [`Level::allowing`] / [`Level::denying`].
288    pub fn new(name: impl Into<String>) -> Self {
289        Self { name: name.into(), allow: Vec::new(), deny: Vec::new() }
290    }
291
292    /// Add an allow clause (widens the admissible region).
293    #[must_use]
294    pub fn allowing(mut self, clause: Clause) -> Self {
295        self.allow.push(clause);
296        self
297    }
298
299    /// Add a deny clause (subtracts a corner; never grants).
300    #[must_use]
301    pub fn denying(mut self, clause: Clause) -> Self {
302        self.deny.push(clause);
303        self
304    }
305
306    /// Whether a single capability is admissible: some allow clause admits it and no
307    /// deny clause matches it.
308    /// Why this level does not admit `cap`, or `None` if it does.
309    ///
310    /// A level is a DISJUNCTION of allow clauses, so a rejected capability failed all of them and
311    /// there is a choice of which complaint to report. Clauses are usually split by operation
312    /// (`editor` allows observes under one clause and mutates under another), so the first clause's
313    /// gripe is frequently "operation = mutate, allowed: [observe]" — true, useless, and it hides
314    /// the facet the author actually needs to change. Preferring a clause whose OPERATION already
315    /// matches surfaces the real blocker.
316    pub fn nearest_miss(&self, cap: &Capability) -> Option<FacetMismatch> {
317        if self.allow.iter().any(|c| c.admits(cap)) {
318            // Admitted by an allow, so if the level still rejects it a deny clause removed it.
319            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        // Among the clauses that could plausibly have admitted this capability, report the one that
338        // came CLOSEST — the largest `admits_count` on the facet that stopped it. Taking the first
339        // match instead named an inherited ancestor clause's much tighter bound, which points the
340        // reader at a rule that was never meant to cover this capability.
341        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    /// Whether a whole profile passes: every capability is admissible. The empty
362    /// profile passes vacuously.
363    pub fn admits(&self, profile: &Profile) -> bool {
364        profile.capabilities.iter().all(|c| self.admits_capability(c))
365    }
366
367    /// Author a level by extending `base` (R27: `extends` only loosens). The result
368    /// inherits `base`'s allow *and* deny clauses unchanged and adds only allow
369    /// clauses — it cannot drop an allow or add a deny, so `extends ⇒ superset` holds
370    /// by construction. `yolo`'s subtractive `deny` is authored directly, never via
371    /// `extend`.
372    #[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        // both admit the empty profile vacuously
397        assert!(all.admits(&Profile::default()));
398        assert!(nothing.admits(&Profile::default()));
399    }
400
401    /// A level INHERITS its ancestors' clauses, so several can match on `operation` and the
402    /// STRICTEST is often first. Reporting that one names a bound never meant for this capability:
403    /// `cat ~/Library/...` was blamed on `<= temp` — paranoid's read floor, inherited by developer —
404    /// when developer's actual read bound is `<= worktree-trusted`. The diagnostic must name the
405    /// clause that came closest, or it sends the reader to fix the wrong rule.
406    #[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        // Strict first, mirroring the inherited-ancestor ordering that produced the bad report.
419        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        // rm -rf ./node_modules — destroy·bounded·recoverable → admitted
471        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        // rm -rf / — destroy·unbounded·irreversible → denied by the corner
480        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        // a developer-shaped clause: network-sourced execution is fine, but only from a
492        // recognized registry (not an unverified URL).
493        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        // cargo build — network-sourced from a public registry
505        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        // curl | sh — network-sourced from an unverified URL
520        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        // cat file — no downloaded code, so the supply-chain constraint is vacuous
535        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        // "deny unverified-url sources" must NOT accidentally deny `cat file` (no supply
542        // chain) — the vacuous-truth asymmetry between allow and deny (review finding #1).
543        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        // but curl|sh (network-sourced, unverified-url) IS still caught by the deny corner
552        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    // ── the algebra contract ──────────────────────────────────────────────────────
596    //
597    // Generators live in `super::super::testgen` (shared with the authoring tests).
598    use crate::engine::testgen::{arb_clause, arb_level, arb_profile};
599
600    proptest! {
601        /// Totality: every level yields a decision on every profile, deterministically,
602        /// never a panic (the worst-case rule guarantees a total function).
603        #[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        /// extends ⇒ superset: a level built by `extend` admits everything its base
610        /// admits (R27, encoded structurally).
611        #[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        /// deny-monotonicity: adding a deny clause can only *shrink* the admitted set —
622        /// a deny never grants. This is what makes the subtractive primitive safe.
623        #[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}