claude_agent/tools/
bash.rs

1//! Bash tool - shell command execution with security hardening.
2
3use std::process::Stdio;
4use std::sync::Arc;
5use std::time::Duration;
6
7use async_trait::async_trait;
8use schemars::JsonSchema;
9use serde::Deserialize;
10use tokio::io::AsyncReadExt;
11use tokio::process::Command;
12use tokio::time::timeout;
13
14use super::SchemaTool;
15use super::context::ExecutionContext;
16use super::process::ProcessManager;
17use crate::types::ToolResult;
18
19#[derive(Debug, Deserialize, JsonSchema)]
20#[schemars(deny_unknown_fields)]
21pub struct BashInput {
22    /// The command to execute
23    pub command: String,
24    /// Clear, concise description of what this command does in 5-10 words, in active voice.
25    #[serde(default)]
26    pub description: Option<String>,
27    /// Optional timeout in milliseconds (max 600000)
28    #[serde(default)]
29    pub timeout: Option<u64>,
30    /// Set to true to run this command in the background. Use TaskOutput to read the output later.
31    #[serde(default)]
32    pub run_in_background: Option<bool>,
33    /// Set this to true to dangerously override sandbox mode and run commands without sandboxing.
34    #[serde(default, rename = "dangerouslyDisableSandbox")]
35    pub dangerously_disable_sandbox: Option<bool>,
36}
37
38pub struct BashTool {
39    process_manager: Arc<ProcessManager>,
40}
41
42impl BashTool {
43    pub fn new() -> Self {
44        Self {
45            process_manager: Arc::new(ProcessManager::new()),
46        }
47    }
48
49    pub fn with_process_manager(manager: Arc<ProcessManager>) -> Self {
50        Self {
51            process_manager: manager,
52        }
53    }
54
55    pub fn process_manager(&self) -> &Arc<ProcessManager> {
56        &self.process_manager
57    }
58
59    fn should_bypass(&self, input: &BashInput, context: &ExecutionContext) -> bool {
60        if input.dangerously_disable_sandbox.unwrap_or(false) {
61            return context.can_bypass_sandbox();
62        }
63        false
64    }
65
66    async fn execute_foreground(
67        &self,
68        command: &str,
69        timeout_ms: u64,
70        context: &ExecutionContext,
71        bypass_sandbox: bool,
72    ) -> ToolResult {
73        let timeout_duration = Duration::from_millis(timeout_ms);
74        let env = context.sanitized_env_with_sandbox();
75        let limits = context.resource_limits().clone();
76
77        let wrapped_command = if bypass_sandbox {
78            command.to_string()
79        } else {
80            match context.wrap_command(command) {
81                Ok(cmd) => cmd,
82                Err(e) => return ToolResult::error(format!("Sandbox error: {}", e)),
83            }
84        };
85
86        let mut cmd = Command::new("bash");
87        cmd.arg("-c").arg(&wrapped_command);
88        cmd.current_dir(context.root());
89        cmd.env_clear();
90        cmd.envs(env);
91        cmd.stdout(Stdio::piped());
92        cmd.stderr(Stdio::piped());
93
94        #[cfg(unix)]
95        unsafe {
96            cmd.pre_exec(move || {
97                let _ = limits.apply();
98                Ok(())
99            });
100        }
101
102        // Ensure process is killed when dropped (safety net)
103        cmd.kill_on_drop(true);
104
105        // Spawn explicitly for proper cleanup on timeout
106        let mut child = match cmd.spawn() {
107            Ok(child) => child,
108            Err(e) => return ToolResult::error(format!("Failed to spawn: {}", e)),
109        };
110
111        // Take stdout/stderr handles before waiting (allows reading after wait)
112        let mut stdout_handle = child.stdout.take();
113        let mut stderr_handle = child.stderr.take();
114
115        match timeout(timeout_duration, child.wait()).await {
116            Ok(Ok(status)) => {
117                // Read output from taken handles
118                let mut stdout_buf = Vec::new();
119                let mut stderr_buf = Vec::new();
120
121                if let Some(ref mut handle) = stdout_handle {
122                    let _ = handle.read_to_end(&mut stdout_buf).await;
123                }
124                if let Some(ref mut handle) = stderr_handle {
125                    let _ = handle.read_to_end(&mut stderr_buf).await;
126                }
127
128                let stdout = String::from_utf8_lossy(&stdout_buf);
129                let stderr = String::from_utf8_lossy(&stderr_buf);
130
131                let mut combined = String::new();
132
133                if !stdout.is_empty() {
134                    combined.push_str(&stdout);
135                }
136
137                if !stderr.is_empty() {
138                    if !combined.is_empty() {
139                        combined.push_str("\n--- stderr ---\n");
140                    }
141                    combined.push_str(&stderr);
142                }
143
144                const MAX_OUTPUT: usize = 30_000;
145                if combined.len() > MAX_OUTPUT {
146                    combined.truncate(MAX_OUTPUT);
147                    combined.push_str("\n... (output truncated)");
148                }
149
150                if combined.is_empty() {
151                    combined = "(no output)".to_string();
152                }
153
154                if !status.success() {
155                    let code = status.code().unwrap_or(-1);
156                    combined = format!("Exit code: {}\n{}", code, combined);
157                }
158
159                ToolResult::success(combined)
160            }
161            Ok(Err(e)) => ToolResult::error(format!("Failed to execute command: {}", e)),
162            Err(_) => {
163                // Timeout: explicitly kill and wait to prevent zombie process
164                let _ = child.kill().await;
165                let _ = child.wait().await;
166                ToolResult::error(format!(
167                    "Command timed out after {} seconds",
168                    timeout_ms / 1000
169                ))
170            }
171        }
172    }
173
174    async fn execute_background(
175        &self,
176        command: &str,
177        context: &ExecutionContext,
178        bypass_sandbox: bool,
179    ) -> ToolResult {
180        let env = context.sanitized_env_with_sandbox();
181
182        let wrapped_command = if bypass_sandbox {
183            command.to_string()
184        } else {
185            match context.wrap_command(command) {
186                Ok(cmd) => cmd,
187                Err(e) => return ToolResult::error(format!("Sandbox error: {}", e)),
188            }
189        };
190
191        match self
192            .process_manager
193            .spawn_with_env(&wrapped_command, context.root(), env)
194            .await
195        {
196            Ok(id) => ToolResult::success(format!(
197                "Background process started with ID: {}\nUse TaskOutput tool to monitor output.",
198                id
199            )),
200            Err(e) => ToolResult::error(e),
201        }
202    }
203}
204
205impl Default for BashTool {
206    fn default() -> Self {
207        Self::new()
208    }
209}
210
211#[async_trait]
212impl SchemaTool for BashTool {
213    type Input = BashInput;
214
215    const NAME: &'static str = "Bash";
216    const DESCRIPTION: &'static str = r#"Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
217
218IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.
219
220Before executing the command, please follow these steps:
221
2221. Directory Verification:
223   - If the command will create new directories or files, first use `ls` to verify the parent directory exists and is the correct location
224   - For example, before running "mkdir foo/bar", first use `ls foo` to check that "foo" exists and is the intended parent directory
225
2262. Command Execution:
227   - Always quote file paths that contain spaces with double quotes (e.g., cd "path with spaces/file.txt")
228   - Examples of proper quoting:
229     - cd "/Users/name/My Documents" (correct)
230     - cd /Users/name/My Documents (incorrect - will fail)
231     - python "/path/with spaces/script.py" (correct)
232     - python /path/with spaces/script.py (incorrect - will fail)
233   - After ensuring proper quoting, execute the command.
234   - Capture the output of the command.
235
236Usage notes:
237  - The command argument is required.
238  - You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes).
239  - It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
240  - If the output exceeds 30000 characters, output will be truncated before being returned to you.
241  - You can use the `run_in_background` parameter to run the command in the background, which allows you to continue working while the command runs. You can monitor the output using the Bash tool as it becomes available. You do not need to use '&' at the end of the command when using this parameter.
242
243  - Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
244    - File search: Use Glob (NOT find or ls)
245    - Content search: Use Grep (NOT grep or rg)
246    - Read files: Use Read (NOT cat/head/tail)
247    - Edit files: Use Edit (NOT sed/awk)
248    - Write files: Use Write (NOT echo >/cat <<EOF)
249    - Communication: Output text directly (NOT echo/printf)
250  - When issuing multiple commands:
251    - If the commands are independent and can run in parallel, make multiple Bash tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two Bash tool calls in parallel.
252    - If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m "message" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead.
253    - Use ';' only when you need to run commands sequentially but don't care if earlier commands fail
254    - DO NOT use newlines to separate commands (newlines are ok in quoted strings)
255  - Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of `cd`. You may use `cd` if the User explicitly requests it.
256    <good-example>
257    pytest /foo/bar/tests
258    </good-example>
259    <bad-example>
260    cd /foo/bar && pytest tests
261    </bad-example>"#;
262
263    async fn handle(&self, input: BashInput, context: &ExecutionContext) -> ToolResult {
264        let bypass = self.should_bypass(&input, context);
265
266        if input.run_in_background.unwrap_or(false) {
267            self.execute_background(&input.command, context, bypass)
268                .await
269        } else {
270            let timeout_ms = input.timeout.unwrap_or(120_000).min(600_000);
271            self.execute_foreground(&input.command, timeout_ms, context, bypass)
272                .await
273        }
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use crate::tools::testing::helpers::TestContext;
281    use crate::tools::{ExecutionContext, Tool};
282    use crate::types::ToolOutput;
283
284    #[tokio::test]
285    async fn test_simple_command() {
286        let tool = BashTool::new();
287        let context = ExecutionContext::permissive();
288        let result = tool
289            .execute(
290                serde_json::json!({"command": "echo 'hello world'"}),
291                &context,
292            )
293            .await;
294
295        assert!(
296            matches!(&result.output, ToolOutput::Success(output) if output.contains("hello world")),
297            "Expected success with 'hello world', got {:?}",
298            result
299        );
300    }
301
302    #[tokio::test]
303    async fn test_background_command() {
304        let tool = BashTool::new();
305        let context = ExecutionContext::permissive();
306        let result = tool
307            .execute(
308                serde_json::json!({
309                    "command": "echo done",
310                    "run_in_background": true
311                }),
312                &context,
313            )
314            .await;
315
316        assert!(
317            matches!(&result.output, ToolOutput::Success(output) if output.contains("Background process started")),
318            "Expected background process started, got {:?}",
319            result
320        );
321    }
322
323    #[tokio::test]
324    async fn test_stderr_output() {
325        let tool = BashTool::new();
326        let context = ExecutionContext::permissive();
327        let result = tool
328            .execute(
329                serde_json::json!({"command": "echo 'stdout' && echo 'stderr' >&2"}),
330                &context,
331            )
332            .await;
333
334        assert!(
335            matches!(&result.output, ToolOutput::Success(output) if output.contains("stdout") && output.contains("stderr")),
336            "Expected stdout and stderr, got {:?}",
337            result
338        );
339    }
340
341    #[tokio::test]
342    async fn test_exit_code_nonzero() {
343        let tool = BashTool::new();
344        let context = ExecutionContext::permissive();
345        let result = tool
346            .execute(serde_json::json!({"command": "exit 42"}), &context)
347            .await;
348
349        assert!(
350            matches!(&result.output, ToolOutput::Success(output) if output.contains("Exit code: 42")),
351            "Expected exit code 42, got {:?}",
352            result
353        );
354    }
355
356    #[tokio::test]
357    async fn test_short_timeout() {
358        let tool = BashTool::new();
359        let context = ExecutionContext::permissive();
360        let result = tool
361            .execute(
362                serde_json::json!({
363                    "command": "sleep 10",
364                    "timeout": 100
365                }),
366                &context,
367            )
368            .await;
369
370        assert!(result.is_error(), "Expected timeout error");
371        assert!(
372            matches!(&result.output, ToolOutput::Error(e) if e.to_string().contains("timed out")),
373            "Expected timeout message, got {:?}",
374            result
375        );
376    }
377
378    #[tokio::test]
379    async fn test_working_directory() {
380        let test_context = TestContext::new();
381        test_context.write_file("testfile.txt", "content");
382
383        let tool = BashTool::new();
384        let result = tool
385            .execute(
386                serde_json::json!({"command": "ls testfile.txt"}),
387                &test_context.context,
388            )
389            .await;
390
391        assert!(
392            matches!(&result.output, ToolOutput::Success(output) if output.contains("testfile.txt")),
393            "Expected testfile.txt in output, got {:?}",
394            result
395        );
396    }
397
398    #[tokio::test]
399    async fn test_shared_process_manager() {
400        let manager = Arc::new(ProcessManager::new());
401        let tool1 = BashTool::with_process_manager(manager.clone());
402        let tool2 = BashTool::with_process_manager(manager.clone());
403
404        assert!(Arc::ptr_eq(
405            tool1.process_manager(),
406            tool2.process_manager()
407        ));
408    }
409}