Skip to main content

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 process_manager(manager: Arc<ProcessManager>) -> Self {
50        Self {
51            process_manager: manager,
52        }
53    }
54
55    pub fn get_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                if let Err(e) = limits.apply() {
98                    eprintln!("Warning: resource limits not applied: {e}");
99                }
100                Ok(())
101            });
102        }
103
104        // Ensure process is killed when dropped (safety net)
105        cmd.kill_on_drop(true);
106
107        // Spawn explicitly for proper cleanup on timeout
108        let mut child = match cmd.spawn() {
109            Ok(child) => child,
110            Err(e) => return ToolResult::error(format!("Failed to spawn: {}", e)),
111        };
112
113        // Take stdout/stderr handles before waiting (allows reading after wait)
114        let mut stdout_handle = child.stdout.take();
115        let mut stderr_handle = child.stderr.take();
116
117        match timeout(timeout_duration, child.wait()).await {
118            Ok(Ok(status)) => {
119                // Read output from taken handles
120                let mut stdout_buf = Vec::new();
121                let mut stderr_buf = Vec::new();
122
123                if let Some(ref mut handle) = stdout_handle {
124                    let _ = handle.read_to_end(&mut stdout_buf).await;
125                }
126                if let Some(ref mut handle) = stderr_handle {
127                    let _ = handle.read_to_end(&mut stderr_buf).await;
128                }
129
130                let stdout = String::from_utf8_lossy(&stdout_buf);
131                let stderr = String::from_utf8_lossy(&stderr_buf);
132
133                let mut combined = String::new();
134
135                if !stdout.is_empty() {
136                    combined.push_str(&stdout);
137                }
138
139                if !stderr.is_empty() {
140                    if !combined.is_empty() {
141                        combined.push_str("\n--- stderr ---\n");
142                    }
143                    combined.push_str(&stderr);
144                }
145
146                const MAX_OUTPUT: usize = 30_000;
147                if combined.len() > MAX_OUTPUT {
148                    combined.truncate(MAX_OUTPUT);
149                    combined.push_str("\n... (output truncated)");
150                }
151
152                if combined.is_empty() {
153                    combined = "(no output)".to_string();
154                }
155
156                if !status.success() {
157                    let code = status.code().unwrap_or(-1);
158                    combined = format!("Exit code: {}\n{}", code, combined);
159                }
160
161                ToolResult::success(combined)
162            }
163            Ok(Err(e)) => ToolResult::error(format!("Failed to execute command: {}", e)),
164            Err(_) => {
165                // Timeout: explicitly kill and wait to prevent zombie process
166                let _ = child.kill().await;
167                let _ = child.wait().await;
168                ToolResult::error(format!(
169                    "Command timed out after {} seconds",
170                    timeout_ms / 1000
171                ))
172            }
173        }
174    }
175
176    async fn execute_background(
177        &self,
178        command: &str,
179        context: &ExecutionContext,
180        bypass_sandbox: bool,
181    ) -> ToolResult {
182        let env = context.sanitized_env_with_sandbox();
183
184        let wrapped_command = if bypass_sandbox {
185            command.to_string()
186        } else {
187            match context.wrap_command(command) {
188                Ok(cmd) => cmd,
189                Err(e) => return ToolResult::error(format!("Sandbox error: {}", e)),
190            }
191        };
192
193        match self
194            .process_manager
195            .spawn_with_env(&wrapped_command, context.root(), env)
196            .await
197        {
198            Ok(id) => ToolResult::success(format!(
199                "Background process started with ID: {}\nUse TaskOutput tool to monitor output.",
200                id
201            )),
202            Err(e) => ToolResult::error(e),
203        }
204    }
205}
206
207impl Default for BashTool {
208    fn default() -> Self {
209        Self::new()
210    }
211}
212
213#[async_trait]
214impl SchemaTool for BashTool {
215    type Input = BashInput;
216
217    const NAME: &'static str = "Bash";
218    const DESCRIPTION: &'static str = r#"Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
219
220IMPORTANT: 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.
221
222Before executing the command, please follow these steps:
223
2241. Directory Verification:
225   - If the command will create new directories or files, first use `ls` to verify the parent directory exists and is the correct location
226   - For example, before running "mkdir foo/bar", first use `ls foo` to check that "foo" exists and is the intended parent directory
227
2282. Command Execution:
229   - Always quote file paths that contain spaces with double quotes (e.g., cd "path with spaces/file.txt")
230   - Examples of proper quoting:
231     - cd "/Users/name/My Documents" (correct)
232     - cd /Users/name/My Documents (incorrect - will fail)
233     - python "/path/with spaces/script.py" (correct)
234     - python /path/with spaces/script.py (incorrect - will fail)
235   - After ensuring proper quoting, execute the command.
236   - Capture the output of the command.
237
238Usage notes:
239  - The command argument is required.
240  - You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes).
241  - It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
242  - If the output exceeds 30000 characters, output will be truncated before being returned to you.
243  - 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.
244
245  - 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:
246    - File search: Use Glob (NOT find or ls)
247    - Content search: Use Grep (NOT grep or rg)
248    - Read files: Use Read (NOT cat/head/tail)
249    - Edit files: Use Edit (NOT sed/awk)
250    - Write files: Use Write (NOT echo >/cat <<EOF)
251    - Communication: Output text directly (NOT echo/printf)
252  - When issuing multiple commands:
253    - 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.
254    - 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.
255    - Use ';' only when you need to run commands sequentially but don't care if earlier commands fail
256    - DO NOT use newlines to separate commands (newlines are ok in quoted strings)
257  - 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.
258    <good-example>
259    pytest /foo/bar/tests
260    </good-example>
261    <bad-example>
262    cd /foo/bar && pytest tests
263    </bad-example>"#;
264
265    async fn handle(&self, input: BashInput, context: &ExecutionContext) -> ToolResult {
266        let bypass = self.should_bypass(&input, context);
267
268        if input.run_in_background.unwrap_or(false) {
269            self.execute_background(&input.command, context, bypass)
270                .await
271        } else {
272            let timeout_ms = input.timeout.unwrap_or(120_000).min(600_000);
273            self.execute_foreground(&input.command, timeout_ms, context, bypass)
274                .await
275        }
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use crate::tools::testing::helpers::TestContext;
283    use crate::tools::{ExecutionContext, Tool};
284    use crate::types::ToolOutput;
285
286    #[tokio::test]
287    async fn test_simple_command() {
288        let tool = BashTool::new();
289        let context = ExecutionContext::permissive();
290        let result = tool
291            .execute(
292                serde_json::json!({"command": "echo 'hello world'"}),
293                &context,
294            )
295            .await;
296
297        assert!(
298            matches!(&result.output, ToolOutput::Success(output) if output.contains("hello world")),
299            "Expected success with 'hello world', got {:?}",
300            result
301        );
302    }
303
304    #[tokio::test]
305    async fn test_background_command() {
306        let tool = BashTool::new();
307        let context = ExecutionContext::permissive();
308        let result = tool
309            .execute(
310                serde_json::json!({
311                    "command": "echo done",
312                    "run_in_background": true
313                }),
314                &context,
315            )
316            .await;
317
318        assert!(
319            matches!(&result.output, ToolOutput::Success(output) if output.contains("Background process started")),
320            "Expected background process started, got {:?}",
321            result
322        );
323    }
324
325    #[tokio::test]
326    async fn test_stderr_output() {
327        let tool = BashTool::new();
328        let context = ExecutionContext::permissive();
329        let result = tool
330            .execute(
331                serde_json::json!({"command": "echo 'stdout' && echo 'stderr' >&2"}),
332                &context,
333            )
334            .await;
335
336        assert!(
337            matches!(&result.output, ToolOutput::Success(output) if output.contains("stdout") && output.contains("stderr")),
338            "Expected stdout and stderr, got {:?}",
339            result
340        );
341    }
342
343    #[tokio::test]
344    async fn test_exit_code_nonzero() {
345        let tool = BashTool::new();
346        let context = ExecutionContext::permissive();
347        let result = tool
348            .execute(serde_json::json!({"command": "exit 42"}), &context)
349            .await;
350
351        assert!(
352            matches!(&result.output, ToolOutput::Success(output) if output.contains("Exit code: 42")),
353            "Expected exit code 42, got {:?}",
354            result
355        );
356    }
357
358    #[tokio::test]
359    async fn test_short_timeout() {
360        let tool = BashTool::new();
361        let context = ExecutionContext::permissive();
362        let result = tool
363            .execute(
364                serde_json::json!({
365                    "command": "sleep 10",
366                    "timeout": 100
367                }),
368                &context,
369            )
370            .await;
371
372        assert!(result.is_error(), "Expected timeout error");
373        assert!(
374            matches!(&result.output, ToolOutput::Error(e) if e.to_string().contains("timed out")),
375            "Expected timeout message, got {:?}",
376            result
377        );
378    }
379
380    #[tokio::test]
381    async fn test_working_directory() {
382        let test_context = TestContext::new();
383        test_context.write_file("testfile.txt", "content");
384
385        let tool = BashTool::new();
386        let result = tool
387            .execute(
388                serde_json::json!({"command": "ls testfile.txt"}),
389                &test_context.context,
390            )
391            .await;
392
393        assert!(
394            matches!(&result.output, ToolOutput::Success(output) if output.contains("testfile.txt")),
395            "Expected testfile.txt in output, got {:?}",
396            result
397        );
398    }
399
400    #[tokio::test]
401    async fn test_shared_process_manager() {
402        let manager = Arc::new(ProcessManager::new());
403        let tool1 = BashTool::process_manager(manager.clone());
404        let tool2 = BashTool::process_manager(manager.clone());
405
406        assert!(Arc::ptr_eq(
407            tool1.get_process_manager(),
408            tool2.get_process_manager()
409        ));
410    }
411}