Skip to main content

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}