Files
hermes-agent/tools/terminal_tool_sudo.py

639 lines
23 KiB
Python

"""Sudo password plumbing and shell-command rewrites for the terminal tool: the
per-scope interactive password cache, the /dev/tty prompt, real-sudo tokenizer
(sudo -S -p '' rewrite), NOPASSWD probe, and the compound-background brace-
group rewrite.
Split out of ``tools/terminal_tool.py``; every public/patched name is re-imported there,
so ``tools.terminal_tool.<name>`` keeps resolving (and monkeypatching) as before.
"""
import logging
import os
import platform
import re
import subprocess
import sys
import threading
import time
from utils import env_var_enabled
# Log-record parity with the origin module.
logger = logging.getLogger("tools.terminal_tool")
# Interactive sudo password cache, scoped to the session key when present,
# else callback identity (ACP / CLI), else the current thread — so one
# session can never reuse another's cached password in a long-lived process.
_sudo_password_cache: dict[str, str] = {}
_sudo_password_cache_lock = threading.Lock()
def _get_sudo_password_cache_scope() -> str:
"""Return the cache scope for interactive sudo passwords."""
from tools.terminal_tool import _current_session_key, _get_sudo_password_callback
session_key = _current_session_key()
if session_key:
return f"session:{session_key}"
callback = _get_sudo_password_callback()
if callback is not None:
owner = getattr(callback, "__self__", None)
func = getattr(callback, "__func__", None)
if owner is not None and func is not None:
return f"callback-owner:{id(owner)}:{id(func)}"
return f"callback:{id(callback)}"
return f"thread:{threading.get_ident()}"
def _get_cached_sudo_password() -> str:
"""Return the cached sudo password for the current scope."""
scope = _get_sudo_password_cache_scope()
with _sudo_password_cache_lock:
return _sudo_password_cache.get(scope, "")
def _set_cached_sudo_password(password: str) -> None:
"""Persist a sudo password for the current scope."""
scope = _get_sudo_password_cache_scope()
with _sudo_password_cache_lock:
if password:
_sudo_password_cache[scope] = password
else:
_sudo_password_cache.pop(scope, None)
def _reset_cached_sudo_passwords() -> None:
"""Clear all cached sudo passwords (tests / process teardown)."""
with _sudo_password_cache_lock:
_sudo_password_cache.clear()
def _in_delegated_child_context() -> bool:
"""True while running inside a delegate_task child.
Subagents run on parent-process worker threads and inherit process-wide
``HERMES_INTERACTIVE=1``, which does NOT mean this context can reach the
user: a raw ``/dev/tty`` sudo prompt from a child races the TUI for the
tty and blocks the child for the full timeout. Children must always be
headless for sudo prompting. The ContextVar is set by
``delegated_child_context()`` and propagates via ``copy_context``.
"""
try:
from agent.delegation_context import is_delegated_child_context
return is_delegated_child_context()
except Exception:
return False
def _handle_sudo_failure(output: str, env_type: str) -> str:
"""Append a SUDO_PASSWORD tip when sudo failed in a headless context
(gateway session or delegate_task child); otherwise return *output* as is."""
is_gateway = env_var_enabled("HERMES_GATEWAY_SESSION")
is_delegated_child = _in_delegated_child_context()
if not is_gateway and not is_delegated_child:
return output
sudo_failures = [
"sudo: a password is required",
"sudo: no tty present",
"sudo: a terminal is required",
]
for failure in sudo_failures:
if failure in output:
from hermes_constants import display_hermes_home as _dhh
if is_delegated_child:
return output + (
"\n\n💡 Tip: Subagents cannot prompt for a sudo password. "
f"Add SUDO_PASSWORD to {_dhh()}/.env on the agent machine, "
"or run the command without sudo."
)
return output + f"\n\n💡 Tip: To enable sudo over messaging, add SUDO_PASSWORD to {_dhh()}/.env on the agent machine."
return output
# sudo -S rejects a bad cached/interactive password with these messages.
_SUDO_WRONG_PASSWORD_MARKERS = (
"sudo: authentication failed",
"sudo: incorrect password attempt",
"sudo: maximum 3 incorrect authentication attempts",
"sudo: 3 incorrect password attempts",
)
def _sudo_wrong_password_failure(output: str) -> bool:
"""Return True when sudo rejected a piped password."""
if not output:
return False
lowered = output.lower()
return any(marker in lowered for marker in _SUDO_WRONG_PASSWORD_MARKERS)
def _invalidate_cached_sudo_on_auth_failure(
command: str | None, output: str
) -> bool:
"""Drop a session-cached sudo password after sudo rejects it.
Env-configured ``SUDO_PASSWORD`` is left alone — that is an explicit
operator choice, not an interactive cache entry.
"""
from tools.terminal_tool import _count_real_sudo_invocations, _sudo_wrong_password_failure
if "SUDO_PASSWORD" in os.environ:
return False
if not _sudo_wrong_password_failure(output):
return False
if _count_real_sudo_invocations(command or "") == 0:
return False
if not _get_cached_sudo_password():
return False
_set_cached_sudo_password("")
return True
def _prompt_for_sudo_password(timeout_seconds: int = 45) -> str:
"""Prompt for a sudo password; "" on skip (empty Enter), timeout, or error.
Prefers the CLI-registered callback (prompt_toolkit-integrated); otherwise
reads /dev/tty (msvcrt on Windows) with echo disabled. Time spent waiting
on the human is excluded from tool deadlines via ``human_wait_window``.
"""
from tools.terminal_tool import _get_sudo_password_callback
_sudo_cb = _get_sudo_password_callback()
if _sudo_cb is not None:
try:
from tools.approval import human_wait_window
with human_wait_window():
return _sudo_cb() or ""
except Exception:
return ""
result = {"password": None, "done": False}
def read_password_thread():
"""Read password with echo disabled. Uses msvcrt on Windows, /dev/tty on Unix."""
tty_fd = None
old_attrs = None
try:
if platform.system() == "Windows":
import msvcrt
chars = []
while True:
c = msvcrt.getwch()
if c in {"\r", "\n"}:
break
if c == "\x03":
raise KeyboardInterrupt
chars.append(c)
result["password"] = "".join(chars)
else:
import termios
tty_fd = os.open("/dev/tty", os.O_RDONLY)
old_attrs = termios.tcgetattr(tty_fd)
new_attrs = termios.tcgetattr(tty_fd)
new_attrs[3] = new_attrs[3] & ~termios.ECHO
termios.tcsetattr(tty_fd, termios.TCSAFLUSH, new_attrs)
chars = []
while True:
b = os.read(tty_fd, 1)
if not b or b in {b"\n", b"\r"}:
break
chars.append(b)
result["password"] = b"".join(chars).decode("utf-8", errors="replace")
except (EOFError, KeyboardInterrupt, OSError):
result["password"] = ""
except Exception:
result["password"] = ""
finally:
if tty_fd is not None and old_attrs is not None:
try:
import termios as _termios
_termios.tcsetattr(tty_fd, _termios.TCSAFLUSH, old_attrs)
except Exception as e:
logger.debug("Failed to restore terminal attributes: %s", e)
if tty_fd is not None:
try:
os.close(tty_fd)
except Exception as e:
logger.debug("Failed to close tty fd: %s", e)
result["done"] = True
try:
os.environ["HERMES_SPINNER_PAUSE"] = "1"
time.sleep(0.2)
print()
print("┌" + "─" * 58 + "┐")
print("│ 🔐 SUDO PASSWORD REQUIRED" + " " * 30 + "│")
print("├" + "─" * 58 + "┤")
print("│ Enter password below (input is hidden), or: │")
print("│ • Press Enter to skip (command fails gracefully) │")
print(f"│ • Wait {timeout_seconds}s to auto-skip" + " " * 27 + "│")
print("└" + "─" * 58 + "┘")
print()
print(" Password (hidden): ", end="", flush=True)
password_thread = threading.Thread(target=read_password_thread, daemon=True)
password_thread.start()
from tools.approval import human_wait_window
with human_wait_window():
password_thread.join(timeout=timeout_seconds)
if result["done"]:
password = result["password"] or ""
print() # newline after hidden input
if password:
print(" ✓ Password received (cached for this session)")
else:
print(" ⏭ Skipped - continuing without sudo")
print()
sys.stdout.flush()
return password
else:
print("\n ⏱ Timeout - continuing without sudo")
print(" (Press Enter to dismiss)")
print()
sys.stdout.flush()
return ""
except (EOFError, KeyboardInterrupt):
print()
print(" ⏭ Cancelled - continuing without sudo")
print()
sys.stdout.flush()
return ""
except Exception as e:
print(f"\n [sudo prompt error: {e}] - continuing without sudo\n")
sys.stdout.flush()
return ""
finally:
if "HERMES_SPINNER_PAUSE" in os.environ:
del os.environ["HERMES_SPINNER_PAUSE"]
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, int]:
"""Rewrite only real unquoted sudo command words, not plain text mentions.
Walks the command with the shared tokenizer, tracking whether the cursor
sits at a command-start position (after newline, ``;``/``|``/``&``/``(``,
``&&``/``||``/``;;``, or a leading ``VAR=val`` assignment); comments at
command start are copied through verbatim. Returns the rewritten command
and the number of sudo invocations rewritten.
"""
out: list[str] = []
i = 0
n = len(command)
command_start = True
sudo_count = 0
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):
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 ''")
sudo_count += 1
else:
out.append(token)
command_start = bool(command_start and _looks_like_env_assignment(token))
i = next_i
return "".join(out), sudo_count
def _count_real_sudo_invocations(command: str) -> int:
"""Return how many real sudo command words appear in *command*."""
return _rewrite_real_sudo_invocations(command)[1]
def _sudo_nopasswd_works() -> bool:
"""True when local sudo currently works without prompting.
Local backend only — Docker/SSH/Modal must not inherit host sudo state.
Re-probes every call (no cache) so an expired sudo timestamp can't make a
later command silently block waiting for a password.
"""
from tools.terminal_tool import _tenv
terminal_env = _tenv("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.
Bash binds `&&` tighter than `&`, so `A && B &` backgrounds a subshell
that runs B in the foreground and waits for it; a long-running B leaves
that subshell stuck in ``wait4`` forever, and its open stdout pipe can
keep the terminal tool from returning. The brace group keeps `&&`'s
skip-B-on-failure semantics without a fork: bash backgrounds B as a
simple command and exits immediately, orphaning B normally.
Handles redirects (``&>``, ``2>&1``) and skips quoted strings and
parenthesised subshells. Simple ``cmd &`` is left alone — it doesn't
have the subshell-wait bug.
"""
n = len(command)
i = 0
paren_depth = 0
brace_depth = 0
# Position in *command* just after the most recent `&&` / `||` at depth 0
# in the current statement; -1 when no chain operator is active.
last_chain_op_end = -1
rewrites: list[tuple[int, int]] = [] # (chain_op_end, amp_pos)
while i < n:
ch = command[i]
# Newline terminates a statement at depth 0 — reset chain state.
# Checked before the whitespace skip so we don't miss it.
if ch == "\n" and paren_depth == 0 and brace_depth == 0:
last_chain_op_end = -1
i += 1
continue
if ch.isspace():
i += 1
continue
# Comments (only at statement start — conservative: any `#` not inside
# a token ends the line). `_read_shell_token` handles quoted strings
# below so `#` inside quotes is safe.
if ch == "#":
nl = command.find("\n", i)
if nl == -1:
break
i = nl
continue
if ch == "\\" and i + 1 < n:
i += 2
continue
# Quoted tokens — consume whole string via the shared tokenizer.
if ch in {"'", '"'}:
_, next_i = _read_shell_token(command, i)
i = max(next_i, i + 1)
continue
if ch == "(":
paren_depth += 1
i += 1
continue
if ch == ")":
paren_depth = max(0, paren_depth - 1)
i += 1
continue
# Brace groups: `{ ... }` is a group (no subshell fork), and bash
# requires whitespace after `{`. We track depth so already-rewritten
# output (`A && { B & }`) is idempotent — the inner `&` is part of
# the group, not a new compound to rewrite. Also skip content inside
# the group since `A && B &` there is separately well-formed.
if ch == "{" and i + 1 < n and (command[i + 1].isspace() or command[i + 1] == "\n"):
brace_depth += 1
i += 1
continue
if ch == "}" and brace_depth > 0:
brace_depth -= 1
# Closing a group completes a compound statement; reset chain.
last_chain_op_end = -1
i += 1
continue
# Inside parens or brace groups, skip operators — they parse in their
# own scope. `(...)` subshells have the same bug class but are not the
# common agent pattern; leave for a follow-up.
if paren_depth > 0 or brace_depth > 0:
i += 1
continue
# Chain operators at depth 0
if command.startswith("&&", i) or command.startswith("||", i):
last_chain_op_end = i + 2
i += 2
continue
# Statement terminators reset the chain state
if ch == ";":
last_chain_op_end = -1
i += 1
continue
# Single `|` (pipe) starts a new pipeline stage; don't rewrite
# across it. `||` handled above.
if ch == "|":
last_chain_op_end = -1
i += 1
continue
# `&` handling: distinguish `&&`, `&>`, fd redirect (`>&`, `<&`),
# and a true backgrounding `&`.
if ch == "&":
# `&&` handled above; won't reach here
if i + 1 < n and command[i + 1] == ">":
# `&>` redirect — consume
i += 2
continue
# `>&` / `<&` fd target — look back past whitespace
j = i - 1
while j >= 0 and command[j].isspace():
j -= 1
if j >= 0 and command[j] in "<>":
i += 1
continue
# Real background operator
if last_chain_op_end >= 0:
rewrites.append((last_chain_op_end, i))
last_chain_op_end = -1
i += 1
continue
# Regular unquoted token — advance past it via the shared tokenizer
_, next_i = _read_shell_token(command, i)
i = max(next_i, i + 1)
if not rewrites:
return command
# Apply rewrites back-to-front so earlier indices remain valid.
result = command
for chain_end, amp_pos in reversed(rewrites):
# Skip whitespace right after the `&&`/`||` so the brace group
# opens flush against the inner command.
insert_pos = chain_end
while insert_pos < amp_pos and result[insert_pos].isspace():
insert_pos += 1
prefix = result[:insert_pos]
middle = result[insert_pos:amp_pos] # inner command + trailing space
suffix = result[amp_pos + 1 :]
# `{` needs a trailing space in bash; the closing `}` needs to be
# preceded by `;` or `&` — we're providing `&` from the backgrounding.
result = prefix + "{ " + middle + "& }" + suffix
return result
def _transform_sudo_command(command: str | None) -> tuple[str | None, str | None]:
"""Rewrite bare ``sudo`` to ``sudo -S -p ''`` when a password is available.
Shared by every execution environment. Returns ``(command, sudo_stdin)``:
``sudo_stdin`` is one password line per sudo invocation that the caller
must PREPEND to the process stdin (sudo -S consumes exactly one line and
passes the rest through, so it's safe alongside the caller's own
stdin_data). Backends that can't pipe stdin (modal, daytona,
vercel_sandbox) embed the password in the command string themselves.
With no password available the command is returned unchanged and
``sudo_stdin`` is None, so it fails gracefully with
"sudo: a password is required". Password sources, in order: configured
SUDO_PASSWORD, the session cache, then an interactive prompt (45s
timeout, cached on success) when a UI is reachable.
"""
from tools.terminal_tool import _get_sudo_password_callback, _prompt_for_sudo_password, _sudo_nopasswd_works
if command is None:
return None, None
transformed, sudo_count = _rewrite_real_sudo_invocations(command)
if sudo_count == 0:
return command, None
# Scope-aware read: under multiplex the process env may hold another
# profile's SUDO_PASSWORD; unscoped callers keep the os.environ read.
try:
from agent.secret_scope import UnscopedSecretError, get_secret
try:
_configured_password = get_secret("SUDO_PASSWORD")
except UnscopedSecretError:
_configured_password = os.environ.get("SUDO_PASSWORD")
except Exception:
_configured_password = os.environ.get("SUDO_PASSWORD")
has_configured_password = _configured_password is not None
sudo_password = (
_configured_password
if has_configured_password
else _get_cached_sudo_password()
)
# sudoers NOPASSWD hosts must not be forced through the prompt or the
# -S password pipe (local backend only; re-probed every call).
if not has_configured_password and not sudo_password and _sudo_nopasswd_works():
return command, None
has_sudo_prompt_callback = _get_sudo_password_callback() is not None
# delegate_task children inherit HERMES_INTERACTIVE=1 (and possibly a
# stale thread-local callback on a recycled worker) but have no user on
# the other side — they always behave as headless; configured password,
# session cache and the NOPASSWD probe still apply.
should_prompt_for_sudo = (
env_var_enabled("HERMES_INTERACTIVE") or has_sudo_prompt_callback
) and not _in_delegated_child_context()
if not has_configured_password and not sudo_password and should_prompt_for_sudo:
sudo_password = _prompt_for_sudo_password(timeout_seconds=45)
if sudo_password:
_set_cached_sudo_password(sudo_password)
if has_configured_password or sudo_password:
# Trailing newline is required: sudo -S reads one line per invocation.
# Compound commands (`sudo a && sudo b`) need one password line each.
password_line = sudo_password + "\n"
return transformed, password_line * sudo_count
return command, None