Skip to main content

safe_chains/engine/
facet.rs

1//! The capability vocabulary — the 12 facets of v1.4 §2.
2//!
3//! A [`Capability`] is one point in facet-space; a [`Profile`] is the set of
4//! capabilities a resolved command line exhibits. Nothing here makes a decision —
5//! admissibility (a level predicate over profiles) arrives in a later commit.
6//!
7//! Two kinds of facet term:
8//! - **ordinal** — a severity/trust ladder; `#[derive(Ord)]` gives declaration
9//!   order = the ladder, so a level can ceiling it (`facet <= term`) or floor it
10//!   (`facet >= term`). The first-declared variant is the least-severe **zero term**
11//!   and the [`Default`].
12//! - **categorical** — a set with no severity ordering; admissibility is set
13//!   membership, never `<=`. Deliberately *not* `Ord`, so a comparison can't be
14//!   written by accident (the R25 bug: never order `kernel` vs `remote`).
15//!
16//! Compound facets (`locus`, `persistence`, `disclosure`, `secret`, `network`) are
17//! structs of independent axes, each its own term — never collapsed to one ordinal.
18
19/// A single facet term: the closed vocabulary of one axis, with its TOML spelling.
20pub trait FacetTerm: Copy + Eq + Sized + 'static {
21    /// Every term, in declaration order (for ordinals, least-severe first).
22    fn all() -> &'static [Self];
23    /// The term's canonical TOML spelling.
24    fn as_str(self) -> &'static str;
25    /// Parse a term from its TOML spelling.
26    fn from_term(s: &str) -> Option<Self>;
27    /// The term a level is LEAST likely to admit — the one `Capability::worst` carries on this axis.
28    ///
29    /// Not derivable from the term list, which is why it is declared. Most ordinals run
30    /// least-severe to most, so the ladder top is the hazard; but `Isolation` and `Pinning` are
31    /// TRUST ladders where higher is safer and a level FLOORS them (`>= namespace`, `>= version`),
32    /// so their hazard is the BOTTOM. And a categorical has no order at all: `TriggerKind::None`
33    /// means "not recurring", the benign case, while `Clock`/`Event` are what persist.
34    ///
35    /// Hand-writing this per axis inside `worst()` is what let it drift — it carried
36    /// `TriggerKind::None`, so a clause allowing only non-recurring triggers admitted the
37    /// fail-closed sentinel on that axis.
38    fn hazard() -> Self;
39}
40
41macro_rules! ordinal_term {
42    (
43        $(#[$meta:meta])*
44        $name:ident { $first:ident => $fs:literal $(, $rest:ident => $rs:literal)* $(,)? }
45        $(hazard = $hz:ident;)?
46    ) => {
47        $(#[$meta])*
48        #[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
49        pub enum $name {
50            #[default]
51            $first,
52            $($rest),*
53        }
54        impl FacetTerm for $name {
55            fn all() -> &'static [Self] { &[Self::$first $(, Self::$rest)*] }
56            fn as_str(self) -> &'static str {
57                match self { Self::$first => $fs $(, Self::$rest => $rs)* }
58            }
59            fn from_term(s: &str) -> Option<Self> {
60                match s { $fs => Some(Self::$first), $($rs => Some(Self::$rest),)* _ => None }
61            }
62            fn hazard() -> Self {
63                // An explicit `hazard =` short-circuits; otherwise the ladder top is the hazard,
64                // which is right for every severity ladder and wrong for the two trust ladders
65                // (they declare it).
66                $( return Self::$hz; )?
67                #[allow(unreachable_code)]
68                { *Self::all().last().expect("a facet has at least one term") }
69            }
70        }
71    };
72}
73
74macro_rules! categorical_term {
75    (
76        $(#[$meta:meta])*
77        $name:ident { $first:ident => $fs:literal $(, $rest:ident => $rs:literal)* $(,)? }
78        hazard = $hz:ident;
79    ) => {
80        $(#[$meta])*
81        #[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
82        pub enum $name {
83            #[default]
84            $first,
85            $($rest),*
86        }
87        impl FacetTerm for $name {
88            fn all() -> &'static [Self] { &[Self::$first $(, Self::$rest)*] }
89            fn as_str(self) -> &'static str {
90                match self { Self::$first => $fs $(, Self::$rest => $rs)* }
91            }
92            fn from_term(s: &str) -> Option<Self> {
93                match s { $fs => Some(Self::$first), $($rs => Some(Self::$rest),)* _ => None }
94            }
95            fn hazard() -> Self { Self::$hz }
96        }
97    };
98}
99
100// ── 2.1 The act ────────────────────────────────────────────────────────────────
101
102categorical_term! {
103    /// The operation a capability performs (v1.4 §2.1). One per capability.
104    Operation {
105        Observe => "observe",
106        Create => "create",
107        Mutate => "mutate",
108        Destroy => "destroy",
109        Execute => "execute",
110        Communicate => "communicate",
111        Configure => "configure",   // change settings that alter future commands
112        Authorize => "authorize",   // change credentials/trust/access
113        Control => "control",       // start/stop/signal processes, services, devices
114    }
115    hazard = Communicate;
116}
117
118// ── 2.2 Reach ──────────────────────────────────────────────────────────────────
119
120ordinal_term! {
121    /// How deep into this host a capability reaches (v1.4 §2.2). `device`/`kernel`
122    /// void the abstractions the fs rungs assume and are deny-by-default everywhere.
123    LocalLocus {
124        Process => "process",
125        Temp => "temp",
126        SandboxScope => "sandbox-scope",
127        Worktree => "worktree",
128        Adjacent => "adjacent",                 // a SIBLING project's ORDINARY files (peer of the
129                                                // workspace under the same parent) — a co-located repo
130                                                // the agent reaches into. BELOW worktree-trusted: a peer
131                                                // source write (dev-loop) is LESS dangerous than a
132                                                // .git/hook write (which auto-executes), so a developer
133                                                // write clause `<= adjacent` must NOT reach the frozen tier.
134        WorktreeTrusted => "worktree-trusted",  // .git/, .envrc, hooks, CI configs — read-ok, WRITE-frozen
135        User => "user",                         // ~, keychain
136        Machine => "machine",                   // services, /etc app config, /usr/local — ordinary admin
137        SystemIntegrity => "system-integrity",  // identity/auth/boot/loader/system binaries — compromise-complete
138        Device => "device",                     // raw block/char devices
139        Kernel => "kernel",                     // module/extension load
140    }
141}
142
143ordinal_term! {
144    /// Which other host a capability reaches (v1.4 §2.2, the reach axis of `locus`).
145    RemoteReach {
146        None => "none",
147        Fixed => "fixed",
148        Arbitrary => "arbitrary",
149    }
150}
151
152categorical_term! {
153    /// Whether a remote target is named on the command line or taken from session
154    /// state — the pinned-vs-ambient bit `infra` gates on (v1.4 §2.2, HP-12).
155    RemoteBinding {
156        Na => "n/a",         // no remote reach
157        Pinned => "pinned",  // host/context/profile explicit on the command line
158        Ambient => "ambient",
159    }
160    hazard = Ambient;
161}
162
163ordinal_term! {
164    /// How the acted-on remote target was DESIGNATED — a *trust* ladder, orthogonal to
165    /// `RemoteReach` (breadth: one host vs any) and `RemoteBinding` (visibility: on the CLI
166    /// vs from session). Trust in a destination follows its provenance: what makes `git push`
167    /// safe is that the target is a pre-established root, not that data leaves. The
168    /// destination-aware resolver assigns it from the target argument (we do not read
169    /// `.git/config`); see `behavioral-taxonomy-exposure.md` §4.
170    Provenance {
171        Na => "n/a",                    // no designated remote target (a local op)
172        Established => "established",    // a stable handle set up by a prior deliberate act:
173                                         // a configured remote, a named context, a saved profile
174        Literal => "literal",           // spelled out in full at invocation (a URL/host typed
175                                         // now) — visible and reviewable, but injectable
176        Opaque => "opaque",             // from a variable / substitution — not visible in the
177                                         // command string, so unreviewable (fail-closed worst)
178    }
179}
180
181ordinal_term! {
182    /// How firmly a LOCAL path is pinned to the place it names — the local counterpart to
183    /// `Provenance`, which grades the same question for remote targets.
184    ///
185    /// A path built by interpolation (`$i`, `$(…)`) denotes whatever the value turns out to be, so
186    /// the classifier cannot read a region off it. `Opaque` is that case and is the fail-closed
187    /// worst. `Anchored` is the narrower one: the interpolation sits beside literal text inside its
188    /// own component AND its source cannot emit a separator, so the component is a filename
189    /// whatever the value is and the literal prefix decides the locus (`out/dx_$i.txt`). See
190    /// `neutralize_atoms` — that function is the only thing that can grant `Anchored`.
191    Anchoring {
192        Literal => "literal",    // every component spelled out
193        Anchored => "anchored",  // interpolated, but confined by a literal prefix and flanking
194        Opaque => "opaque",      // interpolated and unconstrained — could denote anything
195    }
196}
197
198ordinal_term! {
199    /// Breadth of effect (v1.4 §2.2). Modifies `destroy` *and* `disclosure` (R23).
200    Scale {
201        Single => "single",
202        Bounded => "bounded",      // a glob/dir/explicit list
203        Unbounded => "unbounded",  // recursion / mass op
204    }
205}
206
207ordinal_term! {
208    /// Granularity of what a READ retrieves — orthogonal to `scale` (which counts items) and
209    /// `secret` (which flags credentials). Distinguishes a metadata/descriptor read from the
210    /// retrieval of opaque STORED CONTENT the classifier cannot assess. `bulk-content` is a
211    /// stored blob (an S3 object, an EBS block, a Glacier archive) — routinely a secrets file,
212    /// private key, or DB dump, but unknowable statically — so it earns a proportionate tier
213    /// (network-admin: elevated remote egress) WITHOUT being conflated with a credential read
214    /// (`secret = reads` → yolo). `record` is structured data you asked for (query results, a
215    /// db dump); `metadata` is a descriptor (describe/list/get-config). See
216    /// docs/design/behavioral-taxonomy-archetypes.md §5 (#1).
217    RetrievalGranularity {
218        Metadata => "metadata",         // a descriptor: describe/list/get-config
219        Record => "record",             // structured data requested: query results, a db dump
220        BulkContent => "bulk-content",  // opaque stored bytes: an object/block/archive body
221    }
222}
223
224ordinal_term! {
225    /// Privilege the capability runs with (v1.4 §2.2).
226    Authority {
227        User => "user",
228        Elevated => "elevated",      // sudo/doas
229        Root => "root",
230        OtherUser => "other-user",   // setuid/run-as
231    }
232}
233
234ordinal_term! {
235    /// Isolation strength of an enclosing frame (v1.4 §2.2). A frame clamps nested
236    /// `locus` to `sandbox-scope`; breach flags re-add loci (§3.2).
237    Isolation {
238        None => "none",
239        View => "view",             // chroot
240        Namespace => "namespace",
241        Userns => "userns",
242        Vm => "vm",
243        Ocap => "ocap",
244    }
245    hazard = None;
246}
247
248// ── 2.3 Durability ─────────────────────────────────────────────────────────────
249
250ordinal_term! {
251    /// How hard the effect is to undo (v1.4 §2.3). Environment-dependent cases
252    /// resolve worst-case (HP-8).
253    Reversibility {
254        None => "none",              // pure observe
255        Trivial => "trivial",        // idempotent/undo
256        Recoverable => "recoverable",// VCS/recycle/snapshot
257        Effortful => "effortful",    // out-of-band backups only
258        Irreversible => "irreversible",
259    }
260}
261
262ordinal_term! {
263    /// What the capability leaves behind (v1.4 §2.3, the level axis of `persistence`).
264    PersistenceLevel {
265        Transient => "transient",
266        Data => "data",
267        Reconfiguring => "reconfiguring",  // alters future commands
268        Installing => "installing",        // adds executables/services/hooks
269    }
270}
271
272ordinal_term! {
273    /// How far execution escapes the check (v1.4 §2.3, R16/R24) — the part levels gate.
274    TriggerEscape {
275        Immediate => "immediate",  // done on return
276        Detached => "detached",    // one instance survives the session (nohup/setsid)
277        Recurring => "recurring",  // re-fires until removed
278        Boot => "boot",            // re-fires and survives reboot (systemctl enable, @reboot)
279    }
280}
281
282categorical_term! {
283    /// The kind of recurrence (v1.4 §2.3) — for the `because` string, not a severity
284    /// rung: a per-save `event` can fire more often than a monthly `clock`.
285    TriggerKind {
286        None => "none",     // not recurring
287        Clock => "clock",   // cron, at
288        Event => "event",   // watchexec, git hooks, .envrc on cd
289    }
290    hazard = Clock;
291}
292
293// ── 2.4 Information exposure ────────────────────────────────────────────────────
294
295ordinal_term! {
296    /// Who ends up able to see disclosed output (v1.4 §2.4). `local-process` is
297    /// stdout → the agent/model provider — the HP-15 audience that gates secret reads.
298    DisclosureAudience {
299        None => "none",
300        LocalProcess => "local-process",       // stdout → the agent/model
301        LocalPersistent => "local-persistent", // other local users
302        TrustedRemote => "trusted-remote",
303        SharedRemote => "shared-remote",
304        Public => "public",
305    }
306}
307
308ordinal_term! {
309    /// A capability's relationship to secret material (v1.4 §2.4).
310    SecretLevel {
311        None => "none",
312        UsesAmbient => "uses-ambient",
313        Reads => "reads",
314        Writes => "writes",
315        Transmits => "transmits",
316    }
317}
318
319categorical_term! {
320    /// The channel a disclosure or secret flows over (v1.4 §2.4). An **open set**:
321    /// an unrecognized/covert channel is `Unknown` and worst-cased by the resolver.
322    Channel {
323        None => "none",
324        Filesystem => "filesystem",
325        StdoutToModel => "stdout-to-model",
326        Network => "network",
327        Clipboard => "clipboard",              // pbcopy/pbpaste
328        Ipc => "ipc",
329        CredentialStore => "credential-store", // keychain
330        CrossProcess => "cross-process",       // lldb -p, /proc/*/mem
331        Unknown => "unknown",
332    }
333    hazard = Unknown;
334}
335
336categorical_term! {
337    /// Whose data a read touches (v1.4 §2.4) — a read can cross a principal boundary
338    /// on the same host (another process's memory/argv) with no fs or network touch.
339    Principal {
340        Own => "own",
341        Cross => "cross",
342    }
343    hazard = Cross;
344}
345
346// ── 2.5 Channel (network) ──────────────────────────────────────────────────────
347
348ordinal_term! {
349    /// Network direction (v1.4 §2.5).
350    NetDirection {
351        None => "none",
352        Loopback => "loopback",
353        Outbound => "outbound",
354        InboundListen => "inbound-listen",
355    }
356}
357
358ordinal_term! {
359    /// Network destination (v1.4 §2.5). Same axis as `locus.remote` reach.
360    NetDestination {
361        Na => "n/a",
362        Fixed => "fixed",
363        Arbitrary => "arbitrary",
364    }
365}
366
367ordinal_term! {
368    /// What a network capability carries (v1.4 §2.5).
369    NetPayload {
370        None => "none",
371        Fetches => "fetches",
372        SendsHostData => "sends-host-data",
373    }
374}
375
376// ── 2.6 Code provenance ────────────────────────────────────────────────────────
377
378ordinal_term! {
379    /// Where executed code comes from (v1.4 §2.6, local-trust ladder). When
380    /// `NetworkSourced`, the supply-chain sub-facets ([`SupplyChain`]) refine it.
381    ExecutionTrust {
382        None => "none",
383        SelfCode => "self",
384        CallerInline => "caller-inline",
385        CallerFile => "caller-file",
386        AmbientConfig => "ambient-config",   // Makefile/hooks/.envrc/plugins
387        NetworkSourced => "network-sourced",
388    }
389}
390
391categorical_term! {
392    /// Where network-sourced code came from (v1.4 §2.6). Categorical — a level lists
393    /// the sources it accepts rather than assuming a severity order.
394    SupplySource {
395        UnverifiedUrl => "unverified-url",
396        PublicRegistry => "public-registry",
397        SignedRepo => "signed-repo",
398        PrivateRegistry => "private-registry",
399        Vendored => "vendored",
400    }
401    hazard = UnverifiedUrl;
402}
403
404ordinal_term! {
405    /// How tightly a fetched artifact is pinned (v1.4 §2.6). A *trust* ladder: higher
406    /// is safer, so a level floors it (`>= version`) rather than ceilings it.
407    Pinning {
408        Floating => "floating",
409        Version => "version",
410        HashVerified => "hash-verified",
411        Digest => "digest",
412    }
413    hazard = Floating;
414}
415
416categorical_term! {
417    /// When/what fetched code runs (v1.4 §2.6). Categorical — the risk order across
418    /// install-hook / build-script / call-time / run-artifact is genuinely unclear, so
419    /// a level lists the surfaces it accepts instead of ceiling-ing a false ladder.
420    ExecSurface {
421        None => "none",
422        InstallHook => "install-hook",   // code on install (npm lifecycle, pip setup.py)
423        BuildScript => "build-script",   // code on build (cargo build.rs, node-gyp)
424        CallTime => "call-time",         // deps' code runs only when your program runs
425        RunArtifact => "run-artifact",   // you execute the fetched binary/image
426    }
427    hazard = InstallHook;
428}
429
430// ── 2.7 Resource ───────────────────────────────────────────────────────────────
431
432ordinal_term! {
433    /// Resource/billing cost (v1.4 §2.7). Populated for provisioning tools.
434    Cost {
435        None => "none",
436        LocalResource => "local-resource",
437        Metered => "metered",   // billable
438        Quota => "quota",
439    }
440}
441
442// ── 2.8 Compound facets & the capability record ────────────────────────────────
443
444/// Reach — two independent axes (v1.4 §2.2, R25).
445#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
446pub struct Locus {
447    pub local: LocalLocus,
448    pub remote: RemoteReach,
449    pub binding: RemoteBinding,
450    pub provenance: Provenance,
451}
452
453/// Durability trigger — how far execution escapes, and (if recurring) what kind.
454#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
455pub struct Trigger {
456    pub escape: TriggerEscape,
457    pub kind: TriggerKind,
458}
459
460/// What a capability leaves behind, and when it re-fires (v1.4 §2.3).
461#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
462pub struct Persistence {
463    pub level: PersistenceLevel,
464    pub trigger: Trigger,
465}
466
467/// Where disclosed output goes, over which channel, whose data (v1.4 §2.4).
468#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
469pub struct Disclosure {
470    pub audience: DisclosureAudience,
471    pub channel: Channel,
472    pub principal: Principal,
473}
474
475/// A capability's relationship to secrets, over which channel, whose (v1.4 §2.4).
476#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
477pub struct Secret {
478    pub level: SecretLevel,
479    pub channel: Channel,
480    pub principal: Principal,
481}
482
483/// A network capability's shape (v1.4 §2.5).
484#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
485pub struct Network {
486    pub direction: NetDirection,
487    pub destination: NetDestination,
488    pub payload: NetPayload,
489}
490
491/// The provenance of network-sourced code (v1.4 §2.6).
492#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
493pub struct SupplyChain {
494    pub source: SupplySource,
495    pub pinning: Pinning,
496    pub exec_surface: ExecSurface,
497}
498
499/// Code provenance: the local-trust rung, plus supply-chain detail when the code is
500/// network-sourced (v1.4 §2.6). `supply_chain` is present only for network-sourced
501/// execution — a command running no downloaded code leaves it `None`, and a level's
502/// supply-chain constraints are then vacuously satisfied.
503#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
504pub struct Execution {
505    pub trust: ExecutionTrust,
506    pub supply_chain: Option<SupplyChain>,
507}
508
509/// One capability — a single point in facet-space (v1.4 §2.8). Facets left unset
510/// default to their zero term. `because` cites the discriminator (§5); the nested
511/// delegate profile and supply-chain sub-facets arrive with the mechanisms that
512/// need them.
513#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
514pub struct Capability {
515    pub operation: Operation,
516    pub locus: Locus,
517    pub scale: Scale,
518    pub retrieval: RetrievalGranularity,
519    pub authority: Authority,
520    pub isolation: Isolation,
521    pub reversibility: Reversibility,
522    pub persistence: Persistence,
523    pub disclosure: Disclosure,
524    pub secret: Secret,
525    pub network: Network,
526    pub execution: Execution,
527    pub cost: Cost,
528    pub because: String,
529}
530
531impl Capability {
532    /// A capability performing `operation`, every other facet at its zero term.
533    pub fn new(operation: Operation) -> Self {
534        Self { operation, ..Self::default() }
535    }
536
537    /// The facets this capability actually SETS, as `(dotted name, term)` pairs.
538    ///
539    /// Only non-default terms: a capability is a point in 27-dimensional space and almost all of
540    /// those dimensions sit at their zero term, so printing every one buries the four or five that
541    /// characterize it. `because` carries the prose; this carries the point.
542    pub fn set_facets(&self) -> Vec<(&'static str, &'static str)> {
543        let d = Self::default();
544        let mut out: Vec<(&'static str, &'static str)> = Vec::new();
545        macro_rules! push {
546            ($name:literal, $field:expr, $dflt:expr) => {
547                if $field != $dflt {
548                    out.push(($name, $field.as_str()));
549                }
550            };
551        }
552        // `operation` is always shown: it is what the capability IS, even at the zero term.
553        out.push(("operation", self.operation.as_str()));
554        push!("locus.local", self.locus.local, d.locus.local);
555        push!("locus.remote", self.locus.remote, d.locus.remote);
556        push!("locus.binding", self.locus.binding, d.locus.binding);
557        push!("locus.provenance", self.locus.provenance, d.locus.provenance);
558        push!("scale", self.scale, d.scale);
559        push!("retrieval", self.retrieval, d.retrieval);
560        push!("authority", self.authority, d.authority);
561        push!("isolation", self.isolation, d.isolation);
562        push!("reversibility", self.reversibility, d.reversibility);
563        push!("persistence.level", self.persistence.level, d.persistence.level);
564        push!("persistence.trigger.escape", self.persistence.trigger.escape, d.persistence.trigger.escape);
565        push!("persistence.trigger.kind", self.persistence.trigger.kind, d.persistence.trigger.kind);
566        push!("disclosure.audience", self.disclosure.audience, d.disclosure.audience);
567        push!("disclosure.channel", self.disclosure.channel, d.disclosure.channel);
568        push!("disclosure.principal", self.disclosure.principal, d.disclosure.principal);
569        push!("secret.level", self.secret.level, d.secret.level);
570        push!("secret.channel", self.secret.channel, d.secret.channel);
571        push!("secret.principal", self.secret.principal, d.secret.principal);
572        push!("network.direction", self.network.direction, d.network.direction);
573        push!("network.destination", self.network.destination, d.network.destination);
574        push!("network.payload", self.network.payload, d.network.payload);
575        push!("execution.trust", self.execution.trust, d.execution.trust);
576        push!("cost", self.cost, d.cost);
577        if let Some(sc) = self.execution.supply_chain {
578            out.push(("supply_chain.source", sc.source.as_str()));
579            out.push(("supply_chain.pinning", sc.pinning.as_str()));
580            out.push(("supply_chain.exec_surface", sc.exec_surface.as_str()));
581        }
582        out
583    }
584
585    /// A maximally-severe capability — every axis at its declared `hazard`, so no level whose job
586    /// is to deny admits it. `yolo` DOES admit it, which is that level's whole meaning: a user who
587    /// selects "auto-approve everything" has opted out of the question this sentinel answers.
588    ///
589    /// Derived from `FacetTerm::hazard` rather than hand-written. The hand-written version drifted
590    /// on two axes — `persistence.trigger.kind` sat at `none` ("not recurring", the BENIGN case)
591    /// and the supply chain was absent, which satisfies every supply-chain constraint vacuously on
592    /// an allow clause. Neither was exploitable, because the denial rested on `locus.local =
593    /// kernel`; that it rested on a single axis at all is what
594    /// `the_sentinel_is_denied_even_with_any_one_axis_relaxed` now forbids.
595    /// The resolver returns this when it cannot certify something (§0), keeping the
596    /// engine from being *looser* than a strict classifier on a form it doesn't
597    /// understand. Ordinal worsts are the ladder tops; categorical worsts are the
598    /// hazardous term (`Channel::Unknown`, `Principal::Cross`).
599    pub fn worst(because: impl Into<String>) -> Self {
600        Self {
601            operation: Operation::hazard(),
602            locus: Locus {
603                local: LocalLocus::hazard(),
604                remote: RemoteReach::hazard(),
605                binding: RemoteBinding::hazard(),
606                provenance: Provenance::hazard(),
607            },
608            scale: Scale::hazard(),
609            retrieval: RetrievalGranularity::hazard(),
610            authority: Authority::hazard(),
611            isolation: Isolation::hazard(),
612            reversibility: Reversibility::hazard(),
613            persistence: Persistence {
614                level: PersistenceLevel::hazard(),
615                trigger: Trigger { escape: TriggerEscape::hazard(), kind: TriggerKind::hazard() },
616            },
617            disclosure: Disclosure {
618                audience: DisclosureAudience::hazard(),
619                channel: Channel::hazard(),
620                principal: Principal::hazard(),
621            },
622            secret: Secret {
623                level: SecretLevel::hazard(),
624                channel: Channel::hazard(),
625                principal: Principal::hazard(),
626            },
627            network: Network {
628                direction: NetDirection::hazard(),
629                destination: NetDestination::hazard(),
630                payload: NetPayload::hazard(),
631            },
632            execution: Execution {
633                trust: ExecutionTrust::hazard(),
634                // PRESENT, not `None`. An absent supply chain satisfies every supply-chain
635                // constraint vacuously on the allow path, which would leave the install/RCE surface
636                // as the one axis where the fail-closed sentinel is not actually worst.
637                supply_chain: Some(SupplyChain {
638                    source: SupplySource::hazard(),
639                    pinning: Pinning::hazard(),
640                    exec_surface: ExecSurface::hazard(),
641                }),
642            },
643            cost: Cost::hazard(),
644            because: because.into(),
645        }
646    }
647}
648
649/// The set of capabilities a resolved command line exhibits (v1.4 §2.8, §4.1). A
650/// profile passes a level iff *every* capability is admissible.
651#[derive(Clone, Debug, Default, PartialEq, Eq)]
652pub struct Profile {
653    pub capabilities: Vec<Capability>,
654}
655
656impl Profile {
657    /// A profile of exactly these capabilities.
658    pub fn of(capabilities: Vec<Capability>) -> Self {
659        Self { capabilities }
660    }
661}
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666
667    fn assert_term_strings_roundtrip<T: FacetTerm + std::fmt::Debug>() {
668        for &term in T::all() {
669            assert_eq!(
670                T::from_term(term.as_str()),
671                Some(term),
672                "term {:?} did not round-trip through {:?}",
673                term,
674                term.as_str(),
675            );
676        }
677        for (i, &a) in T::all().iter().enumerate() {
678            for &b in &T::all()[i + 1..] {
679                assert_ne!(a.as_str(), b.as_str(), "two variants share a TOML spelling");
680            }
681        }
682        assert_eq!(T::from_term("definitely-not-a-term"), None);
683    }
684
685    fn assert_zero_is_minimum<T: FacetTerm + Ord + Default + std::fmt::Debug>() {
686        let zero = T::all()[0];
687        assert_eq!(T::default(), zero, "Default must be the zero term (first variant)");
688        for &term in T::all() {
689            assert!(zero <= term, "zero term {zero:?} is not <= {term:?}");
690        }
691    }
692
693    #[test]
694    fn every_term_roundtrips_and_is_uniquely_spelled() {
695        assert_term_strings_roundtrip::<Operation>();
696        assert_term_strings_roundtrip::<LocalLocus>();
697        assert_term_strings_roundtrip::<RemoteReach>();
698        assert_term_strings_roundtrip::<RemoteBinding>();
699        assert_term_strings_roundtrip::<Provenance>();
700        assert_term_strings_roundtrip::<Anchoring>();
701        assert_term_strings_roundtrip::<Scale>();
702        assert_term_strings_roundtrip::<RetrievalGranularity>();
703        assert_term_strings_roundtrip::<Authority>();
704        assert_term_strings_roundtrip::<Isolation>();
705        assert_term_strings_roundtrip::<Reversibility>();
706        assert_term_strings_roundtrip::<PersistenceLevel>();
707        assert_term_strings_roundtrip::<TriggerEscape>();
708        assert_term_strings_roundtrip::<TriggerKind>();
709        assert_term_strings_roundtrip::<DisclosureAudience>();
710        assert_term_strings_roundtrip::<SecretLevel>();
711        assert_term_strings_roundtrip::<Channel>();
712        assert_term_strings_roundtrip::<Principal>();
713        assert_term_strings_roundtrip::<NetDirection>();
714        assert_term_strings_roundtrip::<NetDestination>();
715        assert_term_strings_roundtrip::<NetPayload>();
716        assert_term_strings_roundtrip::<ExecutionTrust>();
717        assert_term_strings_roundtrip::<SupplySource>();
718        assert_term_strings_roundtrip::<Pinning>();
719        assert_term_strings_roundtrip::<ExecSurface>();
720        assert_term_strings_roundtrip::<Cost>();
721    }
722
723    #[test]
724    fn ordinal_zero_terms_are_the_minimum() {
725        assert_zero_is_minimum::<LocalLocus>();
726        assert_zero_is_minimum::<RemoteReach>();
727        assert_zero_is_minimum::<Provenance>();
728        assert_zero_is_minimum::<Anchoring>();
729        assert_zero_is_minimum::<Scale>();
730        assert_zero_is_minimum::<RetrievalGranularity>();
731        assert_zero_is_minimum::<Authority>();
732        assert_zero_is_minimum::<Isolation>();
733        assert_zero_is_minimum::<Reversibility>();
734        assert_zero_is_minimum::<PersistenceLevel>();
735        assert_zero_is_minimum::<TriggerEscape>();
736        assert_zero_is_minimum::<DisclosureAudience>();
737        assert_zero_is_minimum::<SecretLevel>();
738        assert_zero_is_minimum::<NetDirection>();
739        assert_zero_is_minimum::<NetDestination>();
740        assert_zero_is_minimum::<NetPayload>();
741        assert_zero_is_minimum::<ExecutionTrust>();
742        assert_zero_is_minimum::<Pinning>();
743        assert_zero_is_minimum::<Cost>();
744    }
745
746    #[test]
747    fn ordinal_ladders_match_the_spec() {
748        assert!(LocalLocus::Process < LocalLocus::Worktree);
749        assert!(LocalLocus::Worktree < LocalLocus::Adjacent);
750        assert!(LocalLocus::Adjacent < LocalLocus::WorktreeTrusted);
751        assert!(LocalLocus::WorktreeTrusted < LocalLocus::User);
752        assert!(LocalLocus::Worktree < LocalLocus::Machine);
753        assert!(LocalLocus::Machine < LocalLocus::SystemIntegrity);
754        assert!(LocalLocus::SystemIntegrity < LocalLocus::Device);
755        assert!(LocalLocus::Device < LocalLocus::Kernel);
756        assert!(Scale::Single < Scale::Bounded && Scale::Bounded < Scale::Unbounded);
757        assert!(RetrievalGranularity::Metadata < RetrievalGranularity::Record);
758        assert!(RetrievalGranularity::Record < RetrievalGranularity::BulkContent);
759        assert!(Authority::User < Authority::Root && Authority::Root < Authority::OtherUser);
760        assert!(Reversibility::Recoverable < Reversibility::Irreversible);
761        assert!(PersistenceLevel::Data < PersistenceLevel::Installing);
762        assert!(TriggerEscape::Immediate < TriggerEscape::Boot);
763        assert!(DisclosureAudience::LocalProcess < DisclosureAudience::Public);
764        assert!(Provenance::Na < Provenance::Established);
765        assert!(Provenance::Established < Provenance::Literal);
766        assert!(Provenance::Literal < Provenance::Opaque);
767        assert!(SecretLevel::Reads < SecretLevel::Transmits);
768        assert!(ExecutionTrust::SelfCode < ExecutionTrust::NetworkSourced);
769        assert!(Pinning::Floating < Pinning::HashVerified);
770    }
771
772    #[test]
773    fn capability_new_leaves_all_other_facets_at_zero() {
774        let cap = Capability::new(Operation::Destroy);
775        assert_eq!(cap.operation, Operation::Destroy);
776        assert_eq!(cap.locus, Locus::default());
777        assert_eq!(cap.locus.local, LocalLocus::Process);
778        assert_eq!(cap.scale, Scale::Single);
779        assert_eq!(cap.retrieval, RetrievalGranularity::Metadata);
780        assert_eq!(cap.authority, Authority::User);
781        assert_eq!(cap.reversibility, Reversibility::None);
782        assert_eq!(cap.secret.level, SecretLevel::None);
783        assert_eq!(cap.disclosure.audience, DisclosureAudience::None);
784        assert_eq!(cap.network.direction, NetDirection::None);
785        assert_eq!(cap.execution.trust, ExecutionTrust::None);
786        assert!(cap.execution.supply_chain.is_none());
787        assert_eq!(cap.cost, Cost::None);
788        assert!(cap.because.is_empty());
789    }
790
791    #[test]
792    fn default_capability_is_a_zero_observe() {
793        assert_eq!(Capability::default().operation, Operation::Observe);
794        assert_eq!(Capability::default(), Capability::new(Operation::Observe));
795    }
796}