claude_agent/security/
guard.rs

1//! SecurityGuard: Pre-execution input validation for tools.
2
3use std::path::Path;
4
5use glob::Pattern;
6use serde_json::Value;
7
8use super::bash::SecurityConcern;
9use super::{SecurityContext, SecurityError};
10use crate::permissions::ToolLimits;
11
12pub struct SecurityGuard;
13
14impl SecurityGuard {
15    pub fn validate(
16        security: &SecurityContext,
17        tool_name: &str,
18        input: &Value,
19    ) -> Result<(), SecurityError> {
20        let limits = security
21            .policy
22            .permission
23            .get_limits(tool_name)
24            .cloned()
25            .unwrap_or_default();
26        let schema = ToolPathSchema::for_tool(tool_name);
27
28        for field in schema.path_fields {
29            if let Some(path_str) = input.get(*field).and_then(|v| v.as_str()) {
30                security.fs.resolve_with_limits(path_str, &limits)?;
31            }
32        }
33
34        if schema.is_shell {
35            Self::validate_bash_command(security, input, &limits)?;
36        }
37
38        Ok(())
39    }
40
41    fn validate_bash_command(
42        security: &SecurityContext,
43        input: &Value,
44        limits: &ToolLimits,
45    ) -> Result<(), SecurityError> {
46        let command = input
47            .get("command")
48            .and_then(|v| v.as_str())
49            .ok_or_else(|| SecurityError::InvalidPath("missing command".into()))?;
50
51        let bypass = input
52            .get("dangerouslyDisableSandbox")
53            .and_then(|v| v.as_bool())
54            .unwrap_or(false)
55            && security.policy.can_bypass_sandbox();
56
57        let analysis = security
58            .bash
59            .validate(command)
60            .map_err(SecurityError::BashBlocked)?;
61
62        for concern in &analysis.concerns {
63            if matches!(concern, SecurityConcern::DangerousCommand(_)) {
64                return Err(SecurityError::BashBlocked(format!(
65                    "Blocked dangerous pattern: {:?}",
66                    concern
67                )));
68            }
69        }
70
71        if bypass {
72            return Ok(());
73        }
74
75        for path_ref in &analysis.paths {
76            let path = Path::new(&path_ref.path);
77
78            if !security.fs.is_within(path) {
79                return Err(SecurityError::PathEscape(path.to_path_buf()));
80            }
81
82            if let Some(ref allowed) = limits.allowed_paths
83                && !allowed.is_empty()
84                && !matches_patterns(path, allowed)
85            {
86                return Err(SecurityError::DeniedPath(path.to_path_buf()));
87            }
88
89            if let Some(ref denied) = limits.denied_paths
90                && matches_patterns(path, denied)
91            {
92                return Err(SecurityError::DeniedPath(path.to_path_buf()));
93            }
94        }
95
96        Ok(())
97    }
98}
99
100fn matches_patterns(path: &Path, patterns: &[String]) -> bool {
101    let path_str = path.to_string_lossy();
102    patterns.iter().any(|pattern| {
103        match Pattern::new(pattern) {
104            Ok(g) => g.matches(&path_str),
105            Err(e) => {
106                tracing::error!(pattern = %pattern, error = %e, "Invalid glob pattern in security policy");
107                true // Fail closed: treat invalid patterns as matching to prevent bypass
108            }
109        }
110    })
111}
112
113struct ToolPathSchema {
114    path_fields: &'static [&'static str],
115    is_shell: bool,
116}
117
118impl ToolPathSchema {
119    fn for_tool(name: &str) -> Self {
120        match name {
121            "Read" | "Write" | "Edit" => Self {
122                path_fields: &["file_path"],
123                is_shell: false,
124            },
125            "Glob" | "Grep" => Self {
126                path_fields: &["path"],
127                is_shell: false,
128            },
129            "Bash" => Self {
130                path_fields: &[],
131                is_shell: true,
132            },
133            _ => Self {
134                path_fields: &[],
135                is_shell: false,
136            },
137        }
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use tempfile::tempdir;
145
146    fn create_test_context(root: &Path) -> SecurityContext {
147        SecurityContext::new(root).unwrap()
148    }
149
150    #[test]
151    fn test_path_escape_blocked() {
152        let dir = tempdir().unwrap();
153        let security = create_test_context(dir.path());
154
155        let input = serde_json::json!({
156            "file_path": "/etc/passwd"
157        });
158
159        let result = SecurityGuard::validate(&security, "Read", &input);
160        assert!(matches!(result, Err(SecurityError::PathEscape(_))));
161    }
162
163    #[test]
164    fn test_traversal_blocked() {
165        let dir = tempdir().unwrap();
166        let security = create_test_context(dir.path());
167
168        let input = serde_json::json!({
169            "file_path": "../../../etc/passwd"
170        });
171
172        let result = SecurityGuard::validate(&security, "Read", &input);
173        assert!(matches!(result, Err(SecurityError::PathEscape(_))));
174    }
175
176    #[test]
177    fn test_valid_path_allowed() {
178        let dir = tempdir().unwrap();
179        let root = std::fs::canonicalize(dir.path()).unwrap();
180        std::fs::write(root.join("test.txt"), "content").unwrap();
181
182        let security = create_test_context(&root);
183
184        let input = serde_json::json!({
185            "file_path": root.join("test.txt").to_str().unwrap()
186        });
187
188        let result = SecurityGuard::validate(&security, "Read", &input);
189        assert!(result.is_ok());
190    }
191
192    #[test]
193    fn test_bash_path_escape_blocked() {
194        let dir = tempdir().unwrap();
195        let security = create_test_context(dir.path());
196
197        let input = serde_json::json!({
198            "command": "cat /etc/passwd"
199        });
200
201        let result = SecurityGuard::validate(&security, "Bash", &input);
202        assert!(matches!(result, Err(SecurityError::PathEscape(_))));
203    }
204
205    #[test]
206    fn test_dangerous_command_blocked() {
207        let dir = tempdir().unwrap();
208        let security = create_test_context(dir.path());
209
210        let input = serde_json::json!({
211            "command": "rm -rf /"
212        });
213
214        let result = SecurityGuard::validate(&security, "Bash", &input);
215        assert!(
216            matches!(result, Err(SecurityError::BashBlocked(_))),
217            "Expected BashBlocked error for dangerous command, got {:?}",
218            result
219        );
220    }
221
222    #[test]
223    fn test_glob_path_optional() {
224        let dir = tempdir().unwrap();
225        let security = create_test_context(dir.path());
226
227        let input = serde_json::json!({
228            "pattern": "*.rs"
229        });
230
231        let result = SecurityGuard::validate(&security, "Glob", &input);
232        assert!(result.is_ok());
233    }
234
235    #[test]
236    fn test_non_path_tool_allowed() {
237        let dir = tempdir().unwrap();
238        let security = create_test_context(dir.path());
239
240        let input = serde_json::json!({
241            "query": "test search"
242        });
243
244        let result = SecurityGuard::validate(&security, "WebSearch", &input);
245        assert!(result.is_ok());
246    }
247}