use anyhow::Context;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
#[must_use]
pub(crate) fn contains_glob(s: &str, include_close_bracket: bool) -> bool {
s.contains(['*', '?', '[']) || (include_close_bracket && s.contains(']'))
}
pub(crate) fn shell_quote(s: &str) -> String {
let escaped = s.replace('\'', "'\\''");
format!("'{escaped}'")
}
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 Some(resolved_parent) = resolved_target.parent() else {
anyhow::bail!("Invalid canonicalized path: missing parent directory");
};
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)
}
#[must_use]
pub(crate) fn normalize_path(path: &Path) -> PathBuf {
use std::path::Component;
let mut normalized: Vec<Component<'_>> = Vec::new();
let mut is_absolute = false;
for component in path.components() {
match component {
Component::RootDir => {
is_absolute = true;
normalized.push(component);
}
Component::Prefix(prefix) => {
is_absolute = true;
normalized.push(Component::Prefix(prefix));
}
Component::CurDir => {
}
Component::Normal(_) => {
normalized.push(component);
}
Component::ParentDir => {
if let Some(last) = normalized.last() {
if matches!(last, Component::Normal(_)) {
normalized.pop();
} else {
if !is_absolute {
normalized.push(component);
}
}
} else {
if !is_absolute {
normalized.push(component);
}
}
}
}
}
let mut result = PathBuf::new();
for component in &normalized {
result.push(component.as_os_str());
}
result
}
pub(crate) fn is_path_under_roots(path: &Path, roots: &[PathBuf]) -> bool {
let expanded = crate::util::expand_tilde(&path.to_string_lossy());
let normalized = normalize_path(&expanded);
roots.iter().any(|root| normalized.starts_with(root))
}
#[must_use]
pub(crate) fn allowed_temp_roots() -> Vec<PathBuf> {
ALLOWED_TEMP_ROOTS.clone()
}
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 = crate::util::expand_tilde(&path.to_string_lossy());
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
}
pub(crate) fn format_spill_filename() -> String {
format!("spill_{:04x}.txt", rand::random::<u16>())
}
fn is_mahbot_spill_filename(name: &str) -> bool {
if name.ends_with(".full.log") {
return true;
}
name.strip_prefix("spill_")
.and_then(|s| s.strip_suffix(".txt"))
.is_some_and(|hex| 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_grandparent_temp_root(path: &Path) -> bool {
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) {
if !is_grandparent_temp_root(path_buf) {
anyhow::bail!("Path not allowed by security policy: {path}");
}
return Ok(());
}
if !is_path_safe_for_workspace(path, workspace_root) && !is_path_in_extra_allowed(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 ALLOWED_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"));
if let Some(legacy) = crate::temp_root::legacy_temp_dir() {
add_path_with_canonical(&mut dirs, legacy.to_path_buf());
add_path_with_canonical(&mut dirs, legacy.join(".agent"));
}
dirs
});
static EXTRA_READ_ALLOWED: LazyLock<Vec<PathBuf>> = LazyLock::new(|| {
let mut dirs = ALLOWED_TEMP_ROOTS.clone();
for raw_path in EXTRA_ALLOWED_RAW_PATHS {
if raw_path.starts_with('~') {
let expanded = crate::util::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]
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;
}
if path.starts_with('~') && !path.starts_with("~/") {
return false;
}
let expanded_path = crate::util::expand_tilde(path);
if expanded_path.is_absolute() {
expanded_path.starts_with(workspace_root)
} else {
true
}
}
#[must_use]
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::util::expand_tilde(trimmed);
if expanded.is_absolute() {
return expanded;
}
workspace_root.join(expanded)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[expect(clippy::too_many_lines)]
#[test]
fn is_path_safe_for_workspace_all_cases() {
struct Case {
name: &'static str,
path: &'static str,
safe: bool,
}
let dot_cases = [
Case {
name: "traversal_etc_passwd",
path: "../etc/passwd",
safe: false,
},
Case {
name: "traversal_via_foo",
path: "foo/../etc/passwd",
safe: false,
},
Case {
name: "my_dot_dot_file",
path: "my..file.txt",
safe: true,
},
Case {
name: "null_byte",
path: "file\0.txt",
safe: false,
},
Case {
name: "proc_self_root",
path: "/proc/self/root/etc/passwd",
safe: false,
},
Case {
name: "docker_socket",
path: "/var/run/docker.sock",
safe: false,
},
Case {
name: "ssh_private_key",
path: "~/.ssh/id_rsa",
safe: false,
},
Case {
name: "gnupg_secret",
path: "~/.gnupg/secring.gpg",
safe: false,
},
Case {
name: "tilde_user_ssh",
path: "~root/.ssh/id_rsa",
safe: false,
},
Case {
name: "tilde_nobody",
path: "~nobody",
safe: false,
},
Case {
name: "root_slash",
path: "/",
safe: false,
},
Case {
name: "anything_absolute",
path: "/anything",
safe: false,
},
Case {
name: "absolute_tmp",
path: "/tmp",
safe: false,
},
Case {
name: "absolute_var_log",
path: "/var/log",
safe: false,
},
Case {
name: "whitespace_absolute",
path: " /etc/passwd",
safe: false,
},
Case {
name: "tab_absolute",
path: "\t/etc/passwd",
safe: false,
},
Case {
name: "whitespace_tilde_user",
path: " ~root/.ssh/id_rsa",
safe: false,
},
Case {
name: "whitespace_traversal",
path: " ../foo",
safe: false,
},
Case {
name: "whitespace_only_empty",
path: " ",
safe: true,
},
Case {
name: "bare_tilde",
path: "~",
safe: true,
},
Case {
name: "leading_whitespace_tilde",
path: " ~",
safe: true,
},
Case {
name: "trailing_whitespace_tilde",
path: "~ ",
safe: true,
},
Case {
name: "both_whitespace_tilde",
path: " ~ ",
safe: true,
},
];
let base = Path::new(".");
for case in &dot_cases {
assert_eq!(
is_path_safe_for_workspace(case.path, base),
case.safe,
"case: {}",
case.name
);
}
let tmp = TempDir::new().expect("tempdir");
let ws = tmp.path().to_path_buf();
assert!(
is_path_safe_for_workspace(ws.join("test.txt").to_str().unwrap(), &ws),
"absolute path inside workspace should be safe"
);
let ws_cases = [
Case {
name: "relative_in_workspace",
path: "relative.txt",
safe: true,
},
Case {
name: "relative_src_main",
path: "src/main.rs",
safe: true,
},
Case {
name: "relative_deep_nested",
path: "deep/nested/dir/file.txt",
safe: true,
},
Case {
name: "dot_gitignore",
path: ".gitignore",
safe: true,
},
Case {
name: "dot_env",
path: ".env",
safe: true,
},
Case {
name: "empty_string",
path: "",
safe: true,
},
Case {
name: "ws_traversal_etc_passwd",
path: "../etc/passwd",
safe: false,
},
Case {
name: "double_traversal_ssh",
path: "../../root/.ssh/id_rsa",
safe: false,
},
Case {
name: "triple_traversal_shadow",
path: "foo/../../../etc/shadow",
safe: false,
},
Case {
name: "dot_dot_alone",
path: "..",
safe: false,
},
Case {
name: "bare_tilde_in_workspace",
path: "~",
safe: true,
},
];
for case in &ws_cases {
assert_eq!(
is_path_safe_for_workspace(case.path, &ws),
case.safe,
"case: {}",
case.name
);
}
{
let temp_file = std::env::temp_dir().join("test-spill.txt");
assert!(
!is_path_safe_for_workspace(temp_file.to_str().unwrap(), &ws),
"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, &ws),
"Dependency path should be blocked by base check"
);
}
}
#[test]
fn is_path_under_allowed_temp_covers_common_roots() {
let roots = allowed_temp_roots();
let temp = std::env::temp_dir();
let spill = temp.join(".agent/spill_test.txt");
assert!(is_path_under_roots(&temp.join("scratch.txt"), &roots));
assert!(is_path_under_roots(&spill, &roots));
assert!(is_path_under_roots(Path::new("/tmp/out.txt"), &roots));
assert!(is_path_under_roots(Path::new("/var/tmp/out.txt"), &roots));
assert!(!is_path_under_roots(Path::new("relative.txt"), &roots));
assert!(!is_path_under_roots(Path::new("/etc/passwd"), &roots));
assert!(
!is_path_under_roots(Path::new("/tmp/../etc/passwd"), &roots),
"Path traversal via /tmp/../etc/passwd must be blocked"
);
assert!(
!is_path_under_roots(Path::new("/tmp/../../../etc/passwd"), &roots),
"Deep path traversal must be blocked"
);
assert!(
is_path_under_roots(Path::new("/tmp/../tmp/file.txt"), &roots),
"Traversal back into temp should be allowed"
);
}
#[test]
fn check_path_read_allowed_all_cases() {
struct Case {
name: &'static str,
path: String,
allowed: bool,
}
let tmp = TempDir::new().expect("tempdir");
let workspace = tmp.path().to_path_buf();
let mut cases: Vec<Case> = Vec::new();
let spill = std::env::temp_dir().join(".agent/spill_ab12.txt");
cases.push(Case {
name: "temp_agent_spill",
path: spill.to_string_lossy().to_string(),
allowed: true,
});
let full_log = std::env::temp_dir().join(".agent/12345_cargo_check.full.log");
cases.push(Case {
name: "full_log_spill",
path: full_log.to_string_lossy().to_string(),
allowed: true,
});
let in_workspace = workspace.join(".agent/spill_ab12.txt");
cases.push(Case {
name: "workspace_spill_rejected",
path: in_workspace.to_string_lossy().to_string(),
allowed: false,
});
cases.push(Case {
name: "etc_passwd_rejected",
path: "/etc/passwd".to_string(),
allowed: false,
});
cases.push(Case {
name: "var_tmp_allowed",
path: "/var/tmp/mahbot-test.txt".to_string(),
allowed: true,
});
for case in &cases {
let result = check_path_read_allowed(&case.path, &workspace);
assert_eq!(
result.is_ok(),
case.allowed,
"case: {} — path: {}",
case.name,
case.path
);
}
#[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"
);
}
#[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"
);
}
}
#[test]
fn extra_allowed_all_cases() {
struct Case {
name: &'static str,
path: &'static str,
allowed: bool,
}
assert!(
!EXTRA_READ_ALLOWED.is_empty(),
"EXTRA_READ_ALLOWED should not be empty"
);
let cases = [
Case {
name: "cargo_registry_tilde",
path: "~/.cargo/registry/src/some-crate/src/lib.rs",
allowed: true,
},
Case {
name: "system_python_site_packages",
path: "/usr/local/lib/python3.12/site-packages/requests/models.py",
allowed: true,
},
];
for case in &cases {
assert_eq!(
is_path_in_extra_allowed(Path::new(case.path)),
case.allowed,
"case: {}",
case.name
);
}
if let Ok(home) = std::env::var("HOME") {
let expanded = PathBuf::from(&home).join(".cargo/registry/src/some-crate/src/lib.rs");
assert!(
is_path_in_extra_allowed(&expanded),
"expanded cargo registry path 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"
);
}
for dir in &*EXTRA_READ_ALLOWED {
let s = dir.to_string_lossy();
assert!(
!s.starts_with('~'),
"Literal tilde path should never be stored: {s}"
);
}
}
#[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"
);
}
}
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}"
);
}
#[test]
fn normalize_path_identity() {
assert_eq!(normalize_path(Path::new("/")), Path::new("/"));
assert_eq!(normalize_path(Path::new("/tmp")), Path::new("/tmp"));
assert_eq!(
normalize_path(Path::new("/tmp/file.txt")),
Path::new("/tmp/file.txt")
);
assert_eq!(
normalize_path(Path::new("relative/path")),
Path::new("relative/path")
);
assert_eq!(normalize_path(Path::new("single")), Path::new("single"));
}
#[test]
fn normalize_path_removes_dot_components() {
assert_eq!(
normalize_path(Path::new("/tmp/./file.txt")),
Path::new("/tmp/file.txt")
);
assert_eq!(
normalize_path(Path::new("/./tmp/./file.txt")),
Path::new("/tmp/file.txt")
);
assert_eq!(
normalize_path(Path::new("./relative/./path")),
Path::new("relative/path")
);
assert_eq!(normalize_path(Path::new("/.")), Path::new("/"));
assert_eq!(normalize_path(Path::new(".")), Path::new(""));
}
#[test]
fn normalize_path_resolves_simple_dotdot() {
assert_eq!(
normalize_path(Path::new("/tmp/../etc/passwd")),
Path::new("/etc/passwd")
);
assert_eq!(
normalize_path(Path::new("/tmp/foo/../../etc")),
Path::new("/etc")
);
assert_eq!(normalize_path(Path::new("/tmp/..")), Path::new("/"));
}
#[test]
fn normalize_path_dotdot_at_root_is_noop() {
assert_eq!(normalize_path(Path::new("/../tmp")), Path::new("/tmp"));
assert_eq!(
normalize_path(Path::new("/tmp/../../tmp/file.txt")),
Path::new("/tmp/file.txt")
);
assert_eq!(normalize_path(Path::new("/../../..")), Path::new("/"));
}
#[test]
fn normalize_path_preserves_excess_dotdot_for_relative_paths() {
assert_eq!(
normalize_path(Path::new("../../foo")),
Path::new("../../foo")
);
assert_eq!(normalize_path(Path::new("../../..")), Path::new("../../.."));
assert_eq!(
normalize_path(Path::new("foo/../../bar")),
Path::new("../bar")
);
assert_eq!(normalize_path(Path::new("a/b/c/../../d")), Path::new("a/d"));
}
#[test]
fn normalize_path_complex_traversal() {
assert_eq!(
normalize_path(Path::new("/a/b/c/../d/./e/../../f")),
Path::new("/a/b/f")
);
assert_eq!(
normalize_path(Path::new("a/./b/./c/../d/../../e")),
Path::new("a/e")
);
assert_eq!(
normalize_path(Path::new("a/b/../../../../c")),
Path::new("../../c")
);
}
#[test]
fn normalize_path_empty_and_dot_only() {
assert_eq!(normalize_path(Path::new("")), Path::new(""));
assert_eq!(normalize_path(Path::new(".")), Path::new(""));
assert_eq!(normalize_path(Path::new("/.")), Path::new("/"));
}
fn assert_allowed_under_temp(path: &str) {
assert!(
is_path_under_roots(Path::new(path), &allowed_temp_roots()),
"Expected path to be allowed under temp: {path}"
);
}
fn assert_not_allowed_under_temp(path: &str) {
assert!(
!is_path_under_roots(Path::new(path), &allowed_temp_roots()),
"Expected path to be rejected under temp: {path}"
);
}
#[test]
fn is_path_under_allowed_temp_blocks_path_traversal() {
assert_not_allowed_under_temp("/tmp/../etc/passwd");
assert_not_allowed_under_temp("/tmp/../../../../etc/passwd");
assert_not_allowed_under_temp("/tmp/foo/../../etc/passwd");
assert_not_allowed_under_temp("/tmp/./../etc/shadow");
assert_allowed_under_temp("/tmp/../tmp/file.txt");
assert_allowed_under_temp("/tmp/foo/../../tmp/file.txt");
assert_allowed_under_temp("/tmp/../../tmp/../tmp/bar");
}
#[test]
fn is_path_under_allowed_temp_blocks_tilde_traversal() {
assert_not_allowed_under_temp("~/../tmp/file.txt");
assert_allowed_under_temp("~/../../tmp/file.txt");
assert_not_allowed_under_temp("~/../etc/passwd");
assert_not_allowed_under_temp("~/../../etc/passwd");
}
#[test]
fn is_path_under_allowed_temp_allows_clean_paths() {
assert_allowed_under_temp("/tmp/out.txt");
assert_allowed_under_temp("/private/tmp/out.txt");
assert_allowed_under_temp("/var/tmp/out.txt");
assert_not_allowed_under_temp("relative.txt");
assert_not_allowed_under_temp("../tmp/out.txt");
}
#[test]
fn is_path_under_allowed_temp_blocks_absolute_non_temp() {
assert_not_allowed_under_temp("/etc/passwd");
assert_not_allowed_under_temp("/usr/bin/foo");
assert_not_allowed_under_temp("/var/log/system.log");
}
#[test]
fn is_path_under_allowed_temp_dot_components_are_harmless() {
assert_allowed_under_temp("/tmp/./file.txt");
assert_allowed_under_temp("/tmp/./././file.txt");
assert_allowed_under_temp("/tmp/foo/./../file.txt");
assert_not_allowed_under_temp("/tmp/./../etc/passwd");
}
#[test]
fn is_path_under_allowed_temp_handles_root_limit() {
assert_not_allowed_under_temp("/tmp/../../../");
assert_not_allowed_under_temp("/../../../../../");
}
}