perspt-policy 0.6.2

Starlark execution policy engine for Perspt
Documentation
//! Command Sanitization
//!
//! Parses and validates shell commands to detect dangerous patterns.

use anyhow::Result;
use shell_words;

fn has_windows_drive_prefix(part: &str) -> bool {
    part.chars()
        .nth(1)
        .is_some_and(|character| character == ':')
}

fn looks_like_path_argument(part: &str) -> bool {
    part.contains('/') || part.contains('\\') || has_windows_drive_prefix(part)
}

fn is_explicit_absolute_path(part: &str, candidate: &std::path::Path) -> bool {
    candidate.is_absolute()
        || part.starts_with('/')
        || part.starts_with('\\')
        || has_windows_drive_prefix(part)
}

/// Sanitization result
#[derive(Debug, Clone)]
pub struct SanitizeResult {
    /// The parsed command parts
    pub parts: Vec<String>,
    /// Warnings about potentially dangerous patterns
    pub warnings: Vec<String>,
    /// Whether the command was rejected
    pub rejected: bool,
    /// Rejection reason if rejected
    pub rejection_reason: Option<String>,
}

/// Sanitize a command string
///
/// Parses the command and checks for:
/// - Subshell expansion (backticks, $())
/// - Command chaining (&&, ||, ;)
/// - Redirections to sensitive paths
/// - Network access without acknowledgment
pub fn sanitize_command(command: &str) -> Result<SanitizeResult> {
    let mut result = SanitizeResult {
        parts: Vec::new(),
        warnings: Vec::new(),
        rejected: false,
        rejection_reason: None,
    };

    // Parse using shell-words
    match shell_words::split(command) {
        Ok(parts) => {
            result.parts = parts;
        }
        Err(e) => {
            result.rejected = true;
            result.rejection_reason = Some(format!("Failed to parse command: {}", e));
            return Ok(result);
        }
    }

    // Check for backtick subshell expansion
    if command.contains('`') {
        result
            .warnings
            .push("Command contains backtick subshell expansion".to_string());
    }

    // Check for $() subshell expansion
    if command.contains("$(") {
        result
            .warnings
            .push("Command contains $() subshell expansion".to_string());
    }

    // Check for command chaining (if not in quotes)
    let dangerous_chains = ["&&", "||", ";"];
    for chain in &dangerous_chains {
        // Simple check - a more robust implementation would respect quoting
        if command.contains(chain) {
            result
                .warnings
                .push(format!("Command contains chaining operator: {}", chain));
        }
    }

    // Check for redirections to sensitive paths
    let sensitive_paths = ["/etc/", "/root/", "~/.ssh/", "/dev/", "/proc/", "/sys/"];

    for path in &sensitive_paths {
        if command.contains(&format!("> {}", path))
            || command.contains(&format!(">> {}", path))
            || command.contains(&format!("< {}", path))
        {
            result.warnings.push(format!(
                "Command redirects to/from sensitive path: {}",
                path
            ));
        }
    }

    // Check for destructive patterns
    let destructive_patterns = [
        ("rm -rf /", "Recursive delete of root"),
        ("rm -rf /*", "Recursive delete of root contents"),
        ("rm -rf ~", "Recursive delete of home directory"),
        (":(){:|:&};:", "Fork bomb"),
        ("mkfs", "Filesystem creation"),
        ("dd if=/dev/zero", "Disk overwrite"),
        ("> /dev/sda", "Direct disk write"),
    ];

    for (pattern, description) in &destructive_patterns {
        if command.contains(pattern) {
            result.rejected = true;
            result.rejection_reason = Some(format!(
                "Dangerous pattern detected: {} ({})",
                pattern, description
            ));
            return Ok(result);
        }
    }

    Ok(result)
}

#[cfg(test)]
/// Canonicalize a command for display
///
/// Normalizes the command to prevent visual obfuscation attacks
pub(crate) fn canonicalize(command: &str) -> Result<String> {
    // Parse and rejoin to normalize spacing
    let parts = shell_words::split(command)?;
    Ok(shell_words::join(&parts))
}

/// Validate that a command is workspace-bound.
///
/// Checks parsed command parts for absolute paths that escape the given
/// workspace root.  Returns `Ok(())` when all path-like arguments resolve
/// inside the workspace, or an error describing the violation.
pub fn validate_workspace_bound(command: &str, workspace_root: &std::path::Path) -> Result<()> {
    // On Windows, normalize backslash path separators to forward slashes
    // before POSIX-style shell tokenization (`shell_words` treats `\` as
    // an escape character, which mangles Windows paths).
    let normalized;
    let command_for_parse = if cfg!(windows) {
        normalized = command.replace('\\', "/");
        &normalized
    } else {
        command
    };
    let parts = shell_words::split(command_for_parse)?;

    for part in &parts {
        // Skip flags and non-path arguments
        if part.starts_with('-') || !looks_like_path_argument(part) {
            continue;
        }

        let candidate = std::path::Path::new(part);
        if is_explicit_absolute_path(part, candidate) {
            // Absolute path — must be inside workspace
            if !candidate.starts_with(workspace_root) {
                anyhow::bail!(
                    "command references path outside workspace: {} (workspace: {})",
                    part,
                    workspace_root.display()
                );
            }
        } else if part.contains("..") {
            // First: logical check that catches traversal even when the
            // target path doesn't exist on disk yet.
            if perspt_core::path::normalize_artifact_path(part).is_err() {
                anyhow::bail!(
                    "command contains path that escapes workspace root: {} (workspace: {})",
                    part,
                    workspace_root.display()
                );
            }
            // Second: filesystem-level check for paths that do exist.
            let resolved = workspace_root.join(candidate);
            if let Ok(canonical) = resolved.canonicalize() {
                if !canonical.starts_with(workspace_root) {
                    anyhow::bail!(
                        "command escapes workspace via '..': {} resolves to {} (workspace: {})",
                        part,
                        canonical.display(),
                        workspace_root.display()
                    );
                }
            }
        }
    }

    Ok(())
}

