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