Skip to main content

enact_security/
policy.rs

1//! Security policy for agent actions
2//!
3//! Defines autonomy levels, rate limiting, and action validation.
4
5use serde::{Deserialize, Serialize};
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicU32, Ordering};
8use std::time::{Duration, Instant};
9
10/// Autonomy level for agent actions
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
12#[serde(rename_all = "snake_case")]
13pub enum AutonomyLevel {
14    /// Read-only access, no modifications allowed
15    ReadOnly,
16    /// Supervised mode (default) - some actions require approval
17    #[default]
18    Supervised,
19    /// Full autonomy - all actions allowed within policy
20    Full,
21}
22
23impl AutonomyLevel {
24    pub fn as_str(&self) -> &'static str {
25        match self {
26            Self::ReadOnly => "read_only",
27            Self::Supervised => "supervised",
28            Self::Full => "full",
29        }
30    }
31
32    pub fn parse(s: &str) -> Self {
33        match s.to_lowercase().as_str() {
34            "read_only" | "readonly" | "read-only" => Self::ReadOnly,
35            "full" | "autonomous" => Self::Full,
36            _ => Self::Supervised,
37        }
38    }
39}
40
41/// Risk level for actions
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum RiskLevel {
45    Low,
46    Medium,
47    High,
48    Critical,
49}
50
51impl RiskLevel {
52    pub fn as_str(&self) -> &'static str {
53        match self {
54            Self::Low => "low",
55            Self::Medium => "medium",
56            Self::High => "high",
57            Self::Critical => "critical",
58        }
59    }
60}
61
62/// Action validation result
63#[derive(Debug, Clone)]
64pub struct ActionValidation {
65    pub allowed: bool,
66    pub risk_level: RiskLevel,
67    pub reason: Option<String>,
68    pub requires_approval: bool,
69}
70
71impl ActionValidation {
72    pub fn allow(risk_level: RiskLevel) -> Self {
73        Self {
74            allowed: true,
75            risk_level,
76            reason: None,
77            requires_approval: false,
78        }
79    }
80
81    pub fn deny(risk_level: RiskLevel, reason: impl Into<String>) -> Self {
82        Self {
83            allowed: false,
84            risk_level,
85            reason: Some(reason.into()),
86            requires_approval: false,
87        }
88    }
89
90    pub fn needs_approval(risk_level: RiskLevel, reason: impl Into<String>) -> Self {
91        Self {
92            allowed: false,
93            risk_level,
94            reason: Some(reason.into()),
95            requires_approval: true,
96        }
97    }
98}
99
100/// Security policy configuration
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct PolicyConfig {
103    pub autonomy: AutonomyLevel,
104    pub max_actions_per_hour: u32,
105    pub allowed_commands: Vec<String>,
106    pub blocked_commands: Vec<String>,
107    pub blocked_patterns: Vec<String>,
108    pub allowed_paths: Vec<String>,
109    pub blocked_paths: Vec<String>,
110    pub require_approval_for: Vec<String>,
111}
112
113impl Default for PolicyConfig {
114    fn default() -> Self {
115        Self {
116            autonomy: AutonomyLevel::Supervised,
117            max_actions_per_hour: 1000,
118            allowed_commands: Vec::new(),
119            blocked_commands: vec![
120                "rm -rf /".into(),
121                "sudo".into(),
122                "chmod 777".into(),
123                "dd if=".into(),
124                "> /dev/".into(),
125            ],
126            blocked_patterns: vec!["curl | sh".into(), "wget | sh".into(), "$(.*rm.*)".into()],
127            allowed_paths: Vec::new(),
128            blocked_paths: vec![
129                "/etc".into(),
130                "/var".into(),
131                "/usr".into(),
132                "/sys".into(),
133                "/proc".into(),
134            ],
135            require_approval_for: vec!["rm".into(), "mv".into(), "chmod".into(), "chown".into()],
136        }
137    }
138}
139
140/// Security policy with runtime state
141pub struct SecurityPolicy {
142    pub config: PolicyConfig,
143    pub workspace_dir: PathBuf,
144    action_count: AtomicU32,
145    last_reset: std::sync::Mutex<Instant>,
146}
147
148impl SecurityPolicy {
149    /// Create a new security policy
150    pub fn new(config: PolicyConfig, workspace_dir: PathBuf) -> Self {
151        Self {
152            config,
153            workspace_dir,
154            action_count: AtomicU32::new(0),
155            last_reset: std::sync::Mutex::new(Instant::now()),
156        }
157    }
158
159    /// Create with default config
160    pub fn default_for(workspace_dir: PathBuf) -> Self {
161        Self::new(PolicyConfig::default(), workspace_dir)
162    }
163
164    /// Check if the agent can perform write actions
165    pub fn can_act(&self) -> bool {
166        !matches!(self.config.autonomy, AutonomyLevel::ReadOnly)
167    }
168
169    /// Get autonomy level
170    pub fn autonomy(&self) -> AutonomyLevel {
171        self.config.autonomy
172    }
173
174    /// Check and reset rate limit if hour has passed
175    fn check_rate_limit_reset(&self) {
176        let mut last_reset = self.last_reset.lock().unwrap();
177        if last_reset.elapsed() >= Duration::from_secs(3600) {
178            self.action_count.store(0, Ordering::Relaxed);
179            *last_reset = Instant::now();
180        }
181    }
182
183    /// Check if rate limited
184    pub fn is_rate_limited(&self) -> bool {
185        if self.config.max_actions_per_hour == 0 {
186            return false;
187        }
188        self.check_rate_limit_reset();
189        self.action_count.load(Ordering::Relaxed) >= self.config.max_actions_per_hour
190    }
191
192    /// Record an action and return true if allowed
193    pub fn record_action(&self) -> bool {
194        if self.config.max_actions_per_hour == 0 {
195            return true;
196        }
197        self.check_rate_limit_reset();
198        let current = self.action_count.fetch_add(1, Ordering::Relaxed);
199        current < self.config.max_actions_per_hour
200    }
201
202    /// Get remaining actions this hour
203    pub fn remaining_actions(&self) -> u32 {
204        if self.config.max_actions_per_hour == 0 {
205            return u32::MAX;
206        }
207        self.check_rate_limit_reset();
208        let used = self.action_count.load(Ordering::Relaxed);
209        self.config.max_actions_per_hour.saturating_sub(used)
210    }
211
212    /// Validate a command execution
213    pub fn validate_command(&self, command: &str, approved: bool) -> ActionValidation {
214        if !self.can_act() {
215            return ActionValidation::deny(RiskLevel::Low, "Action blocked: autonomy is read-only");
216        }
217
218        // Check blocked commands
219        for blocked in &self.config.blocked_commands {
220            if command.contains(blocked) {
221                return ActionValidation::deny(
222                    RiskLevel::Critical,
223                    format!("Command blocked by security policy: {blocked}"),
224                );
225            }
226        }
227
228        // Check blocked patterns
229        for pattern in &self.config.blocked_patterns {
230            if command.contains(pattern) {
231                return ActionValidation::deny(
232                    RiskLevel::High,
233                    format!("Command matches blocked pattern: {pattern}"),
234                );
235            }
236        }
237
238        // Determine risk level
239        let risk = self.assess_command_risk(command);
240
241        // Check if approval is required
242        if self.config.autonomy == AutonomyLevel::Supervised && !approved {
243            let cmd_name = command.split_whitespace().next().unwrap_or("");
244            if self
245                .config
246                .require_approval_for
247                .iter()
248                .any(|c| c == cmd_name)
249            {
250                return ActionValidation::needs_approval(
251                    risk,
252                    format!("Command '{cmd_name}' requires explicit approval in supervised mode"),
253                );
254            }
255        }
256
257        // Check allowed commands (if configured)
258        if !self.config.allowed_commands.is_empty() {
259            let cmd_name = command.split_whitespace().next().unwrap_or("");
260            if !self.config.allowed_commands.iter().any(|c| c == cmd_name) {
261                return ActionValidation::deny(
262                    risk,
263                    format!("Command '{cmd_name}' not in allowed commands list"),
264                );
265            }
266        }
267
268        ActionValidation::allow(risk)
269    }
270
271    /// Assess risk level of a command
272    fn assess_command_risk(&self, command: &str) -> RiskLevel {
273        let high_risk = ["rm -rf", "mkfs", "dd if=", "sudo", "chmod 777"];
274        for pattern in high_risk {
275            if command.contains(pattern) {
276                return RiskLevel::Critical;
277            }
278        }
279
280        let medium_risk = ["rm", "mv", "cp", "chmod", "chown", "kill"];
281        let cmd_name = command.split_whitespace().next().unwrap_or("");
282        if medium_risk.contains(&cmd_name) {
283            return RiskLevel::Medium;
284        }
285
286        RiskLevel::Low
287    }
288
289    /// Validate a file path access
290    pub fn validate_path(&self, path: &str, write: bool) -> ActionValidation {
291        if write && !self.can_act() {
292            return ActionValidation::deny(
293                RiskLevel::Low,
294                "Write access blocked: autonomy is read-only",
295            );
296        }
297
298        // Block path traversal
299        if path.contains("..") {
300            return ActionValidation::deny(RiskLevel::High, "Path traversal not allowed");
301        }
302
303        // Block absolute paths outside workspace
304        if path.starts_with('/') {
305            let path_buf = PathBuf::from(path);
306            if !path_buf.starts_with(&self.workspace_dir) {
307                // Check against blocked paths
308                for blocked in &self.config.blocked_paths {
309                    if path.starts_with(blocked) {
310                        return ActionValidation::deny(
311                            RiskLevel::High,
312                            format!("Access to {blocked} is blocked"),
313                        );
314                    }
315                }
316            }
317        }
318
319        let risk = if write {
320            RiskLevel::Medium
321        } else {
322            RiskLevel::Low
323        };
324        ActionValidation::allow(risk)
325    }
326
327    /// Check if a resolved path is within workspace
328    pub fn is_path_in_workspace(&self, resolved: &Path) -> bool {
329        resolved.starts_with(&self.workspace_dir)
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    fn test_policy() -> SecurityPolicy {
338        SecurityPolicy::default_for(PathBuf::from("/tmp/workspace"))
339    }
340
341    #[test]
342    fn autonomy_level_parse() {
343        assert_eq!(AutonomyLevel::parse("read_only"), AutonomyLevel::ReadOnly);
344        assert_eq!(AutonomyLevel::parse("full"), AutonomyLevel::Full);
345        assert_eq!(
346            AutonomyLevel::parse("supervised"),
347            AutonomyLevel::Supervised
348        );
349        assert_eq!(AutonomyLevel::parse("unknown"), AutonomyLevel::Supervised);
350    }
351
352    #[test]
353    fn can_act_respects_autonomy() {
354        let mut policy = test_policy();
355        assert!(policy.can_act());
356
357        policy.config.autonomy = AutonomyLevel::ReadOnly;
358        assert!(!policy.can_act());
359    }
360
361    #[test]
362    fn validate_command_blocks_dangerous() {
363        let policy = test_policy();
364        let result = policy.validate_command("rm -rf /", false);
365        assert!(!result.allowed);
366        assert_eq!(result.risk_level, RiskLevel::Critical);
367    }
368
369    #[test]
370    fn validate_command_needs_approval() {
371        let policy = test_policy();
372        let result = policy.validate_command("rm file.txt", false);
373        assert!(!result.allowed);
374        assert!(result.requires_approval);
375    }
376
377    #[test]
378    fn validate_command_allows_with_approval() {
379        let policy = test_policy();
380        let result = policy.validate_command("rm file.txt", true);
381        assert!(result.allowed);
382    }
383
384    #[test]
385    fn validate_path_blocks_traversal() {
386        let policy = test_policy();
387        let result = policy.validate_path("../etc/passwd", false);
388        assert!(!result.allowed);
389    }
390
391    #[test]
392    fn rate_limiting_works() {
393        let config = PolicyConfig {
394            max_actions_per_hour: 2,
395            ..PolicyConfig::default()
396        };
397        let policy = SecurityPolicy::new(config, PathBuf::from("/tmp"));
398
399        assert!(!policy.is_rate_limited());
400        assert!(policy.record_action());
401        assert!(policy.record_action());
402        assert!(!policy.record_action());
403        assert!(policy.is_rate_limited());
404    }
405}