Skip to main content

agent_diva_core/security/
config.rs

1//! Security policy configuration
2
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5
6/// Security level presets
7#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "lowercase")]
9pub enum SecurityLevel {
10    /// Minimal restrictions (development only)
11    Permissive,
12    /// Standard restrictions with workspace limits (default)
13    #[default]
14    Standard,
15    /// Strict mode with additional validation
16    Strict,
17    /// Read-only mode, no modifications allowed
18    Paranoid,
19}
20
21impl SecurityLevel {
22    /// Get default max actions per hour for this level
23    pub fn default_max_actions_per_hour(&self) -> u32 {
24        match self {
25            Self::Permissive => 1000,
26            Self::Standard => 100,
27            Self::Strict => 50,
28            Self::Paranoid => 20,
29        }
30    }
31
32    /// Whether workspace_only is enforced by default
33    pub fn default_workspace_only(&self) -> bool {
34        match self {
35            Self::Permissive => false,
36            Self::Standard | Self::Strict | Self::Paranoid => true,
37        }
38    }
39
40    /// Whether read-only mode is enabled
41    pub fn is_read_only(&self) -> bool {
42        matches!(self, Self::Paranoid)
43    }
44}
45
46/// Security policy configuration
47#[derive(Debug, Clone, Serialize, Deserialize)]
48#[serde(default)]
49pub struct SecurityConfig {
50    /// Security level preset
51    pub level: SecurityLevel,
52
53    /// Whether to restrict operations to workspace only
54    pub workspace_only: bool,
55
56    /// Maximum file operations per hour
57    pub max_actions_per_hour: u32,
58
59    /// Forbidden path prefixes
60    pub forbidden_paths: Vec<String>,
61
62    /// Additional allowed roots outside workspace
63    pub allowed_roots: Vec<PathBuf>,
64
65    /// Forbidden file extensions
66    pub forbidden_extensions: Vec<String>,
67
68    /// Read-only mode (overrides level setting)
69    pub read_only: Option<bool>,
70
71    /// Maximum file size in bytes (0 = unlimited)
72    pub max_file_size: u64,
73
74    /// Enable symlink following
75    pub allow_symlinks: bool,
76}
77
78impl Default for SecurityConfig {
79    fn default() -> Self {
80        Self {
81            level: SecurityLevel::default(),
82            workspace_only: true,
83            max_actions_per_hour: 100,
84            forbidden_paths: vec![
85                "/etc".to_string(),
86                "/root".to_string(),
87                "/sys".to_string(),
88                "/proc".to_string(),
89                "~/.ssh".to_string(),
90                "~/.gnupg".to_string(),
91                "~/.aws".to_string(),
92            ],
93            allowed_roots: Vec::new(),
94            forbidden_extensions: vec![
95                ".exe".to_string(),
96                ".dll".to_string(),
97                ".bat".to_string(),
98                ".cmd".to_string(),
99                ".sh".to_string(),
100            ],
101            read_only: None,
102            max_file_size: 10 * 1024 * 1024, // 10MB
103            allow_symlinks: false,
104        }
105    }
106}
107
108impl SecurityConfig {
109    /// Create config from security level
110    pub fn from_level(level: SecurityLevel) -> Self {
111        Self {
112            level,
113            workspace_only: level.default_workspace_only(),
114            max_actions_per_hour: level.default_max_actions_per_hour(),
115            read_only: Some(level.is_read_only()),
116            ..Self::default()
117        }
118    }
119
120    /// Check if read-only mode is enabled
121    pub fn is_read_only(&self) -> bool {
122        self.read_only.unwrap_or_else(|| self.level.is_read_only())
123    }
124
125    /// Validate the configuration
126    pub fn validate(&self) -> Result<(), String> {
127        if self.max_actions_per_hour == 0 {
128            return Err("max_actions_per_hour must be greater than 0".to_string());
129        }
130
131        for path in &self.forbidden_paths {
132            if path.contains('\0') {
133                return Err(format!("Forbidden path contains null byte: {}", path));
134            }
135        }
136
137        Ok(())
138    }
139
140    /// Merge with another config (other takes precedence for non-default values)
141    pub fn merge(&mut self, other: SecurityConfig) {
142        if other.level != SecurityLevel::default() {
143            self.level = other.level;
144        }
145        if !other.forbidden_paths.is_empty() {
146            self.forbidden_paths = other.forbidden_paths;
147        }
148        if !other.allowed_roots.is_empty() {
149            self.allowed_roots = other.allowed_roots;
150        }
151        if other.max_file_size != 0 {
152            self.max_file_size = other.max_file_size;
153        }
154        if other.read_only.is_some() {
155            self.read_only = other.read_only;
156        }
157        // Note: max_actions_per_hour is set via level in SecurityConfig::from_level,
158        // but we also check for non-default values in direct merge
159        if other.max_actions_per_hour != SecurityLevel::default().default_max_actions_per_hour() {
160            self.max_actions_per_hour = other.max_actions_per_hour;
161        }
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn test_security_level_defaults() {
171        assert_eq!(
172            SecurityLevel::Permissive.default_max_actions_per_hour(),
173            1000
174        );
175        assert_eq!(SecurityLevel::Standard.default_max_actions_per_hour(), 100);
176        assert_eq!(SecurityLevel::Strict.default_max_actions_per_hour(), 50);
177        assert_eq!(SecurityLevel::Paranoid.default_max_actions_per_hour(), 20);
178
179        assert!(!SecurityLevel::Permissive.default_workspace_only());
180        assert!(SecurityLevel::Standard.default_workspace_only());
181        assert!(SecurityLevel::Paranoid.is_read_only());
182    }
183
184    #[test]
185    fn test_config_from_level() {
186        let config = SecurityConfig::from_level(SecurityLevel::Strict);
187        assert_eq!(config.max_actions_per_hour, 50);
188        assert!(config.workspace_only);
189        assert!(!config.is_read_only());
190
191        let config = SecurityConfig::from_level(SecurityLevel::Paranoid);
192        assert!(config.is_read_only());
193    }
194
195    #[test]
196    fn test_config_validation() {
197        let mut config = SecurityConfig::default();
198        assert!(config.validate().is_ok());
199
200        config.max_actions_per_hour = 0;
201        assert!(config.validate().is_err());
202    }
203
204    #[test]
205    fn test_config_merge() {
206        let mut base = SecurityConfig::default();
207        let other = SecurityConfig {
208            level: SecurityLevel::Strict,
209            max_actions_per_hour: 50,
210            ..Default::default()
211        };
212
213        base.merge(other);
214        assert_eq!(base.level, SecurityLevel::Strict);
215        assert_eq!(base.max_actions_per_hour, 50);
216    }
217}