1use 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 pub command: String,
24 #[serde(default)]
26 pub description: Option<String>,
27 #[serde(default)]
29 pub timeout: Option<u64>,
30 #[serde(default)]
32 pub run_in_background: Option<bool>,
33 #[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 cmd.kill_on_drop(true);
104
105 let mut child = match cmd.spawn() {
107 Ok(child) => child,
108 Err(e) => return ToolResult::error(format!("Failed to spawn: {}", e)),
109 };
110
111 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 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 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}