fix(terminal): show sudo password prompts for paths and env prefixes
This commit is contained in:
@@ -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
|
||||
|
||||
+55
-12
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user