Files
zesdex/src/tool/shell.rs
T

122 lines
5.2 KiB
Rust
Raw Normal View History

//! 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 <command>`, 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<String> {
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);
// 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).
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);
}
}
}
}
}