fix: handle empty sudo password and false prompts

This commit is contained in:
Lumen Radley 2026-04-07 23:44:12 +02:00 committed by Teknium
parent a94099908a
commit e22416dd9b
6 changed files with 293 additions and 35 deletions

View file

@ -117,7 +117,8 @@ terminal:
timeout: 180 timeout: 180
docker_mount_cwd_to_workspace: false # SECURITY: off by default. Opt in to mount the launch cwd into Docker /workspace. docker_mount_cwd_to_workspace: false # SECURITY: off by default. Opt in to mount the launch cwd into Docker /workspace.
lifetime_seconds: 300 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 # OPTION 2: SSH remote execution
@ -208,13 +209,18 @@ terminal:
# #
# SECURITY WARNING: Password stored in plaintext! # 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: # you'll be prompted to enter your password when sudo is needed:
# - 45-second timeout (auto-skips if no input) # - 45-second timeout (auto-skips if no input)
# - Press Enter to skip (command fails gracefully) # - Press Enter to skip (command fails gracefully)
# - Password is hidden while typing # - Password is hidden while typing
# - Password is cached for the session # - 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: # ALTERNATIVES:
# - SSH backend: Configure passwordless sudo on the remote server # - SSH backend: Configure passwordless sudo on the remote server
# - Containers: Run as root inside the container (no sudo needed) # - Containers: Run as root inside the container (no sudo needed)

34
cli.py
View file

@ -1546,6 +1546,7 @@ class HermesCLI:
self._clarify_deadline = 0 self._clarify_deadline = 0
self._sudo_state = None self._sudo_state = None
self._sudo_deadline = 0 self._sudo_deadline = 0
self._modal_input_snapshot = None
self._approval_state = None self._approval_state = None
self._approval_deadline = 0 self._approval_deadline = 0
self._approval_lock = threading.Lock() self._approval_lock = threading.Lock()
@ -6205,6 +6206,7 @@ class HermesCLI:
timeout = 45 timeout = 45
response_queue = queue.Queue() response_queue = queue.Queue()
self._capture_modal_input_snapshot()
self._sudo_state = { self._sudo_state = {
"response_queue": response_queue, "response_queue": response_queue,
} }
@ -6217,6 +6219,7 @@ class HermesCLI:
result = response_queue.get(timeout=1) result = response_queue.get(timeout=1)
self._sudo_state = None self._sudo_state = None
self._sudo_deadline = 0 self._sudo_deadline = 0
self._restore_modal_input_snapshot()
self._invalidate() self._invalidate()
if result: if result:
_cprint(f"\n{_DIM} ✓ Password received (cached for session){_RST}") _cprint(f"\n{_DIM} ✓ Password received (cached for session){_RST}")
@ -6231,6 +6234,7 @@ class HermesCLI:
self._sudo_state = None self._sudo_state = None
self._sudo_deadline = 0 self._sudo_deadline = 0
self._restore_modal_input_snapshot()
self._invalidate() self._invalidate()
_cprint(f"\n{_DIM} ⏱ Timeout — continuing without sudo{_RST}") _cprint(f"\n{_DIM} ⏱ Timeout — continuing without sudo{_RST}")
return "" return ""
@ -6403,6 +6407,33 @@ class HermesCLI:
def _secret_capture_callback(self, var_name: str, prompt: str, metadata=None) -> dict: def _secret_capture_callback(self, var_name: str, prompt: str, metadata=None) -> dict:
return prompt_for_secret(self, var_name, prompt, metadata) 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: def _submit_secret_response(self, value: str) -> None:
if not self._secret_state: if not self._secret_state:
return return
@ -7130,6 +7161,7 @@ class HermesCLI:
# Sudo password prompt state (similar mechanism to clarify) # Sudo password prompt state (similar mechanism to clarify)
self._sudo_state = None # dict with response_queue when active self._sudo_state = None # dict with response_queue when active
self._sudo_deadline = 0 self._sudo_deadline = 0
self._modal_input_snapshot = None
# Dangerous command approval state (similar mechanism to clarify) # Dangerous command approval state (similar mechanism to clarify)
self._approval_state = None # dict with command, description, choices, selected, response_queue self._approval_state = None # dict with command, description, choices, selected, response_queue
@ -7201,7 +7233,6 @@ class HermesCLI:
text = event.app.current_buffer.text text = event.app.current_buffer.text
self._sudo_state["response_queue"].put(text) self._sudo_state["response_queue"].put(text)
self._sudo_state = None self._sudo_state = None
event.app.current_buffer.reset()
event.app.invalidate() event.app.invalidate()
return return
@ -7406,7 +7437,6 @@ class HermesCLI:
if self._sudo_state: if self._sudo_state:
self._sudo_state["response_queue"].put("") self._sudo_state["response_queue"].put("")
self._sudo_state = None self._sudo_state = None
event.app.current_buffer.reset()
event.app.invalidate() event.app.invalidate()
return return

