From e0758c32118f2aa006db3d8fa4b41833fe6e7ead Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Fri, 17 Jul 2026 23:55:08 +0300 Subject: feat: harden named agent cli workflows refs #1525 --- src/platform/fallback.rs | 8 ++ src/platform/linux.rs | 8 ++ src/platform/macos.rs | 8 ++ src/platform/mod.rs | 116 +++++++++++++++++++++++++++++ src/platform/windows.rs | 188 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 328 insertions(+) (limited to 'src/platform') diff --git a/src/platform/fallback.rs b/src/platform/fallback.rs index 6b5700cc..30bea5eb 100644 --- a/src/platform/fallback.rs +++ b/src/platform/fallback.rs @@ -27,6 +27,10 @@ pub(crate) fn pane_custom_command_pty_builder_platform( portable_pty::CommandBuilder::from_argv(raw_command_argv(command, "-c")) } +pub(crate) fn interactive_shell_command(_argv: &[String], _shell_name: &str) -> Option { + None +} + /// Unsupported platform stub. pub(crate) fn scrollback_editor_argv(_path: &std::path::Path) -> std::io::Result> { Err(std::io::Error::new( @@ -43,6 +47,10 @@ pub fn current_process_is_detached_server_daemon() -> bool { false } +pub(crate) fn available_pane_shell(_child_pid: u32) -> Option { + None +} + /// Unsupported platform stub. pub fn foreground_job(_child_pid: u32) -> Option { None diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 48f1ef1c..6557b41b 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -70,6 +70,10 @@ pub(crate) fn scrollback_editor_argv(path: &std::path::Path) -> std::io::Result< Ok(vec!["/bin/sh".to_string(), "-c".to_string(), command]) } +pub(crate) fn interactive_shell_command(argv: &[String], shell_name: &str) -> Option { + super::interactive_unix_shell_command(argv, shell_name, shell_quote) +} + fn shell_quote(value: &str) -> String { if !value.is_empty() && value.chars().all(|ch| { @@ -87,6 +91,10 @@ fn shell_quote(value: &str) -> String { } /// Collect the foreground terminal job for a given child PID. +pub(crate) fn available_pane_shell(child_pid: u32) -> Option { + super::available_pane_shell_from_job(child_pid, foreground_job(child_pid)?) +} + pub fn foreground_job(child_pid: u32) -> Option { let tpgid = foreground_process_group_id(child_pid)?; let members = foreground_process_group_members(child_pid, tpgid)?; diff --git a/src/platform/macos.rs b/src/platform/macos.rs index f35fd1eb..161354d7 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -45,6 +45,10 @@ pub(crate) fn scrollback_editor_argv(path: &Path) -> std::io::Result Ok(vec!["/bin/sh".to_string(), "-c".to_string(), command]) } +pub(crate) fn interactive_shell_command(argv: &[String], shell_name: &str) -> Option { + super::interactive_unix_shell_command(argv, shell_name, shell_quote) +} + fn shell_quote(value: &str) -> String { if !value.is_empty() && value.chars().all(|ch| { @@ -315,6 +319,10 @@ fn target_nofile_soft_limit( (current < target).then_some(target) } +pub(crate) fn available_pane_shell(child_pid: u32) -> Option { + super::available_pane_shell_from_job(child_pid, foreground_job(child_pid)?) +} + /// Collect the foreground terminal job for a given child PID. pub fn foreground_job(child_pid: u32) -> Option { if child_pid == 0 { diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 82a6704d..1415181e 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -157,6 +157,90 @@ mod fallback; #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] pub use fallback::*; +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn available_pane_shell_from_job(child_pid: u32, job: ForegroundJob) -> Option { + if job.process_group_id != child_pid + || job.processes.iter().any(|process| process.pid != child_pid) + { + return None; + } + job.processes + .into_iter() + .find(|process| process.pid == child_pid) + .map(|process| process.name) + .filter(|name| is_pane_shell_process_name(name)) +} + +fn normalized_process_name(name: &str) -> String { + name.rsplit(['/', '\\']) + .next() + .unwrap_or(name) + .trim_start_matches('-') + .trim_end_matches(".exe") + .to_ascii_lowercase() +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn is_powershell_process_name(name: &str) -> bool { + matches!( + normalized_process_name(name).as_str(), + "pwsh" | "powershell" + ) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn interactive_unix_shell_command( + argv: &[String], + shell_name: &str, + quote_posix_arg: fn(&str) -> String, +) -> Option { + let quote = if is_powershell_process_name(shell_name) { + quote_powershell_arg + } else { + quote_posix_arg + }; + let mut parts = argv.iter(); + let mut command = quote(parts.next()?); + for part in parts { + command.push(' '); + command.push_str("e(part)); + } + Some(command) +} + +pub(crate) fn quote_powershell_arg(value: &str) -> String { + if !value.is_empty() + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!(byte, b'_' | b'-' | b'.' | b'/' | b':' | b'+' | b'=') + }) + { + return value.to_string(); + } + format!("'{}'", value.replace('\'', "''")) +} + +pub(crate) fn is_pane_shell_process_name(name: &str) -> bool { + let normalized = normalized_process_name(name); + matches!( + normalized.as_str(), + "sh" | "bash" + | "dash" + | "zsh" + | "fish" + | "ksh" + | "mksh" + | "csh" + | "tcsh" + | "elvish" + | "xonsh" + | "nu" + | "pwsh" + | "powershell" + | "cmd" + ) +} + #[cfg(not(any(target_os = "linux", target_os = "macos")))] pub fn process_agent_hint(_pid: u32) -> Option { None @@ -226,6 +310,16 @@ impl PrefixInputSource for RealPrefixInputSource { mod tests { use super::*; + #[test] + fn pane_shell_process_names_reject_exec_replacement_programs() { + for shell in ["bash", "-zsh", "/bin/fish", "pwsh", "powershell.exe"] { + assert!(is_pane_shell_process_name(shell), "{shell}"); + } + for program in ["vim", "nvim", "cargo", "test-runner", "opencode"] { + assert!(!is_pane_shell_process_name(program), "{program}"); + } + } + #[test] fn detached_custom_command_preserves_unix_login_shell_flag() { let cmd = detached_custom_command_process("echo hello"); @@ -269,6 +363,28 @@ mod tests { assert_eq!(parse_agent_env_hint(b"HERDR_AGENT=not-an-agent\0"), None); } + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn interactive_shell_command_quotes_for_posix_and_powershell() { + let argv = vec![ + "pi".into(), + String::new(), + "two words".into(), + "a'b".into(), + "$HOME".into(), + "semi;colon".into(), + "@options".into(), + ]; + assert_eq!( + interactive_shell_command(&argv, "bash").as_deref(), + Some("pi '' 'two words' 'a'\\''b' '$HOME' 'semi;colon' @options") + ); + assert_eq!( + interactive_shell_command(&argv, "pwsh").as_deref(), + Some("pi '' 'two words' 'a''b' '$HOME' 'semi;colon' '@options'") + ); + } + #[test] fn read_limited_reader_returns_complete_data_under_limit() { let input = std::io::Cursor::new(b"image".to_vec()); diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 24de21ad..ef41e662 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -62,6 +62,71 @@ fn raw_command_shell(comspec: Option) -> std::ffi::OsString .unwrap_or_else(|| r"C:\Windows\System32\cmd.exe".into()) } +pub(crate) fn interactive_shell_command(argv: &[String], shell_name: &str) -> Option { + let shell_name = shell_name.to_ascii_lowercase(); + let powershell = shell_name.contains("powershell") || shell_name.contains("pwsh"); + let script = powershell_agent_script(argv)?; + if powershell { + Some(script) + } else { + Some(cmd_encoded_powershell_command(&script)) + } +} + +fn powershell_agent_script(argv: &[String]) -> Option { + let (program, args) = argv.split_first()?; + let command_line = args + .iter() + .map(|arg| quote_windows_command_line_arg(arg)) + .collect::>() + .join(" "); + Some(format!( + "$p=Start-Process -FilePath {} -ArgumentList {} -NoNewWindow -Wait -PassThru", + super::quote_powershell_arg(program), + super::quote_powershell_arg(&command_line), + )) +} + +fn quote_windows_command_line_arg(value: &str) -> String { + if !value.is_empty() + && !value + .chars() + .any(|ch| matches!(ch, ' ' | '\t' | '\n' | '\x0b' | '"')) + { + return value.to_string(); + } + + let mut quoted = String::from("\""); + let mut backslashes = 0; + for ch in value.chars() { + if ch == '\\' { + backslashes += 1; + continue; + } + if ch == '"' { + quoted.push_str(&"\\".repeat(backslashes * 2 + 1)); + } else { + quoted.push_str(&"\\".repeat(backslashes)); + } + backslashes = 0; + quoted.push(ch); + } + quoted.push_str(&"\\".repeat(backslashes * 2)); + quoted.push('"'); + quoted +} + +fn cmd_encoded_powershell_command(script: &str) -> String { + use base64::Engine as _; + + let utf16 = script + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect::>(); + let encoded = base64::engine::general_purpose::STANDARD.encode(utf16); + format!("powershell.exe -NoLogo -NoProfile -EncodedCommand {encoded}") +} + pub(crate) fn detached_custom_command_process_platform(command: &str) -> std::process::Command { detached_custom_command_process_with_comspec(command, std::env::var_os("ComSpec")) } @@ -155,6 +220,23 @@ pub fn foreground_job(child_pid: u32) -> Option { select_pane_foreground_job(child_pid, &entries) } +pub(crate) fn available_pane_shell(child_pid: u32) -> Option { + available_pane_shell_from_snapshot(child_pid, &snapshot_processes()) +} + +fn available_pane_shell_from_snapshot( + child_pid: u32, + entries: &[WindowsProcessEntry], +) -> Option { + let shell = entries.iter().find(|entry| entry.pid == child_pid)?; + if !super::is_pane_shell_process_name(&shell.name) { + return None; + } + descendant_entries(child_pid, entries) + .is_empty() + .then(|| shell.name.clone()) +} + pub fn foreground_group_leader_job(process_group_id: u32) -> Option { let entries = snapshot_processes(); let entry = entries.iter().find(|entry| entry.pid == process_group_id)?; @@ -670,6 +752,91 @@ mod tests { AllocConsole, FreeConsole, GetConsoleProcessList, GetConsoleWindow, }; + #[test] + fn cmd_agent_command_encodes_edge_arguments_without_cmd_expansion() { + use base64::Engine as _; + + assert_eq!(super::super::quote_powershell_arg("@options"), "'@options'"); + let argv = vec![ + "pi".into(), + String::new(), + "two words".into(), + "100%".into(), + "wow!".into(), + "a'b".into(), + ]; + let command = super::interactive_shell_command(&argv, "cmd.exe").unwrap(); + let encoded = command.split_whitespace().last().unwrap(); + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .unwrap(); + let utf16 = bytes + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect::>(); + assert_eq!( + String::from_utf16(&utf16).unwrap(), + "$p=Start-Process -FilePath pi -ArgumentList '\"\" \"two words\" 100% wow! a''b' -NoNewWindow -Wait -PassThru" + ); + } + + #[test] + fn windows_shells_round_trip_agent_arguments_through_a_real_command() { + let base = std::env::temp_dir().join(format!( + "herdr-agent-argv-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() + )); + fs::create_dir_all(&base).unwrap(); + let helper = base.join("pi.cmd"); + fs::write( + &helper, + "@echo off\r\n>\"%HERDR_ARGV_CAPTURE%\" (\r\necho(%~1\r\necho(%~2\r\necho(%~3\r\necho(%~4\r\necho(%~5\r\necho(%~6\r\n)\r\n", + ) + .unwrap(); + let argv = vec![ + "pi".into(), + String::new(), + "two words".into(), + "100%".into(), + "wow!".into(), + "a'b".into(), + "@options".into(), + ]; + let inherited_path = std::env::var_os("PATH").unwrap_or_default(); + let path = format!("{};{}", base.display(), inherited_path.to_string_lossy()); + + for shell in ["powershell.exe", "cmd.exe"] { + let capture = base.join(format!("{shell}.txt")); + let command = super::interactive_shell_command(&argv, shell).unwrap(); + let status = if shell == "cmd.exe" { + Command::new("cmd.exe") + .args(["/d", "/c", &command]) + .env("PATH", &path) + .env("HERDR_ARGV_CAPTURE", &capture) + .status() + .unwrap() + } else { + Command::new("powershell.exe") + .args(["-NoLogo", "-NoProfile", "-Command", &command]) + .env("PATH", &path) + .env("HERDR_ARGV_CAPTURE", &capture) + .status() + .unwrap() + }; + assert!(status.success(), "{shell} command failed"); + assert_eq!( + fs::read_to_string(capture).unwrap().replace("\r\n", "\n"), + "\ntwo words\n100%\nwow!\na'b\n@options\n" + ); + } + + let _ = fs::remove_dir_all(base); + } + const CONSOLE_TEST_CHILD_ENV: &str = "HERDR_TEST_CONSOLE_CHILD_MODE"; const CONSOLE_TEST_PARENT_PID_ENV: &str = "HERDR_TEST_CONSOLE_PARENT_PID"; @@ -1001,6 +1168,27 @@ mod tests { assert_eq!(job.processes[0].name, "powershell.exe"); } + #[test] + fn windows_shell_is_available_only_without_descendants() { + let shell_only = vec![test_entry(10, 1, "powershell.exe", &["powershell.exe"])]; + assert_eq!( + super::available_pane_shell_from_snapshot(10, &shell_only).as_deref(), + Some("powershell.exe") + ); + + let busy = vec![ + test_entry(10, 1, "powershell.exe", &["powershell.exe"]), + test_entry(20, 10, "git.exe", &["git.exe", "status"]), + ]; + assert_eq!(super::available_pane_shell_from_snapshot(10, &busy), None); + + let replaced = vec![test_entry(10, 1, "vim.exe", &["vim.exe"])]; + assert_eq!( + super::available_pane_shell_from_snapshot(10, &replaced), + None + ); + } + #[test] fn windows_process_tree_returns_shell_for_multiple_agent_descendants() { let entries = vec![ -- cgit v1.2.3