1use super::bash_github::load_github_command_auth;
4use super::bash_identity::git_identity_env_from_tool_args;
5use super::sandbox::{SandboxPolicy, execute_sandboxed};
6use super::{Tool, ToolResult};
7use crate::audit::{AuditCategory, AuditOutcome, try_audit_log};
8use anyhow::Result;
9use async_trait::async_trait;
10use serde_json::{Value, json};
11use std::ffi::OsString;
12use std::path::PathBuf;
13use std::process::Stdio;
14use std::time::Instant;
15use tokio::process::Command;
16use tokio::time::{Duration, timeout};
17
18use crate::telemetry::{TOOL_EXECUTIONS, ToolExecution, record_persistent};
19
20pub struct BashTool {
22 timeout_secs: u64,
23 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 #[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 #[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 tracing::warn!("Interactive auth risk detected: {}", reason);
212 }
213
214 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 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 let max_len = 50_000;
408 let (output_str, truncated) = if combined.len() > max_len {
409 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 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}