mcp_servers/coding/tools/bash/
mod.rs1use 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 pub command: String,
15 pub timeout: Option<u64>,
17 pub description: Option<String>,
19 #[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 pub output: String,
29 pub exit_code: i32,
31 pub killed: bool,
33}
34
35#[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
83pub 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
136struct 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}