# NousResearch/hermes-agent · sudo password handling safety and guardrails

## Open now

- 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.

## TL;DR

- 5 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #23736. Verify it addresses your case.
- **Recommended:** read PR #23736 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-23736`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member 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.

## Closed / duplicate issues

- #9590: Security: Agent attempts to guess and display sudo password in chat
- #43245: most models ignore the use of the PTY terminal tool when needing sudo

## Prior & competing PRs

- **#23736: fix(approval): sudo-S brute-force block + sudo-stdin/askpass DANGEROUS (salvage of #22194 + #21128)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `scripts/release.py`, `tests/tools/test_approval.py`, `tests/tools/test_hardline_blocklist.py`, `tools/approval.py`
  - fix hunks:
  - `scripts/release.py`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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.
     
    ```

    ```diff
    @@ -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
     # =========================================================================
    ```

    ```diff
    @@ -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"),
     ]
     
     
    ```

    ```diff
    @@ -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()
    ```
- **#20130: feat(Security): Implement multi-layered credential safety guardrails**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#9590`, `#23736`, `#9590`, `#9590`, `#23736`, `#22194`, `#17873`, `#23736`
- **#22194: fix(terminal): block sudo -S password guessing when SUDO_PASSWORD is unset**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#23736`

## Fix status

- Merged to main in PR #23736 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-11`.
- Subsystem: `skills`
