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