2026-07-12 11:28:39 +07:00
|
|
|
//! Bash-shell execution tool with safety filters and optional timeout.
|
2026-07-19 17:05:27 +07:00
|
|
|
//!
|
|
|
|
|
//! This module implements the `bash` tool, which runs a shell command via
|
|
|
|
|
//! `bash -c <command>`. It supports foreground and background execution,
|
|
|
|
|
//! configurable timeouts, and destructive-git-operation gating via
|
|
|
|
|
//! `shell_filter::git::check_git_destructive`.
|
2026-07-11 13:16:10 +07:00
|
|
|
use super::Tool;
|
|
|
|
|
use super::ToolCtx;
|
2026-07-16 07:42:03 +07:00
|
|
|
use anyhow::{anyhow, Result};
|
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
|
use std::process::Command;
|
|
|
|
|
use std::time::Duration;
|
2026-07-19 17:05:27 +07:00
|
|
|
use tracing;
|
2026-07-11 13:16:10 +07:00
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Tool that runs `bash -c <command>`, optionally in the background, with safety
|
|
|
|
|
/// filters applied before spawning.
|
2026-07-11 13:16:10 +07:00
|
|
|
pub struct Bash;
|
|
|
|
|
|
|
|
|
|
impl Tool for Bash {
|
|
|
|
|
fn name(&self) -> &'static str {
|
|
|
|
|
"bash"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description(&self) -> &'static str {
|
2026-07-12 03:56:43 +07:00
|
|
|
"Execute a shell command via bash -c"
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)"
|
2026-07-11 20:21:59 +07:00
|
|
|
},
|
|
|
|
|
"run_in_background": {
|
|
|
|
|
"type": "boolean",
|
|
|
|
|
"description": "Run the command in the background and return immediately with a job ID"
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
"required": ["command"]
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-16 13:32:33 +07:00
|
|
|
/// Run a bash command (foreground or background) with a safety filter and a timeout.
|
2026-07-12 11:28:39 +07:00
|
|
|
///
|
2026-07-16 13:32:33 +07:00
|
|
|
/// Flow: extract args → run `check_git_destructive` (bail if it 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.
|
2026-07-12 11:28:39 +07:00
|
|
|
///
|
2026-07-16 13:32:33 +07:00
|
|
|
/// Why: only destructive git operations are gated here — credential-file reads
|
|
|
|
|
/// (`~/.ssh/id_rsa`, `.netrc`, etc.) are deliberately NOT blocked, since the agent
|
|
|
|
|
/// often needs to read local config for legitimate debugging; the real leak vector
|
|
|
|
|
/// (committing secrets to a remote) is handled by git hooks/user review, not this
|
|
|
|
|
/// tool. `shell_filter::credentials::check_credential_read` exists but is
|
|
|
|
|
/// intentionally not called from here — see its module doc comment. The safety
|
|
|
|
|
/// filter runs 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.
|
2026-07-12 11:28:39 +07:00
|
|
|
///
|
|
|
|
|
/// Return: exit-code + elapsed-seconds summary line (plus captured output) for
|
|
|
|
|
/// foreground runs, or the job ID for background runs.
|
2026-07-12 03:56:43 +07:00
|
|
|
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
2026-07-17 06:44:31 +07:00
|
|
|
let cmd = crate::tool::arg_str(args, "command")?;
|
2026-07-16 07:42:03 +07:00
|
|
|
let timeout_ms = args
|
|
|
|
|
.get("timeout")
|
|
|
|
|
.and_then(serde_json::Value::as_u64)
|
2026-07-19 17:05:27 +07:00
|
|
|
.unwrap_or(120_000) // default: 2 minutes
|
|
|
|
|
.min(600_000); // max: 10 minutes
|
|
|
|
|
tracing::debug!(cmd_len = cmd.len(), timeout = timeout_ms, "Bash::run invoked");
|
2026-07-12 12:02:45 +07:00
|
|
|
// Only gate destructive git operations; credential reads are allowed
|
|
|
|
|
// locally since the AI needs access, and the real threat is committing
|
|
|
|
|
// secrets to a public repo (handled by git pre-commit hooks / user).
|
2026-07-12 01:25:52 +07:00
|
|
|
super::shell_filter::git::check_git_destructive(&cmd)
|
2026-07-13 08:12:02 +07:00
|
|
|
.map_err(|e| anyhow!("blocked: {e}"))?;
|
2026-07-16 07:42:03 +07:00
|
|
|
let run_in_background = args
|
|
|
|
|
.get("run_in_background")
|
|
|
|
|
.and_then(serde_json::Value::as_bool)
|
|
|
|
|
.unwrap_or(false);
|
2026-07-11 20:21:59 +07:00
|
|
|
if run_in_background {
|
2026-07-19 17:05:27 +07:00
|
|
|
tracing::debug!("spawning background bash job");
|
2026-07-11 20:21:59 +07:00
|
|
|
let job = crate::app::bgbash::job::spawn_bash_job(cmd);
|
|
|
|
|
return Ok(format!("Background job: {}", job.id));
|
|
|
|
|
}
|
2026-07-19 17:05:27 +07:00
|
|
|
tracing::debug!("spawning foreground bash -c");
|
2026-07-11 13:16:10 +07:00
|
|
|
let mut child = Command::new("bash")
|
|
|
|
|
.arg("-c")
|
|
|
|
|
.arg(&cmd)
|
|
|
|
|
.stdout(std::process::Stdio::piped())
|
|
|
|
|
.stderr(std::process::Stdio::piped())
|
|
|
|
|
.spawn()
|
2026-07-13 08:12:02 +07:00
|
|
|
.map_err(|e| anyhow!("failed to spawn bash: {e}"))?;
|
2026-07-19 17:05:27 +07:00
|
|
|
let start = std::time::Instant::now(); // used for timeout check and elapsed reporting
|
2026-07-11 13:16:10 +07:00
|
|
|
let timeout = Duration::from_millis(timeout_ms);
|
|
|
|
|
loop {
|
|
|
|
|
match child.try_wait() {
|
|
|
|
|
Ok(Some(status)) => {
|
|
|
|
|
let elapsed = start.elapsed().as_secs_f64();
|
2026-07-16 07:42:03 +07:00
|
|
|
let output = child
|
|
|
|
|
.wait_with_output()
|
2026-07-13 08:12:02 +07:00
|
|
|
.map_err(|e| anyhow!("failed to collect output: {e}"))?;
|
2026-07-11 13:16:10 +07:00
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
|
|
|
|
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
2026-07-16 07:42:03 +07:00
|
|
|
let combined = if stderr.is_empty() {
|
|
|
|
|
stdout
|
|
|
|
|
} else {
|
|
|
|
|
format!("{stdout}\n{stderr}")
|
|
|
|
|
};
|
2026-07-11 13:16:10 +07:00
|
|
|
let trimmed = combined.trim().to_string();
|
|
|
|
|
if status.success() {
|
2026-07-19 17:05:27 +07:00
|
|
|
tracing::debug!(elapsed_secs = elapsed, "bash command succeeded");
|
2026-07-11 13:16:10 +07:00
|
|
|
return Ok(if trimmed.is_empty() {
|
2026-07-13 08:12:02 +07:00
|
|
|
format!("Command completed in {elapsed:.2}s (exit code 0)")
|
2026-07-11 13:16:10 +07:00
|
|
|
} else {
|
2026-07-13 08:12:02 +07:00
|
|
|
format!("{trimmed}\n\nExit code: 0 ({elapsed:.2}s)")
|
2026-07-11 13:16:10 +07:00
|
|
|
});
|
|
|
|
|
}
|
2026-07-19 17:05:27 +07:00
|
|
|
tracing::debug!(elapsed_secs = elapsed, exit_code = status.code().unwrap_or(-1), "bash command finished with non-zero exit");
|
2026-07-16 07:42:03 +07:00
|
|
|
return Ok(format!(
|
|
|
|
|
"{}\n\nExit code: {} ({:.2}s)",
|
|
|
|
|
trimmed,
|
|
|
|
|
status.code().unwrap_or(-1),
|
|
|
|
|
elapsed
|
|
|
|
|
));
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
Ok(None) => {
|
|
|
|
|
if start.elapsed() > timeout {
|
2026-07-19 17:05:27 +07:00
|
|
|
tracing::warn!(timeout_ms = timeout_ms, "bash command timed out, killing");
|
2026-07-11 13:16:10 +07:00
|
|
|
let _ = child.kill();
|
|
|
|
|
let _ = child.wait();
|
2026-07-13 08:12:02 +07:00
|
|
|
anyhow::bail!("command timed out after {timeout_ms}ms");
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
2026-07-19 17:05:27 +07:00
|
|
|
std::thread::sleep(Duration::from_millis(10)); // small sleep to avoid busy-wait
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
Err(e) => {
|
2026-07-19 17:05:27 +07:00
|
|
|
tracing::error!(error = %e, "bash command wait failed");
|
2026-07-13 08:12:02 +07:00
|
|
|
anyhow::bail!("failed to wait for command: {e}");
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|