- bash is now Delegatable so named Agents may run shell commands - remove the legacy anonymous 'general' path entirely: SubAgentManager run_background/run_foreground_batch/run_inline/run_foreground, filter_tools/get_skills_prompt, TaskNotification direct delivery, DelegateContext task-local, background_permits/admission, and the delegate tool's check_task/cancel_task/list_tasks actions (agent_task replaces them); /stop and todo migrate off the task-local bridge - stop writing the legacy background_tasks table (read-only transition); /api/tasks union still renders historical records - ship a built-in general-purpose Agent definition (resources/agents) released to ~/.picobot/agents on first run like built-in skills, with bash/read-only/search/web/calculator/sleep tools; root_delegates now defaults to it so orchestration works out of the box - update AGENTS/README/architecture/config/db-schema docs accordingly
318 lines
8.6 KiB
Rust
318 lines
8.6 KiB
Rust
use std::path::Path;
|
|
use std::process::Stdio;
|
|
use std::time::Duration;
|
|
|
|
use async_trait::async_trait;
|
|
use serde_json::json;
|
|
use tokio::process::Command;
|
|
use tokio::time::timeout;
|
|
|
|
use crate::tools::traits::{Tool, ToolResult};
|
|
|
|
const MAX_TIMEOUT_SECS: u64 = 600;
|
|
const MAX_OUTPUT_CHARS: usize = 50_000;
|
|
|
|
pub struct BashTool {
|
|
timeout_secs: u64,
|
|
working_dir: Option<String>,
|
|
deny_patterns: Vec<String>,
|
|
}
|
|
|
|
impl BashTool {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
timeout_secs: 60,
|
|
working_dir: None,
|
|
deny_patterns: vec![
|
|
r"\brm\s+-[rf]{1,2}\b".to_string(),
|
|
r"\bdel\s+/[fq]\b".to_string(),
|
|
r"\brmdir\s+/s\b".to_string(),
|
|
r":\(\)\s*\{.*\};\s*:".to_string(),
|
|
],
|
|
}
|
|
}
|
|
|
|
pub fn with_timeout(mut self, timeout_secs: u64) -> Self {
|
|
self.timeout_secs = timeout_secs;
|
|
self
|
|
}
|
|
|
|
pub fn with_working_dir(mut self, dir: String) -> Self {
|
|
self.working_dir = Some(dir);
|
|
self
|
|
}
|
|
|
|
fn guard_command(&self, command: &str) -> Option<String> {
|
|
let lower = command.to_lowercase();
|
|
for pattern in &self.deny_patterns {
|
|
if regex::Regex::new(pattern)
|
|
.ok()
|
|
.map(|re| re.is_match(&lower))
|
|
.unwrap_or(false)
|
|
{
|
|
return Some(format!(
|
|
"Command blocked by safety guard (dangerous pattern: {})",
|
|
pattern
|
|
));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn truncate_output(&self, output: &str) -> String {
|
|
if output.len() <= MAX_OUTPUT_CHARS {
|
|
return output.to_string();
|
|
}
|
|
|
|
let half = MAX_OUTPUT_CHARS / 2;
|
|
format!(
|
|
"{}...\n\n(... {} chars truncated ...)\n\n{}",
|
|
&output[..output.ceil_char_boundary(half)],
|
|
output.len() - MAX_OUTPUT_CHARS,
|
|
&output[output.floor_char_boundary(output.len() - half)..]
|
|
)
|
|
}
|
|
}
|
|
|
|
impl Default for BashTool {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Tool for BashTool {
|
|
fn name(&self) -> &str {
|
|
"bash"
|
|
}
|
|
|
|
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
|
|
crate::tools::DelegationPolicy::Delegatable
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"Execute a bash shell command and return its output. Use with caution."
|
|
}
|
|
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"command": {
|
|
"type": "string",
|
|
"description": "The shell command to execute"
|
|
},
|
|
"timeout": {
|
|
"type": "integer",
|
|
"description": format!("Timeout in seconds (default {}, max {})", self.timeout_secs, MAX_TIMEOUT_SECS),
|
|
"minimum": 1,
|
|
"maximum": MAX_TIMEOUT_SECS
|
|
}
|
|
},
|
|
"required": ["command"]
|
|
})
|
|
}
|
|
|
|
fn exclusive(&self) -> bool {
|
|
true // Shell commands should not run concurrently
|
|
}
|
|
|
|
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
|
let command = match args.get("command").and_then(|v| v.as_str()) {
|
|
Some(c) => c,
|
|
None => {
|
|
return Ok(ToolResult {
|
|
success: false,
|
|
output: String::new(),
|
|
error: Some("Missing required parameter: command".to_string()),
|
|
});
|
|
}
|
|
};
|
|
|
|
// Safety check
|
|
if let Some(error) = self.guard_command(command) {
|
|
return Ok(ToolResult {
|
|
success: false,
|
|
output: String::new(),
|
|
error: Some(error),
|
|
});
|
|
}
|
|
|
|
let timeout_secs = args
|
|
.get("timeout")
|
|
.and_then(|v| v.as_u64())
|
|
.unwrap_or(self.timeout_secs)
|
|
.min(MAX_TIMEOUT_SECS);
|
|
|
|
let cwd = self
|
|
.working_dir
|
|
.as_ref()
|
|
.map(Path::new)
|
|
.unwrap_or_else(|| Path::new("."));
|
|
|
|
match self.run_command(command, cwd, timeout_secs).await {
|
|
Ok(output) => Ok(ToolResult {
|
|
success: true,
|
|
output,
|
|
error: None,
|
|
}),
|
|
Err(e) => Ok(ToolResult {
|
|
success: false,
|
|
output: String::new(),
|
|
error: Some(e),
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl BashTool {
|
|
async fn run_command(
|
|
&self,
|
|
command: &str,
|
|
cwd: &Path,
|
|
timeout_secs: u64,
|
|
) -> Result<String, String> {
|
|
let mut cmd = Command::new("bash");
|
|
cmd.args(["-c", command])
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.current_dir(cwd)
|
|
.kill_on_drop(true);
|
|
|
|
let child = cmd.spawn().map_err(|e| format!("Failed to spawn: {}", e))?;
|
|
|
|
let process_output =
|
|
match timeout(Duration::from_secs(timeout_secs), child.wait_with_output()).await {
|
|
Ok(Ok(output)) => output,
|
|
Ok(Err(e)) => return Err(format!("Failed to wait: {}", e)),
|
|
Err(_) => {
|
|
return Err(format!("Command timed out after {} seconds", timeout_secs));
|
|
}
|
|
};
|
|
|
|
let mut output = String::new();
|
|
|
|
if !process_output.stdout.is_empty() {
|
|
let stdout_str = String::from_utf8_lossy(&process_output.stdout);
|
|
output.push_str(&stdout_str);
|
|
}
|
|
|
|
if !process_output.stderr.is_empty() {
|
|
let stderr_str = String::from_utf8_lossy(&process_output.stderr);
|
|
if !stderr_str.trim().is_empty() {
|
|
if !output.is_empty() {
|
|
output.push('\n');
|
|
}
|
|
output.push_str("STDERR:\n");
|
|
output.push_str(&stderr_str);
|
|
}
|
|
}
|
|
|
|
output.push_str(&format!(
|
|
"\nExit code: {}",
|
|
process_output.status.code().unwrap_or(-1)
|
|
));
|
|
|
|
Ok(self.truncate_output(&output))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn test_simple_command() {
|
|
let tool = BashTool::new();
|
|
let result = tool
|
|
.execute(json!({ "command": "echo 'Hello World'" }))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(result.success);
|
|
assert!(result.output.contains("Hello World"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_pwd_command() {
|
|
let tool = BashTool::new();
|
|
let result = tool.execute(json!({ "command": "pwd" })).await.unwrap();
|
|
|
|
assert!(result.success);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_ls_command() {
|
|
let tool = BashTool::new();
|
|
let result = tool
|
|
.execute(json!({ "command": "ls -la /tmp" }))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(result.success);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_dangerous_rm() {
|
|
let tool = BashTool::new();
|
|
let result = tool
|
|
.execute(json!({ "command": "rm -rf /" }))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(!result.success);
|
|
assert!(result.error.unwrap().contains("blocked"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_dangerous_fork_bomb() {
|
|
let tool = BashTool::new();
|
|
let result = tool
|
|
.execute(json!({ "command": ":(){ :|:& };:" }))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(!result.success);
|
|
assert!(result.error.unwrap().contains("blocked"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_missing_command() {
|
|
let tool = BashTool::new();
|
|
let result = tool.execute(json!({})).await.unwrap();
|
|
|
|
assert!(!result.success);
|
|
assert!(result.error.unwrap().contains("command"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_timeout() {
|
|
let tool = BashTool::new();
|
|
let result = tool
|
|
.execute(json!({
|
|
"command": "sleep 10",
|
|
"timeout": 1
|
|
}))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(!result.success);
|
|
assert!(result.error.unwrap().contains("timed out"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_large_stderr_does_not_deadlock() {
|
|
let tool = BashTool::new().with_timeout(5);
|
|
let result = tool
|
|
.execute(json!({
|
|
"command": "for i in $(seq 1 2000); do echo noisy-error-line >&2; done; echo done"
|
|
}))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(result.success);
|
|
assert!(result.output.contains("done"));
|
|
assert!(result.output.contains("STDERR"));
|
|
}
|
|
}
|