/// Validate that an artifact path is safe for a destructive operation
/// (delete or move).
///
/// Beyond the standard path-traversal and absolute-path checks already
/// performed by `ArtifactBundle::validate()`, this adds domain-level
/// guards that prevent accidental loss of critical project files.
pub fn validate_artifact_mutation(
    path: &str,
    workspace_root: &std::path::Path,
    operation: &str,
) -> Result<()> {
    // 1. Canonical path check via perspt_core::path
    perspt_core::path::normalize_artifact_path(path)
        .map_err(|e| anyhow::anyhow!("{} rejected for {}: {}", operation, path, e))?;

    // 2. Protect critical project root files
    let protected: &[&str] = &[
        "Cargo.toml",
        "Cargo.lock",
        "pyproject.toml",
        "package.json",
        "package-lock.json",
        ".gitignore",
        ".git",
    ];

    let normalized = path.replace('\\', "/");
    let basename = normalized.rsplit('/').next().unwrap_or(&normalized);

    // Only protect at root level (not nested e.g. crates/foo/Cargo.toml)
    if !normalized.contains('/') && protected.contains(&basename) {
        anyhow::bail!(
            "{} rejected: '{}' is a protected project root file",
            operation,
            path
        );
    }

    // 3. Reject entire top-level directories (e.g. "src", "crates")
    let resolved = workspace_root.join(path);
    if resolved.is_dir() && !normalized.contains('/') {
        anyhow::bail!(
            "{} rejected: '{}' is a top-level directory; specify individual files",
            operation,
            path
        );
    }

    Ok(())
}

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

    #[test]
    fn test_safe_command() {
        let result = sanitize_command("cargo build --release").unwrap();
        assert!(!result.rejected);
        assert!(result.warnings.is_empty());
    }

    #[test]
    fn test_dangerous_command_rejected() {
        let result = sanitize_command("rm -rf /").unwrap();
        assert!(result.rejected);
    }

    #[test]
    fn test_subshell_warning() {
        let result = sanitize_command("echo $(whoami)").unwrap();
        assert!(!result.warnings.is_empty());
    }

    #[test]
    fn test_chaining_warning() {
        let result = sanitize_command("ls && rm file").unwrap();
        assert!(!result.warnings.is_empty());
    }

    #[test]
    fn test_canonicalize() {
        let normalized = canonicalize("ls   -la    /tmp").unwrap();
        assert_eq!(normalized, "ls -la /tmp");
    }

    #[test]
    fn test_workspace_bound_relative_safe() {
        let ws = std::path::PathBuf::from("/home/user/project");
        assert!(validate_workspace_bound("cargo build", &ws).is_ok());
    }

    #[test]
    fn test_workspace_bound_absolute_inside() {
        let (ws, command) = if cfg!(windows) {
            (
                std::path::PathBuf::from(r"C:\Users\user\project"),
                r"cat C:\Users\user\project\src\main.rs",
            )
        } else {
            (
                std::path::PathBuf::from("/home/user/project"),
                "cat /home/user/project/src/main.rs",
            )
        };

        assert!(validate_workspace_bound(command, &ws).is_ok());
    }

    #[test]
    fn test_workspace_bound_absolute_outside_rejected() {
        let (ws, command) = if cfg!(windows) {
            (
                std::path::PathBuf::from(r"C:\Users\user\project"),
                r"cat C:\Windows\System32\drivers\etc\hosts",
            )
        } else {
            (
                std::path::PathBuf::from("/home/user/project"),
                "cat /etc/passwd",
            )
        };

        let result = validate_workspace_bound(command, &ws);
        assert!(result.is_err());
        assert!(result
            .unwrap_err()
            .to_string()
            .contains("outside workspace"));
    }

    #[test]
    fn test_workspace_bound_flags_ignored() {
        let ws = std::path::PathBuf::from("/home/user/project");
        assert!(validate_workspace_bound("cargo build --release", &ws).is_ok());
    }

    #[test]
    fn test_artifact_mutation_normal_file_allowed() {
        let ws = std::env::temp_dir();
        assert!(validate_artifact_mutation("src/main.rs", &ws, "Delete").is_ok());
    }

    #[test]
    fn test_artifact_mutation_nested_cargo_toml_allowed() {
        let ws = std::env::temp_dir();
        assert!(validate_artifact_mutation("crates/foo/Cargo.toml", &ws, "Delete").is_ok());
    }

    #[test]
    fn test_artifact_mutation_root_cargo_toml_rejected() {
        let ws = std::env::temp_dir();
        let result = validate_artifact_mutation("Cargo.toml", &ws, "Delete");
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("protected"));
    }

    #[test]
    fn test_artifact_mutation_gitignore_rejected() {
        let ws = std::env::temp_dir();
        let result = validate_artifact_mutation(".gitignore", &ws, "Delete");
        assert!(result.is_err());
    }

    #[test]
    fn test_artifact_mutation_traversal_rejected() {
        let ws = std::env::temp_dir();
        let result = validate_artifact_mutation("../etc/passwd", &ws, "Move");
        assert!(result.is_err());
    }
}