agent_diva_core/security/
policy.rs1use crate::security::config::{SecurityConfig, SecurityLevel};
8use crate::security::error::SecurityError;
9use crate::security::path::PathValidator;
10use crate::security::rate_limit::ActionTracker;
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14#[derive(Debug, Clone)]
16pub struct SecurityPolicy {
17 config: SecurityConfig,
19 tracker: ActionTracker,
21 workspace_dir: PathBuf,
23}
24
25impl SecurityPolicy {
26 pub fn new(workspace_dir: PathBuf) -> Self {
28 Self {
29 config: SecurityConfig::default(),
30 tracker: ActionTracker::new(),
31 workspace_dir,
32 }
33 }
34
35 pub fn with_config(workspace_dir: PathBuf, config: SecurityConfig) -> Self {
37 Self {
38 config,
39 tracker: ActionTracker::new(),
40 workspace_dir,
41 }
42 }
43
44 pub fn from_level(workspace_dir: PathBuf, level: SecurityLevel) -> Self {
46 Self {
47 config: SecurityConfig::from_level(level),
48 tracker: ActionTracker::new(),
49 workspace_dir,
50 }
51 }
52
53 pub fn config(&self) -> &SecurityConfig {
55 &self.config
56 }
57
58 pub fn workspace_dir(&self) -> &Path {
60 &self.workspace_dir
61 }
62
63 pub fn is_read_only(&self) -> bool {
65 self.config.is_read_only()
66 }
67
68 pub fn has_shell_access(&self) -> bool {
70 matches!(self.config.level, SecurityLevel::Permissive)
72 }
73
74 pub fn is_path_allowed(&self, path: &str) -> Result<(), SecurityError> {
85 if PathValidator::contains_null_bytes(path) {
87 return Err(SecurityError::InvalidPathFormat {
88 reason: "Path contains null bytes".to_string(),
89 });
90 }
91
92 if PathValidator::contains_path_traversal(path) {
94 return Err(SecurityError::ForbiddenComponent {
95 component: "parent directory (..)".to_string(),
96 });
97 }
98
99 if PathValidator::contains_url_encoded_traversal(path) {
101 return Err(SecurityError::InvalidPathFormat {
102 reason: "URL-encoded path traversal detected".to_string(),
103 });
104 }
105
106 if PathValidator::starts_with_tilde(path) {
108 return Err(SecurityError::InvalidPathFormat {
109 reason: "Tilde expansion is not allowed".to_string(),
110 });
111 }
112
113 if self.config.workspace_only && PathValidator::is_absolute(path) {
115 return Err(SecurityError::InvalidPathFormat {
116 reason: "Absolute paths are not allowed in workspace-only mode".to_string(),
117 });
118 }
119
120 if let Some(prefix) =
122 PathValidator::matches_forbidden_prefix(path, &self.config.forbidden_paths)
123 {
124 return Err(SecurityError::PathNotAllowed {
125 path: format!("matches forbidden prefix: {}", prefix),
126 });
127 }
128
129 if let Some(ext) = PathValidator::get_extension(path) {
131 if PathValidator::is_extension_forbidden(&ext, &self.config.forbidden_extensions) {
132 return Err(SecurityError::ForbiddenExtension { ext });
133 }
134 }
135
136 Ok(())
137 }
138
139 pub fn resolve_path(&self, path: &str) -> PathBuf {
141 if Path::new(path).is_absolute() {
142 PathBuf::from(path)
143 } else {
144 self.workspace_dir.join(path)
145 }
146 }
147
148 pub fn is_resolved_path_allowed(&self, resolved: &Path) -> bool {
150 let resolved_canonical = if let Ok(c) = resolved.canonicalize() {
152 c
153 } else {
154 resolved.to_path_buf()
155 };
156
157 let workspace_canonical = if let Ok(c) = self.workspace_dir.canonicalize() {
159 c
160 } else {
161 self.workspace_dir.clone()
162 };
163
164 if resolved_canonical.starts_with(&workspace_canonical) {
165 return true;
166 }
167
168 PathValidator::is_within_allowed_roots(resolved, &self.config.allowed_roots)
170 }
171
172 pub async fn validate_path(&self, path: &str) -> Result<PathBuf, SecurityError> {
174 self.is_path_allowed(path)?;
176
177 let full_path = self.resolve_path(path);
179
180 let resolved = match tokio::fs::canonicalize(&full_path).await {
182 Ok(p) => p,
183 Err(_) => full_path, };
185
186 if self.config.workspace_only && !self.is_resolved_path_allowed(&resolved) {
188 return Err(SecurityError::PathEscapesWorkspace { resolved });
189 }
190
191 if !self.config.allow_symlinks {
193 if let Ok(meta) = tokio::fs::symlink_metadata(&resolved).await {
194 if meta.file_type().is_symlink() {
195 return Err(SecurityError::SymlinkNotAllowed { path: resolved });
196 }
197 }
198 }
199
200 Ok(resolved)
201 }
202
203 pub async fn validate_parent_directory(&self, path: &Path) -> Result<PathBuf, SecurityError> {
205 let Some(parent) = path.parent() else {
206 return Err(SecurityError::InvalidPathFormat {
207 reason: "Path has no parent directory".to_string(),
208 });
209 };
210
211 if let Err(e) = tokio::fs::create_dir_all(parent).await {
213 return Err(SecurityError::InvalidPathFormat {
214 reason: format!("Failed to create parent directories: {}", e),
215 });
216 }
217
218 let resolved_parent = tokio::fs::canonicalize(parent).await.map_err(|e| {
220 SecurityError::InvalidPathFormat {
221 reason: format!("Failed to resolve parent directory: {}", e),
222 }
223 })?;
224
225 if self.config.workspace_only && !self.is_resolved_path_allowed(&resolved_parent) {
227 return Err(SecurityError::PathEscapesWorkspace {
228 resolved: resolved_parent,
229 });
230 }
231
232 Ok(resolved_parent)
233 }
234
235 pub fn is_rate_limited(&self) -> bool {
239 self.tracker
240 .is_rate_limited(self.config.max_actions_per_hour)
241 }
242
243 pub fn record_action(&self) -> usize {
245 self.tracker.record()
246 }
247
248 pub fn try_record_action(&self) -> Result<(), SecurityError> {
252 if !self.tracker.try_record(self.config.max_actions_per_hour) {
253 let count = self.tracker.count();
254 return Err(SecurityError::RateLimitExceeded {
255 count,
256 max: self.config.max_actions_per_hour,
257 });
258 }
259 Ok(())
260 }
261
262 pub fn action_count(&self) -> usize {
264 self.tracker.count()
265 }
266
267 pub fn can_act(&self) -> Result<(), SecurityError> {
269 if self.is_read_only() {
270 return Err(SecurityError::ReadOnlyMode);
271 }
272 self.try_record_action()
273 }
274
275 pub fn check_file_size(&self, size: u64) -> Result<(), SecurityError> {
279 if self.config.max_file_size > 0 && size > self.config.max_file_size {
280 Err(SecurityError::FileTooLarge {
281 size,
282 max_size: self.config.max_file_size,
283 })
284 } else {
285 Ok(())
286 }
287 }
288}
289
290impl Default for SecurityPolicy {
291 fn default() -> Self {
292 Self::new(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
293 }
294}
295
296pub type SharedSecurityPolicy = Arc<SecurityPolicy>;
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use tempfile::TempDir;
303
304 fn create_test_policy() -> (SecurityPolicy, TempDir) {
305 let temp_dir = TempDir::new().unwrap();
306 let policy = SecurityPolicy::new(temp_dir.path().to_path_buf());
307 (policy, temp_dir)
308 }
309
310 #[test]
311 fn test_path_validation_null_bytes() {
312 let (policy, _temp) = create_test_policy();
313 assert!(policy.is_path_allowed("/path\0/file").is_err());
314 }
315
316 #[test]
317 fn test_path_validation_traversal() {
318 let (policy, _temp) = create_test_policy();
319 assert!(policy.is_path_allowed("../etc/passwd").is_err());
320 assert!(policy.is_path_allowed("/path/../file").is_err());
321 }
322
323 #[test]
324 fn test_rate_limiting() {
325 let (policy, _temp) = create_test_policy();
326
327 for _ in 0..policy.config.max_actions_per_hour {
329 assert!(policy.try_record_action().is_ok());
330 }
331
332 assert!(policy.try_record_action().is_err());
334 }
335
336 #[test]
337 fn test_read_only_mode() {
338 let temp_dir = TempDir::new().unwrap();
339 let policy =
340 SecurityPolicy::from_level(temp_dir.path().to_path_buf(), SecurityLevel::Paranoid);
341
342 assert!(policy.is_read_only());
343 assert!(policy.can_act().is_err());
344 }
345
346 #[tokio::test]
347 async fn test_validate_path() {
348 let temp_dir = TempDir::new().unwrap();
349 let policy = SecurityPolicy::new(temp_dir.path().to_path_buf());
350
351 let test_file = temp_dir.path().join("test.txt");
353 tokio::fs::write(&test_file, "test").await.unwrap();
354
355 let result = policy.validate_path("test.txt").await;
357 assert!(result.is_ok());
358
359 let result = policy.validate_path("../test.txt").await;
361 assert!(result.is_err());
362 }
363}