use std::process::Stdio; use std::time::Duration; use async_trait::async_trait; use harness_core::tool::{Tool, ToolCtx, ToolError, ToolOutput}; use schemars::JsonSchema; use serde::Deserialize; const DEFAULT_TIMEOUT_MS: u64 = 2 * 60 * 1000; const MAX_TIMEOUT_MS: u64 = 10 * 60 * 1000; #[derive(Debug, Deserialize, JsonSchema)] struct BashParams { command: String, timeout_ms: Option, cwd: Option, #[allow(dead_code)] description: String, } pub struct BashTool; /// The "always allow" pattern is coarser than the exact command: opencode/docs/05-tools.md /// grants ` *` (e.g. `git *`), not the literal command string. fn always_pattern(command: &str) -> String { shell_words::split(command) .ok() .and_then(|words| words.into_iter().next()) .map(|first| format!("{first} *")) .unwrap_or_else(|| "*".to_string()) } #[cfg(unix)] #[allow(unsafe_code)] fn kill_group(pid: u32) { // Negative pid signals the whole process group (spawned with `process_group(0)`). // Safety: `kill` is a plain libc syscall; passing a negative pid targets the group, // which is exactly the process tree we spawned and want to tear down. unsafe { libc::kill(-(pid as i32), libc::SIGKILL); } } #[cfg(not(unix))] fn kill_group(_pid: u32) {} #[async_trait] impl Tool for BashTool { fn name(&self) -> &str { "bash" } fn description(&self) -> &str { "Runs a shell command and returns its combined stdout/stderr." } fn parameters(&self) -> serde_json::Value { serde_json::to_value(schemars::schema_for!(BashParams)).unwrap() } async fn execute( &self, input: serde_json::Value, ctx: ToolCtx, ) -> Result { let params: BashParams = serde_json::from_value(input).map_err(|e| ToolError::Invalid(e.to_string()))?; let timeout_ms = params .timeout_ms .unwrap_or(DEFAULT_TIMEOUT_MS) .min(MAX_TIMEOUT_MS); ctx.ask .ask( "bash", params.command.clone(), always_pattern(¶ms.command), serde_json::json!({"command": params.command}), ) .await?; let cwd = params .cwd .as_ref() .map(|c| crate::paths::resolve(&ctx.cwd, c)) .unwrap_or_else(|| ctx.cwd.clone()); let mut cmd = tokio::process::Command::new("sh"); cmd.arg("-c") .arg(¶ms.command) .current_dir(&cwd) .stdout(Stdio::piped()) .stderr(Stdio::piped()); #[cfg(unix)] cmd.process_group(0); let child = cmd .spawn() .map_err(|e| ToolError::Other(format!("spawn failed: {e}")))?; let pid = child.id(); tokio::select! { result = child.wait_with_output() => { let output = result.map_err(|e| ToolError::Other(format!("wait failed: {e}")))?; let mut combined = String::from_utf8_lossy(&output.stdout).into_owned(); combined.push_str(&String::from_utf8_lossy(&output.stderr)); let title = format!("{} (exit {})", params.command, output.status.code().unwrap_or(-1)); Ok(ToolOutput::new(title, combined)) } _ = tokio::time::sleep(Duration::from_millis(timeout_ms)) => { if let Some(pid) = pid { kill_group(pid); } Err(ToolError::Other(format!("command timed out after {timeout_ms}ms"))) } _ = ctx.cancel.cancelled() => { if let Some(pid) = pid { kill_group(pid); } Err(ToolError::Cancelled) } } } } #[cfg(test)] mod tests { use super::*; use harness_core::event::EventBus; use harness_core::permission::{spawn_auto_approve, PermissionService}; use harness_core::tool::{MetadataSink, PermissionHandle}; use harness_core::types::SessionId; use std::sync::{Arc, Mutex}; use tokio_util::sync::CancellationToken; fn ctx(cwd: std::path::PathBuf) -> ToolCtx { let bus = EventBus::new(); let service = Arc::new(PermissionService::new(bus.clone())); spawn_auto_approve(bus, service.clone()); let (metadata, _rx) = MetadataSink::channel(); ToolCtx { session_id: SessionId::new(), message_id: harness_core::types::MessageId::new(), call_id: "call_1".into(), data_dir: cwd.join("tool-output"), cwd, cancel: CancellationToken::new(), ask: PermissionHandle::new( service, SessionId::new(), Vec::new(), Arc::new(Mutex::new(Vec::new())), CancellationToken::new(), ), metadata, spawner: None, context_reporter: None, } } #[test] fn always_pattern_uses_first_word() { assert_eq!(always_pattern("git push origin main"), "git *"); assert_eq!(always_pattern("ls -la"), "ls *"); } #[tokio::test] async fn runs_command_and_captures_stdout() { let dir = tempfile::tempdir().unwrap(); let output = BashTool .execute( serde_json::json!({"command": "echo hello", "description": "say hi"}), ctx(dir.path().to_path_buf()), ) .await .unwrap(); assert!(output.output.contains("hello")); } #[tokio::test] async fn captures_stderr_too() { let dir = tempfile::tempdir().unwrap(); let output = BashTool .execute( serde_json::json!({"command": "echo err 1>&2", "description": "stderr"}), ctx(dir.path().to_path_buf()), ) .await .unwrap(); assert!(output.output.contains("err")); } #[tokio::test] async fn times_out_long_running_commands() { let dir = tempfile::tempdir().unwrap(); let err = BashTool .execute( serde_json::json!({"command": "sleep 5", "timeout_ms": 50, "description": "slow"}), ctx(dir.path().to_path_buf()), ) .await .unwrap_err(); assert!(matches!(err, ToolError::Other(_))); } #[tokio::test] async fn respects_cwd_override() { let dir = tempfile::tempdir().unwrap(); let output = BashTool .execute( serde_json::json!({"command": "pwd", "description": "where"}), ctx(dir.path().to_path_buf()), ) .await .unwrap(); assert!(output .output .trim() .ends_with(dir.path().file_name().unwrap().to_str().unwrap())); } }