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 = execute_sandboxed(
228 &shell.program,
229 &sandbox_args,
230 &policy,
231 work_dir,
232 )
233 .await;
234
235 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 let max_len = 50_000;
413 let (output_str, truncated) = if combined.len() > max_len {
414 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 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}