use std::path::{Component, Path, PathBuf};
use std::time::Duration;
pub const DEFAULT_USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
pub const DEFAULT_MAX_RESPONSE_BYTES: usize = 2 * 1024 * 1024;
pub const DEFAULT_MAX_CONTENT_CHARS: usize = 50_000;
#[derive(Debug, Clone)]
pub struct ToolPolicy {
pub workspace_root: Option<PathBuf>,
pub protected_paths: Vec<PathBuf>,
pub shell_timeout: Duration,
pub web: WebPolicy,
}
impl Default for ToolPolicy {
fn default() -> ToolPolicy {
ToolPolicy {
workspace_root: None,
protected_paths: Vec::new(),
shell_timeout: Duration::from_secs(120),
web: WebPolicy::default(),
}
}
}
impl ToolPolicy {
pub(crate) fn check_write_path(&self, path: &Path) -> Result<(), String> {
self.check_path(path, "Writes")
}
pub(crate) fn check_read_path(&self, path: &Path) -> Result<(), String> {
self.check_path(path, "Reads")
}
fn check_path(&self, path: &Path, action: &str) -> Result<(), String> {
let resolved = resolve_for_containment(path);
for protected in &self.protected_paths {
let protected = resolve_for_containment(protected);
if resolved.starts_with(&protected) {
return Err(format!(
"{action} under '{}' are not permitted: it is a protected directory.",
protected.display()
));
}
}
if let Some(root) = &self.workspace_root {
let root = resolve_for_containment(root);
if !resolved.starts_with(&root) {
return Err(format!(
"Path '{}' is outside the workspace root '{}'.",
resolved.display(),
root.display()
));
}
}
Ok(())
}
fn base_dir(&self) -> PathBuf {
self.workspace_root
.clone()
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| PathBuf::from("."))
}
pub(crate) fn resolve_arg_path(&self, raw: &str) -> PathBuf {
let p = Path::new(raw);
if p.is_absolute() {
p.to_path_buf()
} else {
self.base_dir().join(p)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WebFormat {
Markdown,
Text,
}
impl WebFormat {
pub fn parse(s: Option<&str>, fallback: WebFormat) -> WebFormat {
match s {
Some("text") => WebFormat::Text,
Some("markdown") => WebFormat::Markdown,
_ => fallback,
}
}
}
#[derive(Debug, Clone)]
pub struct WebPolicy {
pub allow_private_hosts: bool,
pub https_only: bool,
pub allowed_urls: Vec<glob::Pattern>,
pub allowlist_only: bool,
pub denied_urls: Vec<glob::Pattern>,
pub max_response_bytes: usize,
pub max_content_chars: usize,
pub default_format: WebFormat,
pub user_agent: String,
}
impl Default for WebPolicy {
fn default() -> WebPolicy {
WebPolicy {
allow_private_hosts: false,
https_only: true,
allowed_urls: Vec::new(),
allowlist_only: false,
denied_urls: Vec::new(),
max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
max_content_chars: DEFAULT_MAX_CONTENT_CHARS,
default_format: WebFormat::Markdown,
user_agent: DEFAULT_USER_AGENT.to_string(),
}
}
}
#[cfg(feature = "web-tools")]
impl WebPolicy {
pub(crate) fn check_url(&self, url: &reqwest::Url) -> Result<(), String> {
let as_str = url.as_str();
if self.https_only && url.scheme() != "https" {
return Err(format!(
"Refusing to fetch '{as_str}': only https:// URLs are allowed \
(https_only is enabled)."
));
}
if let Some(deny) = self.denied_urls.iter().find(|p| p.matches(as_str)) {
return Err(format!(
"Refusing to fetch '{as_str}': it matches a denied URL pattern ('{deny}')."
));
}
if (self.allowlist_only || !self.allowed_urls.is_empty())
&& !self.allowed_urls.iter().any(|p| p.matches(as_str))
{
return Err(if self.allowed_urls.is_empty() {
format!(
"Refusing to fetch '{as_str}': web egress is locked down \
(allowlist-only, and the allowlist is empty)."
)
} else {
format!("Refusing to fetch '{as_str}': it is not in the allowlist.")
});
}
Ok(())
}
}
pub fn normalize_path(path: &Path) -> PathBuf {
let abs = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("/"))
.join(path)
};
let mut out = PathBuf::new();
for comp in abs.components() {
match comp {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
other => out.push(other),
}
}
out
}
pub fn resolve_for_containment(path: &Path) -> PathBuf {
let norm = normalize_path(path);
let mut existing = norm.as_path();
let mut tail: Vec<std::ffi::OsString> = Vec::new();
while !existing.exists() {
match (existing.file_name(), existing.parent()) {
(Some(name), Some(parent)) => {
tail.push(name.to_os_string());
existing = parent;
}
_ => break,
}
}
let canon = existing
.canonicalize()
.unwrap_or_else(|_| existing.to_path_buf());
tail.into_iter().rev().fold(canon, |acc, c| acc.join(c))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_path_resolves_dots() {
assert_eq!(
normalize_path(Path::new("/a/b/../c/./d")),
PathBuf::from("/a/c/d")
);
assert_eq!(normalize_path(Path::new("/a/../../..")), PathBuf::from("/"));
}
#[test]
fn web_format_parse_falls_back() {
assert_eq!(
WebFormat::parse(Some("text"), WebFormat::Markdown),
WebFormat::Text
);
assert_eq!(
WebFormat::parse(Some("bogus"), WebFormat::Text),
WebFormat::Text
);
assert_eq!(
WebFormat::parse(None, WebFormat::Markdown),
WebFormat::Markdown
);
}
#[cfg(feature = "web-tools")]
#[test]
fn check_url_enforces_https_only() {
let policy = WebPolicy::default();
let http = reqwest::Url::parse("http://example.com").unwrap();
assert!(policy.check_url(&http).is_err());
let https = reqwest::Url::parse("https://example.com").unwrap();
assert!(policy.check_url(&https).is_ok());
}
#[cfg(feature = "web-tools")]
#[test]
fn check_url_denylist_wins_over_allowlist() {
let policy = WebPolicy {
allowed_urls: vec![glob::Pattern::new("https://example.com/*").unwrap()],
denied_urls: vec![glob::Pattern::new("*/secret*").unwrap()],
..WebPolicy::default()
};
assert!(
policy
.check_url(&reqwest::Url::parse("https://example.com/ok").unwrap())
.is_ok()
);
assert!(
policy
.check_url(&reqwest::Url::parse("https://example.com/secret").unwrap())
.is_err()
);
assert!(
policy
.check_url(&reqwest::Url::parse("https://other.com/ok").unwrap())
.is_err()
);
}
#[cfg(feature = "web-tools")]
#[test]
fn allowlist_only_with_empty_list_denies_all() {
let policy = WebPolicy {
allowlist_only: true,
..WebPolicy::default()
};
let err = policy
.check_url(&reqwest::Url::parse("https://example.com/ok").unwrap())
.unwrap_err();
assert!(err.contains("locked down"));
let policy = WebPolicy {
allowlist_only: true,
allowed_urls: vec![glob::Pattern::new("https://example.com/*").unwrap()],
..WebPolicy::default()
};
assert!(
policy
.check_url(&reqwest::Url::parse("https://example.com/ok").unwrap())
.is_ok()
);
}
}