use std::collections::HashMap;
use std::path::PathBuf;
use serde::Deserialize;
use serde_json::json;
use crate::context::AppContext;
use crate::protocol::{RawRequest, Response, ERROR_PERMISSION_REQUIRED};
const DEFAULT_PTY_ROWS: u16 = 24;
const DEFAULT_PTY_COLS: u16 = 80;
const MAX_PTY_ROWS: u16 = 60;
const MAX_PTY_COLS: u16 = 140;
const BLOCKED_ENV_VARS: &[&str] = &[
"LD_PRELOAD",
"LD_LIBRARY_PATH",
"LD_AUDIT",
"DYLD_INSERT_LIBRARIES",
"DYLD_LIBRARY_PATH",
"DYLD_FALLBACK_LIBRARY_PATH",
"BASH_ENV",
"ENV",
"IFS",
"PATH",
];
#[derive(Debug, Deserialize)]
struct BashParams {
command: String,
#[serde(default)]
timeout: Option<u64>,
#[serde(default)]
workdir: Option<PathBuf>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
background: bool,
#[serde(default)]
pty: bool,
#[serde(default)]
pty_rows: Option<u16>,
#[serde(default)]
pty_cols: Option<u16>,
#[serde(default = "default_notify_on_completion")]
notify_on_completion: bool,
#[serde(default = "default_compressed")]
compressed: bool,
#[serde(default)]
permissions_granted: Vec<String>,
#[serde(default)]
permissions_requested: bool,
#[serde(default)]
env: HashMap<String, String>,
}
pub fn handle(req: &RawRequest, ctx: &AppContext) -> Response {
let raw_params = req
.params
.get("params")
.cloned()
.unwrap_or_else(|| req.params.clone());
let params = match serde_json::from_value::<BashParams>(raw_params) {
Ok(params) => params,
Err(e) => {
return Response::error(
&req.id,
"invalid_request",
format!("bash: invalid params: {e}"),
);
}
};
if let Some(description) = params.description.as_deref() {
log::debug!("bash description: {description}");
}
if let Err(message) = validate_pty_dimensions(params.pty_rows, params.pty_cols) {
return Response::error(&req.id, "invalid_request", message);
}
if let Some(blocked) = blocked_env_var(¶ms.env) {
return Response::error(
&req.id,
"blocked_env_var",
format!("bash env contains blocked variable: {blocked}"),
);
}
let workdir = params
.workdir
.clone()
.unwrap_or_else(|| default_workdir(ctx));
let permission_asks = if params.permissions_requested || ctx.config().bash_permissions {
crate::bash_permissions::scan::scan_with_cwd(¶ms.command, ctx, &workdir)
} else {
Vec::new()
};
if !permission_asks.is_empty()
&& !permissions_granted_cover(&permission_asks, ¶ms.permissions_granted)
{
return Response::error_with_data(
&req.id,
ERROR_PERMISSION_REQUIRED,
"bash command requires permission",
json!({ "asks": permission_asks }),
);
}
if let Some(mut response) =
crate::bash_rewrite::try_rewrite(¶ms.command, req.session_id.as_deref(), ctx)
{
response.id = req.id.clone();
return response;
}
let workdir = params.workdir.clone();
let env = (!params.env.is_empty()).then_some(params.env.clone());
let effective_background = params.background || params.pty;
let pty_rows = params
.pty_rows
.filter(|v| *v > 0)
.unwrap_or(DEFAULT_PTY_ROWS);
let pty_cols = params
.pty_cols
.filter(|v| *v > 0)
.unwrap_or(DEFAULT_PTY_COLS);
crate::bash_background::spawn(
&req.id,
req.session(),
¶ms.command,
workdir,
env,
params.timeout,
ctx,
effective_background,
params.notify_on_completion,
params.compressed,
params.pty,
pty_rows,
pty_cols,
)
}
fn validate_pty_dimensions(rows: Option<u16>, cols: Option<u16>) -> Result<(), &'static str> {
if rows.is_some_and(|value| value > MAX_PTY_ROWS) {
return Err("ptyRows must be an integer between 1 and 60");
}
if cols.is_some_and(|value| value > MAX_PTY_COLS) {
return Err("ptyCols must be an integer between 1 and 140");
}
Ok(())
}
fn blocked_env_var(env: &HashMap<String, String>) -> Option<&str> {
env.keys()
.find(|key| {
BLOCKED_ENV_VARS.iter().any(|blocked| {
#[cfg(windows)]
{
key.eq_ignore_ascii_case(blocked)
}
#[cfg(not(windows))]
{
key.as_str() == *blocked
}
})
})
.map(String::as_str)
}
fn permissions_granted_cover(
asks: &[crate::bash_permissions::PermissionAsk],
granted: &[String],
) -> bool {
if asks.is_empty() {
return true;
}
if granted.is_empty() {
return false;
}
asks.iter().all(|ask| {
ask.patterns
.iter()
.chain(ask.always.iter())
.any(|pattern| granted.iter().any(|grant| grant == pattern))
})
}
fn default_compressed() -> bool {
true
}
fn default_notify_on_completion() -> bool {
true
}
fn default_workdir(ctx: &AppContext) -> PathBuf {
if let Some(root) = ctx.config().project_root.clone() {
return root;
}
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
#[cfg(test)]
fn try_spawn_with_fallback<C, F>(
candidates: &[crate::windows_shell::WindowsShell],
mut try_one: F,
) -> Result<C, String>
where
F: FnMut(&crate::windows_shell::WindowsShell) -> std::io::Result<C>,
{
let mut last_error: Option<String> = None;
for (idx, shell) in candidates.iter().enumerate() {
match try_one(shell) {
Ok(child) => {
if idx > 0 {
crate::slog_warn!(
"bash spawn fell back to {} after {} earlier candidate(s) failed; \
the cached PATH probe disagreed with runtime spawn — likely PATH \
inheritance, antivirus / AppLocker / Defender ASR, or sandbox policy.",
shell.binary(),
idx
);
}
return Ok(child);
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
crate::slog_warn!(
"bash spawn: {} returned NotFound at runtime — trying next candidate",
shell.binary()
);
last_error = Some(format!("{}: {e}", shell.binary()));
continue;
}
Err(e) => {
return Err(format!(
"failed to spawn bash command via {}: {e}",
shell.binary()
));
}
}
}
Err(format!(
"failed to spawn bash command: no Windows shell could be spawned. \
Last error: {}. PATH-probed candidates: {:?}",
last_error.unwrap_or_else(|| "no candidates were attempted".to_string()),
candidates.iter().map(|s| s.binary()).collect::<Vec<_>>()
))
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(windows)]
use crate::windows_shell::WindowsShell;
#[cfg(windows)]
#[test]
fn windows_shell_args_match_each_shells_invocation_contract() {
let cmd = "echo hello";
let pwsh_args = WindowsShell::Pwsh.args(cmd);
assert!(
pwsh_args.contains(&"-Command"),
"pwsh args missing -Command: {pwsh_args:?}"
);
assert!(pwsh_args.contains(&cmd), "pwsh args missing command body");
assert!(
pwsh_args.contains(&"-NonInteractive"),
"pwsh args missing -NonInteractive (would hang on prompts)"
);
let ps_args = WindowsShell::Powershell.args(cmd);
assert_eq!(
pwsh_args, ps_args,
"pwsh and powershell share the same arg set"
);
let cmd_args = WindowsShell::Cmd.args(cmd);
assert_eq!(
cmd_args,
vec!["/D", "/C", cmd],
"cmd.exe must use /D /C contract"
);
assert!(
!cmd_args.contains(&"-Command"),
"cmd args must not leak PowerShell flags: {cmd_args:?}"
);
}
#[cfg(windows)]
#[test]
fn windows_shell_binary_names_have_exe_suffix() {
assert_eq!(WindowsShell::Pwsh.binary(), "pwsh.exe");
assert_eq!(WindowsShell::Powershell.binary(), "powershell.exe");
assert_eq!(WindowsShell::Cmd.binary(), "cmd.exe");
}
#[test]
fn try_spawn_with_fallback_retries_on_notfound_until_success() {
use crate::windows_shell::WindowsShell;
use std::cell::RefCell;
use std::io::{Error, ErrorKind};
let candidates = [
WindowsShell::Pwsh,
WindowsShell::Powershell,
WindowsShell::Cmd,
];
let attempts: RefCell<Vec<WindowsShell>> = RefCell::new(Vec::new());
let result: Result<&'static str, String> = try_spawn_with_fallback(&candidates, |shell| {
attempts.borrow_mut().push(shell.clone());
match shell {
WindowsShell::Pwsh | WindowsShell::Powershell => {
Err(Error::new(ErrorKind::NotFound, "blocked"))
}
WindowsShell::Cmd => Ok("ok-from-cmd"),
WindowsShell::Posix(_) => unreachable!("test fixture has no Posix shell"),
}
});
assert_eq!(result, Ok("ok-from-cmd"));
assert_eq!(
attempts.into_inner(),
vec![
WindowsShell::Pwsh,
WindowsShell::Powershell,
WindowsShell::Cmd,
],
"retry loop must walk candidates in order until one succeeds"
);
}
#[test]
fn try_spawn_with_fallback_stops_at_first_success() {
use crate::windows_shell::WindowsShell;
use std::cell::RefCell;
let candidates = [
WindowsShell::Pwsh,
WindowsShell::Powershell,
WindowsShell::Cmd,
];
let attempts: RefCell<usize> = RefCell::new(0);
let result: Result<u32, String> = try_spawn_with_fallback(&candidates, |_shell| {
*attempts.borrow_mut() += 1;
Ok(42)
});
assert_eq!(result, Ok(42));
assert_eq!(
attempts.into_inner(),
1,
"first success must short-circuit; later candidates not attempted"
);
}
#[test]
fn try_spawn_with_fallback_returns_immediately_on_non_notfound_error() {
use crate::windows_shell::WindowsShell;
use std::cell::RefCell;
use std::io::{Error, ErrorKind};
let candidates = [
WindowsShell::Pwsh,
WindowsShell::Powershell,
WindowsShell::Cmd,
];
let attempts: RefCell<Vec<WindowsShell>> = RefCell::new(Vec::new());
let result: Result<&'static str, String> = try_spawn_with_fallback(&candidates, |shell| {
attempts.borrow_mut().push(shell.clone());
Err(Error::new(ErrorKind::PermissionDenied, "denied by ACL"))
});
assert!(result.is_err(), "PermissionDenied must error out");
let err = result.unwrap_err();
assert!(
err.contains("pwsh.exe"),
"error must name the failing shell: {err}"
);
assert!(
err.contains("denied by ACL"),
"error must include underlying io error: {err}"
);
assert_eq!(
attempts.into_inner(),
vec![WindowsShell::Pwsh],
"non-NotFound must NOT retry with later candidates"
);
}
#[test]
fn try_spawn_with_fallback_reports_all_candidates_when_none_succeed() {
use crate::windows_shell::WindowsShell;
use std::io::{Error, ErrorKind};
let candidates = [WindowsShell::Pwsh, WindowsShell::Cmd];
let result: Result<&'static str, String> = try_spawn_with_fallback(&candidates, |_shell| {
Err(Error::new(ErrorKind::NotFound, "no shell"))
});
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.contains("pwsh.exe"),
"error must list pwsh.exe candidate: {err}"
);
assert!(
err.contains("cmd.exe"),
"error must list cmd.exe candidate: {err}"
);
assert!(
err.contains("no Windows shell could be spawned"),
"error message must indicate exhaustion: {err}"
);
}
#[test]
fn try_spawn_with_fallback_handles_empty_candidates_list() {
use crate::windows_shell::WindowsShell;
let candidates: [WindowsShell; 0] = [];
let result: Result<&'static str, String> = try_spawn_with_fallback(&candidates, |_shell| {
panic!("try_one must not be called for empty candidates")
});
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.contains("no candidates were attempted"),
"empty list must report no-attempt error: {err}"
);
}
}