Skip to main content

newt_core/
caveats.rs

1//! `Caveats` — the attenuated authority lattice consumed at every tool
2//! dispatch site.
3//!
4//! Issue #95 collapsed the hand-mirrored `newt_core::caveats` module into a
5//! re-export of [`agent_mesh_protocol::caveats`]: the signed wire types and the
6//! enforcement-side types are now literally the same Rust type, so there is no
7//! drift surface between them and no JSON bridge to maintain.
8//!
9//! What stayed in `newt-core` is the **enforcement convenience layer** —
10//! per-axis `permits_*` adaptors and the `permits_one_more` budget check the
11//! call sites in `newt-coder` and `newt-tui` rely on. Those aren't part of
12//! `agent-mesh-protocol`'s 0.6 surface (the upstream crate ships only the
13//! lattice algebra: `top`, `leq`, `meet`), so we hang them off the re-exported
14//! types via extension traits ([`CaveatsExt`], [`CountBoundExt`],
15//! [`ScopeExt`]). The call sites read exactly the way they did before #95:
16//!
17//! ```text
18//!     if !caveats.permits_fs_write(path) { … }
19//!     if !caveats.max_calls.permits_one_more(used) { … }
20//! ```
21//!
22//! Path-prefix and host-suffix matching is *not* in scope here; the lattice
23//! deals in set membership only. Enforcement sites (e.g. `tui_permits_path`)
24//! layer prefix semantics on top.
25
26pub use agent_mesh_protocol::caveats::{Caveats, CountBound, Scope};
27
28/// Per-axis "permits this concrete item?" check.
29///
30/// `All` permits everything; `Only(s)` permits exactly the members of `s`.
31/// Defined as a trait because the upstream `agent-mesh-protocol::Scope` ships
32/// only the lattice algebra; this is the dispatch-site adaptor. Constructors
33/// (`Scope::only`, `Scope::none`) are inherent on the upstream type — no
34/// re-definition needed.
35pub trait ScopeExt<T: Ord + Clone> {
36    /// Does this scope authorize `item`?
37    fn permits(&self, item: &T) -> bool;
38}
39
40impl<T: Ord + Clone> ScopeExt<T> for Scope<T> {
41    fn permits(&self, item: &T) -> bool {
42        match self {
43            Self::All => true,
44            Self::Only(set) => set.contains(item),
45        }
46    }
47}
48
49/// "Does this bound permit one more call?" — the dispatch-site form of the
50/// `max_calls` axis. Defined as an extension trait because the upstream
51/// `CountBound` type ships only the lattice operations.
52pub trait CountBoundExt {
53    /// Does this bound permit one more call when `used_so_far` calls have
54    /// already been counted against it?
55    ///
56    /// `Unlimited` always permits; `AtMost(n)` permits iff `used_so_far < n`.
57    fn permits_one_more(&self, used_so_far: u64) -> bool;
58}
59
60impl CountBoundExt for CountBound {
61    fn permits_one_more(&self, used_so_far: u64) -> bool {
62        match self {
63            Self::Unlimited => true,
64            Self::AtMost(n) => used_so_far < *n,
65        }
66    }
67}
68
69/// Per-axis "permits this concrete item?" adaptors for the [`Caveats`]
70/// lattice. These don't change the algebra; they're convenience adaptors so
71/// dispatch sites read like prose.
72pub trait CaveatsExt {
73    /// Does this authority permit reading `path`?
74    ///
75    /// `path` is matched by exact string equality against the members of
76    /// `fs_read` (or `All` accepts everything). Path-prefix semantics are out
77    /// of scope at this layer — see the module docs.
78    fn permits_fs_read(&self, path: &str) -> bool;
79
80    /// Does this authority permit writing `path`?
81    fn permits_fs_write(&self, path: &str) -> bool;
82
83    /// Does this authority permit executing `cmd`?
84    fn permits_exec(&self, cmd: &str) -> bool;
85
86    /// Does this authority permit a network call to `host`?
87    fn permits_net(&self, host: &str) -> bool;
88}
89
90impl CaveatsExt for Caveats {
91    fn permits_fs_read(&self, path: &str) -> bool {
92        self.fs_read.permits(&path.to_string())
93    }
94
95    fn permits_fs_write(&self, path: &str) -> bool {
96        self.fs_write.permits(&path.to_string())
97    }
98
99    fn permits_exec(&self, cmd: &str) -> bool {
100        self.exec.permits(&cmd.to_string())
101    }
102
103    fn permits_net(&self, host: &str) -> bool {
104        self.net.permits(&host.to_string())
105    }
106}
107
108// ---------------------------------------------------------------------------
109// Workspace fs-lock (the `--read` / `--write` CLI grants)
110// ---------------------------------------------------------------------------
111
112/// Lock the agent's filesystem authority to `workspace` plus the explicitly
113/// granted paths — the shared mechanism behind "the agent is confined to the
114/// CWD unless a path is opened", used by both the interactive session
115/// (`newt code`) and the headless paths (`newt crew` / `newt worker`).
116///
117/// - `fs_read` → `workspace + read_grants + write_grants` (a write grant implies
118///   read). An open default (`All`) is locked; an already-fenced set is widened.
119/// - `fs_write` → an open default (`All`) is fenced to `workspace + write_grants`;
120///   an already-fenced set keeps its members and gains only `write_grants` (so a
121///   read-only `fs_write = none` opens ONLY the explicit write paths, never the
122///   workspace).
123///
124/// Files *under* a granted directory are matched at the enforcement site
125/// (`tui_permits_path`, prefix semantics); this only sets the root set.
126pub fn lock_fs_to_workspace(
127    caveats: &mut Caveats,
128    workspace: &str,
129    read_grants: &[String],
130    write_grants: &[String],
131) {
132    let mut read_roots: Vec<String> = vec![workspace.to_string()];
133    read_roots.extend(read_grants.iter().cloned());
134    read_roots.extend(write_grants.iter().cloned());
135    caveats.fs_read = match &caveats.fs_read {
136        Scope::All => Scope::only(read_roots),
137        Scope::Only(set) => Scope::only(set.iter().cloned().chain(read_roots)),
138    };
139    caveats.fs_write = match &caveats.fs_write {
140        Scope::All => {
141            Scope::only(std::iter::once(workspace.to_string()).chain(write_grants.iter().cloned()))
142        }
143        Scope::Only(set) => Scope::only(set.iter().cloned().chain(write_grants.iter().cloned())),
144    };
145}
146
147/// Apply [`lock_fs_to_workspace`] from the CLI grant env vars `NEWT_READ_PATHS` /
148/// `NEWT_WRITE_PATHS` (absolute paths that `newt-cli` sets from `--read` /
149/// `--write`, joined with the platform path-list separator). The single entry
150/// point every session path calls.
151///
152/// Uses [`std::env::split_paths`] rather than splitting on a hard-coded `':'`,
153/// so a Windows drive-letter grant (`C:\…`) is not shattered into a bare `"C"`
154/// root that would prefix-match the whole drive.
155pub fn apply_cli_fs_grants(caveats: &mut Caveats, workspace: &str) {
156    let parse = |var: &str| -> Vec<String> {
157        std::env::var_os(var)
158            .map(|s| {
159                std::env::split_paths(&s)
160                    .filter(|p| !p.as_os_str().is_empty())
161                    .map(|p| p.to_string_lossy().into_owned())
162                    .collect()
163            })
164            .unwrap_or_default()
165    };
166    lock_fs_to_workspace(
167        caveats,
168        workspace,
169        &parse("NEWT_READ_PATHS"),
170        &parse("NEWT_WRITE_PATHS"),
171    );
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn scope_all_permits_everything() {
180        let s: Scope<String> = Scope::All;
181        assert!(s.permits(&"anything".to_string()));
182        assert!(s.permits(&"".to_string()));
183    }
184
185    #[test]
186    fn scope_only_permits_exact_members() {
187        let s = Scope::<String>::only(["a".to_string(), "b".to_string()]);
188        assert!(s.permits(&"a".to_string()));
189        assert!(s.permits(&"b".to_string()));
190        assert!(!s.permits(&"c".to_string()));
191        assert!(!s.permits(&"".to_string()));
192    }
193
194    fn s(v: &[&str]) -> std::collections::BTreeSet<String> {
195        v.iter().map(|x| x.to_string()).collect()
196    }
197
198    #[test]
199    fn lock_fs_to_workspace_locks_open_reads_and_writes() {
200        // Headless default (fs open both ways) → fenced to ws + grants.
201        let mut c = Caveats {
202            fs_read: Scope::All,
203            fs_write: Scope::All,
204            ..Caveats::top()
205        };
206        lock_fs_to_workspace(&mut c, "/ws", &["/ext/ro".into()], &["/ext/rw".into()]);
207        // reads = ws + read grant + write grant (write implies read)
208        assert_eq!(c.fs_read, Scope::Only(s(&["/ws", "/ext/ro", "/ext/rw"])));
209        // writes = ws + write grant only (the read grant is NOT writable)
210        assert_eq!(c.fs_write, Scope::Only(s(&["/ws", "/ext/rw"])));
211    }
212
213    #[test]
214    fn lock_fs_to_workspace_preserves_a_readonly_write_fence() {
215        // read-only base: fs_write = none. A --write grant opens ONLY that path;
216        // the workspace stays unwritable (the read-only contract holds).
217        let mut c = Caveats {
218            fs_read: Scope::All,
219            fs_write: Scope::none(),
220            ..Caveats::top()
221        };
222        lock_fs_to_workspace(&mut c, "/ws", &[], &["/ext/rw".into()]);
223        assert_eq!(c.fs_read, Scope::Only(s(&["/ws", "/ext/rw"])));
224        assert_eq!(
225            c.fs_write,
226            Scope::Only(s(&["/ext/rw"])),
227            "ws stays unwritable"
228        );
229    }
230
231    #[test]
232    fn lock_fs_to_workspace_widens_an_existing_fence() {
233        // workspace_dev-like base: both fenced to the workspace already.
234        let mut c = Caveats {
235            fs_read: Scope::only(["/ws".to_string()]),
236            fs_write: Scope::only(["/ws".to_string()]),
237            ..Caveats::top()
238        };
239        lock_fs_to_workspace(&mut c, "/ws", &["/ext/ro".into()], &["/ext/rw".into()]);
240        assert_eq!(c.fs_read, Scope::Only(s(&["/ws", "/ext/ro", "/ext/rw"])));
241        assert_eq!(c.fs_write, Scope::Only(s(&["/ws", "/ext/rw"])));
242    }
243
244    #[test]
245    fn scope_none_permits_nothing() {
246        let s: Scope<String> = Scope::none();
247        assert!(!s.permits(&"a".to_string()));
248    }
249
250    #[test]
251    fn count_bound_permits_one_more() {
252        assert!(CountBound::Unlimited.permits_one_more(0));
253        assert!(CountBound::Unlimited.permits_one_more(99_999));
254        assert!(CountBound::AtMost(3).permits_one_more(0));
255        assert!(CountBound::AtMost(3).permits_one_more(2));
256        assert!(!CountBound::AtMost(3).permits_one_more(3));
257        assert!(!CountBound::AtMost(3).permits_one_more(99));
258        // Edge: AtMost(0) refuses immediately.
259        assert!(!CountBound::AtMost(0).permits_one_more(0));
260    }
261
262    #[test]
263    fn caveats_top_permits_everything() {
264        let c = Caveats::top();
265        assert!(c.permits_fs_read("/anywhere"));
266        assert!(c.permits_fs_write("/anywhere"));
267        assert!(c.permits_exec("rm"));
268        assert!(c.permits_net("evil.example.com"));
269        assert!(c.max_calls.permits_one_more(1_000_000));
270    }
271
272    #[test]
273    fn caveats_attenuated_denies_outside_scope() {
274        let c = Caveats {
275            fs_write: Scope::only(["allowed.rs".to_string()]),
276            net: Scope::only(["allowed.example.com".to_string()]),
277            max_calls: CountBound::AtMost(2),
278            ..Caveats::top()
279        };
280        assert!(c.permits_fs_write("allowed.rs"));
281        assert!(!c.permits_fs_write("forbidden.rs"));
282        assert!(c.permits_net("allowed.example.com"));
283        assert!(!c.permits_net("evil.example.com"));
284        assert!(c.max_calls.permits_one_more(1));
285        assert!(!c.max_calls.permits_one_more(2));
286    }
287}