"""Sudo password plumbing and shell-command rewrites for the terminal tool: per-scope password cache, /dev/tty prompt, the quote-aware shell scanner behind the real-sudo rewrite (``sudo -S -p ''``) and the compound-background brace-group rewrite, and the NOPASSWD probe. Split out of ``tools/terminal_tool.py``; every public/patched name is re-imported there so ``tools.terminal_tool.`` keeps resolving (and monkeypatching) as before.""" import logging import os import platform import re import shlex import subprocess import sys import threading import time from collections.abc import Callable, Iterator from contextvars import ContextVar 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() # Only populated while invoking a UI callback; preserve the zero-argument callback API. _sudo_prompt_command: ContextVar[str] = ContextVar("sudo_prompt_command", default="") def get_sudo_prompt_command() -> str: """Original command for the current sudo password prompt, or '' outside its callback.""" return _sudo_prompt_command.get() 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 None: return f"thread:{threading.get_ident()}" 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)}" 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 ("" drops the entry).""" 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 they 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, so children are always 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 _SUDO_HEADLESS_FAILURES = ("sudo: a password is required", "sudo: no tty present", "sudo: a terminal is required") 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 or is_delegated_child) or not any(f in output for f in _SUDO_HEADLESS_FAILURES): return 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." # 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.""" lowered = (output or "").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 — an explicit operator choice, not a cache entry.""" if ( "SUDO_PASSWORD" in os.environ or not _sudo_wrong_password_failure(output) or _count_real_sudo_invocations(command or "") == 0 or not _get_cached_sudo_password() ): return False _set_cached_sudo_password("") return True def _release_tty(tty_fd, old_attrs) -> None: """Restore echo and close the /dev/tty fd opened by the password reader (best effort).""" if tty_fd is None: return if 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) try: os.close(tty_fd) except Exception as e: logger.debug("Failed to close tty fd: %s", e) def _read_hidden_password(result: dict) -> None: """Read one line with echo disabled into ``result``. Uses msvcrt on Windows, /dev/tty on Unix.""" tty_fd = old_attrs = None try: chars = [] if platform.system() == "Windows": import msvcrt while (c := msvcrt.getwch()) not in {"\r", "\n"}: 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) while (b := os.read(tty_fd, 1)) and b not in {b"\n", b"\r"}: chars.append(b) result["password"] = b"".join(chars).decode("utf-8", errors="replace") except (KeyboardInterrupt, Exception): result["password"] = "" finally: _release_tty(tty_fd, old_attrs) result["done"] = True def _prompt_for_sudo_password(timeout_seconds: int = 45, *, command: str = "") -> 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. Human wait time is excluded from tool deadlines (``human_wait_window``).""" from tools.terminal_tool import _get_sudo_password_callback _sudo_cb = _get_sudo_password_callback() if _sudo_cb is not None: token = _sudo_prompt_command.set(command) try: from tools.approval_human_wait import human_wait_window with human_wait_window(): return _sudo_cb() or "" except Exception: return "" finally: _sudo_prompt_command.reset(token) result = {"password": None, "done": False} try: os.environ["HERMES_SPINNER_PAUSE"] = "1" time.sleep(0.2) print("\n".join(( "", "ā”Œ" + "─" * 58 + "┐", "│ šŸ” SUDO PASSWORD REQUIRED" + " " * 30 + "│", "ā”œ" + "─" * 58 + "┤", "│ Enter password below (input is hidden), or: │", "│ • Press Enter to skip (command fails gracefully) │", f"│ • Wait {timeout_seconds}s to auto-skip" + " " * 27 + "│", "ā””" + "─" * 58 + "ā”˜", "", ))) print(" Password (hidden): ", end="", flush=True) password_thread = threading.Thread(target=_read_hidden_password, args=(result,), daemon=True) password_thread.start() from tools.approval_human_wait import human_wait_window with human_wait_window(): password_thread.join(timeout=timeout_seconds) if not result["done"]: print("\n ā± Timeout - continuing without sudo\n (Press Enter to dismiss)\n") sys.stdout.flush() return "" password = result["password"] or "" # Newline after the hidden input, then the outcome line. if password: print("\n āœ“ Password received (cached for this session)\n") else: print("\n ā­ Skipped - continuing without sudo\n") sys.stdout.flush() return password except (EOFError, KeyboardInterrupt): print("\n ā­ Cancelled - continuing without sudo\n") sys.stdout.flush() return "" except Exception as e: print(f"\n [sudo prompt error: {e}] - continuing without sudo\n") sys.stdout.flush() return "" finally: os.environ.pop("HERMES_SPINNER_PAUSE", None) 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)) # One shell word: single-/double-quoted runs (unterminated ok; backslash escapes inside double # quotes), a backslash escape (a trailing lone `\` is a plain char), or any non-`;|&()`/space char. _SHELL_WORD_RE = re.compile(r"""(?:'[^']*'?|"(?:\\.|[^"])*"?|\\.|[^\s;|&()])*""", re.DOTALL) def _read_shell_token(command: str, start: int) -> tuple[str, int]: """Read one shell token, preserving quotes/escapes, starting at *start*.""" end = _SHELL_WORD_RE.match(command, start).end() # type: ignore[union-attr] (`*` always matches) return command[start:end], end def _scan_shell(command: str, background: bool = False) -> Iterator[tuple[str, int, int, bool]]: """Quote-aware shell scanner shared by the sudo and compound-background rewriters. Yields ``(kind, start, end, at_command_start)`` events that tile *command* exactly: ``ws`` (one whitespace char), ``comment`` (``#`` up to, not including, the newline), ``op`` (``&& || ;; ; | & ( )``), ``word`` (one ``_read_shell_token`` token). Comments open at word boundaries. Background mode (compound-background semantics) additionally emits ``escape`` for a bare ``\\x``, ops ``&>``, ``{ `` and a closing ``}``, and tracks ``(...)``/``{ ... }`` depth: inside a group nothing is an operator, so every non-structural char (whitespace included) surfaces as a single ``skip`` event. """ i, n = 0, len(command) at_start = True parens = braces = 0 two_char_ops = ("&&", "||", "&>") if background else ("&&", "||", ";;") while i < n: ch = command[i] grouped = parens or braces was_start = at_start if ch.isspace(): kind, end = ("skip" if grouped else "ws"), i + 1 at_start = at_start or ch == "\n" elif ch == "#": end = command.find("\n", i) kind, end = "comment", (n if end == -1 else end) elif background and ch == "\\" and i + 1 < n: kind, end = "escape", i + 2 elif background and ch in "'\"": kind, end = "word", _read_shell_token(command, i)[1] elif background and ( ch in "()" or (ch == "{" and i + 1 < n and command[i + 1].isspace()) or (ch == "}" and braces) ): parens = max(0, parens + (ch == "(") - (ch == ")")) braces += (ch == "{") - (ch == "}") kind, end = "op", i + 1 elif background and grouped: kind, end = "skip", i + 1 elif command.startswith(two_char_ops, i): kind, end, at_start = "op", i + 2, True elif ch in ";|&()": kind, end, at_start = "op", i + 1, ch != ")" else: token, end = _read_shell_token(command, i) kind, at_start = "word", bool(at_start and _looks_like_env_assignment(token)) yield kind, i, end, was_start i = end def _rewrite_real_sudo_invocations(command: str) -> tuple[str, int]: """Rewrite literal sudo executable words, preserving their spelling and arguments. Follow ordinary env options/assignments, not shell payloads or env split strings: interpreting those requires a second parser and rewriting inside another quoting layer. """ out: list[str] = [] sudo_count = 0 in_env = env_operand = env_options = False value_options = {"-u", "--unset", "-C", "--chdir", "-a", "--argv0"} flag_options = {"-i", "--ignore-environment", "-0", "--null", "-v", "--debug"} for kind, start, end, at_start in _scan_shell(command): text = command[start:end] out.append(text) if kind == "op" or (kind == "ws" and text == "\n"): in_env = env_operand = env_options = False if kind != "word" or not (at_start or in_env): continue try: words = shlex.split(text) except ValueError: in_env = False continue # Redirections and expansions are not literal executable paths. if len(words) != 1 or any(char in text for char in "$`*?[]~<>{}"): in_env = False continue word = words[0] if in_env: if env_operand: env_operand = False continue if env_options and word in {"-", "--"}: env_options = False continue if env_options and word in value_options: env_operand = True continue if env_options and (word in flag_options or any(word.startswith(opt + "=") for opt in value_options if opt.startswith("--")) or any(word.startswith(opt) and len(word) > 2 for opt in ("-u", "-C", "-a"))): continue if env_options and word.startswith("-"): in_env = False continue if _looks_like_env_assignment(word): env_options = False continue elif _looks_like_env_assignment(text): continue executable = word.rsplit("/", 1)[-1] in_env = executable == "env" env_options = in_env if executable == "sudo": out[-1] += " -S -p ''" sudo_count += 1 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 _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. Redirects (``&>``, ``2>&1``), quoted strings, comments and ``(...)``/``{ ... }`` bodies never count as the backgrounding ``&`` (see ``_scan_shell``); tracking brace depth also makes the rewrite idempotent. `(...)` subshells have the same bug class but are not the common agent pattern; left for a follow-up. Simple ``cmd &`` is left alone — it doesn't have the subshell-wait bug.""" chain_end = -1 # just after the last depth-0 `&&`/`||` of this statement; -1 = none active rewrites: list[tuple[int, int]] = [] # (chain_op_end, amp_pos) for kind, start, end, _ in _scan_shell(command, background=True): text = command[start:end] if kind == "op" and text in ("&&", "||"): chain_end = end elif kind == "ws" and text == "\n" or kind == "op" and text in (";", "|", "}"): # Newline / `;` end a statement, `|` starts a pipeline stage, `}` closes a group. chain_end = -1 elif kind == "op" and text == "&": # `&&` and `&>` never reach here; a `>&` / `<&` fd target (look back past # whitespace) is a redirect, anything else is the real background operator. j = start - 1 while j >= 0 and command[j].isspace(): j -= 1 if j >= 0 and command[j] in "<>": continue if chain_end >= 0: rewrites.append((chain_end, start)) chain_end = -1 # 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. `{` needs a trailing space in bash; the closing `}` needs to be # preceded by `;` or `&` — we're providing `&` from the backgrounding. insert_pos = chain_end while insert_pos < amp_pos and result[insert_pos].isspace(): insert_pos += 1 # The consumed `&` also separated the compound from any statement that followed # on the same line (`A && B & C`); `{ B & } C` is a syntax error, so restore a `;` # when the suffix resumes with command text. No separator when the suffix already # starts with a terminator (`;` `&` `|` newline `)` `}`) — except `&>`, which is a # redirect prefix for the NEXT command, not a terminator. Strip only spaces/tabs: # a newline already terminates the group. suffix = result[amp_pos + 1 :] tail = suffix.lstrip(" \t") needs_separator = bool(tail) and (tail[0] not in ";\n&|)}" or tail.startswith("&>")) separator = " ;" if needs_separator else "" result = result[:insert_pos] + "{ " + result[insert_pos:amp_pos] + "& }" + separator + suffix return result def _transform_sudo_command( command: str | None, sudo_nopasswd_check: Callable[[], bool] | None = None, ) -> tuple[str | None, str | None]: """Rewrite command-position ``sudo`` executables 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. ``sudo_nopasswd_check`` (supplied by ``BaseEnvironment``) runs ``sudo -n true`` inside the selected backend; a True result skips the prompt and the ``-S`` rewrite entirely.""" from tools.terminal_tool import _get_sudo_password_callback 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 (UnscopedSecretError) keep the os.environ read. try: from agent.secret_scope import get_secret _configured_password = get_secret("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() # 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 — always headless; # configured password and session cache still apply. should_prompt_for_sudo = ( env_var_enabled("HERMES_INTERACTIVE") or _get_sudo_password_callback() is not None ) and not _in_delegated_child_context() if not has_configured_password and not sudo_password and should_prompt_for_sudo: # sudoers NOPASSWD must not be forced through the prompt or the -S pipe. The probe is # a round trip on the selected backend (an ssh exec for SSH), so it only runs when a # prompt would otherwise fire: headless callers end up at ``(command, None)`` either # way. Re-probed every call so an expired sudo timestamp cannot silently block. if sudo_nopasswd_check is not None and sudo_nopasswd_check(): return command, None sudo_password = _prompt_for_sudo_password(timeout_seconds=45, command=command) if sudo_password: _set_cached_sudo_password(sudo_password) if has_configured_password or sudo_password: # sudo -S reads one line per invocation: compound `sudo a && sudo b` needs one line each. return transformed, (sudo_password + "\n") * sudo_count return command, None