Skip to main content

agent_mesh_protocol/
caveats.rs

1//! `Caveats` — the authority lattice for attenuated agent capabilities.
2//!
3//! A [`Caveats`] value is an element of a bounded **meet-semilattice**
4//! `(L, ⊑, ⊓, ⊤)`:
5//!
6//! - **`⊤` (top)** — [`Caveats::top`] — the user's full, *unrestricted*
7//!   authority. The absence of any caveat is `⊤`.
8//! - **`⊑` (attenuates / "is at most")** — [`Caveats::leq`] — `a ⊑ b` means
9//!   `a` grants no more than `b`. It is a partial order (reflexive,
10//!   antisymmetric, transitive).
11//! - **`⊓` (meet)** — [`Caveats::meet`] — the greatest lower bound: the most
12//!   permissive authority that is still `⊑` both operands. `meet` is the only
13//!   way capabilities compose along a delegation chain, and it can **never
14//!   amplify** — for all `a, b`: `a ⊓ b ⊑ a` and `a ⊓ b ⊑ b`.
15//!
16//! Delegation is **attenuation-only**: a child must satisfy `child ⊑ parent`,
17//! and because the algebra has no reachable join/amplify operation, a confused
18//! or compromised agent *cannot* escalate beyond the down-set of the caveats
19//! it was minted with. Safety becomes structural rather than a property of the
20//! model behaving.
21//!
22//! This crate ships the lattice type and its laws (property-tested). The
23//! lattice is wired into [`crate::AgentMetadata`] (the `caveats` field is part
24//! of the signed cert payload) and `child ⊑ parent` attenuation is enforced at
25//! two independent points: [`AgentKey::delegate`](crate::AgentKey::delegate)
26//! refuses to mint an amplifying child ([`MeshError::CaveatAmplification`](crate::MeshError::CaveatAmplification)),
27//! and [`CertChain::verify`](crate::CertChain::verify) re-checks attenuation at
28//! every delegated link — so a forged chain that amplifies authority is
29//! rejected even when each signature is valid (see
30//! `forged_amplifying_chain_fails_verify` in `agent_key.rs`). OS-level
31//! enforcement (Landlock, uid-mapped namespaces) is the next step. See
32//! `docs/decisions/agentic_object_capability_security.md` in the `newt-agent`
33//! repo for the full design.
34//!
35//! ## Scope semantics
36//!
37//! Each axis is a [`Scope`] (a set of allowed items, or `All`). Membership is
38//! **exact** at this layer: `fs_read` carries the literal paths/prefixes the
39//! authority names, and `⊑`/`⊓` are set inclusion / intersection. Treating a
40//! path as a *prefix* that also authorizes its descendants is an
41//! *enforcement* concern (it belongs with the Landlock layer), not a property
42//! of the lattice algebra — so it is deliberately out of scope here.
43
44use std::collections::BTreeSet;
45
46use serde::{Deserialize, Serialize};
47
48/// A set-valued authority axis: either unrestricted (`All`, the top of this
49/// axis) or exactly the listed items.
50///
51/// Ordered so that `Only(s) ⊑ All` for every `s`, and
52/// `Only(a) ⊑ Only(b) ⟺ a ⊆ b`. The meet is intersection, with `All` acting
53/// as the identity.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum Scope<T: Ord + Clone> {
57    /// Unrestricted — authorizes every item. The `⊤` of this axis.
58    All,
59    /// Authorizes exactly the items in the set (canonical: a `BTreeSet`).
60    Only(BTreeSet<T>),
61}
62
63impl<T: Ord + Clone> Scope<T> {
64    /// The top of this axis (`All`, unrestricted).
65    #[must_use]
66    pub fn top() -> Self {
67        Self::All
68    }
69
70    /// The empty authority on this axis — authorizes nothing.
71    #[must_use]
72    pub fn none() -> Self {
73        Self::Only(BTreeSet::new())
74    }
75
76    /// Build a bounded scope from an iterator of items.
77    pub fn only<I: IntoIterator<Item = T>>(items: I) -> Self {
78        Self::Only(items.into_iter().collect())
79    }
80
81    /// `self ⊑ other` — does `self` authorize no more than `other`?
82    #[must_use]
83    pub fn leq(&self, other: &Self) -> bool {
84        match (self, other) {
85            // Everything is ⊑ unrestricted.
86            (_, Self::All) => true,
87            // Unrestricted is not ⊑ a bounded set.
88            (Self::All, Self::Only(_)) => false,
89            // Bounded ⊑ bounded iff subset.
90            (Self::Only(a), Self::Only(b)) => a.is_subset(b),
91        }
92    }
93
94    /// `self ⊓ other` — the greatest lower bound (most permissive scope still
95    /// `⊑` both). `All` is the identity; otherwise intersection.
96    #[must_use]
97    pub fn meet(&self, other: &Self) -> Self {
98        match (self, other) {
99            (Self::All, x) | (x, Self::All) => x.clone(),
100            (Self::Only(a), Self::Only(b)) => Self::Only(a.intersection(b).cloned().collect()),
101        }
102    }
103}
104
105/// A numeric upper bound axis (e.g. "at most N tool calls").
106///
107/// `Unlimited` is the top; `AtMost(n) ⊑ AtMost(m) ⟺ n ≤ m`. The meet is the
108/// tighter (smaller) bound.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(rename_all = "snake_case")]
111pub enum CountBound {
112    /// No bound — the `⊤` of this axis.
113    Unlimited,
114    /// At most this many.
115    AtMost(u64),
116}
117
118impl CountBound {
119    /// The top of this axis (`Unlimited`).
120    #[must_use]
121    pub fn top() -> Self {
122        Self::Unlimited
123    }
124
125    /// `self ⊑ other` — is `self` at least as tight a bound as `other`?
126    #[must_use]
127    pub fn leq(&self, other: &Self) -> bool {
128        match (self, other) {
129            (_, Self::Unlimited) => true,
130            (Self::Unlimited, Self::AtMost(_)) => false,
131            (Self::AtMost(a), Self::AtMost(b)) => a <= b,
132        }
133    }
134
135    /// `self ⊓ other` — the tighter bound.
136    #[must_use]
137    pub fn meet(&self, other: &Self) -> Self {
138        match (self, other) {
139            (Self::Unlimited, x) | (x, Self::Unlimited) => *x,
140            (Self::AtMost(a), Self::AtMost(b)) => Self::AtMost((*a).min(*b)),
141        }
142    }
143}
144
145/// The capability set an agent holds — one element of the authority
146/// meet-semilattice. See the [module docs](crate::caveats).
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148pub struct Caveats {
149    /// Filesystem paths the agent may read.
150    pub fs_read: Scope<String>,
151    /// Filesystem paths the agent may write.
152    pub fs_write: Scope<String>,
153    /// Commands the agent may execute.
154    pub exec: Scope<String>,
155    /// Network hosts the agent may reach.
156    pub net: Scope<String>,
157    /// Upper bound on tool calls this authority permits.
158    pub max_calls: CountBound,
159    /// Generation counters this authority is valid for (causal, not
160    /// wall-clock — a caveat keys on "flight N", never on time).
161    pub valid_for_generation: Scope<u64>,
162}
163
164impl Caveats {
165    /// `⊤` — unrestricted authority on every axis. Equivalent to "no caveats",
166    /// i.e. the user's full authority.
167    #[must_use]
168    pub fn top() -> Self {
169        Self {
170            fs_read: Scope::top(),
171            fs_write: Scope::top(),
172            exec: Scope::top(),
173            net: Scope::top(),
174            max_calls: CountBound::top(),
175            valid_for_generation: Scope::top(),
176        }
177    }
178
179    /// `self ⊑ other` — does `self` grant no more authority than `other` on
180    /// *every* axis? This is the attenuation check: a delegated child's
181    /// caveats must be `⊑` its parent's.
182    #[must_use]
183    pub fn leq(&self, other: &Self) -> bool {
184        self.fs_read.leq(&other.fs_read)
185            && self.fs_write.leq(&other.fs_write)
186            && self.exec.leq(&other.exec)
187            && self.net.leq(&other.net)
188            && self.max_calls.leq(&other.max_calls)
189            && self.valid_for_generation.leq(&other.valid_for_generation)
190    }
191
192    /// `self ⊓ other` — the greatest lower bound, axis by axis. This is how
193    /// authority composes along a delegation chain; it can never amplify.
194    #[must_use]
195    pub fn meet(&self, other: &Self) -> Self {
196        Self {
197            fs_read: self.fs_read.meet(&other.fs_read),
198            fs_write: self.fs_write.meet(&other.fs_write),
199            exec: self.exec.meet(&other.exec),
200            net: self.net.meet(&other.net),
201            max_calls: self.max_calls.meet(&other.max_calls),
202            valid_for_generation: self.valid_for_generation.meet(&other.valid_for_generation),
203        }
204    }
205}
206
207impl Default for Caveats {
208    /// Absence of caveats is `⊤` (unrestricted) — the back-compatible default
209    /// so an `AgentMetadata` with no declared caveats keeps today's behavior.
210    fn default() -> Self {
211        Self::top()
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use proptest::prelude::*;
219
220    // ── Hand-written examples (read like a spec) ────────────────────────────
221
222    #[test]
223    fn scope_all_is_top() {
224        let bounded = Scope::only(["/a".to_string()]);
225        assert!(bounded.leq(&Scope::All));
226        assert!(!Scope::<String>::All.leq(&bounded));
227        assert_eq!(Scope::<String>::All.meet(&bounded), bounded);
228    }
229
230    #[test]
231    fn scope_subset_order() {
232        let small = Scope::only(["/a".to_string()]);
233        let big = Scope::only(["/a".to_string(), "/b".to_string()]);
234        assert!(small.leq(&big));
235        assert!(!big.leq(&small));
236        assert_eq!(big.meet(&small), small);
237    }
238
239    #[test]
240    fn scope_disjoint_meet_is_empty() {
241        let a = Scope::only(["/a".to_string()]);
242        let b = Scope::only(["/b".to_string()]);
243        assert_eq!(a.meet(&b), Scope::none());
244        assert!(Scope::<String>::none().leq(&a));
245    }
246
247    #[test]
248    fn count_bound_order_and_meet() {
249        assert!(CountBound::AtMost(3).leq(&CountBound::AtMost(5)));
250        assert!(!CountBound::AtMost(5).leq(&CountBound::AtMost(3)));
251        assert!(CountBound::AtMost(99).leq(&CountBound::Unlimited));
252        assert!(!CountBound::Unlimited.leq(&CountBound::AtMost(1)));
253        assert_eq!(
254            CountBound::AtMost(5).meet(&CountBound::AtMost(3)),
255            CountBound::AtMost(3)
256        );
257        assert_eq!(
258            CountBound::Unlimited.meet(&CountBound::AtMost(7)),
259            CountBound::AtMost(7)
260        );
261    }
262
263    #[test]
264    fn caveats_top_is_above_everything() {
265        let restricted = Caveats {
266            fs_read: Scope::only(["/repo".to_string()]),
267            fs_write: Scope::none(),
268            exec: Scope::only(["git".to_string()]),
269            net: Scope::none(),
270            max_calls: CountBound::AtMost(10),
271            valid_for_generation: Scope::only([7u64]),
272        };
273        assert!(restricted.leq(&Caveats::top()));
274        assert!(!Caveats::top().leq(&restricted));
275    }
276
277    #[test]
278    fn caveats_meet_attenuates_each_axis() {
279        let a = Caveats {
280            fs_read: Scope::only(["/repo".to_string(), "/tmp".to_string()]),
281            max_calls: CountBound::AtMost(10),
282            ..Caveats::top()
283        };
284        let b = Caveats {
285            fs_read: Scope::only(["/repo".to_string()]),
286            max_calls: CountBound::AtMost(4),
287            ..Caveats::top()
288        };
289        let m = a.meet(&b);
290        assert_eq!(m.fs_read, Scope::only(["/repo".to_string()]));
291        assert_eq!(m.max_calls, CountBound::AtMost(4));
292        assert!(m.leq(&a) && m.leq(&b));
293    }
294
295    #[test]
296    fn caveats_serde_roundtrip() {
297        let c = Caveats {
298            exec: Scope::only(["git".to_string(), "cargo".to_string()]),
299            max_calls: CountBound::AtMost(3),
300            valid_for_generation: Scope::only([42u64]),
301            ..Caveats::top()
302        };
303        let json = serde_json::to_string(&c).unwrap();
304        let back: Caveats = serde_json::from_str(&json).unwrap();
305        assert_eq!(c, back);
306    }
307
308    // ── Property tests: the lattice laws + attenuation-only ──────────────────
309
310    fn scope_str() -> impl Strategy<Value = Scope<String>> {
311        prop_oneof![
312            Just(Scope::All),
313            prop::collection::btree_set("[a-d]", 0..4).prop_map(Scope::Only),
314        ]
315    }
316
317    fn count_bound() -> impl Strategy<Value = CountBound> {
318        prop_oneof![
319            Just(CountBound::Unlimited),
320            (0u64..6).prop_map(CountBound::AtMost)
321        ]
322    }
323
324    fn gen_scope() -> impl Strategy<Value = Scope<u64>> {
325        prop_oneof![
326            Just(Scope::All),
327            prop::collection::btree_set(0u64..4, 0..4).prop_map(Scope::Only),
328        ]
329    }
330
331    prop_compose! {
332        fn caveats()(
333            fs_read in scope_str(),
334            fs_write in scope_str(),
335            exec in scope_str(),
336            net in scope_str(),
337            max_calls in count_bound(),
338            valid_for_generation in gen_scope(),
339        ) -> Caveats {
340            Caveats { fs_read, fs_write, exec, net, max_calls, valid_for_generation }
341        }
342    }
343
344    proptest! {
345        // Partial order: reflexive, antisymmetric, transitive.
346        #[test]
347        fn leq_reflexive(a in caveats()) {
348            prop_assert!(a.leq(&a));
349        }
350
351        #[test]
352        fn leq_antisymmetric(a in caveats(), b in caveats()) {
353            if a.leq(&b) && b.leq(&a) {
354                prop_assert_eq!(a, b);
355            }
356        }
357
358        #[test]
359        fn leq_transitive(a in caveats(), b in caveats(), c in caveats()) {
360            if a.leq(&b) && b.leq(&c) {
361                prop_assert!(a.leq(&c));
362            }
363        }
364
365        // Meet is the greatest lower bound.
366        #[test]
367        fn meet_is_lower_bound(a in caveats(), b in caveats()) {
368            let m = a.meet(&b);
369            prop_assert!(m.leq(&a), "meet must be ⊑ left");
370            prop_assert!(m.leq(&b), "meet must be ⊑ right");
371        }
372
373        #[test]
374        fn meet_is_greatest_lower_bound(a in caveats(), b in caveats(), c in caveats()) {
375            // Any common lower bound c is ⊑ the meet.
376            if c.leq(&a) && c.leq(&b) {
377                prop_assert!(c.leq(&a.meet(&b)));
378            }
379        }
380
381        // Meet is a commutative, associative, idempotent monoid with ⊤ identity.
382        #[test]
383        fn meet_commutative(a in caveats(), b in caveats()) {
384            prop_assert_eq!(a.meet(&b), b.meet(&a));
385        }
386
387        #[test]
388        fn meet_associative(a in caveats(), b in caveats(), c in caveats()) {
389            prop_assert_eq!(a.meet(&b).meet(&c), a.meet(&b.meet(&c)));
390        }
391
392        #[test]
393        fn meet_idempotent(a in caveats()) {
394            prop_assert_eq!(a.meet(&a), a.clone());
395        }
396
397        #[test]
398        fn top_is_meet_identity(a in caveats()) {
399            prop_assert_eq!(a.meet(&Caveats::top()), a.clone());
400            prop_assert!(a.leq(&Caveats::top()));
401        }
402
403        // The headline safety property: meet can NEVER amplify. Composing two
404        // authorities only ever yields something ⊑ each input — no reachable
405        // operation produces authority above an operand.
406        #[test]
407        fn meet_never_amplifies(a in caveats(), b in caveats()) {
408            let m = a.meet(&b);
409            prop_assert!(m.leq(&a) && m.leq(&b));
410            // And m is strictly not above a unless m == a (no amplification):
411            if a.leq(&m) {
412                prop_assert_eq!(&m, &a);
413            }
414        }
415    }
416}