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    /// Breadth of effect (v1.4 §2.2). Modifies `destroy` *and* `disclosure` (R23).
183    Scale {
184        Single => "single",
185        Bounded => "bounded",      // a glob/dir/explicit list
186        Unbounded => "unbounded",  // recursion / mass op
187    }
188}
189
190ordinal_term! {
191    /// Granularity of what a READ retrieves — orthogonal to `scale` (which counts items) and
192    /// `secret` (which flags credentials). Distinguishes a metadata/descriptor read from the
193    /// retrieval of opaque STORED CONTENT the classifier cannot assess. `bulk-content` is a
194    /// stored blob (an S3 object, an EBS block, a Glacier archive) — routinely a secrets file,
195    /// private key, or DB dump, but unknowable statically — so it earns a proportionate tier
196    /// (network-admin: elevated remote egress) WITHOUT being conflated with a credential read
197    /// (`secret = reads` → yolo). `record` is structured data you asked for (query results, a
198    /// db dump); `metadata` is a descriptor (describe/list/get-config). See
199    /// docs/design/behavioral-taxonomy-archetypes.md §5 (#1).
200    RetrievalGranularity {
201        Metadata => "metadata",         // a descriptor: describe/list/get-config
202        Record => "record",             // structured data requested: query results, a db dump
203        BulkContent => "bulk-content",  // opaque stored bytes: an object/block/archive body
204    }
205}
206
207ordinal_term! {
208    /// Privilege the capability runs with (v1.4 §2.2).
209    Authority {
210        User => "user",
211        Elevated => "elevated",      // sudo/doas
212        Root => "root",
213        OtherUser => "other-user",   // setuid/run-as
214    }
215}
216
217ordinal_term! {
218    /// Isolation strength of an enclosing frame (v1.4 §2.2). A frame clamps nested
219    /// `locus` to `sandbox-scope`; breach flags re-add loci (§3.2).
220    Isolation {
221        None => "none",
222        View => "view",             // chroot
223        Namespace => "namespace",
224        Userns => "userns",
225        Vm => "vm",
226        Ocap => "ocap",
227    }
228    hazard = None;
229}
230
231// ── 2.3 Durability ─────────────────────────────────────────────────────────────
232
233ordinal_term! {
234    /// How hard the effect is to undo (v1.4 §2.3). Environment-dependent cases
235    /// resolve worst-case (HP-8).
236    Reversibility {
237        None => "none",              // pure observe
238        Trivial => "trivial",        // idempotent/undo
239        Recoverable => "recoverable",// VCS/recycle/snapshot
240        Effortful => "effortful",    // out-of-band backups only
241        Irreversible => "irreversible",
242    }
243}
244
245ordinal_term! {
246    /// What the capability leaves behind (v1.4 §2.3, the level axis of `persistence`).
247    PersistenceLevel {
248        Transient => "transient",
249        Data => "data",
250        Reconfiguring => "reconfiguring",  // alters future commands
251        Installing => "installing",        // adds executables/services/hooks
252    }
253}
254
255ordinal_term! {
256    /// How far execution escapes the check (v1.4 §2.3, R16/R24) — the part levels gate.
257    TriggerEscape {
258        Immediate => "immediate",  // done on return
259        Detached => "detached",    // one instance survives the session (nohup/setsid)
260        Recurring => "recurring",  // re-fires until removed
261        Boot => "boot",            // re-fires and survives reboot (systemctl enable, @reboot)
262    }
263}
264
265categorical_term! {
266    /// The kind of recurrence (v1.4 §2.3) — for the `because` string, not a severity
267    /// rung: a per-save `event` can fire more often than a monthly `clock`.
268    TriggerKind {
269        None => "none",     // not recurring
270        Clock => "clock",   // cron, at
271        Event => "event",   // watchexec, git hooks, .envrc on cd
272    }
273    hazard = Clock;
274}
275
276// ── 2.4 Information exposure ────────────────────────────────────────────────────
277
278ordinal_term! {
279    /// Who ends up able to see disclosed output (v1.4 §2.4). `local-process` is
280    /// stdout → the agent/model provider — the HP-15 audience that gates secret reads.
281    DisclosureAudience {
282        None => "none",
283        LocalProcess => "local-process",       // stdout → the agent/model
284        LocalPersistent => "local-persistent", // other local users
285        TrustedRemote => "trusted-remote",
286        SharedRemote => "shared-remote",
287        Public => "public",
288    }
289}
290
291ordinal_term! {
292    /// A capability's relationship to secret material (v1.4 §2.4).
293    SecretLevel {
294        None => "none",
295        UsesAmbient => "uses-ambient",
296        Reads => "reads",
297        Writes => "writes",
298        Transmits => "transmits",
299    }
300}
301
302categorical_term! {
303    /// The channel a disclosure or secret flows over (v1.4 §2.4). An **open set**:
304    /// an unrecognized/covert channel is `Unknown` and worst-cased by the resolver.
305    Channel {
306        None => "none",
307        Filesystem => "filesystem",
308        StdoutToModel => "stdout-to-model",
309        Network => "network",
310        Clipboard => "clipboard",              // pbcopy/pbpaste
311        Ipc => "ipc",
312        CredentialStore => "credential-store", // keychain
313        CrossProcess => "cross-process",       // lldb -p, /proc/*/mem
314        Unknown => "unknown",
315    }
316    hazard = Unknown;
317}
318
319categorical_term! {
320    /// Whose data a read touches (v1.4 §2.4) — a read can cross a principal boundary
321    /// on the same host (another process's memory/argv) with no fs or network touch.
322    Principal {
323        Own => "own",
324        Cross => "cross",
325    }
326    hazard = Cross;
327}
328
329// ── 2.5 Channel (network) ──────────────────────────────────────────────────────
330
331ordinal_term! {
332    /// Network direction (v1.4 §2.5).
333    NetDirection {
334        None => "none",
335        Loopback => "loopback",
336        Outbound => "outbound",
337        InboundListen => "inbound-listen",
338    }
339}
340
341ordinal_term! {
342    /// Network destination (v1.4 §2.5). Same axis as `locus.remote` reach.
343    NetDestination {
344        Na => "n/a",
345        Fixed => "fixed",
346        Arbitrary => "arbitrary",
347    }
348}
349
350ordinal_term! {
351    /// What a network capability carries (v1.4 §2.5).
352    NetPayload {
353        None => "none",
354        Fetches => "fetches",
355        SendsHostData => "sends-host-data",
356    }
357}
358
359// ── 2.6 Code provenance ────────────────────────────────────────────────────────
360
361ordinal_term! {
362    /// Where executed code comes from (v1.4 §2.6, local-trust ladder). When
363    /// `NetworkSourced`, the supply-chain sub-facets ([`SupplyChain`]) refine it.
364    ExecutionTrust {
365        None => "none",
366        SelfCode => "self",
367        CallerInline => "caller-inline",
368        CallerFile => "caller-file",
369        AmbientConfig => "ambient-config",   // Makefile/hooks/.envrc/plugins
370        NetworkSourced => "network-sourced",
371    }
372}
373
374categorical_term! {
375    /// Where network-sourced code came from (v1.4 §2.6). Categorical — a level lists
376    /// the sources it accepts rather than assuming a severity order.
377    SupplySource {
378        UnverifiedUrl => "unverified-url",
379        PublicRegistry => "public-registry",
380        SignedRepo => "signed-repo",
381        PrivateRegistry => "private-registry",
382        Vendored => "vendored",
383    }
384    hazard = UnverifiedUrl;
385}
386
387ordinal_term! {
388    /// How tightly a fetched artifact is pinned (v1.4 §2.6). A *trust* ladder: higher
389    /// is safer, so a level floors it (`>= version`) rather than ceilings it.
390    Pinning {
391        Floating => "floating",
392        Version => "version",
393        HashVerified => "hash-verified",
394        Digest => "digest",
395    }
396    hazard = Floating;
397}
398
399categorical_term! {
400    /// When/what fetched code runs (v1.4 §2.6). Categorical — the risk order across
401    /// install-hook / build-script / call-time / run-artifact is genuinely unclear, so
402    /// a level lists the surfaces it accepts instead of ceiling-ing a false ladder.
403    ExecSurface {
404        None => "none",
405        InstallHook => "install-hook",   // code on install (npm lifecycle, pip setup.py)
406        BuildScript => "build-script",   // code on build (cargo build.rs, node-gyp)
407        CallTime => "call-time",         // deps' code runs only when your program runs
408        RunArtifact => "run-artifact",   // you execute the fetched binary/image
409    }
410    hazard = InstallHook;
411}
412
413// ── 2.7 Resource ───────────────────────────────────────────────────────────────
414
415ordinal_term! {
416    /// Resource/billing cost (v1.4 §2.7). Populated for provisioning tools.
417    Cost {
418        None => "none",
419        LocalResource => "local-resource",
420        Metered => "metered",   // billable
421        Quota => "quota",
422    }
423}
424
425// ── 2.8 Compound facets & the capability record ────────────────────────────────
426
427/// Reach — two independent axes (v1.4 §2.2, R25).
428#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
429pub struct Locus {
430    pub local: LocalLocus,
431    pub remote: RemoteReach,
432    pub binding: RemoteBinding,
433    pub provenance: Provenance,
434}
435
436/// Durability trigger — how far execution escapes, and (if recurring) what kind.
437#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
438pub struct Trigger {
439    pub escape: TriggerEscape,
440    pub kind: TriggerKind,
441}
442
443/// What a capability leaves behind, and when it re-fires (v1.4 §2.3).
444#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
445pub struct Persistence {
446    pub level: PersistenceLevel,
447    pub trigger: Trigger,
448}
449
450/// Where disclosed output goes, over which channel, whose data (v1.4 §2.4).
451#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
452pub struct Disclosure {
453    pub audience: DisclosureAudience,
454    pub channel: Channel,
455    pub principal: Principal,
456}
457
458/// A capability's relationship to secrets, over which channel, whose (v1.4 §2.4).
459#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
460pub struct Secret {
461    pub level: SecretLevel,
462    pub channel: Channel,
463    pub principal: Principal,
464}
465
466/// A network capability's shape (v1.4 §2.5).
467#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
468pub struct Network {
469    pub direction: NetDirection,
470    pub destination: NetDestination,
471    pub payload: NetPayload,
472}
473
474/// The provenance of network-sourced code (v1.4 §2.6).
475#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
476pub struct SupplyChain {
477    pub source: SupplySource,
478    pub pinning: Pinning,
479    pub exec_surface: ExecSurface,
480}
481
482/// Code provenance: the local-trust rung, plus supply-chain detail when the code is
483/// network-sourced (v1.4 §2.6). `supply_chain` is present only for network-sourced
484/// execution — a command running no downloaded code leaves it `None`, and a level's
485/// supply-chain constraints are then vacuously satisfied.
486#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
487pub struct Execution {
488    pub trust: ExecutionTrust,
489    pub supply_chain: Option<SupplyChain>,
490}
491
492/// One capability — a single point in facet-space (v1.4 §2.8). Facets left unset
493/// default to their zero term. `because` cites the discriminator (§5); the nested
494/// delegate profile and supply-chain sub-facets arrive with the mechanisms that
495/// need them.
496#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
497pub struct Capability {
498    pub operation: Operation,
499    pub locus: Locus,
500    pub scale: Scale,
501    pub retrieval: RetrievalGranularity,
502    pub authority: Authority,
503    pub isolation: Isolation,
504    pub reversibility: Reversibility,
505    pub persistence: Persistence,
506    pub disclosure: Disclosure,
507    pub secret: Secret,
508    pub network: Network,
509    pub execution: Execution,
510    pub cost: Cost,
511    pub because: String,
512}
513
514impl Capability {
515    /// A capability performing `operation`, every other facet at its zero term.
516    pub fn new(operation: Operation) -> Self {
517        Self { operation, ..Self::default() }
518    }
519
520    /// The facets this capability actually SETS, as `(dotted name, term)` pairs.
521    ///
522    /// Only non-default terms: a capability is a point in 27-dimensional space and almost all of
523    /// those dimensions sit at their zero term, so printing every one buries the four or five that
524    /// characterize it. `because` carries the prose; this carries the point.
525    pub fn set_facets(&self) -> Vec<(&'static str, &'static str)> {
526        let d = Self::default();
527        let mut out: Vec<(&'static str, &'static str)> = Vec::new();
528        macro_rules! push {
529            ($name:literal, $field:expr, $dflt:expr) => {
530                if $field != $dflt {
531                    out.push(($name, $field.as_str()));
532                }
533            };
534        }
535        // `operation` is always shown: it is what the capability IS, even at the zero term.
536        out.push(("operation", self.operation.as_str()));
537        push!("locus.local", self.locus.local, d.locus.local);
538        push!("locus.remote", self.locus.remote, d.locus.remote);
539        push!("locus.binding", self.locus.binding, d.locus.binding);
540        push!("locus.provenance", self.locus.provenance, d.locus.provenance);
541        push!("scale", self.scale, d.scale);
542        push!("retrieval", self.retrieval, d.retrieval);
543        push!("authority", self.authority, d.authority);
544        push!("isolation", self.isolation, d.isolation);
545        push!("reversibility", self.reversibility, d.reversibility);
546        push!("persistence.level", self.persistence.level, d.persistence.level);
547        push!("persistence.trigger.escape", self.persistence.trigger.escape, d.persistence.trigger.escape);
548        push!("persistence.trigger.kind", self.persistence.trigger.kind, d.persistence.trigger.kind);
549        push!("disclosure.audience", self.disclosure.audience, d.disclosure.audience);
550        push!("disclosure.channel", self.disclosure.channel, d.disclosure.channel);
551        push!("disclosure.principal", self.disclosure.principal, d.disclosure.principal);
552        push!("secret.level", self.secret.level, d.secret.level);
553        push!("secret.channel", self.secret.channel, d.secret.channel);
554        push!("secret.principal", self.secret.principal, d.secret.principal);
555        push!("network.direction", self.network.direction, d.network.direction);
556        push!("network.destination", self.network.destination, d.network.destination);
557        push!("network.payload", self.network.payload, d.network.payload);
558        push!("execution.trust", self.execution.trust, d.execution.trust);
559        push!("cost", self.cost, d.cost);
560        if let Some(sc) = self.execution.supply_chain {
561            out.push(("supply_chain.source", sc.source.as_str()));
562            out.push(("supply_chain.pinning", sc.pinning.as_str()));
563            out.push(("supply_chain.exec_surface", sc.exec_surface.as_str()));
564        }
565        out
566    }
567
568    /// A maximally-severe capability — every axis at its declared `hazard`, so no level whose job
569    /// is to deny admits it. `yolo` DOES admit it, which is that level's whole meaning: a user who
570    /// selects "auto-approve everything" has opted out of the question this sentinel answers.
571    ///
572    /// Derived from `FacetTerm::hazard` rather than hand-written. The hand-written version drifted
573    /// on two axes — `persistence.trigger.kind` sat at `none` ("not recurring", the BENIGN case)
574    /// and the supply chain was absent, which satisfies every supply-chain constraint vacuously on
575    /// an allow clause. Neither was exploitable, because the denial rested on `locus.local =
576    /// kernel`; that it rested on a single axis at all is what
577    /// `the_sentinel_is_denied_even_with_any_one_axis_relaxed` now forbids.
578    /// The resolver returns this when it cannot certify something (§0), keeping the
579    /// engine from being *looser* than a strict classifier on a form it doesn't
580    /// understand. Ordinal worsts are the ladder tops; categorical worsts are the
581    /// hazardous term (`Channel::Unknown`, `Principal::Cross`).
582    pub fn worst(because: impl Into<String>) -> Self {
583        Self {
584            operation: Operation::hazard(),
585            locus: Locus {
586                local: LocalLocus::hazard(),
587                remote: RemoteReach::hazard(),
588                binding: RemoteBinding::hazard(),
589                provenance: Provenance::hazard(),
590            },
591            scale: Scale::hazard(),
592            retrieval: RetrievalGranularity::hazard(),
593            authority: Authority::hazard(),
594            isolation: Isolation::hazard(),
595            reversibility: Reversibility::hazard(),
596            persistence: Persistence {
597                level: PersistenceLevel::hazard(),
598                trigger: Trigger { escape: TriggerEscape::hazard(), kind: TriggerKind::hazard() },
599            },
600            disclosure: Disclosure {
601                audience: DisclosureAudience::hazard(),
602                channel: Channel::hazard(),
603                principal: Principal::hazard(),
604            },
605            secret: Secret {
606                level: SecretLevel::hazard(),
607                channel: Channel::hazard(),
608                principal: Principal::hazard(),
609            },
610            network: Network {
611                direction: NetDirection::hazard(),
612                destination: NetDestination::hazard(),
613                payload: NetPayload::hazard(),
614            },
615            execution: Execution {
616                trust: ExecutionTrust::hazard(),
617                // PRESENT, not `None`. An absent supply chain satisfies every supply-chain
618                // constraint vacuously on the allow path, which would leave the install/RCE surface
619                // as the one axis where the fail-closed sentinel is not actually worst.
620                supply_chain: Some(SupplyChain {
621                    source: SupplySource::hazard(),
622                    pinning: Pinning::hazard(),
623                    exec_surface: ExecSurface::hazard(),
624                }),
625            },
626            cost: Cost::hazard(),
627            because: because.into(),
628        }
629    }
630}
631
632/// The set of capabilities a resolved command line exhibits (v1.4 §2.8, §4.1). A
633/// profile passes a level iff *every* capability is admissible.
634#[derive(Clone, Debug, Default, PartialEq, Eq)]
635pub struct Profile {
636    pub capabilities: Vec<Capability>,
637}
638
639impl Profile {
640    /// A profile of exactly these capabilities.
641    pub fn of(capabilities: Vec<Capability>) -> Self {
642        Self { capabilities }
643    }
644}
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649
650    fn assert_term_strings_roundtrip<T: FacetTerm + std::fmt::Debug>() {
651        for &term in T::all() {
652            assert_eq!(
653                T::from_term(term.as_str()),
654                Some(term),
655                "term {:?} did not round-trip through {:?}",
656                term,
657                term.as_str(),
658            );
659        }
660        for (i, &a) in T::all().iter().enumerate() {
661            for &b in &T::all()[i + 1..] {
662                assert_ne!(a.as_str(), b.as_str(), "two variants share a TOML spelling");
663            }
664        }
665        assert_eq!(T::from_term("definitely-not-a-term"), None);
666    }
667
668    fn assert_zero_is_minimum<T: FacetTerm + Ord + Default + std::fmt::Debug>() {
669        let zero = T::all()[0];
670        assert_eq!(T::default(), zero, "Default must be the zero term (first variant)");
671        for &term in T::all() {
672            assert!(zero <= term, "zero term {zero:?} is not <= {term:?}");
673        }
674    }
675
676    #[test]
677    fn every_term_roundtrips_and_is_uniquely_spelled() {
678        assert_term_strings_roundtrip::<Operation>();
679        assert_term_strings_roundtrip::<LocalLocus>();
680        assert_term_strings_roundtrip::<RemoteReach>();
681        assert_term_strings_roundtrip::<RemoteBinding>();
682        assert_term_strings_roundtrip::<Provenance>();
683        assert_term_strings_roundtrip::<Scale>();
684        assert_term_strings_roundtrip::<RetrievalGranularity>();
685        assert_term_strings_roundtrip::<Authority>();
686        assert_term_strings_roundtrip::<Isolation>();
687        assert_term_strings_roundtrip::<Reversibility>();
688        assert_term_strings_roundtrip::<PersistenceLevel>();
689        assert_term_strings_roundtrip::<TriggerEscape>();
690        assert_term_strings_roundtrip::<TriggerKind>();
691        assert_term_strings_roundtrip::<DisclosureAudience>();
692        assert_term_strings_roundtrip::<SecretLevel>();
693        assert_term_strings_roundtrip::<Channel>();
694        assert_term_strings_roundtrip::<Principal>();
695        assert_term_strings_roundtrip::<NetDirection>();
696        assert_term_strings_roundtrip::<NetDestination>();
697        assert_term_strings_roundtrip::<NetPayload>();
698        assert_term_strings_roundtrip::<ExecutionTrust>();
699        assert_term_strings_roundtrip::<SupplySource>();
700        assert_term_strings_roundtrip::<Pinning>();
701        assert_term_strings_roundtrip::<ExecSurface>();
702        assert_term_strings_roundtrip::<Cost>();
703    }
704
705    #[test]
706    fn ordinal_zero_terms_are_the_minimum() {
707        assert_zero_is_minimum::<LocalLocus>();
708        assert_zero_is_minimum::<RemoteReach>();
709        assert_zero_is_minimum::<Provenance>();
710        assert_zero_is_minimum::<Scale>();
711        assert_zero_is_minimum::<RetrievalGranularity>();
712        assert_zero_is_minimum::<Authority>();
713        assert_zero_is_minimum::<Isolation>();
714        assert_zero_is_minimum::<Reversibility>();
715        assert_zero_is_minimum::<PersistenceLevel>();
716        assert_zero_is_minimum::<TriggerEscape>();
717        assert_zero_is_minimum::<DisclosureAudience>();
718        assert_zero_is_minimum::<SecretLevel>();
719        assert_zero_is_minimum::<NetDirection>();
720        assert_zero_is_minimum::<NetDestination>();
721        assert_zero_is_minimum::<NetPayload>();
722        assert_zero_is_minimum::<ExecutionTrust>();
723        assert_zero_is_minimum::<Pinning>();
724        assert_zero_is_minimum::<Cost>();
725    }
726
727    #[test]
728    fn ordinal_ladders_match_the_spec() {
729        assert!(LocalLocus::Process < LocalLocus::Worktree);
730        assert!(LocalLocus::Worktree < LocalLocus::Adjacent);
731        assert!(LocalLocus::Adjacent < LocalLocus::WorktreeTrusted);
732        assert!(LocalLocus::WorktreeTrusted < LocalLocus::User);
733        assert!(LocalLocus::Worktree < LocalLocus::Machine);
734        assert!(LocalLocus::Machine < LocalLocus::SystemIntegrity);
735        assert!(LocalLocus::SystemIntegrity < LocalLocus::Device);
736        assert!(LocalLocus::Device < LocalLocus::Kernel);
737        assert!(Scale::Single < Scale::Bounded && Scale::Bounded < Scale::Unbounded);
738        assert!(RetrievalGranularity::Metadata < RetrievalGranularity::Record);
739        assert!(RetrievalGranularity::Record < RetrievalGranularity::BulkContent);
740        assert!(Authority::User < Authority::Root && Authority::Root < Authority::OtherUser);
741        assert!(Reversibility::Recoverable < Reversibility::Irreversible);
742        assert!(PersistenceLevel::Data < PersistenceLevel::Installing);
743        assert!(TriggerEscape::Immediate < TriggerEscape::Boot);
744        assert!(DisclosureAudience::LocalProcess < DisclosureAudience::Public);
745        assert!(Provenance::Na < Provenance::Established);
746        assert!(Provenance::Established < Provenance::Literal);
747        assert!(Provenance::Literal < Provenance::Opaque);
748        assert!(SecretLevel::Reads < SecretLevel::Transmits);
749        assert!(ExecutionTrust::SelfCode < ExecutionTrust::NetworkSourced);
750        assert!(Pinning::Floating < Pinning::HashVerified);
751    }
752
753    #[test]
754    fn capability_new_leaves_all_other_facets_at_zero() {
755        let cap = Capability::new(Operation::Destroy);
756        assert_eq!(cap.operation, Operation::Destroy);
757        assert_eq!(cap.locus, Locus::default());
758        assert_eq!(cap.locus.local, LocalLocus::Process);
759        assert_eq!(cap.scale, Scale::Single);
760        assert_eq!(cap.retrieval, RetrievalGranularity::Metadata);
761        assert_eq!(cap.authority, Authority::User);
762        assert_eq!(cap.reversibility, Reversibility::None);
763        assert_eq!(cap.secret.level, SecretLevel::None);
764        assert_eq!(cap.disclosure.audience, DisclosureAudience::None);
765        assert_eq!(cap.network.direction, NetDirection::None);
766        assert_eq!(cap.execution.trust, ExecutionTrust::None);
767        assert!(cap.execution.supply_chain.is_none());
768        assert_eq!(cap.cost, Cost::None);
769        assert!(cap.because.is_empty());
770    }
771
772    #[test]
773    fn default_capability_is_a_zero_observe() {
774        assert_eq!(Capability::default().operation, Operation::Observe);
775        assert_eq!(Capability::default(), Capability::new(Operation::Observe));
776    }
777}