1use super::{strip_ansi, Tool, ToolContext};
2use crate::{Result, RuntimeError};
3use serde_json::{json, Value};
4use zeroize::Zeroize;
5
6const BASH_INTERMEDIARY_CHANNEL_CAPACITY: usize = 64;
7static BASH_INTERMEDIARY_PRODUCED_BYTES: std::sync::atomic::AtomicU64 =
8 std::sync::atomic::AtomicU64::new(0);
9static BASH_INTERMEDIARY_ACCEPTED_BYTES: std::sync::atomic::AtomicU64 =
10 std::sync::atomic::AtomicU64::new(0);
11static BASH_INTERMEDIARY_CONSUMED_BYTES: std::sync::atomic::AtomicU64 =
12 std::sync::atomic::AtomicU64::new(0);
13static BASH_INTERMEDIARY_DROPPED_BYTES: std::sync::atomic::AtomicU64 =
14 std::sync::atomic::AtomicU64::new(0);
15
16#[derive(Debug, Clone, Copy)]
17pub struct BashIntermediarySnapshot {
18 pub produced_bytes: u64,
19 pub accepted_bytes: u64,
20 pub consumed_bytes: u64,
21 pub dropped_bytes: u64,
22 pub retained_bytes: u64,
23}
24
25pub fn bash_intermediary_snapshot() -> BashIntermediarySnapshot {
26 use std::sync::atomic::Ordering;
27 let produced = BASH_INTERMEDIARY_PRODUCED_BYTES.load(Ordering::Relaxed);
28 let accepted = BASH_INTERMEDIARY_ACCEPTED_BYTES.load(Ordering::Relaxed);
29 let consumed = BASH_INTERMEDIARY_CONSUMED_BYTES.load(Ordering::Relaxed);
30 let dropped = BASH_INTERMEDIARY_DROPPED_BYTES.load(Ordering::Relaxed);
31 BashIntermediarySnapshot {
32 produced_bytes: produced,
33 accepted_bytes: accepted,
34 consumed_bytes: consumed,
35 dropped_bytes: dropped,
36 retained_bytes: produced.saturating_sub(consumed).saturating_sub(dropped),
37 }
38}
39
40pub struct BashTool;
41
42const READ_CHUNK_SIZE: usize = 1024;
43const MAX_STREAMED_DELTA_BYTES: usize = 16 * 1024;
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46enum PromptKind {
47 Sudo,
48 Password,
49}
50
51fn sanitize_output(input: &[u8]) -> String {
52 let lossy = String::from_utf8_lossy(input);
53 let stripped = strip_ansi(&lossy);
54 stripped
55 .chars()
56 .filter(|ch| {
57 *ch == '\n' || *ch == '\r' || *ch == '\t' || (!ch.is_control() && *ch != '\u{7f}')
58 })
59 .collect()
60}
61
62fn detect_password_prompt(text: &str) -> Option<PromptKind> {
63 let lower = text.to_ascii_lowercase();
64 let has_password = lower.contains("password");
65 if !has_password {
66 return None;
67 }
68 if lower.contains("[sudo]") || lower.contains("sudo") {
69 Some(PromptKind::Sudo)
70 } else if lower.trim_end().ends_with(':') || lower.contains("password:") {
71 Some(PromptKind::Password)
72 } else {
73 None
74 }
75}
76
77fn append_bounded(output: &mut String, text: &str, max_output: usize) -> bool {
78 if output.len() >= max_output {
79 return false;
80 }
81 let remaining = max_output - output.len();
82 if text.len() <= remaining {
83 output.push_str(text);
84 true
85 } else {
86 let mut end = remaining;
87 while end > 0 && !text.is_char_boundary(end) {
88 end -= 1;
89 }
90 output.push_str(&text[..end]);
91 false
92 }
93}
94
95pub(crate) fn bash_program() -> std::ffi::OsString {
101 #[cfg(unix)]
102 {
103 "bash".into()
104 }
105 #[cfg(windows)]
106 {
107 let on_path = std::env::var_os("PATH")
108 .map(|p| std::env::split_paths(&p).any(|d| d.join("bash.exe").is_file()))
109 .unwrap_or(false);
110 if on_path {
111 return "bash".into();
112 }
113 for var in ["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"] {
114 if let Some(pf) = std::env::var_os(var) {
115 let candidate = std::path::Path::new(&pf).join(r"Git\bin\bash.exe");
116 if candidate.is_file() {
117 return candidate.into_os_string();
118 }
119 }
120 }
121 let wsl = std::path::Path::new(r"C:\Windows\System32\bash.exe");
122 if wsl.is_file() {
123 return wsl.as_os_str().to_os_string();
124 }
125 "bash".into()
127 }
128}
129
130pub(crate) fn bash_script_with_secure_sudo(command: &str) -> String {
131 format!(
137 r#"sudo() {{
138 command sudo -S -p '[sudo] password required: ' "$@"
139}}
140{command}"#
141 )
142}
143
144#[cfg(windows)]
148pub(crate) fn powershell_program() -> std::ffi::OsString {
149 let on_path = |exe: &str| {
150 std::env::var_os("PATH")
151 .map(|p| std::env::split_paths(&p).any(|d| d.join(exe).is_file()))
152 .unwrap_or(false)
153 };
154 if on_path("pwsh.exe") {
155 return "pwsh".into();
156 }
157 for var in ["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"] {
158 if let Some(pf) = std::env::var_os(var) {
159 let candidate = std::path::Path::new(&pf).join(r"PowerShell\7\pwsh.exe");
160 if candidate.is_file() {
161 return candidate.into_os_string();
162 }
163 }
164 }
165 "powershell".into()
167}
168
169#[cfg(not(windows))]
172pub(crate) fn powershell_program() -> std::ffi::OsString {
173 "pwsh".into()
174}
175
176#[async_trait::async_trait]
177impl Tool for BashTool {
178 fn origin(&self) -> crate::tools::ToolOrigin {
179 crate::tools::ToolOrigin::Builtin
180 }
181
182 fn effect(&self) -> crate::tools::catalog::ToolEffect {
185 crate::tools::catalog::ToolEffect::NonIdempotent
186 }
187
188 fn name(&self) -> &str {
189 "bash"
190 }
191
192 fn description(&self) -> &str {
193 "Execute a bash command and return its output. Use for running programs, installing packages, git operations, and any shell commands. Commands time out after 30 seconds by default; pass a larger timeout when needed. If sudo asks for a password, the user is prompted securely in the TUI and the password is never shown to the model."
194 }
195
196 fn parameters(&self) -> Value {
197 json!({
198 "type": "object",
199 "properties": {
200 "command": {
201 "type": "string",
202 "description": "The bash command to execute"
203 },
204 "timeout": {
205 "type": "integer",
206 "description": "Timeout in seconds (default: 30). Use a larger value for long-running commands."
207 }
208 },
209 "required": ["command"]
210 })
211 }
212
213 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
214 let command = params["command"]
215 .as_str()
216 .ok_or_else(|| RuntimeError::Tool("Missing command parameter".to_string()))?;
217
218 let script = bash_script_with_secure_sudo(command);
219 run_shell_command(&script, ShellSpec::Bash, params["timeout"].as_u64(), ctx).await
220 }
221}
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub(crate) enum ShellSpec {
226 Bash,
227 PowerShell,
228}
229
230pub(crate) async fn run_shell_command(
235 script: &str,
236 spec: ShellSpec,
237 requested_timeout: Option<u64>,
238 ctx: ToolContext,
239) -> Result<String> {
240 {
241 let requested_timeout = requested_timeout.unwrap_or(ctx.limits.bash_timeout);
242 let timeout_secs = if ctx.limits.bash_max_timeout > 0 {
245 requested_timeout.min(ctx.limits.bash_max_timeout)
246 } else {
247 requested_timeout
248 };
249 let max_output = ctx.limits.max_tool_buffer;
250
251 let (program, args): (std::ffi::OsString, Vec<std::ffi::OsString>) = match spec {
252 ShellSpec::Bash => (bash_program(), vec!["-c".into(), script.into()]),
253 ShellSpec::PowerShell => (
254 powershell_program(),
255 vec!["-NoProfile".into(), "-Command".into(), script.into()],
256 ),
257 };
258 let mut cmd = tokio::process::Command::new(program);
259 if let Some(cwd) = ctx.capabilities.cwd.as_deref() {
260 cmd.current_dir(cwd);
261 }
262 if let Some(env) = &ctx.capabilities.env {
263 cmd.env_clear().envs(env.iter().map(|(k, v)| (k, v)));
264 }
265 cmd.args(&args)
266 .stdin(std::process::Stdio::piped())
267 .stdout(std::process::Stdio::piped())
268 .stderr(std::process::Stdio::piped())
269 .kill_on_drop(true);
270
271 #[cfg(unix)]
278 unsafe {
279 cmd.pre_exec(|| {
280 libc::setsid();
281 Ok(())
282 });
283 }
284
285 let mut child = cmd.spawn().map_err(|e| RuntimeError::Tool(e.to_string()))?;
286
287 let stdout = child
288 .stdout
289 .take()
290 .ok_or_else(|| RuntimeError::Tool("Failed to capture stdout".to_string()))?;
291 let stderr = child
292 .stderr
293 .take()
294 .ok_or_else(|| RuntimeError::Tool("Failed to capture stderr".to_string()))?;
295 let stdin = child
296 .stdin
297 .take()
298 .ok_or_else(|| RuntimeError::Tool("Failed to capture stdin".to_string()))?;
299
300 let (tx_inter, mut rx_inter) =
301 tokio::sync::mpsc::channel::<(bool, String)>(BASH_INTERMEDIARY_CHANNEL_CAPACITY);
302
303 let tx_o = tx_inter.clone();
304 tokio::spawn(async move {
305 use tokio::io::AsyncReadExt;
306 let mut reader = stdout;
307 let mut buf = vec![0u8; READ_CHUNK_SIZE];
308 loop {
309 match reader.read(&mut buf).await {
310 Ok(0) => break,
311 Ok(n) => {
312 let msg = sanitize_output(&buf[..n]);
313 if !msg.is_empty() {
314 use std::sync::atomic::Ordering;
315 BASH_INTERMEDIARY_PRODUCED_BYTES
316 .fetch_add(msg.len() as u64, Ordering::Relaxed);
317 let len = msg.len();
318 if tx_o.send((false, msg)).await.is_ok() {
319 BASH_INTERMEDIARY_ACCEPTED_BYTES
320 .fetch_add(len as u64, Ordering::Relaxed);
321 } else {
322 BASH_INTERMEDIARY_DROPPED_BYTES
323 .fetch_add(len as u64, Ordering::Relaxed);
324 break;
325 }
326 }
327 }
328 Err(_) => break,
329 }
330 }
331 });
332
333 let tx_e = tx_inter.clone();
334 tokio::spawn(async move {
335 use tokio::io::AsyncReadExt;
336 let mut reader = stderr;
337 let mut buf = vec![0u8; READ_CHUNK_SIZE];
338 loop {
339 match reader.read(&mut buf).await {
340 Ok(0) => break,
341 Ok(n) => {
342 let msg = sanitize_output(&buf[..n]);
343 if !msg.is_empty() {
344 use std::sync::atomic::Ordering;
345 BASH_INTERMEDIARY_PRODUCED_BYTES
346 .fetch_add(msg.len() as u64, Ordering::Relaxed);
347 let len = msg.len();
348 if tx_e.send((true, msg)).await.is_ok() {
349 BASH_INTERMEDIARY_ACCEPTED_BYTES
350 .fetch_add(len as u64, Ordering::Relaxed);
351 } else {
352 BASH_INTERMEDIARY_DROPPED_BYTES
353 .fetch_add(len as u64, Ordering::Relaxed);
354 break;
355 }
356 }
357 }
358 Err(_) => break,
359 }
360 }
361 });
362
363 drop(tx_inter);
364
365 let result = tokio::time::timeout(tokio::time::Duration::from_secs(timeout_secs), async {
366 use tokio::io::AsyncWriteExt;
367
368 let mut stdin = stdin;
369 let mut full_output = String::new();
370 let mut stderr_tail = String::new();
371 let mut truncated = false;
372 let mut streamed_bytes = 0usize;
373 let mut redactions: Vec<String> = Vec::new();
374
375 while let Some((is_stderr, mut msg)) = rx_inter.recv().await {
376 BASH_INTERMEDIARY_CONSUMED_BYTES
377 .fetch_add(msg.len() as u64, std::sync::atomic::Ordering::Relaxed);
378 if is_stderr {
379 stderr_tail.push_str(&msg);
380 if stderr_tail.len() > 512 {
381 let keep_from = stderr_tail.len() - 512;
382 if let Some((idx, _)) =
383 stderr_tail.char_indices().find(|(i, _)| *i >= keep_from)
384 {
385 stderr_tail.drain(..idx);
386 }
387 }
388 if let Some(kind) = detect_password_prompt(&stderr_tail) {
389 let prompt_text = stderr_tail.trim().to_string();
390 let secret = match &ctx.capabilities.secret_prompt {
391 Some(prompt) => {
392 prompt
393 .prompt(
394 match kind {
395 PromptKind::Sudo => {
396 "sudo password required".to_string()
397 }
398 PromptKind::Password => "password required".to_string(),
399 },
400 prompt_text.clone(),
401 )
402 .await
403 }
404 None => None,
405 };
406 match secret {
407 Some(mut value) => {
408 let secret_value = value.clone();
409 if !secret_value.is_empty() {
410 redactions.push(secret_value);
411 }
412 value.push('\n');
413 let write_result = stdin.write_all(value.as_bytes()).await;
414 let flush_result = stdin.flush().await;
415 value.zeroize();
417 write_result.map_err(|e| RuntimeError::Tool(e.to_string()))?;
418 flush_result.map_err(|e| RuntimeError::Tool(e.to_string()))?;
419 }
420 None => {
421 let _ = child.kill().await;
422 return Err(RuntimeError::Tool(
423 "Command canceled while waiting for password".to_string(),
424 ));
425 }
426 }
427 let prompt_len = prompt_text.len();
428 if prompt_len <= msg.len() {
429 let keep_len = msg.len() - prompt_len;
430 msg.truncate(keep_len);
431 } else {
432 msg.clear();
433 }
434 stderr_tail.clear();
435 }
436 }
437
438 for secret in &redactions {
439 if !secret.is_empty() {
440 msg = msg.replace(secret, "[redacted]");
441 }
442 }
443
444 if truncated {
445 continue;
446 }
447
448 let added_all = append_bounded(&mut full_output, &msg, max_output);
449 if let Some(ref txd) = ctx.channels.tx_delta {
450 if streamed_bytes < MAX_STREAMED_DELTA_BYTES {
451 let remaining = MAX_STREAMED_DELTA_BYTES - streamed_bytes;
452 let delta = if msg.len() <= remaining {
453 msg.clone()
454 } else {
455 crate::truncate_str(&msg, remaining).to_string()
456 };
457 streamed_bytes += delta.len();
458 if !delta.is_empty() {
459 txd.send(delta);
460 }
461 }
462 }
463
464 if !added_all {
465 full_output.push_str(&format!("\n\n[output truncated at {}]", max_output));
466 if let Some(ref txd) = ctx.channels.tx_delta {
467 txd.send(format!("\n\n[output truncated at {}]", max_output));
468 }
469 truncated = true;
470 let _ = child.kill().await;
471 }
472 }
473 let status = child
474 .wait()
475 .await
476 .map_err(|e| RuntimeError::Tool(e.to_string()))?;
477 for secret in &mut redactions {
479 secret.zeroize();
480 }
481 Ok::<_, RuntimeError>((status, full_output, truncated))
482 })
483 .await;
484
485 match result {
486 Ok(Ok((status, output, was_truncated))) => {
487 if status.success() || was_truncated {
488 Ok(output)
489 } else {
490 let mut notice = String::new();
491 if !ctx.capabilities.env_stripped.is_empty() {
494 let mut warned = ctx.capabilities.env_warned.lock().unwrap_or_else(|p| p.into_inner());
495 for name in &ctx.capabilities.env_stripped {
496 if warned.contains(name) {
497 continue;
498 }
499 if references_env_name(script, name) {
502 notice.push_str(&format!(
503 "\nnote: ${name} was stripped from the session env as a secret; \
504 reattach from a shell that has it \
505 (synaps --system … from that shell) \
506 — the daemon never receives credentials."
507 ));
508 warned.insert(name.clone());
509 }
510 }
511 }
512 Err(RuntimeError::Tool(format!(
513 "Command failed (exit {}):\n{}{notice}",
514 status.code().unwrap_or(-1),
515 output
516 )))
517 }
518 }
519 Ok(Err(e)) => Err(RuntimeError::Tool(format!(
520 "Failed to execute command: {}",
521 e
522 ))),
523 Err(_) => Err(RuntimeError::Tool(format!(
524 "Command timed out after {}s",
525 timeout_secs
526 ))),
527 }
528 }
529}
530
531fn references_env_name(script: &str, name: &str) -> bool {
536 let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
537 let mut from = 0;
538 while let Some(pos) = script[from..].find(name) {
539 let start = from + pos;
540 let end = start + name.len();
541 let before_ok = !script[..start].chars().next_back().is_some_and(is_ident);
542 let after_ok = !script[end..].chars().next().is_some_and(is_ident);
543 if before_ok && after_ok {
544 return true;
545 }
546 from = end;
547 }
548 false
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554
555 #[tokio::test]
556 async fn bash_intermediary_handoff_conserves_bytes_under_large_output() {
557 let before = bash_intermediary_snapshot();
558 let tool = BashTool;
559 let mut ctx = create_tool_context();
560 ctx.limits.max_tool_buffer = 64 * 1024;
561 let result = tool
562 .execute(
563 json!({
564 "command": "python3 -c \"import sys; sys.stdout.write('x' * 1048576)\"",
565 "timeout": 30
566 }),
567 ctx,
568 )
569 .await
570 .unwrap();
571 assert!(result.contains("output truncated"));
572
573 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
583 let (mut produced, mut consumed, mut accepted, mut dropped);
584 loop {
585 let after = bash_intermediary_snapshot();
586 produced = after.produced_bytes - before.produced_bytes;
587 consumed = after.consumed_bytes - before.consumed_bytes;
588 accepted = after.accepted_bytes - before.accepted_bytes;
589 dropped = after.dropped_bytes - before.dropped_bytes;
590 let balanced =
591 produced == consumed + dropped && after.retained_bytes == before.retained_bytes;
592 if balanced || std::time::Instant::now() >= deadline {
593 assert_eq!(
594 produced,
595 consumed + dropped,
596 "handoff bytes must be conserved at quiescence"
597 );
598 assert_eq!(after.retained_bytes, before.retained_bytes);
599 break;
600 }
601 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
602 }
603 assert!(accepted >= consumed);
604 assert!(produced >= 64 * 1024);
605 }
606
607 #[test]
608 fn detects_sudo_password_prompt_without_newline() {
609 assert_eq!(
610 detect_password_prompt("[sudo] password for me: "),
611 Some(PromptKind::Sudo)
612 );
613 }
614
615 #[test]
616 fn sanitizes_terminal_control_sequences_and_nuls() {
617 let cleaned = sanitize_output(b"ok\x1b[2J\x00done");
618 assert_eq!(cleaned, "okdone");
619 }
620
621 use super::super::test_helpers::create_tool_context;
622 use crate::tools::Tool;
623 use serde_json::json;
624
625 #[test]
626 fn test_bash_tool_schema() {
627 let tool = BashTool;
628 assert_eq!(tool.name(), "bash");
629 assert!(!tool.description().is_empty());
630
631 let params = tool.parameters();
632 assert_eq!(params["type"], "object");
633 assert!(params["properties"].is_object());
634 assert!(params["required"].is_array());
635 }
636
637 #[tokio::test]
638 async fn test_bash_tool_execution() {
639 let tool = BashTool;
640
641 let ctx = create_tool_context();
643 let params = json!({
644 "command": "echo hello"
645 });
646
647 let result = tool.execute(params, ctx).await.unwrap();
648 assert!(result.contains("hello"));
649
650 let ctx = create_tool_context();
652 let params = json!({
653 "command": "sleep 1",
654 "timeout": 2
655 });
656
657 let result = tool.execute(params, ctx).await;
658 assert!(result.is_ok());
660
661 let ctx = create_tool_context();
663 let params = json!({
664 "command": "sleep 3",
665 "timeout": 1
666 });
667
668 let result = tool.execute(params, ctx).await;
669 assert!(result.is_err());
671 assert!(result.unwrap_err().to_string().contains("timed out"));
672 }
673
674 #[tokio::test]
675 async fn test_bash_tool_requested_timeout_is_clamped_by_max_timeout() {
676 let tool = BashTool;
677 let mut ctx = create_tool_context();
678 ctx.limits.bash_max_timeout = 1;
679
680 let params = json!({
681 "command": "sleep 5; echo done",
682 "timeout": 10
683 });
684
685 let result = tool.execute(params, ctx).await;
686 assert!(
687 result.unwrap_err().to_string().contains("timed out"),
688 "requested timeout MUST be clamped by bash_max_timeout"
689 );
690 }
691
692 #[tokio::test]
693 async fn test_bash_fake_sudo_prompt_uses_secret_prompt_and_redacts_password() {
694 let tool = BashTool;
695 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
696 let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
697 let channel = crate::tools::output::delta_channel();
698 let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
699
700 let responder = tokio::spawn(async move {
701 let req = prompt_rx
702 .recv()
703 .await
704 .expect("bash should request a secret prompt");
705 assert!(
706 req.prompt.to_ascii_lowercase().contains("password"),
707 "prompt was {:?}",
708 req.prompt
709 );
710 req.response_tx.send(Some("swordfish".to_string())).unwrap();
711 });
712
713 let mut ctx = create_tool_context();
714 ctx.capabilities.secret_prompt = Some(prompt_handle);
715 ctx.channels.tx_delta = Some(delta_tx);
716 let params = json!({
717 "command": "printf '[sudo] password for testuser: ' >&2; read -r pw; if [ \"$pw\" = swordfish ]; then echo AUTH_OK; else echo AUTH_FAIL; fi",
718 "timeout": 30
719 });
720
721 let result = tool.execute(params, ctx).await.unwrap();
722 responder.await.unwrap();
723 let mut streamed = String::new();
724 while let Some(delta) = delta_rx.try_drain() {
725 streamed.push_str(&delta);
726 }
727
728 assert!(result.contains("AUTH_OK"));
729 assert!(!result.contains("swordfish"));
730 assert!(!result.contains("[sudo] password"));
731 assert!(!streamed.contains("[sudo] password"));
732 }
733
734 #[test]
735 fn test_bash_wraps_sudo_to_force_stdin_prompt() {
736 let script = super::bash_script_with_secure_sudo("sudo id");
737
738 assert!(script.contains("sudo()"));
739 assert!(script.contains("command sudo -S -p '[sudo] password required: '"));
740 assert!(script.ends_with("sudo id"));
741 }
742
743 #[cfg(unix)]
749 fn write_fake_sudo(dir: &std::path::Path) {
750 use std::os::unix::fs::PermissionsExt;
751 let sudo_path = dir.join("sudo");
752 std::fs::write(
753 &sudo_path,
754 r#"#!/bin/sh
755prompt=""
756while [ $# -gt 0 ]; do
757 case "$1" in
758 -S) shift ;;
759 -p) prompt="$2"; shift 2 ;;
760 -k) exit 0 ;;
761 *) break ;;
762 esac
763done
764printf '%s' "$prompt" >&2
765read -r _pw
766exit 1
767"#,
768 )
769 .expect("write fake sudo");
770 let mut perms = std::fs::metadata(&sudo_path)
771 .expect("stat fake sudo")
772 .permissions();
773 perms.set_mode(0o755);
774 std::fs::set_permissions(&sudo_path, perms).expect("chmod fake sudo");
775 }
776
777 #[cfg(unix)]
790 #[tokio::test]
791 async fn test_bash_sudo_function_prompt_is_intercepted_before_streaming() {
792 let fake_bin = tempfile::tempdir().expect("tempdir");
793 write_fake_sudo(fake_bin.path());
794 let tool = BashTool;
795 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
796 let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
797 let channel = crate::tools::output::delta_channel();
798 let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
799
800 let responder = tokio::spawn(async move {
801 let req = prompt_rx
802 .recv()
803 .await
804 .expect("bash should request a secret prompt");
805 assert!(
806 req.prompt.contains("[sudo] password required"),
807 "prompt was {:?}",
808 req.prompt
809 );
810 req.response_tx
811 .send(Some("wrong-password-for-test".to_string()))
812 .unwrap();
813 });
814
815 let mut ctx = create_tool_context();
816 ctx.capabilities.secret_prompt = Some(prompt_handle);
817 ctx.channels.tx_delta = Some(delta_tx);
818 let params = json!({
819 "command": format!(
820 "export PATH=\"{}:$PATH\"; sudo -k; sudo -v",
821 fake_bin.path().display()
822 ),
823 "timeout": 30
824 });
825
826 let _ = tool.execute(params, ctx).await;
827 responder.await.unwrap();
828 let mut streamed = String::new();
829 while let Some(delta) = delta_rx.try_drain() {
830 streamed.push_str(&delta);
831 }
832
833 assert!(
834 !streamed.contains("[sudo] password required"),
835 "sudo password prompt leaked into deltas: {streamed:?}"
836 );
837 }
838
839 #[tokio::test]
840 async fn test_bash_control_char_output_is_sanitized_and_bounded_in_deltas() {
841 let tool = BashTool;
842 let channel = crate::tools::output::delta_channel();
843 let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
844 let mut ctx = create_tool_context();
845 ctx.channels.tx_delta = Some(delta_tx);
846 ctx.limits.max_tool_buffer = 256;
847
848 let params = json!({
849 "command": "python3 -c \"import sys; sys.stdout.buffer.write(b'clean\\x1b[2J\\x00' + b'A' * 2000); sys.stdout.flush()\"",
850 "timeout": 30
851 });
852
853 let result = tool.execute(params, ctx).await.unwrap();
854 let mut streamed = String::new();
855 while let Some(delta) = delta_rx.try_drain() {
856 streamed.push_str(&delta);
857 }
858
859 assert!(result.contains("[output truncated at 256]"));
860 assert!(!result.contains('\u{1b}'));
861 assert!(!result.contains('\0'));
862 assert!(!streamed.contains('\u{1b}'));
863 assert!(!streamed.contains('\0'));
864 assert!(
865 streamed.len() <= 2048,
866 "streamed deltas must be bounded, got {} bytes",
867 streamed.len()
868 );
869 }
870
871 #[tokio::test]
872 async fn test_bash_echoed_secret_is_redacted_from_output() {
873 let tool = BashTool;
874 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
875 let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
876
877 let responder = tokio::spawn(async move {
878 let req = prompt_rx
879 .recv()
880 .await
881 .expect("bash should request a secret prompt");
882 req.response_tx.send(Some("swordfish".to_string())).unwrap();
883 });
884
885 let mut ctx = create_tool_context();
886 ctx.capabilities.secret_prompt = Some(prompt_handle);
887 let params = json!({
888 "command": "printf 'Password: ' >&2; read -r pw; echo seen:$pw",
889 "timeout": 30
890 });
891
892 let result = tool.execute(params, ctx).await.unwrap();
893 responder.await.unwrap();
894
895 assert!(result.contains("seen:[redacted]"));
896 assert!(!result.contains("swordfish"));
897 }
898
899 #[tokio::test]
900 async fn test_bash_sequential_password_prompts_are_each_handled() {
901 let tool = BashTool;
902 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
903 let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
904
905 let responder = tokio::spawn(async move {
906 for value in ["first", "second"] {
907 let req = prompt_rx
908 .recv()
909 .await
910 .expect("bash should request each secret prompt");
911 assert!(req.prompt.to_ascii_lowercase().contains("password"));
912 req.response_tx.send(Some(value.to_string())).unwrap();
913 }
914 });
915
916 let mut ctx = create_tool_context();
917 ctx.capabilities.secret_prompt = Some(prompt_handle);
918 let params = json!({
919 "command": "printf 'Password: ' >&2; read -r one; printf 'Password: ' >&2; read -r two; echo done:$one:$two",
920 "timeout": 30
921 });
922
923 let result = tool.execute(params, ctx).await.unwrap();
924 responder.await.unwrap();
925
926 assert!(result.contains("done:[redacted]:[redacted]"));
927 assert!(!result.contains("first"));
928 assert!(!result.contains("second"));
929 }
930
931 #[tokio::test]
932 async fn test_bash_password_prompt_cancel_kills_command_without_leaking_partial_secret() {
933 let tool = BashTool;
934 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
935 let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
936
937 let responder = tokio::spawn(async move {
938 let req = prompt_rx
939 .recv()
940 .await
941 .expect("bash should request a secret prompt");
942 req.response_tx.send(None).unwrap();
943 });
944
945 let mut ctx = create_tool_context();
946 ctx.capabilities.secret_prompt = Some(prompt_handle);
947 let params = json!({
948 "command": "printf 'Password: ' >&2; read -r pw; echo should-not-run:$pw",
949 "timeout": 30
950 });
951
952 let err = tool.execute(params, ctx).await.unwrap_err().to_string();
953 responder.await.unwrap();
954
955 assert!(err.contains("waiting for password"));
956 assert!(!err.contains("should-not-run"));
957 }
958
959 #[tokio::test]
960 async fn test_bash_binary_output_is_sanitized() {
961 let tool = BashTool;
962 let ctx = create_tool_context();
963 let params = json!({
964 "command": "python3 -c \"import sys; sys.stdout.buffer.write(bytes(range(32)) + b'visible')\"",
965 "timeout": 30
966 });
967
968 let result = tool.execute(params, ctx).await.unwrap();
969
970 assert!(result.contains("visible"));
971 assert!(!result.contains('\0'));
972 assert!(!result.contains('\u{1b}'));
973 }
974
975 #[tokio::test]
976 async fn test_bash_tool_timeout() {
977 let tool = BashTool;
978 let ctx = create_tool_context();
979
980 let params = json!({
981 "command": "sleep 10",
982 "timeout": 1
983 });
984
985 let result = tool.execute(params, ctx).await;
986
987 assert!(result.is_err());
989 let error = result.unwrap_err().to_string();
990 assert!(error.contains("timed out"));
991 }
992
993 #[tokio::test]
994 async fn test_bash_tool_failure() {
995 let tool = BashTool;
996 let ctx = create_tool_context();
997
998 let params = json!({
999 "command": "exit 1"
1000 });
1001
1002 let result = tool.execute(params, ctx).await;
1003
1004 assert!(result.is_err());
1006 let error = result.unwrap_err().to_string();
1007 assert!(error.contains("failed") || error.contains("exit"));
1008 }
1009
1010 #[cfg(unix)]
1011 #[tokio::test]
1012 async fn bash_honours_capability_cwd_when_set() {
1013 let tmp = tempfile::TempDir::new().unwrap();
1014 let expected = tmp.path().canonicalize().unwrap();
1015 let mut ctx = create_tool_context();
1016 ctx.capabilities.cwd = Some(tmp.path().to_path_buf());
1017 let out = BashTool
1018 .execute(json!({"command": "pwd -P"}), ctx)
1019 .await
1020 .unwrap();
1021 assert_eq!(out.trim(), expected.to_string_lossy());
1022 }
1023
1024 #[cfg(unix)]
1025 #[tokio::test]
1026 async fn bash_inherits_process_cwd_when_capability_cwd_is_none() {
1027 let expected = std::env::current_dir().unwrap().canonicalize().unwrap();
1028 let ctx = create_tool_context();
1029 assert!(ctx.capabilities.cwd.is_none());
1030 let out = BashTool
1031 .execute(json!({"command": "pwd -P"}), ctx)
1032 .await
1033 .unwrap();
1034 assert_eq!(out.trim(), expected.to_string_lossy());
1035 }
1036
1037 #[test]
1038 fn references_env_name_is_word_bounded() {
1039 assert!(references_env_name("echo ${GH_TOKEN:?}", "GH_TOKEN"));
1040 assert!(references_env_name("echo $GH_TOKEN", "GH_TOKEN"));
1041 assert!(references_env_name("GH_TOKEN=x gh auth", "GH_TOKEN"));
1042 assert!(references_env_name("$env:GH_TOKEN", "GH_TOKEN"));
1043 assert!(!references_env_name("echo $GH_TOKEN_FILE", "GH_TOKEN"));
1044 assert!(!references_env_name("echo $MY_GH_TOKEN", "GH_TOKEN"));
1045 assert!(!references_env_name("gh auth status", "GH_TOKEN"));
1046 }
1047
1048 #[cfg(unix)]
1050 #[tokio::test]
1051 async fn bash_notice_on_stripped_secret_reference() {
1052 let warned = std::sync::Arc::new(std::sync::Mutex::new(
1053 std::collections::HashSet::<String>::new(),
1054 ));
1055 let mut ctx = create_tool_context();
1057 ctx.capabilities.env_stripped = vec!["GH_TOKEN".into()];
1058 ctx.capabilities.env_warned = warned.clone();
1059 let err = BashTool
1060 .execute(json!({"command": "echo ${GH_TOKEN:?} 2>&1; exit 1"}), ctx)
1061 .await
1062 .unwrap_err();
1063 let msg = format!("{err}");
1064 assert!(
1065 msg.contains("note: $GH_TOKEN was stripped"),
1066 "notice must name the var: {msg}"
1067 );
1068
1069 let mut ctx2 = create_tool_context();
1071 ctx2.capabilities.env_stripped = vec!["GH_TOKEN".into()];
1072 ctx2.capabilities.env_warned = warned.clone();
1073 let out = BashTool
1074 .execute(json!({"command": "echo $GH_TOKEN; exit 0"}), ctx2)
1075 .await
1076 .unwrap();
1077 assert!(
1078 !out.contains("note:"),
1079 "zero exit must not produce a notice: {out}"
1080 );
1081
1082 let mut ctx3 = create_tool_context();
1084 ctx3.capabilities.env_stripped = vec!["GH_TOKEN".into()];
1085 ctx3.capabilities.env_warned = warned.clone();
1086 let err2 = BashTool
1087 .execute(json!({"command": "echo ${GH_TOKEN}; exit 1"}), ctx3)
1088 .await
1089 .unwrap_err();
1090 let msg2 = format!("{err2}");
1091 assert!(
1092 !msg2.contains("note: $GH_TOKEN was stripped"),
1093 "dedup must suppress the second notice: {msg2}"
1094 );
1095 }
1096}