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::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
23pub struct BashTool {
25 timeout_secs: u64,
26 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 #[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 #[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 tracing::warn!("Interactive auth risk detected: {}", reason);
215 }
216
217 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 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 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}