Skip to main content

winx_code_agent/utils/
command_safety.rs

1//! Command safety and validation module
2//!
3//! This module provides utilities for detecting potentially problematic commands
4//! that might hang, require interaction, or cause other issues. Based on WCGW's
5//! command safety patterns.
6
7use std::collections::HashSet;
8use std::time::Duration;
9
10/// Default command timeout in seconds
11pub const DEFAULT_COMMAND_TIMEOUT: u64 = 30;
12
13/// Maximum output buffer size (1MB)
14pub const MAX_OUTPUT_SIZE: usize = 1024 * 1024;
15
16/// Commands that are known to be interactive and might hang
17static INTERACTIVE_COMMANDS: &[&str] = &[
18    // Editors
19    "vim",
20    "vi",
21    "nano",
22    "emacs",
23    "code",
24    "subl",
25    // Interactive shells/languages
26    "python",
27    "python3",
28    "node",
29    "nodejs",
30    "ruby",
31    "irb",
32    "scala",
33    "ghci",
34    // Interactive tools
35    "mysql",
36    "psql",
37    "sqlite3",
38    "redis-cli",
39    "mongo",
40    // Pagers
41    "less",
42    "more",
43    "view",
44    // System tools that might hang
45    "top",
46    "htop",
47    "watch",
48    "tail -f",
49    // Version control interactive
50    "git rebase -i",
51    "git add -i",
52    "git commit", // without -m
53];
54
55/// Commands that might run for a long time
56static LONG_RUNNING_COMMANDS: &[&str] = &[
57    // Build tools
58    "make",
59    "cargo build",
60    "npm install",
61    "pip install",
62    "yarn install",
63    // Compilation
64    "gcc",
65    "g++",
66    "clang",
67    "rustc",
68    "javac",
69    // Package managers
70    "apt-get",
71    "yum",
72    "brew install",
73    "pacman",
74    // Network tools
75    "wget",
76    "curl",
77    "rsync",
78    "scp",
79    // Archive tools
80    "tar",
81    "zip",
82    "unzip",
83    "gzip",
84];
85
86/// Commands that spawn background processes
87static BACKGROUND_COMMANDS: &[&str] = &[
88    // Servers
89    "python -m http.server",
90    "node server",
91    "rails server",
92    "cargo run",
93    // Background services
94    "nohup",
95    "screen",
96    "tmux",
97    // System services
98    "systemctl start",
99    "service start",
100];
101
102/// Command safety analyzer
103#[derive(Debug, Clone)]
104pub struct CommandSafety {
105    interactive: HashSet<String>,
106    long_running: HashSet<String>,
107    background: HashSet<String>,
108}
109
110impl Default for CommandSafety {
111    fn default() -> Self {
112        Self::new()
113    }
114}
115
116impl CommandSafety {
117    /// Create a new command safety analyzer
118    pub fn new() -> Self {
119        let interactive = INTERACTIVE_COMMANDS.iter().map(|s| (*s).to_string()).collect();
120
121        let long_running = LONG_RUNNING_COMMANDS.iter().map(|s| (*s).to_string()).collect();
122
123        let background = BACKGROUND_COMMANDS.iter().map(|s| (*s).to_string()).collect();
124
125        Self { interactive, long_running, background }
126    }
127
128    /// Check if a command is potentially interactive
129    pub fn is_interactive(&self, command: &str) -> bool {
130        let normalized = Self::normalize_command(command);
131
132        // Check exact matches
133        if self.interactive.contains(&normalized) {
134            return true;
135        }
136
137        // Check if command starts with any interactive command
138        for interactive_cmd in &self.interactive {
139            if normalized.starts_with(interactive_cmd) {
140                // Check that it's a word boundary
141                let rest = &normalized[interactive_cmd.len()..];
142                if rest.is_empty() || rest.starts_with(' ') || rest.starts_with('\t') {
143                    // For git commit, check if it has -m flag (non-interactive)
144                    if interactive_cmd == "git commit"
145                        && (normalized.contains("-m") || normalized.contains("--message"))
146                    {
147                        return false;
148                    }
149                    // For python, check if it has a script argument (non-interactive)
150                    if interactive_cmd == "python" || interactive_cmd == "python3" {
151                        // If there's more than just "python" or "python3", it's likely a script
152                        let parts: Vec<&str> = normalized.split_whitespace().collect();
153                        if parts.len() > 1 && !parts[1].starts_with('-') {
154                            return false;
155                        }
156                    }
157                    return true;
158                }
159            }
160        }
161
162        // Special cases for other interactive commands
163        Self::check_special_interactive_cases(&normalized)
164    }
165
166    /// Check if a command might run for a long time
167    pub fn is_long_running(&self, command: &str) -> bool {
168        let normalized = Self::normalize_command(command);
169
170        for long_cmd in &self.long_running {
171            if normalized.starts_with(long_cmd) {
172                let rest = &normalized[long_cmd.len()..];
173                if rest.is_empty() || rest.starts_with(' ') || rest.starts_with('\t') {
174                    return true;
175                }
176            }
177        }
178
179        false
180    }
181
182    /// Check if a command spawns background processes
183    pub fn is_background_command(&self, command: &str) -> bool {
184        let normalized = Self::normalize_command(command);
185
186        // Check for explicit background operators
187        if normalized.contains(" &") || normalized.ends_with('&') {
188            return true;
189        }
190
191        for bg_cmd in &self.background {
192            if normalized.starts_with(bg_cmd) {
193                let rest = &normalized[bg_cmd.len()..];
194                if rest.is_empty() || rest.starts_with(' ') || rest.starts_with('\t') {
195                    return true;
196                }
197            }
198        }
199
200        false
201    }
202
203    /// Get recommended timeout for a command
204    pub fn get_timeout(&self, command: &str) -> Duration {
205        if self.is_long_running(command) {
206            Duration::from_secs(300) // 5 minutes for long-running commands
207        } else if self.is_background_command(command) {
208            Duration::from_secs(60) // 1 minute for background commands
209        } else {
210            Duration::from_secs(DEFAULT_COMMAND_TIMEOUT) // 30 seconds default
211        }
212    }
213
214    /// Get safety warnings for a command
215    pub fn get_warnings(&self, command: &str) -> Vec<String> {
216        let mut warnings = Vec::new();
217
218        if self.is_interactive(command) {
219            warnings.push(format!(
220                "Command '{command}' appears to be interactive and may hang waiting for input"
221            ));
222            warnings.push("Consider using non-interactive flags or alternatives".to_string());
223        }
224
225        if self.is_long_running(command) {
226            warnings.push(format!("Command '{command}' may take a long time to complete"));
227            warnings.push("Consider using status_check to monitor progress".to_string());
228        }
229
230        if self.is_background_command(command) {
231            warnings.push(format!("Command '{command}' may spawn background processes"));
232            warnings.push("Use explicit process management if needed".to_string());
233        }
234
235        warnings
236    }
237
238    /// Normalize command for comparison
239    fn normalize_command(command: &str) -> String {
240        command.trim().to_lowercase()
241    }
242
243    /// Check special cases for interactive commands
244    fn check_special_interactive_cases(command: &str) -> bool {
245        // Git commit without -m flag
246        if command.starts_with("git commit")
247            && !command.contains("-m")
248            && !command.contains("--message")
249        {
250            return true;
251        }
252
253        // Docker run without -d flag (detached)
254        if command.starts_with("docker run")
255            && !command.contains("-d")
256            && !command.contains("--detach")
257        {
258            return true;
259        }
260
261        // SSH without command
262        if command == "ssh" || (command.starts_with("ssh ") && !command.contains(" -- ")) {
263            return true;
264        }
265
266        // FTP/SFTP
267        if command.starts_with("ftp ") || command.starts_with("sftp ") {
268            return true;
269        }
270
271        false
272    }
273}
274
275/// Command execution context
276#[derive(Debug, Clone)]
277pub struct CommandContext {
278    pub command: String,
279    pub timeout: Duration,
280    pub max_output_size: usize,
281    pub is_interactive: bool,
282    pub is_long_running: bool,
283    pub is_background: bool,
284    pub warnings: Vec<String>,
285}
286
287impl CommandContext {
288    /// Create a new command context with safety analysis
289    pub fn new(command: &str) -> Self {
290        let safety = CommandSafety::new();
291        let timeout = safety.get_timeout(command);
292        let is_interactive = safety.is_interactive(command);
293        let is_long_running = safety.is_long_running(command);
294        let is_background = safety.is_background_command(command);
295        let warnings = safety.get_warnings(command);
296
297        Self {
298            command: command.to_string(),
299            timeout,
300            max_output_size: MAX_OUTPUT_SIZE,
301            is_interactive,
302            is_long_running,
303            is_background,
304            warnings,
305        }
306    }
307
308    /// Check if the command should be allowed to execute
309    pub fn should_allow_execution(&self) -> Result<(), crate::errors::WinxError> {
310        if self.is_interactive {
311            return Err(crate::errors::WinxError::InteractiveCommandDetected {
312                command: self.command.clone(),
313            });
314        }
315
316        Ok(())
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn test_interactive_detection() {
326        let safety = CommandSafety::new();
327
328        // Interactive commands
329        assert!(safety.is_interactive("vim file.txt"));
330        assert!(safety.is_interactive("python"));
331        assert!(safety.is_interactive("git commit"));
332        assert!(safety.is_interactive("mysql -u root"));
333
334        // Non-interactive commands
335        assert!(!safety.is_interactive("ls -la"));
336        assert!(!safety.is_interactive("git commit -m 'message'"));
337        assert!(!safety.is_interactive("python script.py"));
338        assert!(!safety.is_interactive("cat file.txt"));
339    }
340
341    #[test]
342    fn test_long_running_detection() {
343        let safety = CommandSafety::new();
344
345        // Long-running commands
346        assert!(safety.is_long_running("cargo build"));
347        assert!(safety.is_long_running("npm install"));
348        assert!(safety.is_long_running("make all"));
349
350        // Quick commands
351        assert!(!safety.is_long_running("ls"));
352        assert!(!safety.is_long_running("echo hello"));
353    }
354
355    #[test]
356    fn test_background_detection() {
357        let safety = CommandSafety::new();
358
359        // Background commands
360        assert!(safety.is_background_command("python -m http.server &"));
361        assert!(safety.is_background_command("nohup long_process"));
362        assert!(safety.is_background_command("screen -S session"));
363
364        // Foreground commands
365        assert!(!safety.is_background_command("ls"));
366        assert!(!safety.is_background_command("python script.py"));
367    }
368
369    #[test]
370    fn test_timeout_calculation() {
371        let safety = CommandSafety::new();
372
373        // Long-running should get 5 minutes
374        assert_eq!(safety.get_timeout("cargo build"), Duration::from_secs(300));
375
376        // Background should get 1 minute
377        assert_eq!(safety.get_timeout("nohup process &"), Duration::from_secs(60));
378
379        // Default should get 30 seconds
380        assert_eq!(safety.get_timeout("ls"), Duration::from_secs(30));
381    }
382
383    #[test]
384    fn test_command_context() {
385        let ctx = CommandContext::new("vim file.txt");
386
387        assert!(ctx.is_interactive);
388        assert!(!ctx.warnings.is_empty());
389        assert!(ctx.should_allow_execution().is_err());
390
391        let ctx2 = CommandContext::new("ls -la");
392        assert!(!ctx2.is_interactive);
393        assert!(ctx2.should_allow_execution().is_ok());
394    }
395}