Skip to main content

mcp_servers/coding/tools/bash/
mod.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::env::current_exe;
4use std::os::unix::process::CommandExt;
5use std::{collections::BTreeMap, path::Path, process::Stdio, time::Duration};
6use tokio::process::Command;
7
8use crate::coding::error::BashError;
9
10#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
11#[serde(rename_all = "camelCase")]
12pub struct BashInput {
13    /// The command to execute
14    pub command: String,
15    /// Optional timeout in milliseconds (max 600000)
16    pub timeout: Option<u64>,
17    /// Clear, concise description of what this command does in 5-10 words
18    pub description: Option<String>,
19    /// Set to true to run this command in the background
20    #[serde(alias = "run_in_background")]
21    pub run_in_background: Option<bool>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
25#[serde(rename_all = "camelCase")]
26pub struct BashOutput {
27    /// Combined stdout and stderr output
28    pub output: String,
29    /// Exit code of the command
30    pub exit_code: i32,
31    /// Whether the command was killed due to timeout
32    pub killed: bool,
33}
34
35/// Immutable environment overrides inherited by Bash commands.
36#[derive(Debug, Clone, Default, PartialEq, Eq)]
37pub struct BashEnvironment {
38    vars: BTreeMap<String, String>,
39}
40
41impl BashEnvironment {
42    pub fn new() -> Self {
43        Self::default()
44    }
45
46    pub fn with_var(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
47        self.vars.insert(name.into(), value.into());
48        self
49    }
50
51    pub fn with_path_prepend(self, directory: impl AsRef<Path>) -> Self {
52        let directory = directory.as_ref().to_string_lossy().into_owned();
53        let current = std::env::var("PATH").unwrap_or_default();
54        let mut entries = vec![directory.clone()];
55        entries.extend(current.split(':').filter(|entry| *entry != directory).map(str::to_owned));
56        self.with_var("PATH", entries.join(":"))
57    }
58
59    pub fn with_current_exe_dir_on_path(self) -> Self {
60        let Some(directory) = current_exe().ok().and_then(|path| path.parent().map(Path::to_path_buf)) else {
61            return self;
62        };
63        self.with_path_prepend(directory)
64    }
65
66    pub fn vars(&self) -> &BTreeMap<String, String> {
67        &self.vars
68    }
69}
70
71pub fn validate_args(args: &BashInput) -> Result<(), BashError> {
72    if args.command.trim() == "rm" {
73        return Err(BashError::Forbidden("Deleting files with a bare 'rm' is not allowed".to_string()));
74    }
75
76    if args.timeout.is_some_and(|timeout_ms| timeout_ms > 600_000) {
77        return Err(BashError::TimeoutTooLarge);
78    }
79
80    Ok(())
81}
82
83/// Runs `args.command` to completion; `args.timeout: None` means no timeout.
84pub async fn execute_command(
85    args: BashInput,
86    cwd: Option<&Path>,
87    environment: &BashEnvironment,
88) -> Result<BashOutput, BashError> {
89    validate_args(&args)?;
90
91    let timeout = args.timeout.map(Duration::from_millis);
92
93    let mut command = Command::new("bash");
94    command.arg("-c").arg(&args.command);
95    if let Some(dir) = cwd {
96        command.current_dir(dir);
97    }
98    command.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
99    command.envs(environment.vars());
100    command.as_std_mut().process_group(0);
101
102    let spawn_failed = |reason: String| BashError::SpawnFailed { command: args.command.clone(), reason };
103    let child = command.spawn().map_err(|error| spawn_failed(error.to_string()))?;
104    let mut process_group = ProcessGroupGuard::new(child.id());
105    let mut child_task = tokio::spawn(child.wait_with_output());
106
107    let output = match timeout {
108        Some(timeout) => {
109            tokio::select! {
110                result = &mut child_task => result,
111                () = tokio::time::sleep(timeout) => {
112                    process_group.kill();
113                    let _ = tokio::time::timeout(Duration::from_secs(5), &mut child_task).await;
114                    return Ok(BashOutput {
115                        output: format!("Command timed out after {}ms", timeout.as_millis()),
116                        exit_code: -1,
117                        killed: true,
118                    });
119                }
120            }
121        }
122        None => child_task.await,
123    };
124    let output = output
125        .map_err(|error| error.to_string())
126        .and_then(|output| output.map_err(|error| error.to_string()))
127        .map_err(spawn_failed)?;
128    process_group.disarm();
129
130    let stdout = String::from_utf8_lossy(&output.stdout);
131    let stderr = String::from_utf8_lossy(&output.stderr);
132    let exit_code = output.status.code().unwrap_or(-1);
133    Ok(BashOutput { output: format!("{stdout}{stderr}"), exit_code, killed: false })
134}
135
136/// Kills an entire process group on drop unless disarmed after a clean exit.
137///
138/// `Child::kill`/`kill_on_drop` signal only the direct child (`bash`), leaving
139/// its spawned commands orphaned; killing the process group covers the tree.
140/// Disarming matters because the kernel may recycle the group id once reaped.
141struct ProcessGroupGuard {
142    pgid: Option<u32>,
143}
144
145impl ProcessGroupGuard {
146    fn new(pgid: Option<u32>) -> Self {
147        Self { pgid }
148    }
149
150    fn kill(&mut self) {
151        if let Some(pgid) = self.pgid.take() {
152            unsafe {
153                libc::killpg(pgid as libc::pid_t, libc::SIGKILL);
154            }
155        }
156    }
157
158    fn disarm(&mut self) {
159        self.pgid = None;
160    }
161}
162
163impl Drop for ProcessGroupGuard {
164    fn drop(&mut self) {
165        self.kill();
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[tokio::test]
174    async fn environment_overrides_are_passed_to_bash() {
175        let environment = BashEnvironment::new().with_var("AETHER_TEST_VALUE", "present");
176        let output = execute_command(
177            BashInput { command: "printf '%s' \"$AETHER_TEST_VALUE\"".into(), ..Default::default() },
178            None,
179            &environment,
180        )
181        .await
182        .unwrap();
183        assert_eq!(output.output, "present");
184    }
185}