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