From bd8f9300a8545848a0b432d8dfad8b2d4fb3516b Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 29 May 2026 11:29:54 +1000 Subject: [PATCH 1/2] fix(staged): detach git subprocesses from user TTY Centralize TTY hardening in `run_with_env` so every git invocation gets both layers: `GIT_TERMINAL_PROMPT=0` + `GIT_SSH_COMMAND=ssh -o BatchMode=yes -o ConnectTimeout=10` make ssh refuse to prompt at the semantic level, and `setsid()` via `pre_exec` ensures ssh can't `open("/dev/tty")` to bypass piped stdio. The previous fix (#756) only covered the env-capture shells; background_sync's `git fetch` still inherited the user's ctty and could surface a passphrase prompt that left the outer zsh wedged on EIO. Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/git/cli.rs | 32 ++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/apps/staged/src-tauri/src/git/cli.rs b/apps/staged/src-tauri/src/git/cli.rs index 0cedf33e..874dfdd0 100644 --- a/apps/staged/src-tauri/src/git/cli.rs +++ b/apps/staged/src-tauri/src/git/cli.rs @@ -92,6 +92,8 @@ fn run_with_env(repo: &Path, args: &[&str], source: EnvSource) -> Result Date: Fri, 29 May 2026 11:52:35 +1000 Subject: [PATCH 2/2] fix(staged): respect user-provided GIT_SSH_COMMAND from captured env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit force_non_interactive unconditionally clobbered any GIT_SSH_COMMAND carried in by the captured shell snapshot — silently overriding corporate ssh wrappers that pin identity files, route through a jump host, or talk to an SSH CA helper. Skip the default injection when the captured env already defines GIT_SSH_COMMAND; trust those users to keep their wrapper non-interactive and lean on detach_from_ctty (the structural half of the pair) to keep an errant prompt from stealing the outer TTY. Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/git/cli.rs | 56 ++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/apps/staged/src-tauri/src/git/cli.rs b/apps/staged/src-tauri/src/git/cli.rs index 874dfdd0..0c129b15 100644 --- a/apps/staged/src-tauri/src/git/cli.rs +++ b/apps/staged/src-tauri/src/git/cli.rs @@ -201,12 +201,29 @@ fn apply_env(command: &mut Command, _repo: &Path, _source: EnvSource) { /// refactor reattaches a controlling TTY, ssh will fail fast with /// `Permission denied` instead of stealing the user's terminal to ask for a /// passphrase. Pairs with [`detach_from_ctty`] for defense in depth. +/// +/// Respects a user-provided `GIT_SSH_COMMAND` from the captured shell env +/// (e.g. a corporate ssh wrapper that pins identity files or routes through a +/// jump host). When the user has set their own, we trust them not to prompt +/// interactively and rely solely on [`detach_from_ctty`] to keep a passphrase +/// prompt from stealing the user's TTY. fn force_non_interactive(command: &mut Command) { command.env("GIT_TERMINAL_PROMPT", "0"); - command.env( - "GIT_SSH_COMMAND", - "ssh -o BatchMode=yes -o ConnectTimeout=10", - ); + if !has_env(command, "GIT_SSH_COMMAND") { + command.env( + "GIT_SSH_COMMAND", + "ssh -o BatchMode=yes -o ConnectTimeout=10", + ); + } +} + +/// Whether `command` has `key` explicitly set to a value (i.e. via +/// [`Command::env`], not removed via [`Command::env_remove`]). Used to detect +/// vars carried in from the captured shell env so we don't clobber them. +fn has_env(command: &Command, key: &str) -> bool { + command + .get_envs() + .any(|(k, v)| v.is_some() && k == std::ffi::OsStr::new(key)) } /// Detach the spawned git from the parent's controlling TTY. Without this, @@ -328,4 +345,35 @@ mod tests { "expected NotARepo, got {err:?}" ); } + + /// Without a user-provided `GIT_SSH_COMMAND` (the common case), + /// `force_non_interactive` injects the BatchMode/ConnectTimeout default so + /// ssh fails fast instead of prompting. + #[test] + fn force_non_interactive_injects_default_ssh_command() { + let mut cmd = Command::new("git"); + force_non_interactive(&mut cmd); + let ssh = cmd + .get_envs() + .find(|(k, _)| *k == std::ffi::OsStr::new("GIT_SSH_COMMAND")) + .and_then(|(_, v)| v) + .expect("GIT_SSH_COMMAND should be set"); + assert_eq!(ssh, "ssh -o BatchMode=yes -o ConnectTimeout=10"); + } + + /// A `GIT_SSH_COMMAND` carried in from the captured shell env (e.g. a + /// corporate ssh wrapper) must not be clobbered. Users who set this + /// have accepted responsibility for non-interactive behavior themselves. + #[test] + fn force_non_interactive_respects_user_ssh_command() { + let mut cmd = Command::new("git"); + cmd.env("GIT_SSH_COMMAND", "/usr/local/bin/my-ssh-wrapper"); + force_non_interactive(&mut cmd); + let ssh = cmd + .get_envs() + .find(|(k, _)| *k == std::ffi::OsStr::new("GIT_SSH_COMMAND")) + .and_then(|(_, v)| v) + .expect("GIT_SSH_COMMAND should still be set"); + assert_eq!(ssh, "/usr/local/bin/my-ssh-wrapper"); + } }