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 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 cmd.kill_on_drop(true);
106
107 let mut child = match cmd.spawn() {
109 Ok(child) => child,
110 Err(e) => return ToolResult::error(format!("Failed to spawn: {}", e)),
111 };
112
113 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 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 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}