Skip to main content

agent_diva_core/security/
policy.rs

1//! Security policy - unified security management
2//!
3//! This module provides the main SecurityPolicy struct that integrates
4//! configuration, path validation, and rate limiting into a cohesive
5//! security framework.
6
7use 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/// Unified security policy that coordinates all security checks
15#[derive(Debug, Clone)]
16pub struct SecurityPolicy {
17    /// Security configuration
18    config: SecurityConfig,
19    /// Action tracker for rate limiting
20    tracker: ActionTracker,
21    /// Workspace directory (base path for relative paths)
22    workspace_dir: PathBuf,
23}
24
25impl SecurityPolicy {
26    /// Create a new security policy with default configuration
27    pub fn new(workspace_dir: PathBuf) -> Self {
28        Self {
29            config: SecurityConfig::default(),
30            tracker: ActionTracker::new(),
31            workspace_dir,
32        }
33    }
34
35    /// Create a new security policy with custom configuration
36    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    /// Create a new security policy from a security level preset
45    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    /// Get the security configuration
54    pub fn config(&self) -> &SecurityConfig {
55        &self.config
56    }
57
58    /// Get the workspace directory
59    pub fn workspace_dir(&self) -> &Path {
60        &self.workspace_dir
61    }
62
63    /// Check if read-only mode is enabled
64    pub fn is_read_only(&self) -> bool {
65        self.config.is_read_only()
66    }
67
68    /// Check if shell access is allowed (always false in standard mode)
69    pub fn has_shell_access(&self) -> bool {
70        // Shell access is determined by security level
71        matches!(self.config.level, SecurityLevel::Permissive)
72    }
73
74    // ==================== Path Validation ====================
75
76    /// Layer 1-5: Basic path validation (before resolution)
77    ///
78    /// Checks for:
79    /// - Null bytes
80    /// - Path traversal (../)
81    /// - URL-encoded traversal
82    /// - Tilde expansion
83    /// - Forbidden prefixes
84    pub fn is_path_allowed(&self, path: &str) -> Result<(), SecurityError> {
85        // Layer 1: Null-byte detection
86        if PathValidator::contains_null_bytes(path) {
87            return Err(SecurityError::InvalidPathFormat {
88                reason: "Path contains null bytes".to_string(),
89            });
90        }
91
92        // Layer 2: Path traversal detection
93        if PathValidator::contains_path_traversal(path) {
94            return Err(SecurityError::ForbiddenComponent {
95                component: "parent directory (..)".to_string(),
96            });
97        }
98
99        // Layer 3: URL-encoded traversal detection
100        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        // Layer 4: Tilde expansion check
107        if PathValidator::starts_with_tilde(path) {
108            return Err(SecurityError::InvalidPathFormat {
109                reason: "Tilde expansion is not allowed".to_string(),
110            });
111        }
112
113        // Layer 5: Absolute path check (when workspace_only is enabled)
114        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        // Layer 6: Forbidden prefix check
121        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        // Layer 7: Forbidden extension check
130        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    /// Resolve a path relative to the workspace
140    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    /// Layer 8: Check if a resolved (canonicalized) path is within allowed roots
149    pub fn is_resolved_path_allowed(&self, resolved: &Path) -> bool {
150        // Try to canonicalize for comparison
151        let resolved_canonical = if let Ok(c) = resolved.canonicalize() {
152            c
153        } else {
154            resolved.to_path_buf()
155        };
156
157        // Check workspace directory
158        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        // Check additional allowed roots
169        PathValidator::is_within_allowed_roots(resolved, &self.config.allowed_roots)
170    }
171
172    /// Full path validation: from input to resolved path
173    pub async fn validate_path(&self, path: &str) -> Result<PathBuf, SecurityError> {
174        // Basic validation
175        self.is_path_allowed(path)?;
176
177        // Resolve the path
178        let full_path = self.resolve_path(path);
179
180        // Canonicalize and check if it exists
181        let resolved = match tokio::fs::canonicalize(&full_path).await {
182            Ok(p) => p,
183            Err(_) => full_path, // File may not exist yet (for write operations)
184        };
185
186        // Check if resolved path is within allowed workspace
187        if self.config.workspace_only && !self.is_resolved_path_allowed(&resolved) {
188            return Err(SecurityError::PathEscapesWorkspace { resolved });
189        }
190
191        // Check symlink restrictions
192        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    /// Validate parent directory for write operations (TOCTOU-safe)
204    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        // Create parent directories if needed
212        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        // Canonicalize parent after creation
219        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        // Validate resolved parent is within allowed workspace
226        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    // ==================== Rate Limiting ====================
236
237    /// Check if rate limit is exceeded (without recording)
238    pub fn is_rate_limited(&self) -> bool {
239        self.tracker
240            .is_rate_limited(self.config.max_actions_per_hour)
241    }
242
243    /// Record an action and return current count
244    pub fn record_action(&self) -> usize {
245        self.tracker.record()
246    }
247
248    /// Try to record an action, returning false if rate limited
249    ///
250    /// This is the main method for checking and recording in one step
251    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    /// Get current action count in the window
263    pub fn action_count(&self) -> usize {
264        self.tracker.count()
265    }
266
267    /// Check if can perform an action (rate limit + read-only check)
268    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    // ==================== File Size ====================
276
277    /// Check if file size is within limits
278    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
296/// Shared security policy (Arc wrapper for thread-safe sharing)
297pub 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        // Should be able to record actions up to limit
328        for _ in 0..policy.config.max_actions_per_hour {
329            assert!(policy.try_record_action().is_ok());
330        }
331
332        // Next action should fail
333        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        // Create a test file
352        let test_file = temp_dir.path().join("test.txt");
353        tokio::fs::write(&test_file, "test").await.unwrap();
354
355        // Should validate successfully
356        let result = policy.validate_path("test.txt").await;
357        assert!(result.is_ok());
358
359        // Should reject path traversal
360        let result = policy.validate_path("../test.txt").await;
361        assert!(result.is_err());
362    }
363}