diff --git a/tests/tools/test_terminal_tool.py b/tests/tools/test_terminal_tool.py index a8551ddd77..1f36223b29 100644 --- a/tests/tools/test_terminal_tool.py +++ b/tests/tools/test_terminal_tool.py @@ -104,6 +104,32 @@ def test_validate_workdir_still_blocks_metachars_in_unicode_paths(): assert terminal_tool._validate_workdir("/tmp/ΓΌ\x00ber") +def test_literal_sudo_executables_receive_password_stdin(monkeypatch): + monkeypatch.setenv("SUDO_PASSWORD", "testpass") + for prefix in ("", "VAR='a b' ", "env ", "'/usr/bin/env' -i -u UNUSED X=1 ", + "env --unset=UNUSED --chdir /tmp -- X=1 ", "env -uUNUSED -C/tmp "): + for executable in ("sudo", "/usr/bin/sudo", "'/opt/my tools/sudo'", '"/usr/bin/sudo"'): + command = prefix + executable + " -u root true" + rewritten, stdin = terminal_tool_sudo._transform_sudo_command(command) + assert rewritten == prefix + executable + " -S -p '' -u root true" + assert stdin == "testpass\n" + + +def test_sudo_rewrite_preserves_env_operands_and_prose(monkeypatch): + monkeypatch.setenv("SUDO_PASSWORD", "testpass") + commands = ( + "echo '/usr/bin/sudo true'", "env echo sudo true", "env -u sudo echo ok", + "env --chdir sudo echo ok", "env --unset=sudo echo ok", "env -- sudo=1 echo ok", + ">/tmp/sudo echo ok", "env 2>/tmp/sudo echo ok", "env > /tmp/sudo echo ok", + "/tmp/{a,b}/sudo true", "env X=1 -u UNUSED sudo", "env - -u UNUSED sudo", "env echo /usr/bin/sudo", "/tmp/*/sudo true", + "env -S 'sudo true'", "env --unknown sudo true", "env --help sudo", + "bash -c 'sudo true'", "echo ok # prose; /usr/bin/sudo true", + '"/usr/bin/sudo', "env -u sudo", "env X=sudo", '"X=1" /usr/bin/sudo true', + ) + for command in commands: + assert terminal_tool_sudo._transform_sudo_command(command) == (command, None) + + def test_count_real_sudo_invocations_ignores_mentions(monkeypatch): assert terminal_tool_sudo._count_real_sudo_invocations("grep sudo README.md") == 0 assert terminal_tool_sudo._count_real_sudo_invocations("sudo a; sudo b") == 2 diff --git a/tools/terminal_tool_sudo.py b/tools/terminal_tool_sudo.py index 7ee30966f2..5ab2be5026 100644 --- a/tools/terminal_tool_sudo.py +++ b/tools/terminal_tool_sudo.py @@ -8,6 +8,7 @@ import logging import os import platform import re +import shlex import subprocess import sys import threading @@ -255,10 +256,8 @@ def _scan_shell(command: str, background: bool = False) -> Iterator[tuple[str, i 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 - only at command start (after newline, an operator, or a leading ``VAR=val``) in sudo mode. - Background mode (compound-background semantics) additionally opens comments anywhere - outside a token, emits ``escape`` for a bare ``\\x``, ops ``&>``, ``{ `` and a closing + ``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. """ @@ -273,7 +272,7 @@ def _scan_shell(command: str, background: bool = False) -> Iterator[tuple[str, i if ch.isspace(): kind, end = ("skip" if grouped else "ws"), i + 1 at_start = at_start or ch == "\n" - elif ch == "#" and (background or at_start): + elif ch == "#": end = command.find("\n", i) kind, end = "comment", (n if end == -1 else end) elif background and ch == "\\" and i + 1 < n: @@ -300,17 +299,61 @@ def _scan_shell(command: str, background: bool = False) -> Iterator[tuple[str, i def _rewrite_real_sudo_invocations(command: str) -> tuple[str, int]: - """Rewrite only real unquoted sudo command words (at a command-start position, see - ``_scan_shell``), not plain text mentions; comments are copied through verbatim. - Returns the rewritten command and the number of sudo invocations rewritten.""" + """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] - if kind == "word" and at_start and text == "sudo": - text = "sudo -S -p ''" - sudo_count += 1 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 @@ -392,7 +435,7 @@ def _rewrite_compound_background(command: str) -> str: 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 + """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 diff --git a/website/docs/user-guide/features/tools.md b/website/docs/user-guide/features/tools.md index 849e137834..ebf2adc7ce 100644 --- a/website/docs/user-guide/features/tools.md +++ b/website/docs/user-guide/features/tools.md @@ -233,7 +233,11 @@ PTY mode (`pty=true`) enables interactive CLI tools like Codex and Claude Code. ## Sudo Support -If a command needs sudo, you'll be prompted for your password (cached for the session). Or set `SUDO_PASSWORD` in `~/.hermes/.env`. +On an interactive parent session, supported sudo commands use the masked password prompt (cached for the session). This includes literal absolute or quoted executable paths and `env` prefixes with ordinary options and assignments, such as `env -u UNUSED /usr/bin/sudo id`. Passwordless sudo does not need a prompt. You can also configure `SUDO_PASSWORD` in your profile's `.env` file on the agent machine. + +Shell payloads such as `bash -c 'sudo id'`, `env -S` split strings, dynamic executable paths, and unrecognized `env` options are not interpreted by the password rewriter. Invoke sudo directly when you need the interactive prompt. This handling does not change approval rules or the guard against agent-supplied sudo passwords. + +Delegated subagents cannot open a password prompt: their concurrent work does not have a serialized human password channel. Run the command in the parent session instead, or provision `SUDO_PASSWORD` locally. Messaging/headless sessions do not have a secure password reply channel; never send passwords in chat. :::warning On messaging platforms, if sudo fails, the output includes a tip to add `SUDO_PASSWORD` to `~/.hermes/.env`.