From ab6c629ccc31ed2dea0b6a2955750b75416d0058 Mon Sep 17 00:00:00 2001 From: Mind-Dragon Date: Thu, 30 Apr 2026 20:37:37 -0700 Subject: [PATCH] fix(terminal): skip sudo prompt when local NOPASSWD sudo works When running on a host with sudoers NOPASSWD configured for the current user, interactive Hermes sessions were unnecessarily entering the password prompt path before executing sudo commands. Outside Hermes, `sudo -n true` exits 0 for that user. Add `_sudo_nopasswd_works()` that probes `sudo -n true` and, when it succeeds, lets `_transform_sudo_command()` return the command unchanged with no stdin password. The probe: - Is scoped to the `local` terminal backend only, so Docker/SSH/Modal and other remote backends do not inherit host sudo state. - Re-probes every call (no process-lifetime cache) so an expired sudo timestamp cannot silently make a later command block waiting for a password that Hermes never prompts for. - Is bypassed entirely when `SUDO_PASSWORD` is configured or a cached password already exists, preserving existing explicit-password flows. Co-authored-by: Junting Wu --- tests/tools/test_terminal_tool.py | 51 +++++++++++++++++++++++++++++++ tools/terminal_tool.py | 35 +++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/tests/tools/test_terminal_tool.py b/tests/tools/test_terminal_tool.py index 9245d9c6b8..b17fc332c4 100644 --- a/tests/tools/test_terminal_tool.py +++ b/tests/tools/test_terminal_tool.py @@ -104,6 +104,57 @@ def test_cached_sudo_password_isolated_by_session_key(monkeypatch): assert terminal_tool._get_cached_sudo_password() == "alpha-pass" +def test_passwordless_sudo_skips_interactive_prompt_and_rewrite(monkeypatch): + monkeypatch.delenv("SUDO_PASSWORD", raising=False) + monkeypatch.delenv("TERMINAL_ENV", raising=False) + monkeypatch.setenv("HERMES_INTERACTIVE", "1") + + def _fail_prompt(*_args, **_kwargs): + raise AssertionError( + "interactive sudo prompt should not run when sudo -n already works" + ) + + monkeypatch.setattr(terminal_tool, "_prompt_for_sudo_password", _fail_prompt) + monkeypatch.setattr(terminal_tool, "_sudo_nopasswd_works", lambda: True, raising=False) + + transformed, sudo_stdin = terminal_tool._transform_sudo_command("sudo whoami") + + assert transformed == "sudo whoami" + assert sudo_stdin is None + + +def test_passwordless_sudo_probe_rechecks_local_terminal(monkeypatch): + monkeypatch.delenv("TERMINAL_ENV", raising=False) + calls = [] + + class Result: + def __init__(self, returncode): + self.returncode = returncode + + def fake_run(args, **kwargs): + calls.append((args, kwargs)) + return Result(0 if len(calls) == 1 else 1) + + monkeypatch.setattr(terminal_tool.subprocess, "run", fake_run) + + assert terminal_tool._sudo_nopasswd_works() is True + assert terminal_tool._sudo_nopasswd_works() is False + assert len(calls) == 2 + assert calls[0][0] == ["sudo", "-n", "true"] + assert calls[1][0] == ["sudo", "-n", "true"] + + +def test_passwordless_sudo_probe_is_disabled_for_nonlocal_terminal_env(monkeypatch): + monkeypatch.setenv("TERMINAL_ENV", "docker") + + def _fail_run(*_args, **_kwargs): + raise AssertionError("host sudo probe must not run for non-local terminal envs") + + monkeypatch.setattr(terminal_tool.subprocess, "run", _fail_run) + + assert terminal_tool._sudo_nopasswd_works() is False + + def test_validate_workdir_allows_windows_drive_paths(): assert terminal_tool._validate_workdir(r"C:\Users\Alice\project") is None assert terminal_tool._validate_workdir("C:/Users/Alice/project") is None diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index f9c203fe06..b65af93fa3 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -620,6 +620,32 @@ def _rewrite_real_sudo_invocations(command: str) -> tuple[str, bool]: return "".join(out), found +def _sudo_nopasswd_works() -> bool: + """Return True when local sudo currently works without prompting. + + Only probes for the `local` terminal backend; Docker/SSH/Modal/etc. must + not inherit the host's sudo state. Re-probes every call (no process-level + cache) so an expired sudo timestamp cannot make a later command silently + block waiting for a password. + """ + terminal_env = os.getenv("TERMINAL_ENV", "local").strip().lower() or "local" + if terminal_env != "local": + return False + + try: + probe = subprocess.run( + ["sudo", "-n", "true"], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=3, + check=False, + ) + return probe.returncode == 0 + except Exception: + return False + + def _rewrite_compound_background(command: str) -> str: """Wrap `A && B &` (or `A || B &`) to `A && { B & }` at depth 0. @@ -833,6 +859,15 @@ def _transform_sudo_command(command: str | None) -> tuple[str | None, str | None else _get_cached_sudo_password() ) + # Local hosts with sudoers NOPASSWD should not be forced through the + # interactive Hermes password prompt or the sudo -S password-pipe path. + # Scoped to the local terminal backend so Docker/SSH/Modal/etc. can't + # inherit host sudo state. Re-probes every call (no process-lifetime + # cache) so an expired sudo timestamp doesn't make a later command block + # silently without Hermes prompting. + if not has_configured_password and not sudo_password and _sudo_nopasswd_works(): + return command, None + if not has_configured_password and not sudo_password and os.getenv("HERMES_INTERACTIVE"): sudo_password = _prompt_for_sudo_password(timeout_seconds=45) if sudo_password: