{"repo":"NousResearch/hermes-agent","public_id":"1553","display_label":"Dangerous command approval prompts need context and clarity","summary":"These issues and PRs address approval prompts for dangerous commands lacking explanation, rationale, and full command visibility. Work spans UI fixes for truncated commands, adding LLM-generated contextual rationale, and presenting risk in plain language.","subsystem":"gateway","counts":{"members":11,"open_issues":3,"open_issues_distinct":3,"open_prs":2,"closed_issues":4,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":1649},"resolution":{"state":"partially_resolved","by":"pr-1649"},"freshness":{"state":"stale","last_verified_at":"2026-03-17T09:02:33+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.13163636363636363},"fix_edges":[{"issue_number":1553,"pr_number":1649,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":1553,"title":"[Feature]: critical: Dangerous Command approval flow truncates the listing of what command approval is being requested for","url":null,"state":"closed"},{"kind":"issue","number":6959,"title":"[UX] Dangerous-command approvals should explain likely action and concrete risk in plain language","url":null,"state":"closed"},{"kind":"issue","number":26113,"title":"audit: flat risk strings with no severity, context, or alternatives","url":null,"state":"closed"},{"kind":"issue","number":27604,"title":"Feature: Approval prompts should include the agent's contextual rationale (like Codex)","url":null,"state":"open"},{"kind":"issue","number":34625,"title":"Add plain-English semantic reasons to permission prompts","url":null,"state":"open"},{"kind":"issue","number":42478,"title":"Dangerous command approval prompts lack context — no explanation of what is dangerous or why","url":null,"state":"closed"},{"kind":"issue","number":45290,"title":"[Feature]: Humanize dangerous command approval prompts via structured LLM explanation","url":null,"state":"open"},{"kind":"pr","number":1566,"title":"fix(approval): show full command in dangerous command approval (Fixes #1553)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#1649","number":1649}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":1649,"title":"fix(approval): show full command in dangerous command approval (#1553)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/discord.py","tests/conftest.py","tests/tools/test_approval.py","tools/approval.py"],"fix_hunks":[{"file":"gateway/platforms/discord.py","hunks":["@@ -1740,9 +1740,12 @@ async def send_exec_approval(\n             if not channel:\n                 channel = await self._client.fetch_channel(int(chat_id))\n \n+            # Discord embed description limit is 4096; show full command up to that\n+            max_desc = 4088\n+            cmd_display = command if len(command) <= max_desc else command[: max_desc - 3] + \"...\"\n             embed = discord.Embed(\n                 title=\"Command Approval Required\",\n-                description=f\"```\\n{command[:500]}\\n```\",\n+                description=f\"```\\n{cmd_display}\\n```\",\n                 color=discord.Color.orange(),\n             )\n             embed.set_footer(text=f\"Approval ID: {approval_id}\")"]},{"file":"tests/conftest.py","hunks":["@@ -107,7 +107,11 @@ def _ensure_current_event_loop(request):\n \n @pytest.fixture(autouse=True)\n def _enforce_test_timeout():\n-    \"\"\"Kill any individual test that takes longer than 30 seconds.\"\"\"\n+    \"\"\"Kill any individual test that takes longer than 30 seconds.\n+    SIGALRM is Unix-only; skip on Windows.\"\"\"\n+    if sys.platform == \"win32\":\n+        yield\n+        return\n     old = signal.signal(signal.SIGALRM, _timeout_handler)\n     signal.alarm(30)\n     yield"]},{"file":"tests/tools/test_approval.py","hunks":["@@ -385,75 +385,47 @@ def test_legacy_find_key_still_approves_find_delete(self):\n             assert is_approved(\"legacy-find\", key_delete) is True\n \n \n-class TestViewFullCommand:\n-    \"\"\"Tests for the 'view full command' option in prompt_dangerous_approval.\"\"\"\n+class TestFullCommandAlwaysShown:\n+    \"\"\"The full command is always shown in the approval prompt (no truncation).\n \n-    def test_view_then_once_fallback(self):\n-        \"\"\"Pressing 'v' shows the full command, then 'o' approves once.\"\"\"\n+    Previously there was a [v]iew full option for long commands. Now the full\n+    command is always displayed. These tests verify the basic approval flow\n+    still works with long commands. (#1553)\n+    \"\"\"\n+\n+    def test_once_with_long_command(self):\n+        \"\"\"Pressing 'o' approves once even for very long commands.\"\"\"\n         long_cmd = \"rm -rf \" + \"a\" * 200\n-        inputs = iter([\"v\", \"o\"])\n-        with mock_patch(\"builtins.input\", side_effect=inputs):\n+        with mock_patch(\"builtins.input\", return_value=\"o\"):\n             result = prompt_dangerous_approval(long_cmd, \"recursive delete\")\n         assert result == \"once\"\n \n-    def test_view_then_deny_fallback(self):\n-        \"\"\"Pressing 'v' shows the full command, then 'd' denies.\"\"\"\n-        long_cmd = \"rm -rf \" + \"b\" * 200\n-        inputs = iter([\"v\", \"d\"])\n-        with mock_patch(\"builtins.input\", side_effect=inputs):\n-            result = prompt_dangerous_approval(long_cmd, \"recursive delete\")\n-        assert result == \"deny\"\n-\n-    def test_view_then_session_fallback(self):\n-        \"\"\"Pressing 'v' shows the full command, then 's' approves for session.\"\"\"\n+    def test_session_with_long_command(self):\n+        \"\"\"Pressing 's' approves for session with long commands.\"\"\"\n         long_cmd = \"rm -rf \" + \"c\" * 200\n-        inputs = iter([\"v\", \"s\"])\n-        with mock_patch(\"builtins.input\", side_effect=inputs):\n+        with mock_patch(\"builtins.input\", return_value=\"s\"):\n             result = prompt_dangerous_approval(long_cmd, \"recursive delete\")\n         assert result == \"session\"\n \n-    def test_view_then_always_fallback(self):\n-        \"\"\"Pressing 'v' shows the full command, then 'a' approves always.\"\"\"\n+    def test_always_with_long_command(self):\n+        \"\"\"Pressing 'a' approves always with long commands.\"\"\"\n         long_cmd = \"rm -rf \" + \"d\" * 200\n-        inputs = iter([\"v\", \"a\"])\n-        with mock_patch(\"builtins.input\", side_effect=inputs):\n+        with mock_patch(\"builtins.input\", return_value=\"a\"):\n             result = prompt_dangerous_approval(long_cmd, \"recursive delete\")\n         assert result == \"always\"\n \n-    def test_view_then_session_when_permanent_hidden(self):\n-        \"\"\"The view-full flow still works when allow_permanent=False.\"\"\"\n-        long_cmd = \"rm -rf \" + \"d\" * 200\n-        inputs = iter([\"v\", \"s\"])\n-        with mock_patch(\"builtins.input\", side_effect=inputs):\n-            result = prompt_dangerous_approval(\n-                long_cmd,\n-                \"recursive delete\",\n-                allow_permanent=False,\n-            )\n-        assert result == \"session\"\n+    def test_deny_with_long_command(self):\n+        \"\"\"Pressing 'd' denies with long commands.\"\"\"\n+        long_cmd = \"rm -rf \" + \"b\" * 200\n+        with mock_patch(\"builtins.input\", return_value=\"d\"):\n+            result = prompt_dangerous_approval(long_cmd, \"recursive delete\")\n+        assert result == \"deny\"\n \n-    def test_view_not_shown_for_short_command(self):\n-        \"\"\"Short commands don't offer the view option; 'v' falls through to deny.\"\"\"\n+    def test_invalid_input_denies(self):\n+        \"\"\"Invalid input (like 'v' which no longer exists) falls through to deny.\"\"\"\n         short_cmd = \"rm -rf /tmp\"\n         with mock_patch(\"builtins.input\", return_value=\"v\"):\n             result = prompt_dangerous_approval(short_cmd, \"recursive delete\")\n-        # 'v' is not a valid choice for short commands, should deny\n-        assert result == \"deny\"\n-\n-    def test_once_without_view(self):\n-        \"\"\"Directly pressing 'o' without viewing still works.\"\"\"\n-        long_cmd = \"rm -rf \" + \"e\" * 200\n-        with mock_patch(\"builtins.input\", return_value=\"o\"):\n-            result = prompt_dangerous_approval(long_cmd, \"recursive delete\")\n-        assert result == \"once\"\n-\n-    def test_view_ignored_after_already_shown(self):\n-        \"\"\"After viewing once, 'v' on a now-untruncated display falls through to deny.\"\"\"\n-        long_cmd = \"rm -rf \" + \"f\" * 200\n-        inputs = iter([\"v\", \"v\"])  # second 'v' should not match since is_truncated is False\n-        with mock_patch(\"builtins.input\", side_effect=inputs):\n-            result = prompt_dangerous_approval(long_cmd, \"recursive delete\")\n-        # After first 'v', is_truncated becomes False, so second 'v' -> deny\n         assert result == \"deny\"\n \n "]},{"file":"tools/approval.py","hunks":["@@ -220,17 +220,15 @@ def prompt_dangerous_approval(command: str, description: str,\n \n     os.environ[\"HERMES_SPINNER_PAUSE\"] = \"1\"\n     try:\n-        is_truncated = len(command) > 80\n         while True:\n             print()\n             print(f\"  ⚠️  DANGEROUS COMMAND: {description}\")\n-            print(f\"      {command[:80]}{'...' if is_truncated else ''}\")\n+            print(f\"      {command}\")\n             print()\n-            view_hint = \"  |  [v]iew full\" if is_truncated else \"\"\n             if allow_permanent:\n-                print(f\"      [o]nce  |  [s]ession  |  [a]lways  |  [d]eny{view_hint}\")\n+                print(\"      [o]nce  |  [s]ession  |  [a]lways  |  [d]eny\")\n             else:\n-                print(f\"      [o]nce  |  [s]ession  |  [d]eny{view_hint}\")\n+                print(\"      [o]nce  |  [s]ession  |  [d]eny\")\n             print()\n             sys.stdout.flush()\n ","@@ -252,12 +250,6 @@ def get_input():\n                 return \"deny\"\n \n             choice = result[\"choice\"]\n-            if choice in ('v', 'view') and is_truncated:\n-                print()\n-                print(\"      Full command:\")\n-                print(f\"      {command}\")\n-                is_truncated = False\n-                continue\n             if choice in ('o', 'once'):\n                 print(\"      ✓ Allowed once\")\n                 return \"once\"","@@ -394,7 +386,10 @@ def check_dangerous_command(command: str, env_type: str,\n             \"status\": \"approval_required\",\n             \"command\": command,\n             \"description\": description,\n-            \"message\": f\"⚠️ This command is potentially dangerous ({description}). Asking the user for approval...\",\n+            \"message\": (\n+                f\"⚠️ This command is potentially dangerous ({description}). \"\n+                f\"Asking the user for approval.\\n\\n**Command:**\\n```\\n{command}\\n```\"\n+            ),\n         }\n \n     choice = prompt_dangerous_approval(command, description,","@@ -542,7 +537,9 @@ def check_all_command_guards(command: str, env_type: str,\n             \"status\": \"approval_required\",\n             \"command\": command,\n             \"description\": combined_desc,\n-            \"message\": f\"⚠️ {combined_desc}. Asking the user for approval...\",\n+            \"message\": (\n+                f\"⚠️ {combined_desc}. Asking the user for approval.\\n\\n**Command:**\\n```\\n{command}\\n```\"\n+            ),\n         }\n \n     # CLI interactive: single combined prompt\n"]}]},{"kind":"pr","number":27833,"title":"feat(gateway): add contextual rationale to approval prompts","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"1bb1489f55","number":0},{"kind":"pr","value":"#27604","number":27604}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":45700,"title":"feat(gateway): add contextual rationale to approval prompts (salvage of #27833 by @zccyman, closes #27604)","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"1f28b1a9b975e61ea6016e192d047031b27e03bc","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}