fix(terminal): block sudo -S password guessing when SUDO_PASSWORD is not set

Fixes #9590: Block explicit sudo -S (stdin password mode) commands
when the SUDO_PASSWORD environment variable is not configured.

The attack vector: the LLM constructs 'echo guessedpass | sudo -S cmd'
to brute-force sudo passwords, iterates based on sudo's error output
('Sorry, try again').  The existing _transform_sudo_command only
injects -S when SUDO_PASSWORD exists; without it, the LLM's explicit
sudo -S must be treated as a guessing attempt.

Changes:
- Add _check_sudo_stdin_guard() in approval.py: detects sudo -S when
  SUDO_PASSWORD is absent, anchored to command-start positions
  (^ ; && || | etc.) to avoid false positives on literal text
- Integrate into check_all_command_guards() above yolo/mode=off so
  the block is unconditional (like the hardline floor)
- Add 6 tests covering: detection, allow-list, SUDO_PASSWORD bypass,
  integration with check_all_command_guards, yolo non-bypass,
  container backend bypass
This commit is contained in:
OpenClaw Agent 2026-05-09 09:58:54 +08:00 committed by kshitij
parent 494824fb11
commit 9520a1ccdf
2 changed files with 147 additions and 0 deletions

View file

@ -221,6 +221,40 @@ HARDLINE_PATTERNS_COMPILED = [
]
# =========================================================================
# Sudo stdin guard — block password guessing via "sudo -S"
# =========================================================================
# When SUDO_PASSWORD is not configured, any explicit "sudo -S" in the
# command is the LLM piping a guessed password via stdin. This is a
# brute-force attack vector: the model iterates through candidate
# passwords, inspects sudo's "Sorry, try again" output, and refines.
# Treat this as an unconditional block — there is never a legitimate
# reason for the agent to pipe passwords to sudo -S when no password
# has been configured.
_SUDO_STDIN_RE = re.compile(
r'(?:^|[;&|`\n]|&&|\|\||\$\()\s*sudo\s+-S\b',
re.IGNORECASE)
def _check_sudo_stdin_guard(command: str) -> tuple:
"""Detect ``sudo -S`` (stdin password) without configured SUDO_PASSWORD.
When SUDO_PASSWORD is set, ``_transform_sudo_command`` injects ``-S``
internally that path is legitimate and handled elsewhere. This guard
only fires when SUDO_PASSWORD is *not* set, meaning the LLM explicitly
wrote ``sudo -S`` to pipe a guessed password.
Returns:
(is_blocked: bool, description: str | None)
"""
if "SUDO_PASSWORD" in os.environ:
return (False, None)
normalized = _normalize_command_for_detection(command).lower()
if _SUDO_STDIN_RE.search(normalized):
return (True, "sudo password guessing via stdin (sudo -S)")
return (False, None)
def detect_hardline_command(command: str) -> tuple:
"""Check if a command matches the unconditional hardline blocklist.
@ -250,6 +284,20 @@ def _hardline_block_result(description: str) -> dict:
}
def _sudo_stdin_block_result(description: str) -> dict:
"""Build the standard block result for sudo stdin guard."""
return {
"approved": False,
"message": (
f"BLOCKED: {description}. "
"Do not pipe passwords to 'sudo -S' — this is a brute-force "
"attack vector. Set SUDO_PASSWORD in your .env file if the "
"agent needs passwordless sudo, or run the sudo command "
"manually in your own terminal."
),
}
# =========================================================================
# Dangerous command patterns
# =========================================================================
@ -970,6 +1018,17 @@ def check_all_command_guards(command: str, env_type: str,
logger.warning("Hardline block: %s (command: %s)", hardline_desc, command[:200])
return _hardline_block_result(hardline_desc)
# == Sudo stdin guard ==
# Like the hardline floor above, this is unconditional: there is never a
# legitimate reason for the agent to pipe passwords to sudo -S when no
# SUDO_PASSWORD has been configured. This must fire BEFORE the yolo
# check so even yolo/smart approval/mode=off cannot bypass it.
is_sudo_guess, sudo_guess_desc = _check_sudo_stdin_guard(command)
if is_sudo_guess:
logger.warning("Sudo stdin guard block: %s (command: %s)",
sudo_guess_desc, command[:200])
return _sudo_stdin_block_result(sudo_guess_desc)
# --yolo or approvals.mode=off: bypass all approval prompts.
# Gateway /yolo is session-scoped; CLI --yolo remains process-scoped.
approval_mode = _get_approval_mode()