claude_code_acp/mcp/tools/
bash.rs

1//! Bash tool implementation
2//!
3//! Executes shell commands with security protections.
4//! Supports both direct process execution and Client-side PTY via Terminal API.
5
6use 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
23// Process group management
24use process_wrap::tokio::*;
25
26/// Maximum output size in characters
27const MAX_OUTPUT_SIZE: usize = 30_000;
28
29/// Shell operators that indicate command chaining (security risk)
30///
31/// These operators allow chaining multiple commands, which could be used
32/// for command injection attacks. Commands containing these operators
33/// should be handled with extra care in permission rules.
34const SHELL_OPERATORS: &[&str] = &["&&", "||", ";", "|", "$(", "`", "\n"];
35
36/// Check if a command string contains shell operators
37///
38/// This is used to prevent command injection when matching prefix-based
39/// permission rules. For example, if a rule allows `npm run:*`, we must
40/// ensure that `npm run build && rm -rf /` doesn't match by detecting
41/// the `&&` operator in the remainder after the prefix.
42///
43/// # Examples
44///
45/// ```
46/// use claude_code_acp::mcp::tools::contains_shell_operator;
47///
48/// assert!(contains_shell_operator("ls && rm -rf /"));
49/// assert!(contains_shell_operator("cat file | grep secret"));
50/// assert!(contains_shell_operator("$(whoami)"));
51/// assert!(!contains_shell_operator("npm run build"));
52/// ```
53pub fn contains_shell_operator(command: &str) -> bool {
54    SHELL_OPERATORS.iter().any(|op| command.contains(op))
55}
56
57/// Bash tool for executing shell commands
58#[derive(Debug, Default)]
59pub struct BashTool;
60
61/// Bash tool input parameters
62#[derive(Debug, Deserialize)]
63struct BashInput {
64    /// The command to execute
65    command: String,
66    /// Optional description of what the command does
67    #[serde(default)]
68    description: Option<String>,
69    /// Optional timeout in milliseconds
70    #[serde(default)]
71    timeout: Option<u64>,
72    /// Run command in background (returns immediately with shell ID)
73    #[serde(default)]
74    run_in_background: Option<bool>,
75}
76
77impl BashTool {
78    /// Create a new Bash tool instance
79    pub fn new() -> Self {
80        Self
81    }
82
83    /// Check permission before executing the tool
84    ///
85    /// Note: Permission checking is now handled at the SDK level.
86    /// The SDK's `mcp_message` handler calls `can_use_tool` callback before executing MCP tools.
87    /// This method is kept for potential future tool-specific permission logic.
88    fn check_permission(
89        &self,
90        _input: &serde_json::Value,
91        _context: &ToolContext,
92    ) -> Option<ToolResult> {
93        // Permission check is handled by SDK's can_use_tool callback
94        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 // Command execution requires permission
139    }
140
141    async fn execute(&self, input: serde_json::Value, context: &ToolContext) -> ToolResult {
142        // Check permission before executing
143        if let Some(result) = self.check_permission(&input, context) {
144            return result;
145        }
146
147        // Parse input
148        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        // Prefer Terminal API when available (Client-side PTY)
154        if let Some(terminal_client) = context.terminal_client() {
155            if params.run_in_background.unwrap_or(false) {
156                return self
157                    .execute_terminal_background(&params, terminal_client, context)
158                    .await;
159            }
160            return self
161                .execute_terminal_foreground(&params, terminal_client, context)
162                .await;
163        }
164
165        // Fall back to direct process execution
166        if params.run_in_background.unwrap_or(false) {
167            return self.execute_background(&params, context);
168        }
169
170        self.execute_foreground(&params, context).await
171    }
172}
173
174impl BashTool {
175    /// Execute command in foreground (blocking)
176    async fn execute_foreground(&self, params: &BashInput, context: &ToolContext) -> ToolResult {
177        let cmd_start = Instant::now();
178
179        // Use timeout as specified by user, without limiting maximum
180        let timeout_ms = params.timeout;
181
182        // Stage 1: Build the command
183        let build_start = Instant::now();
184        let mut cmd = Command::new("bash");
185        cmd.arg("-c")
186            .arg(&params.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        // Stage 2: Execute with or without timeout
200        let exec_start = Instant::now();
201        let output = if let Some(ms) = timeout_ms {
202            // User specified timeout - apply it
203            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            // No timeout specified - execute without timeout limit
229            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        // Stage 3: Process output
246        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        // Add stdout
253        if !stdout.is_empty() {
254            result_text.push_str(&stdout);
255        }
256
257        // Add stderr (prefixed if there's also stdout)
258        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        // Truncate if too long
266        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        // Handle empty output
273        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        // Log execution summary
284        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    /// Execute command in background (non-blocking)
320    fn execute_background(&self, params: &BashInput, context: &ToolContext) -> ToolResult {
321        // Get background process manager
322        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        // Build the command with process-wrap for process group support
330        let mut cmd = CommandWrap::with_new("bash", |c| {
331            c.arg("-c")
332                .arg(&params.command)
333                .current_dir(&context.cwd)
334                .stdout(Stdio::piped())
335                .stderr(Stdio::piped());
336        });
337
338        // Add platform-specific wrapper for process group management
339        #[cfg(unix)]
340        cmd.wrap(ProcessGroup::leader());
341
342        #[cfg(windows)]
343        cmd.wrap(JobObject::new());
344
345        // Spawn the process
346        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        // Extract stdout and stderr BEFORE wrapping (ChildWrapper doesn't expose them)
352        let stdout = wrapped_child.stdout().take();
353        let stderr = wrapped_child.stderr().take();
354
355        // Generate shell ID
356        let shell_id = format!("shell-{}", Uuid::new_v4().simple());
357
358        // Create wrapped child handle (stdout/stderr not stored in handle)
359        let child_handle = ChildHandle::Wrapped {
360            child: Arc::new(tokio::sync::Mutex::new(WrappedChild::new(wrapped_child))),
361        };
362
363        // Create background terminal
364        let terminal = BackgroundTerminal::new_running(child_handle);
365
366        // Get reference to output buffer for the read task
367        let output_buffer = match &terminal {
368            BackgroundTerminal::Running { output_buffer, .. } => output_buffer.clone(),
369            BackgroundTerminal::Finished { .. } => unreachable!(),
370        };
371
372        // Register with manager
373        let shell_id_clone = shell_id.clone();
374        manager.register(shell_id.clone(), terminal);
375
376        // Spawn task to read output
377        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            // Read stdout
383            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            // Read stderr
396            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            // Wait for process to finish and update terminal state
412            // Use get() instead of get_mut() because BackgroundTerminal contains ChildHandle
413            // We only need a shared reference to clone the ChildHandle
414            if let Some(terminal_ref) = manager_clone.get(&shell_id_clone) {
415                if let BackgroundTerminal::Running { child, .. } = &*terminal_ref {
416                    // Clone the ChildHandle to hold it across await points
417                    let mut child_handle = child.clone();
418                    drop(terminal_ref); // Release DashMap read lock before await
419
420                    // ChildHandle::wait() handles locking internally
421                    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        // Return immediately with shell ID
436        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    /// Execute command using Terminal API in foreground (blocking)
447    ///
448    /// Uses Client-side PTY for execution, which provides better terminal
449    /// emulation and real-time output streaming.
450    async fn execute_terminal_foreground(
451        &self,
452        params: &BashInput,
453        terminal_client: &Arc<TerminalClient>,
454        context: &ToolContext,
455    ) -> ToolResult {
456        // Use timeout as specified by user, without limiting maximum
457        let timeout_ms = params.timeout;
458
459        // Create terminal with bash -c command
460        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        // Send ToolCallUpdate with terminal_id immediately
474        // This allows the client (e.g., Zed) to start showing terminal output
475        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            // Continue even if notification fails - tool should still work
482        }
483
484        // Wait for command to exit with optional timeout
485        let exit_result = if let Some(ms) = timeout_ms {
486            // User specified timeout - apply it
487            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            // No timeout - wait indefinitely
495            Ok(terminal_client.wait_for_exit(terminal_id.clone()).await)
496        };
497
498        // Get output regardless of exit status
499        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        // Release terminal (ignore result - best effort)
505        drop(terminal_client.release(terminal_id).await);
506
507        // Process result
508        match exit_result {
509            Ok(Ok(exit_response)) => {
510                let exit_status = exit_response.exit_status;
511                // exit_code is Option<u32>, convert to i32 for compatibility
512                #[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                // Timeout occurred - timeout_ms must be Some in this branch
546                let ms = timeout_ms.unwrap_or(0);
547                ToolResult::error(format!("Command timed out after {}ms\n{}", ms, output))
548            }
549        }
550    }
551
552    /// Execute command using Terminal API in background (non-blocking)
553    ///
554    /// Creates a terminal via Client-side PTY and returns immediately.
555    /// The terminal_id serves as the shell_id for later queries.
556    async fn execute_terminal_background(
557        &self,
558        params: &BashInput,
559        terminal_client: &Arc<TerminalClient>,
560        context: &ToolContext,
561    ) -> ToolResult {
562        // Create terminal with bash -c command
563        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, // No output limit for background
569            )
570            .await
571        {
572            Ok(id) => id,
573            Err(e) => return ToolResult::error(format!("Failed to create terminal: {}", e)),
574        };
575
576        // Use terminal_id as shell_id (prefixed for clarity)
577        let shell_id = format!("term-{}", terminal_id.0.as_ref());
578
579        // Send ToolCallUpdate with terminal_id immediately
580        // This allows the client (e.g., Zed) to start showing terminal output
581        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            // Continue even if notification fails - tool should still work
588        }
589
590        // Return immediately with shell ID
591        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        // Commands with shell operators (should be detected)
716        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        // Safe commands (should not be detected)
725        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        // Simulate prefix matching scenario
735        let prefix = "npm run ";
736        let command = "npm run build && malicious";
737
738        // After prefix match, check remainder for operators
739        let remainder = &command[prefix.len()..];
740        assert!(contains_shell_operator(remainder));
741
742        // Safe case
743        let safe_command = "npm run build --watch";
744        let safe_remainder = &safe_command[prefix.len()..];
745        assert!(!contains_shell_operator(safe_remainder));
746    }
747}