use std::path::{Component, Path, PathBuf};
use crate::{AxisEnforcement, Caveats, SandboxKind, Scope, ToolError, ToolResult};
#[derive(Debug, Clone)]
pub struct ToolContext {
effective: Caveats,
sandbox_kind: SandboxKind,
strength_floor: AxisEnforcement,
}
impl ToolContext {
pub(crate) fn mint(
effective: Caveats,
sandbox_kind: SandboxKind,
strength_floor: AxisEnforcement,
) -> Self {
Self {
effective,
sandbox_kind,
strength_floor,
}
}
#[must_use]
pub fn caveats(&self) -> &Caveats {
&self.effective
}
#[must_use]
pub fn sandbox_kind(&self) -> SandboxKind {
self.sandbox_kind
}
#[must_use]
pub fn strength_floor(&self) -> AxisEnforcement {
self.strength_floor
}
pub fn check_exec(&self, program: &str) -> ToolResult<()> {
if exec_scope_allows(&self.effective.exec, program) {
Ok(())
} else {
Err(ToolError::denied(format!(
"exec of {program:?} is not within the granted authority"
)))
}
}
pub fn check_net(&self, host: &str) -> ToolResult<()> {
if scope_allows(&self.effective.net, host) {
Ok(())
} else {
Err(ToolError::denied(format!(
"network access to {host:?} is not within the granted authority"
)))
}
}
pub fn check_path_read(&self, path: &Path) -> ToolResult<()> {
self.check_path(&self.effective.fs_read, path, "read")
}
pub fn check_path_write(&self, path: &Path) -> ToolResult<()> {
self.check_path(&self.effective.fs_write, path, "write")
}
fn check_path(&self, axis: &Scope<String>, path: &Path, op: &str) -> ToolResult<()> {
let allowed = match axis {
Scope::All => return Ok(()),
Scope::Only(set) => set,
};
let canon = canonicalize_for_check(path).map_err(|e| {
ToolError::denied(format!(
"{op} of {path:?} denied: cannot canonicalize ({e})"
))
})?;
for entry in allowed {
let Ok(base) = canonicalize_for_check(Path::new(entry)) else {
continue;
};
if path_is_within(&canon, &base) {
return Ok(());
}
}
Err(ToolError::denied(format!(
"{op} of {} (resolved {}) is not within the granted fs_{op} scope",
path.display(),
canon.display(),
)))
}
}
fn scope_allows(scope: &Scope<String>, item: &str) -> bool {
match scope {
Scope::All => true,
Scope::Only(set) => set.contains(item),
}
}
fn exec_scope_allows(scope: &Scope<String>, program: &str) -> bool {
let set = match scope {
Scope::All => return true,
Scope::Only(set) => set,
};
if set.contains(program) {
return true;
}
if let Some(base) = Path::new(program).file_name().and_then(|b| b.to_str()) {
if set.contains(base) {
return true;
}
}
false
}
fn canonicalize_for_check(path: &Path) -> std::io::Result<PathBuf> {
if let Ok(c) = path.canonicalize() {
return Ok(c);
}
let mut existing = path;
let mut tail: Vec<Component<'_>> = Vec::new();
loop {
if existing.exists() {
break;
}
match existing.parent() {
Some(parent) => {
if let Some(name) = existing.file_name() {
tail.push(Component::Normal(name));
} else {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"path has no resolvable existing ancestor",
));
}
existing = parent;
}
None => {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"no existing ancestor to canonicalize",
));
}
}
}
let mut base = existing.canonicalize()?;
for comp in tail.into_iter().rev() {
match comp {
Component::Normal(name) => base.push(name),
Component::ParentDir => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"refusing to resolve `..` in a non-existent path tail",
));
}
_ => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"unexpected component in path tail",
));
}
}
}
Ok(base)
}
fn path_is_within(candidate: &Path, base: &Path) -> bool {
candidate == base || candidate.starts_with(base)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{CountBound, Gate};
fn ctx(granted: Caveats) -> ToolContext {
struct AnyTool;
#[async_trait::async_trait]
impl crate::Tool for AnyTool {
fn name(&self) -> &str {
"any"
}
fn schema(&self) -> serde_json::Value {
serde_json::json!({})
}
async fn invoke(
&self,
_args: serde_json::Value,
_cx: &ToolContext,
) -> ToolResult<serde_json::Value> {
Ok(serde_json::Value::Null)
}
}
let gate = Gate::new(0);
gate.authorize(&AnyTool, &granted).expect("authorize")
}
#[test]
fn check_exec_allows_in_scope_denies_out_of_scope() {
let cx = ctx(Caveats {
exec: Scope::only(["echo".to_string()]),
..Caveats::top()
});
assert!(cx.check_exec("echo").is_ok());
assert!(cx.check_exec("rm").is_err());
}
#[test]
fn check_exec_bare_name_grant_matches_resolved_paths() {
let cx = ctx(Caveats {
exec: Scope::only(["git".to_string()]),
..Caveats::top()
});
assert!(cx.check_exec("git").is_ok());
assert!(cx.check_exec("/usr/bin/git").is_ok());
assert!(cx.check_exec("/opt/homebrew/bin/git").is_ok());
}
#[test]
fn check_exec_full_path_grant_pins_exactly() {
let cx = ctx(Caveats {
exec: Scope::only(["/usr/bin/git".to_string()]),
..Caveats::top()
});
assert!(cx.check_exec("/usr/bin/git").is_ok());
assert!(cx.check_exec("/opt/homebrew/bin/git").is_err());
assert!(cx.check_exec("git").is_err());
}
#[test]
fn check_exec_basename_deny_preserved() {
let cx = ctx(Caveats {
exec: Scope::only(["echo".to_string()]),
..Caveats::top()
});
assert!(cx.check_exec("/bin/rm").is_err());
assert!(cx.check_exec("/bin/echo").is_ok());
}
#[test]
fn check_exec_all_allows_anything() {
let cx = ctx(Caveats {
exec: Scope::All,
..Caveats::top()
});
assert!(cx.check_exec("git").is_ok());
assert!(cx.check_exec("/usr/bin/anything").is_ok());
assert!(cx.check_exec("/bin/rm").is_ok());
}
#[test]
fn check_net_allows_in_scope_denies_out_of_scope() {
let cx = ctx(Caveats {
net: Scope::only(["example.com".to_string()]),
..Caveats::top()
});
assert!(cx.check_net("example.com").is_ok());
assert!(cx.check_net("evil.test").is_err());
}
#[test]
fn check_path_write_denies_outside_scope() {
let dir = std::env::temp_dir();
let cx = ctx(Caveats {
fs_write: Scope::only([dir.to_string_lossy().into_owned()]),
..Caveats::top()
});
assert!(cx.check_path_write(&dir.join("brandnew.txt")).is_ok());
assert!(cx.check_path_write(Path::new("/etc/shadow")).is_err());
}
#[test]
fn check_path_write_rejects_dotdot_and_symlink_escape() {
use std::fs;
let root = std::env::temp_dir().join(format!(
"agent-bridle-pathtest-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
let allowed = root.join("allowed");
let secret_dir = root.join("secret");
fs::create_dir_all(&allowed).expect("mkdir allowed");
fs::create_dir_all(&secret_dir).expect("mkdir secret");
let secret_file = secret_dir.join("loot.txt");
fs::write(&secret_file, b"top secret").expect("write secret");
let cx = ctx(Caveats {
fs_write: Scope::only([allowed.to_string_lossy().into_owned()]),
..Caveats::top()
});
assert!(cx.check_path_write(&allowed.join("ok.txt")).is_ok());
let dotdot = allowed.join("..").join("secret").join("loot.txt");
assert!(
cx.check_path_write(&dotdot).is_err(),
"..-traversal out of scope must be denied (got Ok for {dotdot:?})"
);
#[cfg(unix)]
{
let link = allowed.join("escape");
std::os::unix::fs::symlink(&secret_dir, &link).expect("symlink");
let via_symlink = link.join("loot.txt");
assert!(
cx.check_path_write(&via_symlink).is_err(),
"symlink escape must be denied (got Ok for {via_symlink:?})"
);
}
let _ = fs::remove_dir_all(&root);
}
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
#[test]
fn caveats_and_sandbox_kind_are_exposed() {
let cx = ctx(Caveats {
max_calls: CountBound::AtMost(3),
..Caveats::top()
});
assert_eq!(cx.caveats().max_calls, CountBound::AtMost(3));
assert_eq!(cx.sandbox_kind(), SandboxKind::None);
}
}