use crate::sandbox::NoSandbox;
use crate::tools::{Tool, ToolContext, ToolResult};
use anyhow::Result;
use async_trait::async_trait;
pub struct BashTool;
#[async_trait]
impl Tool for BashTool {
fn name(&self) -> &str {
"bash"
}
fn description(&self) -> &str {
"Execute a bash/shell command in the working directory. \
Returns stdout, stderr, and exit code. \
Output is truncated to 50KB (first 25KB + last 25KB) if too large."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The shell command to execute"
},
"timeout": {
"type": "integer",
"description": "Timeout in seconds (default: 120)"
}
},
"required": ["command"]
})
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> Result<ToolResult> {
let command = match args["command"].as_str() {
Some(c) => c.to_string(),
None => {
return Ok(ToolResult {
output: "Missing required argument: command".to_string(),
is_error: true,
});
}
};
let timeout_secs = args["timeout"].as_u64().unwrap_or(120);
let sandbox = NoSandbox::new(ctx.working_dir.clone());
let exec_result = match sandbox.exec(&command, timeout_secs).await {
Ok(r) => r,
Err(e) => {
return Ok(ToolResult {
output: format!("Failed to execute command: {}", e),
is_error: true,
});
}
};
if exec_result.timed_out {
return Ok(ToolResult {
output: format!("Command timed out after {}s", timeout_secs),
is_error: true,
});
}
const MAX_BYTES: usize = 50 * 1024;
const HALF_MAX: usize = MAX_BYTES / 2;
let stdout = truncate_output(&exec_result.stdout, HALF_MAX);
let stderr = truncate_output(&exec_result.stderr, HALF_MAX);
let output = format!(
"Exit code: {}\nStdout:\n{}\nStderr:\n{}",
exec_result.exit_code, stdout, stderr
);
Ok(ToolResult {
output,
is_error: exec_result.exit_code != 0,
})
}
}
fn truncate_output(s: &str, max_bytes: usize) -> String {
if s.len() <= max_bytes {
return s.to_string();
}
let half = max_bytes / 2;
let first_end = find_char_boundary(s, half);
let last_start = s.len() - find_char_boundary_from_end(s, half);
format!(
"{}\n\n[... {} bytes truncated ...]\n\n{}",
&s[..first_end],
s.len() - max_bytes,
&s[last_start..]
)
}
fn find_char_boundary(s: &str, pos: usize) -> usize {
let mut p = pos.min(s.len());
while p > 0 && !s.is_char_boundary(p) {
p -= 1;
}
p
}
fn find_char_boundary_from_end(s: &str, len: usize) -> usize {
let total = s.len();
if len >= total {
return 0;
}
let mut start = total - len;
while start < total && !s.is_char_boundary(start) {
start += 1;
}
total - start
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn ctx() -> ToolContext {
ToolContext {
working_dir: PathBuf::from("/tmp"),
sandbox_enabled: false,
io: std::sync::Arc::new(crate::io::NullIO),
compact_mode: false,
lsp_client: std::sync::Arc::new(tokio::sync::Mutex::new(None)),
mcp_client: None,
nesting_depth: 0,
llm: std::sync::Arc::new(crate::llm::NullLlmProvider),
tools: std::sync::Arc::new(crate::tools::ToolRegistry::new()),
permissions: vec![],
formatters: std::collections::HashMap::new(),
}
}
#[tokio::test]
async fn test_bash_execute() {
let tool = BashTool;
let args = serde_json::json!({ "command": "echo hello" });
let result = tool.execute(args, &ctx()).await.unwrap();
assert!(!result.is_error);
assert!(result.output.contains("hello"));
assert!(result.output.contains("Exit code: 0"));
}
#[tokio::test]
async fn test_bash_exit_code() {
let tool = BashTool;
let args = serde_json::json!({ "command": "exit 1" });
let result = tool.execute(args, &ctx()).await.unwrap();
assert!(result.is_error);
assert!(result.output.contains("Exit code: 1"));
}
#[tokio::test]
async fn test_bash_timeout() {
let tool = BashTool;
let args = serde_json::json!({ "command": "sleep 10", "timeout": 1 });
let result = tool.execute(args, &ctx()).await.unwrap();
assert!(result.is_error);
assert!(result.output.contains("timed out"));
}
#[tokio::test]
async fn test_bash_missing_command() {
let tool = BashTool;
let args = serde_json::json!({});
let result = tool.execute(args, &ctx()).await.unwrap();
assert!(result.is_error);
assert!(result.output.contains("Missing required argument"));
}
#[test]
fn test_truncate_output_short() {
let s = "hello world";
assert_eq!(truncate_output(s, 100), "hello world");
}
#[test]
fn test_truncate_output_long() {
let s = "a".repeat(200);
let out = truncate_output(&s, 100);
assert!(out.contains("truncated"));
}
}