use crate::types::*;
pub type ConfirmFn = Box<dyn Fn(&str) -> bool + Send + Sync>;
use async_trait::async_trait;
use std::time::Duration;
use tokio::process::Command;
const READ_GRACE: Duration = Duration::from_secs(1);
pub struct BashTool {
pub cwd: Option<String>,
pub timeout: Duration,
pub max_output_bytes: usize,
pub deny_patterns: Vec<String>,
pub env_allowlist: Option<Vec<String>>,
pub confirm_fn: Option<ConfirmFn>,
}
impl Default for BashTool {
fn default() -> Self {
Self {
cwd: None,
timeout: Duration::from_secs(120),
max_output_bytes: 256 * 1024, deny_patterns: vec![
"rm -rf /".into(),
"rm -rf /*".into(),
"mkfs".into(),
"dd if=".into(),
":(){:|:&};:".into(), ],
confirm_fn: None,
env_allowlist: None,
}
}
}
impl BashTool {
pub fn new() -> Self {
Self::default()
}
pub fn with_cwd(mut self, cwd: impl Into<String>) -> Self {
self.cwd = Some(cwd.into());
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn with_env_allowlist(mut self, vars: Vec<String>) -> Self {
self.env_allowlist = Some(vars);
self
}
pub fn with_deny_patterns(mut self, patterns: Vec<String>) -> Self {
self.deny_patterns = patterns;
self
}
pub fn with_confirm(mut self, f: impl Fn(&str) -> bool + Send + Sync + 'static) -> Self {
self.confirm_fn = Some(Box::new(f));
self
}
}
#[async_trait]
impl AgentTool for BashTool {
fn name(&self) -> &str {
"bash"
}
fn label(&self) -> &str {
"Execute Command"
}
fn description(&self) -> &str {
"Execute a bash command and return stdout/stderr. Use for running scripts, installing packages, checking system state, etc."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to execute"
}
},
"required": ["command"]
})
}
async fn execute(
&self,
params: serde_json::Value,
ctx: ToolContext,
) -> Result<ToolResult, ToolError> {
let cancel = ctx.cancel;
let command = params["command"]
.as_str()
.ok_or_else(|| ToolError::InvalidArgs("missing 'command' parameter".into()))?;
for pattern in &self.deny_patterns {
if command.contains(pattern.as_str()) {
return Err(ToolError::Failed(format!(
"Command blocked by safety policy: contains '{}'. This pattern is denied for safety.",
pattern
)));
}
}
if let Some(ref confirm) = self.confirm_fn {
if !confirm(command) {
return Err(ToolError::Failed(
"Command was not confirmed by the user.".into(),
));
}
}
#[cfg_attr(not(unix), allow(unused_mut))]
let mut cmd = std::process::Command::new("bash");
#[cfg(unix)]
std::os::unix::process::CommandExt::process_group(&mut cmd, 0);
let mut cmd = Command::from(cmd);
cmd.arg("-c").arg(command);
if let Some(ref allow) = self.env_allowlist {
let keep: Vec<(String, String)> = std::env::vars()
.filter(|(k, _)| {
allow.iter().any(|a| a == k) || matches!(k.as_str(), "PATH" | "HOME" | "PWD")
})
.collect();
cmd.env_clear();
for (k, v) in keep {
cmd.env(k, v);
}
}
if let Some(ref cwd) = self.cwd {
cmd.current_dir(cwd);
}
cmd.stdin(std::process::Stdio::null());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
cmd.kill_on_drop(true);
let timeout = self.timeout;
let max_bytes = self.max_output_bytes;
if cancel.is_cancelled() {
return Err(ToolError::Cancelled);
}
let mut child = cmd
.spawn()
.map_err(|e| ToolError::Failed(format!("Failed to execute: {}", e)))?;
let mut group = GroupKill::new(child.id());
let (Some(mut child_out), Some(mut child_err)) = (child.stdout.take(), child.stderr.take())
else {
return Err(ToolError::Failed("Failed to capture output".into()));
};
let mut out = Capture::default();
let mut err = Capture::default();
let mut exited = None;
let outcome = {
let run = async {
let reads = async {
tokio::join!(
out.read(&mut child_out, max_bytes),
err.read(&mut child_err, max_bytes),
);
};
tokio::pin!(reads);
let mut drained = false;
exited = Some(tokio::select! {
() = &mut reads => {
drained = true;
child.wait().await
}
status = child.wait() => status,
});
drained || tokio::time::timeout(READ_GRACE, &mut reads).await.is_ok()
};
tokio::select! {
_ = cancel.cancelled() => None,
_ = tokio::time::sleep(timeout) => Some(None),
drained = run => Some(Some(drained)),
}
};
let cancelled = outcome.is_none();
let drained = match (outcome, &exited) {
(None, None) => return Err(ToolError::Cancelled),
(Some(None), None) => {
let _ = child.start_kill();
return Err(ToolError::Failed(format!(
"Command timed out after {}s. Output so far:\n{}",
timeout.as_secs(),
render_output(&out, &err)
)));
}
(Some(Some(drained)), _) => drained,
(_, Some(_)) => false,
};
let status = exited
.expect("`bash` has exited")
.map_err(|e| ToolError::Failed(format!("Failed to execute: {}", e)))?;
group.disarm();
if !drained {
tokio::spawn(async move {
let (mut sink_out, mut sink_err) = (tokio::io::sink(), tokio::io::sink());
let _ = tokio::join!(
tokio::io::copy(&mut child_out, &mut sink_out),
tokio::io::copy(&mut child_err, &mut sink_err),
);
});
}
if cancelled {
return Err(ToolError::Cancelled);
}
let exit_code = status.code().unwrap_or(-1);
let mut output = format!("Exit code: {}\n{}", exit_code, render_output(&out, &err));
if !drained {
output.push_str(
"\n\nNOTE: a background process still holds the command's output and was \
left running; what it prints from now on is discarded.",
);
}
Ok(ToolResult {
content: vec![Content::Text { text: output }],
details: serde_json::json!({
"exit_code": exit_code,
"success": exit_code == 0,
"output_still_open": !drained,
}),
})
}
}
struct GroupKill {
#[cfg(unix)]
pgid: Option<libc::pid_t>,
}
impl GroupKill {
fn new(pid: Option<u32>) -> Self {
#[cfg(not(unix))]
let _ = pid;
Self {
#[cfg(unix)]
pgid: pid.and_then(|p| libc::pid_t::try_from(p).ok()),
}
}
fn disarm(&mut self) {
#[cfg(unix)]
{
self.pgid = None;
}
}
}
impl Drop for GroupKill {
fn drop(&mut self) {
#[cfg(unix)]
if let Some(pgid) = self.pgid {
unsafe {
libc::killpg(pgid, libc::SIGKILL);
}
}
}
}
#[derive(Default)]
struct Capture {
bytes: Vec<u8>,
cut: bool,
read_error: Option<String>,
}
impl Capture {
async fn read<R>(&mut self, mut from: R, cap: usize)
where
R: tokio::io::AsyncRead + Unpin,
{
use tokio::io::AsyncReadExt;
let mut chunk = [0u8; 8192];
loop {
match from.read(&mut chunk).await {
Ok(0) => return,
Err(e) => {
self.read_error = Some(e.to_string());
return;
}
Ok(n) => {
let room = cap.saturating_sub(self.bytes.len());
self.bytes.extend_from_slice(&chunk[..n.min(room)]);
self.cut |= n > room;
}
}
}
}
fn text(&self) -> String {
let mut s = String::from_utf8_lossy(&self.bytes).into_owned();
if self.cut {
s.push_str("\n... (output truncated)");
}
if let Some(e) = &self.read_error {
s.push_str(&format!("\n... (output incomplete: read error: {e})"));
}
s
}
fn is_empty(&self) -> bool {
self.bytes.is_empty() && !self.cut && self.read_error.is_none()
}
}
fn render_output(out: &Capture, err: &Capture) -> String {
if err.is_empty() {
out.text()
} else {
format!("STDOUT:\n{}\nSTDERR:\n{}", out.text(), err.text())
}
}