1use async_trait::async_trait;
2use serde_json::json;
3use std::sync::Arc;
4
5use super::{Tool, ToolCtx, ToolResult};
6use sparrow_config::sandbox::{Command, Limits, Sandbox, command_touches_denied_path};
7use sparrow_core::event::RiskLevel;
8
9pub struct Exec {
10 sandbox: Arc<dyn Sandbox>,
11}
12
13impl Exec {
14 pub fn new(sandbox: Arc<dyn Sandbox>) -> Self {
15 Self { sandbox }
16 }
17}
18
19#[async_trait]
20impl Tool for Exec {
21 fn name(&self) -> &str {
22 "exec"
23 }
24 fn description(&self) -> &str {
25 "Execute a shell command in the sandboxed workspace"
26 }
27 fn schema(&self) -> serde_json::Value {
28 json!({
29 "type": "object",
30 "properties": {
31 "command": { "type": "string", "description": "Shell command to execute" },
32 "timeout_ms": { "type": "integer", "description": "Timeout in milliseconds (default 120000)" }
33 },
34 "required": ["command"]
35 })
36 }
37 fn risk(&self) -> RiskLevel {
38 RiskLevel::Exec
39 }
40 async fn call(&self, args: serde_json::Value, ctx: &ToolCtx) -> anyhow::Result<ToolResult> {
41 let cmd_str = args["command"].as_str().unwrap_or("");
42 let timeout_ms = args["timeout_ms"].as_u64().unwrap_or(120_000);
43
44 if let Some(hit) = command_touches_denied_path(cmd_str, &self.sandbox.policy().denied_paths)
50 {
51 return Ok(ToolResult::error(format!(
52 "Refused: command references a protected path ('{hit}'). \
53 Protected paths (.ssh, .env, id_rsa, .git, …) are off-limits to exec."
54 )));
55 }
56
57 let cmd = Command {
58 program: if cfg!(windows) { "cmd" } else { "sh" }.to_string(),
59 args: vec![
60 if cfg!(windows) { "/c" } else { "-c" }.to_string(),
61 cmd_str.to_string(),
62 ],
63 env: std::collections::HashMap::new(),
64 workdir: ctx.workspace_root.clone(),
65 };
66
67 let limits = Limits {
68 timeout_ms,
69 max_output_bytes: 1024 * 1024, };
71
72 let result = self.sandbox.exec(&cmd, &limits).await?;
73
74 let output = if result.stdout.is_empty() && result.stderr.is_empty() {
75 format!("(exit: {})", result.exit_code)
76 } else if result.stderr.is_empty() {
77 result.stdout
78 } else if result.stdout.is_empty() {
79 format!("stderr:\n{}", result.stderr)
80 } else {
81 format!("stdout:\n{}\nstderr:\n{}", result.stdout, result.stderr)
82 };
83
84 Ok(ToolResult::text(output))
85 }
86}