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"))
}
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"]
}