use anyhow::Result;
use async_trait::async_trait;
#[cfg(windows)]
use process_wrap::tokio::JobObject;
#[cfg(unix)]
use process_wrap::tokio::ProcessGroup;
use process_wrap::tokio::{CommandWrap, KillOnDrop};
use serde::Deserialize;
use serde_json::{json, Value};
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::AsyncReadExt;
use tokio_util::sync::CancellationToken;
use super::{Tool, ToolOutput};
use crate::command_sandbox::CommandSandbox;
pub struct BashTool {
sandbox: Arc<CommandSandbox>,
}
impl BashTool {
pub fn new(sandbox: Arc<CommandSandbox>) -> Self {
Self { sandbox }
}
}
#[derive(Deserialize)]
struct Params {
command: String,
#[serde(default)]
timeout: Option<u64>,
#[serde(default)]
#[serde(rename = "description")]
_description: Option<String>,
}
#[async_trait]
impl Tool for BashTool {
fn name(&self) -> &str {
"Bash"
}
fn description(&self) -> &str {
"Execute a bash command. Use for git, build tools, or other CLI operations."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to execute"
},
"timeout": {
"type": "integer",
"description": "Timeout in milliseconds (max 600000, default 120000)"
},
"description": {
"type": "string",
"description": "Short description of what the command does"
}
},
"required": ["command"]
})
}
fn is_read_only(&self) -> bool {
false }
fn summarize(&self, input: &Value) -> String {
let cmd = input["command"].as_str().unwrap_or("?");
if cmd.len() > 80 {
format!("{}...", crate::utils::truncate_str(cmd, 77))
} else {
cmd.to_string()
}
}
async fn execute(&self, input: Value, cancel: CancellationToken) -> Result<ToolOutput> {
let params: Params = serde_json::from_value(input)?;
let timeout_ms = params.timeout.unwrap_or(120_000).min(600_000);
let timeout = Duration::from_millis(timeout_ms);
let mut inner = self.sandbox.command(¶ms.command)?;
inner.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut command = CommandWrap::from(inner);
command.wrap(KillOnDrop);
#[cfg(unix)]
command.wrap(ProcessGroup::leader());
#[cfg(windows)]
command.wrap(JobObject);
let mut child = match command.spawn() {
Ok(c) => c,
Err(e) => {
return Ok(ToolOutput {
content: format!("Failed to execute command: {e}"),
is_error: true,
});
}
};
let mut stdout_pipe = child.stdout().take();
let mut stderr_pipe = child.stderr().take();
let stdout_task = tokio::spawn(async move {
let mut buf = Vec::new();
if let Some(p) = stdout_pipe.as_mut() {
let _ = p.read_to_end(&mut buf).await;
}
buf
});
let stderr_task = tokio::spawn(async move {
let mut buf = Vec::new();
if let Some(p) = stderr_pipe.as_mut() {
let _ = p.read_to_end(&mut buf).await;
}
buf
});
let outcome = tokio::select! {
status = child.wait() => Outcome::Finished(status),
_ = cancel.cancelled() => Outcome::Cancelled,
_ = tokio::time::sleep(timeout) => Outcome::TimedOut,
};
if !matches!(outcome, Outcome::Finished(_)) {
let _ = Box::into_pin(child.kill()).await;
}
let stdout = stdout_task.await.unwrap_or_default();
let stderr = stderr_task.await.unwrap_or_default();
let stdout_s = render_output(&stdout, "stdout");
let stderr_s = render_output(&stderr, "stderr");
let mut content = String::new();
if !stdout_s.is_empty() {
content.push_str(&stdout_s);
}
if !stderr_s.is_empty() {
if !content.is_empty() {
content.push('\n');
}
content.push_str(&stderr_s);
}
let is_error = match &outcome {
Outcome::Finished(Ok(status)) => {
if !status.success() {
content.push_str(&format!("\nExit code: {status}"));
}
!status.success()
}
Outcome::Finished(Err(e)) => {
if !content.is_empty() {
content.push('\n');
}
content.push_str(&format!("wait error: {e}"));
true
}
Outcome::Cancelled => {
if !content.is_empty() {
content.push('\n');
}
content.push_str("Interrupted by user.");
true
}
Outcome::TimedOut => {
if !content.is_empty() {
content.push('\n');
}
content.push_str(&format!("Command timed out after {timeout_ms}ms"));
true
}
};
if content.len() > 100_000 {
content.truncate(100_000);
content.push_str("\n... (output truncated)");
}
Ok(ToolOutput { content, is_error })
}
}
fn render_output(bytes: &[u8], stream: &str) -> String {
match std::str::from_utf8(bytes) {
Ok(text) if !bytes.contains(&0) => text.to_string(),
_ => format!("[binary {stream} suppressed: {} bytes]", bytes.len()),
}
}
enum Outcome {
Finished(std::io::Result<std::process::ExitStatus>),
Cancelled,
TimedOut,
}
#[cfg(test)]
mod tests {
use super::*;
fn token() -> CancellationToken {
CancellationToken::new()
}
fn tool() -> BashTool {
BashTool::new(Arc::new(CommandSandbox::unrestricted_for_tests()))
}
#[tokio::test]
async fn bash_echo() {
let tool = tool();
let result = tool
.execute(json!({"command": "echo hello"}), token())
.await
.unwrap();
assert!(!result.is_error);
assert!(result.content.trim().contains("hello"));
}
#[tokio::test]
async fn bash_exit_code() {
let tool = tool();
let result = tool
.execute(json!({"command": "exit 1"}), token())
.await
.unwrap();
assert!(result.is_error);
assert!(result.content.contains("Exit code"));
}
#[tokio::test]
async fn bash_captures_stderr() {
let tool = tool();
let result = tool
.execute(json!({"command": "echo err >&2"}), token())
.await
.unwrap();
assert!(result.content.contains("err"));
}
#[test]
fn preserves_utf8_output() {
assert_eq!(render_output("héllo\n".as_bytes(), "stdout"), "héllo\n");
}
#[test]
fn suppresses_invalid_utf8_output() {
assert_eq!(
render_output(&[0xff, 0xfe, 0xfd], "stdout"),
"[binary stdout suppressed: 3 bytes]"
);
}
#[test]
fn suppresses_nul_containing_output() {
assert_eq!(
render_output(b"text\0more", "stderr"),
"[binary stderr suppressed: 9 bytes]"
);
}
#[cfg(unix)]
#[tokio::test]
async fn binary_stdout_does_not_hide_text_stderr() {
let result = tool()
.execute(
json!({"command": "printf '\\377'; printf 'warning' >&2"}),
token(),
)
.await
.unwrap();
assert!(!result.is_error);
assert_eq!(
result.content,
"[binary stdout suppressed: 1 bytes]\nwarning"
);
assert!(!result.content.contains('\u{fffd}'));
}
#[tokio::test]
async fn bash_timeout() {
let tool = tool();
let result = tool
.execute(json!({"command": "sleep 10", "timeout": 100}), token())
.await
.unwrap();
assert!(result.is_error);
assert!(result.content.contains("timed out"));
}
#[tokio::test]
async fn bash_cancellation() {
let tool = tool();
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(100)).await;
cancel_clone.cancel();
});
let start = std::time::Instant::now();
let result = tool
.execute(
json!({
"command": "trap '' HUP; sleep 30 & wait",
"timeout": 60000
}),
cancel,
)
.await
.unwrap();
assert!(
start.elapsed() < Duration::from_secs(3),
"cancellation should kill the entire process tree (took {:?})",
start.elapsed()
);
assert!(result.is_error);
assert!(result.content.contains("Interrupted"));
}
}