//! Bash-shell execution tool with safety filters and optional timeout. use std::process::Command; use std::time::Duration; use serde_json::{json, Value}; use anyhow::{Result, anyhow}; use super::Tool; use super::ToolCtx; /// Tool that runs `bash -c `, optionally in the background, with safety /// filters applied before spawning. pub struct Bash; impl Tool for Bash { fn name(&self) -> &'static str { "bash" } fn description(&self) -> &'static str { "Execute a shell command via bash -c" } fn parameters(&self) -> Value { json!({ "type": "object", "properties": { "command": { "type": "string", "description": "Shell command to execute" }, "description": { "type": "string", "description": "Human-readable description of what the command does" }, "timeout": { "type": "integer", "description": "Timeout in milliseconds (default 120000, max 600000)" }, "run_in_background": { "type": "boolean", "description": "Run the command in the background and return immediately with a job ID" } }, "required": ["command"] }) } /// Run a bash command (foreground or background) with safety filters and a timeout. /// /// Flow: extract args → run `check_credential_read` then `check_git_destructive` /// (bail if either rejects) → branch on `run_in_background`: if true, hand off /// to the bg-bash subsystem and return the job ID; else spawn `bash -c`, /// poll with `try_wait`, kill on timeout, format combined stdout+stderr. /// /// Why: the safety filters run unconditionally so background jobs are also gated; /// the timeout is enforced by polling the child rather than relying on a libc alarm /// so cleanup stays in Rust. /// /// Return: exit-code + elapsed-seconds summary line (plus captured output) for /// foreground runs, or the job ID for background runs. fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { let cmd = args.get("command") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: command"))? .to_string(); let _description = args.get("description").and_then(|v| v.as_str()).unwrap_or(""); let timeout_ms = args.get("timeout").and_then(|v| v.as_u64()).unwrap_or(120000).min(600000); super::shell_filter::credentials::check_credential_read(&cmd) .map_err(|e| anyhow!("blocked: {}", e))?; super::shell_filter::git::check_git_destructive(&cmd) .map_err(|e| anyhow!("blocked: {}", e))?; let run_in_background = args.get("run_in_background").and_then(|v| v.as_bool()).unwrap_or(false); if run_in_background { let job = crate::app::bgbash::job::spawn_bash_job(cmd); return Ok(format!("Background job: {}", job.id)); } let mut child = Command::new("bash") .arg("-c") .arg(&cmd) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() .map_err(|e| anyhow!("failed to spawn bash: {}", e))?; let start = std::time::Instant::now(); let timeout = Duration::from_millis(timeout_ms); loop { match child.try_wait() { Ok(Some(status)) => { let elapsed = start.elapsed().as_secs_f64(); let output = child.wait_with_output() .map_err(|e| anyhow!("failed to collect output: {}", e))?; let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); let combined = if stderr.is_empty() { stdout } else { format!("{}\n{}", stdout, stderr) }; let trimmed = combined.trim().to_string(); if status.success() { return Ok(if trimmed.is_empty() { format!("Command completed in {:.2}s (exit code 0)", elapsed) } else { format!("{}\n\nExit code: 0 ({:.2}s)", trimmed, elapsed) }); } else { return Ok(format!("{}\n\nExit code: {} ({:.2}s)", trimmed, status.code().unwrap_or(-1), elapsed)); } } Ok(None) => { if start.elapsed() > timeout { let _ = child.kill(); let _ = child.wait(); anyhow::bail!("command timed out after {}ms", timeout_ms); } std::thread::sleep(Duration::from_millis(10)); } Err(e) => { anyhow::bail!("failed to wait for command: {}", e); } } } } }