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 =
228                execute_sandboxed(&shell.program, &sandbox_args, &policy, work_dir).await;
229
230            // Audit log the sandboxed execution
231            if let Some(audit) = try_audit_log() {
232                let (outcome, detail) = match &sandbox_result {
233                    Ok(r) => (
234                        if r.success {
235                            AuditOutcome::Success
236                        } else {
237                            AuditOutcome::Failure
238                        },
239                        json!({
240                            "sandboxed": true,
241                            "exit_code": r.exit_code,
242                            "duration_ms": r.duration_ms,
243                            "violations": r.sandbox_violations,
244                        }),
245                    ),
246                    Err(e) => (
247                        AuditOutcome::Failure,
248                        json!({ "sandboxed": true, "error": e.to_string() }),
249                    ),
250                };
251                audit
252                    .log(
253                        AuditCategory::Sandbox,
254                        format!("bash:{}", crate::util::truncate_bytes_safe(&command, 80)),
255                        outcome,
256                        None,
257                        Some(detail),
258                    )
259                    .await;
260            }
261
262            return match sandbox_result {
263                Ok(r) => {
264                    let duration = exec_start.elapsed();
265                    let exec = ToolExecution::start(
266                        "bash",
267                        json!({ "command": command, "sandboxed": true }),
268                    );
269                    let exec = if r.success {
270                        exec.complete_success(format!("exit_code={:?}", r.exit_code), duration)
271                    } else {
272                        exec.complete_error(format!("exit_code={:?}", r.exit_code), duration)
273                    };
274                    TOOL_EXECUTIONS.record(exec.success);
275                    let data = serde_json::json!({
276                        "tool": "bash",
277                        "command": command,
278                        "success": r.success,
279                        "exit_code": r.exit_code,
280                    });
281                    let _ = record_persistent("tool_execution", &data);
282
283                    Ok(ToolResult {
284                        output: r.output,
285                        success: r.success,
286                        metadata: [
287                            ("exit_code".to_string(), json!(r.exit_code)),
288                            ("sandboxed".to_string(), json!(true)),
289                            (
290                                "sandbox_violations".to_string(),
291                                json!(r.sandbox_violations),
292                            ),
293                        ]
294                        .into_iter()
295                        .collect(),
296                    })
297                }
298                Err(e) => {
299                    let duration = exec_start.elapsed();
300                    let exec = ToolExecution::start(
301                        "bash",
302                        json!({ "command": command, "sandboxed": true }),
303                    )
304                    .complete_error(e.to_string(), duration);
305                    TOOL_EXECUTIONS.record(exec.success);
306                    let data = serde_json::json!({
307                        "tool": "bash",
308                        "command": command,
309                        "success": false,
310                        "error": e.to_string(),
311                    });
312                    let _ = record_persistent("tool_execution", &data);
313                    Ok(ToolResult::error(format!("Sandbox error: {}", e)))
314                }
315            };
316        }
317
318        let shell = super::bash_shell::resolve();
319        let mut cmd = Command::new(&shell.program);
320        cmd.args(&shell.prefix_args)
321            .arg(&wrapped_command)
322            .stdin(Stdio::null())
323            .stdout(Stdio::piped())
324            .stderr(Stdio::piped())
325            .env("GIT_TERMINAL_PROMPT", "0")
326            .env("GCM_INTERACTIVE", "never")
327            .env("DEBIAN_FRONTEND", "noninteractive")
328            .env("SUDO_ASKPASS", "/bin/false")
329            .env("SSH_ASKPASS", "/bin/false");
330        if let Some((codetether_bin, path)) = codetether_runtime_env() {
331            cmd.env("CODETETHER_BIN", codetether_bin).env("PATH", path);
332        }
333        for (key, value) in git_identity_env_from_tool_args(&args) {
334            cmd.env(key, value);
335        }
336        for (arg_key, env_key) in [
337            ("__ct_current_model", "CODETETHER_CURRENT_MODEL"),
338            ("__ct_provenance_id", "CODETETHER_PROVENANCE_ID"),
339            ("__ct_origin", "CODETETHER_ORIGIN"),
340            ("__ct_agent_name", "CODETETHER_AGENT_NAME"),
341            ("__ct_agent_identity_id", "CODETETHER_AGENT_IDENTITY_ID"),
342            ("__ct_key_id", "CODETETHER_KEY_ID"),
343            ("__ct_signature", "CODETETHER_SIGNATURE"),
344            ("__ct_tenant_id", "CODETETHER_TENANT_ID"),
345            ("__ct_worker_id", "CODETETHER_WORKER_ID"),
346            ("__ct_session_id", "CODETETHER_SESSION_ID"),
347            ("__ct_task_id", "CODETETHER_TASK_ID"),
348            ("__ct_run_id", "CODETETHER_RUN_ID"),
349            ("__ct_attempt_id", "CODETETHER_ATTEMPT_ID"),
350        ] {
351            if let Some(value) = args[arg_key].as_str() {
352                cmd.env(env_key, value);
353            }
354        }
355        let github_auth = match load_github_command_auth(
356            command,
357            effective_cwd.as_deref().and_then(|dir| dir.to_str()),
358        )
359        .await
360        {
361            Ok(auth) => auth,
362            Err(err) => {
363                tracing::warn!(error = %err, "Failed to load GitHub auth for bash command");
364                None
365            }
366        };
367        if let Some(auth) = github_auth.as_ref() {
368            for (key, value) in &auth.env {
369                cmd.env(key, value);
370            }
371        }
372
373        if let Some(dir) = effective_cwd.as_deref() {
374            cmd.current_dir(dir);
375        }
376
377        let result = timeout(Duration::from_secs(timeout_secs), cmd.output()).await;
378
379        match result {
380            Ok(Ok(output)) => {
381                let stdout = String::from_utf8_lossy(&output.stdout);
382                let stderr = String::from_utf8_lossy(&output.stderr);
383                let exit_code = output.status.code().unwrap_or(-1);
384
385                let combined = if stderr.is_empty() {
386                    stdout.to_string()
387                } else if stdout.is_empty() {
388                    stderr.to_string()
389                } else {
390                    format!("{}\n--- stderr ---\n{}", stdout, stderr)
391                };
392                let combined = redact_output(
393                    combined,
394                    github_auth
395                        .as_ref()
396                        .map(|auth| auth.redactions.as_slice())
397                        .unwrap_or(&[]),
398                );
399
400                let success = output.status.success();
401
402                if !success && looks_like_auth_prompt(&combined) {
403                    tracing::warn!("Interactive auth prompt detected in output");
404                }
405
406                // Truncate if too long
407                let max_len = 50_000;
408                let (output_str, truncated) = if combined.len() > max_len {
409                    // Find a valid char boundary at or before max_len
410                    let truncate_at = match combined.is_char_boundary(max_len) {
411                        true => max_len,
412                        false => {
413                            let mut boundary = max_len;
414                            while !combined.is_char_boundary(boundary) && boundary > 0 {
415                                boundary -= 1;
416                            }
417                            boundary
418                        }
419                    };
420                    let truncated_output = format!(
421                        "{}...\n[Output truncated, {} bytes total]",
422                        &combined[..truncate_at],
423                        combined.len()
424                    );
425                    (truncated_output, true)
426                } else {
427                    (combined.clone(), false)
428                };
429
430                let duration = exec_start.elapsed();
431
432                // Record telemetry
433                let exec = ToolExecution::start(
434                    "bash",
435                    json!({
436                        "command": command,
437                        "cwd": effective_cwd
438                            .as_ref()
439                            .map(|dir| dir.display().to_string()),
440                        "timeout": timeout_secs,
441                    }),
442                );
443                let exec = if success {
444                    exec.complete_success(
445                        format!("exit_code={}, output_len={}", exit_code, combined.len()),
446                        duration,
447                    )
448                } else {
449                    exec.complete_error(
450                        format!(
451                            "exit_code={}: {}",
452                            exit_code,
453                            combined.lines().next().unwrap_or("(no output)")
454                        ),
455                        duration,
456                    )
457                };
458                TOOL_EXECUTIONS.record(exec.success);
459                let _ = record_persistent(
460                    "tool_execution",
461                    &serde_json::to_value(&exec).unwrap_or_default(),
462                );
463
464                Ok(ToolResult {
465                    output: output_str,
466                    success,
467                    metadata: [
468                        ("exit_code".to_string(), json!(exit_code)),
469                        ("truncated".to_string(), json!(truncated)),
470                    ]
471                    .into_iter()
472                    .collect(),
473                })
474            }
475            Ok(Err(e)) => {
476                let duration = exec_start.elapsed();
477                let exec = ToolExecution::start(
478                    "bash",
479                    json!({
480                        "command": command,
481                        "cwd": cwd,
482                    }),
483                )
484                .complete_error(format!("Failed to execute: {}", e), duration);
485                TOOL_EXECUTIONS.record(exec.success);
486                let _ = record_persistent(
487                    "tool_execution",
488                    &serde_json::to_value(&exec).unwrap_or_default(),
489                );
490
491                Ok(ToolResult::structured_error(
492                    "EXECUTION_FAILED",
493                    "bash",
494                    &format!("Failed to execute command: {}", e),
495                    None,
496                    Some(json!({"command": command})),
497                ))
498            }
499            Err(_) => {
500                let duration = exec_start.elapsed();
501                let exec = ToolExecution::start(
502                    "bash",
503                    json!({
504                        "command": command,
505                        "cwd": cwd,
506                    }),
507                )
508                .complete_error(format!("Timeout after {}s", timeout_secs), duration);
509                TOOL_EXECUTIONS.record(exec.success);
510                let _ = record_persistent(
511                    "tool_execution",
512                    &serde_json::to_value(&exec).unwrap_or_default(),
513                );
514
515                Ok(ToolResult::structured_error(
516                    "TIMEOUT",
517                    "bash",
518                    &format!("Command timed out after {} seconds", timeout_secs),
519                    None,
520                    Some(json!({
521                        "command": command,
522                        "hint": "Consider increasing timeout or breaking into smaller commands"
523                    })),
524                ))
525            }
526        }
527    }
528}
529
530impl Default for BashTool {
531    fn default() -> Self {
532        Self::new()
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539
540    #[tokio::test]
541    async fn sandboxed_bash_basic() {
542        let tool = BashTool {
543            timeout_secs: 10,
544            sandboxed: true,
545            default_cwd: None,
546        };
547        let result = tool
548            .execute(json!({ "command": "echo hello sandbox" }))
549            .await
550            .unwrap();
551        assert!(result.success);
552        assert!(result.output.contains("hello sandbox"));
553        assert_eq!(result.metadata.get("sandboxed"), Some(&json!(true)));
554    }
555
556    #[tokio::test]
557    async fn sandboxed_bash_timeout() {
558        let tool = BashTool {
559            timeout_secs: 1,
560            sandboxed: true,
561            default_cwd: None,
562        };
563        let result = tool
564            .execute(json!({ "command": "sleep 30" }))
565            .await
566            .unwrap();
567        assert!(!result.success);
568    }
569
570    #[test]
571    fn detects_interactive_auth_risk() {
572        assert!(interactive_auth_risk_reason("sudo apt update").is_some());
573        assert!(interactive_auth_risk_reason("ssh user@host").is_some());
574        assert!(interactive_auth_risk_reason("sudo -n apt update").is_none());
575        assert!(interactive_auth_risk_reason("ssh -o BatchMode=yes user@host").is_none());
576    }
577
578    #[test]
579    fn detects_auth_prompt_output() {
580        assert!(looks_like_auth_prompt("[sudo] password for riley:"));
581        assert!(looks_like_auth_prompt(
582            "sudo: a terminal is required to read the password"
583        ));
584        assert!(!looks_like_auth_prompt("command completed successfully"));
585    }
586
587    #[test]
588    fn wraps_commands_with_codetether_function() {
589        let wrapped = codetether_wrapped_command("codetether run 'hi'");
590        assert!(wrapped.contains("codetether()"));
591        assert!(wrapped.contains("CODETETHER_BIN"));
592        assert!(wrapped.ends_with("codetether run 'hi'"));
593    }
594}