1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use serde::Deserialize;
5use serde_json::json;
6
7use crate::context::AppContext;
8use crate::protocol::{RawRequest, Response, ERROR_PERMISSION_REQUIRED};
9
10const DEFAULT_PTY_ROWS: u16 = 24;
18const DEFAULT_PTY_COLS: u16 = 80;
19const MAX_PTY_ROWS: u16 = 60;
20const MAX_PTY_COLS: u16 = 140;
21
22const BLOCKED_ENV_VARS: &[&str] = &[
23 "LD_PRELOAD",
24 "LD_LIBRARY_PATH",
25 "LD_AUDIT",
26 "DYLD_INSERT_LIBRARIES",
27 "DYLD_LIBRARY_PATH",
28 "DYLD_FALLBACK_LIBRARY_PATH",
29 "BASH_ENV",
30 "ENV",
31 "IFS",
32 "PATH",
33];
34
35#[derive(Debug, Deserialize)]
36struct BashParams {
37 command: String,
38 #[serde(default)]
39 timeout: Option<u64>,
40 #[serde(default)]
41 workdir: Option<PathBuf>,
42 #[serde(default)]
43 description: Option<String>,
44 #[serde(default)]
45 background: bool,
46 #[serde(default)]
47 pty: bool,
48 #[serde(default)]
49 pty_rows: Option<u16>,
50 #[serde(default)]
51 pty_cols: Option<u16>,
52 #[serde(default = "default_notify_on_completion")]
53 notify_on_completion: bool,
54 #[serde(default = "default_compressed")]
55 compressed: bool,
56 #[serde(default)]
57 permissions_granted: Vec<String>,
58 #[serde(default)]
59 permissions_requested: bool,
60 #[serde(default)]
61 env: HashMap<String, String>,
62}
63
64pub fn handle(req: &RawRequest, ctx: &AppContext) -> Response {
65 let raw_params = req
66 .params
67 .get("params")
68 .cloned()
69 .unwrap_or_else(|| req.params.clone());
70 let params = match serde_json::from_value::<BashParams>(raw_params) {
71 Ok(params) => params,
72 Err(e) => {
73 return Response::error(
74 &req.id,
75 "invalid_request",
76 format!("bash: invalid params: {e}"),
77 );
78 }
79 };
80
81 if let Some(description) = params.description.as_deref() {
82 log::debug!("bash description: {description}");
83 }
84
85 if let Err(message) = validate_pty_dimensions(params.pty_rows, params.pty_cols) {
96 return Response::error(&req.id, "invalid_request", message);
97 }
98
99 if let Some(blocked) = blocked_env_var(¶ms.env) {
100 return Response::error(
101 &req.id,
102 "blocked_env_var",
103 format!("bash env contains blocked variable: {blocked}"),
104 );
105 }
106
107 let workdir = params
108 .workdir
109 .clone()
110 .unwrap_or_else(|| default_workdir(ctx));
111 let permission_asks = if params.permissions_requested || ctx.config().bash_permissions {
112 crate::bash_permissions::scan::scan_with_cwd(¶ms.command, ctx, &workdir)
113 } else {
114 Vec::new()
115 };
116 if !permission_asks.is_empty()
117 && !permissions_granted_cover(&permission_asks, ¶ms.permissions_granted)
118 {
119 return Response::error_with_data(
120 &req.id,
121 ERROR_PERMISSION_REQUIRED,
122 "bash command requires permission",
123 json!({ "asks": permission_asks }),
124 );
125 }
126
127 if let Some(mut response) =
128 crate::bash_rewrite::try_rewrite(¶ms.command, req.session_id.as_deref(), ctx)
129 {
130 response.id = req.id.clone();
135 return response;
136 }
137
138 let workdir = params.workdir.clone();
139 let env = (!params.env.is_empty()).then_some(params.env.clone());
140 let effective_background = params.background || params.pty;
143 let pty_rows = params
146 .pty_rows
147 .filter(|v| *v > 0)
148 .unwrap_or(DEFAULT_PTY_ROWS);
149 let pty_cols = params
150 .pty_cols
151 .filter(|v| *v > 0)
152 .unwrap_or(DEFAULT_PTY_COLS);
153 crate::bash_background::spawn(
154 &req.id,
155 req.session(),
156 ¶ms.command,
157 workdir,
158 env,
159 params.timeout,
160 ctx,
161 effective_background,
162 params.notify_on_completion,
163 params.compressed,
164 params.pty,
165 pty_rows,
166 pty_cols,
167 )
168}
169
170fn validate_pty_dimensions(rows: Option<u16>, cols: Option<u16>) -> Result<(), &'static str> {
171 if rows.is_some_and(|value| value > MAX_PTY_ROWS) {
174 return Err("ptyRows must be an integer between 1 and 60");
175 }
176 if cols.is_some_and(|value| value > MAX_PTY_COLS) {
177 return Err("ptyCols must be an integer between 1 and 140");
178 }
179 Ok(())
180}
181
182fn blocked_env_var(env: &HashMap<String, String>) -> Option<&str> {
183 env.keys()
184 .find(|key| {
185 BLOCKED_ENV_VARS.iter().any(|blocked| {
186 #[cfg(windows)]
187 {
188 key.eq_ignore_ascii_case(blocked)
189 }
190 #[cfg(not(windows))]
191 {
192 key.as_str() == *blocked
193 }
194 })
195 })
196 .map(String::as_str)
197}
198
199fn permissions_granted_cover(
200 asks: &[crate::bash_permissions::PermissionAsk],
201 granted: &[String],
202) -> bool {
203 if asks.is_empty() {
204 return true;
205 }
206 if granted.is_empty() {
207 return false;
208 }
209
210 asks.iter().all(|ask| {
211 ask.patterns
212 .iter()
213 .chain(ask.always.iter())
214 .any(|pattern| granted.iter().any(|grant| grant == pattern))
215 })
216}
217
218fn default_compressed() -> bool {
219 true
220}
221
222fn default_notify_on_completion() -> bool {
223 true
224}
225
226fn default_workdir(ctx: &AppContext) -> PathBuf {
227 if let Some(root) = ctx.config().project_root.clone() {
232 return root;
233 }
234 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
235}
236
237#[cfg(test)]
249fn try_spawn_with_fallback<C, F>(
250 candidates: &[crate::windows_shell::WindowsShell],
251 mut try_one: F,
252) -> Result<C, String>
253where
254 F: FnMut(&crate::windows_shell::WindowsShell) -> std::io::Result<C>,
255{
256 let mut last_error: Option<String> = None;
257 for (idx, shell) in candidates.iter().enumerate() {
258 match try_one(shell) {
259 Ok(child) => {
260 if idx > 0 {
261 crate::slog_warn!(
262 "bash spawn fell back to {} after {} earlier candidate(s) failed; \
263 the cached PATH probe disagreed with runtime spawn — likely PATH \
264 inheritance, antivirus / AppLocker / Defender ASR, or sandbox policy.",
265 shell.binary(),
266 idx
267 );
268 }
269 return Ok(child);
270 }
271 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
272 crate::slog_warn!(
273 "bash spawn: {} returned NotFound at runtime — trying next candidate",
274 shell.binary()
275 );
276 last_error = Some(format!("{}: {e}", shell.binary()));
277 continue;
278 }
279 Err(e) => {
280 return Err(format!(
283 "failed to spawn bash command via {}: {e}",
284 shell.binary()
285 ));
286 }
287 }
288 }
289 Err(format!(
290 "failed to spawn bash command: no Windows shell could be spawned. \
291 Last error: {}. PATH-probed candidates: {:?}",
292 last_error.unwrap_or_else(|| "no candidates were attempted".to_string()),
293 candidates.iter().map(|s| s.binary()).collect::<Vec<_>>()
294 ))
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300 #[cfg(windows)]
301 use crate::windows_shell::WindowsShell;
302
303 #[cfg(windows)]
308 #[test]
309 fn windows_shell_args_match_each_shells_invocation_contract() {
310 let cmd = "echo hello";
311 let pwsh_args = WindowsShell::Pwsh.args(cmd);
312 assert!(
313 pwsh_args.contains(&"-Command"),
314 "pwsh args missing -Command: {pwsh_args:?}"
315 );
316 assert!(pwsh_args.contains(&cmd), "pwsh args missing command body");
317 assert!(
318 pwsh_args.contains(&"-NonInteractive"),
319 "pwsh args missing -NonInteractive (would hang on prompts)"
320 );
321
322 let ps_args = WindowsShell::Powershell.args(cmd);
323 assert_eq!(
324 pwsh_args, ps_args,
325 "pwsh and powershell share the same arg set"
326 );
327
328 let cmd_args = WindowsShell::Cmd.args(cmd);
329 assert_eq!(
330 cmd_args,
331 vec!["/D", "/C", cmd],
332 "cmd.exe must use /D /C contract"
333 );
334 assert!(
335 !cmd_args.contains(&"-Command"),
336 "cmd args must not leak PowerShell flags: {cmd_args:?}"
337 );
338 }
339
340 #[cfg(windows)]
344 #[test]
345 fn windows_shell_binary_names_have_exe_suffix() {
346 assert_eq!(WindowsShell::Pwsh.binary(), "pwsh.exe");
347 assert_eq!(WindowsShell::Powershell.binary(), "powershell.exe");
348 assert_eq!(WindowsShell::Cmd.binary(), "cmd.exe");
349 }
350
351 #[test]
357 fn try_spawn_with_fallback_retries_on_notfound_until_success() {
358 use crate::windows_shell::WindowsShell;
359 use std::cell::RefCell;
360 use std::io::{Error, ErrorKind};
361
362 let candidates = [
363 WindowsShell::Pwsh,
364 WindowsShell::Powershell,
365 WindowsShell::Cmd,
366 ];
367 let attempts: RefCell<Vec<WindowsShell>> = RefCell::new(Vec::new());
368
369 let result: Result<&'static str, String> = try_spawn_with_fallback(&candidates, |shell| {
370 attempts.borrow_mut().push(shell.clone());
371 match shell {
372 WindowsShell::Pwsh | WindowsShell::Powershell => {
373 Err(Error::new(ErrorKind::NotFound, "blocked"))
374 }
375 WindowsShell::Cmd => Ok("ok-from-cmd"),
376 WindowsShell::Posix(_) => unreachable!("test fixture has no Posix shell"),
377 }
378 });
379
380 assert_eq!(result, Ok("ok-from-cmd"));
381 assert_eq!(
382 attempts.into_inner(),
383 vec![
384 WindowsShell::Pwsh,
385 WindowsShell::Powershell,
386 WindowsShell::Cmd,
387 ],
388 "retry loop must walk candidates in order until one succeeds"
389 );
390 }
391
392 #[test]
397 fn try_spawn_with_fallback_stops_at_first_success() {
398 use crate::windows_shell::WindowsShell;
399 use std::cell::RefCell;
400
401 let candidates = [
402 WindowsShell::Pwsh,
403 WindowsShell::Powershell,
404 WindowsShell::Cmd,
405 ];
406 let attempts: RefCell<usize> = RefCell::new(0);
407
408 let result: Result<u32, String> = try_spawn_with_fallback(&candidates, |_shell| {
409 *attempts.borrow_mut() += 1;
410 Ok(42)
411 });
412
413 assert_eq!(result, Ok(42));
414 assert_eq!(
415 attempts.into_inner(),
416 1,
417 "first success must short-circuit; later candidates not attempted"
418 );
419 }
420
421 #[test]
426 fn try_spawn_with_fallback_returns_immediately_on_non_notfound_error() {
427 use crate::windows_shell::WindowsShell;
428 use std::cell::RefCell;
429 use std::io::{Error, ErrorKind};
430
431 let candidates = [
432 WindowsShell::Pwsh,
433 WindowsShell::Powershell,
434 WindowsShell::Cmd,
435 ];
436 let attempts: RefCell<Vec<WindowsShell>> = RefCell::new(Vec::new());
437
438 let result: Result<&'static str, String> = try_spawn_with_fallback(&candidates, |shell| {
439 attempts.borrow_mut().push(shell.clone());
440 Err(Error::new(ErrorKind::PermissionDenied, "denied by ACL"))
441 });
442
443 assert!(result.is_err(), "PermissionDenied must error out");
444 let err = result.unwrap_err();
445 assert!(
446 err.contains("pwsh.exe"),
447 "error must name the failing shell: {err}"
448 );
449 assert!(
450 err.contains("denied by ACL"),
451 "error must include underlying io error: {err}"
452 );
453 assert_eq!(
454 attempts.into_inner(),
455 vec![WindowsShell::Pwsh],
456 "non-NotFound must NOT retry with later candidates"
457 );
458 }
459
460 #[test]
465 fn try_spawn_with_fallback_reports_all_candidates_when_none_succeed() {
466 use crate::windows_shell::WindowsShell;
467 use std::io::{Error, ErrorKind};
468
469 let candidates = [WindowsShell::Pwsh, WindowsShell::Cmd];
470
471 let result: Result<&'static str, String> = try_spawn_with_fallback(&candidates, |_shell| {
472 Err(Error::new(ErrorKind::NotFound, "no shell"))
473 });
474
475 assert!(result.is_err());
476 let err = result.unwrap_err();
477 assert!(
478 err.contains("pwsh.exe"),
479 "error must list pwsh.exe candidate: {err}"
480 );
481 assert!(
482 err.contains("cmd.exe"),
483 "error must list cmd.exe candidate: {err}"
484 );
485 assert!(
486 err.contains("no Windows shell could be spawned"),
487 "error message must indicate exhaustion: {err}"
488 );
489 }
490
491 #[test]
494 fn try_spawn_with_fallback_handles_empty_candidates_list() {
495 use crate::windows_shell::WindowsShell;
496
497 let candidates: [WindowsShell; 0] = [];
498 let result: Result<&'static str, String> = try_spawn_with_fallback(&candidates, |_shell| {
499 panic!("try_one must not be called for empty candidates")
500 });
501
502 assert!(result.is_err());
503 let err = result.unwrap_err();
504 assert!(
505 err.contains("no candidates were attempted"),
506 "empty list must report no-attempt error: {err}"
507 );
508 }
509}