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}