Skip to main content

lean_ctx/shell/
platform.rs

1use std::io::{self, IsTerminal};
2
3/// Sets `LC_CTYPE=C.UTF-8` when no UTF-8 locale is inherited from the parent
4/// process. Without this, commands treat bytes >127 as non-printable (C locale),
5/// mangling Cyrillic, CJK, emoji, etc.
6pub(crate) fn apply_utf8_locale(cmd: &mut std::process::Command) {
7    let has_utf8 = std::env::var("LC_ALL")
8        .or_else(|_| std::env::var("LC_CTYPE"))
9        .or_else(|_| std::env::var("LANG"))
10        .is_ok_and(|v| v.to_ascii_lowercase().contains("utf"));
11
12    if !has_utf8 {
13        cmd.env("LC_CTYPE", "C.UTF-8");
14    }
15}
16
17/// Neutralizes inherited startup-file hooks so a non-login/non-interactive
18/// `sh -c` / `bash -c` cannot be hijacked into sourcing a profile or rc file
19/// (#451). lean-ctx runs the system POSIX shell profile-free and deterministic;
20/// a stray inherited `BASH_ENV` (read by bash even for `bash -c`) or `ENV`
21/// pointing at e.g. an `exec nu` snippet would otherwise silently replace the
22/// shell. Cleared to empty so the expansion names no file. No-op in effect for
23/// PowerShell/cmd, which ignore both variables.
24pub(crate) fn apply_profile_free_env(cmd: &mut std::process::Command) {
25    cmd.env("BASH_ENV", "").env("ENV", "");
26}
27
28pub fn decode_output(bytes: &[u8]) -> String {
29    match String::from_utf8(bytes.to_vec()) {
30        Ok(s) => s,
31        Err(_) => {
32            #[cfg(windows)]
33            {
34                decode_windows_output(bytes)
35            }
36            #[cfg(not(windows))]
37            {
38                String::from_utf8_lossy(bytes).into_owned()
39            }
40        }
41    }
42}
43
44#[cfg(windows)]
45fn decode_windows_output(bytes: &[u8]) -> String {
46    use std::os::windows::ffi::OsStringExt;
47
48    let lossy = String::from_utf8_lossy(bytes);
49    let replacement_count = lossy.chars().filter(|&c| c == '\u{FFFD}').count();
50    if replacement_count == 0 {
51        return lossy.into_owned();
52    }
53
54    // SAFETY: declares Win32 API symbols that exist in kernel32; signatures
55    // match the documented ABI.
56    unsafe extern "system" {
57        fn GetACP() -> u32;
58        fn MultiByteToWideChar(
59            cp: u32,
60            flags: u32,
61            src: *const u8,
62            srclen: i32,
63            dst: *mut u16,
64            dstlen: i32,
65        ) -> i32;
66    }
67
68    // SAFETY: `GetACP` takes no arguments and only returns the active code
69    // page; it cannot fail or cause undefined behaviour.
70    let codepage = unsafe { GetACP() };
71    // SAFETY: called with a null destination and length 0 to measure the
72    // required buffer size; `bytes` is a live slice and every pointer/length
73    // argument is valid.
74    let wide_len = unsafe {
75        MultiByteToWideChar(
76            codepage,
77            0,
78            bytes.as_ptr(),
79            bytes.len() as i32,
80            std::ptr::null_mut(),
81            0,
82        )
83    };
84    if wide_len <= 0 {
85        return lossy.into_owned();
86    }
87    let mut wide: Vec<u16> = vec![0u16; wide_len as usize];
88    // SAFETY: `wide` is sized to the previously measured length and `bytes` is
89    // a live slice; the source and destination pointers/lengths are valid and
90    // do not overlap.
91    unsafe {
92        MultiByteToWideChar(
93            codepage,
94            0,
95            bytes.as_ptr(),
96            bytes.len() as i32,
97            wide.as_mut_ptr(),
98            wide_len,
99        );
100    }
101    std::ffi::OsString::from_wide(&wide)
102        .to_string_lossy()
103        .into_owned()
104}
105
106#[cfg(windows)]
107pub(super) fn set_console_utf8() {
108    // SAFETY: declares a Win32 API symbol that exists in kernel32; the
109    // signature matches the documented ABI.
110    unsafe extern "system" {
111        fn SetConsoleOutputCP(id: u32) -> i32;
112    }
113    // SAFETY: `SetConsoleOutputCP` takes a code-page id (65001 = UTF-8) by
114    // value; it cannot cause undefined behaviour.
115    unsafe {
116        SetConsoleOutputCP(65001);
117    }
118}
119
120/// Detects if the current process runs inside a Docker/container environment.
121pub fn is_container() -> bool {
122    #[cfg(unix)]
123    {
124        if std::path::Path::new("/.dockerenv").exists() {
125            return true;
126        }
127        if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup")
128            && (cgroup.contains("/docker/") || cgroup.contains("/lxc/"))
129        {
130            return true;
131        }
132        if let Ok(mounts) = std::fs::read_to_string("/proc/self/mountinfo")
133            && mounts.contains("/docker/containers/")
134        {
135            return true;
136        }
137        false
138    }
139    #[cfg(not(unix))]
140    {
141        false
142    }
143}
144
145/// Returns true if stdin is NOT a terminal (pipe, /dev/null, etc.)
146pub fn is_non_interactive() -> bool {
147    !io::stdin().is_terminal()
148}
149
150/// Returns `true` when `shell_path` points to a PowerShell executable.
151pub(crate) fn is_powershell(shell_path: &str) -> bool {
152    let name = std::path::Path::new(shell_path)
153        .file_name()
154        .and_then(|n| n.to_str())
155        .unwrap_or("")
156        .to_ascii_lowercase();
157    name.contains("powershell") || name.contains("pwsh")
158}
159
160/// Path to the current-user PowerShell profile (`$PROFILE.CurrentUserCurrentHost`).
161///
162/// Windows PowerShell stores it under `Documents\PowerShell\…`, but **PowerShell
163/// (pwsh) on macOS/Linux reads `~/.config/powershell/…` instead** — and stat-ing
164/// anything inside `~/Documents` on macOS pops a TCC privacy prompt ("lean-ctx
165/// would like to access files in your Documents folder", #356). Resolving the
166/// profile per-OS keeps pwsh support everywhere while never touching `~/Documents`
167/// on non-Windows hosts.
168pub(crate) fn powershell_profile_path(home: &std::path::Path) -> std::path::PathBuf {
169    const PROFILE_FILE: &str = "Microsoft.PowerShell_profile.ps1";
170    if cfg!(windows) {
171        home.join("Documents").join("PowerShell").join(PROFILE_FILE)
172    } else {
173        home.join(".config").join("powershell").join(PROFILE_FILE)
174    }
175}
176
177/// Windows only: argument that passes one command string to the shell binary.
178/// `exe_basename` must already be ASCII-lowercase (e.g. `bash.exe`, `cmd.exe`).
179fn windows_shell_flag_for_exe_basename(exe_basename: &str) -> &'static str {
180    if exe_basename.contains("powershell") || exe_basename.contains("pwsh") {
181        "-Command"
182    } else if exe_basename == "cmd.exe" || exe_basename == "cmd" {
183        "/C"
184    } else {
185        "-c"
186    }
187}
188
189pub fn shell_and_flag() -> (String, String) {
190    let shell = detect_shell();
191    let flag = if cfg!(windows) {
192        let name = std::path::Path::new(&shell)
193            .file_name()
194            .and_then(|n| n.to_str())
195            .unwrap_or("")
196            .to_ascii_lowercase();
197        windows_shell_flag_for_exe_basename(&name).to_string()
198    } else {
199        "-c".to_string()
200    };
201    (shell, flag)
202}
203
204/// Returns a short, human-readable shell name (e.g. "bash", "zsh", "powershell", "cmd").
205pub fn shell_name() -> String {
206    let shell = detect_shell();
207    let basename = std::path::Path::new(&shell)
208        .file_name()
209        .and_then(|n| n.to_str())
210        .unwrap_or("sh")
211        .to_ascii_lowercase();
212    basename
213        .strip_suffix(".exe")
214        .unwrap_or(&basename)
215        .to_string()
216}
217
218pub(super) fn detect_shell() -> String {
219    if let Ok(shell) = std::env::var("LEAN_CTX_SHELL") {
220        return shell;
221    }
222
223    if let Ok(shell) = std::env::var("SHELL") {
224        let bin = std::path::Path::new(&shell)
225            .file_name()
226            .and_then(|n| n.to_str())
227            .unwrap_or("sh");
228
229        if bin == "lean-ctx" {
230            return find_real_shell();
231        }
232        // #451: `$SHELL` is the user's *interactive* shell preference, not the
233        // agent's execution shell. Agents emit bash/POSIX command syntax, so an
234        // interactive-only shell like Nushell or Fish silently mis-runs it.
235        // Honor `$SHELL` only when it is POSIX-compatible; otherwise fall back
236        // to a real POSIX shell (deterministic, agent-trained). Set
237        // `LEAN_CTX_SHELL` to force a specific shell regardless of this gate.
238        if shell_acceptable_for_exec(&shell) {
239            return shell;
240        }
241        return find_real_shell();
242    }
243
244    find_real_shell()
245}
246
247/// Whether a `$SHELL` value may be auto-selected as the command-execution shell.
248///
249/// On Unix this rejects non-POSIX interactive shells (Nushell, Fish, Elvish,
250/// xonsh, PowerShell) so lean-ctx never feeds them bash/POSIX syntax (#451). On
251/// Windows the intended shells *are* PowerShell/cmd, so any `$SHELL` is honored
252/// (the POSIX gate would wrongly reject them).
253#[cfg(unix)]
254fn shell_acceptable_for_exec(shell: &str) -> bool {
255    is_posix_compatible_shell(shell)
256}
257
258#[cfg(windows)]
259fn shell_acceptable_for_exec(_shell: &str) -> bool {
260    true
261}
262
263/// `true` if `shell`'s basename is a POSIX-compatible shell that can run
264/// agent-authored bash/POSIX commands. Excludes interactive-only shells
265/// (`nu`, `fish`, `elvish`, `xonsh`) and non-POSIX shells (`pwsh`,
266/// `powershell`, `cmd`) where that syntax fails (#451).
267#[cfg(unix)]
268fn is_posix_compatible_shell(shell: &str) -> bool {
269    let name = std::path::Path::new(shell)
270        .file_name()
271        .and_then(|n| n.to_str())
272        .unwrap_or("")
273        .to_ascii_lowercase();
274    let name = name.strip_suffix(".exe").unwrap_or(&name);
275    matches!(
276        name,
277        "bash" | "zsh" | "sh" | "dash" | "ash" | "ksh" | "ksh93" | "mksh" | "busybox"
278    )
279}
280
281#[cfg(unix)]
282fn find_real_shell() -> String {
283    for shell in &["/bin/zsh", "/bin/bash", "/bin/sh"] {
284        if std::path::Path::new(shell).exists() {
285            return shell.to_string();
286        }
287    }
288    "/bin/sh".to_string()
289}
290
291#[cfg(windows)]
292fn find_real_shell() -> String {
293    if is_running_in_msys_or_gitbash() {
294        for candidate in &["bash.exe", "sh.exe"] {
295            if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
296                if output.status.success() {
297                    if let Ok(path) = String::from_utf8(output.stdout) {
298                        if let Some(first_line) = path.lines().next() {
299                            let trimmed = first_line.trim();
300                            if !trimmed.is_empty() {
301                                return trimmed.to_string();
302                            }
303                        }
304                    }
305                }
306            }
307        }
308    }
309    if let Ok(pwsh) = which_powershell() {
310        return pwsh;
311    }
312    if let Ok(comspec) = std::env::var("COMSPEC") {
313        return comspec;
314    }
315    "cmd.exe".to_string()
316}
317
318#[cfg(windows)]
319fn is_running_in_msys_or_gitbash() -> bool {
320    std::env::var("MSYSTEM").is_ok() || std::env::var("MINGW_PREFIX").is_ok()
321}
322
323#[cfg(windows)]
324fn which_powershell() -> Result<String, ()> {
325    for candidate in &["pwsh.exe", "powershell.exe"] {
326        if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
327            if output.status.success() {
328                if let Ok(path) = String::from_utf8(output.stdout) {
329                    if let Some(first_line) = path.lines().next() {
330                        let trimmed = first_line.trim();
331                        if !trimmed.is_empty() {
332                            return Ok(trimmed.to_string());
333                        }
334                    }
335                }
336            }
337        }
338    }
339    Err(())
340}
341
342/// Join multiple CLI arguments into a single command string, using quoting
343/// conventions appropriate for the detected shell.
344///
345/// On Unix, this always produces POSIX-compatible quoting.
346/// On Windows, the quoting adapts to the actual shell (PowerShell, cmd.exe,
347/// or Git Bash / MSYS).
348pub fn join_command(args: &[String]) -> String {
349    let (_, flag) = shell_and_flag();
350    join_command_for(args, &flag)
351}
352
353pub fn join_command_for(args: &[String], shell_flag: &str) -> String {
354    match shell_flag {
355        "-Command" => join_powershell(args),
356        "/C" => join_cmd(args),
357        _ => join_posix(args),
358    }
359}
360
361fn join_posix(args: &[String]) -> String {
362    args.iter()
363        .map(|a| quote_posix(a))
364        .collect::<Vec<_>>()
365        .join(" ")
366}
367
368fn join_powershell(args: &[String]) -> String {
369    if args.len() == 1 && args[0].contains(' ') {
370        return args[0].clone();
371    }
372    let quoted: Vec<String> = args.iter().map(|a| quote_powershell(a)).collect();
373    format!("& {}", quoted.join(" "))
374}
375
376fn join_cmd(args: &[String]) -> String {
377    args.iter()
378        .map(|a| quote_cmd(a))
379        .collect::<Vec<_>>()
380        .join(" ")
381}
382
383fn quote_posix(s: &str) -> String {
384    if s.is_empty() {
385        return "''".to_string();
386    }
387    if s.bytes()
388        .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
389    {
390        return s.to_string();
391    }
392    format!("'{}'", s.replace('\'', "'\\''"))
393}
394
395fn quote_powershell(s: &str) -> String {
396    if s.is_empty() {
397        return "''".to_string();
398    }
399    if s.bytes()
400        .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
401    {
402        return s.to_string();
403    }
404    format!("'{}'", s.replace('\'', "''"))
405}
406
407fn quote_cmd(s: &str) -> String {
408    if s.is_empty() {
409        return "\"\"".to_string();
410    }
411    if s.bytes()
412        .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^\\".contains(&b))
413    {
414        return s.to_string();
415    }
416    format!("\"{}\"", s.replace('"', "\\\""))
417}
418
419#[cfg(test)]
420mod join_command_tests {
421    use super::*;
422
423    #[test]
424    fn posix_simple_args() {
425        let args: Vec<String> = vec!["git".into(), "status".into()];
426        assert_eq!(join_command_for(&args, "-c"), "git status");
427    }
428
429    #[test]
430    fn posix_path_with_spaces() {
431        let args: Vec<String> = vec!["/usr/local/my app/bin".into(), "--help".into()];
432        assert_eq!(
433            join_command_for(&args, "-c"),
434            "'/usr/local/my app/bin' --help"
435        );
436    }
437
438    #[test]
439    fn posix_single_quotes_escaped() {
440        let args: Vec<String> = vec!["echo".into(), "it's".into()];
441        assert_eq!(join_command_for(&args, "-c"), "echo 'it'\\''s'");
442    }
443
444    #[test]
445    fn posix_empty_arg() {
446        let args: Vec<String> = vec!["cmd".into(), String::new()];
447        assert_eq!(join_command_for(&args, "-c"), "cmd ''");
448    }
449
450    #[test]
451    fn powershell_simple_args() {
452        let args: Vec<String> = vec!["npm".into(), "install".into()];
453        assert_eq!(join_command_for(&args, "-Command"), "& npm install");
454    }
455
456    #[test]
457    fn powershell_path_with_spaces() {
458        let args: Vec<String> = vec![
459            "C:\\Program Files\\nodejs\\npm.cmd".into(),
460            "install".into(),
461        ];
462        assert_eq!(
463            join_command_for(&args, "-Command"),
464            "& 'C:\\Program Files\\nodejs\\npm.cmd' install"
465        );
466    }
467
468    #[test]
469    fn powershell_single_quotes_escaped() {
470        let args: Vec<String> = vec!["echo".into(), "it's done".into()];
471        assert_eq!(join_command_for(&args, "-Command"), "& echo 'it''s done'");
472    }
473
474    #[test]
475    fn cmd_simple_args() {
476        let args: Vec<String> = vec!["npm.cmd".into(), "install".into()];
477        assert_eq!(join_command_for(&args, "/C"), "npm.cmd install");
478    }
479
480    #[test]
481    fn cmd_path_with_spaces() {
482        let args: Vec<String> = vec![
483            "C:\\Program Files\\nodejs\\npm.cmd".into(),
484            "install".into(),
485        ];
486        assert_eq!(
487            join_command_for(&args, "/C"),
488            "\"C:\\Program Files\\nodejs\\npm.cmd\" install"
489        );
490    }
491
492    #[test]
493    fn cmd_double_quotes_escaped() {
494        let args: Vec<String> = vec!["echo".into(), "say \"hello\"".into()];
495        assert_eq!(join_command_for(&args, "/C"), "echo \"say \\\"hello\\\"\"");
496    }
497
498    #[test]
499    fn unknown_flag_uses_posix() {
500        let args: Vec<String> = vec!["ls".into(), "-la".into()];
501        assert_eq!(join_command_for(&args, "--exec"), "ls -la");
502    }
503
504    #[test]
505    fn powershell_single_full_command_not_quoted() {
506        let args: Vec<String> = vec!["git commit -m \"feat: add feature\"".into()];
507        let result = join_command_for(&args, "-Command");
508        assert_eq!(result, "git commit -m \"feat: add feature\"");
509        assert!(
510            !result.starts_with("& '"),
511            "must not wrap full command in & '...'"
512        );
513    }
514
515    #[test]
516    fn powershell_single_no_spaces_still_uses_call_operator() {
517        let args: Vec<String> = vec!["git".into()];
518        assert_eq!(join_command_for(&args, "-Command"), "& git");
519    }
520}
521
522#[cfg(test)]
523mod is_powershell_tests {
524    use super::is_powershell;
525
526    #[test]
527    fn detects_pwsh_exe() {
528        assert!(is_powershell("pwsh.exe"));
529    }
530
531    #[test]
532    fn detects_powershell_exe() {
533        assert!(is_powershell("powershell.exe"));
534    }
535
536    #[test]
537    fn rejects_cmd() {
538        assert!(!is_powershell("cmd.exe"));
539    }
540
541    #[test]
542    fn rejects_bash() {
543        assert!(!is_powershell("/usr/bin/bash"));
544    }
545
546    #[test]
547    fn case_insensitive() {
548        assert!(is_powershell("PWSH.EXE"));
549        assert!(is_powershell("PowerShell.exe"));
550    }
551
552    #[test]
553    fn full_path_with_pwsh() {
554        assert!(is_powershell(
555            "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
556        ));
557        assert!(is_powershell("/usr/local/bin/pwsh"));
558    }
559}
560
561#[cfg(test)]
562mod powershell_profile_tests {
563    use super::powershell_profile_path;
564    use std::path::Path;
565
566    #[test]
567    fn always_ends_with_profile_file() {
568        let p = powershell_profile_path(Path::new("/home/u"));
569        assert!(p.ends_with("Microsoft.PowerShell_profile.ps1"));
570    }
571
572    #[cfg(not(windows))]
573    #[test]
574    fn non_windows_uses_config_powershell_never_documents() {
575        // #356: stat-ing anything under ~/Documents pops a macOS TCC prompt, so the
576        // non-Windows profile path must live under ~/.config/powershell instead.
577        let p = powershell_profile_path(Path::new("/Users/jane"));
578        assert_eq!(
579            p,
580            Path::new("/Users/jane/.config/powershell/Microsoft.PowerShell_profile.ps1")
581        );
582        assert!(
583            !p.to_string_lossy().contains("Documents"),
584            "macOS/Linux PowerShell profile must never touch ~/Documents (#356)"
585        );
586    }
587
588    #[cfg(windows)]
589    #[test]
590    fn windows_uses_documents_powershell() {
591        let p = powershell_profile_path(Path::new("C:\\Users\\jane"));
592        assert!(p.ends_with("Documents\\PowerShell\\Microsoft.PowerShell_profile.ps1"));
593    }
594}
595
596#[cfg(test)]
597mod windows_shell_flag_tests {
598    use super::windows_shell_flag_for_exe_basename;
599
600    #[test]
601    fn cmd_uses_slash_c() {
602        assert_eq!(windows_shell_flag_for_exe_basename("cmd.exe"), "/C");
603        assert_eq!(windows_shell_flag_for_exe_basename("cmd"), "/C");
604    }
605
606    #[test]
607    fn powershell_uses_command() {
608        assert_eq!(
609            windows_shell_flag_for_exe_basename("powershell.exe"),
610            "-Command"
611        );
612        assert_eq!(windows_shell_flag_for_exe_basename("pwsh.exe"), "-Command");
613    }
614
615    #[test]
616    fn posix_shells_use_dash_c() {
617        assert_eq!(windows_shell_flag_for_exe_basename("bash.exe"), "-c");
618        assert_eq!(windows_shell_flag_for_exe_basename("bash"), "-c");
619        assert_eq!(windows_shell_flag_for_exe_basename("sh.exe"), "-c");
620        assert_eq!(windows_shell_flag_for_exe_basename("zsh.exe"), "-c");
621        assert_eq!(windows_shell_flag_for_exe_basename("fish.exe"), "-c");
622    }
623}
624
625#[cfg(test)]
626mod platform_tests {
627    #[test]
628    fn is_container_returns_bool() {
629        let _ = super::is_container();
630    }
631
632    #[test]
633    fn is_non_interactive_returns_bool() {
634        let _ = super::is_non_interactive();
635    }
636
637    #[test]
638    fn join_command_preserves_structure() {
639        let args = vec![
640            "git".to_string(),
641            "commit".to_string(),
642            "-m".to_string(),
643            "my message".to_string(),
644        ];
645        let joined = super::join_command(&args);
646        assert!(joined.contains("git"));
647        assert!(joined.contains("commit"));
648        assert!(joined.contains("my message") || joined.contains("'my message'"));
649    }
650
651    #[test]
652    fn quote_posix_handles_em_dash() {
653        let result = super::quote_posix("closing — see #407");
654        assert!(
655            result.starts_with('\''),
656            "em-dash args must be single-quoted: {result}"
657        );
658    }
659
660    #[test]
661    fn quote_posix_handles_nested_single_quotes() {
662        let result = super::quote_posix("it's a test");
663        assert!(
664            result.contains("\\'"),
665            "single quotes must be escaped: {result}"
666        );
667    }
668
669    #[test]
670    fn quote_posix_safe_chars_unquoted() {
671        let result = super::quote_posix("simple_word");
672        assert_eq!(result, "simple_word");
673    }
674
675    #[test]
676    fn quote_posix_empty_string() {
677        let result = super::quote_posix("");
678        assert_eq!(result, "''");
679    }
680
681    #[test]
682    fn quote_posix_dollar_expansion_protected() {
683        let result = super::quote_posix("$HOME/test");
684        assert!(
685            result.starts_with('\''),
686            "dollar signs must be single-quoted: {result}"
687        );
688    }
689
690    #[test]
691    fn quote_posix_backtick_protected() {
692        let result = super::quote_posix("echo `date`");
693        assert!(
694            result.starts_with('\''),
695            "backticks must be single-quoted: {result}"
696        );
697    }
698
699    #[test]
700    fn quote_posix_double_quotes_protected() {
701        let result = super::quote_posix(r#"he said "hello""#);
702        assert!(
703            result.starts_with('\''),
704            "double quotes must be single-quoted: {result}"
705        );
706    }
707
708    // #451: a non-interactive `bash -c` sources $BASH_ENV. lean-ctx must run
709    // profile-free, so `apply_profile_free_env` has to neutralize an inherited
710    // BASH_ENV before it can pull in a contaminating startup file.
711    #[cfg(unix)]
712    #[test]
713    fn profile_free_env_blocks_bash_env_contamination() {
714        let Some(bash) = ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"]
715            .into_iter()
716            .find(|p| std::path::Path::new(p).exists())
717        else {
718            return; // no bash on this host → nothing to guard against
719        };
720
721        let startup = std::env::temp_dir().join(format!(
722            "lean_ctx_bashenv_{}_{}.sh",
723            std::process::id(),
724            "guard"
725        ));
726        std::fs::write(&startup, "echo CONTAMINATED\n").expect("write startup file");
727
728        let mut cmd = std::process::Command::new(bash);
729        cmd.arg("-c").arg("echo clean").env("BASH_ENV", &startup);
730        super::apply_profile_free_env(&mut cmd);
731        let out = cmd.output().expect("run bash");
732        let stdout = String::from_utf8_lossy(&out.stdout);
733
734        let _ = std::fs::remove_file(&startup);
735
736        assert!(stdout.contains("clean"), "command output missing: {stdout}");
737        assert!(
738            !stdout.contains("CONTAMINATED"),
739            "apply_profile_free_env must neutralize BASH_ENV, got: {stdout}"
740        );
741    }
742}
743
744#[cfg(all(test, unix))]
745mod posix_shell_gate_tests {
746    use super::{detect_shell, is_posix_compatible_shell};
747
748    #[test]
749    fn accepts_posix_shells() {
750        for s in [
751            "/bin/bash",
752            "/bin/zsh",
753            "/bin/sh",
754            "/usr/bin/dash",
755            "/bin/ash",
756            "/usr/bin/ksh",
757            "/usr/bin/mksh",
758            "bash",
759            "zsh",
760        ] {
761            assert!(is_posix_compatible_shell(s), "{s} must be POSIX-compatible");
762        }
763    }
764
765    #[test]
766    fn rejects_interactive_and_nonposix_shells() {
767        // #451: agent bash/POSIX syntax fails in these — never auto-select them.
768        for s in [
769            "/usr/bin/nu",
770            "/opt/homebrew/bin/nu",
771            "/usr/bin/fish",
772            "/usr/local/bin/elvish",
773            "/usr/bin/xonsh",
774            "/usr/bin/pwsh",
775            "powershell.exe",
776            "cmd.exe",
777        ] {
778            assert!(
779                !is_posix_compatible_shell(s),
780                "{s} must be rejected by the POSIX gate"
781            );
782        }
783    }
784
785    #[test]
786    #[cfg_attr(miri, ignore)]
787    fn detect_shell_falls_back_when_shell_is_nushell() {
788        // Serialize env mutation (set_var/remove_var are process-global).
789        let _lock = crate::core::data_dir::test_env_lock();
790        let saved_shell = std::env::var_os("SHELL");
791        let saved_override = std::env::var_os("LEAN_CTX_SHELL");
792
793        crate::test_env::remove_var("LEAN_CTX_SHELL");
794        crate::test_env::set_var("SHELL", "/usr/bin/nu");
795        let resolved = detect_shell();
796        assert!(
797            is_posix_compatible_shell(&resolved),
798            "a non-POSIX $SHELL (nu) must resolve to a POSIX shell, got {resolved}"
799        );
800        assert!(
801            !resolved.ends_with("/nu") && resolved != "/usr/bin/nu",
802            "must not run agent commands in Nushell, got {resolved}"
803        );
804
805        // A POSIX $SHELL is honored verbatim.
806        crate::test_env::set_var("SHELL", "/bin/sh");
807        assert_eq!(detect_shell(), "/bin/sh");
808
809        // LEAN_CTX_SHELL always wins, even when pointing at a non-POSIX shell.
810        crate::test_env::set_var("LEAN_CTX_SHELL", "/usr/bin/nu");
811        assert_eq!(detect_shell(), "/usr/bin/nu");
812
813        match saved_shell {
814            Some(v) => crate::test_env::set_var("SHELL", v),
815            None => crate::test_env::remove_var("SHELL"),
816        }
817        match saved_override {
818            Some(v) => crate::test_env::set_var("LEAN_CTX_SHELL", v),
819            None => crate::test_env::remove_var("LEAN_CTX_SHELL"),
820        }
821    }
822}