1use async_trait::async_trait;
7use sacp::schema::ToolCallStatus;
8use serde::Deserialize;
9use serde_json::json;
10use std::process::Stdio;
11use std::sync::Arc;
12use std::time::{Duration, Instant};
13use tokio::io::{AsyncBufReadExt, BufReader};
14use tokio::process::Command;
15use tokio::time::timeout;
16use uuid::Uuid;
17
18use super::base::{Tool, ToolKind};
19use crate::mcp::registry::{ToolContext, ToolResult};
20use crate::session::{BackgroundTerminal, ChildHandle, TerminalExitStatus, WrappedChild};
21use crate::terminal::TerminalClient;
22
23use process_wrap::tokio::*;
25
26const MAX_OUTPUT_SIZE: usize = 30_000;
28
29const SHELL_OPERATORS: &[&str] = &["&&", "||", ";", "|", "$(", "`", "\n"];
35
36pub fn contains_shell_operator(command: &str) -> bool {
54 SHELL_OPERATORS.iter().any(|op| command.contains(op))
55}
56
57#[derive(Debug, Default)]
59pub struct BashTool;
60
61#[derive(Debug, Deserialize)]
63struct BashInput {
64 command: String,
66 #[serde(default)]
68 description: Option<String>,
69 #[serde(default)]
71 timeout: Option<u64>,
72 #[serde(default)]
74 run_in_background: Option<bool>,
75}
76
77impl BashTool {
78 pub fn new() -> Self {
80 Self
81 }
82
83 fn check_permission(
89 &self,
90 _input: &serde_json::Value,
91 _context: &ToolContext,
92 ) -> Option<ToolResult> {
93 None
95 }
96}
97
98#[async_trait]
99impl Tool for BashTool {
100 fn name(&self) -> &str {
101 "Bash"
102 }
103
104 fn description(&self) -> &str {
105 "Execute a shell command. Commands are run in a bash shell with the session's working directory. Use for git, npm, build tools, and other terminal operations."
106 }
107
108 fn input_schema(&self) -> serde_json::Value {
109 json!({
110 "type": "object",
111 "required": ["command"],
112 "properties": {
113 "command": {
114 "type": "string",
115 "description": "The shell command to execute"
116 },
117 "description": {
118 "type": "string",
119 "description": "A short description of what this command does"
120 },
121 "timeout": {
122 "type": "integer",
123 "description": "Timeout in milliseconds (max 600000, default 120000)"
124 },
125 "run_in_background": {
126 "type": "boolean",
127 "description": "Run command in background. Returns immediately with a shell ID that can be used with BashOutput to retrieve output."
128 }
129 }
130 })
131 }
132
133 fn kind(&self) -> ToolKind {
134 ToolKind::Execute
135 }
136
137 fn requires_permission(&self) -> bool {
138 true }
140
141 async fn execute(&self, input: serde_json::Value, context: &ToolContext) -> ToolResult {
142 if let Some(result) = self.check_permission(&input, context) {
144 return result;
145 }
146
147 let params: BashInput = match serde_json::from_value(input) {
149 Ok(p) => p,
150 Err(e) => return ToolResult::error(format!("Invalid input: {}", e)),
151 };
152
153 if let Some(terminal_client) = context.terminal_client() {
155 if params.run_in_background.unwrap_or(false) {
156 return self
157 .execute_terminal_background(¶ms, terminal_client, context)
158 .await;
159 }
160 return self
161 .execute_terminal_foreground(¶ms, terminal_client, context)
162 .await;
163 }
164
165 if params.run_in_background.unwrap_or(false) {
167 return self.execute_background(¶ms, context);
168 }
169
170 self.execute_foreground(¶ms, context).await
171 }
172}
173
174impl BashTool {
175 async fn execute_foreground(&self, params: &BashInput, context: &ToolContext) -> ToolResult {
177 let cmd_start = Instant::now();
178
179 let timeout_ms = params.timeout;
181
182 let build_start = Instant::now();
184 let mut cmd = Command::new("bash");
185 cmd.arg("-c")
186 .arg(¶ms.command)
187 .current_dir(&context.cwd)
188 .stdout(Stdio::piped())
189 .stderr(Stdio::piped());
190 let build_duration = build_start.elapsed();
191
192 tracing::debug!(
193 command = %params.command,
194 build_duration_ms = build_duration.as_millis(),
195 timeout_ms = ?timeout_ms,
196 "Bash command built"
197 );
198
199 let exec_start = Instant::now();
201 let output = if let Some(ms) = timeout_ms {
202 let timeout_duration = Duration::from_millis(ms);
204 match timeout(timeout_duration, cmd.output()).await {
205 Ok(Ok(output)) => output,
206 Ok(Err(e)) => {
207 let exec_duration = exec_start.elapsed();
208 tracing::error!(
209 command = %params.command,
210 exec_duration_ms = exec_duration.as_millis(),
211 error = %e,
212 "Bash command execution failed"
213 );
214 return ToolResult::error(format!("Failed to execute command: {}", e));
215 }
216 Err(_) => {
217 let exec_duration = exec_start.elapsed();
218 tracing::warn!(
219 command = %params.command,
220 exec_duration_ms = exec_duration.as_millis(),
221 timeout_ms = ms,
222 "Bash command timed out"
223 );
224 return ToolResult::error(format!("Command timed out after {}ms", ms));
225 }
226 }
227 } else {
228 match cmd.output().await {
230 Ok(output) => output,
231 Err(e) => {
232 let exec_duration = exec_start.elapsed();
233 tracing::error!(
234 command = %params.command,
235 exec_duration_ms = exec_duration.as_millis(),
236 error = %e,
237 "Bash command execution failed"
238 );
239 return ToolResult::error(format!("Failed to execute command: {}", e));
240 }
241 }
242 };
243 let exec_duration = exec_start.elapsed();
244
245 let process_start = Instant::now();
247 let stdout = String::from_utf8_lossy(&output.stdout);
248 let stderr = String::from_utf8_lossy(&output.stderr);
249
250 let mut result_text = String::new();
251
252 if !stdout.is_empty() {
254 result_text.push_str(&stdout);
255 }
256
257 if !stderr.is_empty() {
259 if !result_text.is_empty() {
260 result_text.push_str("\n--- stderr ---\n");
261 }
262 result_text.push_str(&stderr);
263 }
264
265 let was_truncated = result_text.len() > MAX_OUTPUT_SIZE;
267 if was_truncated {
268 result_text.truncate(MAX_OUTPUT_SIZE);
269 result_text.push_str("\n... (output truncated)");
270 }
271
272 if result_text.is_empty() {
274 result_text = "(no output)".to_string();
275 }
276
277 let process_duration = process_start.elapsed();
278 let total_elapsed = cmd_start.elapsed();
279
280 let exit_code = output.status.code().unwrap_or(-1);
281 let success = output.status.success();
282
283 tracing::info!(
285 command = %params.command,
286 exit_code = exit_code,
287 success = success,
288 build_duration_ms = build_duration.as_millis(),
289 exec_duration_ms = exec_duration.as_millis(),
290 process_duration_ms = process_duration.as_millis(),
291 total_elapsed_ms = total_elapsed.as_millis(),
292 output_size_bytes = result_text.len(),
293 was_truncated = was_truncated,
294 "Bash command execution summary"
295 );
296
297 if success {
298 ToolResult::success(result_text).with_metadata(json!({
299 "exit_code": exit_code,
300 "truncated": was_truncated,
301 "description": params.description,
302 "total_elapsed_ms": total_elapsed.as_millis(),
303 "exec_duration_ms": exec_duration.as_millis()
304 }))
305 } else {
306 ToolResult::error(format!(
307 "Command failed with exit code {}\n{}",
308 exit_code, result_text
309 ))
310 .with_metadata(json!({
311 "exit_code": exit_code,
312 "truncated": was_truncated,
313 "total_elapsed_ms": total_elapsed.as_millis(),
314 "exec_duration_ms": exec_duration.as_millis()
315 }))
316 }
317 }
318
319 fn execute_background(&self, params: &BashInput, context: &ToolContext) -> ToolResult {
321 let manager = match context.background_processes() {
323 Some(m) => m.clone(),
324 None => {
325 return ToolResult::error("Background process manager not available");
326 }
327 };
328
329 let mut cmd = CommandWrap::with_new("bash", |c| {
331 c.arg("-c")
332 .arg(¶ms.command)
333 .current_dir(&context.cwd)
334 .stdout(Stdio::piped())
335 .stderr(Stdio::piped());
336 });
337
338 #[cfg(unix)]
340 cmd.wrap(ProcessGroup::leader());
341
342 #[cfg(windows)]
343 cmd.wrap(JobObject::new());
344
345 let mut wrapped_child = match cmd.spawn() {
347 Ok(c) => c,
348 Err(e) => return ToolResult::error(format!("Failed to spawn command: {}", e)),
349 };
350
351 let stdout = wrapped_child.stdout().take();
353 let stderr = wrapped_child.stderr().take();
354
355 let shell_id = format!("shell-{}", Uuid::new_v4().simple());
357
358 let child_handle = ChildHandle::Wrapped {
360 child: Arc::new(tokio::sync::Mutex::new(WrappedChild::new(wrapped_child))),
361 };
362
363 let terminal = BackgroundTerminal::new_running(child_handle);
365
366 let output_buffer = match &terminal {
368 BackgroundTerminal::Running { output_buffer, .. } => output_buffer.clone(),
369 BackgroundTerminal::Finished { .. } => unreachable!(),
370 };
371
372 let shell_id_clone = shell_id.clone();
374 manager.register(shell_id.clone(), terminal);
375
376 let manager_clone = manager.clone();
378 let description = params.description.clone();
379 tokio::spawn(async move {
380 let mut combined_output = String::new();
381
382 if let Some(stdout) = stdout {
384 let reader = BufReader::new(stdout);
385 let mut lines = reader.lines();
386 while let Ok(Some(line)) = lines.next_line().await {
387 combined_output.push_str(&line);
388 combined_output.push('\n');
389 let mut buffer = output_buffer.lock().await;
390 buffer.push_str(&line);
391 buffer.push('\n');
392 }
393 }
394
395 if let Some(stderr) = stderr {
397 let reader = BufReader::new(stderr);
398 let mut lines = reader.lines();
399 while let Ok(Some(line)) = lines.next_line().await {
400 if !combined_output.is_empty() && !combined_output.ends_with('\n') {
401 combined_output.push('\n');
402 }
403 combined_output.push_str(&line);
404 combined_output.push('\n');
405 let mut buffer = output_buffer.lock().await;
406 buffer.push_str(&line);
407 buffer.push('\n');
408 }
409 }
410
411 if let Some(terminal_ref) = manager_clone.get(&shell_id_clone) {
415 if let BackgroundTerminal::Running { child, .. } = &*terminal_ref {
416 let mut child_handle = child.clone();
418 drop(terminal_ref); if let Ok(status) = child_handle.wait().await {
422 let exit_code = status.code().unwrap_or(-1);
423 manager_clone
424 .finish_terminal(&shell_id_clone, TerminalExitStatus::Exited(exit_code))
425 .await;
426 } else {
427 manager_clone
428 .finish_terminal(&shell_id_clone, TerminalExitStatus::Aborted)
429 .await;
430 }
431 }
432 }
433 });
434
435 ToolResult::success(format!(
437 "Command started in background.\n\nShell ID: {}\n\nUse BashOutput to check status and retrieve output.",
438 shell_id
439 )).with_metadata(json!({
440 "shell_id": shell_id,
441 "status": "running",
442 "description": description
443 }))
444 }
445
446 async fn execute_terminal_foreground(
451 &self,
452 params: &BashInput,
453 terminal_client: &Arc<TerminalClient>,
454 context: &ToolContext,
455 ) -> ToolResult {
456 let timeout_ms = params.timeout;
458
459 let terminal_id = match terminal_client
461 .create(
462 "bash",
463 vec!["-c".to_string(), params.command.clone()],
464 Some(context.cwd.clone()),
465 Some(MAX_OUTPUT_SIZE as u64),
466 )
467 .await
468 {
469 Ok(id) => id,
470 Err(e) => return ToolResult::error(format!("Failed to create terminal: {}", e)),
471 };
472
473 if let Err(e) = context.send_terminal_update(
476 terminal_id.0.as_ref(),
477 ToolCallStatus::InProgress,
478 params.description.as_deref(),
479 ) {
480 tracing::debug!("Failed to send terminal update: {}", e);
481 }
483
484 let exit_result = if let Some(ms) = timeout_ms {
486 let timeout_duration = Duration::from_millis(ms);
488 timeout(
489 timeout_duration,
490 terminal_client.wait_for_exit(terminal_id.clone()),
491 )
492 .await
493 } else {
494 Ok(terminal_client.wait_for_exit(terminal_id.clone()).await)
496 };
497
498 let output = match terminal_client.output(terminal_id.clone()).await {
500 Ok(resp) => resp.output,
501 Err(e) => format!("(failed to get output: {})", e),
502 };
503
504 drop(terminal_client.release(terminal_id).await);
506
507 match exit_result {
509 Ok(Ok(exit_response)) => {
510 let exit_status = exit_response.exit_status;
511 #[allow(clippy::cast_possible_wrap)]
513 let exit_code = exit_status.exit_code.map(|c| c as i32).unwrap_or(-1);
514 let was_truncated = output.len() >= MAX_OUTPUT_SIZE;
515
516 let result_text = if output.is_empty() {
517 "(no output)".to_string()
518 } else if was_truncated {
519 format!("{}\n... (output truncated)", output)
520 } else {
521 output
522 };
523
524 if exit_code == 0 {
525 ToolResult::success(result_text).with_metadata(json!({
526 "exit_code": exit_code,
527 "truncated": was_truncated,
528 "description": params.description,
529 "terminal_api": true
530 }))
531 } else {
532 ToolResult::error(format!(
533 "Command failed with exit code {}\n{}",
534 exit_code, result_text
535 ))
536 .with_metadata(json!({
537 "exit_code": exit_code,
538 "truncated": was_truncated,
539 "terminal_api": true
540 }))
541 }
542 }
543 Ok(Err(e)) => ToolResult::error(format!("Terminal execution failed: {}", e)),
544 Err(_) => {
545 let ms = timeout_ms.unwrap_or(0);
547 ToolResult::error(format!("Command timed out after {}ms\n{}", ms, output))
548 }
549 }
550 }
551
552 async fn execute_terminal_background(
557 &self,
558 params: &BashInput,
559 terminal_client: &Arc<TerminalClient>,
560 context: &ToolContext,
561 ) -> ToolResult {
562 let terminal_id = match terminal_client
564 .create(
565 "bash",
566 vec!["-c".to_string(), params.command.clone()],
567 Some(context.cwd.clone()),
568 None, )
570 .await
571 {
572 Ok(id) => id,
573 Err(e) => return ToolResult::error(format!("Failed to create terminal: {}", e)),
574 };
575
576 let shell_id = format!("term-{}", terminal_id.0.as_ref());
578
579 if let Err(e) = context.send_terminal_update(
582 terminal_id.0.as_ref(),
583 ToolCallStatus::InProgress,
584 params.description.as_deref(),
585 ) {
586 tracing::debug!("Failed to send terminal update: {}", e);
587 }
589
590 ToolResult::success(format!(
592 "Command started in background via Terminal API.\n\nShell ID: {}\n\nUse BashOutput to check status and retrieve output.",
593 shell_id
594 )).with_metadata(json!({
595 "shell_id": shell_id,
596 "terminal_id": terminal_id.0.as_ref(),
597 "status": "running",
598 "description": params.description,
599 "terminal_api": true
600 }))
601 }
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607 use tempfile::TempDir;
608
609 #[tokio::test]
610 async fn test_bash_echo() {
611 let temp_dir = TempDir::new().unwrap();
612 let tool = BashTool::new();
613 let context = ToolContext::new("test", temp_dir.path());
614
615 let result = tool
616 .execute(
617 json!({
618 "command": "echo 'Hello, World!'"
619 }),
620 &context,
621 )
622 .await;
623
624 assert!(!result.is_error);
625 assert!(result.content.contains("Hello, World!"));
626 }
627
628 #[tokio::test]
629 async fn test_bash_with_cwd() {
630 let temp_dir = TempDir::new().unwrap();
631 let tool = BashTool::new();
632 let context = ToolContext::new("test", temp_dir.path());
633
634 let result = tool
635 .execute(
636 json!({
637 "command": "pwd"
638 }),
639 &context,
640 )
641 .await;
642
643 assert!(!result.is_error);
644 assert!(result.content.contains(temp_dir.path().to_str().unwrap()));
645 }
646
647 #[tokio::test]
648 async fn test_bash_failure() {
649 let temp_dir = TempDir::new().unwrap();
650 let tool = BashTool::new();
651 let context = ToolContext::new("test", temp_dir.path());
652
653 let result = tool
654 .execute(
655 json!({
656 "command": "exit 1"
657 }),
658 &context,
659 )
660 .await;
661
662 assert!(result.is_error);
663 assert!(result.content.contains("exit code 1"));
664 }
665
666 #[tokio::test]
667 async fn test_bash_stderr() {
668 let temp_dir = TempDir::new().unwrap();
669 let tool = BashTool::new();
670 let context = ToolContext::new("test", temp_dir.path());
671
672 let result = tool
673 .execute(
674 json!({
675 "command": "echo 'error message' >&2"
676 }),
677 &context,
678 )
679 .await;
680
681 assert!(!result.is_error);
682 assert!(result.content.contains("error message"));
683 }
684
685 #[tokio::test]
686 async fn test_bash_timeout() {
687 let temp_dir = TempDir::new().unwrap();
688 let tool = BashTool::new();
689 let context = ToolContext::new("test", temp_dir.path());
690
691 let result = tool
692 .execute(
693 json!({
694 "command": "sleep 10",
695 "timeout": 100
696 }),
697 &context,
698 )
699 .await;
700
701 assert!(result.is_error);
702 assert!(result.content.contains("timed out"));
703 }
704
705 #[test]
706 fn test_bash_tool_properties() {
707 let tool = BashTool::new();
708 assert_eq!(tool.name(), "Bash");
709 assert_eq!(tool.kind(), ToolKind::Execute);
710 assert!(tool.requires_permission());
711 }
712
713 #[test]
714 fn test_shell_operator_detection() {
715 assert!(contains_shell_operator("ls && rm -rf /"));
717 assert!(contains_shell_operator("cat file || echo fail"));
718 assert!(contains_shell_operator("echo a; echo b"));
719 assert!(contains_shell_operator("cat file | grep secret"));
720 assert!(contains_shell_operator("echo $(whoami)"));
721 assert!(contains_shell_operator("echo `whoami`"));
722 assert!(contains_shell_operator("echo a\necho b"));
723
724 assert!(!contains_shell_operator("npm run build"));
726 assert!(!contains_shell_operator("git status"));
727 assert!(!contains_shell_operator("cargo test --release"));
728 assert!(!contains_shell_operator("ls -la /tmp"));
729 assert!(!contains_shell_operator("echo 'hello world'"));
730 }
731
732 #[test]
733 fn test_shell_operator_prefix_matching() {
734 let prefix = "npm run ";
736 let command = "npm run build && malicious";
737
738 let remainder = &command[prefix.len()..];
740 assert!(contains_shell_operator(remainder));
741
742 let safe_command = "npm run build --watch";
744 let safe_remainder = &safe_command[prefix.len()..];
745 assert!(!contains_shell_operator(safe_remainder));
746 }
747}