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};
19use crate::util;
20
21/// Execute shell commands
22pub struct BashTool {
23    timeout_secs: u64,
24    /// When true, execute commands through the sandbox with restricted env.
25    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    /// Create a new BashTool with a custom timeout
49    #[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    /// Create a sandboxed BashTool
59    #[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            // Log warning but don't block anymore per user request
212            tracing::warn!("Interactive auth risk detected: {}", reason);
213        }
214
215        // Sandboxed execution path: restricted env, resource limits, audit logged
216        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            // Audit log the sandboxed execution
234            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                // Truncate if too long
409                let max_len = 50_000;
410                let (output_str, truncated) = if combined.len() > max_len {
411                    // Find a valid char boundary at or before max_len
412                    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                // Record telemetry
435                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}