Skip to main content

planarian_policy/
lib.rs

1//! Planarian policy (SPEC §11): one pure function from a transition and the
2//! facts the executor derived to a `Decision`.
3//!
4//! `decide` has no `Proposal` parameter, so nothing a proposer writes can
5//! reach it except the transition itself (I14). Its order:
6//! 1. authorization (§11 caller identity and capabilities),
7//! 2. hard rejections (core target, catalog, locked option, allowlists),
8//! 3. the per-transition table, then the global floors (including the
9//!    security floor, which asks an administrator: I16),
10//! 4. cumulative-session classification (I11),
11//! 5. the autonomy setting, which can only raise a tier to AskFirst and never
12//!    touches a `Reject` (I5).
13//!
14//! Verified with Verus (SPEC §17a): `decide` is proved equal to `decide_spec`,
15//! and the properties are proved about `decide_spec` in `proofs`. Sets of
16//! names are vectors searched linearly, because vstd specifies `BTreeSet` only
17//! for key types with an ordering spec, which `String` lacks
18//! (docs/deviations.md). Run: `cargo verus verify -p planarian-policy`.
19
20// Verus idioms: `&Vec` for its `@` view, explicit `match` and nested `if`
21// rather than `matches!` and let-chains.
22#![allow(
23    clippy::ptr_arg,
24    clippy::match_like_matches_macro,
25    clippy::collapsible_if,
26    clippy::collapsible_match,
27    clippy::redundant_pattern_matching
28)]
29
30use planarian_schema::{
31    Actor, ApproverClass, Authorization, Capabilities, ChangeId, ConfigDiff, Decision, DerivedFacts, GenerationId,
32    Grant, Hash, ModelId, PolicyVersion, PrincipalId, Recovery, RejectReason, Scope, Sensitivities, Tier, Transition,
33    UnitId,
34};
35use serde::{Deserialize, Serialize};
36use vstd::prelude::*;
37
38pub mod proofs;
39
40/// Bumped by hand whenever the decision logic changes; part of `PolicyVersion`.
41pub const POLICY_REVISION: &str = "phase-a.3";
42
43verus! {
44
45// ---------- Names: vectors and linear search ----------
46
47pub open spec fn has_str(v: Seq<String>, s: Seq<char>) -> bool {
48    exists|j: int| 0 <= j < v.len() && (#[trigger] v[j])@ == s
49}
50
51pub open spec fn has_change(v: Seq<ChangeId>, c: ChangeId) -> bool {
52    exists|j: int| 0 <= j < v.len() && (#[trigger] v[j]).0@ == c.0@
53}
54
55pub open spec fn has_model(v: Seq<ModelId>, m: ModelId) -> bool {
56    exists|j: int| 0 <= j < v.len() && (#[trigger] v[j]).0@ == m.0@
57}
58
59/// `path` is `prefix`, or lies under it: `prefix` followed by `.` and more.
60pub open spec fn under(path: Seq<char>, prefix: Seq<char>) -> bool {
61    path == prefix || (prefix.len() < path.len() && path.subrange(0, prefix.len() as int) == prefix
62        && path[prefix.len() as int] == '.')
63}
64
65fn str_eq(a: &str, b: &str) -> (r: bool)
66    ensures
67        r == (a@ == b@),
68{
69    let n = a.unicode_len();
70    if n != b.unicode_len() {
71        return false;
72    }
73    let mut i: usize = 0;
74    while i < n
75        invariant
76            n == a@.len(),
77            n == b@.len(),
78            i <= n,
79            forall|j: int| 0 <= j < i ==> a@[j] == b@[j],
80        decreases n - i,
81    {
82        if a.get_char(i) != b.get_char(i) {
83            return false;
84        }
85        i += 1;
86    }
87    assert(a@ =~= b@);
88    true
89}
90
91pub fn contains_str(v: &Vec<String>, s: &str) -> (r: bool)
92    ensures
93        r == has_str(v@, s@),
94{
95    let mut i: usize = 0;
96    while i < v.len()
97        invariant
98            i <= v@.len(),
99            forall|j: int| 0 <= j < i ==> (#[trigger] v@[j])@ != s@,
100        decreases v.len() - i,
101    {
102        if str_eq(v[i].as_str(), s) {
103            return true;
104        }
105        i += 1;
106    }
107    false
108}
109
110pub fn contains_change(v: &Vec<ChangeId>, c: &ChangeId) -> (r: bool)
111    ensures
112        r == has_change(v@, *c),
113{
114    let mut i: usize = 0;
115    while i < v.len()
116        invariant
117            i <= v@.len(),
118            forall|j: int| 0 <= j < i ==> (#[trigger] v@[j]).0@ != c.0@,
119        decreases v.len() - i,
120    {
121        if str_eq(v[i].0.as_str(), c.0.as_str()) {
122            return true;
123        }
124        i += 1;
125    }
126    false
127}
128
129pub fn contains_model(v: &Vec<ModelId>, m: &ModelId) -> (r: bool)
130    ensures
131        r == has_model(v@, *m),
132{
133    let mut i: usize = 0;
134    while i < v.len()
135        invariant
136            i <= v@.len(),
137            forall|j: int| 0 <= j < i ==> (#[trigger] v@[j]).0@ != m.0@,
138        decreases v.len() - i,
139    {
140        if str_eq(v[i].0.as_str(), m.0.as_str()) {
141            return true;
142        }
143        i += 1;
144    }
145    false
146}
147
148pub fn is_under(path: &str, prefix: &str) -> (r: bool)
149    ensures
150        r == under(path@, prefix@),
151{
152    let n = path.unicode_len();
153    let m = prefix.unicode_len();
154    if m > n {
155        return false;
156    }
157    let mut i: usize = 0;
158    while i < m
159        invariant
160            n == path@.len(),
161            m == prefix@.len(),
162            m <= n,
163            i <= m,
164            forall|j: int| 0 <= j < i ==> path@[j] == prefix@[j],
165        decreases m - i,
166    {
167        if path.get_char(i) != prefix.get_char(i) {
168            assert(path@.subrange(0, m as int)[i as int] != prefix@[i as int]);
169            return false;
170        }
171        i += 1;
172    }
173    assert(path@.subrange(0, m as int) =~= prefix@);
174    if m == n {
175        assert(path@ =~= prefix@);
176        true
177    } else {
178        path.get_char(m) == '.'
179    }
180}
181
182// ---------- Settings and tables ----------
183
184/// Owner settings (not manifest options, not proposal fields).
185#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
186#[verifier::external_derive]
187pub struct Settings {
188    /// `Observe`, `Runtime` or `Tested`. Anything above it becomes AskFirst.
189    pub max_tier: Tier,
190    /// `services.planarian.userControllable`: system unit names an
191    /// unprivileged user may act on.
192    pub user_controllable_units: Vec<String>,
193    /// Catalog entries an unprivileged user may apply (display scaling, …).
194    pub user_controllable_changes: Vec<ChangeId>,
195    /// Runtime options an unprivileged user may set (their own Wi-Fi, …).
196    pub user_controllable_options: Vec<String>,
197}
198
199/// Shipped tables. Hashed into `PolicyVersion`.
200#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
201#[verifier::external_derive]
202pub struct Tables {
203    /// Change-catalog entry ids (§9).
204    pub catalog: Vec<ChangeId>,
205    /// The core-owned runtime-settable options (§12a).
206    pub runtime_allowlist: Vec<String>,
207    /// Option-path prefix → sensitivity. Longest prefix wins (the later entry
208    /// on a tie); no match is Routine.
209    pub option_sensitivity: Vec<(String, Sensitivities)>,
210    /// Hash-pinned model catalog (Phase B; empty in A).
211    pub model_catalog: Vec<ModelId>,
212}
213
214pub open spec fn routine() -> Sensitivities {
215    Sensitivities { routine: true, security: false, user_data: false, external: false, core: false }
216}
217
218/// The best match for `path` in `tbl`: the matched prefix's length (-1 for
219/// none) and its sensitivity (Routine for none).
220pub open spec fn lookup(tbl: Seq<(String, Sensitivities)>, path: Seq<char>) -> (int, Sensitivities)
221    decreases tbl.len(),
222{
223    if tbl.len() == 0 {
224        (-1, routine())
225    } else {
226        let prev = lookup(tbl.drop_last(), path);
227        let k = tbl.last().0@;
228        if under(path, k) && k.len() >= prev.0 {
229            (k.len() as int, tbl.last().1)
230        } else {
231            prev
232        }
233    }
234}
235
236pub open spec fn path_non_routine(tb: Tables, path: Seq<char>) -> bool {
237    !lookup(tb.option_sensitivity@, path).1.routine_only()
238}
239
240/// Some option the diff touches is classified above Routine.
241pub open spec fn diff_non_routine(tb: Tables, d: ConfigDiff) -> bool {
242    exists|j: int| 0 <= j < d.options.entries@.len() && path_non_routine(tb, (#[trigger] d.options.entries@[j]).0@)
243}
244
245/// Some option the diff touches was set by the owner with `mkForce`.
246pub open spec fn diff_locked(locked: Seq<String>, d: ConfigDiff) -> bool {
247    exists|j: int| 0 <= j < d.options.entries@.len() && has_str(locked, (#[trigger] d.options.entries@[j]).0@)
248}
249
250impl Tables {
251    pub fn option_sensitivity(&self, path: &str) -> (r: Sensitivities)
252        ensures
253            r == lookup(self.option_sensitivity@, path@).1,
254    {
255        let tbl = &self.option_sensitivity;
256        let mut best: Option<usize> = None;
257        let mut sens = Sensitivities { routine: true, security: false, user_data: false, external: false, core: false };
258        let mut i: usize = 0;
259        while i < tbl.len()
260            invariant
261                i <= tbl@.len(),
262                lookup(tbl@.subrange(0, i as int), path@) == (
263                    match best {
264                        None => -1int,
265                        Some(b) => b as int,
266                    },
267                    sens,
268                ),
269            decreases tbl.len() - i,
270        {
271            let k = tbl[i].0.as_str();
272            let len = k.unicode_len();
273            let longer = match best {
274                None => true,
275                Some(b) => len >= b,
276            };
277            if is_under(path, k) && longer {
278                best = Some(len);
279                sens = tbl[i].1;
280            }
281            assert(tbl@.subrange(0, i as int + 1).drop_last() =~= tbl@.subrange(0, i as int));
282            i += 1;
283        }
284        assert(tbl@.subrange(0, tbl@.len() as int) =~= tbl@);
285        sens
286    }
287}
288
289pub fn any_non_routine(tb: &Tables, d: &ConfigDiff) -> (r: bool)
290    ensures
291        r == diff_non_routine(*tb, *d),
292{
293    let mut i: usize = 0;
294    while i < d.options.entries.len()
295        invariant
296            i <= d.options.entries@.len(),
297            forall|j: int| 0 <= j < i ==> !path_non_routine(*tb, (#[trigger] d.options.entries@[j]).0@),
298        decreases d.options.entries.len() - i,
299    {
300        if !tb.option_sensitivity(d.options.entries[i].0.as_str()).only_routine() {
301            assert(path_non_routine(*tb, d.options.entries@[i as int].0@));
302            return true;
303        }
304        i += 1;
305    }
306    false
307}
308
309pub fn any_locked(locked: &Vec<String>, d: &ConfigDiff) -> (r: bool)
310    ensures
311        r == diff_locked(locked@, *d),
312{
313    let mut i: usize = 0;
314    while i < d.options.entries.len()
315        invariant
316            i <= d.options.entries@.len(),
317            forall|j: int| 0 <= j < i ==> !has_str(locked@, (#[trigger] d.options.entries@[j]).0@),
318        decreases d.options.entries.len() - i,
319    {
320        if contains_str(locked, d.options.entries[i].0.as_str()) {
321            assert(has_str(locked@, d.options.entries@[i as int].0@));
322            return true;
323        }
324        i += 1;
325    }
326    false
327}
328
329// ---------- Authorization (§11) ----------
330
331pub open spec fn is_user(a: Actor) -> bool {
332    match a {
333        Actor::User { .. } => true,
334        _ => false,
335    }
336}
337
338/// A user holding administrator capability. Capabilities count only for
339/// users in Phase A: agents are not constructed, and the daemon, watchdog and
340/// model are never authorized users.
341pub open spec fn is_admin(a: Authorization) -> bool {
342    is_user(a.actor) && a.capabilities.administrator
343}
344
345/// A caller with a seat: a local, not a remote, session.
346pub open spec fn is_local(a: Actor) -> bool {
347    match a {
348        Actor::User { seat, .. } => seat is Some,
349        _ => false,
350    }
351}
352
353/// The disruption lands on exactly the caller's own session (per session, not per uid).
354pub open spec fn own_session(a: Actor, f: DerivedFacts) -> bool {
355    match a {
356        Actor::User { session, .. } => match session {
357            Some(s) => f.affected_sessions@.len() == 1 && f.affected_sessions@[0]@ == s@,
358            None => false,
359        },
360        _ => false,
361    }
362}
363
364/// A unit a non-admin user `uid` may act on directly: their own user scope
365/// (`OwnScope`) or a user-controllable system unit (`UserControllable`).
366pub open spec fn unit_ok(u: UnitId, uid: u32, c: Capabilities, s: Settings) -> bool {
367    match u.scope {
368        Scope::User { uid: owner, .. } => c.own_scope && owner == uid,
369        Scope::System => c.user_controllable && has_str(s.user_controllable_units@, u.name@),
370    }
371}
372
373/// A unit a non-admin user's cataloged change may affect: their own, or any system unit.
374pub open spec fn may_affect(u: UnitId, uid: u32) -> bool {
375    match u.scope {
376        Scope::User { uid: owner, .. } => owner == uid,
377        Scope::System => true,
378    }
379}
380
381pub open spec fn all_may_affect(units: Seq<UnitId>, uid: u32) -> bool {
382    forall|j: int| 0 <= j < units.len() ==> may_affect(#[trigger] units[j], uid)
383}
384
385/// An override: `SetOverride`, `LiftOverride` or `StopAndSuppress`.
386pub open spec fn is_override(t: Transition) -> bool {
387    match t {
388        Transition::SetOverride(_) | Transition::LiftOverride(_) | Transition::StopAndSuppress { .. } => true,
389        _ => false,
390    }
391}
392
393/// §11: a user may act on units in their own user scope and on
394/// user-controllable system units; every other system action needs
395/// administrator authority. Authorized to start a unit means authorized to
396/// override it, and a non-administrator may also *ask* to override a
397/// Security-sensitive unit (held for an administrator by the table). The
398/// daemon, the watchdog and the model are not users: their proposals are
399/// governed by tiers, except `Resolve`, which only an administrator may issue.
400/// An agent (reserved) and the unattributed actor are never authorized.
401pub open spec fn authorized_spec(t: Transition, f: DerivedFacts, a: Authorization, s: Settings) -> bool {
402    match a.actor {
403        Actor::User { uid, .. } => a.capabilities.administrator || match t {
404            Transition::RestartUnit(u) => unit_ok(u, uid, a.capabilities, s),
405            Transition::StopUnit(u) => unit_ok(u, uid, a.capabilities, s),
406            Transition::LiftOverride(u) => unit_ok(u, uid, a.capabilities, s) || f.sensitivity.security,
407            Transition::SetOverride(o) => unit_ok(o.unit, uid, a.capabilities, s) || f.sensitivity.security,
408            Transition::StopAndSuppress { unit, .. } => unit_ok(unit, uid, a.capabilities, s)
409                || f.sensitivity.security,
410            Transition::ApplyChange { change, .. } => a.capabilities.user_controllable && has_change(
411                s.user_controllable_changes@,
412                change,
413            ) && all_may_affect(f.affected_units@, uid),
414            Transition::SetRuntimeOption { path, .. } => a.capabilities.user_controllable && has_str(
415                s.user_controllable_options@,
416                path@,
417            ),
418            _ => false,
419        },
420        // The model holds nothing until Phase B defines its grants (§11):
421        // it is treated as an agent, never as the daemon.
422        Actor::Agent(_) | Actor::Model(_) => false,
423        Actor::Unattributed => false,
424        _ => match t {
425            Transition::Resolve { .. } => false,
426            _ => true,
427        },
428    }
429}
430
431fn unit_ok_exec(u: &UnitId, uid: u32, c: &Capabilities, s: &Settings) -> (r: bool)
432    ensures
433        r == unit_ok(*u, uid, *c, *s),
434{
435    match &u.scope {
436        Scope::User { uid: owner, .. } => c.own_scope && *owner == uid,
437        Scope::System => c.user_controllable && contains_str(&s.user_controllable_units, u.name.as_str()),
438    }
439}
440
441#[verifier::loop_isolation(false)]
442pub fn authorized(t: &Transition, facts: &DerivedFacts, authz: &Authorization, settings: &Settings) -> (r: bool)
443    ensures
444        r == authorized_spec(*t, *facts, *authz, *settings),
445{
446    let uid = match &authz.actor {
447        Actor::User { uid, .. } => *uid,
448        Actor::Agent(_) | Actor::Model(_) => return false,
449        Actor::Unattributed => return false,
450        _ => {
451            return match t {
452                Transition::Resolve { .. } => false,
453                _ => true,
454            };
455        },
456    };
457    let c = &authz.capabilities;
458    if c.administrator {
459        return true;
460    }
461    let security = facts.sensitivity.security;
462    match t {
463        Transition::RestartUnit(u) => unit_ok_exec(u, uid, c, settings),
464        Transition::StopUnit(u) => unit_ok_exec(u, uid, c, settings),
465        Transition::LiftOverride(u) => unit_ok_exec(u, uid, c, settings) || security,
466        Transition::SetOverride(o) => unit_ok_exec(&o.unit, uid, c, settings) || security,
467        Transition::StopAndSuppress { unit, .. } => unit_ok_exec(unit, uid, c, settings) || security,
468        Transition::ApplyChange { change, .. } => {
469            if !c.user_controllable || !contains_change(&settings.user_controllable_changes, change) {
470                return false;
471            }
472            let mut i: usize = 0;
473            while i < facts.affected_units.len()
474                invariant
475                    i <= facts.affected_units@.len(),
476                    forall|j: int| 0 <= j < i ==> may_affect(#[trigger] facts.affected_units@[j], uid),
477                decreases facts.affected_units.len() - i,
478            {
479                let ok = match &facts.affected_units[i].scope {
480                    Scope::User { uid: owner, .. } => *owner == uid,
481                    Scope::System => true,
482                };
483                if !ok {
484                    assert(!may_affect(facts.affected_units@[i as int], uid));
485                    return false;
486                }
487                i += 1;
488            }
489            true
490        },
491        Transition::SetRuntimeOption { path, .. } => c.user_controllable && contains_str(
492            &settings.user_controllable_options,
493            path.as_str(),
494        ),
495        _ => false,
496    }
497}
498
499// ---------- The decision ----------
500
501pub open spec fn sec_or_ext(s: Sensitivities) -> bool {
502    s.security || s.external
503}
504
505pub open spec fn ask_if(c: bool, t: Tier) -> Tier {
506    if c {
507        Tier::AskFirst
508    } else {
509        t
510    }
511}
512
513/// `Administrator` if `c`, else `k`: classes only ever rise.
514pub open spec fn admin_if(c: bool, k: ApproverClass) -> ApproverClass {
515    if c {
516        ApproverClass::Administrator
517    } else {
518        k
519    }
520}
521
522pub open spec fn allow(t: Tier) -> Decision {
523    Decision::Allow { tier: t, approver: ApproverClass::AnyApprover }
524}
525
526/// Known-good, or the live-tested generation: already shown to work.
527pub open spec fn vetted(g: GenerationId, known_good: GenerationId, live_tested: Option<GenerationId>) -> bool {
528    live_tested == Some(g) || g == known_good
529}
530
531/// Everything `decide` reads.
532pub struct Input<'a> {
533    pub transition: &'a Transition,
534    pub facts: &'a DerivedFacts,
535    pub known_good: GenerationId,
536    pub live_tested: Option<GenerationId>,
537    /// The session's cumulative diff before this change (I11).
538    pub cumulative: &'a ConfigDiff,
539    pub authz: &'a Authorization,
540    pub settings: &'a Settings,
541    pub tables: &'a Tables,
542    /// Option paths the owner set with `mkForce` (§9 `locked_if`).
543    pub locked: &'a Vec<String>,
544    /// For a `Revert` inside a transaction: the `revert_to` recorded at Prepared.
545    pub revert_to: Option<GenerationId>,
546}
547
548/// The override row (§11): AskFirst when the caller is not a user or the unit
549/// is External-sensitive; held for an administrator when it is
550/// Security-sensitive and the caller is not one.
551pub open spec fn override_spec(f: DerivedFacts, a: Authorization) -> Decision {
552    let held = f.sensitivity.security && !is_admin(a);
553    Decision::Allow {
554        tier: ask_if(!is_user(a.actor) || f.sensitivity.external || held, Tier::Runtime),
555        approver: admin_if(held, ApproverClass::AnyApprover),
556    }
557}
558
559/// The per-transition table (§11). The security-floor escalations of the
560/// SwitchGeneration, Revert and Rollback rows are the `below_security_floor`
561/// floor, which applies to every transition kind.
562pub open spec fn base_spec(i: Input) -> Decision {
563    let f = *i.facts;
564    match *i.transition {
565        // §11: a Security-sensitive unit stopped outside Planarian and not
566        // attributed is restored on its own (and alarmed); otherwise a
567        // Security restart asks. An External restart asks unless the unit has
568        // already failed: what that escalation protects is already lost
569        // (owner decision, Nov 2).
570        Transition::RestartUnit(_) => allow(
571            ask_if(
572                (f.sensitivity.security && !f.restores_unattributed_stop && !f.restarts_held_unit)
573                    || (f.sensitivity.external && !f.restarts_failed_unit && !f.restarts_held_unit),
574                Tier::Runtime,
575            ),
576        ),
577        Transition::StopUnit(_) => allow(ask_if(sec_or_ext(f.sensitivity), Tier::Runtime)),
578        Transition::SetOverride(_) => override_spec(f, *i.authz),
579        Transition::LiftOverride(_) => override_spec(f, *i.authz),
580        Transition::StopAndSuppress { .. } => override_spec(f, *i.authz),
581        Transition::BindDriver { .. } => allow(ask_if(f.sensitivity.security, Tier::Tested)),
582        Transition::ApplyChange { .. } => allow(
583            ask_if(!f.sensitivity.routine_only() || f.touches_manifest || f.touches_canonical_probe, Tier::Tested),
584        ),
585        Transition::SwitchGeneration(g) => allow(ask_if(!vetted(g, i.known_good, i.live_tested), Tier::Tested)),
586        Transition::Revert { to } => allow(
587            ask_if(
588                !vetted(to, i.known_good, i.live_tested) || match i.revert_to {
589                    Some(r) => r != to,
590                    None => false,
591                },
592                Tier::Tested,
593            ),
594        ),
595        // §11: a Rollback asks for what it would take away, not for what it
596        // restarts. Security- or External-sensitive options it would restore
597        // ask through I11 (any non-Routine option in its diff); a Security- or
598        // External-sensitive unit it would remove or stop asks here (owner
599        // decision, Nov 2).
600        Transition::Rollback => allow(ask_if(sec_or_ext(f.removes_sensitivity), Tier::Runtime)),
601        Transition::LoadModel(m) => if has_model(i.tables.model_catalog@, m) {
602            allow(Tier::Tested)
603        } else {
604            Decision::Reject(RejectReason::NotInCatalog)
605        },
606        Transition::RestoreSnapshot(_) => allow(Tier::AskFirst),
607        Transition::CollectGarbage { .. } => allow(Tier::Runtime),
608        Transition::SetRuntimeOption { path, .. } => if has_str(i.tables.runtime_allowlist@, path@) {
609            allow(Tier::Runtime)
610        } else {
611            Decision::Reject(RejectReason::NotInCatalog)
612        },
613        Transition::Resolve { .. } => allow(Tier::Runtime),
614    }
615}
616
617/// The global floors (§11) that ask any approver, for every transition kind:
618/// non-reversible; session or data loss unless the user's own session;
619/// connectivity loss unless a user with a seat; a session or connectivity loss
620/// reaching another uid's session unless an administrator.
621///
622/// The disruption is a set (C10): each floor applies when any member triggers
623/// it, so a change carrying two kinds meets both floors.
624pub open spec fn floor_spec(f: DerivedFacts, a: Authorization) -> bool {
625    f.recovery == Recovery::NonReversible || ((f.disruption.session_loss || f.disruption.data_interruption)
626        && !(is_user(a.actor) && own_session(a.actor, f))) || (f.disruption.connectivity_loss && !is_local(
627        a.actor,
628    )) || (f.other_sessions_affected && (f.disruption.session_loss || f.disruption.connectivity_loss)
629        && !is_admin(a))
630}
631
632/// I11: this change's diff, or the session's cumulative diff, is non-Routine.
633/// Classifying the two separately covers their composition: an option the
634/// change returns to its start drops out of the composed diff, but it is in
635/// this change's own diff.
636pub open spec fn cumulative_spec(i: Input) -> bool {
637    match i.facts.config_diff {
638        Some(d) => diff_non_routine(*i.tables, d) || diff_non_routine(*i.tables, *i.cumulative),
639        None => false,
640    }
641}
642
643/// The decision before the autonomy setting.
644pub open spec fn uncapped_spec(i: Input) -> Decision {
645    let f = *i.facts;
646    if !authorized_spec(*i.transition, f, *i.authz, *i.settings) {
647        Decision::Reject(RejectReason::Unauthorized)
648    } else if f.touches_core || f.sensitivity.core {
649        Decision::Reject(RejectReason::CoreTarget)
650    } else if match *i.transition {
651        Transition::ApplyChange { change, .. } => !has_change(i.tables.catalog@, change),
652        _ => false,
653    } {
654        Decision::Reject(RejectReason::NotInCatalog)
655    } else if match f.config_diff {
656        Some(d) => diff_locked(i.locked@, d),
657        None => false,
658    } {
659        Decision::Reject(RejectReason::LockedOption)
660    } else {
661        match base_spec(i) {
662            Decision::Allow { tier, approver } => Decision::Allow {
663                tier: ask_if(
664                    floor_spec(f, *i.authz) || cumulative_spec(i) || f.touches_canonical_probe
665                        || f.below_security_floor,
666                    tier,
667                ),
668                // I16: going below the security floor needs an administrator.
669                approver: admin_if(f.below_security_floor, approver),
670            },
671            other => other,
672        }
673    }
674}
675
676/// The autonomy setting: raises a tier above `max` to AskFirst, keeping the
677/// approver class; a Reject passes through.
678pub open spec fn cap(d: Decision, max: Tier) -> Decision {
679    match d {
680        Decision::Allow { tier, approver } => if tier.rank() > max.rank() {
681            Decision::Allow { tier: Tier::AskFirst, approver }
682        } else {
683            Decision::Allow { tier, approver }
684        },
685        other => other,
686    }
687}
688
689pub open spec fn decide_spec(i: Input) -> Decision {
690    cap(uncapped_spec(i), i.settings.max_tier)
691}
692
693fn is_user_exec(a: &Actor) -> (r: bool)
694    ensures
695        r == is_user(*a),
696{
697    match a {
698        Actor::User { .. } => true,
699        _ => false,
700    }
701}
702
703fn is_admin_exec(a: &Authorization) -> (r: bool)
704    ensures
705        r == is_admin(*a),
706{
707    is_user_exec(&a.actor) && a.capabilities.administrator
708}
709
710fn is_local_exec(a: &Actor) -> (r: bool)
711    ensures
712        r == is_local(*a),
713{
714    match a {
715        Actor::User { seat, .. } => match seat {
716            Some(_) => true,
717            None => false,
718        },
719        _ => false,
720    }
721}
722
723fn own_session_exec(a: &Actor, f: &DerivedFacts) -> (r: bool)
724    ensures
725        r == own_session(*a, *f),
726{
727    match a {
728        Actor::User { session, .. } => match session {
729            Some(s) => f.affected_sessions.len() == 1 && str_eq(f.affected_sessions[0].as_str(), s.as_str()),
730            None => false,
731        },
732        _ => false,
733    }
734}
735
736fn sec_or_ext_exec(s: &Sensitivities) -> (r: bool)
737    ensures
738        r == sec_or_ext(*s),
739{
740    s.security || s.external
741}
742
743fn ask_if_exec(c: bool, t: Tier) -> (r: Tier)
744    ensures
745        r == ask_if(c, t),
746{
747    if c {
748        Tier::AskFirst
749    } else {
750        t
751    }
752}
753
754fn admin_if_exec(c: bool, k: ApproverClass) -> (r: ApproverClass)
755    ensures
756        r == admin_if(c, k),
757{
758    if c {
759        ApproverClass::Administrator
760    } else {
761        k
762    }
763}
764
765fn allow_exec(t: Tier) -> (r: Decision)
766    ensures
767        r == allow(t),
768{
769    Decision::Allow { tier: t, approver: ApproverClass::AnyApprover }
770}
771
772fn gen_eq(a: GenerationId, b: GenerationId) -> (r: bool)
773    ensures
774        r == (a == b),
775{
776    a.0 == b.0
777}
778
779fn vetted_exec(g: GenerationId, known_good: GenerationId, live_tested: Option<GenerationId>) -> (r: bool)
780    ensures
781        r == vetted(g, known_good, live_tested),
782{
783    let live = match live_tested {
784        Some(l) => gen_eq(l, g),
785        None => false,
786    };
787    live || gen_eq(g, known_good)
788}
789
790fn override_exec(f: &DerivedFacts, a: &Authorization) -> (r: Decision)
791    ensures
792        r == override_spec(*f, *a),
793{
794    let held = f.sensitivity.security && !is_admin_exec(a);
795    Decision::Allow {
796        tier: ask_if_exec(!is_user_exec(&a.actor) || f.sensitivity.external || held, Tier::Runtime),
797        approver: admin_if_exec(held, ApproverClass::AnyApprover),
798    }
799}
800
801fn base_tier(i: &Input) -> (r: Decision)
802    ensures
803        r == base_spec(*i),
804{
805    let f = i.facts;
806    match i.transition {
807        Transition::RestartUnit(_) => allow_exec(
808            ask_if_exec(
809                (f.sensitivity.security && !f.restores_unattributed_stop && !f.restarts_held_unit)
810                    || (f.sensitivity.external && !f.restarts_failed_unit && !f.restarts_held_unit),
811                Tier::Runtime,
812            ),
813        ),
814        Transition::StopUnit(_) => allow_exec(ask_if_exec(sec_or_ext_exec(&f.sensitivity), Tier::Runtime)),
815        Transition::SetOverride(_) => override_exec(f, i.authz),
816        Transition::LiftOverride(_) => override_exec(f, i.authz),
817        Transition::StopAndSuppress { .. } => override_exec(f, i.authz),
818        Transition::BindDriver { .. } => allow_exec(ask_if_exec(f.sensitivity.security, Tier::Tested)),
819        Transition::ApplyChange { .. } => allow_exec(
820            ask_if_exec(
821                !f.sensitivity.only_routine() || f.touches_manifest || f.touches_canonical_probe,
822                Tier::Tested,
823            ),
824        ),
825        Transition::SwitchGeneration(g) => allow_exec(
826            ask_if_exec(!vetted_exec(*g, i.known_good, i.live_tested), Tier::Tested),
827        ),
828        Transition::Revert { to } => {
829            let mismatched = match i.revert_to {
830                Some(r) => !gen_eq(r, *to),
831                None => false,
832            };
833            allow_exec(ask_if_exec(!vetted_exec(*to, i.known_good, i.live_tested) || mismatched, Tier::Tested))
834        },
835        Transition::Rollback => allow_exec(ask_if_exec(sec_or_ext_exec(&f.removes_sensitivity), Tier::Runtime)),
836        Transition::LoadModel(m) => if contains_model(&i.tables.model_catalog, m) {
837            allow_exec(Tier::Tested)
838        } else {
839            Decision::Reject(RejectReason::NotInCatalog)
840        },
841        Transition::RestoreSnapshot(_) => allow_exec(Tier::AskFirst),
842        Transition::CollectGarbage { .. } => allow_exec(Tier::Runtime),
843        Transition::SetRuntimeOption { path, .. } => if contains_str(&i.tables.runtime_allowlist, path.as_str()) {
844            allow_exec(Tier::Runtime)
845        } else {
846            Decision::Reject(RejectReason::NotInCatalog)
847        },
848        Transition::Resolve { .. } => allow_exec(Tier::Runtime),
849    }
850}
851
852fn floors(f: &DerivedFacts, a: &Authorization) -> (r: bool)
853    ensures
854        r == floor_spec(*f, *a),
855{
856    let user = is_user_exec(&a.actor);
857    let non_reversible = match f.recovery {
858        Recovery::NonReversible => true,
859        _ => false,
860    };
861    let session = f.disruption.session_loss || f.disruption.data_interruption;
862    let connectivity = f.disruption.connectivity_loss;
863    let reaches_others = f.other_sessions_affected && (f.disruption.session_loss || f.disruption.connectivity_loss);
864    non_reversible || (session && !(user && own_session_exec(&a.actor, f))) || (connectivity && !is_local_exec(
865        &a.actor,
866    )) || (reaches_others && !is_admin_exec(a))
867}
868
869pub fn decide(i: &Input) -> (r: Decision)
870    ensures
871        r == decide_spec(*i),
872{
873    let f = i.facts;
874    if !authorized(i.transition, f, i.authz, i.settings) {
875        return Decision::Reject(RejectReason::Unauthorized);
876    }
877    if f.touches_core || f.sensitivity.core {
878        return Decision::Reject(RejectReason::CoreTarget);
879    }
880    if let Transition::ApplyChange { change, .. } = i.transition {
881        if !contains_change(&i.tables.catalog, change) {
882            return Decision::Reject(RejectReason::NotInCatalog);
883        }
884    }
885    if let Some(d) = &f.config_diff {
886        if any_locked(i.locked, d) {
887            return Decision::Reject(RejectReason::LockedOption);
888        }
889    }
890    let (base, class) = match base_tier(i) {
891        Decision::Allow { tier, approver } => (tier, approver),
892        Decision::Reject(r) => return Decision::Reject(r),
893    };
894    let cumulative = match &f.config_diff {
895        Some(d) => any_non_routine(i.tables, d) || any_non_routine(i.tables, i.cumulative),
896        None => false,
897    };
898    let tier = ask_if_exec(
899        floors(f, i.authz) || cumulative || f.touches_canonical_probe || f.below_security_floor,
900        base,
901    );
902    let approver = admin_if_exec(f.below_security_floor, class);
903    if tier.rank_of() > i.settings.max_tier.rank_of() {
904        Decision::Allow { tier: Tier::AskFirst, approver }
905    } else {
906        Decision::Allow { tier, approver }
907    }
908}
909
910// ---------- Approval (§11, I19) ----------
911
912/// `approver` may approve, with class `class`, a proposal made by `proposer`:
913/// a different principal, holding `Approve`, and `Administrator` too when the
914/// class asks for it. Remote principals hold no approval capability by default;
915/// the daemon computes capabilities, so that is a fact of `approver`.
916pub open spec fn may_approve_spec(approver: Authorization, proposer: PrincipalId, class: ApproverClass) -> bool {
917    is_user(approver.actor) && approver.principal != proposer && approver.capabilities.approve && match class {
918        ApproverClass::AnyApprover => true,
919        ApproverClass::Administrator => approver.capabilities.administrator,
920    }
921}
922
923pub fn may_approve(approver: &Authorization, proposer: PrincipalId, class: ApproverClass) -> (r: bool)
924    ensures
925        r == may_approve_spec(*approver, proposer, class),
926{
927    let holds_class = match class {
928        ApproverClass::AnyApprover => true,
929        ApproverClass::Administrator => approver.capabilities.administrator,
930    };
931    is_user_exec(&approver.actor) && approver.principal.0 != proposer.0 && approver.capabilities.approve
932        && holds_class
933}
934
935/// A capability grant (§11, I19): approved by an administrator who is neither
936/// the grantee nor the proposer, and who holds the capability being granted.
937pub open spec fn may_grant_spec(approver: Authorization, proposer: PrincipalId, grant: Grant) -> bool {
938    may_approve_spec(approver, proposer, ApproverClass::Administrator) && approver.principal != grant.to
939        && approver.capabilities.has(grant.capability)
940}
941
942pub fn may_grant(approver: &Authorization, proposer: PrincipalId, grant: &Grant) -> (r: bool)
943    ensures
944        r == may_grant_spec(*approver, proposer, *grant),
945{
946    may_approve(approver, proposer, ApproverClass::Administrator) && approver.principal.0 != grant.to.0
947        && approver.capabilities.contains(grant.capability)
948}
949
950} // verus!
951
952impl Default for Settings {
953    /// §11 defaults: audio, display scaling, the user's own Wi-Fi selection.
954    fn default() -> Self {
955        Settings {
956            max_tier: Tier::Tested,
957            user_controllable_units: ["pipewire", "wireplumber"].map(String::from).into(),
958            user_controllable_changes: ["font-scale", "wifi-select"].map(|s| ChangeId(s.into())).into(),
959            user_controllable_options: ["font.scale", "audio.volume", "display.brightness", "wifi.network"]
960                .map(String::from)
961                .into(),
962        }
963    }
964}
965
966impl Tables {
967    pub fn diff_sensitivity(&self, diff: &ConfigDiff) -> Sensitivities {
968        diff.options.keys().fold(Sensitivities::default(), |acc, p| acc.union(&self.option_sensitivity(p)))
969    }
970
971    /// Sorted and deduplicated name lists, so equal tables hash equally
972    /// whatever order the configuration listed them in.
973    pub fn canonical(&self) -> Tables {
974        let sorted = |mut v: Vec<String>| {
975            v.sort();
976            v.dedup();
977            v
978        };
979        let mut catalog = self.catalog.clone();
980        catalog.sort();
981        catalog.dedup();
982        let mut model_catalog = self.model_catalog.clone();
983        model_catalog.sort();
984        model_catalog.dedup();
985        Tables {
986            catalog,
987            runtime_allowlist: sorted(self.runtime_allowlist.clone()),
988            // Order matters here: on equal prefixes the later entry wins.
989            option_sensitivity: self.option_sensitivity.clone(),
990            model_catalog,
991        }
992    }
993}
994
995pub fn policy_version(tables: &Tables) -> PolicyVersion {
996    let mut bytes = format!("planarian-policy {} {}\n", env!("CARGO_PKG_VERSION"), POLICY_REVISION).into_bytes();
997    bytes.extend(serde_json::to_vec(&tables.canonical()).expect("tables serialize"));
998    PolicyVersion(Hash::of(&bytes))
999}
1000
1001/// The authorization of a non-user actor: no capabilities (§11).
1002pub fn authorization_of(actor: Actor) -> Authorization {
1003    Authorization { principal: actor.principal(), actor, capabilities: Capabilities::default() }
1004}
1005
1006verus! {
1007
1008/// Who is an administrator in Phase A (§11): root, and the members of
1009/// `planarian-admin` (`in_admin_group`, from the group file).
1010pub open spec fn administrator_spec(uid: Option<u32>, in_admin_group: bool) -> bool {
1011    in_admin_group || uid == Some(0u32)
1012}
1013
1014pub fn is_administrator(uid: Option<u32>, in_admin_group: bool) -> (r: bool)
1015    ensures
1016        r == administrator_spec(uid, in_admin_group),
1017{
1018    in_admin_group || match uid {
1019        Some(u) => u == 0,
1020        None => false,
1021    }
1022}
1023
1024} // verus!
1025
1026/// Phase A's capability grants (§11): every user acts on their own scope and
1027/// the user-controllable units; root and `planarian-admin` members are
1028/// administrators and approvers. I17 holds because root always is
1029/// (`is_administrator`; the proof is `proofs::i17_administrators_never_empty`).
1030pub fn user_authorization(actor: Actor, admin: bool) -> Authorization {
1031    use planarian_schema::Capability::*;
1032    let admin = is_administrator(actor.uid(), admin);
1033    let caps = if admin {
1034        Capabilities::of(&[OwnScope, UserControllable, Administrator, Approve])
1035    } else {
1036        Capabilities::of(&[OwnScope, UserControllable])
1037    };
1038    Authorization { principal: actor.principal(), actor, capabilities: caps }
1039}
1040
1041impl std::fmt::Debug for Input<'_> {
1042    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1043        f.debug_struct("Input")
1044            .field("transition", self.transition)
1045            .field("facts", self.facts)
1046            .field("authz", self.authz)
1047            .field("max_tier", &self.settings.max_tier)
1048            .finish_non_exhaustive()
1049    }
1050}
1051
1052#[cfg(test)]
1053mod tests;