use anyhow::Context;
use std::borrow::Cow;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
async fn canonicalize_parent_and_join(path: &Path) -> std::io::Result<PathBuf> {
let parent = path.parent().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "no parent directory")
})?;
let name = path
.file_name()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "no file name"))?;
let canon_parent = tokio::fs::canonicalize(parent).await?;
Ok(canon_parent.join(name))
}
pub(crate) async fn resolve_directory_read_fallback(full_path: &Path) -> Option<PathBuf> {
let meta = tokio::fs::symlink_metadata(full_path).await.ok()?;
if !meta.is_dir() {
return None;
}
let resolved = canonicalize_parent_and_join(full_path).await.ok()?;
if tokio::fs::symlink_metadata(&resolved)
.await
.is_ok_and(|m| m.is_dir())
{
return Some(resolved);
}
Some(full_path.to_path_buf())
}
pub(crate) async fn resolve_write_target(
workspace_root: &Path,
path: &str,
ensure_parent: bool,
) -> anyhow::Result<PathBuf> {
let full_path = resolve_tool_path_with_base(path, workspace_root);
if !is_path_safe_for_workspace(path, workspace_root) {
anyhow::bail!("Path not allowed by security policy: {path}");
}
let Some(parent) = full_path.parent() else {
anyhow::bail!("Invalid path: missing parent directory");
};
if ensure_parent {
tokio::fs::create_dir_all(parent)
.await
.context("Failed to create parent directories")?;
}
let resolved_target = canonicalize_parent_and_join(&full_path)
.await
.context("Failed to resolve file path")?;
let resolved_parent = resolved_target.parent().unwrap();
if !is_path_safe_for_workspace(&resolved_parent.to_string_lossy(), workspace_root) {
anyhow::bail!(
"Path not allowed by security policy: {}",
resolved_parent.display()
);
}
if let Ok(meta) = tokio::fs::symlink_metadata(&resolved_target).await
&& meta.file_type().is_symlink()
{
anyhow::bail!(
"Refusing to write through symlink: {}",
resolved_target.display()
);
}
Ok(resolved_target)
}
pub(crate) async fn resolve_read_target(
workspace_root: &Path,
path: &str,
) -> anyhow::Result<PathBuf> {
let full_path = resolve_tool_path_with_base(path, workspace_root);
check_path_read_allowed(path, workspace_root)?;
let resolved_path = match tokio::fs::canonicalize(&full_path).await {
Ok(resolved) => resolved,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
resolve_directory_read_fallback(&full_path)
.await
.ok_or_else(|| anyhow::anyhow!("File not found: {}", full_path.display()))?
}
Err(e) => {
return Err(match e.kind() {
std::io::ErrorKind::PermissionDenied => {
anyhow::anyhow!("Permission denied: {}", full_path.display())
}
_ => anyhow::anyhow!("Failed to resolve file path: {}: {e}", full_path.display()),
});
}
};
check_path_read_allowed(&resolved_path.to_string_lossy(), workspace_root)?;
Ok(resolved_path)
}
fn expand_tilde_for_path_check(path: &Path) -> Cow<'_, Path> {
if path.to_str().is_some_and(|s| s.starts_with('~')) {
Cow::Owned(crate::config::expand_tilde(&path.to_string_lossy()))
} else {
Cow::Borrowed(path)
}
}
fn is_path_under_roots(path: &Path, roots: &[PathBuf]) -> bool {
let check_path = expand_tilde_for_path_check(path);
roots.iter().any(|root| check_path.starts_with(root))
}
fn is_path_in_extra_allowed(path: &Path) -> bool {
is_path_under_roots(path, &EXTRA_READ_ALLOWED)
}
fn paths_same_or_canonical(a: &Path, b: &Path) -> bool {
if a == b {
return true;
}
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
(Ok(ca), Ok(cb)) => ca == cb,
_ => false,
}
}
fn is_os_temp_root(path: &Path) -> bool {
let check_path = expand_tilde_for_path_check(path);
if paths_same_or_canonical(&check_path, &std::env::temp_dir()) {
return true;
}
for var in ["TMPDIR", "TEMP", "TMP"] {
if let Ok(val) = std::env::var(var) {
let env_path = PathBuf::from(val);
if paths_same_or_canonical(&check_path, &env_path) {
return true;
}
}
}
#[cfg(unix)]
{
for prefix in ["/tmp", "/private/tmp", "/var/tmp"] {
if paths_same_or_canonical(&check_path, Path::new(prefix)) {
return true;
}
}
let lossy = check_path.to_string_lossy();
let parts: Vec<&str> = lossy.trim_start_matches('/').split('/').collect();
if parts.len() == 5 && parts[0] == "var" && parts[1] == "folders" && parts[4] == "T" {
return true;
}
}
false
}
fn is_mahbot_spill_filename(name: &str) -> bool {
if name.ends_with(".raw.log") {
return true;
}
let Some(hex) = name
.strip_prefix("spill_")
.and_then(|s| s.strip_suffix(".txt"))
else {
return false;
};
hex.len() == 4 && hex.chars().all(|c| c.is_ascii_hexdigit())
}
fn is_mahbot_spill_shaped(path: &Path) -> bool {
if !path.is_absolute() {
return false;
}
let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
return false;
};
if !is_mahbot_spill_filename(file_name) {
return false;
}
path.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
== Some(".agent")
}
fn is_mahbot_spill_file(path: &Path) -> bool {
if !is_mahbot_spill_shaped(path) {
return false;
}
path.parent()
.and_then(|p| p.parent())
.is_some_and(is_os_temp_root)
}
fn check_path_read_allowed(path: &str, workspace_root: &Path) -> anyhow::Result<()> {
let path_buf = Path::new(path);
if is_mahbot_spill_shaped(path_buf) && !is_mahbot_spill_file(path_buf) {
anyhow::bail!("Path not allowed by security policy: {path}");
}
if !is_path_safe_for_workspace(path, workspace_root)
&& !is_path_in_extra_allowed(path_buf)
&& !is_mahbot_spill_file(path_buf)
{
anyhow::bail!("Path not allowed by security policy: {path}");
}
Ok(())
}
fn add_path_with_canonical(dirs: &mut Vec<PathBuf>, raw: PathBuf) {
if dirs.contains(&raw) {
return;
}
match std::fs::canonicalize(&raw) {
Ok(canonical) => {
if !dirs.contains(&canonical) {
dirs.push(canonical.clone());
}
if canonical != raw {
dirs.push(raw);
}
}
Err(_) => {
dirs.push(raw);
}
}
}
const XDG_SUBDIR_TO_ENV: &[(&str, &str)] = &[
(".cache/", "XDG_CACHE_HOME"),
(".config/", "XDG_CONFIG_HOME"),
(".local/share/", "XDG_DATA_HOME"),
(".local/state/", "XDG_STATE_HOME"),
];
fn xdg_variant_path(tilde_path: &str) -> Option<String> {
for (xdg_subdir, env_var) in XDG_SUBDIR_TO_ENV {
if let Some(suffix) = tilde_path
.strip_prefix("~/")
.and_then(|p| p.strip_prefix(xdg_subdir))
&& let Ok(xdg_dir) = std::env::var(env_var)
{
let xdg_dir = xdg_dir.trim_end_matches('/');
return Some(format!("{xdg_dir}/{suffix}"));
}
}
None
}
const EXTRA_ALLOWED_RAW_PATHS: &[&str] = &[
"~/.cargo/registry/src/",
"~/.cargo/git/checkouts/",
"~/.local/lib/",
"~/Library/Python/",
"~/AppData/Roaming/Python/",
"~/AppData/Local/Programs/Python/",
"/usr/local/lib/",
"/usr/lib/",
"/Library/Frameworks/Python.framework/Versions/",
"/opt/homebrew/lib/",
"~/anaconda3/",
"~/miniconda3/",
"/opt/anaconda3/",
"/opt/miniconda3/",
"~/AppData/Local/conda/",
"~/.cache/pypoetry/",
"~/Library/Caches/pypoetry/",
"~/AppData/Local/pypoetry/",
"~/.local/share/virtualenvs/",
"~/.cache/pipenv/",
"~/Library/Caches/pipenv/",
"~/AppData/Local/pipenv/",
"~/.cache/uv/",
"~/.local/share/uv/",
"~/AppData/Local/uv/",
"~/.rye/",
"~/.bun/install/cache/",
"~/.local/share/pnpm/",
"~/Library/pnpm/",
"~/AppData/Local/pnpm/",
"~/AppData/Roaming/npm/",
"~/go/pkg/mod/",
"~/.gem/",
"~/.local/share/gem/",
"~/.bundle/",
"~/.composer/",
"~/.cache/composer/",
"~/Library/Caches/composer/",
"~/AppData/Local/composer/",
"~/.conan/",
"~/.conan2/",
"/usr/local/Cellar/",
"/opt/homebrew/Cellar/",
"/usr/local/Homebrew/Library/Taps/",
r"C:\ProgramData\chocolatey\lib\",
r"C:\msys64\mingw64\include\",
r"C:\msys64\ucrt64\include\",
r"C:\msys64\clang64\include\",
r"C:\msys64\usr\include\",
r"C:\Program Files (x86)\Windows Kits\",
r"C:\Program Files\Microsoft Visual Studio\",
"~/.swiftpm/",
"~/Library/Developer/Xcode/DerivedData/",
"~/.pub-cache/",
"~/.hex/",
"~/.cabal/",
"~/.local/state/cabal/",
"~/.stack/",
"~/AppData/Local/stack/",
"~/AppData/Roaming/stack/",
"~/.luarocks/",
"~/.cache/luarocks/",
"~/Library/Caches/luarocks/",
"~/AppData/Local/luarocks/",
"~/Library/R/",
"~/R/",
"~/Documents/R/",
"~/.opam/",
"~/.julia/",
"/nix/store/",
"/opt/local/",
"~/.local/pipx/",
];
static APPROVED_TEMP_ROOTS: LazyLock<Vec<PathBuf>> = LazyLock::new(|| {
let mut dirs = Vec::new();
add_path_with_canonical(&mut dirs, std::env::temp_dir());
add_path_with_canonical(&mut dirs, PathBuf::from("/tmp"));
add_path_with_canonical(&mut dirs, PathBuf::from("/private/tmp"));
add_path_with_canonical(&mut dirs, PathBuf::from("/var/tmp"));
add_path_with_canonical(&mut dirs, std::env::temp_dir().join(".agent"));
dirs
});
#[must_use]
pub(crate) fn is_path_under_allowed_temp(path: &Path) -> bool {
is_path_under_roots(path, &APPROVED_TEMP_ROOTS)
}
static EXTRA_READ_ALLOWED: LazyLock<Vec<PathBuf>> = LazyLock::new(|| {
let mut dirs = APPROVED_TEMP_ROOTS.clone();
for raw_path in EXTRA_ALLOWED_RAW_PATHS {
if raw_path.starts_with('~') {
let expanded = crate::config::expand_tilde(raw_path);
if expanded.to_string_lossy().starts_with('~') {
continue;
}
add_path_with_canonical(&mut dirs, expanded);
if let Some(xdg_path) = xdg_variant_path(raw_path) {
add_path_with_canonical(&mut dirs, PathBuf::from(xdg_path));
}
} else {
add_path_with_canonical(&mut dirs, PathBuf::from(raw_path));
}
}
dirs
});
#[must_use]
pub(crate) fn is_path_safe_for_workspace(path: &str, workspace_root: &Path) -> bool {
let path = path.trim();
if path.is_empty() {
return true; }
if path == "~" {
return true;
}
if path.contains('\0') {
return false;
}
if Path::new(path)
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return false;
}
let lower = path.to_lowercase();
if lower.contains("..%2f") || lower.contains("%2f..") {
return false;
}
if path.starts_with('~') && path != "~" && !path.starts_with("~/") {
return false;
}
let expanded_path = crate::config::expand_tilde(path);
if expanded_path.is_absolute() {
expanded_path.starts_with(workspace_root)
} else {
true
}
}
#[must_use]
pub(crate) fn resolve_tool_path_with_base(path: &str, workspace_root: &Path) -> PathBuf {
let trimmed = path.trim();
if trimmed.is_empty() || trimmed == "~" {
return workspace_root.to_path_buf();
}
let expanded = crate::config::expand_tilde(trimmed);
if expanded.is_absolute() {
return expanded;
}
workspace_root.join(expanded)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn path_traversal_edge_cases() {
let base = Path::new(".");
assert!(!is_path_safe_for_workspace("../etc/passwd", base));
assert!(!is_path_safe_for_workspace("foo/../etc/passwd", base));
assert!(is_path_safe_for_workspace("my..file.txt", base));
assert!(!is_path_safe_for_workspace(
"foo/..%2f..%2fetc/passwd",
base
));
}
#[test]
fn path_blocked_system_and_sensitive() {
let base = Path::new(".");
assert!(!is_path_safe_for_workspace("file\0.txt", base));
assert!(!is_path_safe_for_workspace(
"/proc/self/root/etc/passwd",
base
));
assert!(!is_path_safe_for_workspace("/var/run/docker.sock", base));
assert!(!is_path_safe_for_workspace("~/.ssh/id_rsa", base));
assert!(!is_path_safe_for_workspace("~/.gnupg/secring.gpg", base));
assert!(!is_path_safe_for_workspace("~root/.ssh/id_rsa", base));
assert!(!is_path_safe_for_workspace("~nobody", base));
}
#[test]
fn path_blocks_absolute_and_whitespace_bypass() {
let base = Path::new(".");
assert!(!is_path_safe_for_workspace("/", base));
assert!(!is_path_safe_for_workspace("/anything", base));
assert!(!is_path_safe_for_workspace("/tmp", base));
assert!(!is_path_safe_for_workspace("/var/log", base));
assert!(!is_path_safe_for_workspace(" /etc/passwd", base));
assert!(!is_path_safe_for_workspace("\t/etc/passwd", base));
assert!(!is_path_safe_for_workspace(" ~root/.ssh/id_rsa", base));
assert!(!is_path_safe_for_workspace(" ../foo", base));
assert!(is_path_safe_for_workspace(" ", base));
let tmp = TempDir::new().expect("tempdir");
let workspace = tmp.path().to_path_buf();
assert!(is_path_safe_for_workspace(
workspace.join("test.txt").to_str().unwrap(),
&workspace
));
assert!(is_path_safe_for_workspace("relative.txt", &workspace));
}
#[test]
fn bare_tilde_accepted() {
let base = Path::new(".");
assert!(is_path_safe_for_workspace("~", base));
assert!(is_path_safe_for_workspace(" ~", base));
assert!(is_path_safe_for_workspace("~ ", base));
assert!(is_path_safe_for_workspace(" ~ ", base));
let tmp = TempDir::new().expect("tempdir");
let workspace = tmp.path().to_path_buf();
assert!(is_path_safe_for_workspace("~", &workspace));
}
#[test]
fn path_allows_relative_and_blocks_absolute() {
let tmp = TempDir::new().expect("tempdir");
let workspace = tmp.path().to_path_buf();
assert!(is_path_safe_for_workspace("src/main.rs", &workspace));
assert!(is_path_safe_for_workspace(
"deep/nested/dir/file.txt",
&workspace
));
assert!(is_path_safe_for_workspace(".gitignore", &workspace));
assert!(is_path_safe_for_workspace(".env", &workspace));
assert!(is_path_safe_for_workspace("", &workspace));
assert!(!is_path_safe_for_workspace("../etc/passwd", &workspace));
assert!(!is_path_safe_for_workspace(
"../../root/.ssh/id_rsa",
&workspace
));
assert!(!is_path_safe_for_workspace(
"foo/../../../etc/shadow",
&workspace
));
assert!(!is_path_safe_for_workspace("..", &workspace));
}
#[test]
fn is_path_under_allowed_temp_covers_common_roots() {
let temp = std::env::temp_dir();
let spill = temp.join(".agent/spill_test.txt");
assert!(is_path_under_allowed_temp(&temp.join("scratch.txt")));
assert!(is_path_under_allowed_temp(&spill));
assert!(is_path_under_allowed_temp(Path::new("/tmp/out.txt")));
assert!(is_path_under_allowed_temp(Path::new("/var/tmp/out.txt")));
assert!(!is_path_under_allowed_temp(Path::new("relative.txt")));
assert!(!is_path_under_allowed_temp(Path::new("/etc/passwd")));
}
#[test]
fn check_path_read_allowed_var_tmp() {
let tmp = TempDir::new().expect("tempdir");
let workspace = tmp.path().to_path_buf();
assert!(
check_path_read_allowed("/var/tmp/mahbot-test.txt", &workspace).is_ok(),
"/var/tmp should be readable via EXTRA_READ_ALLOWED"
);
}
#[test]
fn is_mahbot_spill_file_allows_temp_agent_spills() {
let tmp = TempDir::new().expect("tempdir");
let workspace = tmp.path().to_path_buf();
let spill = std::env::temp_dir().join(".agent/spill_ab12.txt");
assert!(
check_path_read_allowed(&spill.to_string_lossy(), &workspace).is_ok(),
"spill under current temp_dir should be allowed: {}",
spill.display()
);
#[cfg(unix)]
{
let mac_spill = PathBuf::from("/var/folders/xx/yy/T/.agent/spill_cd34.txt");
assert!(
check_path_read_allowed(&mac_spill.to_string_lossy(), &workspace).is_ok(),
"macOS-shaped spill path should be allowed"
);
}
let raw_log = std::env::temp_dir().join(".agent/12345_cargo_check.raw.log");
assert!(
check_path_read_allowed(&raw_log.to_string_lossy(), &workspace).is_ok(),
"raw.log spill should be allowed"
);
}
#[test]
fn is_mahbot_spill_file_rejects_workspace_and_system_paths() {
let tmp = TempDir::new().expect("tempdir");
let workspace = tmp.path().to_path_buf();
let in_workspace = workspace.join(".agent/spill_ab12.txt");
assert!(
check_path_read_allowed(&in_workspace.to_string_lossy(), &workspace).is_err(),
"workspace .agent spill should be rejected: {}",
in_workspace.display()
);
#[cfg(unix)]
{
let outside = PathBuf::from("/usr/local/.agent/spill_ab12.txt");
assert!(
check_path_read_allowed(&outside.to_string_lossy(), &workspace).is_err(),
"non-temp spill-shaped path should be rejected"
);
}
assert!(
check_path_read_allowed("/etc/passwd", &workspace).is_err(),
"/etc/passwd should be rejected"
);
}
#[test]
fn extra_allowed_init_does_not_panic() {
let dirs = &*EXTRA_READ_ALLOWED;
assert!(!dirs.is_empty(), "EXTRA_READ_ALLOWED should not be empty");
}
#[test]
fn extra_allowed_no_literal_tilde() {
let dirs = &*EXTRA_READ_ALLOWED;
for dir in dirs {
let s = dir.to_string_lossy();
assert!(
!s.starts_with('~'),
"Literal tilde path should never be stored: {s}"
);
}
}
#[test]
fn extra_allowed_tilde_input_matches() {
if let Ok(home) = std::env::var("HOME") {
let tilde_input = "~/.cargo/registry/src/some-crate/src/lib.rs";
assert!(
is_path_in_extra_allowed(Path::new(tilde_input)),
"~-prefixed path should match expanded allowlist entry"
);
let expanded = PathBuf::from(&home).join(".cargo/registry/src/some-crate/src/lib.rs");
assert!(
is_path_in_extra_allowed(&expanded),
"Expanded path should match allowlist entry"
);
}
}
#[test]
fn extra_allowed_prefix_matching() {
assert!(
is_path_in_extra_allowed(Path::new(
"/usr/local/lib/python3.12/site-packages/requests/models.py"
)),
"File under /usr/local/lib/ should match"
);
#[cfg(unix)]
{
assert!(
!is_path_in_extra_allowed(Path::new("/usr/local/lib_evil/foo")),
"Sibling of allowed root should not match"
);
assert!(
!is_path_in_extra_allowed(Path::new("/usr/local/lib64/foo")),
"Numeric suffix should not match"
);
}
}
#[test]
fn check_path_read_allowed_extra_dependency_paths() {
let tmp = TempDir::new().expect("tempdir");
let workspace = tmp.path().to_path_buf();
let temp_file = std::env::temp_dir().join("test-read.txt");
let temp_str = temp_file.to_string_lossy().to_string();
assert!(
check_path_read_allowed(&temp_str, &workspace).is_ok(),
"Temp file should be allowed for read"
);
if let Ok(home) = std::env::var("HOME") {
let dep_path = format!("{home}/.cargo/registry/src/crate-0.1.0/src/lib.rs");
assert!(
check_path_read_allowed(&dep_path, &workspace).is_ok(),
"Dependency path should be allowed for read"
);
let tilde_input = "~/.cargo/registry/src/crate-0.1.0/src/lib.rs";
assert!(
check_path_read_allowed(tilde_input, &workspace).is_ok(),
"~-prefixed dependency path should be allowed for read"
);
}
}
#[test]
fn is_path_safe_for_workspace_blocks_extra_dependency_paths() {
let tmp = TempDir::new().expect("tempdir");
let workspace = tmp.path().to_path_buf();
let temp_file = std::env::temp_dir().join("test-spill.txt");
let temp_str = temp_file.to_string_lossy().to_string();
assert!(
!is_path_safe_for_workspace(&temp_str, &workspace),
"Temp file should be blocked by base check"
);
if let Ok(home) = std::env::var("HOME") {
let dep_path = format!("{home}/.cargo/registry/src/crate-0.1.0/src/lib.rs");
assert!(
!is_path_safe_for_workspace(&dep_path, &workspace),
"Dependency path should be blocked by base check"
);
}
}
async fn test_workspace() -> (TempDir, PathBuf) {
let tmp = TempDir::new().expect("tempdir");
let ws_raw = tmp.path().join("ws");
tokio::fs::create_dir(&ws_raw).await.unwrap();
let ws = tokio::fs::canonicalize(&ws_raw).await.unwrap();
(tmp, ws)
}
#[tokio::test]
async fn resolve_read_target_file_exists() {
let (_tmp, ws) = test_workspace().await;
let file_path = ws.join("existing.txt");
tokio::fs::write(&file_path, "hello").await.unwrap();
let result = resolve_read_target(&ws, "existing.txt").await;
assert!(
result.is_ok(),
"Should resolve existing file: {:?}",
result.err()
);
let resolved = result.unwrap();
let canonical = tokio::fs::canonicalize(&file_path).await.unwrap();
assert_eq!(resolved, canonical, "should resolve to the canonical path");
}
#[tokio::test]
async fn resolve_read_target_existing_subdirectory_without_trailing_slash() {
let (_tmp, ws) = test_workspace().await;
let sub = ws.join("nested");
tokio::fs::create_dir_all(&sub).await.unwrap();
tokio::fs::write(sub.join("leaf.txt"), "hello")
.await
.unwrap();
let result = resolve_read_target(&ws, "nested").await;
assert!(
result.is_ok(),
"Should resolve existing directory without trailing slash: {:?}",
result.err()
);
let resolved = result.unwrap();
let canonical = tokio::fs::canonicalize(&sub).await.unwrap();
assert_eq!(resolved, canonical);
}
#[tokio::test]
async fn resolve_read_target_file_not_found() {
let (_tmp, ws) = test_workspace().await;
let result = resolve_read_target(&ws, "nonexistent.txt").await;
let err = result.unwrap_err();
assert!(
err.to_string().contains("File not found"),
"Should report File not found: {err}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn resolve_read_target_permission_denied() {
use std::os::unix::fs::PermissionsExt;
let (_tmp, ws) = test_workspace().await;
let restricted_dir = ws.join("secret");
tokio::fs::create_dir(&restricted_dir).await.unwrap();
let file_path = restricted_dir.join("file.txt");
tokio::fs::write(&file_path, "secret").await.unwrap();
std::fs::set_permissions(&restricted_dir, std::fs::Permissions::from_mode(0o000)).unwrap();
let result = resolve_read_target(&ws, "secret/file.txt").await;
let _ = std::fs::set_permissions(&restricted_dir, std::fs::Permissions::from_mode(0o755));
assert!(result.is_err(), "Should fail with Permission denied");
let err = result.unwrap_err();
assert!(
err.to_string().contains("Permission denied"),
"Should mention Permission denied: {err}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn resolve_read_target_symlink_resolution() {
let (_tmp, ws) = test_workspace().await;
let secret = ws.join("secret.txt");
tokio::fs::write(&secret, "content").await.unwrap();
let link = ws.join("link.txt");
std::os::unix::fs::symlink(&secret, &link).unwrap();
let result = resolve_read_target(&ws, "link.txt").await;
assert!(result.is_ok(), "Should resolve symlink: {:?}", result.err());
let resolved = result.unwrap();
let canonical = tokio::fs::canonicalize(&link).await.unwrap();
assert_eq!(resolved, canonical, "should resolve to the canonical path");
}
#[tokio::test]
async fn resolve_read_target_extra_allowed_path() {
let tmp = TempDir::new().expect("tempdir");
let ws = tmp.path().join("ws");
tokio::fs::create_dir(&ws).await.unwrap();
let spill_dir = std::env::temp_dir().join(".agent");
tokio::fs::create_dir_all(&spill_dir).await.unwrap();
let spill_file = spill_dir.join("spill_ab12.txt");
tokio::fs::write(&spill_file, "spill content")
.await
.unwrap();
let spill_str = spill_file.to_string_lossy().to_string();
let result = resolve_read_target(&ws, &spill_str).await;
let _ = tokio::fs::remove_file(&spill_file).await;
assert!(
result.is_ok(),
"Should allow extra read paths (e.g. /tmp): {:?}",
result.err()
);
}
#[tokio::test]
async fn resolve_read_target_non_canonicalized_workspace_root() {
let tmp = TempDir::new().expect("tempdir");
let ws_dir = tmp.path().join("ws");
tokio::fs::create_dir(&ws_dir).await.unwrap();
let file_path = ws_dir.join("hello.txt");
tokio::fs::write(&file_path, "world").await.unwrap();
let result = resolve_read_target(&ws_dir, "hello.txt").await;
assert!(
result.is_ok(),
"Read should succeed even with non-canonicalized root: {:?}",
result.err()
);
let resolved = result.unwrap();
let content = tokio::fs::read_to_string(&resolved).await.unwrap();
assert_eq!(content, "world", "should read the correct file content");
}
#[tokio::test]
async fn resolve_write_target_new_file_in_existing_dir() {
let (_tmp, ws) = test_workspace().await;
let subdir = ws.join("subdir");
tokio::fs::create_dir(&subdir).await.unwrap();
let result = resolve_write_target(&ws, "subdir/new_file.rs", false).await;
assert!(
result.is_ok(),
"Should resolve new file in existing dir: {:?}",
result.err()
);
let resolved = result.unwrap();
assert!(resolved.starts_with(&ws), "Path should be within workspace");
assert_eq!(resolved.file_name().unwrap(), "new_file.rs");
assert!(!resolved.exists(), "File should not exist yet");
}
#[tokio::test]
async fn resolve_write_target_new_file_new_dir_with_ensure_parent() {
let (_tmp, ws) = test_workspace().await;
let result = resolve_write_target(&ws, "a/b/c/new_file.rs", true).await;
assert!(
result.is_ok(),
"Should create parent directories: {:?}",
result.err()
);
let resolved = result.unwrap();
assert!(resolved.starts_with(&ws), "Path should be within workspace");
assert_eq!(resolved.file_name().unwrap(), "new_file.rs");
assert!(
ws.join("a/b/c").exists(),
"Parent directories should be created"
);
assert!(!resolved.exists(), "File should not exist yet");
}
#[tokio::test]
async fn resolve_write_target_new_file_new_dir_no_ensure_parent() {
let (_tmp, ws) = test_workspace().await;
let result = resolve_write_target(&ws, "nonexistent_dir/new_file.rs", false).await;
assert!(result.is_err(), "Should fail when parent doesn't exist");
let err = result.unwrap_err();
assert!(
err.to_string().contains("Failed to resolve file path")
|| err.to_string().contains("No such file or directory"),
"Should mention resolution failure: {err}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn resolve_write_target_symlink_refusal() {
let (_tmp, ws) = test_workspace().await;
let link = ws.join("malicious_link.txt");
std::os::unix::fs::symlink("/etc/passwd", &link).unwrap();
let result = resolve_write_target(&ws, "malicious_link.txt", false).await;
assert!(result.is_err(), "Should refuse to write through symlink");
let err = result.unwrap_err();
assert!(
err.to_string().contains("symlink"),
"Error should mention symlink: {err}"
);
}
#[tokio::test]
async fn resolve_write_target_outside_workspace_rejected() {
let (_tmp, ws) = test_workspace().await;
let outside = PathBuf::from("/tmp/outside_write_test.txt");
let result = resolve_write_target(&ws, &outside.to_string_lossy(), false).await;
assert!(result.is_err(), "Should reject write outside workspace");
let err = result.unwrap_err();
assert!(
err.to_string().contains("Path not allowed"),
"Error should mention security policy: {err}"
);
}
}