use serde_json::Value;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct Sandbox {
safe_dirs: Vec<PathBuf>,
enabled: bool,
}
impl Default for Sandbox {
fn default() -> Self {
Self::new()
}
}
impl Sandbox {
pub fn new() -> Self {
let cwd = std::env::current_dir()
.ok()
.and_then(|p| p.canonicalize().ok());
Self {
safe_dirs: cwd.into_iter().collect(),
enabled: true,
}
}
#[allow(dead_code)]
pub fn add_safe_dir(&mut self, dir: &Path) {
if let Ok(canonical) = dir.canonicalize()
&& !self.safe_dirs.contains(&canonical)
{
self.safe_dirs.push(canonical);
}
}
pub fn is_outside(&self, tool_name: &str, arguments: &str) -> bool {
if !self.enabled {
return false;
}
let paths = extract_paths(tool_name, arguments);
if paths.is_empty() {
return false;
}
for path_str in &paths {
if !self.is_path_safe(path_str) {
return true;
}
}
false
}
fn is_path_safe(&self, path_str: &str) -> bool {
let expanded = super::tools::expand_tilde(path_str);
let path = Path::new(&expanded);
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
match std::env::current_dir() {
Ok(cwd) => cwd.join(path),
Err(_) => return false,
}
};
let canonical = match absolute.canonicalize() {
Ok(p) => p,
Err(_) => {
if let Some(parent) = absolute.parent() {
match parent.canonicalize() {
Ok(p) => p.join(absolute.file_name().unwrap_or_default()),
Err(_) => {
normalize_path(&absolute)
}
}
} else {
return false;
}
}
};
for safe_dir in &self.safe_dirs {
if canonical.starts_with(safe_dir) {
return true;
}
}
false
}
pub fn outside_message(&self, tool_name: &str, arguments: &str) -> String {
let paths = extract_paths(tool_name, arguments);
let outside: Vec<String> = paths
.into_iter()
.filter(|p| !self.is_path_safe(p))
.collect();
format!(
"⚠️ {} 正在操作当前目录以外的文件: {}",
tool_name,
outside.join(", "),
)
}
}
fn extract_paths(tool_name: &str, arguments: &str) -> Vec<String> {
let parsed: Value = match serde_json::from_str(arguments) {
Ok(v) => v,
Err(_) => return Vec::new(),
};
let mut paths = Vec::new();
match tool_name {
"Read" | "Write" | "Edit" | "Glob" | "Grep" => {
if let Some(p) = parsed
.get("path")
.or_else(|| parsed.get("file_path"))
.and_then(|v| v.as_str())
{
paths.push(p.to_string());
}
}
"Bash" | "Shell" => {
if let Some(cwd) = parsed.get("cwd").and_then(|v| v.as_str()) {
paths.push(cwd.to_string());
}
}
_ => {}
}
paths
}
fn normalize_path(path: &Path) -> PathBuf {
let mut components = Vec::new();
for comp in path.components() {
match comp {
std::path::Component::ParentDir => {
components.pop();
}
std::path::Component::CurDir => {}
_ => components.push(comp),
}
}
components.iter().collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_path_in_sandbox() {
let sandbox = Sandbox::new(); let cwd = std::env::current_dir().unwrap();
let child = cwd.join("src/main.rs");
assert!(sandbox.is_path_safe(&child.display().to_string()));
assert!(sandbox.is_path_safe("src/main.rs"));
assert!(sandbox.is_path_safe("./src/main.rs"));
}
#[test]
fn test_path_outside_sandbox() {
let sandbox = Sandbox::new();
assert!(!sandbox.is_path_safe("/etc/passwd"));
assert!(!sandbox.is_path_safe("/tmp/something"));
}
#[test]
fn test_path_traversal_blocked() {
let sandbox = Sandbox::new();
assert!(!sandbox.is_path_safe("../../../etc/passwd"));
}
#[test]
fn test_extract_paths_read() {
let args = r#"{"path": "/Users/test/file.rs"}"#;
let paths = extract_paths("Read", args);
assert_eq!(paths, vec!["/Users/test/file.rs"]);
}
#[test]
fn test_extract_paths_bash_cwd() {
let args = r#"{"command": "ls", "cwd": "/tmp"}"#;
let paths = extract_paths("Bash", args);
assert_eq!(paths, vec!["/tmp"]);
}
#[test]
fn test_extract_paths_bash_no_cwd() {
let args = r#"{"command": "cargo build"}"#;
let paths = extract_paths("Bash", args);
assert!(paths.is_empty());
}
#[test]
fn test_is_outside() {
let sandbox = Sandbox::new();
let args = r#"{"path": "/etc/passwd"}"#;
assert!(sandbox.is_outside("Read", args));
let args2 = r#"{"path": "src/main.rs"}"#;
assert!(!sandbox.is_outside("Read", args2));
}
#[test]
fn test_add_safe_dir() {
let mut sandbox = Sandbox::new();
let tmp = PathBuf::from("/tmp");
sandbox.add_safe_dir(&tmp);
assert!(sandbox.is_path_safe("/tmp/test.txt"));
}
#[test]
fn test_normalize_path() {
let path = Path::new("/a/b/../c/./d");
let normalized = normalize_path(path);
assert_eq!(normalized, PathBuf::from("/a/c/d"));
}
}