diff --git a/cli-config.yaml.example b/cli-config.yaml.example index af0917ded..d75284443 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -117,7 +117,8 @@ terminal: timeout: 180 docker_mount_cwd_to_workspace: false # SECURITY: off by default. Opt in to mount the launch cwd into Docker /workspace. lifetime_seconds: 300 - # sudo_password: "" # Enable sudo commands (pipes via sudo -S) - SECURITY WARNING: plaintext! + # sudo_password: "hunter2" # Optional: pipe a sudo password via sudo -S. SECURITY WARNING: plaintext. + # sudo_password: "" # Explicit empty password: try empty and never open the interactive sudo prompt. # ----------------------------------------------------------------------------- # OPTION 2: SSH remote execution @@ -208,13 +209,18 @@ terminal: # # SECURITY WARNING: Password stored in plaintext! # -# INTERACTIVE PROMPT: If no sudo_password is set and the CLI is running, +# INTERACTIVE PROMPT: If sudo_password is unset and the CLI is running, # you'll be prompted to enter your password when sudo is needed: # - 45-second timeout (auto-skips if no input) # - Press Enter to skip (command fails gracefully) # - Password is hidden while typing # - Password is cached for the session # +# EMPTY PASSWORDS: Setting sudo_password to an explicit empty string is different +# from leaving it unset. Hermes will try an empty password via `sudo -S` and +# will not open the interactive prompt. This is useful for passwordless sudo, +# Touch ID sudo setups, and environments where prompting is just noise. +# # ALTERNATIVES: # - SSH backend: Configure passwordless sudo on the remote server # - Containers: Run as root inside the container (no sudo needed) diff --git a/cli.py b/cli.py index f0edf67ee..324bb0569 100644 --- a/cli.py +++ b/cli.py @@ -1546,6 +1546,7 @@ class HermesCLI: self._clarify_deadline = 0 self._sudo_state = None self._sudo_deadline = 0 + self._modal_input_snapshot = None self._approval_state = None self._approval_deadline = 0 self._approval_lock = threading.Lock() @@ -6205,6 +6206,7 @@ class HermesCLI: timeout = 45 response_queue = queue.Queue() + self._capture_modal_input_snapshot() self._sudo_state = { "response_queue": response_queue, } @@ -6217,6 +6219,7 @@ class HermesCLI: result = response_queue.get(timeout=1) self._sudo_state = None self._sudo_deadline = 0 + self._restore_modal_input_snapshot() self._invalidate() if result: _cprint(f"\n{_DIM} ✓ Password received (cached for session){_RST}") @@ -6231,6 +6234,7 @@ class HermesCLI: self._sudo_state = None self._sudo_deadline = 0 + self._restore_modal_input_snapshot() self._invalidate() _cprint(f"\n{_DIM} ⏱ Timeout — continuing without sudo{_RST}") return "" @@ -6403,6 +6407,33 @@ class HermesCLI: def _secret_capture_callback(self, var_name: str, prompt: str, metadata=None) -> dict: return prompt_for_secret(self, var_name, prompt, metadata) + def _capture_modal_input_snapshot(self) -> None: + """Temporarily clear the input buffer and save the user's in-progress draft.""" + if self._modal_input_snapshot is not None or not getattr(self, "_app", None): + return + try: + buf = self._app.current_buffer + self._modal_input_snapshot = { + "text": buf.text, + "cursor_position": buf.cursor_position, + } + buf.reset() + except Exception: + self._modal_input_snapshot = None + + def _restore_modal_input_snapshot(self) -> None: + """Restore any draft text that was present before a modal prompt opened.""" + snapshot = self._modal_input_snapshot + self._modal_input_snapshot = None + if not snapshot or not getattr(self, "_app", None): + return + try: + buf = self._app.current_buffer + buf.text = snapshot.get("text", "") + buf.cursor_position = min(snapshot.get("cursor_position", 0), len(buf.text)) + except Exception: + pass + def _submit_secret_response(self, value: str) -> None: if not self._secret_state: return @@ -7130,6 +7161,7 @@ class HermesCLI: # Sudo password prompt state (similar mechanism to clarify) self._sudo_state = None # dict with response_queue when active self._sudo_deadline = 0 + self._modal_input_snapshot = None # Dangerous command approval state (similar mechanism to clarify) self._approval_state = None # dict with command, description, choices, selected, response_queue @@ -7201,7 +7233,6 @@ class HermesCLI: text = event.app.current_buffer.text self._sudo_state["response_queue"].put(text) self._sudo_state = None - event.app.current_buffer.reset() event.app.invalidate() return @@ -7406,7 +7437,6 @@ class HermesCLI: if self._sudo_state: self._sudo_state["response_queue"].put("") self._sudo_state = None - event.app.current_buffer.reset() event.app.invalidate() return diff --git a/hermes_cli/config.py b/hermes_cli/config.py index cf9889679..387bef667 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1217,7 +1217,7 @@ OPTIONAL_ENV_VARS = { "category": "setting", }, "SUDO_PASSWORD": { - "description": "Sudo password for terminal commands requiring root access", + "description": "Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting", "prompt": "Sudo password", "url": None, "password": True, diff --git a/tests/cli/test_cli_approval_ui.py b/tests/cli/test_cli_approval_ui.py index 9b2e0bbb2..63e03b9ab 100644 --- a/tests/cli/test_cli_approval_ui.py +++ b/tests/cli/test_cli_approval_ui.py @@ -2,22 +2,65 @@ import queue import threading import time from types import SimpleNamespace -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch +import cli as cli_module from cli import HermesCLI +class _FakeBuffer: + def __init__(self, text="", cursor_position=None): + self.text = text + self.cursor_position = len(text) if cursor_position is None else cursor_position + + def reset(self, append_to_history=False): + self.text = "" + self.cursor_position = 0 + + def _make_cli_stub(): cli = HermesCLI.__new__(HermesCLI) cli._approval_state = None cli._approval_deadline = 0 cli._approval_lock = threading.Lock() + cli._sudo_state = None + cli._sudo_deadline = 0 + cli._modal_input_snapshot = None cli._invalidate = MagicMock() - cli._app = SimpleNamespace(invalidate=MagicMock()) + cli._app = SimpleNamespace(invalidate=MagicMock(), current_buffer=_FakeBuffer()) return cli class TestCliApprovalUi: + def test_sudo_prompt_restores_existing_draft_after_response(self): + cli = _make_cli_stub() + cli._app.current_buffer = _FakeBuffer("draft command", cursor_position=5) + result = {} + + def _run_callback(): + result["value"] = cli._sudo_password_callback() + + with patch.object(cli_module, "_cprint"): + thread = threading.Thread(target=_run_callback, daemon=True) + thread.start() + + deadline = time.time() + 2 + while cli._sudo_state is None and time.time() < deadline: + time.sleep(0.01) + + assert cli._sudo_state is not None + assert cli._app.current_buffer.text == "" + + cli._app.current_buffer.text = "secret" + cli._app.current_buffer.cursor_position = len("secret") + cli._sudo_state["response_queue"].put("secret") + + thread.join(timeout=2) + + assert result["value"] == "secret" + assert cli._app.current_buffer.text == "draft command" + assert cli._app.current_buffer.cursor_position == 5 + def test_approval_callback_includes_view_for_long_commands(self): cli = _make_cli_stub() command = "sudo dd if=/tmp/githubcli-keyring.gpg of=/usr/share/keyrings/githubcli-archive-keyring.gpg bs=4M status=progress" diff --git a/tests/tools/test_terminal_tool.py b/tests/tools/test_terminal_tool.py new file mode 100644 index 000000000..42ed693a2 --- /dev/null +++ b/tests/tools/test_terminal_tool.py @@ -0,0 +1,90 @@ +"""Regression tests for sudo detection and sudo password handling.""" + +import tools.terminal_tool as terminal_tool + + +def setup_function(): + terminal_tool._cached_sudo_password = "" + + +def teardown_function(): + terminal_tool._cached_sudo_password = "" + + +def test_searching_for_sudo_does_not_trigger_rewrite(monkeypatch): + monkeypatch.delenv("SUDO_PASSWORD", raising=False) + monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) + + command = "rg --line-number --no-heading --with-filename 'sudo' . | head -n 20" + transformed, sudo_stdin = terminal_tool._transform_sudo_command(command) + + assert transformed == command + assert sudo_stdin is None + + +def test_printf_literal_sudo_does_not_trigger_rewrite(monkeypatch): + monkeypatch.delenv("SUDO_PASSWORD", raising=False) + monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) + + command = "printf '%s\\n' sudo" + transformed, sudo_stdin = terminal_tool._transform_sudo_command(command) + + assert transformed == command + assert sudo_stdin is None + + +def test_non_command_argument_named_sudo_does_not_trigger_rewrite(monkeypatch): + monkeypatch.delenv("SUDO_PASSWORD", raising=False) + monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) + + command = "grep -n sudo README.md" + transformed, sudo_stdin = terminal_tool._transform_sudo_command(command) + + assert transformed == command + assert sudo_stdin is None + + +def test_actual_sudo_command_uses_configured_password(monkeypatch): + monkeypatch.setenv("SUDO_PASSWORD", "testpass") + monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) + + transformed, sudo_stdin = terminal_tool._transform_sudo_command("sudo apt install -y ripgrep") + + assert transformed == "sudo -S -p '' apt install -y ripgrep" + assert sudo_stdin == "testpass\n" + + +def test_actual_sudo_after_leading_env_assignment_is_rewritten(monkeypatch): + monkeypatch.setenv("SUDO_PASSWORD", "testpass") + monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) + + transformed, sudo_stdin = terminal_tool._transform_sudo_command("DEBUG=1 sudo whoami") + + assert transformed == "DEBUG=1 sudo -S -p '' whoami" + assert sudo_stdin == "testpass\n" + + +def test_explicit_empty_sudo_password_tries_empty_without_prompt(monkeypatch): + monkeypatch.setenv("SUDO_PASSWORD", "") + monkeypatch.setenv("HERMES_INTERACTIVE", "1") + + def _fail_prompt(*_args, **_kwargs): + raise AssertionError("interactive sudo prompt should not run for explicit empty password") + + monkeypatch.setattr(terminal_tool, "_prompt_for_sudo_password", _fail_prompt) + + transformed, sudo_stdin = terminal_tool._transform_sudo_command("sudo true") + + assert transformed == "sudo -S -p '' true" + assert sudo_stdin == "\n" + + +def test_cached_sudo_password_is_used_when_env_is_unset(monkeypatch): + monkeypatch.delenv("SUDO_PASSWORD", raising=False) + monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) + terminal_tool._cached_sudo_password = "cached-pass" + + transformed, sudo_stdin = terminal_tool._transform_sudo_command("echo ok && sudo whoami") + + assert transformed == "echo ok && sudo -S -p '' whoami" + assert sudo_stdin == "cached-pass\n" diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 96a114775..0dc0fd587 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -326,7 +326,6 @@ def _prompt_for_sudo_password(timeout_seconds: int = 45) -> str: if "HERMES_SPINNER_PAUSE" in os.environ: del os.environ["HERMES_SPINNER_PAUSE"] - def _safe_command_preview(command: Any, limit: int = 200) -> str: """Return a log-safe preview for possibly-invalid command values.""" if command is None: @@ -338,6 +337,110 @@ def _safe_command_preview(command: Any, limit: int = 200) -> str: except Exception: return f"<{type(command).__name__}>" +def _looks_like_env_assignment(token: str) -> bool: + """Return True when *token* is a leading shell environment assignment.""" + if "=" not in token or token.startswith("="): + return False + name, _value = token.split("=", 1) + return bool(re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name)) + + +def _read_shell_token(command: str, start: int) -> tuple[str, int]: + """Read one shell token, preserving quotes/escapes, starting at *start*.""" + i = start + n = len(command) + + while i < n: + ch = command[i] + if ch.isspace() or ch in ";|&()": + break + if ch == "'": + i += 1 + while i < n and command[i] != "'": + i += 1 + if i < n: + i += 1 + continue + if ch == '"': + i += 1 + while i < n: + inner = command[i] + if inner == "\\" and i + 1 < n: + i += 2 + continue + if inner == '"': + i += 1 + break + i += 1 + continue + if ch == "\\" and i + 1 < n: + i += 2 + continue + i += 1 + + return command[start:i], i + + +def _rewrite_real_sudo_invocations(command: str) -> tuple[str, bool]: + """Rewrite only real unquoted sudo command words, not plain text mentions.""" + out: list[str] = [] + i = 0 + n = len(command) + command_start = True + found = False + + while i < n: + ch = command[i] + + if ch.isspace(): + out.append(ch) + if ch == "\n": + command_start = True + i += 1 + continue + + if ch == "#" and command_start: + comment_end = command.find("\n", i) + if comment_end == -1: + out.append(command[i:]) + break + out.append(command[i:comment_end]) + i = comment_end + continue + + if command.startswith("&&", i) or command.startswith("||", i) or command.startswith(";;", i): + out.append(command[i:i + 2]) + i += 2 + command_start = True + continue + + if ch in ";|&(": + out.append(ch) + i += 1 + command_start = True + continue + + if ch == ")": + out.append(ch) + i += 1 + command_start = False + continue + + token, next_i = _read_shell_token(command, i) + if command_start and token == "sudo": + out.append("sudo -S -p ''") + found = True + else: + out.append(token) + + if command_start and _looks_like_env_assignment(token): + command_start = True + else: + command_start = False + i = next_i + + return "".join(out), found + def _transform_sudo_command(command: str | None) -> tuple[str | None, str | None]: """ @@ -374,40 +477,26 @@ def _transform_sudo_command(command: str | None) -> tuple[str | None, str | None Command runs as-is (fails gracefully with "sudo: a password is required"). """ global _cached_sudo_password - import re - # Check if command even contains sudo if command is None: return None, None + transformed, has_real_sudo = _rewrite_real_sudo_invocations(command) + if not has_real_sudo: + return command, None - if not re.search(r'\bsudo\b', command): - return command, None # No sudo in command, nothing to do + has_configured_password = "SUDO_PASSWORD" in os.environ + sudo_password = os.environ.get("SUDO_PASSWORD", "") if has_configured_password else _cached_sudo_password - # Try to get password from: env var -> session cache -> interactive prompt - sudo_password = os.getenv("SUDO_PASSWORD", "") or _cached_sudo_password + 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: + _cached_sudo_password = sudo_password - if not sudo_password: - # No password configured - check if we're in interactive mode - if os.getenv("HERMES_INTERACTIVE"): - # Prompt user for password - sudo_password = _prompt_for_sudo_password(timeout_seconds=45) - if sudo_password: - _cached_sudo_password = sudo_password # Cache for session + if has_configured_password or sudo_password: + # Trailing newline is required: sudo -S reads one line for the password. + return transformed, sudo_password + "\n" - if not sudo_password: - return command, None # No password, let it fail gracefully - - def replace_sudo(match): - # Replace bare 'sudo' with 'sudo -S -p ""'. - # The password is returned as sudo_stdin and must be written to the - # process's stdin pipe by the caller — it never appears in any - # command-line argument or shell string. - return "sudo -S -p ''" - - # Match 'sudo' at word boundaries (not 'visudo' or 'sudoers') - transformed = re.sub(r'\bsudo\b', replace_sudo, command) - # Trailing newline is required: sudo -S reads one line for the password. - return transformed, sudo_password + "\n" + return command, None # Environment classes now live in tools/environments/