claude_agent/security/
guard.rs1use 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 }
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}