1use super::{strip_ansi, Tool, ToolContext};
2use crate::{Result, RuntimeError};
3use serde_json::{json, Value};
4use zeroize::Zeroizing;
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
25impl BashIntermediarySnapshot {
26 pub fn is_settled(&self) -> bool {
29 self.produced_bytes == self.consumed_bytes + self.dropped_bytes
30 }
31}
32
33pub fn bash_intermediary_snapshot() -> BashIntermediarySnapshot {
38 use std::sync::atomic::Ordering::SeqCst;
39 let consumed = BASH_INTERMEDIARY_CONSUMED_BYTES.load(SeqCst);
40 let dropped = BASH_INTERMEDIARY_DROPPED_BYTES.load(SeqCst);
41 let accepted = BASH_INTERMEDIARY_ACCEPTED_BYTES.load(SeqCst);
42 let produced = BASH_INTERMEDIARY_PRODUCED_BYTES.load(SeqCst);
43 BashIntermediarySnapshot {
44 produced_bytes: produced,
45 accepted_bytes: accepted,
46 consumed_bytes: consumed,
47 dropped_bytes: dropped,
48 retained_bytes: produced.saturating_sub(consumed).saturating_sub(dropped),
49 }
50}
51
52#[cfg(test)]
55async fn wait_handoff_settled() -> BashIntermediarySnapshot {
56 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
57 loop {
58 let now = bash_intermediary_snapshot();
59 if now.is_settled() {
60 return now;
61 }
62 assert!(std::time::Instant::now() < deadline, "handoff bytes never settled: {now:?}");
63 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
64 }
65}
66
67pub struct BashTool;
68
69struct HandoffChunk(Option<String>);
75
76impl HandoffChunk {
77 fn new(text: String) -> Self {
78 BASH_INTERMEDIARY_PRODUCED_BYTES
79 .fetch_add(text.len() as u64, std::sync::atomic::Ordering::SeqCst);
80 Self(Some(text))
81 }
82
83 fn len(&self) -> usize {
84 self.0.as_ref().map_or(0, String::len)
85 }
86
87 fn consume(mut self) -> String {
88 let text = self.0.take().unwrap_or_default();
89 BASH_INTERMEDIARY_CONSUMED_BYTES
90 .fetch_add(text.len() as u64, std::sync::atomic::Ordering::SeqCst);
91 text
92 }
93}
94
95impl Drop for HandoffChunk {
96 fn drop(&mut self) {
97 if let Some(text) = self.0.take() {
98 BASH_INTERMEDIARY_DROPPED_BYTES
99 .fetch_add(text.len() as u64, std::sync::atomic::Ordering::SeqCst);
100 }
101 }
102}
103
104const READ_CHUNK_SIZE: usize = 1024;
105const MAX_STREAMED_DELTA_BYTES: usize = 16 * 1024;
106
107pub(crate) type Redactions = std::sync::Arc<std::sync::Mutex<Vec<Zeroizing<String>>>>;
110
111#[cfg(target_os = "linux")]
113pub(crate) type Askpass = super::sudo_askpass::broker::PreparedBroker;
114#[cfg(not(target_os = "linux"))]
115pub(crate) type Askpass = std::convert::Infallible;
116
117fn sanitize_output(input: &str) -> String {
118 let stripped = strip_ansi(input);
119 stripped
120 .chars()
121 .filter(|ch| {
122 *ch == '\n' || *ch == '\r' || *ch == '\t' || (!ch.is_control() && *ch != '\u{7f}')
123 })
124 .collect()
125}
126
127async fn forward_output<R>(
133 mut reader: R,
134 is_stderr: bool,
135 tx: tokio::sync::mpsc::Sender<(bool, HandoffChunk)>,
136) where
137 R: tokio::io::AsyncRead + Unpin,
138{
139 use tokio::io::AsyncReadExt;
140 let mut buf = vec![0u8; READ_CHUNK_SIZE];
141 let mut decoder = super::utf8_stream::Utf8StreamDecoder::new();
142 loop {
143 let (text, eof) = match reader.read(&mut buf).await {
144 Ok(0) | Err(_) => (decoder.finish(), true),
145 Ok(n) => (decoder.decode(&buf[..n]), false),
146 };
147 let msg = sanitize_output(&text);
148 if !msg.is_empty() {
149 let chunk = HandoffChunk::new(msg);
150 let Ok(permit) = tx.reserve().await else {
154 break;
155 };
156 BASH_INTERMEDIARY_ACCEPTED_BYTES
157 .fetch_add(chunk.len() as u64, std::sync::atomic::Ordering::SeqCst);
158 permit.send((is_stderr, chunk));
159 }
160 if eof {
161 break;
162 }
163 }
164}
165
166fn append_bounded(output: &mut String, text: &str, max_output: usize) -> bool {
167 if output.len() >= max_output {
168 return false;
169 }
170 let remaining = max_output - output.len();
171 if text.len() <= remaining {
172 output.push_str(text);
173 true
174 } else {
175 let mut end = remaining;
176 while end > 0 && !text.is_char_boundary(end) {
177 end -= 1;
178 }
179 output.push_str(&text[..end]);
180 false
181 }
182}
183
184pub(crate) fn bash_program() -> std::ffi::OsString {
190 #[cfg(unix)]
191 {
192 "bash".into()
193 }
194 #[cfg(windows)]
195 {
196 let on_path = std::env::var_os("PATH")
197 .map(|p| std::env::split_paths(&p).any(|d| d.join("bash.exe").is_file()))
198 .unwrap_or(false);
199 if on_path {
200 return "bash".into();
201 }
202 for var in ["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"] {
203 if let Some(pf) = std::env::var_os(var) {
204 let candidate = std::path::Path::new(&pf).join(r"Git\bin\bash.exe");
205 if candidate.is_file() {
206 return candidate.into_os_string();
207 }
208 }
209 }
210 let wsl = std::path::Path::new(r"C:\Windows\System32\bash.exe");
211 if wsl.is_file() {
212 return wsl.as_os_str().to_os_string();
213 }
214 "bash".into()
216 }
217}
218
219pub(crate) fn bash_script_with_secure_sudo(command: &str, askpass: bool) -> String {
227 #[cfg(unix)]
228 let call = if askpass {
229 r#"command sudo -A "$@""#
230 } else {
231 r#"command sudo -n "$@""#
232 };
233 #[cfg(not(unix))]
234 let call = {
235 let _ = askpass;
236 r#"command sudo -S -p '[sudo] password required: ' "$@""#
237 };
238 format!(
239 r#"sudo() {{
240 {call}
241}}
242{command}"#
243 )
244}
245
246#[cfg(windows)]
250pub(crate) fn powershell_program() -> std::ffi::OsString {
251 let on_path = |exe: &str| {
252 std::env::var_os("PATH")
253 .map(|p| std::env::split_paths(&p).any(|d| d.join(exe).is_file()))
254 .unwrap_or(false)
255 };
256 if on_path("pwsh.exe") {
257 return "pwsh".into();
258 }
259 for var in ["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"] {
260 if let Some(pf) = std::env::var_os(var) {
261 let candidate = std::path::Path::new(&pf).join(r"PowerShell\7\pwsh.exe");
262 if candidate.is_file() {
263 return candidate.into_os_string();
264 }
265 }
266 }
267 "powershell".into()
269}
270
271#[cfg(not(windows))]
274pub(crate) fn powershell_program() -> std::ffi::OsString {
275 "pwsh".into()
276}
277
278#[async_trait::async_trait]
279impl Tool for BashTool {
280 fn origin(&self) -> crate::tools::ToolOrigin {
281 crate::tools::ToolOrigin::Builtin
282 }
283
284 fn effect(&self) -> crate::tools::catalog::ToolEffect {
287 crate::tools::catalog::ToolEffect::NonIdempotent
288 }
289
290 fn name(&self) -> &str {
291 "bash"
292 }
293
294 fn description(&self) -> &str {
295 "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. Stdin is empty. On Linux, if sudo needs a password, the user is asked in the TUI, shown the command sudo will run, and the password goes only to sudo, never to the model. Other password prompts are not answered."
296 }
297
298 fn parameters(&self) -> Value {
299 json!({
300 "type": "object",
301 "properties": {
302 "command": {
303 "type": "string",
304 "description": "The bash command to execute"
305 },
306 "timeout": {
307 "type": "integer",
308 "description": "Timeout in seconds (default: 30). Use a larger value for long-running commands."
309 }
310 },
311 "required": ["command"]
312 })
313 }
314
315 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
316 let command = params["command"]
317 .as_str()
318 .ok_or_else(|| RuntimeError::Tool("Missing command parameter".to_string()))?;
319
320 run_bash(command, params["timeout"].as_u64(), ctx, prepare_askpass()).await
321 }
322}
323
324fn prepare_askpass() -> Option<Askpass> {
326 #[cfg(target_os = "linux")]
327 {
328 use super::sudo_askpass::broker::{AskpassConfig, PreparedBroker};
329 let config = AskpassConfig::production()?;
330 match PreparedBroker::prepare(&config) {
331 Ok(broker) => Some(broker),
332 Err(e) => {
333 tracing::warn!("sudo askpass unavailable for this command: {e}");
334 None
335 }
336 }
337 }
338 #[cfg(not(target_os = "linux"))]
339 {
340 None
341 }
342}
343
344pub(crate) async fn run_bash(
345 command: &str,
346 timeout: Option<u64>,
347 ctx: ToolContext,
348 askpass: Option<Askpass>,
349) -> Result<String> {
350 let script = bash_script_with_secure_sudo(command, askpass.is_some());
351 run_shell_command(&script, ShellSpec::Bash, timeout, ctx, askpass).await
352}
353
354struct KillGroupUnlessFinished {
363 pgid: Option<u32>,
364}
365
366impl KillGroupUnlessFinished {
367 fn new(pid: Option<u32>) -> Self {
368 Self { pgid: pid }
369 }
370
371 fn finished(&mut self) {
373 self.pgid = None;
374 }
375
376 fn kill_now(&mut self) {
378 #[cfg(unix)]
379 if let Some(pgid) = self.pgid.take() {
380 unsafe {
382 libc::killpg(pgid as libc::pid_t, libc::SIGKILL);
383 }
384 }
385 #[cfg(not(unix))]
386 {
387 self.pgid = None;
388 }
389 }
390}
391
392impl Drop for KillGroupUnlessFinished {
393 fn drop(&mut self) {
394 self.kill_now();
395 }
396}
397
398#[derive(Debug, Clone, Copy, PartialEq, Eq)]
400pub(crate) enum ShellSpec {
401 Bash,
402 PowerShell,
403}
404
405impl ShellSpec {
406 fn scope_description(self) -> &'static str {
408 match self {
409 ShellSpec::Bash => "synaps tool: bash",
410 ShellSpec::PowerShell => "synaps tool: powershell",
411 }
412 }
413}
414
415pub(crate) async fn run_shell_command(
422 script: &str,
423 spec: ShellSpec,
424 requested_timeout: Option<u64>,
425 ctx: ToolContext,
426 askpass: Option<Askpass>,
427) -> Result<String> {
428 {
429 let requested_timeout = requested_timeout.unwrap_or(ctx.limits.bash_timeout);
430 let timeout_secs = if ctx.limits.bash_max_timeout > 0 {
433 requested_timeout.min(ctx.limits.bash_max_timeout)
434 } else {
435 requested_timeout
436 };
437 let max_output = ctx.limits.max_tool_buffer;
438
439 let (program, args): (std::ffi::OsString, Vec<std::ffi::OsString>) = match spec {
440 ShellSpec::Bash => (bash_program(), vec!["-c".into(), script.into()]),
441 ShellSpec::PowerShell => (
442 powershell_program(),
443 vec!["-NoProfile".into(), "-Command".into(), script.into()],
444 ),
445 };
446 let effective_env = ctx.capabilities.env.as_ref()
447 .map(|env| env.iter().map(|(k, v)| (k.into(), v.into())).collect())
448 .unwrap_or_else(crate::process_scope::inherited_env);
449 let probe_env = effective_env.clone();
450 let probe_cwd = ctx.capabilities.cwd.clone();
451 if ctx.capabilities.launch_cancel.as_ref().is_some_and(|c| c.is_cancelled()) {
454 return Err(RuntimeError::Tool("Command canceled before launch".into()));
455 }
456 let probe_task = tokio::task::spawn_blocking(move || {
457 crate::process_scope::launcher_for_effective_env(
458 &probe_env, probe_cwd.as_deref(), crate::process_scope::ScopeKind::Tool)
459 });
460 let launcher = if let Some(cancel) = &ctx.capabilities.launch_cancel {
461 tokio::select! {
462 biased;
463 _ = cancel.cancelled() => return Err(RuntimeError::Tool("Command canceled before launch".into())),
464 result = probe_task => result,
465 }
466 } else {
467 probe_task.await
468 }.map_err(|e| RuntimeError::Tool(e.to_string()))?;
469 let (program, args) = match launcher {
470 Some(launcher) => launcher.wrap(spec.scope_description(),
471 crate::process_scope::resolve_program(program, &effective_env,
472 ctx.capabilities.cwd.as_deref()).map_err(|e| RuntimeError::Tool(e.to_string()))?, args),
473 None => (program, args),
474 };
475 let mut cmd = tokio::process::Command::new(program);
476 if let Some(cwd) = ctx.capabilities.cwd.as_deref() {
477 cmd.current_dir(cwd);
478 }
479 cmd.env_clear().envs(effective_env);
480 #[cfg(target_os = "linux")]
481 if let Some(broker) = &askpass {
482 cmd.env("SUDO_ASKPASS", broker.helper_path());
483 }
484 cmd.args(&args)
485 .stdin(std::process::Stdio::null())
486 .stdout(std::process::Stdio::piped())
487 .stderr(std::process::Stdio::piped())
488 .kill_on_drop(true);
489
490 #[cfg(unix)]
497 unsafe {
498 cmd.pre_exec(|| {
499 libc::setsid();
500 Ok(())
501 });
502 }
503
504 if ctx.capabilities.launch_cancel.as_ref().is_some_and(|c| c.is_cancelled()) {
505 return Err(RuntimeError::Tool("Command canceled before launch".into()));
506 }
507 let mut child = cmd.spawn().map_err(|e| RuntimeError::Tool(e.to_string()))?;
508 let mut group = KillGroupUnlessFinished::new(child.id());
512
513 let stdout = child
514 .stdout
515 .take()
516 .ok_or_else(|| RuntimeError::Tool("Failed to capture stdout".to_string()))?;
517 let stderr = child
518 .stderr
519 .take()
520 .ok_or_else(|| RuntimeError::Tool("Failed to capture stderr".to_string()))?;
521 let redactions = Redactions::default();
523 let password_cancel = crate::CancellationToken::new();
524 #[cfg(target_os = "linux")]
525 let _askpass = askpass.and_then(|broker| {
526 broker
527 .start(
528 child.id(),
529 ctx.capabilities.secret_prompt.clone(),
530 redactions.clone(),
531 password_cancel.clone(),
532 )
533 .map_err(|e| tracing::warn!("sudo askpass unavailable for this command: {e}"))
534 .ok()
535 });
536 #[cfg(not(target_os = "linux"))]
537 let _ = askpass;
538
539 let (tx_inter, mut rx_inter) =
540 tokio::sync::mpsc::channel::<(bool, HandoffChunk)>(BASH_INTERMEDIARY_CHANNEL_CAPACITY);
541
542 tokio::spawn(forward_output(stdout, false, tx_inter.clone()));
543 tokio::spawn(forward_output(stderr, true, tx_inter.clone()));
544
545 drop(tx_inter);
546
547 let result = tokio::time::timeout(tokio::time::Duration::from_secs(timeout_secs), async {
548 let mut full_output = String::new();
549 let mut truncated = false;
550 let mut streamed_bytes = 0usize;
551
552 loop {
553 let item = tokio::select! {
554 biased;
555 _ = password_cancel.cancelled() => {
558 group.kill_now();
559 let _ = child.kill().await;
560 return Err(RuntimeError::Tool(
561 "Command canceled while waiting for password".to_string(),
562 ));
563 }
564 item = rx_inter.recv() => item,
565 };
566 let Some((_is_stderr, chunk)) = item else {
567 break;
568 };
569 let mut msg = chunk.consume();
570 for secret in redactions.lock().unwrap_or_else(|p| p.into_inner()).iter() {
571 if !secret.is_empty() {
572 msg = msg.replace(secret.as_str(), "[redacted]");
573 }
574 }
575
576 if truncated {
577 continue;
578 }
579
580 let added_all = append_bounded(&mut full_output, &msg, max_output);
581 if let Some(ref txd) = ctx.channels.tx_delta {
582 if streamed_bytes < MAX_STREAMED_DELTA_BYTES {
583 let remaining = MAX_STREAMED_DELTA_BYTES - streamed_bytes;
584 let delta = if msg.len() <= remaining {
585 msg.clone()
586 } else {
587 crate::truncate_str(&msg, remaining).to_string()
588 };
589 streamed_bytes += delta.len();
590 if !delta.is_empty() {
591 txd.send(delta);
592 }
593 }
594 }
595
596 if !added_all {
597 full_output.push_str(&format!("\n\n[output truncated at {}]", max_output));
598 if let Some(ref txd) = ctx.channels.tx_delta {
599 txd.send(format!("\n\n[output truncated at {}]", max_output));
600 }
601 truncated = true;
602 group.kill_now();
603 let _ = child.kill().await;
604 }
605 }
606 let status = child.wait().await;
607 group.finished();
610 let status = status.map_err(|e| RuntimeError::Tool(e.to_string()))?;
611 redactions.lock().unwrap_or_else(|p| p.into_inner()).clear();
615 Ok::<_, RuntimeError>((status, full_output, truncated))
616 })
617 .await;
618
619 match result {
620 Ok(Ok((status, output, was_truncated))) => {
621 if status.success() || was_truncated {
622 Ok(output)
623 } else {
624 let mut notice = String::new();
625 if !ctx.capabilities.env_stripped.is_empty() {
628 let mut warned = ctx.capabilities.env_warned.lock().unwrap_or_else(|p| p.into_inner());
629 for name in &ctx.capabilities.env_stripped {
630 if warned.contains(name) {
631 continue;
632 }
633 if references_env_name(script, name) {
636 notice.push_str(&format!(
637 "\nnote: ${name} was stripped from the session env as a secret; \
638 reattach from a shell that has it \
639 (synaps --system … from that shell) \
640 — the daemon never receives credentials."
641 ));
642 warned.insert(name.clone());
643 }
644 }
645 }
646 Err(RuntimeError::Tool(format!(
647 "Command failed (exit {}):\n{}{notice}",
648 status.code().unwrap_or(-1),
649 output
650 )))
651 }
652 }
653 Ok(Err(e)) => Err(RuntimeError::Tool(format!(
654 "Failed to execute command: {}",
655 e
656 ))),
657 Err(_) => Err(RuntimeError::Tool(format!(
658 "Command timed out after {}s",
659 timeout_secs
660 ))),
661 }
662 }
663}
664
665fn references_env_name(script: &str, name: &str) -> bool {
670 let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
671 let mut from = 0;
672 while let Some(pos) = script[from..].find(name) {
673 let start = from + pos;
674 let end = start + name.len();
675 let before_ok = !script[..start].chars().next_back().is_some_and(is_ident);
676 let after_ok = !script[end..].chars().next().is_some_and(is_ident);
677 if before_ok && after_ok {
678 return true;
679 }
680 from = end;
681 }
682 false
683}
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688
689 #[test]
690 fn explicit_child_kills_signal_and_disarm_group_before_reaping() {
691 let source = include_str!("bash.rs");
692 let executor = source.split("pub(crate) async fn run_shell_command").nth(1).unwrap()
693 .split("#[cfg(test)]").next().unwrap();
694 let kills: Vec<_> = executor.match_indices("let _ = child.kill().await;").collect();
695 assert_eq!(kills.len(), 2, "audit every new explicit kill path");
696 for (offset, _) in kills {
697 assert!(executor[..offset].trim_end().ends_with("group.kill_now();"));
698 }
699 assert!(executor.contains("let status = child.wait().await;"));
700 let wait = executor.split("let status = child.wait().await;").nth(1).unwrap();
701 assert!(wait.find("group.finished();").unwrap() < wait.find("status.map_err").unwrap());
702 assert!(executor.find("let mut child = cmd.spawn()").unwrap()
703 < executor.find("let mut group = KillGroupUnlessFinished::new").unwrap());
704 }
705
706 #[tokio::test]
707 async fn canceled_launch_never_runs_payload() {
708 let dir = tempfile::tempdir().unwrap();
709 let marker = dir.path().join("must-not-exist");
710 let token = crate::CancellationToken::new();
711 token.cancel();
712 let mut ctx = create_tool_context();
713 ctx.capabilities.launch_cancel = Some(token);
714 let error = BashTool.execute(json!({"command": format!("touch '{}'", marker.display())}), ctx)
715 .await.unwrap_err().to_string();
716 assert!(error.contains("canceled before launch"), "{error}");
717 assert!(!marker.exists());
718 }
719
720 #[cfg(target_os = "linux")]
721 fn own_cgroup() -> String {
722 std::fs::read_to_string("/proc/self/cgroup")
723 .unwrap_or_default()
724 .trim()
725 .to_string()
726 }
727
728 #[cfg(target_os = "linux")]
733 #[tokio::test]
734 async fn bash_command_gets_its_own_scope_when_available() {
735 if std::env::var("SYNAPS_TEST_REAL_SCOPES").as_deref() != Ok("1") { return; }
736 let out = BashTool
737 .execute(
738 json!({ "command": "cat /proc/self/cgroup; echo pid=$$", "timeout": 30 }),
739 create_tool_context(),
740 )
741 .await
742 .unwrap();
743 let child_cgroup = out.lines().next().unwrap_or_default().trim().to_string();
744 assert!(child_cgroup.starts_with("0::/"), "cgroup v2 line: {out}");
745 if crate::process_scope::launcher_for_env(None).is_some() {
746 assert_ne!(
747 child_cgroup,
748 own_cgroup(),
749 "the command must leave our cgroup"
750 );
751 assert!(child_cgroup.ends_with(".scope"), "{child_cgroup}");
752 assert!(child_cgroup.contains("/app.slice/"), "{child_cgroup}");
753 } else {
754 assert_eq!(child_cgroup, own_cgroup(), "unwrapped fallback");
755 }
756 }
757
758 #[cfg(target_os = "linux")]
761 #[tokio::test]
762 #[serial_test::serial(synaps_base_dir)]
763 async fn a_bash_command_is_scoped_in_the_tool_slice() {
764 use crate::process_scope::test_support::{fake_env, fake_systemd_run, recorded};
765 let base = crate::test_env::BaseDirGuard::new();
766 let _scopes = crate::test_env::EnvVarGuard::remove("SYNAPS_PROCESS_SCOPES");
767 std::fs::write(
768 base.path().join("config"),
769 "tool_scope_slice = t_tools.slice\ndaemon_scope_slice = d_daemon.slice\n",
770 )
771 .unwrap();
772 let dir = tempfile::tempdir().unwrap();
773 let fake = fake_systemd_run(dir.path());
774 let mut env = fake_env(&fake);
775 env.push(("PATH".to_string(), std::env::var("PATH").unwrap_or_default()));
776 let mut ctx = create_tool_context();
777 ctx.capabilities.env = Some(env);
778 let out = BashTool
779 .execute(json!({ "command": "echo scoped-run", "timeout": 30 }), ctx)
780 .await
781 .unwrap();
782 assert!(out.contains("scoped-run"), "{out}");
783 let launch = recorded(dir.path(), "launch.args");
784 let slices: Vec<&String> = launch.iter().filter(|a| a.starts_with("--slice")).collect();
785 assert_eq!(slices, ["--slice=t_tools.slice"], "{launch:?}");
786 assert!(recorded(dir.path(), "probe.args").contains(&"--slice=t_tools.slice".to_string()));
787 }
788
789 #[cfg(target_os = "linux")]
792 #[tokio::test]
793 async fn bash_command_with_a_rebuilt_env_lacking_the_user_bus_runs_unwrapped() {
794 let mut ctx = create_tool_context();
795 ctx.capabilities.env = Some(vec![(
796 "PATH".to_string(),
797 std::env::var("PATH").unwrap_or_default(),
798 )]);
799 let out = BashTool
800 .execute(
801 json!({ "command": "cat /proc/self/cgroup", "timeout": 30 }),
802 ctx,
803 )
804 .await
805 .unwrap();
806 assert_eq!(out.lines().next().unwrap_or_default().trim(), own_cgroup());
807 }
808
809 #[cfg(target_os = "linux")]
810 fn processes_named(name: &str) -> Vec<String> {
811 let out = std::process::Command::new("pgrep")
812 .args(["-f", name])
813 .output()
814 .unwrap();
815 String::from_utf8_lossy(&out.stdout)
816 .split_whitespace()
817 .map(str::to_string)
818 .collect()
819 }
820
821 #[cfg(target_os = "linux")]
825 #[tokio::test]
826 async fn bash_timeout_kills_the_whole_process_group() {
827 let marker = format!("synaps-timeout-child-{}", std::process::id());
828 let out = BashTool
829 .execute(
830 json!({
831 "command": format!("(exec -a {marker} sleep 300) & (exec -a {marker} sleep 300); wait"),
832 "timeout": 1
833 }),
834 create_tool_context(),
835 )
836 .await;
837 let text = match out {
838 Ok(s) => s,
839 Err(e) => e.to_string(),
840 };
841 assert!(text.contains("timed out"), "{text}");
842 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
843 while !processes_named(&marker).is_empty() && std::time::Instant::now() < deadline {
844 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
845 }
846 let left = processes_named(&marker);
847 if !left.is_empty() {
848 let _ = std::process::Command::new("pkill")
849 .args(["-f", &marker])
850 .status();
851 }
852 assert!(left.is_empty(), "children survived the timeout: {left:?}");
853 }
854
855 #[cfg(target_os = "linux")]
858 #[tokio::test]
859 async fn bash_normal_exit_leaves_a_background_job_running() {
860 let marker = format!("synaps-background-job-{}", std::process::id());
861 let out = BashTool
862 .execute(
863 json!({
864 "command": format!("(exec -a {marker} sleep 30) >/dev/null 2>&1 & echo started"),
865 "timeout": 10
866 }),
867 create_tool_context(),
868 )
869 .await
870 .unwrap();
871 assert!(out.contains("started"), "{out}");
872 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
873 let alive = processes_named(&marker);
874 let _ = std::process::Command::new("pkill")
875 .args(["-f", &marker])
876 .status();
877 assert!(
878 !alive.is_empty(),
879 "a deliberate background job must survive a normal exit"
880 );
881 }
882
883 #[tokio::test]
884 async fn bash_intermediary_handoff_conserves_bytes_under_large_output() {
885 let before = bash_intermediary_snapshot();
886 let tool = BashTool;
887 let mut ctx = create_tool_context();
888 ctx.limits.max_tool_buffer = 64 * 1024;
889 let result = tool
890 .execute(
891 json!({
892 "command": "python3 -c \"import sys; sys.stdout.write('x' * 1048576)\"",
893 "timeout": 30
894 }),
895 ctx,
896 )
897 .await
898 .unwrap();
899 assert!(result.contains("output truncated"));
900
901 let after = wait_handoff_settled().await;
909 assert_eq!(after.retained_bytes, 0);
910 assert!(after.accepted_bytes >= after.consumed_bytes);
911 assert!(after.produced_bytes - before.produced_bytes >= 64 * 1024);
912 }
913
914 #[test]
915 fn sanitizes_terminal_control_sequences_and_nuls() {
916 let cleaned = sanitize_output("ok\x1b[2J\x00done");
917 assert_eq!(cleaned, "okdone");
918 }
919
920 use super::super::test_helpers::create_tool_context;
921 use crate::tools::Tool;
922 use serde_json::json;
923
924 #[test]
925 fn test_bash_tool_schema() {
926 let tool = BashTool;
927 assert_eq!(tool.name(), "bash");
928 assert!(!tool.description().is_empty());
929
930 let params = tool.parameters();
931 assert_eq!(params["type"], "object");
932 assert!(params["properties"].is_object());
933 assert!(params["required"].is_array());
934 }
935
936 #[tokio::test]
937 async fn test_bash_tool_execution() {
938 let tool = BashTool;
939
940 let ctx = create_tool_context();
942 let params = json!({
943 "command": "echo hello"
944 });
945
946 let result = tool.execute(params, ctx).await.unwrap();
947 assert!(result.contains("hello"));
948
949 let ctx = create_tool_context();
951 let params = json!({
952 "command": "sleep 1",
953 "timeout": 2
954 });
955
956 let result = tool.execute(params, ctx).await;
957 assert!(result.is_ok());
959
960 let ctx = create_tool_context();
962 let params = json!({
963 "command": "sleep 3",
964 "timeout": 1
965 });
966
967 let result = tool.execute(params, ctx).await;
968 assert!(result.is_err());
970 assert!(result.unwrap_err().to_string().contains("timed out"));
971 }
972
973 #[tokio::test]
974 async fn test_bash_tool_requested_timeout_is_clamped_by_max_timeout() {
975 let tool = BashTool;
976 let mut ctx = create_tool_context();
977 ctx.limits.bash_max_timeout = 1;
978
979 let params = json!({
980 "command": "sleep 5; echo done",
981 "timeout": 10
982 });
983
984 let result = tool.execute(params, ctx).await;
985 assert!(
986 result.unwrap_err().to_string().contains("timed out"),
987 "requested timeout MUST be clamped by bash_max_timeout"
988 );
989 }
990
991 fn prompt_channel() -> (
992 crate::tools::SecretPromptHandle,
993 tokio::sync::mpsc::UnboundedReceiver<crate::tools::SecretPromptRequest>,
994 ) {
995 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
996 (crate::tools::SecretPromptHandle::new(tx), rx)
997 }
998
999 const PROMPT_LOOKALIKES: &str = r#"printf '%s\n' "command sudo -S -p '[sudo] password required: ' \"\$@\"" >&2
1003printf '[sudo] password for x: ' >&2
1004printf 'Password: ' >&2
1005if read -r got; then echo "stdin gave: $got"; else echo stdin-eof; fi"#;
1006
1007 #[tokio::test]
1010 async fn sudo_prompt_lookalike_output_raises_no_prompt_and_feeds_no_stdin() {
1011 let (handle, mut prompts) = prompt_channel();
1012 let mut ctx = create_tool_context();
1013 ctx.capabilities.secret_prompt = Some(handle);
1014 let started = std::time::Instant::now();
1015 let out = BashTool
1016 .execute(json!({"command": PROMPT_LOOKALIKES, "timeout": 30}), ctx)
1017 .await
1018 .unwrap();
1019 assert!(out.contains("stdin-eof"), "{out}");
1020 assert!(out.contains("[sudo] password for x: "), "it is only output: {out}");
1021 assert!(out.contains("password required: "), "{out}");
1022 assert!(prompts.try_recv().is_err(), "no prompt was raised");
1023 assert!(started.elapsed() < std::time::Duration::from_secs(10));
1024 }
1025
1026 #[cfg(unix)]
1027 #[test]
1028 fn the_sudo_wrapper_asks_through_askpass_or_not_at_all() {
1029 let script = super::bash_script_with_secure_sudo("sudo id", true);
1030 assert_eq!(script, "sudo() {\n command sudo -A \"$@\"\n}\nsudo id");
1031 let script = super::bash_script_with_secure_sudo("sudo id", false);
1032 assert_eq!(script, "sudo() {\n command sudo -n \"$@\"\n}\nsudo id");
1033 for askpass in [true, false] {
1034 let script = super::bash_script_with_secure_sudo("x", askpass);
1035 assert!(!script.contains(" -S") && !script.contains("password"), "{script}");
1036 }
1037 }
1038
1039 #[cfg(target_os = "linux")]
1042 #[tokio::test]
1043 async fn without_askpass_sudo_never_asks() {
1044 let fake = tempfile::tempdir().unwrap();
1045 crate::tools::sudo_askpass::testing::write_fixtures(fake.path()).unwrap();
1046 let (handle, mut prompts) = prompt_channel();
1047 let mut ctx = create_tool_context();
1048 ctx.capabilities.secret_prompt = Some(handle);
1049 let command = format!(
1050 "export PATH=\"{}:$PATH\"; sudo echo ran; echo after:$?",
1051 fake.path().display()
1052 );
1053 let out = BashTool
1054 .execute(json!({"command": command, "timeout": 30}), ctx)
1055 .await
1056 .unwrap();
1057 assert!(out.contains("sudo: a password is required"), "{out}");
1058 assert!(out.contains("after:1") && !out.contains("ran\n"), "{out}");
1059 assert!(prompts.try_recv().is_err());
1060 }
1061
1062 #[tokio::test]
1063 async fn test_bash_control_char_output_is_sanitized_and_bounded_in_deltas() {
1064 let tool = BashTool;
1065 let channel = crate::tools::output::delta_channel();
1066 let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
1067 let mut ctx = create_tool_context();
1068 ctx.channels.tx_delta = Some(delta_tx);
1069 ctx.limits.max_tool_buffer = 256;
1070
1071 let params = json!({
1072 "command": "python3 -c \"import sys; sys.stdout.buffer.write(b'clean\\x1b[2J\\x00' + b'A' * 2000); sys.stdout.flush()\"",
1073 "timeout": 30
1074 });
1075
1076 let result = tool.execute(params, ctx).await.unwrap();
1077 let mut streamed = String::new();
1078 while let Some(delta) = delta_rx.try_drain() {
1079 streamed.push_str(&delta);
1080 }
1081
1082 assert!(result.contains("[output truncated at 256]"));
1083 assert!(!result.contains('\u{1b}'));
1084 assert!(!result.contains('\0'));
1085 assert!(!streamed.contains('\u{1b}'));
1086 assert!(!streamed.contains('\0'));
1087 assert!(
1088 streamed.len() <= 2048,
1089 "streamed deltas must be bounded, got {} bytes",
1090 streamed.len()
1091 );
1092 }
1093
1094 #[tokio::test]
1095 async fn test_bash_binary_output_is_sanitized() {
1096 let tool = BashTool;
1097 let ctx = create_tool_context();
1098 let params = json!({
1099 "command": "python3 -c \"import sys; sys.stdout.buffer.write(bytes(range(32)) + b'visible')\"",
1100 "timeout": 30
1101 });
1102
1103 let result = tool.execute(params, ctx).await.unwrap();
1104
1105 assert!(result.contains("visible"));
1106 assert!(!result.contains('\0'));
1107 assert!(!result.contains('\u{1b}'));
1108 }
1109
1110 #[tokio::test]
1111 async fn test_bash_tool_timeout() {
1112 let tool = BashTool;
1113 let ctx = create_tool_context();
1114
1115 let params = json!({
1116 "command": "sleep 10",
1117 "timeout": 1
1118 });
1119
1120 let result = tool.execute(params, ctx).await;
1121
1122 assert!(result.is_err());
1124 let error = result.unwrap_err().to_string();
1125 assert!(error.contains("timed out"));
1126 }
1127
1128 #[tokio::test]
1129 async fn test_bash_tool_failure() {
1130 let tool = BashTool;
1131 let ctx = create_tool_context();
1132
1133 let params = json!({
1134 "command": "exit 1"
1135 });
1136
1137 let result = tool.execute(params, ctx).await;
1138
1139 assert!(result.is_err());
1141 let error = result.unwrap_err().to_string();
1142 assert!(error.contains("failed") || error.contains("exit"));
1143 }
1144
1145 #[cfg(unix)]
1146 #[tokio::test]
1147 async fn bash_honours_capability_cwd_when_set() {
1148 let tmp = tempfile::TempDir::new().unwrap();
1149 let expected = tmp.path().canonicalize().unwrap();
1150 let mut ctx = create_tool_context();
1151 ctx.capabilities.cwd = Some(tmp.path().to_path_buf());
1152 let out = BashTool
1153 .execute(json!({"command": "pwd -P"}), ctx)
1154 .await
1155 .unwrap();
1156 assert_eq!(out.trim(), expected.to_string_lossy());
1157 }
1158
1159 #[cfg(unix)]
1160 #[tokio::test]
1161 async fn bash_inherits_process_cwd_when_capability_cwd_is_none() {
1162 let expected = std::env::current_dir().unwrap().canonicalize().unwrap();
1163 let ctx = create_tool_context();
1164 assert!(ctx.capabilities.cwd.is_none());
1165 let out = BashTool
1166 .execute(json!({"command": "pwd -P"}), ctx)
1167 .await
1168 .unwrap();
1169 assert_eq!(out.trim(), expected.to_string_lossy());
1170 }
1171
1172 #[test]
1173 fn references_env_name_is_word_bounded() {
1174 assert!(references_env_name("echo ${GH_TOKEN:?}", "GH_TOKEN"));
1175 assert!(references_env_name("echo $GH_TOKEN", "GH_TOKEN"));
1176 assert!(references_env_name("GH_TOKEN=x gh auth", "GH_TOKEN"));
1177 assert!(references_env_name("$env:GH_TOKEN", "GH_TOKEN"));
1178 assert!(!references_env_name("echo $GH_TOKEN_FILE", "GH_TOKEN"));
1179 assert!(!references_env_name("echo $MY_GH_TOKEN", "GH_TOKEN"));
1180 assert!(!references_env_name("gh auth status", "GH_TOKEN"));
1181 }
1182
1183 #[cfg(unix)]
1185 #[tokio::test]
1186 async fn bash_notice_on_stripped_secret_reference() {
1187 let warned = std::sync::Arc::new(std::sync::Mutex::new(
1188 std::collections::HashSet::<String>::new(),
1189 ));
1190 let mut ctx = create_tool_context();
1192 ctx.capabilities.env_stripped = vec!["GH_TOKEN".into()];
1193 ctx.capabilities.env_warned = warned.clone();
1194 let err = BashTool
1195 .execute(json!({"command": "echo ${GH_TOKEN:?} 2>&1; exit 1"}), ctx)
1196 .await
1197 .unwrap_err();
1198 let msg = format!("{err}");
1199 assert!(
1200 msg.contains("note: $GH_TOKEN was stripped"),
1201 "notice must name the var: {msg}"
1202 );
1203
1204 let mut ctx2 = create_tool_context();
1206 ctx2.capabilities.env_stripped = vec!["GH_TOKEN".into()];
1207 ctx2.capabilities.env_warned = warned.clone();
1208 let out = BashTool
1209 .execute(json!({"command": "echo $GH_TOKEN; exit 0"}), ctx2)
1210 .await
1211 .unwrap();
1212 assert!(
1213 !out.contains("note:"),
1214 "zero exit must not produce a notice: {out}"
1215 );
1216
1217 let mut ctx3 = create_tool_context();
1219 ctx3.capabilities.env_stripped = vec!["GH_TOKEN".into()];
1220 ctx3.capabilities.env_warned = warned.clone();
1221 let err2 = BashTool
1222 .execute(json!({"command": "echo ${GH_TOKEN}; exit 1"}), ctx3)
1223 .await
1224 .unwrap_err();
1225 let msg2 = format!("{err2}");
1226 assert!(
1227 !msg2.contains("note: $GH_TOKEN was stripped"),
1228 "dedup must suppress the second notice: {msg2}"
1229 );
1230 }
1231}
1232
1233#[cfg(test)]
1236mod utf8_chunk_tests {
1237 use super::*;
1238 use crate::tools::Tool;
1239 use serde_json::json;
1240 use std::collections::VecDeque;
1241 use std::pin::Pin;
1242 use std::task::{Context, Poll};
1243
1244 struct ChunkedReader(VecDeque<Vec<u8>>);
1246
1247 impl tokio::io::AsyncRead for ChunkedReader {
1248 fn poll_read(
1249 mut self: Pin<&mut Self>,
1250 _cx: &mut Context<'_>,
1251 buf: &mut tokio::io::ReadBuf<'_>,
1252 ) -> Poll<std::io::Result<()>> {
1253 if let Some(chunk) = self.0.pop_front() {
1254 assert!(chunk.len() <= buf.remaining(), "test chunk larger than read buffer");
1255 buf.put_slice(&chunk);
1256 }
1257 Poll::Ready(Ok(()))
1258 }
1259 }
1260
1261 async fn forward_chunks(chunks: Vec<Vec<u8>>, is_stderr: bool) -> String {
1262 let (tx, mut rx) = tokio::sync::mpsc::channel(1024);
1263 forward_output(ChunkedReader(chunks.into()), is_stderr, tx).await;
1264 let mut out = String::new();
1265 while let Some((stderr, chunk)) = rx.recv().await {
1266 let msg = chunk.consume();
1267 assert_eq!(stderr, is_stderr);
1268 out.push_str(&msg);
1269 }
1270 out
1271 }
1272
1273 #[tokio::test]
1274 async fn characters_split_at_every_offset_survive_both_streams() {
1275 for ch in ['é', '▀', '😀'] {
1276 let text = format!("a{ch}b{ch}{ch}c");
1277 let bytes = text.as_bytes();
1278 for split in 1..bytes.len() {
1279 for is_stderr in [false, true] {
1280 let out = forward_chunks(
1281 vec![bytes[..split].to_vec(), bytes[split..].to_vec()],
1282 is_stderr,
1283 )
1284 .await;
1285 assert!(!out.contains('\u{FFFD}'), "{ch:?} split at {split}: {out:?}");
1286 assert_eq!(out.as_bytes(), bytes, "{ch:?} split at {split}");
1287 }
1288 }
1289 }
1290 }
1291
1292 #[tokio::test]
1293 async fn one_byte_reads_reassemble_every_character() {
1294 let text = "é▀😀 mixed ascii ✓ 日本語";
1295 let chunks = text.bytes().map(|b| vec![b]).collect();
1296 assert_eq!(forward_chunks(chunks, false).await, text);
1297 }
1298
1299 #[tokio::test]
1300 async fn genuinely_invalid_bytes_still_become_replacement_characters() {
1301 assert_eq!(forward_chunks(vec![b"ok\xffok".to_vec()], false).await, "ok\u{FFFD}ok");
1302 assert_eq!(
1304 forward_chunks(vec![b"a\xe2".to_vec(), b"Xb".to_vec()], false).await,
1305 "a\u{FFFD}Xb"
1306 );
1307 assert_eq!(
1309 forward_chunks(vec![b"a".to_vec(), b"\x80b".to_vec()], false).await,
1310 "a\u{FFFD}b"
1311 );
1312 }
1313
1314 #[tokio::test]
1315 async fn incomplete_sequence_at_eof_is_flushed_not_dropped() {
1316 assert_eq!(forward_chunks(vec![b"ab\xe2\x96".to_vec()], false).await, "ab\u{FFFD}");
1317 assert_eq!(
1318 forward_chunks(vec![b"ab".to_vec(), b"\xf0\x9f".to_vec()], true).await,
1319 "ab\u{FFFD}"
1320 );
1321 }
1322
1323 #[tokio::test]
1327 async fn chunks_still_queued_when_the_receiver_goes_away_count_as_dropped() {
1328 let (tx, rx) = tokio::sync::mpsc::channel(16);
1329 let chunks: Vec<Vec<u8>> = (0..8).map(|_| b"queued".to_vec()).collect();
1330 forward_output(ChunkedReader(chunks.into()), false, tx).await;
1331 let before = bash_intermediary_snapshot();
1332 drop(rx);
1333 let after = wait_handoff_settled().await;
1334 assert!(after.dropped_bytes - before.dropped_bytes >= 8 * 6);
1335 }
1336
1337 #[tokio::test]
1340 async fn prompt_without_newline_is_forwarded_before_eof() {
1341 let (mut writer, reader) = tokio::io::duplex(4096);
1342 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1343 let task = tokio::spawn(forward_output(reader, true, tx));
1344 use tokio::io::AsyncWriteExt;
1345 writer.write_all("[sudo] password for x: ".as_bytes()).await.unwrap();
1346 let (is_stderr, chunk) =
1347 tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
1348 .await
1349 .expect("prompt must arrive without EOF")
1350 .unwrap();
1351 let msg = chunk.consume();
1352 assert!(is_stderr);
1353 assert_eq!(msg, "[sudo] password for x: ");
1354 drop(writer);
1355 task.await.unwrap();
1356 }
1357
1358 #[tokio::test]
1361 async fn bash_tool_output_over_1kib_keeps_multibyte_characters_whole() {
1362 let channel = crate::tools::output::delta_channel();
1363 let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
1364 let mut ctx = super::super::test_helpers::create_tool_context();
1365 ctx.channels.tx_delta = Some(delta_tx);
1366 let result = BashTool
1367 .execute(json!({"command": "printf '▀%.0s' {1..2000}", "timeout": 30}), ctx)
1368 .await
1369 .unwrap();
1370 let mut streamed = String::new();
1371 while let Some(delta) = delta_rx.try_drain() {
1372 streamed.push_str(&delta);
1373 }
1374 let expected = "▀".repeat(2000);
1375 assert!(!result.contains('\u{FFFD}'), "result has U+FFFD");
1376 assert_eq!(result, expected);
1377 assert!(!streamed.contains('\u{FFFD}'), "deltas have U+FFFD");
1378 assert!(expected.starts_with(&streamed) && !streamed.is_empty());
1379
1380 let ctx = super::super::test_helpers::create_tool_context();
1381 let err = BashTool
1382 .execute(
1383 json!({"command": "python3 -c \"import sys; sys.stderr.write(('▀'*119+'\\n')*45)\"; exit 3", "timeout": 30}),
1384 ctx,
1385 )
1386 .await
1387 .unwrap_err()
1388 .to_string();
1389 assert!(!err.contains('\u{FFFD}'), "stderr has U+FFFD: {err}");
1390 assert!(err.contains(&("▀".repeat(119) + "\n").repeat(45)), "{err}");
1391 }
1392}