Skip to main content

codetether_agent/tool/
bash.rs

1//! Bash tool: execute shell commands
2
3use super::sandbox::{SandboxPolicy, execute_sandboxed};
4use super::{Tool, ToolResult};
5use crate::audit::{AuditCategory, AuditOutcome, try_audit_log};
6use anyhow::Result;
7use async_trait::async_trait;
8use serde_json::{Value, json};
9use std::process::Stdio;
10use std::time::Instant;
11use tokio::process::Command;
12use tokio::time::{Duration, timeout};
13
14use crate::telemetry::{TOOL_EXECUTIONS, ToolExecution, record_persistent};
15
16/// Execute shell commands
17pub struct BashTool {
18    timeout_secs: u64,
19    /// When true, execute commands through the sandbox with restricted env.
20    sandboxed: bool,
21}
22
23impl BashTool {
24    pub fn new() -> Self {
25        let sandboxed = std::env::var("CODETETHER_SANDBOX_BASH")
26            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
27            .unwrap_or(false);
28        Self {
29            timeout_secs: 120,
30            sandboxed,
31        }
32    }
33
34    /// Create a new BashTool with a custom timeout
35    #[allow(dead_code)]
36    pub fn with_timeout(timeout_secs: u64) -> Self {
37        Self {
38            timeout_secs,
39            sandboxed: false,
40        }
41    }
42
43    /// Create a sandboxed BashTool
44    #[allow(dead_code)]
45    pub fn sandboxed() -> Self {
46        Self {
47            timeout_secs: 120,
48            sandboxed: true,
49        }
50    }
51}
52
53#[async_trait]
54impl Tool for BashTool {
55    fn id(&self) -> &str {
56        "bash"
57    }
58
59    fn name(&self) -> &str {
60        "Bash"
61    }
62
63    fn description(&self) -> &str {
64        "bash(command: string, cwd?: string, timeout?: int) - Execute a shell command. Commands run in a bash shell with the current working directory."
65    }
66
67    fn parameters(&self) -> Value {
68        json!({
69            "type": "object",
70            "properties": {
71                "command": {
72                    "type": "string",
73                    "description": "The shell command to execute"
74                },
75                "cwd": {
76                    "type": "string",
77                    "description": "Working directory for the command (optional)"
78                },
79                "timeout": {
80                    "type": "integer",
81                    "description": "Timeout in seconds (default: 120)"
82                }
83            },
84            "required": ["command"],
85            "example": {
86                "command": "ls -la src/",
87                "cwd": "/path/to/project"
88            }
89        })
90    }
91
92    async fn execute(&self, args: Value) -> Result<ToolResult> {
93        let exec_start = Instant::now();
94
95        let command = match args["command"].as_str() {
96            Some(c) => c,
97            None => {
98                return Ok(ToolResult::structured_error(
99                    "INVALID_ARGUMENT",
100                    "bash",
101                    "command is required",
102                    Some(vec!["command"]),
103                    Some(json!({"command": "ls -la", "cwd": "."})),
104                ));
105            }
106        };
107        let cwd = args["cwd"].as_str();
108        let timeout_secs = args["timeout"].as_u64().unwrap_or(self.timeout_secs);
109
110        // Sandboxed execution path: restricted env, resource limits, audit logged
111        if self.sandboxed {
112            let policy = SandboxPolicy {
113                allowed_paths: cwd
114                    .map(|d| vec![std::path::PathBuf::from(d)])
115                    .unwrap_or_default(),
116                allow_network: false,
117                allow_exec: true,
118                timeout_secs,
119                ..SandboxPolicy::default()
120            };
121            let work_dir = cwd.map(std::path::Path::new);
122            let sandbox_result = execute_sandboxed(
123                "bash",
124                &["-c".to_string(), command.to_string()],
125                &policy,
126                work_dir,
127            )
128            .await;
129
130            // Audit log the sandboxed execution
131            if let Some(audit) = try_audit_log() {
132                let (outcome, detail) = match &sandbox_result {
133                    Ok(r) => (
134                        if r.success {
135                            AuditOutcome::Success
136                        } else {
137                            AuditOutcome::Failure
138                        },
139                        json!({
140                            "sandboxed": true,
141                            "exit_code": r.exit_code,
142                            "duration_ms": r.duration_ms,
143                            "violations": r.sandbox_violations,
144                        }),
145                    ),
146                    Err(e) => (
147                        AuditOutcome::Failure,
148                        json!({ "sandboxed": true, "error": e.to_string() }),
149                    ),
150                };
151                audit
152                    .log(
153                        AuditCategory::Sandbox,
154                        format!("bash:{}", &command[..command.len().min(80)]),
155                        outcome,
156                        None,
157                        Some(detail),
158                    )
159                    .await;
160            }
161
162            return match sandbox_result {
163                Ok(r) => {
164                    let duration = exec_start.elapsed();
165                    let exec = ToolExecution::start(
166                        "bash",
167                        json!({ "command": command, "sandboxed": true }),
168                    );
169                    let exec = if r.success {
170                        exec.complete_success(format!("exit_code={:?}", r.exit_code), duration)
171                    } else {
172                        exec.complete_error(format!("exit_code={:?}", r.exit_code), duration)
173                    };
174                    TOOL_EXECUTIONS.record(exec.clone());
175                    record_persistent(exec);
176
177                    Ok(ToolResult {
178                        output: r.output,
179                        success: r.success,
180                        metadata: [
181                            ("exit_code".to_string(), json!(r.exit_code)),
182                            ("sandboxed".to_string(), json!(true)),
183                            (
184                                "sandbox_violations".to_string(),
185                                json!(r.sandbox_violations),
186                            ),
187                        ]
188                        .into_iter()
189                        .collect(),
190                    })
191                }
192                Err(e) => {
193                    let duration = exec_start.elapsed();
194                    let exec = ToolExecution::start(
195                        "bash",
196                        json!({ "command": command, "sandboxed": true }),
197                    )
198                    .complete_error(e.to_string(), duration);
199                    TOOL_EXECUTIONS.record(exec.clone());
200                    record_persistent(exec);
201                    Ok(ToolResult::error(format!("Sandbox error: {}", e)))
202                }
203            };
204        }
205
206        let mut cmd = Command::new("bash");
207        cmd.arg("-c")
208            .arg(command)
209            .stdout(Stdio::piped())
210            .stderr(Stdio::piped());
211
212        if let Some(dir) = cwd {
213            cmd.current_dir(dir);
214        }
215
216        let result = timeout(Duration::from_secs(timeout_secs), cmd.output()).await;
217
218        match result {
219            Ok(Ok(output)) => {
220                let stdout = String::from_utf8_lossy(&output.stdout);
221                let stderr = String::from_utf8_lossy(&output.stderr);
222                let exit_code = output.status.code().unwrap_or(-1);
223
224                let combined = if stderr.is_empty() {
225                    stdout.to_string()
226                } else if stdout.is_empty() {
227                    stderr.to_string()
228                } else {
229                    format!("{}\n--- stderr ---\n{}", stdout, stderr)
230                };
231
232                let success = output.status.success();
233
234                // Truncate if too long
235                let max_len = 50_000;
236                let (output_str, truncated) = if combined.len() > max_len {
237                    let truncated_output = format!(
238                        "{}...\n[Output truncated, {} bytes total]",
239                        &combined[..max_len],
240                        combined.len()
241                    );
242                    (truncated_output, true)
243                } else {
244                    (combined.clone(), false)
245                };
246
247                let duration = exec_start.elapsed();
248
249                // Record telemetry
250                let exec = ToolExecution::start(
251                    "bash",
252                    json!({
253                        "command": command,
254                        "cwd": cwd,
255                        "timeout": timeout_secs,
256                    }),
257                );
258                let exec = if success {
259                    exec.complete_success(
260                        format!("exit_code={}, output_len={}", exit_code, combined.len()),
261                        duration,
262                    )
263                } else {
264                    exec.complete_error(
265                        format!(
266                            "exit_code={}: {}",
267                            exit_code,
268                            combined.lines().next().unwrap_or("(no output)")
269                        ),
270                        duration,
271                    )
272                };
273                TOOL_EXECUTIONS.record(exec.clone());
274                record_persistent(exec);
275
276                Ok(ToolResult {
277                    output: output_str,
278                    success,
279                    metadata: [
280                        ("exit_code".to_string(), json!(exit_code)),
281                        ("truncated".to_string(), json!(truncated)),
282                    ]
283                    .into_iter()
284                    .collect(),
285                })
286            }
287            Ok(Err(e)) => {
288                let duration = exec_start.elapsed();
289                let exec = ToolExecution::start(
290                    "bash",
291                    json!({
292                        "command": command,
293                        "cwd": cwd,
294                    }),
295                )
296                .complete_error(format!("Failed to execute: {}", e), duration);
297                TOOL_EXECUTIONS.record(exec.clone());
298                record_persistent(exec);
299
300                Ok(ToolResult::structured_error(
301                    "EXECUTION_FAILED",
302                    "bash",
303                    &format!("Failed to execute command: {}", e),
304                    None,
305                    Some(json!({"command": command})),
306                ))
307            }
308            Err(_) => {
309                let duration = exec_start.elapsed();
310                let exec = ToolExecution::start(
311                    "bash",
312                    json!({
313                        "command": command,
314                        "cwd": cwd,
315                    }),
316                )
317                .complete_error(format!("Timeout after {}s", timeout_secs), duration);
318                TOOL_EXECUTIONS.record(exec.clone());
319                record_persistent(exec);
320
321                Ok(ToolResult::structured_error(
322                    "TIMEOUT",
323                    "bash",
324                    &format!("Command timed out after {} seconds", timeout_secs),
325                    None,
326                    Some(json!({
327                        "command": command,
328                        "hint": "Consider increasing timeout or breaking into smaller commands"
329                    })),
330                ))
331            }
332        }
333    }
334}
335
336impl Default for BashTool {
337    fn default() -> Self {
338        Self::new()
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[tokio::test]
347    async fn sandboxed_bash_basic() {
348        let tool = BashTool {
349            timeout_secs: 10,
350            sandboxed: true,
351        };
352        let result = tool
353            .execute(json!({ "command": "echo hello sandbox" }))
354            .await
355            .unwrap();
356        assert!(result.success);
357        assert!(result.output.contains("hello sandbox"));
358        assert_eq!(result.metadata.get("sandboxed"), Some(&json!(true)));
359    }
360
361    #[tokio::test]
362    async fn sandboxed_bash_timeout() {
363        let tool = BashTool {
364            timeout_secs: 1,
365            sandboxed: true,
366        };
367        let result = tool
368            .execute(json!({ "command": "sleep 30" }))
369            .await
370            .unwrap();
371        assert!(!result.success);
372    }
373}