use crate::core::{Tool, ToolArgs, ToolError, ToolResult};
use anyhow::Result;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::atomic::Ordering;
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
const MAX_OUTPUT_LENGTH: usize = 30_000;
const DEFAULT_TIMEOUT_MS: u64 = 120_000; const POLL_INTERVAL: Duration = Duration::from_millis(500);
const INTERACTIVE_PATTERNS: &[&str] = &[
"password:",
"Password:",
"PASSWORD:",
"passphrase",
"Enter passphrase",
"[Y/n]",
"[y/N]",
"[yes/no]",
"[YES/NO]",
"Are you sure",
"are you sure",
"Verification code:",
"Enter MFA",
"Username:",
"username:",
"PIN:",
"Enter PIN",
"Confirm deletion",
"Continue? [Y/n]",
"Press any key to continue",
"Press ENTER to continue",
"Type 'yes' to continue",
"Authentication failed",
"Permission denied (publickey,password)",
];
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct BashParams {
pub command: String,
pub timeout: Option<u64>,
pub workdir: Option<String>,
pub description: Option<String>,
}
pub struct BashTool {
name: String,
}
impl BashTool {
pub fn new() -> Self {
Self {
name: "bash".to_string(),
}
}
}
impl Default for BashTool {
fn default() -> Self {
Self::new()
}
}
fn detect_interactive_prompt(text: &str) -> Option<String> {
for pattern in INTERACTIVE_PATTERNS {
if text.contains(pattern) {
return Some(pattern.to_string());
}
}
None
}
impl Tool for BashTool {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
"Executes a given bash command in a persistent shell session with optional timeout"
}
fn signature(&self) -> &str {
"bash --command <command> [--timeout <ms>] [--workdir <dir>] [--description <desc>]"
}
fn validate_args(&self, args: &ToolArgs) -> Result<(), ToolError> {
if args.get_named_arg("command").is_none() && args.args.is_empty() {
return Err(ToolError::InvalidArgs {
message: "bash tool requires a 'command' argument".to_string(),
});
}
Ok(())
}
fn execute(
&mut self,
args: &ToolArgs,
state: &Arc<Mutex<crate::state::ToolState>>,
) -> Result<ToolResult> {
let params = parse_bash_args(args)?;
let cancel_signal = {
let state_guard =
state.lock().map_err(|e| anyhow::anyhow!("Failed to lock tool state: {}", e))?;
state_guard.cancel_signal()
};
let workdir = params.workdir.map(PathBuf::from).unwrap_or_else(|| {
state
.lock()
.map(|s| s.working_directory.clone())
.unwrap_or_else(|_| {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
})
});
let timeout = params.timeout.unwrap_or(DEFAULT_TIMEOUT_MS);
let mut cmd = if cfg!(target_os = "windows") {
let mut c = Command::new("powershell.exe");
c.arg("-NoProfile")
.arg("-NonInteractive")
.arg("-Command");
c
} else {
let mut c = Command::new("/bin/sh");
c.arg("-c");
c
};
let start = Instant::now();
let mut child = cmd
.arg(¶ms.command)
.current_dir(&workdir)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| anyhow::anyhow!("Failed to execute command: {}", e))?;
#[derive(Debug)]
enum Source {
Stdout,
Stderr,
}
let (tx, rx) = mpsc::channel::<(Source, String)>();
if let Some(stdout) = child.stdout.take() {
let tx = tx.clone();
thread::spawn(move || {
let reader = BufReader::new(stdout);
for line in reader.lines() {
match line {
Ok(l) => {
if tx.send((Source::Stdout, l)).is_err() {
break;
}
}
Err(_) => break,
}
}
});
}
if let Some(stderr) = child.stderr.take() {
let tx = tx.clone();
thread::spawn(move || {
let reader = BufReader::new(stderr);
for line in reader.lines() {
match line {
Ok(l) => {
if tx.send((Source::Stderr, l)).is_err() {
break;
}
}
Err(_) => break,
}
}
});
}
drop(tx);
let mut output = String::new();
let mut truncated = false;
let mut cancelled = false;
let mut interactive_detected: Option<String> = None;
loop {
match rx.recv_timeout(POLL_INTERVAL) {
Ok((_source, line)) => {
if interactive_detected.is_none() {
if let Some(pattern) = detect_interactive_prompt(&line) {
interactive_detected = Some(pattern);
let _ = child.kill();
break;
}
}
if output.len() + line.len() + 1 > MAX_OUTPUT_LENGTH {
truncated = true;
break;
}
output.push_str(&line);
output.push('\n');
}
Err(mpsc::RecvTimeoutError::Timeout) => {
if cancel_signal.load(Ordering::Relaxed) {
cancelled = true;
let _ = child.kill();
break;
}
if start.elapsed() > Duration::from_millis(timeout) {
truncated = true;
let _ = child.kill();
break;
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
break;
}
}
}
if cancelled {
let _ = child.wait();
return Ok(ToolResult::success_with_data(
"Command cancelled by user (ESC)".to_string(),
serde_json::json!({
"exit_code": -1,
"description": params.description,
}),
));
}
if let Some(ref pattern) = interactive_detected {
let _ = child.wait();
return Ok(ToolResult::success_with_data(
format!(
"Command appears to require interactive input (detected: \"{}\").\n\n\
Interactive commands are not supported because stdin is set to null.\n\
Use non-interactive alternatives such as:\n \
- sshpass -p 'PASS' ssh ...\n \
- ssh -o BatchMode=yes -o PasswordAuthentication=no ...\n \
- echo 'PASS' | sudo -S ...\n \
- Use --yes / -y / --non-interactive flags\n \
- Use heredocs or pipes to provide input\n",
pattern
),
serde_json::json!({
"exit_code": -1,
"description": params.description,
}),
));
}
let exit_status = child
.wait()
.map_err(|e| anyhow::anyhow!("Failed to wait for command: {}", e))?;
let exit_code = exit_status.code().unwrap_or(-1);
let mut result_output = output.clone();
if truncated || start.elapsed() > Duration::from_millis(timeout) {
result_output.push_str("\n\n<bash_metadata>\n");
if result_output.len() >= MAX_OUTPUT_LENGTH {
result_output.push_str(&format!(
"bash tool truncated output as it exceeded {} char limit\n",
MAX_OUTPUT_LENGTH
));
}
if start.elapsed() > Duration::from_millis(timeout) {
result_output.push_str(&format!(
"bash tool terminated command after exceeding timeout {} ms. \
This usually means the command is a blocking or interactive \
command (e.g. ssh password prompt, sudo, mysql shell) or a \
long-running process.\n\
If the command needs interactive input, use non-interactive \
alternatives:\n \
- sshpass -p 'PASS' ssh ...\n \
- ssh -o BatchMode=yes -o PasswordAuthentication=no ...\n \
- echo 'PASS' | sudo -S ...\n \
- Use --yes / -y / --non-interactive flags\n \
- Use heredocs or pipes to provide input\n\
If the command genuinely needs more time, retry with a \
larger timeout value in milliseconds.\n",
timeout
));
}
result_output.push_str("</bash_metadata>");
}
Ok(ToolResult::success_with_data(
result_output,
serde_json::json!({
"exit_code": exit_code,
"description": params.description,
}),
))
}
fn get_parameters_schema(&self) -> serde_json::Value {
let schema = schemars::schema_for!(BashParams);
serde_json::to_value(schema).unwrap_or_default()
}
}
fn parse_bash_args(args: &ToolArgs) -> Result<BashParams> {
let command = args
.get_named_arg("command")
.cloned()
.or_else(|| args.args.first().cloned())
.ok_or_else(|| anyhow::anyhow!("command is required"))?;
let timeout = args
.get_named_arg("timeout")
.and_then(|s| s.parse::<u64>().ok());
let workdir = args
.get_named_arg("workdir")
.cloned()
.filter(|s| !s.is_empty());
let description = args.get_named_arg("description").cloned();
Ok(BashParams {
command,
timeout,
workdir,
description,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bash_tool_creation() {
let tool = BashTool::new();
assert_eq!(tool.name(), "bash");
}
#[test]
fn test_bash_tool_echo() {
let mut tool = BashTool::new();
let state = Arc::new(Mutex::new(crate::state::ToolState::new()));
let args = ToolArgs::from_args(&["echo hello"]);
let result = tool.execute(&args, &state).unwrap();
assert!(result.success);
assert!(result.message.contains("hello"));
}
#[test]
fn test_bash_tool_with_timeout() {
let mut tool = BashTool::new();
let state = Arc::new(Mutex::new(crate::state::ToolState::new()));
let args = ToolArgs::with_named_args(
vec!["sleep 1".to_string()],
vec![("timeout".to_string(), "100".to_string())]
.into_iter()
.collect(),
);
let result = tool.execute(&args, &state).unwrap();
assert!(result.message.contains("timeout") || result.success);
}
#[test]
fn test_bash_tool_validation() {
let tool = BashTool::new();
let args = ToolArgs::from_args(&[]);
let result = tool.validate_args(&args);
assert!(result.is_err());
}
#[test]
fn test_detect_interactive_prompt_password() {
assert_eq!(
detect_interactive_prompt("pod@10.2.41.21's password:"),
Some("password:".to_string())
);
}
#[test]
fn test_detect_interactive_prompt_yn() {
assert_eq!(
detect_interactive_prompt("Do you want to continue? [Y/n]"),
Some("[Y/n]".to_string())
);
}
#[test]
fn test_detect_interactive_prompt_none() {
assert_eq!(detect_interactive_prompt("Hello world"), None);
}
#[test]
fn test_detect_interactive_prompt_permission_denied() {
assert_eq!(
detect_interactive_prompt("Permission denied (publickey,password)."),
Some("Permission denied (publickey,password)".to_string())
);
}
}