View file

@ -1217,7 +1217,7 @@ OPTIONAL_ENV_VARS = {
"category": "setting", "category": "setting",
}, },
"SUDO_PASSWORD": { "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", "prompt": "Sudo password",
"url": None, "url": None,
"password": True, "password": True,

View file

@ -2,22 +2,65 @@ import queue
import threading import threading
import time import time
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock from unittest.mock import MagicMock, patch
import cli as cli_module
from cli import HermesCLI 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(): def _make_cli_stub():
cli = HermesCLI.__new__(HermesCLI) cli = HermesCLI.__new__(HermesCLI)
cli._approval_state = None cli._approval_state = None
cli._approval_deadline = 0 cli._approval_deadline = 0
cli._approval_lock = threading.Lock() cli._approval_lock = threading.Lock()
cli._sudo_state = None
cli._sudo_deadline = 0
cli._modal_input_snapshot = None
cli._invalidate = MagicMock() cli._invalidate = MagicMock()
cli._app = SimpleNamespace(invalidate=MagicMock()) cli._app = SimpleNamespace(invalidate=MagicMock(), current_buffer=_FakeBuffer())
return cli return cli
class TestCliApprovalUi: 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): def test_approval_callback_includes_view_for_long_commands(self):
cli = _make_cli_stub() cli = _make_cli_stub()
command = "sudo dd if=/tmp/githubcli-keyring.gpg of=/usr/share/keyrings/githubcli-archive-keyring.gpg bs=4M status=progress" command = "sudo dd if=/tmp/githubcli-keyring.gpg of=/usr/share/keyrings/githubcli-archive-keyring.gpg bs=4M status=progress"

View file

@ -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"

View file

@ -326,7 +326,6 @@ def _prompt_for_sudo_password(timeout_seconds: int = 45) -> str:
if "HERMES_SPINNER_PAUSE" in os.environ: if "HERMES_SPINNER_PAUSE" in os.environ:
del os.environ["HERMES_SPINNER_PAUSE"] del os.environ["HERMES_SPINNER_PAUSE"]
def _safe_command_preview(command: Any, limit: int = 200) -> str: def _safe_command_preview(command: Any, limit: int = 200) -> str:
"""Return a log-safe preview for possibly-invalid command values.""" """Return a log-safe preview for possibly-invalid command values."""
if command is None: if command is None:
@ -338,6 +337,110 @@ def _safe_command_preview(command: Any, limit: int = 200) -> str:
except Exception: except Exception:
return f"<{type(command).__name__}>" 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]: def _transform_sudo_command(command: str | None) -> tuple[str | None, str | None]:
""" """
@ -374,41 +477,27 @@ def _transform_sudo_command(command: str | None) -> tuple[str | None, str | None
Command runs as-is (fails gracefully with "sudo: a password is required"). Command runs as-is (fails gracefully with "sudo: a password is required").
""" """
global _cached_sudo_password global _cached_sudo_password
import re
# Check if command even contains sudo
if command is None: if command is None:
return None, 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): has_configured_password = "SUDO_PASSWORD" in os.environ
return command, None # No sudo in command, nothing to do 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 if not has_configured_password and not sudo_password and os.getenv("HERMES_INTERACTIVE"):
sudo_password = os.getenv("SUDO_PASSWORD", "") or _cached_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) sudo_password = _prompt_for_sudo_password(timeout_seconds=45)
if sudo_password: if sudo_password:
_cached_sudo_password = sudo_password # Cache for session _cached_sudo_password = sudo_password
if not sudo_password: if has_configured_password or 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. # Trailing newline is required: sudo -S reads one line for the password.
return transformed, sudo_password + "\n" return transformed, sudo_password + "\n"
return command, None
# Environment classes now live in tools/environments/ # Environment classes now live in tools/environments/
from tools.environments.local import LocalEnvironment as _LocalEnvironment from tools.environments.local import LocalEnvironment as _LocalEnvironment