Skip to main content

ralph/
guardrails.rs

1use crate::config::GuardrailsConfig;
2use crate::errors::{RalphError, Result};
3use regex::Regex;
4use serde_json::Value;
5use std::path::PathBuf;
6
7/// Secret patterns — compiled once.
8static SECRET_PATTERNS: &[&str] = &[
9    r"(?i)(?:api[_-]?key|secret[_-]?key|access[_-]?key)\s*[:=]\s*[A-Za-z0-9+/]{20,}",
10    r"(?i)password\s*[:=]\s*[a-zA-Z0-9!@#$%^&*]{8,}",
11    r"sk-[A-Za-z0-9]{32,}",
12    r"AKIA[0-9A-Z]{16}",
13    r"(?i)anthropic[_-]?key\s*[:=]\s*sk-ant-[A-Za-z0-9]{20,}",
14    r"-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----",
15];
16
17pub struct GuardrailChecker {
18    workspace: PathBuf,
19    config: GuardrailsConfig,
20    secret_patterns: Vec<Regex>,
21}
22
23impl GuardrailChecker {
24    pub fn new(workspace: PathBuf, config: GuardrailsConfig) -> Self {
25        let secret_patterns = SECRET_PATTERNS
26            .iter()
27            .filter_map(|p| Regex::new(p).ok())
28            .collect();
29        Self {
30            workspace,
31            config,
32            secret_patterns,
33        }
34    }
35
36    /// Check a tool call before execution.
37    pub fn check_tool_call(&self, tool_name: &str, args: &Value) -> Result<()> {
38        match tool_name {
39            "write_file" | "edit_file" => {
40                if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
41                    self.check_path(path)?;
42                }
43            }
44            "read_file" | "list_dir" | "delete_file" => {
45                if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
46                    self.check_path(path)?;
47                }
48            }
49            "run_command" => {
50                if let Some(cmd) = args.get("cmd").and_then(|v| v.as_str()) {
51                    self.check_command(cmd)?;
52                }
53                // Ensure cwd stays in workspace
54                if let Some(cwd) = args.get("cwd").and_then(|v| v.as_str()) {
55                    self.check_path(cwd)?;
56                }
57            }
58            _ => {}
59        }
60        Ok(())
61    }
62
63    /// Check that a path does not escape the workspace.
64    pub fn check_path(&self, path: &str) -> Result<()> {
65        // Reject obvious traversal attempts early (before canonicalization)
66        if path.contains("..") {
67            let joined = self.workspace.join(path);
68            // Normalize without requiring the path to exist
69            let normalized = normalize_path(&joined);
70            let ws_norm = normalize_path(&self.workspace);
71            if !normalized.starts_with(&ws_norm) {
72                return Err(RalphError::PathEscape(path.to_string()));
73            }
74        }
75        // Reject absolute paths that are outside the workspace
76        if std::path::Path::new(path).is_absolute() {
77            let p = PathBuf::from(path);
78            let ws = self
79                .workspace
80                .canonicalize()
81                .unwrap_or_else(|_| self.workspace.clone());
82            if !p.starts_with(&ws) {
83                return Err(RalphError::PathEscape(path.to_string()));
84            }
85        }
86        Ok(())
87    }
88
89    /// Check a shell command against the blocklist.
90    pub fn check_command(&self, cmd: &str) -> Result<()> {
91        for blocked in &self.config.blocked_commands {
92            // Normalize whitespace for matching
93            let normalized = cmd
94                .split_whitespace()
95                .collect::<Vec<_>>()
96                .join(" ")
97                .to_lowercase();
98            let block_norm = blocked
99                .split_whitespace()
100                .collect::<Vec<_>>()
101                .join(" ")
102                .to_lowercase();
103            if normalized.contains(&block_norm) {
104                return Err(RalphError::BlockedCommand(cmd.to_string()));
105            }
106        }
107        Ok(())
108    }
109
110    /// Scan content for secrets before writing.
111    pub fn check_content_for_secrets(&self, content: &str) -> Result<()> {
112        for pattern in &self.secret_patterns {
113            if pattern.is_match(content) {
114                return Err(RalphError::SecretDetected);
115            }
116        }
117        Ok(())
118    }
119}
120
121/// Normalize a path without requiring it to exist (resolves `..` lexically).
122fn normalize_path(path: &PathBuf) -> PathBuf {
123    let mut components = Vec::new();
124    for component in path.components() {
125        match component {
126            std::path::Component::ParentDir => {
127                components.pop();
128            }
129            std::path::Component::CurDir => {}
130            c => components.push(c),
131        }
132    }
133    components.iter().collect()
134}