From 9520a1ccdfd4d735b9450fe8624c44ff7f54d5fd Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Sat, 9 May 2026 09:58:54 +0800 Subject: [PATCH] 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 --- tests/tools/test_hardline_blocklist.py | 88 ++++++++++++++++++++++++++ tools/approval.py | 59 +++++++++++++++++ 2 files changed, 147 insertions(+) diff --git a/tests/tools/test_hardline_blocklist.py b/tests/tools/test_hardline_blocklist.py index a3a08cd464a..16b88ac1801 100644 --- a/tests/tools/test_hardline_blocklist.py +++ b/tests/tools/test_hardline_blocklist.py @@ -288,3 +288,91 @@ def test_hardline_list_is_small(): f"HARDLINE_PATTERNS has grown to {len(HARDLINE_PATTERNS)} entries; " "only truly unrecoverable commands belong here." ) + + +# ========================================================================= +# Sudo stdin guard — blocks "sudo -S" without SUDO_PASSWORD +# ========================================================================= + +_SUDO_STDIN_BLOCK = [ + "sudo -S whoami", + "echo hunter2 | sudo -S whoami", + "sudo -S -u root whoami", + "sudo -S apt-get install foo", + "echo password | sudo -S systemctl restart nginx", + "sudo -k && sudo -S whoami", +] + +_SUDO_STDIN_ALLOW = [ + # Plain sudo without -S — goes through normal approval + "sudo whoami", + "sudo apt-get update", + "sudo -u root whoami", + # -S flag not attached to sudo + "echo -S hello", + "some_tool -S thing", + # Literal text mention of sudo + "echo 'use sudo -S to pipe passwords'", +] + +_SUDO_STDIN_BLOCK_YOLO = [ + "sudo -S whoami", + "echo hunter2 | sudo -S apt-get install", +] + + +def test_sudo_stdin_guard_detects_without_password(): + """sudo -S is dangerous when SUDO_PASSWORD is not configured.""" + import tools.approval as approval_mod + + for cmd in _SUDO_STDIN_BLOCK: + is_blocked, desc = approval_mod._check_sudo_stdin_guard(cmd) + assert is_blocked, f"expected sudo stdin guard to block {cmd!r}" + assert "sudo" in desc.lower() + + +def test_sudo_stdin_guard_allows_benign_commands(): + """Commands without explicit sudo -S are not blocked.""" + import tools.approval as approval_mod + + for cmd in _SUDO_STDIN_ALLOW: + is_blocked, desc = approval_mod._check_sudo_stdin_guard(cmd) + assert not is_blocked, f"expected sudo stdin guard NOT to block {cmd!r}" + + +def test_sudo_stdin_guard_bypassed_when_password_configured(monkeypatch): + """When SUDO_PASSWORD is set, sudo -S is legitimate (injected by transform).""" + import tools.approval as approval_mod + + monkeypatch.setenv("SUDO_PASSWORD", "testpass") + for cmd in _SUDO_STDIN_BLOCK: + is_blocked, _ = approval_mod._check_sudo_stdin_guard(cmd) + assert not is_blocked, f"with SUDO_PASSWORD set, {cmd!r} should NOT be blocked" + + +def test_sudo_stdin_guard_blocks_via_check_all_command_guards(clean_session): + """Integration: check_all_command_guards returns block for sudo -S.""" + for cmd in _SUDO_STDIN_BLOCK: + result = check_all_command_guards(cmd, "local") + assert result["approved"] is False, f"expected block on {cmd!r}" + # Should NOT be marked as hardline (it's sudo-specific) + assert result.get("hardline") is not True + assert "BLOCKED" in result["message"] + assert "sudo -S" in result["message"].lower() or "sudo password" in result["message"].lower() + + +def test_sudo_stdin_guard_not_blocked_by_yolo(clean_session, monkeypatch): + """yolo/approvals.mode=off must NOT bypass sudo stdin guard.""" + monkeypatch.setenv("HERMES_YOLO_MODE", "1") + + for cmd in _SUDO_STDIN_BLOCK_YOLO: + result = check_all_command_guards(cmd, "local") + assert result["approved"] is False, f"yolo leaked sudo guard on {cmd!r}" + + +def test_sudo_stdin_guard_container_bypass(clean_session): + """Containerized backends still bypass — they can't touch the host.""" + for env in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"): + for cmd in _SUDO_STDIN_BLOCK: + result = check_all_command_guards(cmd, env) + assert result["approved"] is True, f"container {env} should bypass sudo guard on {cmd!r}" diff --git a/tools/approval.py b/tools/approval.py index 068748f6854..9f7c1989a84 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -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()