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

0open issues
0open PRs
5items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #23736 fix(approval): sudo-S brute-force block + sudo-stdin/askpass DANGEROUS (salvage of #22194 + #21128)

    merged to main

    files: scripts/release.py, tests/tools/test_approval.py, tests/tools/test_hardline_blocklist.py, tools/approval.py

    Show 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

  • #20130 feat(Security): Implement multi-layered credential safety guardrails · superseded by another PR → #9590, #23736, #22194, #17873
  • #22194 fix(terminal): block sudo -S password guessing when SUDO_PASSWORD is unset · merged via another change → #23736
Closed issues · 2
  • Closed#9590Security: Agent attempts to guess and display sudo password in chat
  • Closed#43245most models ignore the use of the PTY terminal tool when needing sudo
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.00 means 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 Lab

At a glance

Subsystem
skills
Merged PRs
#23736
Closed issues
2
Attempts
2 PRs closed without merging
Fix link checked
11 May 2026

For your agent

The same facts, typed.