use super::{strip_ansi, Tool, ToolContext};
use crate::{Result, RuntimeError};
use serde_json::{json, Value};
use zeroize::Zeroizing;
const BASH_INTERMEDIARY_CHANNEL_CAPACITY: usize = 64;
static BASH_INTERMEDIARY_PRODUCED_BYTES: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
static BASH_INTERMEDIARY_ACCEPTED_BYTES: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
static BASH_INTERMEDIARY_CONSUMED_BYTES: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
static BASH_INTERMEDIARY_DROPPED_BYTES: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
#[derive(Debug, Clone, Copy)]
pub struct BashIntermediarySnapshot {
pub produced_bytes: u64,
pub accepted_bytes: u64,
pub consumed_bytes: u64,
pub dropped_bytes: u64,
pub retained_bytes: u64,
}
impl BashIntermediarySnapshot {
pub fn is_settled(&self) -> bool {
self.produced_bytes == self.consumed_bytes + self.dropped_bytes
}
}
pub fn bash_intermediary_snapshot() -> BashIntermediarySnapshot {
use std::sync::atomic::Ordering::SeqCst;
let consumed = BASH_INTERMEDIARY_CONSUMED_BYTES.load(SeqCst);
let dropped = BASH_INTERMEDIARY_DROPPED_BYTES.load(SeqCst);
let accepted = BASH_INTERMEDIARY_ACCEPTED_BYTES.load(SeqCst);
let produced = BASH_INTERMEDIARY_PRODUCED_BYTES.load(SeqCst);
BashIntermediarySnapshot {
produced_bytes: produced,
accepted_bytes: accepted,
consumed_bytes: consumed,
dropped_bytes: dropped,
retained_bytes: produced.saturating_sub(consumed).saturating_sub(dropped),
}
}
#[cfg(test)]
async fn wait_handoff_settled() -> BashIntermediarySnapshot {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
let now = bash_intermediary_snapshot();
if now.is_settled() {
return now;
}
assert!(std::time::Instant::now() < deadline, "handoff bytes never settled: {now:?}");
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
}
pub struct BashTool;
struct HandoffChunk(Option<String>);
impl HandoffChunk {
fn new(text: String) -> Self {
BASH_INTERMEDIARY_PRODUCED_BYTES
.fetch_add(text.len() as u64, std::sync::atomic::Ordering::SeqCst);
Self(Some(text))
}
fn len(&self) -> usize {
self.0.as_ref().map_or(0, String::len)
}
fn consume(mut self) -> String {
let text = self.0.take().unwrap_or_default();
BASH_INTERMEDIARY_CONSUMED_BYTES
.fetch_add(text.len() as u64, std::sync::atomic::Ordering::SeqCst);
text
}
}
impl Drop for HandoffChunk {
fn drop(&mut self) {
if let Some(text) = self.0.take() {
BASH_INTERMEDIARY_DROPPED_BYTES
.fetch_add(text.len() as u64, std::sync::atomic::Ordering::SeqCst);
}
}
}
const READ_CHUNK_SIZE: usize = 1024;
const MAX_STREAMED_DELTA_BYTES: usize = 16 * 1024;
pub(crate) type Redactions = std::sync::Arc<std::sync::Mutex<Vec<Zeroizing<String>>>>;
#[cfg(target_os = "linux")]
pub(crate) type Askpass = super::sudo_askpass::broker::PreparedBroker;
#[cfg(not(target_os = "linux"))]
pub(crate) type Askpass = std::convert::Infallible;
fn sanitize_output(input: &str) -> String {
let stripped = strip_ansi(input);
stripped
.chars()
.filter(|ch| {
*ch == '\n' || *ch == '\r' || *ch == '\t' || (!ch.is_control() && *ch != '\u{7f}')
})
.collect()
}
async fn forward_output<R>(
mut reader: R,
is_stderr: bool,
tx: tokio::sync::mpsc::Sender<(bool, HandoffChunk)>,
) where
R: tokio::io::AsyncRead + Unpin,
{
use tokio::io::AsyncReadExt;
let mut buf = vec![0u8; READ_CHUNK_SIZE];
let mut decoder = super::utf8_stream::Utf8StreamDecoder::new();
loop {
let (text, eof) = match reader.read(&mut buf).await {
Ok(0) | Err(_) => (decoder.finish(), true),
Ok(n) => (decoder.decode(&buf[..n]), false),
};
let msg = sanitize_output(&text);
if !msg.is_empty() {
let chunk = HandoffChunk::new(msg);
let Ok(permit) = tx.reserve().await else {
break;
};
BASH_INTERMEDIARY_ACCEPTED_BYTES
.fetch_add(chunk.len() as u64, std::sync::atomic::Ordering::SeqCst);
permit.send((is_stderr, chunk));
}
if eof {
break;
}
}
}
fn append_bounded(output: &mut String, text: &str, max_output: usize) -> bool {
if output.len() >= max_output {
return false;
}
let remaining = max_output - output.len();
if text.len() <= remaining {
output.push_str(text);
true
} else {
let mut end = remaining;
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
output.push_str(&text[..end]);
false
}
}
pub(crate) fn bash_program() -> std::ffi::OsString {
#[cfg(unix)]
{
"bash".into()
}
#[cfg(windows)]
{
let on_path = std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).any(|d| d.join("bash.exe").is_file()))
.unwrap_or(false);
if on_path {
return "bash".into();
}
for var in ["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"] {
if let Some(pf) = std::env::var_os(var) {
let candidate = std::path::Path::new(&pf).join(r"Git\bin\bash.exe");
if candidate.is_file() {
return candidate.into_os_string();
}
}
}
let wsl = std::path::Path::new(r"C:\Windows\System32\bash.exe");
if wsl.is_file() {
return wsl.as_os_str().to_os_string();
}
"bash".into()
}
}
pub(crate) fn bash_script_with_secure_sudo(command: &str, askpass: bool) -> String {
#[cfg(unix)]
let call = if askpass {
r#"command sudo -A "$@""#
} else {
r#"command sudo -n "$@""#
};
#[cfg(not(unix))]
let call = {
let _ = askpass;
r#"command sudo -S -p '[sudo] password required: ' "$@""#
};
format!(
r#"sudo() {{
{call}
}}
{command}"#
)
}
#[cfg(windows)]
pub(crate) fn powershell_program() -> std::ffi::OsString {
let on_path = |exe: &str| {
std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).any(|d| d.join(exe).is_file()))
.unwrap_or(false)
};
if on_path("pwsh.exe") {
return "pwsh".into();
}
for var in ["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"] {
if let Some(pf) = std::env::var_os(var) {
let candidate = std::path::Path::new(&pf).join(r"PowerShell\7\pwsh.exe");
if candidate.is_file() {
return candidate.into_os_string();
}
}
}
"powershell".into()
}
#[cfg(not(windows))]
pub(crate) fn powershell_program() -> std::ffi::OsString {
"pwsh".into()
}
#[async_trait::async_trait]
impl Tool for BashTool {
fn origin(&self) -> crate::tools::ToolOrigin {
crate::tools::ToolOrigin::Builtin
}
fn effect(&self) -> crate::tools::catalog::ToolEffect {
crate::tools::catalog::ToolEffect::NonIdempotent
}
fn name(&self) -> &str {
"bash"
}
fn description(&self) -> &str {
"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."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to execute"
},
"timeout": {
"type": "integer",
"description": "Timeout in seconds (default: 30). Use a larger value for long-running commands."
}
},
"required": ["command"]
})
}
async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
let command = params["command"]
.as_str()
.ok_or_else(|| RuntimeError::Tool("Missing command parameter".to_string()))?;
run_bash(command, params["timeout"].as_u64(), ctx, prepare_askpass()).await
}
}
fn prepare_askpass() -> Option<Askpass> {
#[cfg(target_os = "linux")]
{
use super::sudo_askpass::broker::{AskpassConfig, PreparedBroker};
let config = AskpassConfig::production()?;
match PreparedBroker::prepare(&config) {
Ok(broker) => Some(broker),
Err(e) => {
tracing::warn!("sudo askpass unavailable for this command: {e}");
None
}
}
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
pub(crate) async fn run_bash(
command: &str,
timeout: Option<u64>,
ctx: ToolContext,
askpass: Option<Askpass>,
) -> Result<String> {
let script = bash_script_with_secure_sudo(command, askpass.is_some());
run_shell_command(&script, ShellSpec::Bash, timeout, ctx, askpass).await
}
struct KillGroupUnlessFinished {
pgid: Option<u32>,
}
impl KillGroupUnlessFinished {
fn new(pid: Option<u32>) -> Self {
Self { pgid: pid }
}
fn finished(&mut self) {
self.pgid = None;
}
fn kill_now(&mut self) {
#[cfg(unix)]
if let Some(pgid) = self.pgid.take() {
unsafe {
libc::killpg(pgid as libc::pid_t, libc::SIGKILL);
}
}
#[cfg(not(unix))]
{
self.pgid = None;
}
}
}
impl Drop for KillGroupUnlessFinished {
fn drop(&mut self) {
self.kill_now();
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ShellSpec {
Bash,
PowerShell,
}
impl ShellSpec {
fn scope_description(self) -> &'static str {
match self {
ShellSpec::Bash => "synaps tool: bash",
ShellSpec::PowerShell => "synaps tool: powershell",
}
}
}
pub(crate) async fn run_shell_command(
script: &str,
spec: ShellSpec,
requested_timeout: Option<u64>,
ctx: ToolContext,
askpass: Option<Askpass>,
) -> Result<String> {
{
let requested_timeout = requested_timeout.unwrap_or(ctx.limits.bash_timeout);
let timeout_secs = if ctx.limits.bash_max_timeout > 0 {
requested_timeout.min(ctx.limits.bash_max_timeout)
} else {
requested_timeout
};
let max_output = ctx.limits.max_tool_buffer;
let (program, args): (std::ffi::OsString, Vec<std::ffi::OsString>) = match spec {
ShellSpec::Bash => (bash_program(), vec!["-c".into(), script.into()]),
ShellSpec::PowerShell => (
powershell_program(),
vec!["-NoProfile".into(), "-Command".into(), script.into()],
),
};
let effective_env = ctx.capabilities.env.as_ref()
.map(|env| env.iter().map(|(k, v)| (k.into(), v.into())).collect())
.unwrap_or_else(crate::process_scope::inherited_env);
let probe_env = effective_env.clone();
let probe_cwd = ctx.capabilities.cwd.clone();
if ctx.capabilities.launch_cancel.as_ref().is_some_and(|c| c.is_cancelled()) {
return Err(RuntimeError::Tool("Command canceled before launch".into()));
}
let probe_task = tokio::task::spawn_blocking(move || {
crate::process_scope::launcher_for_effective_env(
&probe_env, probe_cwd.as_deref(), crate::process_scope::ScopeKind::Tool)
});
let launcher = if let Some(cancel) = &ctx.capabilities.launch_cancel {
tokio::select! {
biased;
_ = cancel.cancelled() => return Err(RuntimeError::Tool("Command canceled before launch".into())),
result = probe_task => result,
}
} else {
probe_task.await
}.map_err(|e| RuntimeError::Tool(e.to_string()))?;
let (program, args) = match launcher {
Some(launcher) => launcher.wrap(spec.scope_description(),
crate::process_scope::resolve_program(program, &effective_env,
ctx.capabilities.cwd.as_deref()).map_err(|e| RuntimeError::Tool(e.to_string()))?, args),
None => (program, args),
};
let mut cmd = tokio::process::Command::new(program);
if let Some(cwd) = ctx.capabilities.cwd.as_deref() {
cmd.current_dir(cwd);
}
cmd.env_clear().envs(effective_env);
#[cfg(target_os = "linux")]
if let Some(broker) = &askpass {
cmd.env("SUDO_ASKPASS", broker.helper_path());
}
cmd.args(&args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
#[cfg(unix)]
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
if ctx.capabilities.launch_cancel.as_ref().is_some_and(|c| c.is_cancelled()) {
return Err(RuntimeError::Tool("Command canceled before launch".into()));
}
let mut child = cmd.spawn().map_err(|e| RuntimeError::Tool(e.to_string()))?;
let mut group = KillGroupUnlessFinished::new(child.id());
let stdout = child
.stdout
.take()
.ok_or_else(|| RuntimeError::Tool("Failed to capture stdout".to_string()))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| RuntimeError::Tool("Failed to capture stderr".to_string()))?;
let redactions = Redactions::default();
let password_cancel = crate::CancellationToken::new();
#[cfg(target_os = "linux")]
let _askpass = askpass.and_then(|broker| {
broker
.start(
child.id(),
ctx.capabilities.secret_prompt.clone(),
redactions.clone(),
password_cancel.clone(),
)
.map_err(|e| tracing::warn!("sudo askpass unavailable for this command: {e}"))
.ok()
});
#[cfg(not(target_os = "linux"))]
let _ = askpass;
let (tx_inter, mut rx_inter) =
tokio::sync::mpsc::channel::<(bool, HandoffChunk)>(BASH_INTERMEDIARY_CHANNEL_CAPACITY);
tokio::spawn(forward_output(stdout, false, tx_inter.clone()));
tokio::spawn(forward_output(stderr, true, tx_inter.clone()));
drop(tx_inter);
let result = tokio::time::timeout(tokio::time::Duration::from_secs(timeout_secs), async {
let mut full_output = String::new();
let mut truncated = false;
let mut streamed_bytes = 0usize;
loop {
let item = tokio::select! {
biased;
_ = password_cancel.cancelled() => {
group.kill_now();
let _ = child.kill().await;
return Err(RuntimeError::Tool(
"Command canceled while waiting for password".to_string(),
));
}
item = rx_inter.recv() => item,
};
let Some((_is_stderr, chunk)) = item else {
break;
};
let mut msg = chunk.consume();
for secret in redactions.lock().unwrap_or_else(|p| p.into_inner()).iter() {
if !secret.is_empty() {
msg = msg.replace(secret.as_str(), "[redacted]");
}
}
if truncated {
continue;
}
let added_all = append_bounded(&mut full_output, &msg, max_output);
if let Some(ref txd) = ctx.channels.tx_delta {
if streamed_bytes < MAX_STREAMED_DELTA_BYTES {
let remaining = MAX_STREAMED_DELTA_BYTES - streamed_bytes;
let delta = if msg.len() <= remaining {
msg.clone()
} else {
crate::truncate_str(&msg, remaining).to_string()
};
streamed_bytes += delta.len();
if !delta.is_empty() {
txd.send(delta);
}
}
}
if !added_all {
full_output.push_str(&format!("\n\n[output truncated at {}]", max_output));
if let Some(ref txd) = ctx.channels.tx_delta {
txd.send(format!("\n\n[output truncated at {}]", max_output));
}
truncated = true;
group.kill_now();
let _ = child.kill().await;
}
}
let status = child.wait().await;
group.finished();
let status = status.map_err(|e| RuntimeError::Tool(e.to_string()))?;
redactions.lock().unwrap_or_else(|p| p.into_inner()).clear();
Ok::<_, RuntimeError>((status, full_output, truncated))
})
.await;
match result {
Ok(Ok((status, output, was_truncated))) => {
if status.success() || was_truncated {
Ok(output)
} else {
let mut notice = String::new();
if !ctx.capabilities.env_stripped.is_empty() {
let mut warned = ctx.capabilities.env_warned.lock().unwrap_or_else(|p| p.into_inner());
for name in &ctx.capabilities.env_stripped {
if warned.contains(name) {
continue;
}
if references_env_name(script, name) {
notice.push_str(&format!(
"\nnote: ${name} was stripped from the session env as a secret; \
reattach from a shell that has it \
(synaps --system … from that shell) \
— the daemon never receives credentials."
));
warned.insert(name.clone());
}
}
}
Err(RuntimeError::Tool(format!(
"Command failed (exit {}):\n{}{notice}",
status.code().unwrap_or(-1),
output
)))
}
}
Ok(Err(e)) => Err(RuntimeError::Tool(format!(
"Failed to execute command: {}",
e
))),
Err(_) => Err(RuntimeError::Tool(format!(
"Command timed out after {}s",
timeout_secs
))),
}
}
}
fn references_env_name(script: &str, name: &str) -> bool {
let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
let mut from = 0;
while let Some(pos) = script[from..].find(name) {
let start = from + pos;
let end = start + name.len();
let before_ok = !script[..start].chars().next_back().is_some_and(is_ident);
let after_ok = !script[end..].chars().next().is_some_and(is_ident);
if before_ok && after_ok {
return true;
}
from = end;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn explicit_child_kills_signal_and_disarm_group_before_reaping() {
let source = include_str!("bash.rs");
let executor = source.split("pub(crate) async fn run_shell_command").nth(1).unwrap()
.split("#[cfg(test)]").next().unwrap();
let kills: Vec<_> = executor.match_indices("let _ = child.kill().await;").collect();
assert_eq!(kills.len(), 2, "audit every new explicit kill path");
for (offset, _) in kills {
assert!(executor[..offset].trim_end().ends_with("group.kill_now();"));
}
assert!(executor.contains("let status = child.wait().await;"));
let wait = executor.split("let status = child.wait().await;").nth(1).unwrap();
assert!(wait.find("group.finished();").unwrap() < wait.find("status.map_err").unwrap());
assert!(executor.find("let mut child = cmd.spawn()").unwrap()
< executor.find("let mut group = KillGroupUnlessFinished::new").unwrap());
}
#[tokio::test]
async fn canceled_launch_never_runs_payload() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("must-not-exist");
let token = crate::CancellationToken::new();
token.cancel();
let mut ctx = create_tool_context();
ctx.capabilities.launch_cancel = Some(token);
let error = BashTool.execute(json!({"command": format!("touch '{}'", marker.display())}), ctx)
.await.unwrap_err().to_string();
assert!(error.contains("canceled before launch"), "{error}");
assert!(!marker.exists());
}
#[cfg(target_os = "linux")]
fn own_cgroup() -> String {
std::fs::read_to_string("/proc/self/cgroup")
.unwrap_or_default()
.trim()
.to_string()
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn bash_command_gets_its_own_scope_when_available() {
if std::env::var("SYNAPS_TEST_REAL_SCOPES").as_deref() != Ok("1") { return; }
let out = BashTool
.execute(
json!({ "command": "cat /proc/self/cgroup; echo pid=$$", "timeout": 30 }),
create_tool_context(),
)
.await
.unwrap();
let child_cgroup = out.lines().next().unwrap_or_default().trim().to_string();
assert!(child_cgroup.starts_with("0::/"), "cgroup v2 line: {out}");
if crate::process_scope::launcher_for_env(None).is_some() {
assert_ne!(
child_cgroup,
own_cgroup(),
"the command must leave our cgroup"
);
assert!(child_cgroup.ends_with(".scope"), "{child_cgroup}");
assert!(child_cgroup.contains("/app.slice/"), "{child_cgroup}");
} else {
assert_eq!(child_cgroup, own_cgroup(), "unwrapped fallback");
}
}
#[cfg(target_os = "linux")]
#[tokio::test]
#[serial_test::serial(synaps_base_dir)]
async fn a_bash_command_is_scoped_in_the_tool_slice() {
use crate::process_scope::test_support::{fake_env, fake_systemd_run, recorded};
let base = crate::test_env::BaseDirGuard::new();
let _scopes = crate::test_env::EnvVarGuard::remove("SYNAPS_PROCESS_SCOPES");
std::fs::write(
base.path().join("config"),
"tool_scope_slice = t_tools.slice\ndaemon_scope_slice = d_daemon.slice\n",
)
.unwrap();
let dir = tempfile::tempdir().unwrap();
let fake = fake_systemd_run(dir.path());
let mut env = fake_env(&fake);
env.push(("PATH".to_string(), std::env::var("PATH").unwrap_or_default()));
let mut ctx = create_tool_context();
ctx.capabilities.env = Some(env);
let out = BashTool
.execute(json!({ "command": "echo scoped-run", "timeout": 30 }), ctx)
.await
.unwrap();
assert!(out.contains("scoped-run"), "{out}");
let launch = recorded(dir.path(), "launch.args");
let slices: Vec<&String> = launch.iter().filter(|a| a.starts_with("--slice")).collect();
assert_eq!(slices, ["--slice=t_tools.slice"], "{launch:?}");
assert!(recorded(dir.path(), "probe.args").contains(&"--slice=t_tools.slice".to_string()));
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn bash_command_with_a_rebuilt_env_lacking_the_user_bus_runs_unwrapped() {
let mut ctx = create_tool_context();
ctx.capabilities.env = Some(vec![(
"PATH".to_string(),
std::env::var("PATH").unwrap_or_default(),
)]);
let out = BashTool
.execute(
json!({ "command": "cat /proc/self/cgroup", "timeout": 30 }),
ctx,
)
.await
.unwrap();
assert_eq!(out.lines().next().unwrap_or_default().trim(), own_cgroup());
}
#[cfg(target_os = "linux")]
fn processes_named(name: &str) -> Vec<String> {
let out = std::process::Command::new("pgrep")
.args(["-f", name])
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout)
.split_whitespace()
.map(str::to_string)
.collect()
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn bash_timeout_kills_the_whole_process_group() {
let marker = format!("synaps-timeout-child-{}", std::process::id());
let out = BashTool
.execute(
json!({
"command": format!("(exec -a {marker} sleep 300) & (exec -a {marker} sleep 300); wait"),
"timeout": 1
}),
create_tool_context(),
)
.await;
let text = match out {
Ok(s) => s,
Err(e) => e.to_string(),
};
assert!(text.contains("timed out"), "{text}");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
while !processes_named(&marker).is_empty() && std::time::Instant::now() < deadline {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
let left = processes_named(&marker);
if !left.is_empty() {
let _ = std::process::Command::new("pkill")
.args(["-f", &marker])
.status();
}
assert!(left.is_empty(), "children survived the timeout: {left:?}");
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn bash_normal_exit_leaves_a_background_job_running() {
let marker = format!("synaps-background-job-{}", std::process::id());
let out = BashTool
.execute(
json!({
"command": format!("(exec -a {marker} sleep 30) >/dev/null 2>&1 & echo started"),
"timeout": 10
}),
create_tool_context(),
)
.await
.unwrap();
assert!(out.contains("started"), "{out}");
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let alive = processes_named(&marker);
let _ = std::process::Command::new("pkill")
.args(["-f", &marker])
.status();
assert!(
!alive.is_empty(),
"a deliberate background job must survive a normal exit"
);
}
#[tokio::test]
async fn bash_intermediary_handoff_conserves_bytes_under_large_output() {
let before = bash_intermediary_snapshot();
let tool = BashTool;
let mut ctx = create_tool_context();
ctx.limits.max_tool_buffer = 64 * 1024;
let result = tool
.execute(
json!({
"command": "python3 -c \"import sys; sys.stdout.write('x' * 1048576)\"",
"timeout": 30
}),
ctx,
)
.await
.unwrap();
assert!(result.contains("output truncated"));
let after = wait_handoff_settled().await;
assert_eq!(after.retained_bytes, 0);
assert!(after.accepted_bytes >= after.consumed_bytes);
assert!(after.produced_bytes - before.produced_bytes >= 64 * 1024);
}
#[test]
fn sanitizes_terminal_control_sequences_and_nuls() {
let cleaned = sanitize_output("ok\x1b[2J\x00done");
assert_eq!(cleaned, "okdone");
}
use super::super::test_helpers::create_tool_context;
use crate::tools::Tool;
use serde_json::json;
#[test]
fn test_bash_tool_schema() {
let tool = BashTool;
assert_eq!(tool.name(), "bash");
assert!(!tool.description().is_empty());
let params = tool.parameters();
assert_eq!(params["type"], "object");
assert!(params["properties"].is_object());
assert!(params["required"].is_array());
}
#[tokio::test]
async fn test_bash_tool_execution() {
let tool = BashTool;
let ctx = create_tool_context();
let params = json!({
"command": "echo hello"
});
let result = tool.execute(params, ctx).await.unwrap();
assert!(result.contains("hello"));
let ctx = create_tool_context();
let params = json!({
"command": "sleep 1",
"timeout": 2
});
let result = tool.execute(params, ctx).await;
assert!(result.is_ok());
let ctx = create_tool_context();
let params = json!({
"command": "sleep 3",
"timeout": 1
});
let result = tool.execute(params, ctx).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("timed out"));
}
#[tokio::test]
async fn test_bash_tool_requested_timeout_is_clamped_by_max_timeout() {
let tool = BashTool;
let mut ctx = create_tool_context();
ctx.limits.bash_max_timeout = 1;
let params = json!({
"command": "sleep 5; echo done",
"timeout": 10
});
let result = tool.execute(params, ctx).await;
assert!(
result.unwrap_err().to_string().contains("timed out"),
"requested timeout MUST be clamped by bash_max_timeout"
);
}
fn prompt_channel() -> (
crate::tools::SecretPromptHandle,
tokio::sync::mpsc::UnboundedReceiver<crate::tools::SecretPromptRequest>,
) {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
(crate::tools::SecretPromptHandle::new(tx), rx)
}
const PROMPT_LOOKALIKES: &str = r#"printf '%s\n' "command sudo -S -p '[sudo] password required: ' \"\$@\"" >&2
printf '[sudo] password for x: ' >&2
printf 'Password: ' >&2
if read -r got; then echo "stdin gave: $got"; else echo stdin-eof; fi"#;
#[tokio::test]
async fn sudo_prompt_lookalike_output_raises_no_prompt_and_feeds_no_stdin() {
let (handle, mut prompts) = prompt_channel();
let mut ctx = create_tool_context();
ctx.capabilities.secret_prompt = Some(handle);
let started = std::time::Instant::now();
let out = BashTool
.execute(json!({"command": PROMPT_LOOKALIKES, "timeout": 30}), ctx)
.await
.unwrap();
assert!(out.contains("stdin-eof"), "{out}");
assert!(out.contains("[sudo] password for x: "), "it is only output: {out}");
assert!(out.contains("password required: "), "{out}");
assert!(prompts.try_recv().is_err(), "no prompt was raised");
assert!(started.elapsed() < std::time::Duration::from_secs(10));
}
#[cfg(unix)]
#[test]
fn the_sudo_wrapper_asks_through_askpass_or_not_at_all() {
let script = super::bash_script_with_secure_sudo("sudo id", true);
assert_eq!(script, "sudo() {\n command sudo -A \"$@\"\n}\nsudo id");
let script = super::bash_script_with_secure_sudo("sudo id", false);
assert_eq!(script, "sudo() {\n command sudo -n \"$@\"\n}\nsudo id");
for askpass in [true, false] {
let script = super::bash_script_with_secure_sudo("x", askpass);
assert!(!script.contains(" -S") && !script.contains("password"), "{script}");
}
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn without_askpass_sudo_never_asks() {
let fake = tempfile::tempdir().unwrap();
crate::tools::sudo_askpass::testing::write_fixtures(fake.path()).unwrap();
let (handle, mut prompts) = prompt_channel();
let mut ctx = create_tool_context();
ctx.capabilities.secret_prompt = Some(handle);
let command = format!(
"export PATH=\"{}:$PATH\"; sudo echo ran; echo after:$?",
fake.path().display()
);
let out = BashTool
.execute(json!({"command": command, "timeout": 30}), ctx)
.await
.unwrap();
assert!(out.contains("sudo: a password is required"), "{out}");
assert!(out.contains("after:1") && !out.contains("ran\n"), "{out}");
assert!(prompts.try_recv().is_err());
}
#[tokio::test]
async fn test_bash_control_char_output_is_sanitized_and_bounded_in_deltas() {
let tool = BashTool;
let channel = crate::tools::output::delta_channel();
let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
let mut ctx = create_tool_context();
ctx.channels.tx_delta = Some(delta_tx);
ctx.limits.max_tool_buffer = 256;
let params = json!({
"command": "python3 -c \"import sys; sys.stdout.buffer.write(b'clean\\x1b[2J\\x00' + b'A' * 2000); sys.stdout.flush()\"",
"timeout": 30
});
let result = tool.execute(params, ctx).await.unwrap();
let mut streamed = String::new();
while let Some(delta) = delta_rx.try_drain() {
streamed.push_str(&delta);
}
assert!(result.contains("[output truncated at 256]"));
assert!(!result.contains('\u{1b}'));
assert!(!result.contains('\0'));
assert!(!streamed.contains('\u{1b}'));
assert!(!streamed.contains('\0'));
assert!(
streamed.len() <= 2048,
"streamed deltas must be bounded, got {} bytes",
streamed.len()
);
}
#[tokio::test]
async fn test_bash_binary_output_is_sanitized() {
let tool = BashTool;
let ctx = create_tool_context();
let params = json!({
"command": "python3 -c \"import sys; sys.stdout.buffer.write(bytes(range(32)) + b'visible')\"",
"timeout": 30
});
let result = tool.execute(params, ctx).await.unwrap();
assert!(result.contains("visible"));
assert!(!result.contains('\0'));
assert!(!result.contains('\u{1b}'));
}
#[tokio::test]
async fn test_bash_tool_timeout() {
let tool = BashTool;
let ctx = create_tool_context();
let params = json!({
"command": "sleep 10",
"timeout": 1
});
let result = tool.execute(params, ctx).await;
assert!(result.is_err());
let error = result.unwrap_err().to_string();
assert!(error.contains("timed out"));
}
#[tokio::test]
async fn test_bash_tool_failure() {
let tool = BashTool;
let ctx = create_tool_context();
let params = json!({
"command": "exit 1"
});
let result = tool.execute(params, ctx).await;
assert!(result.is_err());
let error = result.unwrap_err().to_string();
assert!(error.contains("failed") || error.contains("exit"));
}
#[cfg(unix)]
#[tokio::test]
async fn bash_honours_capability_cwd_when_set() {
let tmp = tempfile::TempDir::new().unwrap();
let expected = tmp.path().canonicalize().unwrap();
let mut ctx = create_tool_context();
ctx.capabilities.cwd = Some(tmp.path().to_path_buf());
let out = BashTool
.execute(json!({"command": "pwd -P"}), ctx)
.await
.unwrap();
assert_eq!(out.trim(), expected.to_string_lossy());
}
#[cfg(unix)]
#[tokio::test]
async fn bash_inherits_process_cwd_when_capability_cwd_is_none() {
let expected = std::env::current_dir().unwrap().canonicalize().unwrap();
let ctx = create_tool_context();
assert!(ctx.capabilities.cwd.is_none());
let out = BashTool
.execute(json!({"command": "pwd -P"}), ctx)
.await
.unwrap();
assert_eq!(out.trim(), expected.to_string_lossy());
}
#[test]
fn references_env_name_is_word_bounded() {
assert!(references_env_name("echo ${GH_TOKEN:?}", "GH_TOKEN"));
assert!(references_env_name("echo $GH_TOKEN", "GH_TOKEN"));
assert!(references_env_name("GH_TOKEN=x gh auth", "GH_TOKEN"));
assert!(references_env_name("$env:GH_TOKEN", "GH_TOKEN"));
assert!(!references_env_name("echo $GH_TOKEN_FILE", "GH_TOKEN"));
assert!(!references_env_name("echo $MY_GH_TOKEN", "GH_TOKEN"));
assert!(!references_env_name("gh auth status", "GH_TOKEN"));
}
#[cfg(unix)]
#[tokio::test]
async fn bash_notice_on_stripped_secret_reference() {
let warned = std::sync::Arc::new(std::sync::Mutex::new(
std::collections::HashSet::<String>::new(),
));
let mut ctx = create_tool_context();
ctx.capabilities.env_stripped = vec!["GH_TOKEN".into()];
ctx.capabilities.env_warned = warned.clone();
let err = BashTool
.execute(json!({"command": "echo ${GH_TOKEN:?} 2>&1; exit 1"}), ctx)
.await
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("note: $GH_TOKEN was stripped"),
"notice must name the var: {msg}"
);
let mut ctx2 = create_tool_context();
ctx2.capabilities.env_stripped = vec!["GH_TOKEN".into()];
ctx2.capabilities.env_warned = warned.clone();
let out = BashTool
.execute(json!({"command": "echo $GH_TOKEN; exit 0"}), ctx2)
.await
.unwrap();
assert!(
!out.contains("note:"),
"zero exit must not produce a notice: {out}"
);
let mut ctx3 = create_tool_context();
ctx3.capabilities.env_stripped = vec!["GH_TOKEN".into()];
ctx3.capabilities.env_warned = warned.clone();
let err2 = BashTool
.execute(json!({"command": "echo ${GH_TOKEN}; exit 1"}), ctx3)
.await
.unwrap_err();
let msg2 = format!("{err2}");
assert!(
!msg2.contains("note: $GH_TOKEN was stripped"),
"dedup must suppress the second notice: {msg2}"
);
}
}
#[cfg(test)]
mod utf8_chunk_tests {
use super::*;
use crate::tools::Tool;
use serde_json::json;
use std::collections::VecDeque;
use std::pin::Pin;
use std::task::{Context, Poll};
struct ChunkedReader(VecDeque<Vec<u8>>);
impl tokio::io::AsyncRead for ChunkedReader {
fn poll_read(
mut self: Pin<&mut Self>,
_cx: &mut Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
if let Some(chunk) = self.0.pop_front() {
assert!(chunk.len() <= buf.remaining(), "test chunk larger than read buffer");
buf.put_slice(&chunk);
}
Poll::Ready(Ok(()))
}
}
async fn forward_chunks(chunks: Vec<Vec<u8>>, is_stderr: bool) -> String {
let (tx, mut rx) = tokio::sync::mpsc::channel(1024);
forward_output(ChunkedReader(chunks.into()), is_stderr, tx).await;
let mut out = String::new();
while let Some((stderr, chunk)) = rx.recv().await {
let msg = chunk.consume();
assert_eq!(stderr, is_stderr);
out.push_str(&msg);
}
out
}
#[tokio::test]
async fn characters_split_at_every_offset_survive_both_streams() {
for ch in ['é', '▀', '😀'] {
let text = format!("a{ch}b{ch}{ch}c");
let bytes = text.as_bytes();
for split in 1..bytes.len() {
for is_stderr in [false, true] {
let out = forward_chunks(
vec![bytes[..split].to_vec(), bytes[split..].to_vec()],
is_stderr,
)
.await;
assert!(!out.contains('\u{FFFD}'), "{ch:?} split at {split}: {out:?}");
assert_eq!(out.as_bytes(), bytes, "{ch:?} split at {split}");
}
}
}
}
#[tokio::test]
async fn one_byte_reads_reassemble_every_character() {
let text = "é▀😀 mixed ascii ✓ 日本語";
let chunks = text.bytes().map(|b| vec![b]).collect();
assert_eq!(forward_chunks(chunks, false).await, text);
}
#[tokio::test]
async fn genuinely_invalid_bytes_still_become_replacement_characters() {
assert_eq!(forward_chunks(vec![b"ok\xffok".to_vec()], false).await, "ok\u{FFFD}ok");
assert_eq!(
forward_chunks(vec![b"a\xe2".to_vec(), b"Xb".to_vec()], false).await,
"a\u{FFFD}Xb"
);
assert_eq!(
forward_chunks(vec![b"a".to_vec(), b"\x80b".to_vec()], false).await,
"a\u{FFFD}b"
);
}
#[tokio::test]
async fn incomplete_sequence_at_eof_is_flushed_not_dropped() {
assert_eq!(forward_chunks(vec![b"ab\xe2\x96".to_vec()], false).await, "ab\u{FFFD}");
assert_eq!(
forward_chunks(vec![b"ab".to_vec(), b"\xf0\x9f".to_vec()], true).await,
"ab\u{FFFD}"
);
}
#[tokio::test]
async fn chunks_still_queued_when_the_receiver_goes_away_count_as_dropped() {
let (tx, rx) = tokio::sync::mpsc::channel(16);
let chunks: Vec<Vec<u8>> = (0..8).map(|_| b"queued".to_vec()).collect();
forward_output(ChunkedReader(chunks.into()), false, tx).await;
let before = bash_intermediary_snapshot();
drop(rx);
let after = wait_handoff_settled().await;
assert!(after.dropped_bytes - before.dropped_bytes >= 8 * 6);
}
#[tokio::test]
async fn prompt_without_newline_is_forwarded_before_eof() {
let (mut writer, reader) = tokio::io::duplex(4096);
let (tx, mut rx) = tokio::sync::mpsc::channel(16);
let task = tokio::spawn(forward_output(reader, true, tx));
use tokio::io::AsyncWriteExt;
writer.write_all("[sudo] password for x: ".as_bytes()).await.unwrap();
let (is_stderr, chunk) =
tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
.await
.expect("prompt must arrive without EOF")
.unwrap();
let msg = chunk.consume();
assert!(is_stderr);
assert_eq!(msg, "[sudo] password for x: ");
drop(writer);
task.await.unwrap();
}
#[tokio::test]
async fn bash_tool_output_over_1kib_keeps_multibyte_characters_whole() {
let channel = crate::tools::output::delta_channel();
let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
let mut ctx = super::super::test_helpers::create_tool_context();
ctx.channels.tx_delta = Some(delta_tx);
let result = BashTool
.execute(json!({"command": "printf '▀%.0s' {1..2000}", "timeout": 30}), ctx)
.await
.unwrap();
let mut streamed = String::new();
while let Some(delta) = delta_rx.try_drain() {
streamed.push_str(&delta);
}
let expected = "▀".repeat(2000);
assert!(!result.contains('\u{FFFD}'), "result has U+FFFD");
assert_eq!(result, expected);
assert!(!streamed.contains('\u{FFFD}'), "deltas have U+FFFD");
assert!(expected.starts_with(&streamed) && !streamed.is_empty());
let ctx = super::super::test_helpers::create_tool_context();
let err = BashTool
.execute(
json!({"command": "python3 -c \"import sys; sys.stderr.write(('▀'*119+'\\n')*45)\"; exit 3", "timeout": 30}),
ctx,
)
.await
.unwrap_err()
.to_string();
assert!(!err.contains('\u{FFFD}'), "stderr has U+FFFD: {err}");
assert!(err.contains(&("▀".repeat(119) + "\n").repeat(45)), "{err}");
}
}