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