agent_mesh_protocol/authority.rs
1//! Content-addressed **authority provenance** — the portable semantic layer.
2//!
3//! This is the mesh half of the Content-Addressed Authority Provenance design
4//! (PLAN step 1 / task #134). Software supply-chain security protects the
5//! provenance of artifacts that *execute*; this applies the same techniques to
6//! the provenance of the **authority under which execution is permitted**.
7//!
8//! Four distinct proofs, never collapsed:
9//!
10//! | Proof | Mechanism | Answers |
11//! |-------|-----------|---------|
12//! | Identity | [`AuthorityId`]/[`GrantId`] (CID) | *is this the exact object?* |
13//! | Assertion | detached signature ([`crate::assertion::Assertion`]) | *who vouched for it?* |
14//! | Authorization | attenuation algebra ([`check_derivation`]) | *is this legal?* |
15//! | Enforcement | platform witness (agent-bridle) | *what did the OS enforce?* |
16//!
17//! **Scope of this module (deliberately portable):** it owns [`Authority`] /
18//! [`Grant`] and their typed CIDs, the resolved-authority lattice
19//! ([`ResolvedScope`] / [`ResolvedAuthority`]), the [`ScopeRelation`], the pure
20//! **admission** decision ([`admit`], the L3 BOUND law), and the **verifier
21//! contract** ([`AttestationVerifier`]). It never learns a native fence format
22//! (SBPL / Landlock / AppContainer) — agent-bridle *projects* its native fence
23//! into a [`ResolvedAuthority`] and calls [`admit`] here.
24//!
25//! The seven governing laws (each carries a proof obligation, discharged
26//! separately by the formal specs — task #137):
27//! L1 IDENTITY, L2 NON-EQUIVOCATION, L3 BOUND, L4 FLOOR, L5 PROVENANCE,
28//! L6 AUTHORIZATION, L7 FAIL-CLOSED. This module implements the executable form
29//! of L3/L6/L7 and the L1 typed-identity discipline.
30
31use std::collections::BTreeSet;
32
33use content_addressable::canonical::to_canonical_dagcbor;
34use content_addressable::{ContentAddressable, ContentError, ContentId};
35use serde::{Deserialize, Serialize};
36
37use crate::caveats::{Caveats, Scope};
38
39// ── Domain tags (L1: typed domains cannot be confused) ───────────────────────
40//
41// Each provenance body carries its domain tag in the hashed bytes, so two
42// objects with coincidentally-equal payloads still get distinct CIDs.
43const AUTHORITY_KIND: &str = "agent-mesh/provenance/authority/v1";
44const GRANT_KIND: &str = "agent-mesh/provenance/grant/v1";
45
46/// A domain-tagged body: `{ kind, body }`. dag-cbor sorts map keys, so field
47/// order is irrelevant to the canonical form; the tag's presence is what
48/// separates domains. Serialize-only (never round-tripped as a value).
49///
50/// **The canonical form is a hash preimage, not a value codec.** [`Authority`]
51/// and [`Grant`] have *two distinct* serializations: their derived `serde`
52/// (`{ caveats }` / `{ authority, derivation }`), used for transport, and this
53/// tagged [`ContentAddressable::canonical_form`], used *only* to compute the CID.
54/// The two are intentionally different shapes, so `from_canonical_dagcbor` of the
55/// canonical bytes as the value type does **not** round-trip — decoding always
56/// goes through the value's own `serde`, after which identity is re-established by
57/// recomputing the CID (never trusted from the received bytes). The
58/// `canonical_form_is_a_hash_preimage_not_a_value_codec` test pins this boundary.
59#[derive(Serialize)]
60struct Tagged<'a, B: Serialize> {
61 kind: &'a str,
62 body: &'a B,
63}
64
65fn typed_canonical_form<B: Serialize>(kind: &str, body: &B) -> Result<Vec<u8>, ContentError> {
66 to_canonical_dagcbor(&Tagged { kind, body })
67}
68
69// ── Typed content IDs (L1) ───────────────────────────────────────────────────
70//
71// The inner `ContentId` is PRIVATE: domain-separated hashing is the cryptographic
72// protection, but the Rust type system reinforces it too. Without a public inner
73// field, an accidental rebrand like `GrantId(authority_id.0)` won't compile — a
74// typed id is obtained from its object's `.id()` (or deserialized), never
75// re-tagged from another domain's id. Read the underlying CID via `content_id()`.
76
77/// The content identity of an [`Authority`] value.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
79pub struct AuthorityId(ContentId);
80
81/// The content identity of a [`Grant`] (its authority-ref + derivation).
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
83pub struct GrantId(ContentId);
84
85/// The content identity of an operator Attestation (signing is deferred — the id
86/// is stable now so an Elevation edge can name it before the verifier lands).
87///
88/// **Acyclicity invariant (must hold when the real attestation body lands).** A
89/// [`Grant`] names its [`AttestationId`] (via [`Derivation::Elevation`]), so the
90/// grant's CID depends on the attestation's CID. The attestation body must
91/// therefore **not** include the child [`GrantId`] it authorizes — that would
92/// create the cycle `GrantId → AttestationId → GrantId`, which is uncomputable
93/// under content addressing (neither CID can be formed without the other). The
94/// attestation binds *upward and sideways only*: the parent [`GrantId`], the
95/// child's [`AuthorityId`] (the authority the elevation grants, which exists
96/// independently of the grant), and any operator context — never the child grant
97/// id. The dependency DAG stays acyclic.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
99pub struct AttestationId(ContentId);
100
101impl AuthorityId {
102 /// The underlying content id (read-only; construction is via [`Authority::id`]).
103 #[must_use]
104 pub fn content_id(&self) -> ContentId {
105 self.0
106 }
107}
108
109impl GrantId {
110 /// The underlying content id (read-only; construction is via [`Grant::id`]).
111 #[must_use]
112 pub fn content_id(&self) -> ContentId {
113 self.0
114 }
115}
116
117impl AttestationId {
118 /// The underlying content id (read-only). Construction is deliberately narrow
119 /// while attestations are deferred: an `AttestationId` will be produced by a
120 /// future `Attestation::id()`; deserialization is the only external path today.
121 #[must_use]
122 pub fn content_id(&self) -> ContentId {
123 self.0
124 }
125}
126
127// ── Authority = the authority VALUE (a tagged `Caveats`) ──────────────────────
128
129/// A portable authority value: exactly the mesh [`Caveats`] lattice, given a
130/// stable content identity for the first time. **Introducing an identity, not a
131/// new authority model** — Newt has no `Grant` type today; authority is a bare
132/// `Caveats`, so [`AuthorityId`] is the first stable name a delegated authority
133/// ever gets.
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135pub struct Authority {
136 caveats: Caveats,
137}
138
139impl Authority {
140 /// Wrap a `Caveats` as a content-addressable authority value.
141 #[must_use]
142 pub fn new(caveats: Caveats) -> Self {
143 Self { caveats }
144 }
145
146 /// The authority lattice value.
147 #[must_use]
148 pub fn caveats(&self) -> &Caveats {
149 &self.caveats
150 }
151
152 /// This authority's typed content id (L1).
153 ///
154 /// # Errors
155 /// Propagates a dag-cbor encoding error (e.g. a non-finite float — impossible
156 /// for `Caveats`, which has no floats).
157 pub fn id(&self) -> Result<AuthorityId, ContentError> {
158 Ok(AuthorityId(self.content_id()?))
159 }
160}
161
162impl ContentAddressable for Authority {
163 fn canonical_form(&self) -> Result<Vec<u8>, ContentError> {
164 typed_canonical_form(AUTHORITY_KIND, &self.caveats)
165 }
166}
167
168// ── Grant = authority-ref + derivation (the id-split fix) ─────────────────────
169
170/// How a [`Grant`] came to hold its authority (the authorization proof — L6).
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(rename_all = "snake_case")]
173pub enum Derivation {
174 /// A root grant — the session baseline, derived from nothing.
175 Root,
176 /// Attenuated from `parent`: the algebra requires `child ⊑ parent`
177 /// ([`check_derivation`] enforces it). An attenuation can NEVER widen.
178 Attenuation { parent: GrantId },
179 /// Elevated from `parent`: widens by definition, so the algebra cannot bless
180 /// it — it is legal *only* through a valid operator `attestation`
181 /// (signature). Signatures are deferred (contract §8); until a verifier
182 /// lands, [`check_derivation`] fail-closes every elevation.
183 ///
184 /// Because this grant's CID depends on `attestation`, the attestation body
185 /// must not name this grant's id in turn — see the acyclicity invariant on
186 /// [`AttestationId`].
187 Elevation {
188 parent: GrantId,
189 attestation: AttestationId,
190 },
191}
192
193/// A grant: a reference to an [`Authority`] value plus the [`Derivation`] that
194/// justifies holding it.
195///
196/// **The id-split fix:** the grant references its authority by [`AuthorityId`]
197/// rather than inlining the `Caveats`, so the *authority value* and the *grant*
198/// are two distinct objects with two distinct CIDs. The same authority reached
199/// by two different derivations is two different grants (correct), and a grant's
200/// identity is not silently a re-hash of the raw caveats.
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202pub struct Grant {
203 authority: AuthorityId,
204 derivation: Derivation,
205}
206
207impl Grant {
208 /// A grant over `authority` justified by `derivation`.
209 #[must_use]
210 pub fn new(authority: AuthorityId, derivation: Derivation) -> Self {
211 Self {
212 authority,
213 derivation,
214 }
215 }
216
217 /// The authority this grant confers, by content id.
218 #[must_use]
219 pub fn authority(&self) -> AuthorityId {
220 self.authority
221 }
222
223 /// How this grant was derived.
224 #[must_use]
225 pub fn derivation(&self) -> &Derivation {
226 &self.derivation
227 }
228
229 /// This grant's typed content id (L1).
230 ///
231 /// # Errors
232 /// Propagates a dag-cbor encoding error.
233 pub fn id(&self) -> Result<GrantId, ContentError> {
234 Ok(GrantId(self.content_id()?))
235 }
236}
237
238impl ContentAddressable for Grant {
239 fn canonical_form(&self) -> Result<Vec<u8>, ContentError> {
240 // The grant body: `{ authority, derivation }`. Domain-tagged so a grant
241 // CID can never collide with the authority CID it references.
242 #[derive(Serialize)]
243 struct Body<'a> {
244 authority: &'a AuthorityId,
245 derivation: &'a Derivation,
246 }
247 typed_canonical_form(
248 GRANT_KIND,
249 &Body {
250 authority: &self.authority,
251 derivation: &self.derivation,
252 },
253 )
254 }
255}
256
257// ── Grant ⇄ Authority content binding (the CID is load-bearing) ──────────────
258
259/// The two authority ids in an [`BindError::AuthorityCidMismatch`] (boxed so the
260/// error variant stays small).
261#[derive(Debug, Clone, PartialEq, Eq)]
262pub struct AuthorityMismatch {
263 /// The [`AuthorityId`] the grant commits to.
264 pub named_by_grant: AuthorityId,
265 /// The CID of the body the caller tried to bind.
266 pub of_body: AuthorityId,
267}
268
269/// Why an [`Authority`] body could not be bound to a [`Grant`].
270#[derive(Debug, Clone, PartialEq, Eq)]
271pub enum BindError {
272 /// Computing the authority's CID failed (dag-cbor encoding).
273 Encoding(String),
274 /// The supplied body's CID is **not** the [`AuthorityId`] the grant names —
275 /// it is a different authority than the one the grant content-binds. A
276 /// resolver handing over a "convenient" body is rejected here, fail-closed.
277 AuthorityCidMismatch(Box<AuthorityMismatch>),
278}
279
280/// A [`Grant`] paired with the exact [`Authority`] body it names — and the
281/// pairing is **only constructible when the body's CID equals the grant's
282/// `authority` field**. This is the whole point of content addressing: the
283/// caveats an edge is judged against are provably the caveats the grant commits
284/// to, not a body a buggy or hostile resolver substituted. Every function that
285/// reasons about a grant's *authority value* takes a `ResolvedGrant`, so the
286/// mismatch case is unrepresentable rather than merely checked-somewhere.
287#[derive(Debug, Clone, PartialEq, Eq)]
288pub struct ResolvedGrant {
289 grant: Grant,
290 authority: Authority,
291}
292
293impl ResolvedGrant {
294 /// Bind `authority` to `grant`, **verifying** `authority.id() ==
295 /// grant.authority()`. Fails closed on any mismatch or encoding error — the
296 /// only way to obtain a `ResolvedGrant`, so a mismatched body can never reach
297 /// the derivation/admission logic.
298 pub fn bind(grant: Grant, authority: Authority) -> Result<Self, BindError> {
299 let of_body = authority
300 .id()
301 .map_err(|e| BindError::Encoding(e.to_string()))?;
302 if of_body != grant.authority() {
303 return Err(BindError::AuthorityCidMismatch(Box::new(
304 AuthorityMismatch {
305 named_by_grant: grant.authority(),
306 of_body,
307 },
308 )));
309 }
310 Ok(Self { grant, authority })
311 }
312
313 /// The grant.
314 #[must_use]
315 pub fn grant(&self) -> &Grant {
316 &self.grant
317 }
318
319 /// The authority body — provably the one `grant` names.
320 #[must_use]
321 pub fn authority(&self) -> &Authority {
322 &self.authority
323 }
324
325 /// The grant's content id.
326 ///
327 /// # Errors
328 /// Propagates a dag-cbor encoding error.
329 pub fn id(&self) -> Result<GrantId, ContentError> {
330 self.grant.id()
331 }
332}
333
334// ── L6 AUTHORIZATION: attenuation algebra / elevation signature ──────────────
335
336/// The verifier contract for operator Elevation attestations (L6). Real
337/// signature verification is provided downstream (agent-bridle / newt operator
338/// key); Phase A ships only the contract plus a fail-closed default.
339pub trait AttestationVerifier {
340 /// Does `attestation` authorize elevating from `parent` to `child`? A CID
341 /// **identifies**, it never authorizes — an elevation is legal only if this
342 /// returns `true`.
343 fn verify_elevation(&self, parent: GrantId, child: &Grant, attestation: AttestationId) -> bool;
344}
345
346/// The fail-closed default verifier: **rejects every elevation** (L7). Signing
347/// is deferred (contract §8), so until a real operator-key verifier lands, no
348/// elevation edge is admissible — only attenuation (algebra) is.
349#[derive(Debug, Clone, Copy, Default)]
350pub struct DenyAllElevations;
351
352impl AttestationVerifier for DenyAllElevations {
353 fn verify_elevation(
354 &self,
355 _parent: GrantId,
356 _child: &Grant,
357 _attestation: AttestationId,
358 ) -> bool {
359 false
360 }
361}
362
363/// Why a derivation edge is not valid.
364#[derive(Debug, Clone, PartialEq, Eq)]
365pub enum DerivationReject {
366 /// An attenuation edge whose child authority is NOT `⊑` the parent (it
367 /// widens) — algebra forbids it (I5).
368 AttenuationWidens,
369 /// The parent grant supplied does not have the [`GrantId`] the child's
370 /// derivation names (computed from the parent grant, not trusted from a
371 /// caller-passed id).
372 ParentMismatch,
373 /// An elevation whose attestation the verifier rejected (or the deferred
374 /// default, which rejects all).
375 ElevationUnauthorized,
376 /// A non-root edge needs its parent grant, but none was supplied.
377 MissingParent,
378 /// A CID needed to check the edge could not be computed (fail-closed).
379 CidUnavailable,
380}
381
382/// Result of checking one derivation edge (L6 + L7).
383#[derive(Debug, Clone, PartialEq, Eq)]
384pub enum DerivationDecision {
385 /// The edge is valid.
386 Valid,
387 /// The edge is rejected, fail-closed.
388 Reject(DerivationReject),
389}
390
391/// Check a single derivation edge for `child` against its `parent` (L6).
392///
393/// Both are [`ResolvedGrant`]s, so each carries an authority body that is
394/// **CID-bound to its grant** — the caveats compared are provably the ones the
395/// grants name, closing the substitution attack where a resolver validates an
396/// edge against a convenient body the grant does not commit to. The parent's
397/// [`GrantId`] is recomputed from the parent grant here and matched against the
398/// id the child's derivation names; it is never trusted from the caller.
399///
400/// * `Root` is always valid (no parent).
401/// * `Attenuation` requires the parent id to match AND `child ⊑ parent`
402/// (algebra, [`Caveats::leq`]); a widening is rejected.
403/// * `Elevation` requires the parent id to match AND `verifier.verify_elevation`
404/// to accept — the algebra deliberately cannot bless a widening.
405#[must_use]
406pub fn check_derivation(
407 child: &ResolvedGrant,
408 parent: Option<&ResolvedGrant>,
409 verifier: &dyn AttestationVerifier,
410) -> DerivationDecision {
411 use DerivationReject as R;
412 // The parent's identity is COMPUTED from the parent grant and matched against
413 // the id the child's derivation names — never trusted from a caller. Combined
414 // with `ResolvedGrant` (each authority body is CID-bound to its grant at
415 // construction), every caveat compared below is provably the caveats the
416 // grants commit to. That is what makes the algebra sound; an unchecked CID
417 // would be decorative.
418 let matched_parent = |declared: &GrantId| -> Result<&ResolvedGrant, DerivationDecision> {
419 let parent = parent.ok_or(DerivationDecision::Reject(R::MissingParent))?;
420 let parent_id = parent
421 .id()
422 .map_err(|_| DerivationDecision::Reject(R::CidUnavailable))?;
423 if &parent_id != declared {
424 return Err(DerivationDecision::Reject(R::ParentMismatch));
425 }
426 Ok(parent)
427 };
428
429 match child.grant().derivation() {
430 Derivation::Root => DerivationDecision::Valid,
431 Derivation::Attenuation {
432 parent: declared_parent,
433 } => {
434 let parent = match matched_parent(declared_parent) {
435 Ok(p) => p,
436 Err(reject) => return reject,
437 };
438 // Both authorities are CID-bound to their grants (ResolvedGrant), so
439 // this `⊑` is over the exact caveats the grants name.
440 if child
441 .authority()
442 .caveats()
443 .leq(parent.authority().caveats())
444 {
445 DerivationDecision::Valid
446 } else {
447 DerivationDecision::Reject(R::AttenuationWidens)
448 }
449 }
450 Derivation::Elevation {
451 parent: declared_parent,
452 attestation,
453 } => {
454 let parent = match matched_parent(declared_parent) {
455 Ok(p) => p,
456 Err(reject) => return reject,
457 };
458 let parent_id = match parent.id() {
459 Ok(id) => id,
460 Err(_) => return DerivationDecision::Reject(R::CidUnavailable),
461 };
462 if verifier.verify_elevation(parent_id, child.grant(), *attestation) {
463 DerivationDecision::Valid
464 } else {
465 DerivationDecision::Reject(R::ElevationUnauthorized)
466 }
467 }
468 }
469}
470
471// ── L5 PROVENANCE: chain termination at an externally-trusted root ───────────
472
473/// Why a derivation chain is not trusted.
474#[derive(Debug, Clone, PartialEq, Eq)]
475pub enum ChainReject {
476 /// An empty chain proves nothing (fail-closed).
477 Empty,
478 /// Edge `index` (child `chain[index]` → parent `chain[index + 1]`) failed
479 /// [`check_derivation`] for the carried reason.
480 Edge {
481 index: usize,
482 reason: DerivationReject,
483 },
484 /// `chain[index]` is a [`Derivation::Root`] but is not the terminus — a root
485 /// may only appear at the end of a chain (it derives from nothing).
486 RootNotTerminal { index: usize },
487 /// The terminus is not a [`Derivation::Root`]: the chain does not bottom out
488 /// at a self-standing root, so there is nothing to anchor trust to.
489 NonRootTerminus,
490 /// The chain is structurally valid **and** terminates at a root, but that
491 /// root is not the externally-configured trusted anchor. Structural validity
492 /// is not trust: a CID identifies, it does not authorize. This is the reject
493 /// that stops an attacker's self-authored, internally-consistent chain.
494 UntrustedRoot { found: GrantId, expected: GrantId },
495 /// A CID needed to anchor the chain could not be computed (fail-closed).
496 CidUnavailable,
497}
498
499/// Whether a derivation chain is trusted.
500#[derive(Debug, Clone, PartialEq, Eq)]
501pub enum ChainDecision {
502 /// Every edge is a valid derivation AND the chain terminates at the exact
503 /// externally-trusted root.
504 Trusted,
505 /// The chain is rejected, fail-closed.
506 Reject(ChainReject),
507}
508
509/// **Verify a full derivation chain against an externally-trusted root (L5).**
510///
511/// `chain` is ordered leaf → root: `chain[0]` is the grant whose trust is in
512/// question, `chain[i + 1]` is the parent of `chain[i]`, and the last element is
513/// the claimed root. Every element is a [`ResolvedGrant`], so each authority body
514/// is CID-bound to its grant.
515///
516/// A chain is [`ChainDecision::Trusted`] iff **both** hold:
517///
518/// 1. **Every edge is a valid derivation.** For each non-terminal `chain[i]`,
519/// [`check_derivation`] against `chain[i + 1]` succeeds — which recomputes the
520/// parent id and rejects any link whose declared parent is not the actual next
521/// grant. The terminus must be a [`Derivation::Root`]; a root may not appear
522/// anywhere else.
523/// 2. **The root is the configured trust anchor.** The terminal root's [`GrantId`]
524/// must equal `trusted_root`. **This is the load-bearing step of L5.** A
525/// structurally perfect, internally-consistent chain an attacker authored
526/// themselves is *rejected here* — a CID identifies the root, it does not make
527/// the root trusted. Trust enters the system only by an operator configuring
528/// which root [`GrantId`] is the anchor; it is never inferred from structure.
529#[must_use]
530pub fn verify_chain(
531 chain: &[ResolvedGrant],
532 trusted_root: GrantId,
533 verifier: &dyn AttestationVerifier,
534) -> ChainDecision {
535 let Some(terminus) = chain.last() else {
536 return ChainDecision::Reject(ChainReject::Empty);
537 };
538 // Walk every edge leaf → root. `chain[i]` (i < last) must derive validly from
539 // `chain[i + 1]`, and must not itself be a Root (a root derives from nothing,
540 // so it can only be the terminus).
541 for (index, child) in chain.iter().enumerate() {
542 if index == chain.len() - 1 {
543 break;
544 }
545 if matches!(child.grant().derivation(), Derivation::Root) {
546 return ChainDecision::Reject(ChainReject::RootNotTerminal { index });
547 }
548 let parent = &chain[index + 1];
549 match check_derivation(child, Some(parent), verifier) {
550 DerivationDecision::Valid => {}
551 DerivationDecision::Reject(reason) => {
552 return ChainDecision::Reject(ChainReject::Edge { index, reason });
553 }
554 }
555 }
556
557 // The terminus must be a root...
558 if !matches!(terminus.grant().derivation(), Derivation::Root) {
559 return ChainDecision::Reject(ChainReject::NonRootTerminus);
560 }
561 // ...and it must be THE trusted root. Structural validity ≠ trust.
562 let root_id = match terminus.id() {
563 Ok(id) => id,
564 Err(_) => return ChainDecision::Reject(ChainReject::CidUnavailable),
565 };
566 if root_id != trusted_root {
567 return ChainDecision::Reject(ChainReject::UntrustedRoot {
568 found: root_id,
569 expected: trusted_root,
570 });
571 }
572 ChainDecision::Trusted
573}
574
575// ── L3 BOUND: the resolved-authority lattice + pure admission ────────────────
576
577/// What a native fence actually permits on ONE axis, expressed portably (the
578/// projection agent-bridle produces from its native profile). The lattice the
579/// L3 BOUND law is *computed* over — fidelity is never asserted.
580#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
581#[serde(rename_all = "snake_case")]
582pub enum ResolvedScope {
583 /// Bounded authority: the union of `concrete` items the mechanism permits
584 /// (e.g. canonicalized paths / hosts) **and** named capability `classes` it
585 /// permits but cannot enumerate as a concrete set (e.g. a whole loopback
586 /// interface). The two are independent dimensions, so a fence can permit both.
587 /// The empty scope (`{}`, `{}`) is the **bottom / identity** element for
588 /// [`union`](Self::union): `∅ ∪ X = X`.
589 Bounded {
590 concrete: BTreeSet<String>,
591 classes: BTreeSet<String>,
592 },
593 /// The mechanism imposes no restriction on this axis (`⊤`, the top element).
594 Unbounded,
595 /// The mechanism's authority on this axis could not be resolved. **Any**
596 /// comparison involving `Unknown` is `Unknown` → fail-closed (L7).
597 Unknown,
598}
599
600impl ResolvedScope {
601 /// The bottom element: permits nothing. The identity for [`union`](Self::union).
602 #[must_use]
603 pub fn empty() -> Self {
604 Self::Bounded {
605 concrete: BTreeSet::new(),
606 classes: BTreeSet::new(),
607 }
608 }
609
610 /// A bounded scope of concrete items only.
611 #[must_use]
612 pub fn concrete<I: IntoIterator<Item = String>>(items: I) -> Self {
613 Self::Bounded {
614 concrete: items.into_iter().collect(),
615 classes: BTreeSet::new(),
616 }
617 }
618
619 /// A bounded scope of a single named capability class only.
620 #[must_use]
621 pub fn class(name: impl Into<String>) -> Self {
622 Self::Bounded {
623 concrete: BTreeSet::new(),
624 classes: BTreeSet::from([name.into()]),
625 }
626 }
627
628 /// Lift a delegated [`Scope<String>`] into the resolved lattice: `All ⇒
629 /// Unbounded`, `Only(set) ⇒ Bounded{concrete: set}`.
630 #[must_use]
631 pub fn from_scope(scope: &Scope<String>) -> Self {
632 match scope {
633 Scope::All => Self::Unbounded,
634 Scope::Only(set) => Self::Bounded {
635 concrete: set.clone(),
636 classes: BTreeSet::new(),
637 },
638 }
639 }
640
641 /// The union `self ∪ other` — the bounding authority when combining the
642 /// delegated grant with the authorized closure. `Unknown` propagates
643 /// (fail-closed); `Unbounded` absorbs; two bounded scopes union their concrete
644 /// and class dimensions independently. The empty scope is the identity, so
645 /// `∅ ∪ X = X` for every `X` (the composability law).
646 #[must_use]
647 pub fn union(&self, other: &Self) -> Self {
648 match (self, other) {
649 (Self::Unknown, _) | (_, Self::Unknown) => Self::Unknown,
650 (Self::Unbounded, _) | (_, Self::Unbounded) => Self::Unbounded,
651 (
652 Self::Bounded {
653 concrete: ca,
654 classes: la,
655 },
656 Self::Bounded {
657 concrete: cb,
658 classes: lb,
659 },
660 ) => Self::Bounded {
661 concrete: ca.union(cb).cloned().collect(),
662 classes: la.union(lb).cloned().collect(),
663 },
664 }
665 }
666}
667
668/// How a resolved scope stands relative to a bounding scope (does the fence stay
669/// within what was authorized?).
670#[derive(Debug, Clone, Copy, PartialEq, Eq)]
671pub enum ScopeRelation {
672 /// The resolved scope equals the bound.
673 Equal,
674 /// The resolved scope is a subset of the bound (more confined — fine).
675 Subset,
676 /// The resolved scope is a superset of the bound — it **widens** authority
677 /// beyond what was granted (the OCAP bug this whole layer exists to catch).
678 Superset,
679 /// Neither contains the other.
680 Incomparable,
681 /// Not decidable in the portable lattice (an `Unknown` operand) → treated as
682 /// fail-closed by [`admit`].
683 Unknown,
684}
685
686/// Relate a resolved fence scope to the bound it must stay within: does
687/// `resolved ⊆ bound`? Returns the precise relation so a widening
688/// ([`ScopeRelation::Superset`]) is a distinct, reportable fact from an
689/// undecidable comparison ([`ScopeRelation::Unknown`]). Containment is per
690/// dimension: `Bounded ⊆ Bounded` iff both `concrete ⊆ concrete` and
691/// `classes ⊆ classes`.
692#[must_use]
693pub fn relate(resolved: &ResolvedScope, bound: &ResolvedScope) -> ScopeRelation {
694 use ResolvedScope as S;
695 match (resolved, bound) {
696 (S::Unknown, _) | (_, S::Unknown) => ScopeRelation::Unknown,
697 (S::Unbounded, S::Unbounded) => ScopeRelation::Equal,
698 // The fence permits everything but the bound does not — a widening.
699 (S::Unbounded, _) => ScopeRelation::Superset,
700 // The bound permits everything, the fence is narrower — within bounds.
701 (_, S::Unbounded) => ScopeRelation::Subset,
702 (
703 S::Bounded {
704 concrete: ca,
705 classes: la,
706 },
707 S::Bounded {
708 concrete: cb,
709 classes: lb,
710 },
711 ) => {
712 if ca == cb && la == lb {
713 ScopeRelation::Equal
714 } else if ca.is_subset(cb) && la.is_subset(lb) {
715 ScopeRelation::Subset
716 } else if ca.is_superset(cb) && la.is_superset(lb) {
717 ScopeRelation::Superset
718 } else {
719 ScopeRelation::Incomparable
720 }
721 }
722 }
723}
724
725/// Per-axis resolved authority for the four OS-confinement axes (the axes a
726/// native fence governs; `max_calls`/`valid_for_generation` are gate budget, not
727/// fence scope, and are out of this projection).
728#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
729pub struct ResolvedAuthority {
730 pub fs_read: ResolvedScope,
731 pub fs_write: ResolvedScope,
732 pub exec: ResolvedScope,
733 pub net: ResolvedScope,
734}
735
736impl ResolvedAuthority {
737 /// The four axes in fixed order (for uniform per-axis checks).
738 fn axes(&self) -> [(ConfinedAxis, &ResolvedScope); 4] {
739 [
740 (ConfinedAxis::FsRead, &self.fs_read),
741 (ConfinedAxis::FsWrite, &self.fs_write),
742 (ConfinedAxis::Exec, &self.exec),
743 (ConfinedAxis::Net, &self.net),
744 ]
745 }
746
747 /// Project a delegated [`Caveats`] into the resolved lattice — the bound the
748 /// grant alone authorizes (before adding any [`RuntimeClosure`], which lives
749 /// in agent-bridle).
750 #[must_use]
751 pub fn from_delegated(caveats: &Caveats) -> Self {
752 Self {
753 fs_read: ResolvedScope::from_scope(&caveats.fs_read),
754 fs_write: ResolvedScope::from_scope(&caveats.fs_write),
755 exec: ResolvedScope::from_scope(&caveats.exec),
756 net: ResolvedScope::from_scope(&caveats.net),
757 }
758 }
759
760 fn axis(&self, axis: ConfinedAxis) -> &ResolvedScope {
761 match axis {
762 ConfinedAxis::FsRead => &self.fs_read,
763 ConfinedAxis::FsWrite => &self.fs_write,
764 ConfinedAxis::Exec => &self.exec,
765 ConfinedAxis::Net => &self.net,
766 }
767 }
768}
769
770/// The four OS-confinement axes (names an [`AdmissionReject`]).
771#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
772#[serde(rename_all = "snake_case")]
773pub enum ConfinedAxis {
774 FsRead,
775 FsWrite,
776 Exec,
777 Net,
778}
779
780/// The reason an admission is refused (L3 + L7): the axis whose resolved fence
781/// exceeded its bound, and how.
782#[derive(Debug, Clone, PartialEq, Eq)]
783pub struct AdmissionReject {
784 pub axis: ConfinedAxis,
785 pub relation: ScopeRelation,
786}
787
788/// The admission decision (L3 BOUND). `Admit` iff, on **every** axis, the fence's
789/// resolved scope stays within the bound `delegated ∪ closure`; any widening,
790/// incomparability, or undecidable comparison refuses fail-closed.
791#[derive(Debug, Clone, PartialEq, Eq)]
792pub enum AdmissionDecision {
793 Admit,
794 Reject(AdmissionReject),
795}
796
797/// **The pure L3 admission decision.** For every axis, the actually-permitted
798/// authority (`resolved`, projected from the native fence by agent-bridle) must
799/// be `⊆` the bound `delegated ∪ closure`:
800///
801/// * `delegated` — the least-authority grant the model holds.
802/// * `closure` — the explicit, minimal, harness-disjoint [`RuntimeClosure`] the
803/// harness legitimately adds (loader paths, etc.); it is agent-bridle's
804/// object, projected here as a [`ResolvedAuthority`].
805///
806/// Admit iff `resolved ⊆ (delegated ∪ closure)` on every axis. A fence that
807/// permits MORE than that bound (`Superset`), an undecidable comparison
808/// (`Unknown`), or an `Incomparable` set refuses — a widening is a refusal, not a
809/// silently-admitted run. Pure: no IO, no platform knowledge.
810#[must_use]
811pub fn admit(
812 resolved: &ResolvedAuthority,
813 delegated: &Caveats,
814 closure: &ResolvedAuthority,
815) -> AdmissionDecision {
816 let delegated = ResolvedAuthority::from_delegated(delegated);
817 for (axis, fence_scope) in resolved.axes() {
818 let bound = delegated.axis(axis).union(closure.axis(axis));
819 match relate(fence_scope, &bound) {
820 ScopeRelation::Equal | ScopeRelation::Subset => {}
821 relation => {
822 return AdmissionDecision::Reject(AdmissionReject { axis, relation });
823 }
824 }
825 }
826 AdmissionDecision::Admit
827}
828
829/// A no-authority closure (all axes deny/empty) — the common case where the
830/// harness adds nothing beyond the delegated grant.
831#[must_use]
832pub fn empty_closure() -> ResolvedAuthority {
833 ResolvedAuthority {
834 fs_read: ResolvedScope::empty(),
835 fs_write: ResolvedScope::empty(),
836 exec: ResolvedScope::empty(),
837 net: ResolvedScope::empty(),
838 }
839}
840
841#[cfg(test)]
842mod tests {
843 use super::*;
844 use crate::caveats::CountBound;
845
846 fn concrete(items: &[&str]) -> ResolvedScope {
847 ResolvedScope::concrete(items.iter().map(|s| s.to_string()))
848 }
849
850 fn caveats(fs_read: Scope<String>, exec: Scope<String>, net: Scope<String>) -> Caveats {
851 Caveats {
852 fs_read,
853 fs_write: Scope::none(),
854 exec,
855 net,
856 max_calls: CountBound::top(),
857 valid_for_generation: Scope::top(),
858 }
859 }
860
861 /// A parent grant `(GrantId, Authority)` over `auth`, as a Root — the shape
862 /// `check_derivation` consumes (derivations reference the parent GRANT).
863 /// A ROOT [`ResolvedGrant`] over `c` — a grant paired with its CID-bound
864 /// authority (the only way `check_derivation` accepts an authority body).
865 fn root_grant(c: Caveats) -> ResolvedGrant {
866 let auth = Authority::new(c);
867 let grant = Grant::new(auth.id().unwrap(), Derivation::Root);
868 ResolvedGrant::bind(grant, auth).unwrap()
869 }
870
871 /// A derived [`ResolvedGrant`] over `c` with derivation `d`, honestly bound to
872 /// its own authority body.
873 fn derived_grant(c: Caveats, d: Derivation) -> ResolvedGrant {
874 let auth = Authority::new(c);
875 let grant = Grant::new(auth.id().unwrap(), d);
876 ResolvedGrant::bind(grant, auth).unwrap()
877 }
878
879 // ── L1 identity: determinism + typed-domain separation ───────────────────
880
881 #[test]
882 fn authority_id_is_deterministic() {
883 let a = Authority::new(Caveats::top());
884 assert_eq!(a.id().unwrap(), a.clone().id().unwrap());
885 }
886
887 #[test]
888 fn typed_domains_cannot_collide() {
889 // An Authority and a Grant can never share a CID, even constructed to be
890 // "the same shape", because each hashes a distinct domain tag.
891 let auth = Authority::new(Caveats::top());
892 let auth_id = auth.id().unwrap();
893 let grant = Grant::new(auth_id, Derivation::Root);
894 // Different typed ids (distinct kinds in the hashed body). Compared via
895 // the public `content_id()` accessor — the inner CID is private precisely
896 // so an id can only come from its object.
897 assert_ne!(auth_id.content_id(), grant.id().unwrap().content_id());
898 }
899
900 #[test]
901 fn identity_is_transport_agnostic() {
902 // The CID comes from the canonical dag-cbor form, not the transport wire,
903 // so a serde_json round-trip preserves the id (L1 — one validated
904 // canonical representation).
905 let auth_id = Authority::new(caveats(
906 Scope::only(["/repo".into()]),
907 Scope::only(["git".into()]),
908 Scope::none(),
909 ))
910 .id()
911 .unwrap();
912 let g = Grant::new(auth_id, Derivation::Root);
913 let back: Grant = serde_json::from_slice(&serde_json::to_vec(&g).unwrap()).unwrap();
914 assert_eq!(g, back);
915 assert_eq!(g.id().unwrap(), back.id().unwrap());
916 }
917
918 #[test]
919 fn grant_id_splits_authority_from_derivation() {
920 // Same authority, two derivations → two distinct grants (the id-split).
921 let auth_id = Authority::new(Caveats::top()).id().unwrap();
922 // A real parent grant id, obtained the only supported way — from a grant.
923 let other_parent = Grant::new(
924 Authority::new(caveats(Scope::none(), Scope::none(), Scope::none()))
925 .id()
926 .unwrap(),
927 Derivation::Root,
928 )
929 .id()
930 .unwrap();
931 let root = Grant::new(auth_id, Derivation::Root);
932 let attenuated = Grant::new(
933 auth_id,
934 Derivation::Attenuation {
935 parent: other_parent,
936 },
937 );
938 assert_ne!(root.id().unwrap(), attenuated.id().unwrap());
939 }
940
941 // ── L6 authorization: attenuation algebra / elevation signature ──────────
942
943 #[test]
944 fn attenuation_admits_only_when_child_leq_parent() {
945 let parent = root_grant(caveats(
946 Scope::only(["/repo".into(), "/tmp".into()]),
947 Scope::only(["git".into()]),
948 Scope::none(),
949 ));
950 let parent_id = parent.id().unwrap();
951 // Child ⊑ parent (narrower fs_read) — valid.
952 let child = derived_grant(
953 caveats(
954 Scope::only(["/repo".into()]),
955 Scope::only(["git".into()]),
956 Scope::none(),
957 ),
958 Derivation::Attenuation { parent: parent_id },
959 );
960 assert_eq!(
961 check_derivation(&child, Some(&parent), &DenyAllElevations),
962 DerivationDecision::Valid
963 );
964 // Child ⋠ parent (adds /etc) — a widening masquerading as attenuation.
965 let widening = derived_grant(
966 caveats(
967 Scope::only(["/repo".into(), "/etc".into()]),
968 Scope::only(["git".into()]),
969 Scope::none(),
970 ),
971 Derivation::Attenuation { parent: parent_id },
972 );
973 assert_eq!(
974 check_derivation(&widening, Some(&parent), &DenyAllElevations),
975 DerivationDecision::Reject(DerivationReject::AttenuationWidens)
976 );
977 }
978
979 #[test]
980 fn elevation_is_fail_closed_without_a_verifier() {
981 let parent = root_grant(Caveats::top());
982 let parent_id = parent.id().unwrap();
983 let child = derived_grant(
984 Caveats::top(),
985 Derivation::Elevation {
986 parent: parent_id,
987 // A placeholder attestation id — the deferred default verifier
988 // rejects regardless of its value. Built in-module (the module
989 // owns the type) from a real content id via the public accessor.
990 attestation: AttestationId(parent_id.content_id()),
991 },
992 );
993 // The deferred default rejects every elevation (L7).
994 assert_eq!(
995 check_derivation(&child, Some(&parent), &DenyAllElevations),
996 DerivationDecision::Reject(DerivationReject::ElevationUnauthorized)
997 );
998 }
999
1000 #[test]
1001 fn derivation_rejects_a_parent_that_is_not_the_named_grant() {
1002 let parent = root_grant(Caveats::top());
1003 let parent_id = parent.id().unwrap();
1004 // A DIFFERENT resolved grant, whose computed id ≠ the declared parent id.
1005 let impostor = root_grant(caveats(Scope::none(), Scope::none(), Scope::none()));
1006 assert_ne!(impostor.id().unwrap(), parent_id);
1007 let child = derived_grant(
1008 Caveats::top(),
1009 Derivation::Attenuation { parent: parent_id },
1010 );
1011 // Supplying the impostor as the parent is rejected — the parent id is
1012 // recomputed from the grant, not trusted.
1013 assert_eq!(
1014 check_derivation(&child, Some(&impostor), &DenyAllElevations),
1015 DerivationDecision::Reject(DerivationReject::ParentMismatch)
1016 );
1017 }
1018
1019 // ── Content binding: the CID is load-bearing (the #72 review) ────────────
1020
1021 #[test]
1022 fn bind_rejects_a_body_the_grant_does_not_name() {
1023 // A grant that commits to a WIDE authority (/repo + /etc)…
1024 let named = Authority::new(caveats(
1025 Scope::only(["/repo".into(), "/etc".into()]),
1026 Scope::none(),
1027 Scope::none(),
1028 ));
1029 let grant = Grant::new(named.id().unwrap(), Derivation::Root);
1030 // …cannot be paired with a convenient, narrower body — bind fails closed.
1031 let convenient = Authority::new(caveats(
1032 Scope::only(["/repo".into()]),
1033 Scope::none(),
1034 Scope::none(),
1035 ));
1036 match ResolvedGrant::bind(grant, convenient) {
1037 Err(BindError::AuthorityCidMismatch(_)) => {}
1038 other => panic!("a mismatched body must be rejected, got {other:?}"),
1039 }
1040 }
1041
1042 #[test]
1043 fn attenuation_cannot_be_validated_against_a_substituted_body() {
1044 // The reviewer's attack, end to end: a child grant NAMES a widening
1045 // authority (/repo + /etc, which ⋠ the parent), but the attacker wants it
1046 // judged against a convenient narrow body (/repo, which ⊑ parent).
1047 let parent = root_grant(caveats(
1048 Scope::only(["/repo".into()]),
1049 Scope::none(),
1050 Scope::none(),
1051 ));
1052 let parent_id = parent.id().unwrap();
1053 let widening = Authority::new(caveats(
1054 Scope::only(["/repo".into(), "/etc".into()]),
1055 Scope::none(),
1056 Scope::none(),
1057 ));
1058 let child_grant = Grant::new(
1059 widening.id().unwrap(),
1060 Derivation::Attenuation { parent: parent_id },
1061 );
1062 // The substitution is impossible: binding the convenient body to the grant
1063 // fails (its CID ≠ the grant's authority field).
1064 let convenient = Authority::new(caveats(
1065 Scope::only(["/repo".into()]),
1066 Scope::none(),
1067 Scope::none(),
1068 ));
1069 assert!(ResolvedGrant::bind(child_grant.clone(), convenient).is_err());
1070 // The only admissible pairing uses the grant's REAL (widening) body, which
1071 // check_derivation then rejects — the widening cannot hide.
1072 let honest = ResolvedGrant::bind(child_grant, widening).unwrap();
1073 assert_eq!(
1074 check_derivation(&honest, Some(&parent), &DenyAllElevations),
1075 DerivationDecision::Reject(DerivationReject::AttenuationWidens)
1076 );
1077 }
1078
1079 #[test]
1080 fn attenuation_cannot_be_validated_against_a_substituted_parent_body() {
1081 // The mirror attack, on the PARENT side. The child honestly holds /repo +
1082 // /etc. The real parent grant commits to a NARROW body (/repo only), so the
1083 // child widens and must be rejected. The attacker wants the check to run
1084 // against a convenient WIDE parent body (/repo + /etc + /srv) that would
1085 // make the child look like a valid attenuation.
1086 let child_caveats = caveats(
1087 Scope::only(["/repo".into(), "/etc".into()]),
1088 Scope::none(),
1089 Scope::none(),
1090 );
1091 // The genuine parent grant names a narrow authority.
1092 let narrow_parent_auth = Authority::new(caveats(
1093 Scope::only(["/repo".into()]),
1094 Scope::none(),
1095 Scope::none(),
1096 ));
1097 let parent_grant = Grant::new(narrow_parent_auth.id().unwrap(), Derivation::Root);
1098 let child = derived_grant(
1099 child_caveats,
1100 Derivation::Attenuation {
1101 parent: parent_grant.id().unwrap(),
1102 },
1103 );
1104 // Attempt to pair the parent grant with a convenient WIDE body — bind fails
1105 // closed: the wide body's CID ≠ the parent grant's authority field.
1106 let convenient_wide = Authority::new(caveats(
1107 Scope::only(["/repo".into(), "/etc".into(), "/srv".into()]),
1108 Scope::none(),
1109 Scope::none(),
1110 ));
1111 assert!(ResolvedGrant::bind(parent_grant.clone(), convenient_wide).is_err());
1112 // The only admissible parent pairing uses the REAL narrow body, against
1113 // which the child's widening is caught.
1114 let honest_parent = ResolvedGrant::bind(parent_grant, narrow_parent_auth).unwrap();
1115 assert_eq!(
1116 check_derivation(&child, Some(&honest_parent), &DenyAllElevations),
1117 DerivationDecision::Reject(DerivationReject::AttenuationWidens)
1118 );
1119 }
1120
1121 #[test]
1122 fn derivation_without_a_parent_fails_closed() {
1123 let parent_id = root_grant(Caveats::top()).id().unwrap();
1124 let child = derived_grant(
1125 Caveats::top(),
1126 Derivation::Attenuation { parent: parent_id },
1127 );
1128 assert_eq!(
1129 check_derivation(&child, None, &DenyAllElevations),
1130 DerivationDecision::Reject(DerivationReject::MissingParent)
1131 );
1132 }
1133
1134 #[test]
1135 fn canonical_form_is_a_hash_preimage_not_a_value_codec() {
1136 use content_addressable::canonical::{
1137 from_canonical_dagcbor_checked, to_canonical_dagcbor,
1138 };
1139
1140 let auth = golden_authority();
1141
1142 // 1. The canonical (hash) bytes carry the domain tag and are the tagged
1143 // `{kind, body}` shape — decoding them AS an Authority does not
1144 // reproduce the value: the hash preimage is not a value codec.
1145 let hash_preimage = auth.canonical_form().unwrap();
1146 let decoded_as_value: Result<Authority, _> = from_canonical_dagcbor_checked(&hash_preimage);
1147 assert!(
1148 decoded_as_value.is_err(),
1149 "the tagged hash preimage must not decode as the bare value type"
1150 );
1151
1152 // 2. Transport uses the value's OWN derived serde, which round-trips
1153 // cleanly, and identity is re-established by recomputing the CID from
1154 // the decoded value — never read from the wire.
1155 let wire = to_canonical_dagcbor(&auth).unwrap();
1156 let back: Authority = from_canonical_dagcbor_checked(&wire).unwrap();
1157 assert_eq!(auth, back);
1158 assert_eq!(auth.id().unwrap(), back.id().unwrap());
1159
1160 // 3. The same boundary holds for Grant.
1161 let grant = Grant::new(auth.id().unwrap(), Derivation::Root);
1162 let grant_wire = to_canonical_dagcbor(&grant).unwrap();
1163 let grant_back: Grant = from_canonical_dagcbor_checked(&grant_wire).unwrap();
1164 assert_eq!(grant, grant_back);
1165 assert_eq!(grant.id().unwrap(), grant_back.id().unwrap());
1166 // Grant's hash preimage is likewise not its value codec.
1167 assert!(from_canonical_dagcbor_checked::<Grant>(&grant.canonical_form().unwrap()).is_err());
1168 }
1169
1170 // ── v1 CID golden corpus: drift detection ────────────────────────────────
1171 //
1172 // These literal CIDs pin the v1 wire representation. If the canonical dag-cbor
1173 // form, the domain tags, the BLAKE3/CIDv1 codec choice, or a field name/order
1174 // in Caveats/Authority/Grant ever changes, these break — which is the point.
1175 // A silent representation drift would re-key every previously-issued authority
1176 // and grant. Regenerating these literals is a DELIBERATE, reviewed act (bump
1177 // the domain-tag version), never a reflexive "update the golden file".
1178
1179 /// The single fixed authority the golden grants below reference.
1180 fn golden_authority() -> Authority {
1181 Authority::new(caveats(
1182 Scope::only(["/repo".into()]),
1183 Scope::only(["git".into()]),
1184 Scope::none(),
1185 ))
1186 }
1187
1188 #[test]
1189 fn golden_authority_cid_is_pinned() {
1190 assert_eq!(
1191 golden_authority().id().unwrap().content_id().to_string(),
1192 "bafyr4igphdy36nsau5r4pzj2qmraozhexhp4k2acxv4bymeuq4xj7kjsjq"
1193 );
1194 }
1195
1196 #[test]
1197 fn golden_root_grant_cid_is_pinned() {
1198 let root = Grant::new(golden_authority().id().unwrap(), Derivation::Root);
1199 assert_eq!(
1200 root.id().unwrap().content_id().to_string(),
1201 "bafyr4ia3m4fhli2zz3lnuulufidc5qitut5wkahyguh3fjneakpgn6keqi"
1202 );
1203 }
1204
1205 #[test]
1206 fn golden_attenuation_grant_cid_is_pinned() {
1207 let auth_id = golden_authority().id().unwrap();
1208 let root = Grant::new(auth_id, Derivation::Root);
1209 let atten = Grant::new(
1210 auth_id,
1211 Derivation::Attenuation {
1212 parent: root.id().unwrap(),
1213 },
1214 );
1215 assert_eq!(
1216 atten.id().unwrap().content_id().to_string(),
1217 "bafyr4ihshpvudtgiiseiwnbezg6cwzmonkmzzdiwf54plgfzenmhbohly4"
1218 );
1219 }
1220
1221 // ── L5 provenance: chain termination at a trusted root ───────────────────
1222
1223 /// Build a valid leaf → root chain: a root over `parent_c`, and an
1224 /// attenuation child over `child_c` whose declared parent is that root.
1225 fn root_and_child(parent_c: Caveats, child_c: Caveats) -> (ResolvedGrant, ResolvedGrant) {
1226 let root = root_grant(parent_c);
1227 let child = derived_grant(
1228 child_c,
1229 Derivation::Attenuation {
1230 parent: root.id().unwrap(),
1231 },
1232 );
1233 (root, child)
1234 }
1235
1236 #[test]
1237 fn chain_is_trusted_only_when_it_terminates_at_the_configured_root() {
1238 // A legitimate root the operator has anchored, with an honest attenuation
1239 // beneath it, is trusted.
1240 let (root, child) = root_and_child(
1241 caveats(
1242 Scope::only(["/repo".into(), "/tmp".into()]),
1243 Scope::only(["git".into()]),
1244 Scope::none(),
1245 ),
1246 caveats(
1247 Scope::only(["/repo".into()]),
1248 Scope::only(["git".into()]),
1249 Scope::none(),
1250 ),
1251 );
1252 let trusted = root.id().unwrap();
1253 let chain = [child, root];
1254 assert_eq!(
1255 verify_chain(&chain, trusted, &DenyAllElevations),
1256 ChainDecision::Trusted
1257 );
1258 }
1259
1260 #[test]
1261 fn a_self_authored_root_is_rejected_unless_it_is_the_configured_anchor() {
1262 // The attacker builds a *structurally perfect* chain: a well-formed root
1263 // and a genuine attenuation beneath it. Every edge validates. But the root
1264 // is one they minted themselves, not the operator's anchor — so it is NOT
1265 // trusted. A CID identifies the root; it does not authorize it.
1266 let (attacker_root, child) = root_and_child(
1267 Caveats::top(),
1268 caveats(Scope::only(["/repo".into()]), Scope::none(), Scope::none()),
1269 );
1270 // The edge itself is perfectly valid...
1271 assert_eq!(
1272 check_derivation(&child, Some(&attacker_root), &DenyAllElevations),
1273 DerivationDecision::Valid
1274 );
1275 // ...but a DIFFERENT root is the configured trust anchor.
1276 let real_anchor = root_grant(caveats(
1277 Scope::only(["/srv".into()]),
1278 Scope::none(),
1279 Scope::none(),
1280 ))
1281 .id()
1282 .unwrap();
1283 let attacker_root_id = attacker_root.id().unwrap();
1284 let chain = [child, attacker_root];
1285 assert_eq!(
1286 verify_chain(&chain, real_anchor, &DenyAllElevations),
1287 ChainDecision::Reject(ChainReject::UntrustedRoot {
1288 found: attacker_root_id,
1289 expected: real_anchor,
1290 })
1291 );
1292 }
1293
1294 #[test]
1295 fn a_chain_that_does_not_bottom_out_at_a_root_is_rejected() {
1296 // A single-element chain whose only element is an attenuation (no root
1297 // beneath it) has nothing to anchor — fail-closed.
1298 let orphan = derived_grant(
1299 Caveats::top(),
1300 Derivation::Attenuation {
1301 parent: root_grant(Caveats::top()).id().unwrap(),
1302 },
1303 );
1304 let anchor = root_grant(Caveats::top()).id().unwrap();
1305 assert_eq!(
1306 verify_chain(&[orphan], anchor, &DenyAllElevations),
1307 ChainDecision::Reject(ChainReject::NonRootTerminus)
1308 );
1309 }
1310
1311 #[test]
1312 fn a_broken_link_in_the_chain_is_rejected() {
1313 // The child names a parent that is not the grant actually sitting above it
1314 // in the chain — check_derivation recomputes the parent id and rejects.
1315 let real_root = root_grant(Caveats::top());
1316 let other_parent_id = root_grant(caveats(
1317 Scope::only(["/elsewhere".into()]),
1318 Scope::none(),
1319 Scope::none(),
1320 ))
1321 .id()
1322 .unwrap();
1323 // Child claims descent from `other_parent_id`, but the chain places
1324 // `real_root` above it.
1325 let child = derived_grant(
1326 Caveats::top(),
1327 Derivation::Attenuation {
1328 parent: other_parent_id,
1329 },
1330 );
1331 let trusted = real_root.id().unwrap();
1332 let chain = [child, real_root];
1333 assert_eq!(
1334 verify_chain(&chain, trusted, &DenyAllElevations),
1335 ChainDecision::Reject(ChainReject::Edge {
1336 index: 0,
1337 reason: DerivationReject::ParentMismatch,
1338 })
1339 );
1340 }
1341
1342 #[test]
1343 fn an_empty_chain_proves_nothing() {
1344 let anchor = root_grant(Caveats::top()).id().unwrap();
1345 assert_eq!(
1346 verify_chain(&[], anchor, &DenyAllElevations),
1347 ChainDecision::Reject(ChainReject::Empty)
1348 );
1349 }
1350
1351 // ── L3 bound: the resolved lattice + admission ───────────────────────────
1352
1353 #[test]
1354 fn relate_classifies_subset_superset_equal() {
1355 assert_eq!(
1356 relate(&concrete(&["a"]), &concrete(&["a", "b"])),
1357 ScopeRelation::Subset
1358 );
1359 assert_eq!(
1360 relate(&concrete(&["a", "b"]), &concrete(&["a"])),
1361 ScopeRelation::Superset
1362 );
1363 assert_eq!(
1364 relate(&concrete(&["a"]), &concrete(&["a"])),
1365 ScopeRelation::Equal
1366 );
1367 assert_eq!(
1368 relate(&concrete(&["a"]), &concrete(&["b"])),
1369 ScopeRelation::Incomparable
1370 );
1371 // Unbounded fence over a bounded bound is a widening.
1372 assert_eq!(
1373 relate(&ResolvedScope::Unbounded, &concrete(&["a"])),
1374 ScopeRelation::Superset
1375 );
1376 // Any Unknown → Unknown (fail-closed).
1377 assert_eq!(
1378 relate(&ResolvedScope::Unknown, &concrete(&["a"])),
1379 ScopeRelation::Unknown
1380 );
1381 // A capability class and a concrete set live on independent dimensions:
1382 // neither contains the other, so the relation is Incomparable — which
1383 // `admit` refuses just as firmly as Unknown (fail-closed), but it is an
1384 // honest structural fact, not an undecidable one.
1385 assert_eq!(
1386 relate(&ResolvedScope::class("loopback"), &concrete(&["127.0.0.1"])),
1387 ScopeRelation::Incomparable
1388 );
1389 // A class within the same class dimension relates cleanly.
1390 assert_eq!(
1391 relate(
1392 &ResolvedScope::class("loopback"),
1393 &ResolvedScope::class("loopback")
1394 ),
1395 ScopeRelation::Equal
1396 );
1397 }
1398
1399 #[test]
1400 fn admit_when_fence_matches_the_delegated_grant() {
1401 let delegated = caveats(
1402 Scope::only(["/repo".into()]),
1403 Scope::only(["git".into()]),
1404 Scope::none(),
1405 );
1406 // Fence resolves to exactly the delegated scopes (fs_write empty = delegated none).
1407 let fence = ResolvedAuthority {
1408 fs_read: concrete(&["/repo"]),
1409 fs_write: concrete(&[]),
1410 exec: concrete(&["git"]),
1411 net: concrete(&[]),
1412 };
1413 assert_eq!(
1414 admit(&fence, &delegated, &empty_closure()),
1415 AdmissionDecision::Admit
1416 );
1417 }
1418
1419 #[test]
1420 fn admit_refuses_a_fence_that_widens_beyond_delegated_plus_closure() {
1421 let delegated = caveats(
1422 Scope::only(["/repo".into()]),
1423 Scope::only(["git".into()]),
1424 Scope::none(),
1425 );
1426 // The fence permits /etc on fs_read — beyond the grant, and no closure adds it.
1427 let fence = ResolvedAuthority {
1428 fs_read: concrete(&["/repo", "/etc"]),
1429 fs_write: concrete(&[]),
1430 exec: concrete(&["git"]),
1431 net: concrete(&[]),
1432 };
1433 assert_eq!(
1434 admit(&fence, &delegated, &empty_closure()),
1435 AdmissionDecision::Reject(AdmissionReject {
1436 axis: ConfinedAxis::FsRead,
1437 relation: ScopeRelation::Superset,
1438 })
1439 );
1440 }
1441
1442 #[test]
1443 fn admit_accepts_a_widening_that_the_explicit_closure_authorizes() {
1444 // The classic legitimate case: the harness closure explicitly adds the
1445 // loader path, so the fence permitting it is within delegated ∪ closure.
1446 let delegated = caveats(Scope::only(["/repo".into()]), Scope::none(), Scope::none());
1447 let closure = ResolvedAuthority {
1448 fs_read: concrete(&["/usr/lib"]),
1449 fs_write: concrete(&[]),
1450 exec: concrete(&[]),
1451 net: concrete(&[]),
1452 };
1453 let fence = ResolvedAuthority {
1454 fs_read: concrete(&["/repo", "/usr/lib"]),
1455 fs_write: concrete(&[]),
1456 exec: concrete(&[]),
1457 net: concrete(&[]),
1458 };
1459 assert_eq!(
1460 admit(&fence, &delegated, &closure),
1461 AdmissionDecision::Admit
1462 );
1463 // …but a path in NEITHER the grant nor the closure still refuses.
1464 let sneaky = ResolvedAuthority {
1465 fs_read: concrete(&["/repo", "/usr/lib", "/root/.ssh"]),
1466 ..fence
1467 };
1468 assert_eq!(
1469 admit(&sneaky, &delegated, &closure),
1470 AdmissionDecision::Reject(AdmissionReject {
1471 axis: ConfinedAxis::FsRead,
1472 relation: ScopeRelation::Superset,
1473 })
1474 );
1475 }
1476
1477 #[test]
1478 fn admit_fail_closes_on_an_unknown_resolution() {
1479 // A fence axis the projector could not resolve refuses (L7), never admits.
1480 let delegated = caveats(Scope::top(), Scope::top(), Scope::top());
1481 let fence = ResolvedAuthority {
1482 fs_read: ResolvedScope::Unknown,
1483 fs_write: ResolvedScope::Unbounded,
1484 exec: ResolvedScope::Unbounded,
1485 net: ResolvedScope::Unbounded,
1486 };
1487 assert_eq!(
1488 admit(&fence, &delegated, &empty_closure()),
1489 AdmissionDecision::Reject(AdmissionReject {
1490 axis: ConfinedAxis::FsRead,
1491 relation: ScopeRelation::Unknown,
1492 })
1493 );
1494 }
1495
1496 // ── L3 lattice laws: the resolved-authority algebra needs the same
1497 // property-test rigor as the caveat lattice, because a broken union or
1498 // a non-monotone bound would let a widening slip past `admit`. ──────────
1499 mod lattice_laws {
1500 use super::*;
1501 use proptest::prelude::*;
1502
1503 /// A generator over the whole `ResolvedScope` domain: the empty bottom,
1504 /// arbitrary bounded concrete/class mixes, `Unbounded`, and `Unknown`.
1505 fn scope() -> impl Strategy<Value = ResolvedScope> {
1506 prop_oneof![
1507 Just(ResolvedScope::Unbounded),
1508 Just(ResolvedScope::Unknown),
1509 (
1510 prop::collection::btree_set("[a-d]", 0..4),
1511 prop::collection::btree_set("class-[x-z]", 0..3),
1512 )
1513 .prop_map(|(concrete, classes)| ResolvedScope::Bounded { concrete, classes }),
1514 ]
1515 }
1516
1517 proptest! {
1518 /// The empty scope is the identity for union: `∅ ∪ X = X` and
1519 /// `X ∪ ∅ = X`. This is the exact law the old `ConcreteScope /
1520 /// CapabilityClass` split violated (`∅ ∪ class(X)` collapsed to
1521 /// `Unknown`), which is why a legitimate closure-authorized widening
1522 /// could be mis-classified.
1523 #[test]
1524 fn empty_is_the_union_identity(x in scope()) {
1525 prop_assert_eq!(ResolvedScope::empty().union(&x), x.clone());
1526 prop_assert_eq!(x.union(&ResolvedScope::empty()), x);
1527 }
1528
1529 /// Union is commutative: `a ∪ b = b ∪ a`.
1530 #[test]
1531 fn union_is_commutative(a in scope(), b in scope()) {
1532 prop_assert_eq!(a.union(&b), b.union(&a));
1533 }
1534
1535 /// Union is associative: `(a ∪ b) ∪ c = a ∪ (b ∪ c)`.
1536 #[test]
1537 fn union_is_associative(a in scope(), b in scope(), c in scope()) {
1538 prop_assert_eq!(a.union(&b).union(&c), a.union(&b.union(&c)));
1539 }
1540
1541 /// Union is idempotent: `a ∪ a = a`.
1542 #[test]
1543 fn union_is_idempotent(a in scope()) {
1544 prop_assert_eq!(a.union(&a), a.clone());
1545 }
1546
1547 /// `Unknown` is absorbing under union — fail-closed propagation: an
1548 /// unresolved axis can never be cleaned up by combining it with
1549 /// anything.
1550 #[test]
1551 fn unknown_propagates_through_union(a in scope()) {
1552 prop_assert_eq!(a.union(&ResolvedScope::Unknown), ResolvedScope::Unknown);
1553 prop_assert_eq!(ResolvedScope::Unknown.union(&a), ResolvedScope::Unknown);
1554 }
1555
1556 /// `Unbounded` absorbs every *resolved* scope (bottom..Unbounded).
1557 /// Only `Unknown` overrides it (checked above): `⊤ ∪ bounded = ⊤`.
1558 #[test]
1559 fn unbounded_absorbs_bounded(
1560 concrete in prop::collection::btree_set("[a-d]", 0..4),
1561 classes in prop::collection::btree_set("class-[x-z]", 0..3),
1562 ) {
1563 let b = ResolvedScope::Bounded { concrete, classes };
1564 prop_assert_eq!(ResolvedScope::Unbounded.union(&b), ResolvedScope::Unbounded);
1565 prop_assert_eq!(b.union(&ResolvedScope::Unbounded), ResolvedScope::Unbounded);
1566 }
1567
1568 /// No-widening / monotonicity: the union is an *upper bound*, so a
1569 /// resolved scope is always `⊆` its union with anything. If this fails,
1570 /// `admit`'s `resolved ⊆ delegated ∪ closure` test would be judging
1571 /// against a bound that is not actually a bound.
1572 #[test]
1573 fn union_is_an_upper_bound(a in scope(), b in scope()) {
1574 let bound = a.union(&b);
1575 // `a` relative to `a ∪ b` is never a Superset (never a widening).
1576 prop_assert_ne!(relate(&a, &bound), ScopeRelation::Superset);
1577 prop_assert_ne!(relate(&b, &bound), ScopeRelation::Superset);
1578 }
1579
1580 /// `relate` is reflexive: every scope equals itself (including
1581 /// `Unbounded`; `Unknown` is the sole exception — it is never
1582 /// decidably equal, by fail-closed design).
1583 #[test]
1584 fn relate_is_reflexive(a in scope()) {
1585 match a {
1586 ResolvedScope::Unknown => {
1587 prop_assert_eq!(relate(&a, &a), ScopeRelation::Unknown);
1588 }
1589 _ => prop_assert_eq!(relate(&a, &a), ScopeRelation::Equal),
1590 }
1591 }
1592
1593 /// `relate` is antisymmetric in direction: if `a ⊆ b` is `Subset`
1594 /// then `b ⊆ a` is `Superset`, and vice-versa (mirrored view).
1595 #[test]
1596 fn relate_direction_is_mirrored(a in scope(), b in scope()) {
1597 match relate(&a, &b) {
1598 ScopeRelation::Subset => {
1599 prop_assert_eq!(relate(&b, &a), ScopeRelation::Superset);
1600 }
1601 ScopeRelation::Superset => {
1602 prop_assert_eq!(relate(&b, &a), ScopeRelation::Subset);
1603 }
1604 ScopeRelation::Equal => {
1605 prop_assert_eq!(relate(&b, &a), ScopeRelation::Equal);
1606 }
1607 ScopeRelation::Incomparable => {
1608 prop_assert_eq!(relate(&b, &a), ScopeRelation::Incomparable);
1609 }
1610 // Any Unknown operand keeps both directions Unknown.
1611 ScopeRelation::Unknown => {
1612 prop_assert_eq!(relate(&b, &a), ScopeRelation::Unknown);
1613 }
1614 }
1615 }
1616 }
1617 }
1618}