1use 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
16pub struct BashTool {
18 timeout_secs: u64,
19 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 #[allow(dead_code)]
36 pub fn with_timeout(timeout_secs: u64) -> Self {
37 Self {
38 timeout_secs,
39 sandboxed: false,
40 }
41 }
42
43 #[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 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 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 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 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}