Skip to main content

selfware/safety/
yolo.rs

1//! YOLO Mode - Fully autonomous operation without confirmations
2//!
3//! Enables the agent to run for extended periods (hours/days) without
4//! requiring user intervention. All confirmations are auto-approved
5//! with comprehensive audit logging.
6
7// Feature-gated module
8
9use chrono::{DateTime, Utc};
10use once_cell::sync::Lazy;
11use regex::Regex;
12use serde::{Deserialize, Serialize};
13use std::fs::{self, OpenOptions};
14use std::io::Write;
15use std::path::PathBuf;
16use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
17use std::sync::RwLock;
18
19/// YOLO mode configuration
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct YoloConfig {
22    /// Whether YOLO mode is enabled
23    pub enabled: bool,
24    /// Maximum operations before requiring check-in (0 = unlimited)
25    pub max_operations: usize,
26    /// Maximum time in hours before requiring check-in (0 = unlimited)
27    pub max_hours: f64,
28    /// Operations that are NEVER auto-approved even in YOLO mode
29    pub forbidden_operations: Vec<String>,
30    /// Paths that should never be modified
31    pub protected_paths: Vec<String>,
32    /// Whether to allow git push operations
33    pub allow_git_push: bool,
34    /// Whether to allow destructive shell commands
35    pub allow_destructive_shell: bool,
36    /// Audit log file path
37    pub audit_log_path: Option<PathBuf>,
38    /// Send periodic status updates (every N operations)
39    pub status_interval: usize,
40}
41
42impl Default for YoloConfig {
43    fn default() -> Self {
44        Self {
45            enabled: false,
46            max_operations: 0, // Unlimited
47            max_hours: 0.0,    // Unlimited
48            forbidden_operations: vec![
49                // These are NEVER auto-approved
50                "rm -rf /".to_string(),
51                "rm -rf /*".to_string(),
52                "dd if=/dev/zero".to_string(),
53                "mkfs".to_string(),
54                "> /dev/sda".to_string(),
55                "chmod -R 777 /".to_string(),
56            ],
57            protected_paths: vec![
58                "/etc".to_string(),
59                "/usr".to_string(),
60                "/bin".to_string(),
61                "/sbin".to_string(),
62                "/boot".to_string(),
63                "/root".to_string(),
64                "~/.ssh".to_string(),
65                "~/.gnupg".to_string(),
66            ],
67            allow_git_push: true,
68            // SAFETY: Default to false - destructive commands require explicit opt-in
69            allow_destructive_shell: false,
70            audit_log_path: None,
71            status_interval: 100,
72        }
73    }
74}
75
76impl YoloConfig {
77    /// Create a YOLO config with sensible defaults for autonomous coding
78    ///
79    /// This enables autonomous operation for most coding tasks while
80    /// requiring confirmation for destructive shell commands.
81    pub fn for_coding() -> Self {
82        Self {
83            enabled: true,
84            allow_git_push: false,          // Require explicit push
85            allow_destructive_shell: false, // Safer default - require confirmation for rm, etc.
86            status_interval: 50,
87            ..Default::default()
88        }
89    }
90
91    /// Create a fully autonomous config for long-running unattended operations
92    ///
93    /// IMPORTANT: This still disallows destructive shell commands by default.
94    /// Use `with_destructive_shell(true)` if you explicitly need that capability.
95    ///
96    /// # Safety
97    /// Even in fully autonomous mode, certain operations are never auto-approved:
98    /// - Commands in the `forbidden_operations` list
99    /// - Modifications to `protected_paths`
100    pub fn fully_autonomous() -> Self {
101        Self {
102            enabled: true,
103            allow_git_push: true,
104            allow_destructive_shell: false, // Safer default - use with_destructive_shell() to enable
105            status_interval: 100,
106            ..Default::default()
107        }
108    }
109
110    /// Builder method to explicitly enable destructive shell commands
111    ///
112    /// # Warning
113    /// This allows commands like `rm -rf`, `git reset --hard`, etc.
114    /// Only use this if you understand the risks and have proper backups.
115    pub fn with_destructive_shell(mut self, allow: bool) -> Self {
116        self.allow_destructive_shell = allow;
117        self
118    }
119
120    /// Builder method to enable/disable git push
121    pub fn with_git_push(mut self, allow: bool) -> Self {
122        self.allow_git_push = allow;
123        self
124    }
125
126    /// Check if an operation is forbidden
127    ///
128    /// Uses regex word-boundary matching with whitespace normalization to prevent
129    /// bypass via extra whitespace, backslash escapes, or other trivial variations.
130    pub fn is_forbidden(&self, operation: &str) -> bool {
131        let normalized = normalize_input(operation);
132        self.forbidden_operations.iter().any(|f| {
133            let pattern = build_boundary_pattern(f);
134            Regex::new(&pattern)
135                .map(|re| re.is_match(&normalized))
136                .unwrap_or(false)
137        })
138    }
139
140    /// Check if a path is protected
141    pub fn is_protected_path(&self, path: &str) -> bool {
142        let expanded = expand_home(path);
143        self.protected_paths.iter().any(|p| {
144            let protected = expand_home(p);
145            expanded.starts_with(&protected) || expanded == protected
146        })
147    }
148}
149
150/// Normalize input for security matching.
151///
152/// Collapses multiple whitespace characters into a single space and trims
153/// leading/trailing whitespace. This prevents bypass via extra spacing.
154fn normalize_input(input: &str) -> String {
155    static WS_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\s+").unwrap());
156    WS_RE.replace_all(input.trim(), " ").to_lowercase()
157}
158
159/// Build a regex pattern with word boundaries from a literal forbidden string.
160///
161/// - Splits the pattern on whitespace and escapes each token for regex
162/// - Joins tokens with `\s+` for flexible whitespace matching
163/// - Adds `\b` word boundaries at start/end when the boundary character is
164///   a word character (alphanumeric or underscore), preventing partial-word matches
165fn build_boundary_pattern(pattern: &str) -> String {
166    let trimmed = pattern.trim().to_lowercase();
167    let tokens: Vec<&str> = trimmed.split_whitespace().collect();
168    if tokens.is_empty() {
169        return String::new();
170    }
171
172    // Escape each token individually, then join with flexible whitespace
173    let escaped_tokens: Vec<String> = tokens.iter().map(|t| regex::escape(t)).collect();
174    let flexible = escaped_tokens.join(r"\s+");
175
176    // Add word boundaries where appropriate based on the original (unescaped) tokens
177    let first_char = tokens.first().and_then(|t| t.chars().next());
178    let last_char = tokens.last().and_then(|t| t.chars().last());
179
180    let prefix = if first_char.is_some_and(|c| c.is_alphanumeric() || c == '_') {
181        r"\b"
182    } else {
183        ""
184    };
185    let suffix = if last_char.is_some_and(|c| c.is_alphanumeric() || c == '_') {
186        r"\b"
187    } else {
188        ""
189    };
190
191    format!("(?i){}{}{}", prefix, flexible, suffix)
192}
193
194/// Expand ~ to home directory
195fn expand_home(path: &str) -> String {
196    if path.starts_with("~/") {
197        if let Some(home) = std::env::var_os("HOME") {
198            return format!("{}{}", home.to_string_lossy(), &path[1..]);
199        }
200    }
201    path.to_string()
202}
203
204/// Audit log entry for YOLO mode operations
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct AuditEntry {
207    pub timestamp: DateTime<Utc>,
208    pub operation_id: usize,
209    pub tool_name: String,
210    pub arguments_summary: String,
211    pub auto_approved: bool,
212    pub result: AuditResult,
213    pub duration_ms: u64,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub enum AuditResult {
218    Success,
219    Failed(String),
220    Blocked(String),
221}
222
223/// YOLO mode manager
224pub struct YoloManager {
225    config: YoloConfig,
226    enabled: AtomicBool,
227    operation_count: AtomicUsize,
228    start_time: RwLock<Option<std::time::Instant>>,
229    audit_log: RwLock<Vec<AuditEntry>>,
230}
231
232impl YoloManager {
233    /// Create a new YOLO manager
234    pub fn new(config: YoloConfig) -> Self {
235        let enabled = config.enabled;
236        Self {
237            config,
238            enabled: AtomicBool::new(enabled),
239            operation_count: AtomicUsize::new(0),
240            start_time: RwLock::new(if enabled {
241                Some(std::time::Instant::now())
242            } else {
243                None
244            }),
245            audit_log: RwLock::new(Vec::new()),
246        }
247    }
248
249    /// Check if YOLO mode is currently active
250    pub fn is_active(&self) -> bool {
251        if !self.enabled.load(Ordering::SeqCst) {
252            return false;
253        }
254
255        // Check operation limit
256        if self.config.max_operations > 0
257            && self.operation_count.load(Ordering::SeqCst) >= self.config.max_operations
258        {
259            return false;
260        }
261
262        // Check time limit
263        if self.config.max_hours > 0.0 {
264            if let Ok(start) = self.start_time.read() {
265                if let Some(start_time) = *start {
266                    let hours = start_time.elapsed().as_secs_f64() / 3600.0;
267                    if hours >= self.config.max_hours {
268                        return false;
269                    }
270                }
271            }
272        }
273
274        true
275    }
276
277    /// Enable YOLO mode
278    pub fn enable(&self) {
279        self.enabled.store(true, Ordering::SeqCst);
280        if let Ok(mut start) = self.start_time.write() {
281            *start = Some(std::time::Instant::now());
282        }
283        self.operation_count.store(0, Ordering::SeqCst);
284    }
285
286    /// Disable YOLO mode
287    pub fn disable(&self) {
288        self.enabled.store(false, Ordering::SeqCst);
289    }
290
291    /// Check if an operation should be auto-approved
292    pub fn should_auto_approve(&self, tool_name: &str, args: &serde_json::Value) -> YoloDecision {
293        if !self.is_active() {
294            return YoloDecision::RequireConfirmation("YOLO mode not active".to_string());
295        }
296
297        // Check forbidden operations
298        let args_str = serde_json::to_string(args).unwrap_or_default();
299        if self.config.is_forbidden(&args_str) {
300            return YoloDecision::Block("Operation is in forbidden list".to_string());
301        }
302
303        // Check protected paths
304        if let Some(path) = extract_path(args) {
305            if self.config.is_protected_path(&path) {
306                return YoloDecision::Block(format!("Path '{}' is protected", path));
307            }
308        }
309
310        // Check git push
311        if tool_name == "git_push" && !self.config.allow_git_push {
312            return YoloDecision::RequireConfirmation("Git push requires confirmation".to_string());
313        }
314
315        // Check destructive shell commands
316        if tool_name == "shell_exec" {
317            if let Some(cmd) = args.get("command").and_then(|c| c.as_str()) {
318                if is_destructive_command(cmd) && !self.config.allow_destructive_shell {
319                    return YoloDecision::RequireConfirmation(
320                        "Destructive shell command requires confirmation".to_string(),
321                    );
322                }
323            }
324        }
325
326        YoloDecision::AutoApprove
327    }
328
329    /// Record an operation in the audit log
330    pub fn record_operation(
331        &self,
332        tool_name: &str,
333        args: &serde_json::Value,
334        auto_approved: bool,
335        result: AuditResult,
336        duration_ms: u64,
337    ) {
338        let op_id = self.operation_count.fetch_add(1, Ordering::SeqCst);
339
340        let entry = AuditEntry {
341            timestamp: Utc::now(),
342            operation_id: op_id,
343            tool_name: tool_name.to_string(),
344            arguments_summary: summarize_args(args),
345            auto_approved,
346            result,
347            duration_ms,
348        };
349
350        // Acquire file lock so in-memory push and file write are atomic.
351        let _file_guard = if self.config.audit_log_path.is_some() {
352            Some(AUDIT_FILE_LOCK.lock().unwrap_or_else(|e| e.into_inner()))
353        } else {
354            None
355        };
356
357        // Add to in-memory log
358        if let Ok(mut log) = self.audit_log.write() {
359            log.push(entry.clone());
360        }
361
362        // Write to file if configured
363        if let Some(ref path) = self.config.audit_log_path {
364            let _ = append_to_audit_file(path, &entry);
365        }
366
367        // Print status update at intervals
368        if self.config.status_interval > 0
369            && op_id > 0
370            && op_id.is_multiple_of(self.config.status_interval)
371        {
372            self.print_status();
373        }
374    }
375
376    /// Get the current operation count
377    pub fn operation_count(&self) -> usize {
378        self.operation_count.load(Ordering::SeqCst)
379    }
380
381    /// Get elapsed time in hours
382    pub fn elapsed_hours(&self) -> f64 {
383        if let Ok(start) = self.start_time.read() {
384            if let Some(start_time) = *start {
385                return start_time.elapsed().as_secs_f64() / 3600.0;
386            }
387        }
388        0.0
389    }
390
391    /// Print a status update
392    pub fn print_status(&self) {
393        let ops = self.operation_count();
394        let hours = self.elapsed_hours();
395        let success_count = self
396            .audit_log
397            .read()
398            .map(|log| {
399                log.iter()
400                    .filter(|e| matches!(e.result, AuditResult::Success))
401                    .count()
402            })
403            .unwrap_or(0);
404        let failed_count = self
405            .audit_log
406            .read()
407            .map(|log| {
408                log.iter()
409                    .filter(|e| matches!(e.result, AuditResult::Failed(_)))
410                    .count()
411            })
412            .unwrap_or(0);
413
414        eprintln!("\n╔══════════════════════════════════════╗");
415        eprintln!("║      YOLO MODE STATUS UPDATE         ║");
416        eprintln!("╠══════════════════════════════════════╣");
417        eprintln!("║ Operations: {:<6} | Time: {:.1}h      ║", ops, hours);
418        eprintln!(
419            "║ Success: {:<4} | Failed: {:<4}         ║",
420            success_count, failed_count
421        );
422        eprintln!("╚══════════════════════════════════════╝\n");
423    }
424
425    /// Get audit log summary
426    pub fn audit_summary(&self) -> AuditSummary {
427        let log = self.audit_log.read().unwrap_or_else(|e| e.into_inner());
428
429        let mut tools_used: std::collections::HashMap<String, usize> =
430            std::collections::HashMap::new();
431        let mut success = 0;
432        let mut failed = 0;
433        let mut blocked = 0;
434        let mut total_duration_ms = 0u64;
435
436        for entry in log.iter() {
437            *tools_used.entry(entry.tool_name.clone()).or_insert(0) += 1;
438            total_duration_ms += entry.duration_ms;
439            match &entry.result {
440                AuditResult::Success => success += 1,
441                AuditResult::Failed(_) => failed += 1,
442                AuditResult::Blocked(_) => blocked += 1,
443            }
444        }
445
446        AuditSummary {
447            total_operations: log.len(),
448            success,
449            failed,
450            blocked,
451            tools_used,
452            total_duration_ms,
453            elapsed_hours: self.elapsed_hours(),
454        }
455    }
456
457    /// Export audit log to file
458    pub fn export_audit_log(&self, path: &std::path::Path) -> std::io::Result<()> {
459        let log = self.audit_log.read().unwrap_or_else(|e| e.into_inner());
460        let json = serde_json::to_string_pretty(&*log).unwrap_or_default();
461        fs::write(path, json)
462    }
463}
464
465/// Decision from YOLO mode check
466#[derive(Debug, Clone, PartialEq)]
467pub enum YoloDecision {
468    /// Auto-approve the operation
469    AutoApprove,
470    /// Require user confirmation with reason
471    RequireConfirmation(String),
472    /// Block the operation entirely
473    Block(String),
474}
475
476/// Summary of audit log
477#[derive(Debug, Clone, Serialize)]
478pub struct AuditSummary {
479    pub total_operations: usize,
480    pub success: usize,
481    pub failed: usize,
482    pub blocked: usize,
483    pub tools_used: std::collections::HashMap<String, usize>,
484    pub total_duration_ms: u64,
485    pub elapsed_hours: f64,
486}
487
488impl std::fmt::Display for AuditSummary {
489    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
490        writeln!(f, "YOLO Mode Audit Summary")?;
491        writeln!(f, "======================")?;
492        writeln!(f, "Total Operations: {}", self.total_operations)?;
493        writeln!(f, "  - Success: {}", self.success)?;
494        writeln!(f, "  - Failed: {}", self.failed)?;
495        writeln!(f, "  - Blocked: {}", self.blocked)?;
496        writeln!(f, "Elapsed Time: {:.2} hours", self.elapsed_hours)?;
497        writeln!(
498            f,
499            "Total Duration: {:.1}s",
500            self.total_duration_ms as f64 / 1000.0
501        )?;
502        writeln!(f, "\nTools Used:")?;
503        for (tool, count) in &self.tools_used {
504            writeln!(f, "  - {}: {}", tool, count)?;
505        }
506        Ok(())
507    }
508}
509
510/// Extract path from tool arguments (recursively)
511fn extract_path(args: &serde_json::Value) -> Option<String> {
512    match args {
513        serde_json::Value::Object(map) => {
514            for (k, v) in map {
515                if k == "path" || k == "file" || k == "directory" {
516                    if let Some(s) = v.as_str() {
517                        return Some(s.to_string());
518                    }
519                }
520                if let Some(res) = extract_path(v) {
521                    return Some(res);
522                }
523            }
524            None
525        }
526        serde_json::Value::Array(arr) => {
527            for v in arr {
528                if let Some(res) = extract_path(v) {
529                    return Some(res);
530                }
531            }
532            None
533        }
534        _ => None,
535    }
536}
537
538/// Pre-compiled regexes for destructive command detection.
539///
540/// Each pattern uses word-boundary matching and flexible whitespace to prevent
541/// bypass via extra spacing, backslash insertion, or other trivial variations.
542static DESTRUCTIVE_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
543    let patterns = [
544        "rm -rf",
545        "rm -r",
546        "rmdir",
547        "git push -f",
548        "git push --force",
549        "git reset --hard",
550        "git clean -f",
551        "DROP TABLE",
552        "DROP DATABASE",
553        "DELETE FROM",
554        "TRUNCATE",
555        "> /dev/",
556        "dd if=",
557    ];
558    patterns
559        .iter()
560        .filter_map(|p| {
561            let re_pattern = build_boundary_pattern(p);
562            Regex::new(&re_pattern).ok()
563        })
564        .collect()
565});
566
567/// Check if a shell command is destructive
568fn is_destructive_command(cmd: &str) -> bool {
569    let normalized = normalize_input(cmd);
570    DESTRUCTIVE_PATTERNS
571        .iter()
572        .any(|re| re.is_match(&normalized))
573}
574
575/// Summarize arguments for audit log (truncate long values)
576fn summarize_args(args: &serde_json::Value) -> String {
577    let mut summary = serde_json::Map::new();
578
579    if let Some(obj) = args.as_object() {
580        for (key, value) in obj {
581            let summarized = match value {
582                serde_json::Value::String(s) if s.len() > 100 => {
583                    serde_json::Value::String(format!(
584                        "{}... ({} chars)",
585                        s.chars().take(100).collect::<String>(),
586                        s.len()
587                    ))
588                }
589                other => other.clone(),
590            };
591            summary.insert(key.clone(), summarized);
592        }
593    }
594
595    serde_json::to_string(&summary).unwrap_or_else(|_| "{}".to_string())
596}
597
598/// Append an audit entry to file.
599///
600/// Thread-safety: the caller serialises access through AUDIT_FILE_LOCK.
601fn append_to_audit_file(path: &PathBuf, entry: &AuditEntry) -> std::io::Result<()> {
602    let json = serde_json::to_string(entry).unwrap_or_default();
603    let line = format!("{}\n", json);
604
605    let mut file = OpenOptions::new().create(true).append(true).open(path)?;
606    file.write_all(line.as_bytes())?;
607    file.flush()
608}
609
610/// Global mutex protecting audit file writes from concurrent threads.
611static AUDIT_FILE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616
617    #[test]
618    fn test_yolo_config_default() {
619        let config = YoloConfig::default();
620        assert!(!config.enabled);
621        assert!(config.allow_git_push);
622    }
623
624    #[test]
625    fn test_yolo_config_for_coding() {
626        let config = YoloConfig::for_coding();
627        assert!(config.enabled);
628        assert!(!config.allow_git_push); // Safer default
629    }
630
631    #[test]
632    fn test_is_forbidden() {
633        let config = YoloConfig::default();
634        assert!(config.is_forbidden("rm -rf /"));
635        assert!(config.is_forbidden("sudo rm -rf /"));
636        assert!(!config.is_forbidden("rm file.txt"));
637    }
638
639    #[test]
640    fn test_is_protected_path() {
641        let config = YoloConfig::default();
642        assert!(config.is_protected_path("/etc/passwd"));
643        assert!(config.is_protected_path("/usr/bin/bash"));
644        assert!(!config.is_protected_path("/home/user/project"));
645    }
646
647    #[test]
648    fn test_yolo_manager_inactive_by_default() {
649        let config = YoloConfig::default();
650        let manager = YoloManager::new(config);
651        assert!(!manager.is_active());
652    }
653
654    #[test]
655    fn test_yolo_manager_enable_disable() {
656        let config = YoloConfig {
657            enabled: true,
658            ..Default::default()
659        };
660        let manager = YoloManager::new(config);
661
662        assert!(manager.is_active());
663        manager.disable();
664        assert!(!manager.is_active());
665        manager.enable();
666        assert!(manager.is_active());
667    }
668
669    #[test]
670    fn test_auto_approve_when_active() {
671        let config = YoloConfig::fully_autonomous();
672        let manager = YoloManager::new(config);
673
674        let args = serde_json::json!({"path": "/home/user/test.txt"});
675        let decision = manager.should_auto_approve("file_read", &args);
676
677        assert_eq!(decision, YoloDecision::AutoApprove);
678    }
679
680    #[test]
681    fn test_block_forbidden_operation() {
682        let config = YoloConfig::fully_autonomous();
683        let manager = YoloManager::new(config);
684
685        let args = serde_json::json!({"command": "rm -rf /"});
686        let decision = manager.should_auto_approve("shell_exec", &args);
687
688        assert!(matches!(decision, YoloDecision::Block(_)));
689    }
690
691    #[test]
692    fn test_block_protected_path() {
693        let config = YoloConfig::fully_autonomous();
694        let manager = YoloManager::new(config);
695
696        let args = serde_json::json!({"path": "/etc/passwd"});
697        let decision = manager.should_auto_approve("file_write", &args);
698
699        assert!(matches!(decision, YoloDecision::Block(_)));
700    }
701
702    #[test]
703    fn test_require_confirmation_git_push() {
704        let config = YoloConfig::for_coding(); // git push disabled
705        let manager = YoloManager::new(config);
706
707        let args = serde_json::json!({"branch": "main"});
708        let decision = manager.should_auto_approve("git_push", &args);
709
710        assert!(matches!(decision, YoloDecision::RequireConfirmation(_)));
711    }
712
713    #[test]
714    fn test_operation_counting() {
715        let config = YoloConfig::fully_autonomous();
716        let manager = YoloManager::new(config);
717
718        assert_eq!(manager.operation_count(), 0);
719
720        manager.record_operation(
721            "file_read",
722            &serde_json::json!({"path": "test.txt"}),
723            true,
724            AuditResult::Success,
725            100,
726        );
727
728        assert_eq!(manager.operation_count(), 1);
729    }
730
731    #[test]
732    fn test_max_operations_limit() {
733        let mut config = YoloConfig::fully_autonomous();
734        config.max_operations = 2;
735        let manager = YoloManager::new(config);
736
737        assert!(manager.is_active());
738
739        manager.record_operation("t1", &serde_json::json!({}), true, AuditResult::Success, 0);
740        assert!(manager.is_active());
741
742        manager.record_operation("t2", &serde_json::json!({}), true, AuditResult::Success, 0);
743        assert!(!manager.is_active()); // Limit reached
744    }
745
746    #[test]
747    fn test_audit_summary() {
748        let config = YoloConfig::fully_autonomous();
749        let manager = YoloManager::new(config);
750
751        manager.record_operation(
752            "file_read",
753            &serde_json::json!({}),
754            true,
755            AuditResult::Success,
756            50,
757        );
758        manager.record_operation(
759            "file_write",
760            &serde_json::json!({}),
761            true,
762            AuditResult::Success,
763            100,
764        );
765        manager.record_operation(
766            "shell_exec",
767            &serde_json::json!({}),
768            true,
769            AuditResult::Failed("error".to_string()),
770            200,
771        );
772
773        let summary = manager.audit_summary();
774
775        assert_eq!(summary.total_operations, 3);
776        assert_eq!(summary.success, 2);
777        assert_eq!(summary.failed, 1);
778        assert_eq!(summary.total_duration_ms, 350);
779    }
780
781    #[test]
782    fn test_is_destructive_command() {
783        assert!(is_destructive_command("rm -rf /tmp/test"));
784        assert!(is_destructive_command("git push --force"));
785        assert!(is_destructive_command("DROP TABLE users"));
786        assert!(!is_destructive_command("ls -la"));
787        assert!(!is_destructive_command("cargo test"));
788    }
789
790    #[test]
791    fn test_summarize_args_truncates() {
792        let long_content = "x".repeat(200);
793        let args = serde_json::json!({"content": long_content});
794        let summary = summarize_args(&args);
795
796        assert!(summary.len() < 250);
797        assert!(summary.contains("200 chars"));
798    }
799
800    #[test]
801    fn test_expand_home() {
802        // This test depends on HOME being set
803        if std::env::var("HOME").is_ok() {
804            let expanded = expand_home("~/test");
805            assert!(!expanded.starts_with("~"));
806            assert!(expanded.ends_with("/test"));
807        }
808    }
809
810    #[test]
811    fn test_yolo_config_default_values() {
812        let config = YoloConfig::default();
813        assert!(!config.enabled);
814        assert_eq!(config.max_operations, 0);
815        assert!((config.max_hours - 0.0).abs() < f64::EPSILON);
816        assert!(config.allow_git_push);
817        assert!(!config.allow_destructive_shell);
818        assert!(config.audit_log_path.is_none());
819        assert_eq!(config.status_interval, 100);
820    }
821
822    #[test]
823    fn test_yolo_config_for_coding_values() {
824        let config = YoloConfig::for_coding();
825        assert!(config.enabled);
826        assert!(!config.allow_git_push);
827        assert!(!config.allow_destructive_shell);
828        assert_eq!(config.status_interval, 50);
829    }
830
831    #[test]
832    fn test_yolo_config_fully_autonomous() {
833        let config = YoloConfig::fully_autonomous();
834        assert!(config.enabled);
835        assert!(config.allow_git_push);
836        assert!(!config.allow_destructive_shell);
837    }
838
839    #[test]
840    fn test_yolo_config_with_destructive_shell() {
841        let config = YoloConfig::for_coding().with_destructive_shell(true);
842        assert!(config.allow_destructive_shell);
843
844        let config2 = YoloConfig::for_coding().with_destructive_shell(false);
845        assert!(!config2.allow_destructive_shell);
846    }
847
848    #[test]
849    fn test_yolo_config_with_git_push() {
850        let config = YoloConfig::for_coding().with_git_push(true);
851        assert!(config.allow_git_push);
852
853        let config2 = YoloConfig::fully_autonomous().with_git_push(false);
854        assert!(!config2.allow_git_push);
855    }
856
857    #[test]
858    fn test_is_forbidden_case_insensitive() {
859        let config = YoloConfig::default();
860        assert!(config.is_forbidden("RM -RF /"));
861        assert!(config.is_forbidden("DD IF=/DEV/ZERO"));
862        assert!(!config.is_forbidden("ls -la"));
863    }
864
865    #[test]
866    fn test_yolo_decision_eq() {
867        assert_eq!(YoloDecision::AutoApprove, YoloDecision::AutoApprove);
868        assert_ne!(
869            YoloDecision::AutoApprove,
870            YoloDecision::Block("x".to_string())
871        );
872    }
873
874    #[test]
875    fn test_yolo_decision_debug() {
876        let decision = YoloDecision::RequireConfirmation("test".to_string());
877        let debug_str = format!("{:?}", decision);
878        assert!(debug_str.contains("RequireConfirmation"));
879    }
880
881    #[test]
882    fn test_audit_result_variants() {
883        let success = AuditResult::Success;
884        let failed = AuditResult::Failed("error".to_string());
885        let blocked = AuditResult::Blocked("protected".to_string());
886
887        let _ = format!("{:?}", success);
888        let _ = format!("{:?}", failed);
889        let _ = format!("{:?}", blocked);
890    }
891
892    #[test]
893    fn test_audit_entry_clone() {
894        let entry = AuditEntry {
895            timestamp: Utc::now(),
896            operation_id: 1,
897            tool_name: "test".to_string(),
898            arguments_summary: "args".to_string(),
899            auto_approved: true,
900            result: AuditResult::Success,
901            duration_ms: 100,
902        };
903
904        let cloned = entry.clone();
905        assert_eq!(entry.operation_id, cloned.operation_id);
906        assert_eq!(entry.tool_name, cloned.tool_name);
907    }
908
909    #[test]
910    fn test_audit_entry_serde() {
911        let entry = AuditEntry {
912            timestamp: Utc::now(),
913            operation_id: 1,
914            tool_name: "file_read".to_string(),
915            arguments_summary: "path: test.txt".to_string(),
916            auto_approved: true,
917            result: AuditResult::Success,
918            duration_ms: 50,
919        };
920
921        let json = serde_json::to_string(&entry).unwrap();
922        assert!(json.contains("file_read"));
923        assert!(json.contains("operation_id"));
924
925        let parsed: AuditEntry = serde_json::from_str(&json).unwrap();
926        assert_eq!(parsed.tool_name, entry.tool_name);
927    }
928
929    #[test]
930    fn test_yolo_config_clone() {
931        let config = YoloConfig::fully_autonomous();
932        let cloned = config.clone();
933        assert_eq!(config.enabled, cloned.enabled);
934        assert_eq!(config.allow_git_push, cloned.allow_git_push);
935    }
936
937    #[test]
938    fn test_yolo_config_serde() {
939        let config = YoloConfig::for_coding();
940        let json = serde_json::to_string(&config).unwrap();
941        assert!(json.contains("enabled"));
942
943        let parsed: YoloConfig = serde_json::from_str(&json).unwrap();
944        assert_eq!(parsed.enabled, config.enabled);
945    }
946
947    #[test]
948    fn test_audit_summary_fields() {
949        let summary = AuditSummary {
950            total_operations: 10,
951            success: 8,
952            failed: 1,
953            blocked: 1,
954            tools_used: std::collections::HashMap::new(),
955            total_duration_ms: 5000,
956            elapsed_hours: 1.5,
957        };
958
959        let debug_str = format!("{:?}", summary);
960        assert!(debug_str.contains("total_operations"));
961    }
962
963    #[test]
964    fn test_require_confirmation_destructive_shell() {
965        let config = YoloConfig::fully_autonomous().with_destructive_shell(false);
966        let manager = YoloManager::new(config);
967
968        let args = serde_json::json!({"command": "rm -rf ./test"});
969        let decision = manager.should_auto_approve("shell_exec", &args);
970
971        assert!(matches!(decision, YoloDecision::RequireConfirmation(_)));
972    }
973
974    #[test]
975    fn test_allow_destructive_shell_when_enabled() {
976        let config = YoloConfig::fully_autonomous().with_destructive_shell(true);
977        let manager = YoloManager::new(config);
978
979        // Safe destructive command (not in forbidden list)
980        let args = serde_json::json!({"command": "rm -rf ./test_dir"});
981        let decision = manager.should_auto_approve("shell_exec", &args);
982
983        // Should auto-approve since destructive shell is enabled
984        // and it's not in the forbidden list
985        assert_eq!(decision, YoloDecision::AutoApprove);
986    }
987
988    #[test]
989    fn test_yolo_manager_with_audit_log_path() {
990        let config = YoloConfig {
991            enabled: true,
992            audit_log_path: Some(PathBuf::from("/tmp/test_audit.log")),
993            ..Default::default()
994        };
995        let manager = YoloManager::new(config);
996        assert!(manager.is_active());
997    }
998
999    #[test]
1000    fn test_protected_paths_include_ssh() {
1001        let config = YoloConfig::default();
1002        // SSH directory should be protected
1003        if std::env::var("HOME").is_ok() {
1004            let expanded = expand_home("~/.ssh/id_rsa");
1005            assert!(
1006                config.is_protected_path(&expanded) || config.is_protected_path("~/.ssh/id_rsa")
1007            );
1008        }
1009    }
1010
1011    #[test]
1012    fn test_expand_home_no_tilde() {
1013        let path = "/absolute/path";
1014        let expanded = expand_home(path);
1015        assert_eq!(expanded, path);
1016    }
1017}