const MAX_FILE_SIZE: u64 = 50 * 1024 * 1024;
fn is_sensitive_path(canonical: &std::path::Path) -> bool {
if let Some(home) = dirs::home_dir() {
let ssh_dir = home.join(".ssh");
let gnupg_dir = home.join(".gnupg");
let aws_dir = home.join(".aws");
let docker_dir = home.join(".docker");
let netrc_file = home.join(".netrc");
let gh_config_dir = home.join(".config").join("gh");
let gcloud_config_dir = home.join(".config").join("gcloud");
if canonical.starts_with(&ssh_dir)
|| canonical.starts_with(&gnupg_dir)
|| canonical.starts_with(&aws_dir)
|| canonical.starts_with(&docker_dir)
|| canonical == netrc_file
|| canonical.starts_with(&gh_config_dir)
|| canonical.starts_with(&gcloud_config_dir)
{
return true;
}
}
if canonical.starts_with("/etc/") || canonical == std::path::Path::new("/etc") {
return true;
}
false
}
fn check_path_allowed(path: &str) -> Result<std::path::PathBuf, String> {
let p = std::path::Path::new(path);
let canonical = if p.exists() {
std::fs::canonicalize(p).map_err(|e| format!("Cannot resolve path: {e}"))?
} else {
let parent = p
.parent()
.ok_or_else(|| "Path has no parent directory".to_string())?;
let canonical_parent =
std::fs::canonicalize(parent).map_err(|e| format!("Cannot resolve parent: {e}"))?;
let file_name = p
.file_name()
.ok_or_else(|| "Path has no file name".to_string())?;
canonical_parent.join(file_name)
};
if is_sensitive_path(&canonical) {
return Err(format!(
"Access denied: '{}' is in a restricted directory. \
ACP agents cannot read or list ~/.ssh/, ~/.gnupg/, ~/.aws/, ~/.docker/, \
~/.netrc, ~/.config/gh/, ~/.config/gcloud/, or /etc/.",
path
));
}
Ok(canonical)
}
pub fn read_file_with_range(
path: &str,
line: Option<u64>,
limit: Option<u64>,
) -> Result<String, String> {
check_path_allowed(path)?;
let metadata = std::fs::metadata(path).map_err(|e| e.to_string())?;
if metadata.len() > MAX_FILE_SIZE {
return Err(format!(
"File too large: {} bytes (max {} bytes)",
metadata.len(),
MAX_FILE_SIZE
));
}
let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
match (line, limit) {
(None, None) => Ok(content),
_ => {
let skip = line.unwrap_or(1).saturating_sub(1) as usize;
let lines: Vec<&str> = content.lines().skip(skip).collect();
let taken: Vec<&str> = if let Some(lim) = limit {
lines.into_iter().take(lim as usize).collect()
} else {
lines
};
Ok(taken.join("\n"))
}
}
}
pub fn write_file_safe(path: &str, content: &str) -> Result<(), String> {
let p = std::path::Path::new(path);
if !p.is_absolute() {
return Err("Path must be absolute".to_string());
}
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directories: {e}"))?;
}
std::fs::write(p, content).map_err(|e| format!("Failed to write file: {e}"))
}
pub fn list_directory_entries(
path: &str,
pattern: Option<&str>,
) -> Result<Vec<serde_json::Value>, String> {
let dir = std::path::Path::new(path);
if !dir.is_absolute() {
return Err("Path must be absolute".to_string());
}
check_path_allowed(path)?;
let entries = std::fs::read_dir(dir).map_err(|e| format!("Failed to read directory: {e}"))?;
let mut result: Vec<serde_json::Value> = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
let name = entry.file_name().to_string_lossy().to_string();
if let Some(pat) = pattern
&& !glob_match_simple(pat, &name)
{
continue;
}
let file_type = entry.file_type().map_err(|e| e.to_string())?;
result.push(serde_json::json!({
"name": name,
"path": entry.path().to_string_lossy(),
"isDirectory": file_type.is_dir(),
"isFile": file_type.is_file(),
}));
}
result.sort_by(|a, b| {
let a_name = a.get("name").and_then(|v| v.as_str()).unwrap_or("");
let b_name = b.get("name").and_then(|v| v.as_str()).unwrap_or("");
a_name.cmp(b_name)
});
Ok(result)
}
const MAX_SEARCH_DEPTH: usize = 20;
pub fn find_files_recursive(base_path: &str, pattern: &str) -> Result<Vec<String>, String> {
let base = std::path::Path::new(base_path);
if !base.is_absolute() {
return Err("Path must be absolute".to_string());
}
if !base.exists() {
return Err(format!("Path does not exist: {base_path}"));
}
check_path_allowed(base_path)?;
let mut results = Vec::new();
let file_pattern = pattern.strip_prefix("**/").unwrap_or(pattern);
fn walk_dir(
dir: &std::path::Path,
file_pattern: &str,
results: &mut Vec<String>,
remaining_depth: usize,
) -> Result<(), String> {
if remaining_depth == 0 {
return Ok(());
}
let entries =
std::fs::read_dir(dir).map_err(|e| format!("Failed to read {}: {e}", dir.display()))?;
for entry in entries {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
let file_type = entry.file_type().map_err(|e| e.to_string())?;
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
walk_dir(&path, file_pattern, results, remaining_depth - 1)?;
} else if file_type.is_file() {
let name = entry.file_name().to_string_lossy().to_string();
if glob_match_simple(file_pattern, &name) {
results.push(path.to_string_lossy().to_string());
}
}
}
Ok(())
}
walk_dir(base, file_pattern, &mut results, MAX_SEARCH_DEPTH)?;
results.sort();
Ok(results)
}
pub fn glob_match_simple(pattern: &str, name: &str) -> bool {
if pattern == "*" {
return true;
}
if let Some(ext) = pattern.strip_prefix("*.") {
return name.ends_with(&format!(".{ext}"));
}
if let Some(prefix) = pattern.strip_suffix("*") {
return name.starts_with(prefix);
}
name == pattern
}