1use crate::config::GuardrailsConfig;
2use crate::errors::{RalphError, Result};
3use regex::Regex;
4use serde_json::Value;
5use std::path::PathBuf;
6
7static 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 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 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 pub fn check_path(&self, path: &str) -> Result<()> {
65 if path.contains("..") {
67 let joined = self.workspace.join(path);
68 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 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 pub fn check_command(&self, cmd: &str) -> Result<()> {
91 for blocked in &self.config.blocked_commands {
92 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 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
121fn 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}