Skip to main content

aft/commands/
bash.rs

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
10// Foreground bash no longer has a 30s default kill cap. When `params.timeout`
11// is `None`, the spawn path passes `None` through and the registry applies
12// `DEFAULT_BG_TIMEOUT` (30 min) — same default as explicit `background: true`.
13// The "agent should expect a 30s wait" UX is now enforced purely in the plugin
14// layer's polling wait-window, decoupled from the task budget. See council
15// decision in .alfonso/athena/council-aft-bash-timeout-design-5f25c3ee503ab303/
16// for the full rationale.
17const 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    // NOTE (v0.30.1 prep, unblock-only): the previous two rejections
86    // ("PTY mode requires background: true" and "ptyRows/ptyCols require
87    // pty: true") have been removed so that:
88    //   1. pty:true silently implies background:true (handled below by
89    //      passing `params.background || params.pty` to bash_background::spawn)
90    //   2. ptyRows/ptyCols are silently ignored when pty:false instead of
91    //      rejecting agent calls that defensively include the params
92    // Bounds validation (1..60 rows, 1..140 cols) still applies via
93    // `validate_pty_dimensions` below.
94
95    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(&params.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(&params.command, ctx, &workdir)
113    } else {
114        Vec::new()
115    };
116    if !permission_asks.is_empty()
117        && !permissions_granted_cover(&permission_asks, &params.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(&params.command, req.session_id.as_deref(), ctx)
129    {
130        // Rewriter rules build their own internal request with a placeholder id
131        // (e.g. "bash_rewrite") to call into read/grep/glob handlers. Stamp the
132        // original bash request id back onto the response so the bridge correlates
133        // it with the in-flight `send()` instead of timing out.
134        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    // pty:true silently implies background:true so agents don't need to know
141    // both flags. The PTY runtime requires a polling lifecycle regardless.
142    let effective_background = params.background || params.pty;
143    // Treat ptyRows/ptyCols == 0 as "use default" so empty-sentinel-style
144    // agent calls don't trip bounds validation.
145    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        &params.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    // 0 is silently treated as "use default" (see handle()); only reject
172    // explicit out-of-bound positive values.
173    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    // Prefer the configured project root so bash commands run against the
228    // user's project rather than the (often unrelated) cwd of the long-lived
229    // aft worker process. Falls back to process cwd only when no project root
230    // is configured (e.g. direct CLI usage).
231    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/// Generic retry loop for the Windows shell-fallback path. Walks the
238/// `candidates` list, calling `try_one(shell)` for each; on `NotFound`
239/// continues to the next candidate, on success returns the child, on
240/// other errors returns immediately. Extracted from `spawn_shell_command`
241/// so tests can exercise the retry decision logic without a real
242/// `Command::spawn` (mock closures simulate per-shell outcomes).
243///
244/// `Child` is generic so tests can substitute a unit type or mock value;
245/// production callers always pass `std::process::Child`. Compiled for tests
246/// only so the retry-decision unit tests can run on macOS/Linux dev machines
247/// without leaving dead code in non-test builds.
248#[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                // Non-NotFound errors (permission denied, OOM, etc.) are not
281                // remediated by trying a different shell — return immediately.
282                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    /// Issue #27: `WindowsShell::args` must produce shell-appropriate flags.
304    /// PowerShell variants need `-Command <string>`; cmd.exe needs `/D /C
305    /// <string>`. Mixing these up would make the spawned shell ignore the
306    /// command or interpret it as a parameter to the wrong cmdlet.
307    #[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    /// Each shell's binary name must match what `Command::new` expects on
341    /// Windows. Bare names rely on PATH lookup; `.exe` suffix is mandatory
342    /// for cross-compatibility with `which::which()` probing.
343    #[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    /// Issue #27 P2 test gap: foreground retry path. When the first
352    /// candidate returns NotFound at runtime spawn time, the loop must
353    /// move to the next candidate. The first SUCCESSFUL spawn wins.
354    /// Uses the generic `try_spawn_with_fallback` so the test runs on
355    /// macOS/Linux dev machines without a real Windows spawn.
356    #[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    /// Issue #27 P2 test gap: short-circuit on first success. When pwsh
393    /// spawns successfully, the loop must NOT call try_one for the
394    /// remaining candidates — that would waste resources and could double-
395    /// spawn shells.
396    #[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    /// Issue #27 P2 test gap: non-NotFound errors return immediately.
422    /// PermissionDenied, OutOfMemory, etc. are not remediated by trying a
423    /// different shell — those would just fail in the same way. Returning
424    /// early avoids wasted work and surfaces the real error.
425    #[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    /// Issue #27 P2 test gap: all candidates fail with NotFound. This is
461    /// the worst case where no shell on the system is reachable — the
462    /// final error must include the candidate list so users debugging
463    /// issue #27-class problems can see what was attempted.
464    #[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    /// Edge case: empty candidate list. Should return an error mentioning
492    /// "no candidates were attempted" rather than panic on empty iteration.
493    #[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}