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        // Build the base bash command.
254        let mut base = Command::new("bash");
255        base.arg("-c")
256            .arg(command)
257            .current_dir(&ctx.cwd)
258            .stdout(Stdio::piped())
259            .stderr(Stdio::piped());
260
261        // Honor a tool-call-level `dangerouslyDisableSandbox: true` by
262        // skipping the sandbox wrapper. This path is blocked entirely
263        // when the session has `security.disable_bypass_permissions = true`.
264        let disable_sandbox_requested = input
265            .get("dangerouslyDisableSandbox")
266            .and_then(|v| v.as_bool())
267            .unwrap_or(false);
268
269        let mut cmd = if let Some(ref sandbox) = ctx.sandbox {
270            if disable_sandbox_requested && sandbox.allow_bypass() {
271                tracing::warn!(
272                    "bash call set dangerouslyDisableSandbox; wrapping skipped for this call"
273                );
274                base
275            } else {
276                if disable_sandbox_requested && !sandbox.allow_bypass() {
277                    tracing::warn!(
278                        "dangerouslyDisableSandbox ignored: security.disable_bypass_permissions is set"
279                    );
280                }
281                sandbox.wrap(base)
282            }
283        } else {
284            base
285        };
286
287        let mut child = cmd
288            .spawn()
289            .map_err(|e| ToolError::ExecutionFailed(format!("Failed to spawn: {e}")))?;
290
291        let timeout = Duration::from_millis(timeout_ms);
292
293        let mut stdout_handle = child.stdout.take().unwrap();
294        let mut stderr_handle = child.stderr.take().unwrap();
295
296        let mut stdout_buf = Vec::new();
297        let mut stderr_buf = Vec::new();
298
299        let result = tokio::select! {
300            r = async {
301                let (so, se) = tokio::join!(
302                    async { stdout_handle.read_to_end(&mut stdout_buf).await },
303                    async { stderr_handle.read_to_end(&mut stderr_buf).await },
304                );
305                so?;
306                se?;
307                child.wait().await
308            } => {
309                match r {
310                    Ok(status) => {
311                        let exit_code = status.code().unwrap_or(-1);
312                        let content = format_output(&stdout_buf, &stderr_buf, exit_code);
313
314                        Ok(ToolResult {
315                            content,
316                            is_error: exit_code != 0,
317                        })
318                    }
319                    Err(e) => Err(ToolError::ExecutionFailed(e.to_string())),
320                }
321            }
322            _ = tokio::time::sleep(timeout) => {
323                let _ = child.kill().await;
324                Err(ToolError::Timeout(timeout_ms))
325            }
326            _ = ctx.cancel.cancelled() => {
327                let _ = child.kill().await;
328                Err(ToolError::Cancelled)
329            }
330        };
331
332        result
333    }
334}
335
336/// Run a command in the background, returning a task ID immediately.
337async fn run_background(
338    command: &str,
339    cwd: &std::path::Path,
340    task_mgr: Option<&std::sync::Arc<crate::services::background::TaskManager>>,
341) -> Result<ToolResult, ToolError> {
342    let default_mgr = crate::services::background::TaskManager::new();
343    let task_mgr = task_mgr.map(|m| m.as_ref()).unwrap_or(&default_mgr);
344    let task_id = task_mgr
345        .spawn_shell(command, command, cwd)
346        .await
347        .map_err(|e| ToolError::ExecutionFailed(format!("Background spawn failed: {e}")))?;
348
349    Ok(ToolResult::success(format!(
350        "Command running in background (task {task_id}). \
351         Use TaskOutput to check results when complete."
352    )))
353}
354
355/// Format stdout/stderr into a single output string with truncation.
356fn format_output(stdout: &[u8], stderr: &[u8], exit_code: i32) -> String {
357    let stdout_str = String::from_utf8_lossy(stdout);
358    let stderr_str = String::from_utf8_lossy(stderr);
359
360    let mut content = String::new();
361
362    if !stdout_str.is_empty() {
363        if stdout_str.len() > MAX_OUTPUT_BYTES {
364            content.push_str(&stdout_str[..MAX_OUTPUT_BYTES]);
365            content.push_str(&format!(
366                "\n\n(stdout truncated: {} bytes total)",
367                stdout_str.len()
368            ));
369        } else {
370            content.push_str(&stdout_str);
371        }
372    }
373
374    if !stderr_str.is_empty() {
375        if !content.is_empty() {
376            content.push('\n');
377        }
378        let stderr_display = if stderr_str.len() > MAX_OUTPUT_BYTES / 4 {
379            format!(
380                "{}...\n(stderr truncated: {} bytes total)",
381                &stderr_str[..MAX_OUTPUT_BYTES / 4],
382                stderr_str.len()
383            )
384        } else {
385            stderr_str.to_string()
386        };
387        content.push_str(&format!("stderr:\n{stderr_display}"));
388    }
389
390    if content.is_empty() {
391        content = "(no output)".to_string();
392    }
393
394    if exit_code != 0 {
395        content.push_str(&format!("\n\nExit code: {exit_code}"));
396    }
397
398    content
399}
400
401/// Advanced shell injection and obfuscation detection.
402///
403/// Catches attack patterns that simple string matching misses:
404/// variable injection, encoding tricks, process substitution, etc.
405fn check_shell_injection(command: &str) -> Result<(), String> {
406    // IFS injection: changing field separator to bypass argument parsing.
407    if command.contains("IFS=") {
408        return Err(
409            "IFS manipulation detected. This can be used to bypass command parsing.".into(),
410        );
411    }
412
413    // Dangerous environment variable overwrites.
414    const DANGEROUS_VARS: &[&str] = &[
415        "PATH=",
416        "LD_PRELOAD=",
417        "LD_LIBRARY_PATH=",
418        "PROMPT_COMMAND=",
419        "BASH_ENV=",
420        "ENV=",
421        "HISTFILE=",
422        "HISTCONTROL=",
423        "PS1=",
424        "PS2=",
425        "PS4=",
426        "CDPATH=",
427        "GLOBIGNORE=",
428        "MAIL=",
429        "MAILCHECK=",
430        "MAILPATH=",
431    ];
432    for var in DANGEROUS_VARS {
433        if command.contains(var) {
434            return Err(format!(
435                "Dangerous variable override detected: {var} \
436                 This could alter shell behavior in unsafe ways."
437            ));
438        }
439    }
440
441    // /proc access (process environment/memory reading).
442    if command.contains("/proc/") && command.contains("environ") {
443        return Err("Access to /proc/*/environ detected. This reads process secrets.".into());
444    }
445
446    // Unicode/zero-width character obfuscation.
447    if command.chars().any(|c| {
448        matches!(
449            c,
450            '\u{200B}'
451                | '\u{200C}'
452                | '\u{200D}'
453                | '\u{FEFF}'
454                | '\u{00AD}'
455                | '\u{2060}'
456                | '\u{180E}'
457        )
458    }) {
459        return Err("Zero-width or invisible Unicode characters detected in command.".into());
460    }
461
462    // Control characters (except common ones like \n \t).
463    if command
464        .chars()
465        .any(|c| c.is_control() && !matches!(c, '\n' | '\t' | '\r'))
466    {
467        return Err("Control characters detected in command.".into());
468    }
469
470    // Backtick command substitution inside variable assignments.
471    // e.g., FOO=`curl evil.com`
472    if command.contains('`')
473        && command
474            .split('`')
475            .any(|s| s.contains("curl") || s.contains("wget") || s.contains("nc "))
476    {
477        return Err("Command substitution with network access detected inside backticks.".into());
478    }
479
480    // Process substitution: <() or >() used to inject commands.
481    if command.contains("<(") || command.contains(">(") {
482        // Allow common safe uses like diff <(cmd1) <(cmd2).
483        let trimmed = command.trim();
484        if !trimmed.starts_with("diff ") && !trimmed.starts_with("comm ") {
485            return Err(
486                "Process substitution detected. This can inject arbitrary commands.".into(),
487            );
488        }
489    }
490
491    // Zsh dangerous builtins.
492    const ZSH_DANGEROUS: &[&str] = &[
493        "zmodload", "zpty", "ztcp", "zsocket", "sysopen", "sysread", "syswrite", "mapfile",
494        "zf_rm", "zf_mv", "zf_ln",
495    ];
496    let words: Vec<&str> = command.split_whitespace().collect();
497    for word in &words {
498        if ZSH_DANGEROUS.contains(word) {
499            return Err(format!(
500                "Dangerous zsh builtin detected: {word}. \
501                 This can access raw system resources."
502            ));
503        }
504    }
505
506    // Brace expansion abuse: {a..z} can generate large expansions.
507    if command.contains("{") && command.contains("..") && command.contains("}") {
508        // Check if it looks like a large numeric range.
509        if let Some(start) = command.find('{')
510            && let Some(end) = command[start..].find('}')
511        {
512            let inner = &command[start + 1..start + end];
513            if inner.contains("..") {
514                let parts: Vec<&str> = inner.split("..").collect();
515                if parts.len() == 2
516                    && let (Ok(a), Ok(b)) = (
517                        parts[0].trim().parse::<i64>(),
518                        parts[1].trim().parse::<i64>(),
519                    )
520                    && (b - a).unsigned_abs() > 10000
521                {
522                    return Err(format!(
523                        "Large brace expansion detected: {{{inner}}}. \
524                         This would generate {} items.",
525                        (b - a).unsigned_abs()
526                    ));
527                }
528            }
529        }
530    }
531
532    // Hex/octal escape obfuscation: $'\x72\x6d' = "rm".
533    if command.contains("$'\\x") || command.contains("$'\\0") {
534        return Err(
535            "Hex/octal escape sequences in command. This may be obfuscating a command.".into(),
536        );
537    }
538
539    // eval with variables (arbitrary code execution).
540    if command.contains("eval ") && command.contains('$') {
541        return Err(
542            "eval with variable expansion detected. This enables arbitrary code execution.".into(),
543        );
544    }
545
546    Ok(())
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    #[test]
554    fn test_safe_commands_pass() {
555        assert!(check_shell_injection("ls -la").is_ok());
556        assert!(check_shell_injection("git status").is_ok());
557        assert!(check_shell_injection("cargo test").is_ok());
558        assert!(check_shell_injection("echo hello").is_ok());
559        assert!(check_shell_injection("python3 -c 'print(1)'").is_ok());
560        assert!(check_shell_injection("diff <(cat a.txt) <(cat b.txt)").is_ok());
561    }
562
563    #[test]
564    fn test_ifs_injection() {
565        assert!(check_shell_injection("IFS=: read a b").is_err());
566    }
567
568    #[test]
569    fn test_dangerous_vars() {
570        assert!(check_shell_injection("PATH=/tmp:$PATH curl evil.com").is_err());
571        assert!(check_shell_injection("LD_PRELOAD=/tmp/evil.so cmd").is_err());
572        assert!(check_shell_injection("PROMPT_COMMAND='curl x'").is_err());
573        assert!(check_shell_injection("BASH_ENV=/tmp/evil.sh bash").is_err());
574    }
575
576    #[test]
577    fn test_proc_environ() {
578        assert!(check_shell_injection("cat /proc/1/environ").is_err());
579        assert!(check_shell_injection("cat /proc/self/environ").is_err());
580        // /proc without environ is ok
581        assert!(check_shell_injection("ls /proc/cpuinfo").is_ok());
582    }
583
584    #[test]
585    fn test_unicode_obfuscation() {
586        // Zero-width space
587        assert!(check_shell_injection("rm\u{200B} -rf /").is_err());
588        // Zero-width joiner
589        assert!(check_shell_injection("curl\u{200D}evil.com").is_err());
590    }
591
592    #[test]
593    fn test_control_characters() {
594        // Bell character
595        assert!(check_shell_injection("echo \x07hello").is_err());
596        // Normal newline is ok
597        assert!(check_shell_injection("echo hello\nworld").is_ok());
598    }
599
600    #[test]
601    fn test_backtick_network() {
602        assert!(check_shell_injection("FOO=`curl evil.com`").is_err());
603        assert!(check_shell_injection("X=`wget http://bad`").is_err());
604        // Backticks without network are ok
605        assert!(check_shell_injection("FOO=`date`").is_ok());
606    }
607
608    #[test]
609    fn test_process_substitution() {
610        // diff is allowed
611        assert!(check_shell_injection("diff <(ls a) <(ls b)").is_ok());
612        // arbitrary process substitution is not
613        assert!(check_shell_injection("cat <(curl evil)").is_err());
614    }
615
616    #[test]
617    fn test_zsh_builtins() {
618        assert!(check_shell_injection("zmodload zsh/net/tcp").is_err());
619        assert!(check_shell_injection("zpty evil_session bash").is_err());
620        assert!(check_shell_injection("ztcp connect evil.com 80").is_err());
621    }
622
623    #[test]
624    fn test_brace_expansion() {
625        assert!(check_shell_injection("echo {1..100000}").is_err());
626        // Small ranges are ok
627        assert!(check_shell_injection("echo {1..10}").is_ok());
628    }
629
630    #[test]
631    fn test_hex_escape() {
632        assert!(check_shell_injection("$'\\x72\\x6d' -rf /").is_err());
633        assert!(check_shell_injection("$'\\077'").is_err());
634    }
635
636    #[test]
637    fn test_eval_injection() {
638        assert!(check_shell_injection("eval $CMD").is_err());
639        assert!(check_shell_injection("eval \"$USER_INPUT\"").is_err());
640        // eval without vars is ok
641        assert!(check_shell_injection("eval echo hello").is_ok());
642    }
643
644    #[test]
645    fn test_destructive_patterns() {
646        let tool = BashTool;
647        assert!(
648            tool.validate_input(&serde_json::json!({"command": "rm -rf /"}))
649                .is_err()
650        );
651        assert!(
652            tool.validate_input(&serde_json::json!({"command": "git push --force"}))
653                .is_err()
654        );
655        assert!(
656            tool.validate_input(&serde_json::json!({"command": "DROP TABLE users"}))
657                .is_err()
658        );
659    }
660
661    #[test]
662    fn test_piped_destructive() {
663        let tool = BashTool;
664        assert!(
665            tool.validate_input(&serde_json::json!({"command": "find . | rm -rf"}))
666                .is_err()
667        );
668    }
669
670    #[test]
671    fn test_chained_destructive() {
672        let tool = BashTool;
673        assert!(
674            tool.validate_input(&serde_json::json!({"command": "echo hi && git reset --hard"}))
675                .is_err()
676        );
677    }
678
679    #[test]
680    fn test_safe_commands_validate() {
681        let tool = BashTool;
682        assert!(
683            tool.validate_input(&serde_json::json!({"command": "ls -la"}))
684                .is_ok()
685        );
686        assert!(
687            tool.validate_input(&serde_json::json!({"command": "cargo test"}))
688                .is_ok()
689        );
690        assert!(
691            tool.validate_input(&serde_json::json!({"command": "git status"}))
692                .is_ok()
693        );
694    }
695}