regy 0.1.0

Private-by-default desktop agent for the Regy web interface
use crate::domain::errors::{AgentError, AgentResult, ErrorCode};
use std::fs;
use std::path::{Component, Path};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidatedCommand {
    pub command: Vec<String>,
    pub cwd: Option<String>,
}

#[derive(Debug, Clone)]
pub struct CommandPolicy {
    allowed_commands: Vec<String>,
    allowed_workdirs: Vec<String>,
}

impl CommandPolicy {
    pub fn new(allowed_commands: Vec<String>, allowed_workdirs: Vec<String>) -> Self {
        Self {
            allowed_commands,
            allowed_workdirs,
        }
    }

    pub fn validate(&self, command: &[String], cwd: Option<&str>) -> AgentResult<ValidatedCommand> {
        if command.is_empty()
            || command[0].trim().is_empty()
            || command.iter().any(|part| part.contains('\0'))
        {
            return Err(AgentError::new(ErrorCode::CommandDenied, "command denied"));
        }

        if !self.allowed_commands.is_empty()
            && !self
                .allowed_commands
                .iter()
                .any(|allowed| allowed == &command[0])
        {
            return Err(AgentError::new(ErrorCode::CommandDenied, "command denied"));
        }

        let cwd = if let Some(cwd) = cwd {
            Some(self.validate_cwd(cwd)?)
        } else {
            if !self.allowed_workdirs.is_empty() {
                return Err(AgentError::new(ErrorCode::CwdDenied, "cwd denied"));
            }
            None
        };

        Ok(ValidatedCommand {
            command: command.to_vec(),
            cwd,
        })
    }
}

impl CommandPolicy {
    pub(crate) fn validate_chat_cwd(&self, cwd: &str) -> AgentResult<String> {
        self.validate_cwd(cwd)
    }

    pub(crate) fn validate_workspace(&self, workspace: &Path) -> AgentResult<String> {
        let canonical = workspace
            .canonicalize()
            .map_err(|_| AgentError::new(ErrorCode::SkillScopeDenied, "workspace denied"))?;
        self.validate_canonical_cwd(&canonical)
            .map_err(|_| AgentError::new(ErrorCode::SkillScopeDenied, "workspace denied"))
    }

    /// Returns true if `path` is under one of the configured `allowed_workdirs`
    /// roots. Does not perform cwd-validity checks (sensitive roots, ssh
    /// segments) — callers that need those should use `validate_canonical_cwd`.
    pub fn is_under_allowed_workdir(&self, path: &Path) -> bool {
        if self.allowed_workdirs.is_empty() {
            return false;
        }
        self.allowed_workdirs
            .iter()
            .any(|root| matches_workdir(root, path))
    }

    fn validate_cwd(&self, cwd: &str) -> AgentResult<String> {
        if cwd.contains('\0') || has_dot_segments(cwd) || has_ssh_segment(Path::new(cwd)) {
            return Err(AgentError::new(ErrorCode::CwdDenied, "cwd denied"));
        }

        let cwd = canonicalize_path(cwd)?;
        self.validate_canonical_cwd(&cwd)
    }

    pub(crate) fn validate_canonical_cwd(&self, cwd: &Path) -> AgentResult<String> {
        if is_sensitive_cwd(cwd) {
            return Err(AgentError::new(ErrorCode::CwdDenied, "cwd denied"));
        }

        if !self.allowed_workdirs.is_empty()
            && !self
                .allowed_workdirs
                .iter()
                .any(|root| matches_workdir(root, cwd))
        {
            return Err(AgentError::new(ErrorCode::CwdDenied, "cwd denied"));
        }

        canonicalized_cwd_string(cwd)
    }
}

pub(crate) fn canonicalized_cwd_string(cwd: &Path) -> AgentResult<String> {
    cwd.to_str()
        .map(str::to_owned)
        .ok_or_else(|| AgentError::new(ErrorCode::CwdDenied, "cwd denied"))
}

fn has_dot_segments(cwd: &str) -> bool {
    Path::new(cwd)
        .components()
        .any(|component| matches!(component, Component::CurDir | Component::ParentDir))
}

fn canonicalize_path(path: &str) -> AgentResult<std::path::PathBuf> {
    fs::canonicalize(path).map_err(|_| AgentError::new(ErrorCode::CwdDenied, "cwd denied"))
}

fn is_sensitive_cwd(cwd: &Path) -> bool {
    cwd == Path::new("/")
        || has_ssh_segment(cwd)
        || sensitive_roots()
            .iter()
            .skip(1)
            .any(|root| canonicalize_path(root).is_ok_and(|root| cwd.starts_with(root)))
}

fn matches_workdir(root: &str, cwd: &Path) -> bool {
    canonicalize_path(root).is_ok_and(|root| cwd.starts_with(root))
}

fn has_ssh_segment(cwd: &Path) -> bool {
    cwd.components().any(|component| {
        matches!(component, Component::Normal(segment) if segment.to_string_lossy().eq_ignore_ascii_case(".ssh"))
    })
}

fn sensitive_roots() -> &'static [&'static str] {
    &["/", "/etc", "/root", "/var/lib", "/var/root"]
}