1use super::bash_github::load_github_command_auth;
4use super::bash_identity::git_identity_env_from_tool_args;
5use super::sandbox::{SandboxPolicy, execute_sandboxed};
6use super::{Tool, ToolResult};
7use crate::audit::{AuditCategory, AuditOutcome, try_audit_log};
8use anyhow::Result;
9use async_trait::async_trait;
10use serde_json::{Value, json};
11use std::ffi::OsString;
12use std::path::PathBuf;
13use std::process::Stdio;
14use std::time::Instant;
15use tokio::process::Command;
16use tokio::time::{Duration, timeout};
17
18use crate::telemetry::{TOOL_EXECUTIONS, ToolExecution, record_persistent};
19use crate::util;
20
21pub struct BashTool {
23 timeout_secs: u64,
24 sandboxed: bool,
26 default_cwd: Option<PathBuf>,
27}
28
29impl BashTool {
30 pub fn new() -> Self {
31 let sandboxed = std::env::var("CODETETHER_SANDBOX_BASH")
32 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
33 .unwrap_or(false);
34 Self {
35 timeout_secs: 120,
36 sandboxed,
37 default_cwd: None,
38 }
39 }
40
41 pub fn with_cwd(default_cwd: PathBuf) -> Self {
42 Self {
43 default_cwd: Some(default_cwd),
44 ..Self::new()
45 }
46 }
47
48 #[allow(dead_code)]
50 pub fn with_timeout(timeout_secs: u64) -> Self {
51 Self {
52 timeout_secs,
53 sandboxed: false,
54 default_cwd: None,
55 }
56 }
57
58 #[allow(dead_code)]
60 pub fn sandboxed() -> Self {
61 Self {
62 timeout_secs: 120,
63 sandboxed: true,
64 default_cwd: None,
65 }
66 }
67}
68
69fn interactive_auth_risk_reason(command: &str) -> Option<&'static str> {
70 let lower = command.to_ascii_lowercase();
71
72 let has_sudo = lower.starts_with("sudo ")
73 || lower.contains(";sudo ")
74 || lower.contains("&& sudo ")
75 || lower.contains("|| sudo ")
76 || lower.contains("| sudo ");
77 let sudo_non_interactive =
78 lower.contains("sudo -n") || lower.contains("sudo --non-interactive");
79 if has_sudo && !sudo_non_interactive {
80 return Some("Command uses sudo without non-interactive mode (-n).");
81 }
82
83 let has_ssh_family = lower.starts_with("ssh ")
84 || lower.contains(";ssh ")
85 || lower.starts_with("scp ")
86 || lower.contains(";scp ")
87 || lower.starts_with("sftp ")
88 || lower.contains(";sftp ")
89 || lower.contains(" rsync ");
90 if has_ssh_family && !lower.contains("batchmode=yes") {
91 return Some(
92 "SSH-family command may prompt for password/passphrase (missing -o BatchMode=yes).",
93 );
94 }
95
96 if lower.starts_with("su ")
97 || lower.contains(";su ")
98 || lower.contains(" passwd ")
99 || lower.starts_with("passwd")
100 || lower.contains("ssh-add")
101 {
102 return Some("Command is interactive and may require a password prompt.");
103 }
104
105 None
106}
107
108fn looks_like_auth_prompt(output: &str) -> bool {
109 let lower = output.to_ascii_lowercase();
110 [
111 "[sudo] password for",
112 "password:",
113 "passphrase",
114 "no tty present and no askpass program specified",
115 "a terminal is required to read the password",
116 "could not read password",
117 "permission denied (publickey,password",
118 ]
119 .iter()
120 .any(|needle| lower.contains(needle))
121}
122
123fn redact_output(mut output: String, secrets: &[String]) -> String {
124 for secret in secrets {
125 if !secret.is_empty() {
126 output = output.replace(secret, "[REDACTED]");
127 }
128 }
129 output
130}
131
132fn codetether_wrapped_command(command: &str) -> String {
133 format!(
134 "codetether() {{ \"$CODETETHER_BIN\" \"$@\"; }}\nexport -f codetether >/dev/null 2>&1 || true\n{command}"
135 )
136}
137
138fn codetether_runtime_env() -> Option<(String, OsString)> {
139 let current_exe = std::env::current_exe().ok()?;
140 let mut path_entries = current_exe
141 .parent()
142 .map(|parent| vec![parent.to_path_buf()])
143 .unwrap_or_default();
144 if let Some(existing_path) = std::env::var_os("PATH") {
145 path_entries.extend(std::env::split_paths(&existing_path));
146 }
147 let path = std::env::join_paths(path_entries).ok()?;
148 Some((current_exe.to_string_lossy().into_owned(), path))
149}
150
151#[async_trait]
152impl Tool for BashTool {
153 fn id(&self) -> &str {
154 "bash"
155 }
156
157 fn name(&self) -> &str {
158 "Bash"
159 }
160
161 fn description(&self) -> &str {
162 "bash(command: string, cwd?: string, timeout?: int) - Execute a shell command. Commands run in a bash shell with the current working directory."
163 }
164
165 fn parameters(&self) -> Value {
166 json!({
167 "type": "object",
168 "properties": {
169 "command": {
170 "type": "string",
171 "description": "The shell command to execute"
172 },
173 "cwd": {
174 "type": "string",
175 "description": "Working directory for the command (optional)"
176 },
177 "timeout": {
178 "type": "integer",
179 "description": "Timeout in seconds (default: 120)"
180 }
181 },
182 "required": ["command"],
183 "example": {
184 "command": "ls -la src/",
185 "cwd": "/path/to/project"
186 }
187 })
188 }
189
190 async fn execute(&self, args: Value) -> Result<ToolResult> {
191 let exec_start = Instant::now();
192
193 let command = match args["command"].as_str() {
194 Some(c) => c,
195 None => {
196 return Ok(ToolResult::structured_error(
197 "INVALID_ARGUMENT",
198 "bash",
199 "command is required",
200 Some(vec!["command"]),
201 Some(json!({"command": "ls -la", "cwd": "."})),
202 ));
203 }
204 };
205 let cwd = args["cwd"].as_str().map(PathBuf::from);
206 let effective_cwd = cwd.clone().or_else(|| self.default_cwd.clone());
207 let timeout_secs = args["timeout"].as_u64().unwrap_or(self.timeout_secs);
208 let wrapped_command = codetether_wrapped_command(command);
209
210 if let Some(reason) = interactive_auth_risk_reason(command) {
211 tracing::warn!("Interactive auth risk detected: {}", reason);
213 }
214
215 if self.sandboxed {
217 let policy = SandboxPolicy {
218 allowed_paths: effective_cwd.clone().map(|d| vec![d]).unwrap_or_default(),
219 allow_network: false,
220 allow_exec: true,
221 timeout_secs,
222 ..SandboxPolicy::default()
223 };
224 let work_dir = effective_cwd.as_deref();
225 let sandbox_result = execute_sandboxed(
226 "bash",
227 &["-c".to_string(), wrapped_command.clone()],
228 &policy,
229 work_dir,
230 )
231 .await;
232
233 if let Some(audit) = try_audit_log() {
235 let (outcome, detail) = match &sandbox_result {
236 Ok(r) => (
237 if r.success {
238 AuditOutcome::Success
239 } else {
240 AuditOutcome::Failure
241 },
242 json!({
243 "sandboxed": true,
244 "exit_code": r.exit_code,
245 "duration_ms": r.duration_ms,
246 "violations": r.sandbox_violations,
247 }),
248 ),
249 Err(e) => (
250 AuditOutcome::Failure,
251 json!({ "sandboxed": true, "error": e.to_string() }),
252 ),
253 };
254 audit
255 .log(
256 AuditCategory::Sandbox,
257 format!("bash:{}", crate::util::truncate_bytes_safe(&command, 80)),
258 outcome,
259 None,
260 Some(detail),
261 )
262 .await;
263 }
264
265 return match sandbox_result {
266 Ok(r) => {
267 let duration = exec_start.elapsed();
268 let exec = ToolExecution::start(
269 "bash",
270 json!({ "command": command, "sandboxed": true }),
271 );
272 let exec = if r.success {
273 exec.complete_success(format!("exit_code={:?}", r.exit_code), duration)
274 } else {
275 exec.complete_error(format!("exit_code={:?}", r.exit_code), duration)
276 };
277 TOOL_EXECUTIONS.record(exec.success);
278 let data = serde_json::json!({
279 "tool": "bash",
280 "command": command,
281 "success": r.success,
282 "exit_code": r.exit_code,
283 });
284 let _ = record_persistent("tool_execution", &data);
285
286 Ok(ToolResult {
287 output: r.output,
288 success: r.success,
289 metadata: [
290 ("exit_code".to_string(), json!(r.exit_code)),
291 ("sandboxed".to_string(), json!(true)),
292 (
293 "sandbox_violations".to_string(),
294 json!(r.sandbox_violations),
295 ),
296 ]
297 .into_iter()
298 .collect(),
299 })
300 }
301 Err(e) => {
302 let duration = exec_start.elapsed();
303 let exec = ToolExecution::start(
304 "bash",
305 json!({ "command": command, "sandboxed": true }),
306 )
307 .complete_error(e.to_string(), duration);
308 TOOL_EXECUTIONS.record(exec.success);
309 let data = serde_json::json!({
310 "tool": "bash",
311 "command": command,
312 "success": false,
313 "error": e.to_string(),
314 });
315 let _ = record_persistent("tool_execution", &data);
316 Ok(ToolResult::error(format!("Sandbox error: {}", e)))
317 }
318 };
319 }
320
321 let mut cmd = Command::new("bash");
322 cmd.arg("-c")
323 .arg(&wrapped_command)
324 .stdin(Stdio::null())
325 .stdout(Stdio::piped())
326 .stderr(Stdio::piped())
327 .env("GIT_TERMINAL_PROMPT", "0")
328 .env("GCM_INTERACTIVE", "never")
329 .env("DEBIAN_FRONTEND", "noninteractive")
330 .env("SUDO_ASKPASS", "/bin/false")
331 .env("SSH_ASKPASS", "/bin/false");
332 if let Some((codetether_bin, path)) = codetether_runtime_env() {
333 cmd.env("CODETETHER_BIN", codetether_bin).env("PATH", path);
334 }
335 for (key, value) in git_identity_env_from_tool_args(&args) {
336 cmd.env(key, value);
337 }
338 for (arg_key, env_key) in [
339 ("__ct_current_model", "CODETETHER_CURRENT_MODEL"),
340 ("__ct_provenance_id", "CODETETHER_PROVENANCE_ID"),
341 ("__ct_origin", "CODETETHER_ORIGIN"),
342 ("__ct_agent_name", "CODETETHER_AGENT_NAME"),
343 ("__ct_agent_identity_id", "CODETETHER_AGENT_IDENTITY_ID"),
344 ("__ct_key_id", "CODETETHER_KEY_ID"),
345 ("__ct_signature", "CODETETHER_SIGNATURE"),
346 ("__ct_tenant_id", "CODETETHER_TENANT_ID"),
347 ("__ct_worker_id", "CODETETHER_WORKER_ID"),
348 ("__ct_session_id", "CODETETHER_SESSION_ID"),
349 ("__ct_task_id", "CODETETHER_TASK_ID"),
350 ("__ct_run_id", "CODETETHER_RUN_ID"),
351 ("__ct_attempt_id", "CODETETHER_ATTEMPT_ID"),
352 ] {
353 if let Some(value) = args[arg_key].as_str() {
354 cmd.env(env_key, value);
355 }
356 }
357 let github_auth = match load_github_command_auth(
358 command,
359 effective_cwd.as_deref().and_then(|dir| dir.to_str()),
360 )
361 .await
362 {
363 Ok(auth) => auth,
364 Err(err) => {
365 tracing::warn!(error = %err, "Failed to load GitHub auth for bash command");
366 None
367 }
368 };
369 if let Some(auth) = github_auth.as_ref() {
370 for (key, value) in &auth.env {
371 cmd.env(key, value);
372 }
373 }
374
375 if let Some(dir) = effective_cwd.as_deref() {
376 cmd.current_dir(dir);
377 }
378
379 let result = timeout(Duration::from_secs(timeout_secs), cmd.output()).await;
380
381 match result {
382 Ok(Ok(output)) => {
383 let stdout = String::from_utf8_lossy(&output.stdout);
384 let stderr = String::from_utf8_lossy(&output.stderr);
385 let exit_code = output.status.code().unwrap_or(-1);
386
387 let combined = if stderr.is_empty() {
388 stdout.to_string()
389 } else if stdout.is_empty() {
390 stderr.to_string()
391 } else {
392 format!("{}\n--- stderr ---\n{}", stdout, stderr)
393 };
394 let combined = redact_output(
395 combined,
396 github_auth
397 .as_ref()
398 .map(|auth| auth.redactions.as_slice())
399 .unwrap_or(&[]),
400 );
401
402 let success = output.status.success();
403
404 if !success && looks_like_auth_prompt(&combined) {
405 tracing::warn!("Interactive auth prompt detected in output");
406 }
407
408 let max_len = 50_000;
410 let (output_str, truncated) = if combined.len() > max_len {
411 let truncate_at = match combined.is_char_boundary(max_len) {
413 true => max_len,
414 false => {
415 let mut boundary = max_len;
416 while !combined.is_char_boundary(boundary) && boundary > 0 {
417 boundary -= 1;
418 }
419 boundary
420 }
421 };
422 let truncated_output = format!(
423 "{}...\n[Output truncated, {} bytes total]",
424 &combined[..truncate_at],
425 combined.len()
426 );
427 (truncated_output, true)
428 } else {
429 (combined.clone(), false)
430 };
431
432 let duration = exec_start.elapsed();
433
434 let exec = ToolExecution::start(
436 "bash",
437 json!({
438 "command": command,
439 "cwd": effective_cwd
440 .as_ref()
441 .map(|dir| dir.display().to_string()),
442 "timeout": timeout_secs,
443 }),
444 );
445 let exec = if success {
446 exec.complete_success(
447 format!("exit_code={}, output_len={}", exit_code, combined.len()),
448 duration,
449 )
450 } else {
451 exec.complete_error(
452 format!(
453 "exit_code={}: {}",
454 exit_code,
455 combined.lines().next().unwrap_or("(no output)")
456 ),
457 duration,
458 )
459 };
460 TOOL_EXECUTIONS.record(exec.success);
461 let _ = record_persistent(
462 "tool_execution",
463 &serde_json::to_value(&exec).unwrap_or_default(),
464 );
465
466 Ok(ToolResult {
467 output: output_str,
468 success,
469 metadata: [
470 ("exit_code".to_string(), json!(exit_code)),
471 ("truncated".to_string(), json!(truncated)),
472 ]
473 .into_iter()
474 .collect(),
475 })
476 }
477 Ok(Err(e)) => {
478 let duration = exec_start.elapsed();
479 let exec = ToolExecution::start(
480 "bash",
481 json!({
482 "command": command,
483 "cwd": cwd,
484 }),
485 )
486 .complete_error(format!("Failed to execute: {}", e), duration);
487 TOOL_EXECUTIONS.record(exec.success);
488 let _ = record_persistent(
489 "tool_execution",
490 &serde_json::to_value(&exec).unwrap_or_default(),
491 );
492
493 Ok(ToolResult::structured_error(
494 "EXECUTION_FAILED",
495 "bash",
496 &format!("Failed to execute command: {}", e),
497 None,
498 Some(json!({"command": command})),
499 ))
500 }
501 Err(_) => {
502 let duration = exec_start.elapsed();
503 let exec = ToolExecution::start(
504 "bash",
505 json!({
506 "command": command,
507 "cwd": cwd,
508 }),
509 )
510 .complete_error(format!("Timeout after {}s", timeout_secs), duration);
511 TOOL_EXECUTIONS.record(exec.success);
512 let _ = record_persistent(
513 "tool_execution",
514 &serde_json::to_value(&exec).unwrap_or_default(),
515 );
516
517 Ok(ToolResult::structured_error(
518 "TIMEOUT",
519 "bash",
520 &format!("Command timed out after {} seconds", timeout_secs),
521 None,
522 Some(json!({
523 "command": command,
524 "hint": "Consider increasing timeout or breaking into smaller commands"
525 })),
526 ))
527 }
528 }
529 }
530}
531
532impl Default for BashTool {
533 fn default() -> Self {
534 Self::new()
535 }
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541
542 #[tokio::test]
543 async fn sandboxed_bash_basic() {
544 let tool = BashTool {
545 timeout_secs: 10,
546 sandboxed: true,
547 default_cwd: None,
548 };
549 let result = tool
550 .execute(json!({ "command": "echo hello sandbox" }))
551 .await
552 .unwrap();
553 assert!(result.success);
554 assert!(result.output.contains("hello sandbox"));
555 assert_eq!(result.metadata.get("sandboxed"), Some(&json!(true)));
556 }
557
558 #[tokio::test]
559 async fn sandboxed_bash_timeout() {
560 let tool = BashTool {
561 timeout_secs: 1,
562 sandboxed: true,
563 default_cwd: None,
564 };
565 let result = tool
566 .execute(json!({ "command": "sleep 30" }))
567 .await
568 .unwrap();
569 assert!(!result.success);
570 }
571
572 #[test]
573 fn detects_interactive_auth_risk() {
574 assert!(interactive_auth_risk_reason("sudo apt update").is_some());
575 assert!(interactive_auth_risk_reason("ssh user@host").is_some());
576 assert!(interactive_auth_risk_reason("sudo -n apt update").is_none());
577 assert!(interactive_auth_risk_reason("ssh -o BatchMode=yes user@host").is_none());
578 }
579
580 #[test]
581 fn detects_auth_prompt_output() {
582 assert!(looks_like_auth_prompt("[sudo] password for riley:"));
583 assert!(looks_like_auth_prompt(
584 "sudo: a terminal is required to read the password"
585 ));
586 assert!(!looks_like_auth_prompt("command completed successfully"));
587 }
588
589 #[test]
590 fn wraps_commands_with_codetether_function() {
591 let wrapped = codetether_wrapped_command("codetether run 'hi'");
592 assert!(wrapped.contains("codetether()"));
593 assert!(wrapped.contains("CODETETHER_BIN"));
594 assert!(wrapped.ends_with("codetether run 'hi'"));
595 }
596}