{"repo":"NousResearch/hermes-agent","public_id":"9590","display_label":"sudo password handling safety and guardrails","summary":"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.","subsystem":"skills","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":23736},"resolution":{"state":"partially_resolved","by":"pr-23736"},"freshness":{"state":"verified","last_verified_at":"2026-05-11T13:56:31+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.33599999999999997},"fix_edges":[{"issue_number":9590,"pr_number":23736,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":9590,"title":"Security: Agent attempts to guess and display sudo password in chat","url":null,"state":"closed"},{"kind":"issue","number":43245,"title":"most models ignore the use of the PTY terminal tool when needing sudo","url":null,"state":"closed"},{"kind":"pr","number":20130,"title":"feat(Security): Implement multi-layered credential safety guardrails","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#9590","number":9590},{"kind":"pr","value":"#23736","number":23736},{"kind":"pr","value":"#9590","number":9590},{"kind":"pr","value":"#9590","number":9590},{"kind":"pr","value":"#23736","number":23736},{"kind":"pr","value":"#22194","number":22194},{"kind":"pr","value":"#17873","number":17873},{"kind":"pr","value":"#23736","number":23736}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":22194,"title":"fix(terminal): block sudo -S password guessing when SUDO_PASSWORD is unset","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#23736","number":23736}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":23736,"title":"fix(approval): sudo-S brute-force block + sudo-stdin/askpass DANGEROUS (salvage of #22194 + #21128)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["scripts/release.py","tests/tools/test_approval.py","tests/tools/test_hardline_blocklist.py","tools/approval.py"],"fix_hunks":[{"file":"scripts/release.py","hunks":["@@ -977,6 +977,8 @@\n     \"[redacted:email]\": \"dmnkhorvath\",  # salvage of #23358 (kanban worker send_message)\n     \"[redacted:email]\": \"smwbev\",  # salvage of #23659 (aria-label colLabel)\n     \"[redacted:email]\": \"TurgutKural\",  # salvage of #23356 (HERMES_HOME inject)\n+    \"[redacted:email]\": \"29206394\",  # PR #22194 salvage (sudo -S brute-force guard, #9590)\n+    \"[redacted:email]\": \"fr33d3m0n\",  # PR #21128 salvage (sudo stdin/askpass DANGEROUS, #17873 cat 4)\n }\n \n "]},{"file":"tests/tools/test_approval.py","hunks":["@@ -965,3 +965,140 @@ def cb(command, description, **kwargs):\n             assert result == \"once\"\n         finally:\n             ptc.get_app_or_none = orig\n+\n+\n+class TestDetectSudoStdin:\n+    \"\"\"Sudo with stdin / askpass / shell / list-privileges flags (#17873 cat 4).\n+\n+    An LLM-driven agent has no TTY, so the sudo invocations that succeed\n+    without human interaction are those reading the password from stdin\n+    (-S / --stdin) or via an askpass helper (-A / --askpass). The\n+    shell-launch (-s) and list-privileges (-a) flags are also gated since\n+    they are privilege-relevant invocations the agent can chain after\n+    acquiring the password.\n+\n+    `_normalize_command_for_detection` lowercases input before pattern\n+    matching, so -S/-s and -A/-a are indistinguishable at the regex\n+    layer; both letter-pairs are gated.\n+    \"\"\"\n+\n+    # Positive cases (must match)\n+\n+    def test_canonical_pipe_to_sudo_S_detected(self):\n+        is_dangerous, _, desc = detect_dangerous_command(\n+            \"echo pwd | sudo -S whoami\"\n+        )\n+        assert is_dangerous is True\n+        assert \"sudo\" in desc.lower()\n+\n+    def test_long_flag_stdin_detected(self):\n+        is_dangerous, _, _ = detect_dangerous_command(\"sudo --stdin id\")\n+        assert is_dangerous is True\n+\n+    def test_non_interactive_plus_stdin_detected(self):\n+        is_dangerous, _, _ = detect_dangerous_command(\"sudo -n -S id\")\n+        assert is_dangerous is True\n+\n+    def test_user_then_stdin_detected(self):\n+        # Codex audit caught that the original \"leading flags only\" regex\n+        # missed this form because `-u root` has a flag-argument (`root`)\n+        # that broke the (?:\\s+-[^\\s]+)* loop. The lazy [^;|&\\n]*? class\n+        # consumes flag-args without spanning command separators.\n+        is_dangerous, _, _ = detect_dangerous_command(\n+            \"sudo -u root -S whoami\"\n+        )\n+        assert is_dangerous is True\n+\n+    def test_long_non_interactive_plus_stdin_detected(self):\n+        is_dangerous, _, _ = detect_dangerous_command(\n+            \"sudo --non-interactive -S whoami\"\n+        )\n+        assert is_dangerous is True\n+\n+    def test_long_user_equals_stdin_detected(self):\n+        is_dangerous, _, _ = detect_dangerous_command(\n+            \"sudo --user=root -S id\"\n+        )\n+        assert is_dangerous is True\n+\n+    def test_herestring_input_detected(self):\n+        is_dangerous, _, _ = detect_dangerous_command(\n+            \"sudo -S id <<< 'mypwd'\"\n+        )\n+        assert is_dangerous is True\n+\n+    def test_combined_short_flags_nS_detected(self):\n+        # `-nS` packs `-n` and `-S` into one arg; second pattern catches.\n+        is_dangerous, _, _ = detect_dangerous_command(\"sudo -nS id\")\n+        assert is_dangerous is True\n+\n+    def test_printf_form_detected(self):\n+        is_dangerous, _, _ = detect_dangerous_command(\n+            'printf \"%s\\\\n\" \"$PW\" | sudo -S id'\n+        )\n+        assert is_dangerous is True\n+\n+    def test_askpass_short_flag_detected(self):\n+        is_dangerous, _, _ = detect_dangerous_command(\"sudo -A id\")\n+        assert is_dangerous is True\n+\n+    def test_askpass_long_flag_detected(self):\n+        is_dangerous, _, _ = detect_dangerous_command(\"sudo --askpass id\")\n+        assert is_dangerous is True\n+\n+    def test_two_sudo_invocations_second_caught(self):\n+        # The first sudo here is benign (no -S); the second has -S.\n+        # Lazy [^;|&\\n]*? does NOT span past `;`, so re.search anchors\n+        # on the second sudo invocation independently.\n+        is_dangerous, _, _ = detect_dangerous_command(\n+            \"sudo whoami; sudo -S id\"\n+        )\n+        assert is_dangerous is True\n+\n+    # Negative cases (must NOT match)\n+\n+    def test_plain_sudo_safe(self):\n+        is_dangerous, _, _ = detect_dangerous_command(\"sudo whoami\")\n+        assert is_dangerous is False\n+\n+    def test_sudo_interactive_shell_safe(self):\n+        is_dangerous, _, _ = detect_dangerous_command(\"sudo -i\")\n+        assert is_dangerous is False\n+\n+    def test_sudo_with_user_no_stdin_flag_safe(self):\n+        is_dangerous, _, _ = detect_dangerous_command(\"sudo -u root -i\")\n+        assert is_dangerous is False\n+\n+    def test_man_sudo_safe(self):\n+        is_dangerous, _, _ = detect_dangerous_command(\"man sudo\")\n+        assert is_dangerous is False\n+\n+    def test_which_sudo_safe(self):\n+        is_dangerous, _, _ = detect_dangerous_command(\"which sudo\")\n+        assert is_dangerous is False\n+\n+    def test_sudo_user_env_reference_safe(self):\n+        is_dangerous, _, _ = detect_dangerous_command(\n+            \"echo SUDO_USER=$SUDO_USER\"\n+        )\n+        assert is_dangerous is False\n+\n+    def test_apt_install_sudo_safe(self):\n+        is_dangerous, _, _ = detect_dangerous_command(\"apt install sudo\")\n+        assert is_dangerous is False\n+\n+    def test_ls_etc_sudoers_safe(self):\n+        is_dangerous, _, _ = detect_dangerous_command(\"ls /etc/sudoers\")\n+        assert is_dangerous is False\n+\n+    def test_pseudosudo_safe_word_boundary(self):\n+        # `\\bsudo\\b` requires a word boundary; `pseudosudo` has none\n+        # before `sudo`, so should not trigger.\n+        is_dangerous, _, _ = detect_dangerous_command(\"pseudosudo -S id\")\n+        assert is_dangerous is False\n+\n+    def test_unrelated_redirection_safe(self):\n+        is_dangerous, _, _ = detect_dangerous_command(\n+            \"make 2>&1 | tee build.log\"\n+        )\n+        assert is_dangerous is False"]},{"file":"tests/tools/test_hardline_blocklist.py","hunks":["@@ -288,3 +288,91 @@ def test_hardline_list_is_small():\n         f\"HARDLINE_PATTERNS has grown to {len(HARDLINE_PATTERNS)} entries; \"\n         \"only truly unrecoverable commands belong here.\"\n     )\n+\n+\n+# =========================================================================\n+# Sudo stdin guard — blocks \"sudo -S\" without SUDO_PASSWORD\n+# =========================================================================\n+\n+_SUDO_STDIN_BLOCK = [\n+    \"sudo -S whoami\",\n+    \"echo hunter2 | sudo -S whoami\",\n+    \"sudo -S -u root whoami\",\n+    \"sudo -S apt-get install foo\",\n+    \"echo password | sudo -S systemctl restart nginx\",\n+    \"sudo -k && sudo -S whoami\",\n+]\n+\n+_SUDO_STDIN_ALLOW = [\n+    # Plain sudo without -S — goes through normal approval\n+    \"sudo whoami\",\n+    \"sudo apt-get update\",\n+    \"sudo -u root whoami\",\n+    # -S flag not attached to sudo\n+    \"echo -S hello\",\n+    \"some_tool -S thing\",\n+    # Literal text mention of sudo\n+    \"echo 'use sudo -S to pipe passwords'\",\n+]\n+\n+_SUDO_STDIN_BLOCK_YOLO = [\n+    \"sudo -S whoami\",\n+    \"echo hunter2 | sudo -S apt-get install\",\n+]\n+\n+\n+def test_sudo_stdin_guard_detects_without_password():\n+    \"\"\"sudo -S is dangerous when SUDO_PASSWORD is not configured.\"\"\"\n+    import tools.approval as approval_mod\n+\n+    for cmd in _SUDO_STDIN_BLOCK:\n+        is_blocked, desc = approval_mod._check_sudo_stdin_guard(cmd)\n+        assert is_blocked, f\"expected sudo stdin guard to block {cmd!r}\"\n+        assert \"sudo\" in desc.lower()\n+\n+\n+def test_sudo_stdin_guard_allows_benign_commands():\n+    \"\"\"Commands without explicit sudo -S are not blocked.\"\"\"\n+    import tools.approval as approval_mod\n+\n+    for cmd in _SUDO_STDIN_ALLOW:\n+        is_blocked, desc = approval_mod._check_sudo_stdin_guard(cmd)\n+        assert not is_blocked, f\"expected sudo stdin guard NOT to block {cmd!r}\"\n+\n+\n+def test_sudo_stdin_guard_bypassed_when_password_configured(monkeypatch):\n+    \"\"\"When SUDO_PASSWORD is set, sudo -S is legitimate (injected by transform).\"\"\"\n+    import tools.approval as approval_mod\n+\n+    monkeypatch.setenv(\"SUDO_PASSWORD\", \"testpass\")\n+    for cmd in _SUDO_STDIN_BLOCK:\n+        is_blocked, _ = approval_mod._check_sudo_stdin_guard(cmd)\n+        assert not is_blocked, f\"with SUDO_PASSWORD set, {cmd!r} should NOT be blocked\"\n+\n+\n+def test_sudo_stdin_guard_blocks_via_check_all_command_guards(clean_session):\n+    \"\"\"Integration: check_all_command_guards returns block for sudo -S.\"\"\"\n+    for cmd in _SUDO_STDIN_BLOCK:\n+        result = check_all_command_guards(cmd, \"local\")\n+        assert result[\"approved\"] is False, f\"expected block on {cmd!r}\"\n+        # Should NOT be marked as hardline (it's sudo-specific)\n+        assert result.get(\"hardline\") is not True\n+        assert \"BLOCKED\" in result[\"message\"]\n+        assert \"sudo -S\" in result[\"message\"].lower() or \"sudo password\" in result[\"message\"].lower()\n+\n+\n+def test_sudo_stdin_guard_not_blocked_by_yolo(clean_session, monkeypatch):\n+    \"\"\"yolo/approvals.mode=off must NOT bypass sudo stdin guard.\"\"\"\n+    monkeypatch.setenv(\"HERMES_YOLO_MODE\", \"1\")\n+\n+    for cmd in _SUDO_STDIN_BLOCK_YOLO:\n+        result = check_all_command_guards(cmd, \"local\")\n+        assert result[\"approved\"] is False, f\"yolo leaked sudo guard on {cmd!r}\"\n+\n+\n+def test_sudo_stdin_guard_container_bypass(clean_session):\n+    \"\"\"Containerized backends still bypass — they can't touch the host.\"\"\"\n+    for env in (\"docker\", \"singularity\", \"modal\", \"daytona\", \"vercel_sandbox\"):\n+        for cmd in _SUDO_STDIN_BLOCK:\n+            result = check_all_command_guards(cmd, env)\n+            assert result[\"approved\"] is True, f\"container {env} should bypass sudo guard on {cmd!r}\""]},{"file":"tools/approval.py","hunks":["@@ -221,6 +221,40 @@ def _is_gateway_approval_context() -> bool:\n ]\n \n \n+# =========================================================================\n+# Sudo stdin guard — block password guessing via \"sudo -S\"\n+# =========================================================================\n+# When SUDO_PASSWORD is not configured, any explicit \"sudo -S\" in the\n+# command is the LLM piping a guessed password via stdin.  This is a\n+# brute-force attack vector: the model iterates through candidate\n+# passwords, inspects sudo's \"Sorry, try again\" output, and refines.\n+# Treat this as an unconditional block — there is never a legitimate\n+# reason for the agent to pipe passwords to sudo -S when no password\n+# has been configured.\n+_SUDO_STDIN_RE = re.compile(\n+    r'(?:^|[;&|`\\n]|&&|\\|\\||\\$\\()\\s*sudo\\s+-S\\b',\n+    re.IGNORECASE)\n+\n+\n+def _check_sudo_stdin_guard(command: str) -> tuple:\n+    \"\"\"Detect ``sudo -S`` (stdin password) without configured SUDO_PASSWORD.\n+\n+    When SUDO_PASSWORD is set, ``_transform_sudo_command`` injects ``-S``\n+    internally — that path is legitimate and handled elsewhere.  This guard\n+    only fires when SUDO_PASSWORD is *not* set, meaning the LLM explicitly\n+    wrote ``sudo -S`` to pipe a guessed password.\n+\n+    Returns:\n+        (is_blocked: bool, description: str | None)\n+    \"\"\"\n+    if \"SUDO_PASSWORD\" in os.environ:\n+        return (False, None)\n+    normalized = _normalize_command_for_detection(command).lower()\n+    if _SUDO_STDIN_RE.search(normalized):\n+        return (True, \"sudo password guessing via stdin (sudo -S)\")\n+    return (False, None)\n+\n+\n def detect_hardline_command(command: str) -> tuple:\n     \"\"\"Check if a command matches the unconditional hardline blocklist.\n ","@@ -250,6 +284,20 @@ def _hardline_block_result(description: str) -> dict:\n     }\n \n \n+def _sudo_stdin_block_result(description: str) -> dict:\n+    \"\"\"Build the standard block result for sudo stdin guard.\"\"\"\n+    return {\n+        \"approved\": False,\n+        \"message\": (\n+            f\"BLOCKED: {description}. \"\n+            \"Do not pipe passwords to 'sudo -S' — this is a brute-force \"\n+            \"attack vector. Set SUDO_PASSWORD in your .env file if the \"\n+            \"agent needs passwordless sudo, or run the sudo command \"\n+            \"manually in your own terminal.\"\n+        ),\n+    }\n+\n+\n # =========================================================================\n # Dangerous command patterns\n # =========================================================================","@@ -320,6 +368,25 @@ def _hardline_block_result(description: str) -> dict:\n     # a script is first made executable then immediately run. The script\n     # content may contain dangerous commands that individual patterns miss.\n     (r'\\bchmod\\s+\\+x\\b.*[;&|]+\\s*\\./', \"chmod +x followed by immediate execution\"),\n+    # Sudo with stdin / askpass / shell / list-privs flags. An LLM-driven\n+    # agent has no TTY, so sudo invocations that succeed without human\n+    # interaction are those reading the password from stdin (-S/--stdin)\n+    # or via an askpass helper (-A/--askpass). The shell-launch (-s) and\n+    # list-privileges (-a) flags are also gated since they are\n+    # privilege-relevant invocations the agent can chain after acquiring\n+    # the password (e.g. read SUDO_PASSWORD from .env -> sudo -S -s ->\n+    # root shell). Plain `sudo cmd` (no flag) is TTY-bound and excluded.\n+    # `_normalize_command_for_detection` lowercases input before pattern\n+    # matching, so case variants of S/s and A/a collapse — both forms\n+    # are gated below. Lazy `[^;|&\\n]*?` allows flag arguments (e.g.\n+    # `sudo -u root -S whoami`) without spanning command separators. See\n+    # #17873 category 4.\n+    (r'\\bsudo\\b[^;|&\\n]*?\\s+(?:-s\\b|--stdin\\b|-a\\b|--askpass\\b)',\n+     \"sudo with privilege flag (stdin/askpass/shell/list)\"),\n+    # Combined short-flag form: -nS, -ns, -sa, -las — sudo flags packed\n+    # into a single -X token. Catches the same threat class.\n+    (r'\\bsudo\\b[^;|&\\n]*?\\s+-[a-z]*[sa][a-z]*\\b',\n+     \"sudo with combined-flag privilege escalation\"),\n ]\n \n ","@@ -970,6 +1037,17 @@ def check_all_command_guards(command: str, env_type: str,\n         logger.warning(\"Hardline block: %s (command: %s)\", hardline_desc, command[:200])\n         return _hardline_block_result(hardline_desc)\n \n+    # == Sudo stdin guard ==\n+    # Like the hardline floor above, this is unconditional: there is never a\n+    # legitimate reason for the agent to pipe passwords to sudo -S when no\n+    # SUDO_PASSWORD has been configured.  This must fire BEFORE the yolo\n+    # check so even yolo/smart approval/mode=off cannot bypass it.\n+    is_sudo_guess, sudo_guess_desc = _check_sudo_stdin_guard(command)\n+    if is_sudo_guess:\n+        logger.warning(\"Sudo stdin guard block: %s (command: %s)\",\n+                       sudo_guess_desc, command[:200])\n+        return _sudo_stdin_block_result(sudo_guess_desc)\n+\n     # --yolo or approvals.mode=off: bypass all approval prompts.\n     # Gateway /yolo is session-scoped; CLI --yolo remains process-scoped.\n     approval_mode = _get_approval_mode()\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}