sudo password handling safety and guardrails
NousResearch/hermes-agent · cluster 9590 · skills
Data as of
Nothing open on GitHub. Fix merged in #23736. Check it covers your case.
The short answer
- Known?
- Yes. 5 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #23736. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #23736. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and PRs address unsafe or incorrect sudo password behavior, including blocking brute-force guessing via sudo -S, handling NOPASSWD cases, and implementing credential safety guardrails across terminal and desktop environments.
Open issues0
Open PRs0
Fixes on record
-
#23736 fix(approval): sudo-S brute-force block + sudo-stdin/askpass DANGEROUS (salvage of #22194 + #21128)
files:
scripts/release.py,tests/tools/test_approval.py,tests/tools/test_hardline_blocklist.py,tools/approval.pyShow the diff
-
scripts/release.py@@ -977,6 +977,8 @@ "[redacted:email]": "dmnkhorvath", # salvage of #23358 (kanban worker send_message) "[redacted:email]": "smwbev", # salvage of #23659 (aria-label colLabel) "[redacted:email]": "TurgutKural", # salvage of #23356 (HERMES_HOME inject) + "[redacted:email]": "29206394", # PR #22194 salvage (sudo -S brute-force guard, #9590) + "[redacted:email]": "fr33d3m0n", # PR #21128 salvage (sudo stdin/askpass DANGEROUS, #17873 cat 4) } -
tests/tools/test_approval.py@@ -965,3 +965,140 @@ def cb(command, description, **kwargs): assert result == "once" finally: ptc.get_app_or_none = orig + + +class TestDetectSudoStdin: + """Sudo with stdin / askpass / shell / list-privileges flags (#17873 cat 4). + + An LLM-driven agent has no TTY, so the sudo invocations that succeed + without human interaction are those reading the password from stdin + (-S / --stdin) or via an askpass helper (-A / --askpass). The + shell-launch (-s) and list-privileges (-a) flags are also gated since + they are privilege-relevant invocations the agent can chain after + acquiring the password. + + `_normalize_command_for_detection` lowercases input before pattern + matching, so -S/-s and -A/-a are indistinguishable at the regex + layer; both letter-pairs are gated. + """ + + # Positive cases (must match) + + def test_canonical_pipe_to_sudo_S_detected(self): + is_dangerous, _, desc = detect_dangerous_command( + "echo pwd | sudo -S whoami" + ) + assert is_dangerous is True + assert "sudo" in desc.lower() + + def test_long_flag_stdin_detected(self): + is_dangerous, _, _ = detect_dangerous_command("sudo --stdin id") + assert is_dangerous is True + + def test_non_interactive_plus_stdin_detected(self): + is_dangerous, _, _ = detect_dangerous_command("sudo -n -S id") + assert is_dangerous is True + + def test_user_then_stdin_detected(self): + # Codex audit caught that the original "leading flags only" regex + # missed this form because `-u root` has a flag-argument (`root`) + # that broke the (?:\s+-[^\s]+)* loop. The lazy [^;|&\n]*? class + # consumes flag-args without spanning command separators. + is_dangerous, _, _ = detect_dangerous_command( + "sudo -u root -S whoami" + ) + assert is_dangerous is True + + def test_long_non_interactive_plus_stdin_detected(self): + is_dangerous, _, _ = detect_dangerous_command( + "sudo --non-interactive -S whoami" + ) + assert is_dangerous is True + + def test_long_user_equals_stdin_detected(self): + is_dangerous, _, _ = detect_dangerous_command( + "sudo --user=root -S id" + ) + assert is_dangerous is True + + def test_herestring_input_detected(self): + is_dangerous, _, _ = detect_dangerous_command( + "sudo -S id <<< 'mypwd'" + ) + assert is_dangerous is True + + def test_combined_short_flags_nS_detected(self): + # `-nS` packs `-n` and `-S` into one arg; second pattern catches. + is_dangerous, _, _ = detect_dangerous_command("sudo -nS id") + assert is_dangerous is True + + def test_printf_form_detected(self): + is_dangerous, _, _ = detect_dangerous_command( + 'printf "%s\\n" "$PW" | sudo -S id' + ) + assert is_dangerous is True + + def test_askpass_short_flag_detected(self): + is_dangerous, _, _ = detect_dangerous_command("sudo -A id") + assert is_dangerous is True + + def test_askpass_long_flag_detected(self): + is_dangerous, _, _ = detect_dangerous_command("sudo --askpass id") + assert is_dangerous is True + + def test_two_sudo_invocations_second_caught(self): + # The first sudo here is benign (no -S); the second has -S. + # Lazy [^;|&\n]*? does NOT span past `;`, so re.search anchors + # on the second sudo invocation independently. + is_dangerous, _, _ = detect_dangerous_command( + "sudo whoami; sudo -S id" + ) + assert is_dangerous is True + + # Negative cases (must NOT match) + + def test_plain_sudo_safe(self): + is_dangerous, _, _ = detect_dangerous_command("sudo whoami") + assert is_dangerous is False + + def test_sudo_interactive_shell_safe(self): + is_dangerous, _, _ = detect_dangerous_command("sudo -i") + assert is_dangerous is False + + def test_sudo_with_user_no_stdin_flag_safe(self): + is_dangerous, _, _ = detect_dangerous_command("sudo -u root -i") + assert is_dangerous is False + + def test_man_sudo_safe(self): + is_dangerous, _, _ = detect_dangerous_command("man sudo") + assert is_dangerous is False + + def test_which_sudo_safe(self): + is_dangerous, _, _ = detect_dangerous_command("which sudo") + assert is_dangerous is False + + def test_sudo_user_env_reference_safe(self): + is_dangerous, _, _ = detect_dangerous_command( + "echo SUDO_USER=$SUDO_USER" + ) + assert is_dangerous is False + + def test_apt_install_sudo_safe(self): + is_dangerous, _, _ = detect_dangerous_command("apt install sudo") + assert is_dangerous is False + + def test_ls_etc_sudoers_safe(self): + is_dangerous, _, _ = detect_dangerous_command("ls /etc/sudoers") + assert is_dangerous is False + + def test_pseudosudo_safe_word_boundary(self): + # `\bsudo\b` requires a word boundary; `pseudosudo` has none + # before `sudo`, so should not trigger. + is_dangerous, _, _ = detect_dangerous_command("pseudosudo -S id") + assert is_dangerous is False + + def test_unrelated_redirection_safe(self): + is_dangerous, _, _ = detect_dangerous_command( + "make 2>&1 | tee build.log" + ) + assert is_dangerous is False -
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}" -
tools/approval.py@@ -221,6 +221,40 @@ def _is_gateway_approval_context() -> bool: ] +# ========================================================================= +# 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 # ========================================================================= @@ -320,6 +368,25 @@ def _hardline_block_result(description: str) -> dict: # a script is first made executable then immediately run. The script # content may contain dangerous commands that individual patterns miss. (r'\bchmod\s+\+x\b.*[;&|]+\s*\./', "chmod +x followed by immediate execution"), + # Sudo with stdin / askpass / shell / list-privs flags. An LLM-driven + # agent has no TTY, so sudo invocations that succeed without human + # interaction are those reading the password from stdin (-S/--stdin) + # or via an askpass helper (-A/--askpass). The shell-launch (-s) and + # list-privileges (-a) flags are also gated since they are + # privilege-relevant invocations the agent can chain after acquiring + # the password (e.g. read SUDO_PASSWORD from .env -> sudo -S -s -> + # root shell). Plain `sudo cmd` (no flag) is TTY-bound and excluded. + # `_normalize_command_for_detection` lowercases input before pattern + # matching, so case variants of S/s and A/a collapse — both forms + # are gated below. Lazy `[^;|&\n]*?` allows flag arguments (e.g. + # `sudo -u root -S whoami`) without spanning command separators. See + # #17873 category 4. + (r'\bsudo\b[^;|&\n]*?\s+(?:-s\b|--stdin\b|-a\b|--askpass\b)', + "sudo with privilege flag (stdin/askpass/shell/list)"), + # Combined short-flag form: -nS, -ns, -sa, -las — sudo flags packed + # into a single -X token. Catches the same threat class. + (r'\bsudo\b[^;|&\n]*?\s+-[a-z]*[sa][a-z]*\b', + "sudo with combined-flag privilege escalation"), ] @@ -970,6 +1037,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()
-
2 PRs closed without merging
From the close comments: merged via another change 1 · superseded by another PR 1
Closed issues · 2
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.34: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- skills
- Merged PRs
- #23736
- Closed issues
- 2
- Attempts
- 2 PRs closed without merging
- Fix link checked
- 11 May 2026