fix(terminal): show sudo password prompts for paths and env prefixes

This commit is contained in:
Teknium
2026-09-07 02:49:21 -07:00
parent 61d690acf6
commit 9745a7f0f1
3 changed files with 86 additions and 13 deletions
+26
View File
@@ -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
View File
@@ -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
+5 -1
View File
@@ -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`.