Skip to main content

codetether_agent/tool/
bash.rs

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