mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-23 05:31:23 +00:00
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:
parent
494824fb11
commit
9520a1ccdf
2 changed files with 147 additions and 0 deletions
|
|
@ -288,3 +288,91 @@ def test_hardline_list_is_small():
|
||||||
f"HARDLINE_PATTERNS has grown to {len(HARDLINE_PATTERNS)} entries; "
|
f"HARDLINE_PATTERNS has grown to {len(HARDLINE_PATTERNS)} entries; "
|
||||||
"only truly unrecoverable commands belong here."
|
"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}"
|
||||||
|
|
|
||||||
|
|
@ -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:
|
def detect_hardline_command(command: str) -> tuple:
|
||||||
"""Check if a command matches the unconditional hardline blocklist.
|
"""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
|
# 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])
|
logger.warning("Hardline block: %s (command: %s)", hardline_desc, command[:200])
|
||||||
return _hardline_block_result(hardline_desc)
|
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.
|
# --yolo or approvals.mode=off: bypass all approval prompts.
|
||||||
# Gateway /yolo is session-scoped; CLI --yolo remains process-scoped.
|
# Gateway /yolo is session-scoped; CLI --yolo remains process-scoped.
|
||||||
approval_mode = _get_approval_mode()
|
approval_mode = _get_approval_mode()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue