ralph-coder 0.2.1

An agentic code generation CLI powered by multiple LLM backends
Documentation
use crate::config::GuardrailsConfig;
use crate::errors::{RalphError, Result};
use regex::Regex;
use serde_json::Value;
use std::path::PathBuf;

/// Secret patterns — compiled once.
static SECRET_PATTERNS: &[&str] = &[
    r"(?i)(?:api[_-]?key|secret[_-]?key|access[_-]?key)\s*[:=]\s*[A-Za-z0-9+/]{20,}",
    r"(?i)password\s*[:=]\s*[a-zA-Z0-9!@#$%^&*]{8,}",
    r"sk-[A-Za-z0-9]{32,}",
    r"AKIA[0-9A-Z]{16}",
    r"(?i)anthropic[_-]?key\s*[:=]\s*sk-ant-[A-Za-z0-9]{20,}",
    r"-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----",
];

pub struct GuardrailChecker {
    workspace: PathBuf,
    config: GuardrailsConfig,
    secret_patterns: Vec<Regex>,
}

impl GuardrailChecker {
    pub fn new(workspace: PathBuf, config: GuardrailsConfig) -> Self {
        let secret_patterns = SECRET_PATTERNS
            .iter()
            .filter_map(|p| Regex::new(p).ok())
            .collect();
        Self {
            workspace,
            config,
            secret_patterns,
        }
    }

    /// Check a tool call before execution.
    pub fn check_tool_call(&self, tool_name: &str, args: &Value) -> Result<()> {
        match tool_name {
            "write_file" | "edit_file" => {
                if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
                    self.check_path(path)?;
                }
            }
            "read_file" | "list_dir" | "delete_file" => {
                if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
                    self.check_path(path)?;
                }
            }
            "run_command" => {
                if let Some(cmd) = args.get("cmd").and_then(|v| v.as_str()) {
                    self.check_command(cmd)?;
                }
                // Ensure cwd stays in workspace
                if let Some(cwd) = args.get("cwd").and_then(|v| v.as_str()) {
                    self.check_path(cwd)?;
                }
            }
            _ => {}
        }
        Ok(())
    }

    /// Check that a path does not escape the workspace.
    pub fn check_path(&self, path: &str) -> Result<()> {
        // Reject obvious traversal attempts early (before canonicalization)
        if path.contains("..") {
            let joined = self.workspace.join(path);
            // Normalize without requiring the path to exist
            let normalized = normalize_path(&joined);
            let ws_norm = normalize_path(&self.workspace);
            if !normalized.starts_with(&ws_norm) {
                return Err(RalphError::PathEscape(path.to_string()));
            }
        }
        // Reject absolute paths that are outside the workspace
        if std::path::Path::new(path).is_absolute() {
            let p = PathBuf::from(path);
            let ws = self
                .workspace
                .canonicalize()
                .unwrap_or_else(|_| self.workspace.clone());
            if !p.starts_with(&ws) {
                return Err(RalphError::PathEscape(path.to_string()));
            }
        }
        Ok(())
    }

    /// Check a shell command against the blocklist.
    pub fn check_command(&self, cmd: &str) -> Result<()> {
        for blocked in &self.config.blocked_commands {
            // Normalize whitespace for matching
            let normalized = cmd
                .split_whitespace()
                .collect::<Vec<_>>()
                .join(" ")
                .to_lowercase();
            let block_norm = blocked
                .split_whitespace()
                .collect::<Vec<_>>()
                .join(" ")
                .to_lowercase();
            if normalized.contains(&block_norm) {
                return Err(RalphError::BlockedCommand(cmd.to_string()));
            }
        }
        Ok(())
    }

    /// Scan content for secrets before writing.
    pub fn check_content_for_secrets(&self, content: &str) -> Result<()> {
        for pattern in &self.secret_patterns {
            if pattern.is_match(content) {
                return Err(RalphError::SecretDetected);
            }
        }
        Ok(())
    }
}

/// Normalize a path without requiring it to exist (resolves `..` lexically).
fn normalize_path(path: &PathBuf) -> PathBuf {
    let mut components = Vec::new();
    for component in path.components() {
        match component {
            std::path::Component::ParentDir => {
                components.pop();
            }
            std::path::Component::CurDir => {}
            c => components.push(c),
        }
    }
    components.iter().collect()
}