albatross-cli 2.2.0

Open source, terminal-first AI coding agent with fully transparent multi-model routing. Local (Ollama, LM Studio, MLX, llama.cpp) or cloud, your keys, one TUI.
use std::path::{Path, PathBuf};

use crate::config::OutsideWorkspace;

#[derive(Debug, Clone)]
pub struct PathResolution {
    pub normalized: PathBuf,
    pub outside_workspace: bool,
}

#[derive(Debug, Clone)]
pub struct PathPolicy {
    root: PathBuf,
    outside: OutsideWorkspace,
}

impl PathPolicy {
    pub fn new(root: &str, outside: OutsideWorkspace) -> Self {
        Self {
            root: crate::path_security::canonical_root(Path::new(root)),
            outside,
        }
    }

    pub fn root(&self) -> &Path {
        &self.root
    }

    pub fn resolve(&self, path: &str) -> PathResolution {
        let p = Path::new(path);
        let (normalized, outside_workspace) =
            crate::path_security::resolve_under_root(&self.root, &self.root, p);
        PathResolution {
            normalized,
            outside_workspace,
        }
    }

    pub fn require_prompt_for_path(&self, path: &str) -> bool {
        self.outside == OutsideWorkspace::Prompt && self.resolve(path).outside_workspace
    }

    pub fn deny_path(&self, path: &str) -> Option<String> {
        let resolved = self.resolve(path);
        if self.outside == OutsideWorkspace::Deny && resolved.outside_workspace {
            Some(format!(
                "Path is outside workspace root: {} (root: {})",
                resolved.normalized.display(),
                self.root.display()
            ))
        } else {
            None
        }
    }

    pub fn require_prompt_for_cwd(&self) -> bool {
        self.outside == OutsideWorkspace::Prompt && !self.cwd_is_inside()
    }

    pub fn deny_cwd(&self) -> Option<String> {
        if self.outside == OutsideWorkspace::Deny && !self.cwd_is_inside() {
            Some(format!(
                "Current directory is outside workspace root: {}",
                std::env::current_dir()
                    .unwrap_or_else(|_| PathBuf::from("."))
                    .display()
            ))
        } else {
            None
        }
    }

    fn cwd_is_inside(&self) -> bool {
        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
        crate::path_security::resolve_existing_prefix(&cwd).starts_with(&self.root)
    }
}

impl Default for PathPolicy {
    fn default() -> Self {
        Self::new(".", OutsideWorkspace::Allow)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn normalizes_relative_path_under_workspace() {
        let cwd = std::env::current_dir().unwrap();
        let policy = PathPolicy::new(cwd.to_str().unwrap(), OutsideWorkspace::Prompt);
        let resolved = policy.resolve("src/../Cargo.toml");
        assert!(resolved.normalized.ends_with("Cargo.toml"));
        assert!(!resolved.outside_workspace);
    }

    #[test]
    fn detects_outside_workspace() {
        let policy = PathPolicy::new("/tmp/workspace", OutsideWorkspace::Prompt);
        assert!(policy.resolve("/tmp/other/file.txt").outside_workspace);
        assert!(policy.require_prompt_for_path("/tmp/other/file.txt"));
    }

    #[cfg(unix)]
    #[test]
    fn detects_symlink_escape() {
        use std::os::unix::fs::symlink;

        let workspace = tempfile::tempdir().unwrap();
        let outside = tempfile::tempdir().unwrap();
        symlink(outside.path(), workspace.path().join("link")).unwrap();
        let policy = PathPolicy::new(workspace.path().to_str().unwrap(), OutsideWorkspace::Deny);
        assert!(policy.resolve("link/secret.txt").outside_workspace);
        assert!(policy.deny_path("link/secret.txt").is_some());
    }
}