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
223struct KillGroupUnlessFinished {
232 pgid: Option<u32>,
233}
234
235impl KillGroupUnlessFinished {
236 fn new(pid: Option<u32>) -> Self {
237 Self { pgid: pid }
238 }
239
240 fn finished(&mut self) {
242 self.pgid = None;
243 }
244
245 fn kill_now(&mut self) {
247 #[cfg(unix)]
248 if let Some(pgid) = self.pgid.take() {
249 unsafe {
251 libc::killpg(pgid as libc::pid_t, libc::SIGKILL);
252 }
253 }
254 #[cfg(not(unix))]
255 {
256 self.pgid = None;
257 }
258 }
259}
260
261impl Drop for KillGroupUnlessFinished {
262 fn drop(&mut self) {
263 self.kill_now();
264 }
265}
266
267#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269pub(crate) enum ShellSpec {
270 Bash,
271 PowerShell,
272}
273
274impl ShellSpec {
275 fn scope_description(self) -> &'static str {
277 match self {
278 ShellSpec::Bash => "synaps tool: bash",
279 ShellSpec::PowerShell => "synaps tool: powershell",
280 }
281 }
282}
283
284pub(crate) async fn run_shell_command(
289 script: &str,
290 spec: ShellSpec,
291 requested_timeout: Option<u64>,
292 ctx: ToolContext,
293) -> Result<String> {
294 {
295 let requested_timeout = requested_timeout.unwrap_or(ctx.limits.bash_timeout);
296 let timeout_secs = if ctx.limits.bash_max_timeout > 0 {
299 requested_timeout.min(ctx.limits.bash_max_timeout)
300 } else {
301 requested_timeout
302 };
303 let max_output = ctx.limits.max_tool_buffer;
304
305 let (program, args): (std::ffi::OsString, Vec<std::ffi::OsString>) = match spec {
306 ShellSpec::Bash => (bash_program(), vec!["-c".into(), script.into()]),
307 ShellSpec::PowerShell => (
308 powershell_program(),
309 vec!["-NoProfile".into(), "-Command".into(), script.into()],
310 ),
311 };
312 let effective_env = ctx.capabilities.env.as_ref()
313 .map(|env| env.iter().map(|(k, v)| (k.into(), v.into())).collect())
314 .unwrap_or_else(crate::process_scope::inherited_env);
315 let probe_env = effective_env.clone();
316 let probe_cwd = ctx.capabilities.cwd.clone();
317 if ctx.capabilities.launch_cancel.as_ref().is_some_and(|c| c.is_cancelled()) {
320 return Err(RuntimeError::Tool("Command canceled before launch".into()));
321 }
322 let probe_task = tokio::task::spawn_blocking(move || {
323 crate::process_scope::launcher_for_effective_env(
324 &probe_env, probe_cwd.as_deref())
325 });
326 let launcher = if let Some(cancel) = &ctx.capabilities.launch_cancel {
327 tokio::select! {
328 biased;
329 _ = cancel.cancelled() => return Err(RuntimeError::Tool("Command canceled before launch".into())),
330 result = probe_task => result,
331 }
332 } else {
333 probe_task.await
334 }.map_err(|e| RuntimeError::Tool(e.to_string()))?;
335 let (program, args) = match launcher {
336 Some(launcher) => launcher.wrap(spec.scope_description(),
337 crate::process_scope::resolve_program(program, &effective_env,
338 ctx.capabilities.cwd.as_deref()).map_err(|e| RuntimeError::Tool(e.to_string()))?, args),
339 None => (program, args),
340 };
341 let mut cmd = tokio::process::Command::new(program);
342 if let Some(cwd) = ctx.capabilities.cwd.as_deref() {
343 cmd.current_dir(cwd);
344 }
345 cmd.env_clear().envs(effective_env);
346 cmd.args(&args)
347 .stdin(std::process::Stdio::piped())
348 .stdout(std::process::Stdio::piped())
349 .stderr(std::process::Stdio::piped())
350 .kill_on_drop(true);
351
352 #[cfg(unix)]
359 unsafe {
360 cmd.pre_exec(|| {
361 libc::setsid();
362 Ok(())
363 });
364 }
365
366 if ctx.capabilities.launch_cancel.as_ref().is_some_and(|c| c.is_cancelled()) {
367 return Err(RuntimeError::Tool("Command canceled before launch".into()));
368 }
369 let mut child = cmd.spawn().map_err(|e| RuntimeError::Tool(e.to_string()))?;
370 let mut group = KillGroupUnlessFinished::new(child.id());
374
375 let stdout = child
376 .stdout
377 .take()
378 .ok_or_else(|| RuntimeError::Tool("Failed to capture stdout".to_string()))?;
379 let stderr = child
380 .stderr
381 .take()
382 .ok_or_else(|| RuntimeError::Tool("Failed to capture stderr".to_string()))?;
383 let stdin = child
384 .stdin
385 .take()
386 .ok_or_else(|| RuntimeError::Tool("Failed to capture stdin".to_string()))?;
387
388 let (tx_inter, mut rx_inter) =
389 tokio::sync::mpsc::channel::<(bool, String)>(BASH_INTERMEDIARY_CHANNEL_CAPACITY);
390
391 let tx_o = tx_inter.clone();
392 tokio::spawn(async move {
393 use tokio::io::AsyncReadExt;
394 let mut reader = stdout;
395 let mut buf = vec![0u8; READ_CHUNK_SIZE];
396 loop {
397 match reader.read(&mut buf).await {
398 Ok(0) => break,
399 Ok(n) => {
400 let msg = sanitize_output(&buf[..n]);
401 if !msg.is_empty() {
402 use std::sync::atomic::Ordering;
403 BASH_INTERMEDIARY_PRODUCED_BYTES
404 .fetch_add(msg.len() as u64, Ordering::Relaxed);
405 let len = msg.len();
406 if tx_o.send((false, msg)).await.is_ok() {
407 BASH_INTERMEDIARY_ACCEPTED_BYTES
408 .fetch_add(len as u64, Ordering::Relaxed);
409 } else {
410 BASH_INTERMEDIARY_DROPPED_BYTES
411 .fetch_add(len as u64, Ordering::Relaxed);
412 break;
413 }
414 }
415 }
416 Err(_) => break,
417 }
418 }
419 });
420
421 let tx_e = tx_inter.clone();
422 tokio::spawn(async move {
423 use tokio::io::AsyncReadExt;
424 let mut reader = stderr;
425 let mut buf = vec![0u8; READ_CHUNK_SIZE];
426 loop {
427 match reader.read(&mut buf).await {
428 Ok(0) => break,
429 Ok(n) => {
430 let msg = sanitize_output(&buf[..n]);
431 if !msg.is_empty() {
432 use std::sync::atomic::Ordering;
433 BASH_INTERMEDIARY_PRODUCED_BYTES
434 .fetch_add(msg.len() as u64, Ordering::Relaxed);
435 let len = msg.len();
436 if tx_e.send((true, msg)).await.is_ok() {
437 BASH_INTERMEDIARY_ACCEPTED_BYTES
438 .fetch_add(len as u64, Ordering::Relaxed);
439 } else {
440 BASH_INTERMEDIARY_DROPPED_BYTES
441 .fetch_add(len as u64, Ordering::Relaxed);
442 break;
443 }
444 }
445 }
446 Err(_) => break,
447 }
448 }
449 });
450
451 drop(tx_inter);
452
453 let result = tokio::time::timeout(tokio::time::Duration::from_secs(timeout_secs), async {
454 use tokio::io::AsyncWriteExt;
455
456 let mut stdin = stdin;
457 let mut full_output = String::new();
458 let mut stderr_tail = String::new();
459 let mut truncated = false;
460 let mut streamed_bytes = 0usize;
461 let mut redactions: Vec<String> = Vec::new();
462
463 while let Some((is_stderr, mut msg)) = rx_inter.recv().await {
464 BASH_INTERMEDIARY_CONSUMED_BYTES
465 .fetch_add(msg.len() as u64, std::sync::atomic::Ordering::Relaxed);
466 if is_stderr {
467 stderr_tail.push_str(&msg);
468 if stderr_tail.len() > 512 {
469 let keep_from = stderr_tail.len() - 512;
470 if let Some((idx, _)) =
471 stderr_tail.char_indices().find(|(i, _)| *i >= keep_from)
472 {
473 stderr_tail.drain(..idx);
474 }
475 }
476 if let Some(kind) = detect_password_prompt(&stderr_tail) {
477 let prompt_text = stderr_tail.trim().to_string();
478 let secret = match &ctx.capabilities.secret_prompt {
479 Some(prompt) => {
480 prompt
481 .prompt(
482 match kind {
483 PromptKind::Sudo => {
484 "sudo password required".to_string()
485 }
486 PromptKind::Password => "password required".to_string(),
487 },
488 prompt_text.clone(),
489 )
490 .await
491 }
492 None => None,
493 };
494 match secret {
495 Some(mut value) => {
496 let secret_value = value.clone();
497 if !secret_value.is_empty() {
498 redactions.push(secret_value);
499 }
500 value.push('\n');
501 let write_result = stdin.write_all(value.as_bytes()).await;
502 let flush_result = stdin.flush().await;
503 value.zeroize();
505 write_result.map_err(|e| RuntimeError::Tool(e.to_string()))?;
506 flush_result.map_err(|e| RuntimeError::Tool(e.to_string()))?;
507 }
508 None => {
509 group.kill_now();
510 let _ = child.kill().await;
511 return Err(RuntimeError::Tool(
512 "Command canceled while waiting for password".to_string(),
513 ));
514 }
515 }
516 let prompt_len = prompt_text.len();
517 if prompt_len <= msg.len() {
518 let keep_len = msg.len() - prompt_len;
519 msg.truncate(keep_len);
520 } else {
521 msg.clear();
522 }
523 stderr_tail.clear();
524 }
525 }
526
527 for secret in &redactions {
528 if !secret.is_empty() {
529 msg = msg.replace(secret, "[redacted]");
530 }
531 }
532
533 if truncated {
534 continue;
535 }
536
537 let added_all = append_bounded(&mut full_output, &msg, max_output);
538 if let Some(ref txd) = ctx.channels.tx_delta {
539 if streamed_bytes < MAX_STREAMED_DELTA_BYTES {
540 let remaining = MAX_STREAMED_DELTA_BYTES - streamed_bytes;
541 let delta = if msg.len() <= remaining {
542 msg.clone()
543 } else {
544 crate::truncate_str(&msg, remaining).to_string()
545 };
546 streamed_bytes += delta.len();
547 if !delta.is_empty() {
548 txd.send(delta);
549 }
550 }
551 }
552
553 if !added_all {
554 full_output.push_str(&format!("\n\n[output truncated at {}]", max_output));
555 if let Some(ref txd) = ctx.channels.tx_delta {
556 txd.send(format!("\n\n[output truncated at {}]", max_output));
557 }
558 truncated = true;
559 group.kill_now();
560 let _ = child.kill().await;
561 }
562 }
563 let status = child.wait().await;
564 group.finished();
567 let status = status.map_err(|e| RuntimeError::Tool(e.to_string()))?;
568 for secret in &mut redactions {
572 secret.zeroize();
573 }
574 Ok::<_, RuntimeError>((status, full_output, truncated))
575 })
576 .await;
577
578 match result {
579 Ok(Ok((status, output, was_truncated))) => {
580 if status.success() || was_truncated {
581 Ok(output)
582 } else {
583 let mut notice = String::new();
584 if !ctx.capabilities.env_stripped.is_empty() {
587 let mut warned = ctx.capabilities.env_warned.lock().unwrap_or_else(|p| p.into_inner());
588 for name in &ctx.capabilities.env_stripped {
589 if warned.contains(name) {
590 continue;
591 }
592 if references_env_name(script, name) {
595 notice.push_str(&format!(
596 "\nnote: ${name} was stripped from the session env as a secret; \
597 reattach from a shell that has it \
598 (synaps --system … from that shell) \
599 — the daemon never receives credentials."
600 ));
601 warned.insert(name.clone());
602 }
603 }
604 }
605 Err(RuntimeError::Tool(format!(
606 "Command failed (exit {}):\n{}{notice}",
607 status.code().unwrap_or(-1),
608 output
609 )))
610 }
611 }
612 Ok(Err(e)) => Err(RuntimeError::Tool(format!(
613 "Failed to execute command: {}",
614 e
615 ))),
616 Err(_) => Err(RuntimeError::Tool(format!(
617 "Command timed out after {}s",
618 timeout_secs
619 ))),
620 }
621 }
622}
623
624fn references_env_name(script: &str, name: &str) -> bool {
629 let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
630 let mut from = 0;
631 while let Some(pos) = script[from..].find(name) {
632 let start = from + pos;
633 let end = start + name.len();
634 let before_ok = !script[..start].chars().next_back().is_some_and(is_ident);
635 let after_ok = !script[end..].chars().next().is_some_and(is_ident);
636 if before_ok && after_ok {
637 return true;
638 }
639 from = end;
640 }
641 false
642}
643
644#[cfg(test)]
645mod tests {
646 use super::*;
647
648 #[test]
649 fn explicit_child_kills_signal_and_disarm_group_before_reaping() {
650 let source = include_str!("bash.rs");
651 let executor = source.split("pub(crate) async fn run_shell_command").nth(1).unwrap()
652 .split("#[cfg(test)]").next().unwrap();
653 let kills: Vec<_> = executor.match_indices("let _ = child.kill().await;").collect();
654 assert_eq!(kills.len(), 2, "audit every new explicit kill path");
655 for (offset, _) in kills {
656 assert!(executor[..offset].trim_end().ends_with("group.kill_now();"));
657 }
658 assert!(executor.contains("let status = child.wait().await;"));
659 let wait = executor.split("let status = child.wait().await;").nth(1).unwrap();
660 assert!(wait.find("group.finished();").unwrap() < wait.find("status.map_err").unwrap());
661 assert!(executor.find("let mut child = cmd.spawn()").unwrap()
662 < executor.find("let mut group = KillGroupUnlessFinished::new").unwrap());
663 }
664
665 #[tokio::test]
666 async fn canceled_launch_never_runs_payload() {
667 let dir = tempfile::tempdir().unwrap();
668 let marker = dir.path().join("must-not-exist");
669 let token = crate::CancellationToken::new();
670 token.cancel();
671 let mut ctx = create_tool_context();
672 ctx.capabilities.launch_cancel = Some(token);
673 let error = BashTool.execute(json!({"command": format!("touch '{}'", marker.display())}), ctx)
674 .await.unwrap_err().to_string();
675 assert!(error.contains("canceled before launch"), "{error}");
676 assert!(!marker.exists());
677 }
678
679 fn own_cgroup() -> String {
680 std::fs::read_to_string("/proc/self/cgroup")
681 .unwrap_or_default()
682 .trim()
683 .to_string()
684 }
685
686 #[cfg(target_os = "linux")]
691 #[tokio::test]
692 async fn bash_command_gets_its_own_scope_when_available() {
693 if std::env::var("SYNAPS_TEST_REAL_SCOPES").as_deref() != Ok("1") { return; }
694 let out = BashTool
695 .execute(
696 json!({ "command": "cat /proc/self/cgroup; echo pid=$$", "timeout": 30 }),
697 create_tool_context(),
698 )
699 .await
700 .unwrap();
701 let child_cgroup = out.lines().next().unwrap_or_default().trim().to_string();
702 assert!(child_cgroup.starts_with("0::/"), "cgroup v2 line: {out}");
703 if crate::process_scope::launcher_for_env(None).is_some() {
704 assert_ne!(
705 child_cgroup,
706 own_cgroup(),
707 "the command must leave our cgroup"
708 );
709 assert!(child_cgroup.ends_with(".scope"), "{child_cgroup}");
710 assert!(child_cgroup.contains("/app.slice/"), "{child_cgroup}");
711 } else {
712 assert_eq!(child_cgroup, own_cgroup(), "unwrapped fallback");
713 }
714 }
715
716 #[cfg(target_os = "linux")]
719 #[tokio::test]
720 async fn bash_command_with_a_rebuilt_env_lacking_the_user_bus_runs_unwrapped() {
721 let mut ctx = create_tool_context();
722 ctx.capabilities.env = Some(vec![(
723 "PATH".to_string(),
724 std::env::var("PATH").unwrap_or_default(),
725 )]);
726 let out = BashTool
727 .execute(
728 json!({ "command": "cat /proc/self/cgroup", "timeout": 30 }),
729 ctx,
730 )
731 .await
732 .unwrap();
733 assert_eq!(out.lines().next().unwrap_or_default().trim(), own_cgroup());
734 }
735
736 fn processes_named(name: &str) -> Vec<String> {
737 let out = std::process::Command::new("pgrep")
738 .args(["-f", name])
739 .output()
740 .unwrap();
741 String::from_utf8_lossy(&out.stdout)
742 .split_whitespace()
743 .map(str::to_string)
744 .collect()
745 }
746
747 #[cfg(target_os = "linux")]
751 #[tokio::test]
752 async fn bash_timeout_kills_the_whole_process_group() {
753 let marker = format!("synaps-timeout-child-{}", std::process::id());
754 let out = BashTool
755 .execute(
756 json!({
757 "command": format!("(exec -a {marker} sleep 300) & (exec -a {marker} sleep 300); wait"),
758 "timeout": 1
759 }),
760 create_tool_context(),
761 )
762 .await;
763 let text = match out {
764 Ok(s) => s,
765 Err(e) => e.to_string(),
766 };
767 assert!(text.contains("timed out"), "{text}");
768 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
769 while !processes_named(&marker).is_empty() && std::time::Instant::now() < deadline {
770 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
771 }
772 let left = processes_named(&marker);
773 if !left.is_empty() {
774 let _ = std::process::Command::new("pkill")
775 .args(["-f", &marker])
776 .status();
777 }
778 assert!(left.is_empty(), "children survived the timeout: {left:?}");
779 }
780
781 #[cfg(target_os = "linux")]
784 #[tokio::test]
785 async fn bash_normal_exit_leaves_a_background_job_running() {
786 let marker = format!("synaps-background-job-{}", std::process::id());
787 let out = BashTool
788 .execute(
789 json!({
790 "command": format!("(exec -a {marker} sleep 30) >/dev/null 2>&1 & echo started"),
791 "timeout": 10
792 }),
793 create_tool_context(),
794 )
795 .await
796 .unwrap();
797 assert!(out.contains("started"), "{out}");
798 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
799 let alive = processes_named(&marker);
800 let _ = std::process::Command::new("pkill")
801 .args(["-f", &marker])
802 .status();
803 assert!(
804 !alive.is_empty(),
805 "a deliberate background job must survive a normal exit"
806 );
807 }
808
809 #[tokio::test]
810 async fn bash_intermediary_handoff_conserves_bytes_under_large_output() {
811 let before = bash_intermediary_snapshot();
812 let tool = BashTool;
813 let mut ctx = create_tool_context();
814 ctx.limits.max_tool_buffer = 64 * 1024;
815 let result = tool
816 .execute(
817 json!({
818 "command": "python3 -c \"import sys; sys.stdout.write('x' * 1048576)\"",
819 "timeout": 30
820 }),
821 ctx,
822 )
823 .await
824 .unwrap();
825 assert!(result.contains("output truncated"));
826
827 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
837 let (mut produced, mut consumed, mut accepted, mut dropped);
838 loop {
839 let after = bash_intermediary_snapshot();
840 produced = after.produced_bytes - before.produced_bytes;
841 consumed = after.consumed_bytes - before.consumed_bytes;
842 accepted = after.accepted_bytes - before.accepted_bytes;
843 dropped = after.dropped_bytes - before.dropped_bytes;
844 let balanced =
845 produced == consumed + dropped && after.retained_bytes == before.retained_bytes;
846 if balanced || std::time::Instant::now() >= deadline {
847 assert_eq!(
848 produced,
849 consumed + dropped,
850 "handoff bytes must be conserved at quiescence"
851 );
852 assert_eq!(after.retained_bytes, before.retained_bytes);
853 break;
854 }
855 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
856 }
857 assert!(accepted >= consumed);
858 assert!(produced >= 64 * 1024);
859 }
860
861 #[test]
862 fn detects_sudo_password_prompt_without_newline() {
863 assert_eq!(
864 detect_password_prompt("[sudo] password for me: "),
865 Some(PromptKind::Sudo)
866 );
867 }
868
869 #[test]
870 fn sanitizes_terminal_control_sequences_and_nuls() {
871 let cleaned = sanitize_output(b"ok\x1b[2J\x00done");
872 assert_eq!(cleaned, "okdone");
873 }
874
875 use super::super::test_helpers::create_tool_context;
876 use crate::tools::Tool;
877 use serde_json::json;
878
879 #[test]
880 fn test_bash_tool_schema() {
881 let tool = BashTool;
882 assert_eq!(tool.name(), "bash");
883 assert!(!tool.description().is_empty());
884
885 let params = tool.parameters();
886 assert_eq!(params["type"], "object");
887 assert!(params["properties"].is_object());
888 assert!(params["required"].is_array());
889 }
890
891 #[tokio::test]
892 async fn test_bash_tool_execution() {
893 let tool = BashTool;
894
895 let ctx = create_tool_context();
897 let params = json!({
898 "command": "echo hello"
899 });
900
901 let result = tool.execute(params, ctx).await.unwrap();
902 assert!(result.contains("hello"));
903
904 let ctx = create_tool_context();
906 let params = json!({
907 "command": "sleep 1",
908 "timeout": 2
909 });
910
911 let result = tool.execute(params, ctx).await;
912 assert!(result.is_ok());
914
915 let ctx = create_tool_context();
917 let params = json!({
918 "command": "sleep 3",
919 "timeout": 1
920 });
921
922 let result = tool.execute(params, ctx).await;
923 assert!(result.is_err());
925 assert!(result.unwrap_err().to_string().contains("timed out"));
926 }
927
928 #[tokio::test]
929 async fn test_bash_tool_requested_timeout_is_clamped_by_max_timeout() {
930 let tool = BashTool;
931 let mut ctx = create_tool_context();
932 ctx.limits.bash_max_timeout = 1;
933
934 let params = json!({
935 "command": "sleep 5; echo done",
936 "timeout": 10
937 });
938
939 let result = tool.execute(params, ctx).await;
940 assert!(
941 result.unwrap_err().to_string().contains("timed out"),
942 "requested timeout MUST be clamped by bash_max_timeout"
943 );
944 }
945
946 #[tokio::test]
947 async fn test_bash_fake_sudo_prompt_uses_secret_prompt_and_redacts_password() {
948 let tool = BashTool;
949 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
950 let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
951 let channel = crate::tools::output::delta_channel();
952 let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
953
954 let responder = tokio::spawn(async move {
955 let req = prompt_rx
956 .recv()
957 .await
958 .expect("bash should request a secret prompt");
959 assert!(
960 req.prompt.to_ascii_lowercase().contains("password"),
961 "prompt was {:?}",
962 req.prompt
963 );
964 req.response_tx.send(Some("swordfish".to_string())).unwrap();
965 });
966
967 let mut ctx = create_tool_context();
968 ctx.capabilities.secret_prompt = Some(prompt_handle);
969 ctx.channels.tx_delta = Some(delta_tx);
970 let params = json!({
971 "command": "printf '[sudo] password for testuser: ' >&2; read -r pw; if [ \"$pw\" = swordfish ]; then echo AUTH_OK; else echo AUTH_FAIL; fi",
972 "timeout": 30
973 });
974
975 let result = tool.execute(params, ctx).await.unwrap();
976 responder.await.unwrap();
977 let mut streamed = String::new();
978 while let Some(delta) = delta_rx.try_drain() {
979 streamed.push_str(&delta);
980 }
981
982 assert!(result.contains("AUTH_OK"));
983 assert!(!result.contains("swordfish"));
984 assert!(!result.contains("[sudo] password"));
985 assert!(!streamed.contains("[sudo] password"));
986 }
987
988 #[test]
989 fn test_bash_wraps_sudo_to_force_stdin_prompt() {
990 let script = super::bash_script_with_secure_sudo("sudo id");
991
992 assert!(script.contains("sudo()"));
993 assert!(script.contains("command sudo -S -p '[sudo] password required: '"));
994 assert!(script.ends_with("sudo id"));
995 }
996
997 #[cfg(unix)]
1003 fn write_fake_sudo(dir: &std::path::Path) {
1004 use std::os::unix::fs::PermissionsExt;
1005 let sudo_path = dir.join("sudo");
1006 std::fs::write(
1007 &sudo_path,
1008 r#"#!/bin/sh
1009prompt=""
1010while [ $# -gt 0 ]; do
1011 case "$1" in
1012 -S) shift ;;
1013 -p) prompt="$2"; shift 2 ;;
1014 -k) exit 0 ;;
1015 *) break ;;
1016 esac
1017done
1018printf '%s' "$prompt" >&2
1019read -r _pw
1020exit 1
1021"#,
1022 )
1023 .expect("write fake sudo");
1024 let mut perms = std::fs::metadata(&sudo_path)
1025 .expect("stat fake sudo")
1026 .permissions();
1027 perms.set_mode(0o755);
1028 std::fs::set_permissions(&sudo_path, perms).expect("chmod fake sudo");
1029 }
1030
1031 #[cfg(unix)]
1044 #[tokio::test]
1045 async fn test_bash_sudo_function_prompt_is_intercepted_before_streaming() {
1046 let fake_bin = tempfile::tempdir().expect("tempdir");
1047 write_fake_sudo(fake_bin.path());
1048 let tool = BashTool;
1049 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
1050 let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
1051 let channel = crate::tools::output::delta_channel();
1052 let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
1053
1054 let responder = tokio::spawn(async move {
1055 let req = prompt_rx
1056 .recv()
1057 .await
1058 .expect("bash should request a secret prompt");
1059 assert!(
1060 req.prompt.contains("[sudo] password required"),
1061 "prompt was {:?}",
1062 req.prompt
1063 );
1064 req.response_tx
1065 .send(Some("wrong-password-for-test".to_string()))
1066 .unwrap();
1067 });
1068
1069 let mut ctx = create_tool_context();
1070 ctx.capabilities.secret_prompt = Some(prompt_handle);
1071 ctx.channels.tx_delta = Some(delta_tx);
1072 let params = json!({
1073 "command": format!(
1074 "export PATH=\"{}:$PATH\"; sudo -k; sudo -v",
1075 fake_bin.path().display()
1076 ),
1077 "timeout": 30
1078 });
1079
1080 let _ = tool.execute(params, ctx).await;
1081 responder.await.unwrap();
1082 let mut streamed = String::new();
1083 while let Some(delta) = delta_rx.try_drain() {
1084 streamed.push_str(&delta);
1085 }
1086
1087 assert!(
1088 !streamed.contains("[sudo] password required"),
1089 "sudo password prompt leaked into deltas: {streamed:?}"
1090 );
1091 }
1092
1093 #[tokio::test]
1094 async fn test_bash_control_char_output_is_sanitized_and_bounded_in_deltas() {
1095 let tool = BashTool;
1096 let channel = crate::tools::output::delta_channel();
1097 let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
1098 let mut ctx = create_tool_context();
1099 ctx.channels.tx_delta = Some(delta_tx);
1100 ctx.limits.max_tool_buffer = 256;
1101
1102 let params = json!({
1103 "command": "python3 -c \"import sys; sys.stdout.buffer.write(b'clean\\x1b[2J\\x00' + b'A' * 2000); sys.stdout.flush()\"",
1104 "timeout": 30
1105 });
1106
1107 let result = tool.execute(params, ctx).await.unwrap();
1108 let mut streamed = String::new();
1109 while let Some(delta) = delta_rx.try_drain() {
1110 streamed.push_str(&delta);
1111 }
1112
1113 assert!(result.contains("[output truncated at 256]"));
1114 assert!(!result.contains('\u{1b}'));
1115 assert!(!result.contains('\0'));
1116 assert!(!streamed.contains('\u{1b}'));
1117 assert!(!streamed.contains('\0'));
1118 assert!(
1119 streamed.len() <= 2048,
1120 "streamed deltas must be bounded, got {} bytes",
1121 streamed.len()
1122 );
1123 }
1124
1125 #[tokio::test]
1126 async fn test_bash_echoed_secret_is_redacted_from_output() {
1127 let tool = BashTool;
1128 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
1129 let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
1130
1131 let responder = tokio::spawn(async move {
1132 let req = prompt_rx
1133 .recv()
1134 .await
1135 .expect("bash should request a secret prompt");
1136 req.response_tx.send(Some("swordfish".to_string())).unwrap();
1137 });
1138
1139 let mut ctx = create_tool_context();
1140 ctx.capabilities.secret_prompt = Some(prompt_handle);
1141 let params = json!({
1142 "command": "printf 'Password: ' >&2; read -r pw; echo seen:$pw",
1143 "timeout": 30
1144 });
1145
1146 let result = tool.execute(params, ctx).await.unwrap();
1147 responder.await.unwrap();
1148
1149 assert!(result.contains("seen:[redacted]"));
1150 assert!(!result.contains("swordfish"));
1151 }
1152
1153 #[tokio::test]
1154 async fn test_bash_sequential_password_prompts_are_each_handled() {
1155 let tool = BashTool;
1156 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
1157 let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
1158
1159 let responder = tokio::spawn(async move {
1160 for value in ["first", "second"] {
1161 let req = prompt_rx
1162 .recv()
1163 .await
1164 .expect("bash should request each secret prompt");
1165 assert!(req.prompt.to_ascii_lowercase().contains("password"));
1166 req.response_tx.send(Some(value.to_string())).unwrap();
1167 }
1168 });
1169
1170 let mut ctx = create_tool_context();
1171 ctx.capabilities.secret_prompt = Some(prompt_handle);
1172 let params = json!({
1173 "command": "printf 'Password: ' >&2; read -r one; printf 'Password: ' >&2; read -r two; echo done:$one:$two",
1174 "timeout": 30
1175 });
1176
1177 let result = tool.execute(params, ctx).await.unwrap();
1178 responder.await.unwrap();
1179
1180 assert!(result.contains("done:[redacted]:[redacted]"));
1181 assert!(!result.contains("first"));
1182 assert!(!result.contains("second"));
1183 }
1184
1185 #[tokio::test]
1186 async fn test_bash_password_prompt_cancel_kills_command_without_leaking_partial_secret() {
1187 let tool = BashTool;
1188 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
1189 let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
1190
1191 let responder = tokio::spawn(async move {
1192 let req = prompt_rx
1193 .recv()
1194 .await
1195 .expect("bash should request a secret prompt");
1196 req.response_tx.send(None).unwrap();
1197 });
1198
1199 let mut ctx = create_tool_context();
1200 ctx.capabilities.secret_prompt = Some(prompt_handle);
1201 let params = json!({
1202 "command": "printf 'Password: ' >&2; read -r pw; echo should-not-run:$pw",
1203 "timeout": 30
1204 });
1205
1206 let err = tool.execute(params, ctx).await.unwrap_err().to_string();
1207 responder.await.unwrap();
1208
1209 assert!(err.contains("waiting for password"));
1210 assert!(!err.contains("should-not-run"));
1211 }
1212
1213 #[tokio::test]
1214 async fn test_bash_binary_output_is_sanitized() {
1215 let tool = BashTool;
1216 let ctx = create_tool_context();
1217 let params = json!({
1218 "command": "python3 -c \"import sys; sys.stdout.buffer.write(bytes(range(32)) + b'visible')\"",
1219 "timeout": 30
1220 });
1221
1222 let result = tool.execute(params, ctx).await.unwrap();
1223
1224 assert!(result.contains("visible"));
1225 assert!(!result.contains('\0'));
1226 assert!(!result.contains('\u{1b}'));
1227 }
1228
1229 #[tokio::test]
1230 async fn test_bash_tool_timeout() {
1231 let tool = BashTool;
1232 let ctx = create_tool_context();
1233
1234 let params = json!({
1235 "command": "sleep 10",
1236 "timeout": 1
1237 });
1238
1239 let result = tool.execute(params, ctx).await;
1240
1241 assert!(result.is_err());
1243 let error = result.unwrap_err().to_string();
1244 assert!(error.contains("timed out"));
1245 }
1246
1247 #[tokio::test]
1248 async fn test_bash_tool_failure() {
1249 let tool = BashTool;
1250 let ctx = create_tool_context();
1251
1252 let params = json!({
1253 "command": "exit 1"
1254 });
1255
1256 let result = tool.execute(params, ctx).await;
1257
1258 assert!(result.is_err());
1260 let error = result.unwrap_err().to_string();
1261 assert!(error.contains("failed") || error.contains("exit"));
1262 }
1263
1264 #[cfg(unix)]
1265 #[tokio::test]
1266 async fn bash_honours_capability_cwd_when_set() {
1267 let tmp = tempfile::TempDir::new().unwrap();
1268 let expected = tmp.path().canonicalize().unwrap();
1269 let mut ctx = create_tool_context();
1270 ctx.capabilities.cwd = Some(tmp.path().to_path_buf());
1271 let out = BashTool
1272 .execute(json!({"command": "pwd -P"}), ctx)
1273 .await
1274 .unwrap();
1275 assert_eq!(out.trim(), expected.to_string_lossy());
1276 }
1277
1278 #[cfg(unix)]
1279 #[tokio::test]
1280 async fn bash_inherits_process_cwd_when_capability_cwd_is_none() {
1281 let expected = std::env::current_dir().unwrap().canonicalize().unwrap();
1282 let ctx = create_tool_context();
1283 assert!(ctx.capabilities.cwd.is_none());
1284 let out = BashTool
1285 .execute(json!({"command": "pwd -P"}), ctx)
1286 .await
1287 .unwrap();
1288 assert_eq!(out.trim(), expected.to_string_lossy());
1289 }
1290
1291 #[test]
1292 fn references_env_name_is_word_bounded() {
1293 assert!(references_env_name("echo ${GH_TOKEN:?}", "GH_TOKEN"));
1294 assert!(references_env_name("echo $GH_TOKEN", "GH_TOKEN"));
1295 assert!(references_env_name("GH_TOKEN=x gh auth", "GH_TOKEN"));
1296 assert!(references_env_name("$env:GH_TOKEN", "GH_TOKEN"));
1297 assert!(!references_env_name("echo $GH_TOKEN_FILE", "GH_TOKEN"));
1298 assert!(!references_env_name("echo $MY_GH_TOKEN", "GH_TOKEN"));
1299 assert!(!references_env_name("gh auth status", "GH_TOKEN"));
1300 }
1301
1302 #[cfg(unix)]
1304 #[tokio::test]
1305 async fn bash_notice_on_stripped_secret_reference() {
1306 let warned = std::sync::Arc::new(std::sync::Mutex::new(
1307 std::collections::HashSet::<String>::new(),
1308 ));
1309 let mut ctx = create_tool_context();
1311 ctx.capabilities.env_stripped = vec!["GH_TOKEN".into()];
1312 ctx.capabilities.env_warned = warned.clone();
1313 let err = BashTool
1314 .execute(json!({"command": "echo ${GH_TOKEN:?} 2>&1; exit 1"}), ctx)
1315 .await
1316 .unwrap_err();
1317 let msg = format!("{err}");
1318 assert!(
1319 msg.contains("note: $GH_TOKEN was stripped"),
1320 "notice must name the var: {msg}"
1321 );
1322
1323 let mut ctx2 = create_tool_context();
1325 ctx2.capabilities.env_stripped = vec!["GH_TOKEN".into()];
1326 ctx2.capabilities.env_warned = warned.clone();
1327 let out = BashTool
1328 .execute(json!({"command": "echo $GH_TOKEN; exit 0"}), ctx2)
1329 .await
1330 .unwrap();
1331 assert!(
1332 !out.contains("note:"),
1333 "zero exit must not produce a notice: {out}"
1334 );
1335
1336 let mut ctx3 = create_tool_context();
1338 ctx3.capabilities.env_stripped = vec!["GH_TOKEN".into()];
1339 ctx3.capabilities.env_warned = warned.clone();
1340 let err2 = BashTool
1341 .execute(json!({"command": "echo ${GH_TOKEN}; exit 1"}), ctx3)
1342 .await
1343 .unwrap_err();
1344 let msg2 = format!("{err2}");
1345 assert!(
1346 !msg2.contains("note: $GH_TOKEN was stripped"),
1347 "dedup must suppress the second notice: {msg2}"
1348 );
1349 }
1350}