mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix: handle empty sudo password and false prompts
This commit is contained in:
parent
a94099908a
commit
e22416dd9b
6 changed files with 293 additions and 35 deletions
|
|
@ -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
34
cli.py
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
90
tests/tools/test_terminal_tool.py
Normal file
90
tests/tools/test_terminal_tool.py
Normal 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"
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue