1use serde::{Deserialize, Serialize};
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicU32, Ordering};
8use std::time::{Duration, Instant};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
12#[serde(rename_all = "snake_case")]
13pub enum AutonomyLevel {
14 ReadOnly,
16 #[default]
18 Supervised,
19 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#[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#[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#[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
140pub 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 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 pub fn default_for(workspace_dir: PathBuf) -> Self {
161 Self::new(PolicyConfig::default(), workspace_dir)
162 }
163
164 pub fn can_act(&self) -> bool {
166 !matches!(self.config.autonomy, AutonomyLevel::ReadOnly)
167 }
168
169 pub fn autonomy(&self) -> AutonomyLevel {
171 self.config.autonomy
172 }
173
174 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 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 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 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 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 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 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 let risk = self.assess_command_risk(command);
240
241 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 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 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 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 if path.contains("..") {
300 return ActionValidation::deny(RiskLevel::High, "Path traversal not allowed");
301 }
302
303 if path.starts_with('/') {
305 let path_buf = PathBuf::from(path);
306 if !path_buf.starts_with(&self.workspace_dir) {
307 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 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}