Skip to main content

newt_core/
caveats.rs

1//! `Caveats` — the local mirror of agent-mesh's attenuated authority lattice.
2//!
3//! This is the **enforcement-side** copy of the algebra defined in
4//! `agent_mesh_core::caveats`. agent-mesh owns the *signed wire format*
5//! (the cert chain carries one of these on every `AgentMetadata`); this
6//! module owns the *consult-it-from-the-tool-dispatch-site* surface that
7//! `newt-coder` and the rest of the in-workspace crates use to decide
8//! whether a given tool call is permitted.
9//!
10//! The two types are structurally identical — same field names, same
11//! variant shapes, same `top` / `leq` / `meet` semantics — so the
12//! `newt-mesh` crate (which depends on both) can trivially convert one
13//! into the other at the worker boundary. We deliberately re-define them
14//! here (instead of pulling in agent-mesh) because `newt-mesh` is
15//! [intentionally excluded from the default workspace][1]: depending on
16//! `agent-mesh-protocol` from a workspace member would break CI.
17//!
18//! [1]: ../../docs/decisions/mesh_integration.md
19//!
20//! # Why a lattice
21//!
22//! See `docs/decisions/agentic_object_capability_security.md`. The short
23//! version: delegation must be **attenuation-only** so a confused or
24//! compromised sub-principal cannot amplify beyond the authority it was
25//! minted with. That property is structural — it falls out of `meet`
26//! being the only composition operator and `meet` never being able to
27//! produce something above its operands.
28//!
29//! # What `35b` adds on top of the algebra
30//!
31//! Per-axis "permits this concrete item?" helpers ([`Scope::permits`],
32//! [`Caveats::permits_fs_write`], etc.) — the actual question the
33//! dispatch sites in `newt-coder` ask. These don't change the algebra at
34//! all; they're convenience adaptors so the call sites can read like
35//! prose.
36
37use std::collections::BTreeSet;
38
39use serde::{Deserialize, Serialize};
40
41/// A set-valued authority axis: either unrestricted (`All`, the top of this
42/// axis) or exactly the listed items.
43///
44/// Membership at this layer is **exact**. Path-prefix or host-suffix matching
45/// is an enforcement concern that belongs further down the stack (Landlock,
46/// network namespace); the lattice itself just deals with set inclusion.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum Scope<T: Ord + Clone> {
50    /// Unrestricted — authorizes every item. The `⊤` of this axis.
51    All,
52    /// Authorizes exactly the items in the set.
53    Only(BTreeSet<T>),
54}
55
56impl<T: Ord + Clone> Scope<T> {
57    /// The top of this axis (`All`, unrestricted).
58    #[must_use]
59    pub fn top() -> Self {
60        Self::All
61    }
62
63    /// The empty authority on this axis — authorizes nothing.
64    #[must_use]
65    pub fn none() -> Self {
66        Self::Only(BTreeSet::new())
67    }
68
69    /// Build a bounded scope from an iterator of items.
70    pub fn only<I: IntoIterator<Item = T>>(items: I) -> Self {
71        Self::Only(items.into_iter().collect())
72    }
73
74    /// `self ⊑ other` — does `self` authorize no more than `other`?
75    #[must_use]
76    pub fn leq(&self, other: &Self) -> bool {
77        match (self, other) {
78            (_, Self::All) => true,
79            (Self::All, Self::Only(_)) => false,
80            (Self::Only(a), Self::Only(b)) => a.is_subset(b),
81        }
82    }
83
84    /// `self ⊓ other` — the greatest lower bound. `All` is the identity;
85    /// otherwise intersection.
86    #[must_use]
87    pub fn meet(&self, other: &Self) -> Self {
88        match (self, other) {
89            (Self::All, x) | (x, Self::All) => x.clone(),
90            (Self::Only(a), Self::Only(b)) => Self::Only(a.intersection(b).cloned().collect()),
91        }
92    }
93
94    /// Does this scope authorize `item`?
95    ///
96    /// `All` permits everything; `Only(s)` permits exactly the members of `s`.
97    /// This is the "yes/no" form the tool dispatch sites consult.
98    #[must_use]
99    pub fn permits(&self, item: &T) -> bool {
100        match self {
101            Self::All => true,
102            Self::Only(set) => set.contains(item),
103        }
104    }
105}
106
107/// A numeric upper bound axis (e.g. "at most N tool calls").
108///
109/// `Unlimited` is the top; `AtMost(n) ⊑ AtMost(m) ⟺ n ≤ m`. The meet is the
110/// tighter (smaller) bound.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113pub enum CountBound {
114    /// No bound — the `⊤` of this axis.
115    Unlimited,
116    /// At most this many.
117    AtMost(u64),
118}
119
120impl CountBound {
121    /// The top of this axis (`Unlimited`).
122    #[must_use]
123    pub fn top() -> Self {
124        Self::Unlimited
125    }
126
127    /// `self ⊑ other` — is `self` at least as tight a bound as `other`?
128    #[must_use]
129    pub fn leq(&self, other: &Self) -> bool {
130        match (self, other) {
131            (_, Self::Unlimited) => true,
132            (Self::Unlimited, Self::AtMost(_)) => false,
133            (Self::AtMost(a), Self::AtMost(b)) => a <= b,
134        }
135    }
136
137    /// `self ⊓ other` — the tighter bound.
138    #[must_use]
139    pub fn meet(&self, other: &Self) -> Self {
140        match (self, other) {
141            (Self::Unlimited, x) | (x, Self::Unlimited) => *x,
142            (Self::AtMost(a), Self::AtMost(b)) => Self::AtMost((*a).min(*b)),
143        }
144    }
145
146    /// Does this bound permit one more call when `used_so_far` calls have
147    /// already been counted against it?
148    ///
149    /// `Unlimited` always permits; `AtMost(n)` permits iff `used_so_far < n`.
150    #[must_use]
151    pub fn permits_one_more(&self, used_so_far: u64) -> bool {
152        match self {
153            Self::Unlimited => true,
154            Self::AtMost(n) => used_so_far < *n,
155        }
156    }
157}
158
159/// The capability set an agent holds — one element of the authority
160/// meet-semilattice. See the [module docs](crate::caveats).
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162pub struct Caveats {
163    /// Filesystem paths the agent may read.
164    pub fs_read: Scope<String>,
165    /// Filesystem paths the agent may write.
166    pub fs_write: Scope<String>,
167    /// Commands the agent may execute.
168    pub exec: Scope<String>,
169    /// Network hosts the agent may reach.
170    pub net: Scope<String>,
171    /// Upper bound on tool calls this authority permits.
172    pub max_calls: CountBound,
173    /// Generation counters this authority is valid for (causal, not
174    /// wall-clock — a caveat keys on "flight N", never on time).
175    pub valid_for_generation: Scope<u64>,
176}
177
178impl Caveats {
179    /// `⊤` — unrestricted authority on every axis. Equivalent to "no caveats",
180    /// i.e. the user's full authority. This is the back-compat default the
181    /// existing call sites pass while 35c (per-peer attenuation) is in flight.
182    #[must_use]
183    pub fn top() -> Self {
184        Self {
185            fs_read: Scope::top(),
186            fs_write: Scope::top(),
187            exec: Scope::top(),
188            net: Scope::top(),
189            max_calls: CountBound::top(),
190            valid_for_generation: Scope::top(),
191        }
192    }
193
194    /// `self ⊑ other` — does `self` grant no more authority than `other` on
195    /// *every* axis?
196    #[must_use]
197    pub fn leq(&self, other: &Self) -> bool {
198        self.fs_read.leq(&other.fs_read)
199            && self.fs_write.leq(&other.fs_write)
200            && self.exec.leq(&other.exec)
201            && self.net.leq(&other.net)
202            && self.max_calls.leq(&other.max_calls)
203            && self.valid_for_generation.leq(&other.valid_for_generation)
204    }
205
206    /// `self ⊓ other` — the greatest lower bound, axis by axis.
207    #[must_use]
208    pub fn meet(&self, other: &Self) -> Self {
209        Self {
210            fs_read: self.fs_read.meet(&other.fs_read),
211            fs_write: self.fs_write.meet(&other.fs_write),
212            exec: self.exec.meet(&other.exec),
213            net: self.net.meet(&other.net),
214            max_calls: self.max_calls.meet(&other.max_calls),
215            valid_for_generation: self.valid_for_generation.meet(&other.valid_for_generation),
216        }
217    }
218
219    /// Does this authority permit reading `path`?
220    ///
221    /// `path` is matched by exact string equality against the members of
222    /// `fs_read` (or `All` accepts everything). Path-prefix semantics are
223    /// out of scope at this layer — see the module docs.
224    #[must_use]
225    pub fn permits_fs_read(&self, path: &str) -> bool {
226        self.fs_read.permits(&path.to_string())
227    }
228
229    /// Does this authority permit writing `path`?
230    #[must_use]
231    pub fn permits_fs_write(&self, path: &str) -> bool {
232        self.fs_write.permits(&path.to_string())
233    }
234
235    /// Does this authority permit executing `cmd`?
236    #[must_use]
237    pub fn permits_exec(&self, cmd: &str) -> bool {
238        self.exec.permits(&cmd.to_string())
239    }
240
241    /// Does this authority permit a network call to `host`?
242    #[must_use]
243    pub fn permits_net(&self, host: &str) -> bool {
244        self.net.permits(&host.to_string())
245    }
246}
247
248impl Default for Caveats {
249    /// Absence of caveats is `⊤` (unrestricted) — the back-compatible default
250    /// so existing call sites that don't yet know about caveats keep today's
251    /// behavior.
252    fn default() -> Self {
253        Self::top()
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn scope_all_permits_everything() {
263        let s: Scope<String> = Scope::All;
264        assert!(s.permits(&"anything".to_string()));
265        assert!(s.permits(&"".to_string()));
266    }
267
268    #[test]
269    fn scope_only_permits_exact_members() {
270        let s = Scope::only(["a".to_string(), "b".to_string()]);
271        assert!(s.permits(&"a".to_string()));
272        assert!(s.permits(&"b".to_string()));
273        assert!(!s.permits(&"c".to_string()));
274        assert!(!s.permits(&"".to_string()));
275    }
276
277    #[test]
278    fn scope_none_permits_nothing() {
279        let s: Scope<String> = Scope::none();
280        assert!(!s.permits(&"a".to_string()));
281    }
282
283    #[test]
284    fn scope_lattice_order() {
285        let small = Scope::only(["a".to_string()]);
286        let big = Scope::only(["a".to_string(), "b".to_string()]);
287        assert!(small.leq(&big));
288        assert!(small.leq(&Scope::All));
289        assert!(!Scope::<String>::All.leq(&small));
290        assert!(!big.leq(&small));
291    }
292
293    #[test]
294    fn scope_meet_intersects() {
295        let a = Scope::only(["a".to_string(), "b".to_string()]);
296        let b = Scope::only(["b".to_string(), "c".to_string()]);
297        assert_eq!(a.meet(&b), Scope::only(["b".to_string()]));
298        assert_eq!(a.meet(&Scope::All), a);
299    }
300
301    #[test]
302    fn count_bound_permits_one_more() {
303        assert!(CountBound::Unlimited.permits_one_more(0));
304        assert!(CountBound::Unlimited.permits_one_more(99_999));
305        assert!(CountBound::AtMost(3).permits_one_more(0));
306        assert!(CountBound::AtMost(3).permits_one_more(2));
307        assert!(!CountBound::AtMost(3).permits_one_more(3));
308        assert!(!CountBound::AtMost(3).permits_one_more(99));
309        // Edge: AtMost(0) refuses immediately.
310        assert!(!CountBound::AtMost(0).permits_one_more(0));
311    }
312
313    #[test]
314    fn count_bound_meet_is_tighter() {
315        assert_eq!(
316            CountBound::AtMost(5).meet(&CountBound::AtMost(3)),
317            CountBound::AtMost(3)
318        );
319        assert_eq!(
320            CountBound::Unlimited.meet(&CountBound::AtMost(7)),
321            CountBound::AtMost(7)
322        );
323    }
324
325    #[test]
326    fn caveats_top_permits_everything() {
327        let c = Caveats::top();
328        assert!(c.permits_fs_read("/anywhere"));
329        assert!(c.permits_fs_write("/anywhere"));
330        assert!(c.permits_exec("rm"));
331        assert!(c.permits_net("evil.example.com"));
332        assert!(c.max_calls.permits_one_more(1_000_000));
333    }
334
335    #[test]
336    fn caveats_default_is_top() {
337        assert_eq!(Caveats::default(), Caveats::top());
338    }
339
340    #[test]
341    fn caveats_attenuated_denies_outside_scope() {
342        let c = Caveats {
343            fs_write: Scope::only(["allowed.rs".to_string()]),
344            net: Scope::only(["allowed.example.com".to_string()]),
345            max_calls: CountBound::AtMost(2),
346            ..Caveats::top()
347        };
348        assert!(c.permits_fs_write("allowed.rs"));
349        assert!(!c.permits_fs_write("forbidden.rs"));
350        assert!(c.permits_net("allowed.example.com"));
351        assert!(!c.permits_net("evil.example.com"));
352        assert!(c.max_calls.permits_one_more(1));
353        assert!(!c.max_calls.permits_one_more(2));
354    }
355
356    #[test]
357    fn caveats_leq_per_axis() {
358        let restricted = Caveats {
359            fs_write: Scope::only(["a.rs".to_string()]),
360            ..Caveats::top()
361        };
362        assert!(restricted.leq(&Caveats::top()));
363        assert!(!Caveats::top().leq(&restricted));
364    }
365
366    #[test]
367    fn caveats_meet_attenuates_each_axis() {
368        let a = Caveats {
369            fs_read: Scope::only(["/repo".to_string(), "/tmp".to_string()]),
370            max_calls: CountBound::AtMost(10),
371            ..Caveats::top()
372        };
373        let b = Caveats {
374            fs_read: Scope::only(["/repo".to_string()]),
375            max_calls: CountBound::AtMost(4),
376            ..Caveats::top()
377        };
378        let m = a.meet(&b);
379        assert_eq!(m.fs_read, Scope::only(["/repo".to_string()]));
380        assert_eq!(m.max_calls, CountBound::AtMost(4));
381        assert!(m.leq(&a) && m.leq(&b));
382    }
383
384    #[test]
385    fn caveats_serde_roundtrip() {
386        let c = Caveats {
387            exec: Scope::only(["git".to_string(), "cargo".to_string()]),
388            max_calls: CountBound::AtMost(3),
389            valid_for_generation: Scope::only([42u64]),
390            ..Caveats::top()
391        };
392        let json = serde_json::to_string(&c).unwrap();
393        let back: Caveats = serde_json::from_str(&json).unwrap();
394        assert_eq!(c, back);
395    }
396}