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}