diff --git a/cli.py b/cli.py index 54013b0ebf..80e2e78463 100755 --- a/cli.py +++ b/cli.py @@ -3824,7 +3824,17 @@ class HermesCLI: selected = state["selected"] choices = state["choices"] if 0 <= selected < len(choices): - state["response_queue"].put(choices[selected]) + chosen = choices[selected] + if chosen == "view": + # Toggle full command display without closing the prompt + state["show_full"] = True + # Remove the "view" option since it's been used + state["choices"] = [c for c in choices if c != "view"] + if state["selected"] >= len(state["choices"]): + state["selected"] = len(state["choices"]) - 1 + event.app.invalidate() + return + state["response_queue"].put(chosen) self._approval_state = None event.app.invalidate() return @@ -4372,13 +4382,18 @@ class HermesCLI: description = state["description"] choices = state["choices"] selected = state.get("selected", 0) + show_full = state.get("show_full", False) - cmd_display = command[:70] + '...' if len(command) > 70 else command + if show_full or len(command) <= 70: + cmd_display = command + else: + cmd_display = command[:70] + '...' choice_labels = { "once": "Allow once", "session": "Allow for this session", "always": "Add to permanent allowlist", "deny": "Deny", + "view": "Show full command", } preview_lines = _wrap_panel_text(description, 60) preview_lines.extend(_wrap_panel_text(cmd_display, 60)) diff --git a/gateway/run.py b/gateway/run.py index 6f4e43e981..aae5c63426 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -988,6 +988,10 @@ class GatewayRunner: elif user_text in ("no", "n", "deny", "cancel", "nope"): self._pending_approvals.pop(session_key_preview) return "❌ Command denied." + elif user_text in ("full", "show", "view", "show full", "view full"): + # Show full command without consuming the approval + cmd = self._pending_approvals[session_key_preview]["command"] + return f"Full command:\n\n```\n{cmd}\n```\n\nReply yes/no to approve or deny." # If it's not clearly an approval/denial, fall through to normal processing # Get or create session diff --git a/hermes_cli/callbacks.py b/hermes_cli/callbacks.py index bfce9c0010..425e5c84e0 100644 --- a/hermes_cli/callbacks.py +++ b/hermes_cli/callbacks.py @@ -105,10 +105,14 @@ def approval_callback(cli, command: str, description: str) -> str: """Prompt for dangerous command approval through the TUI. Shows a selection UI with choices: once / session / always / deny. + When the command is longer than 70 characters, a "view" option is + included so the user can reveal the full text before deciding. """ timeout = 60 response_queue = queue.Queue() choices = ["once", "session", "always", "deny"] + if len(command) > 70: + choices.append("view") cli._approval_state = { "command": command, diff --git a/tests/tools/test_approval.py b/tests/tools/test_approval.py index 339dbbe847..311a0ba674 100644 --- a/tests/tools/test_approval.py +++ b/tests/tools/test_approval.py @@ -1,5 +1,7 @@ """Tests for the dangerous command approval module.""" +from unittest.mock import patch as mock_patch + from tools.approval import ( approve_session, clear_session, @@ -7,6 +9,7 @@ from tools.approval import ( has_pending, is_approved, pop_pending, + prompt_dangerous_approval, submit_pending, ) @@ -338,3 +341,63 @@ class TestFindExecFullPathRm: assert dangerous is False assert key is None + +class TestViewFullCommand: + """Tests for the 'view full command' option in prompt_dangerous_approval.""" + + def test_view_then_once_fallback(self): + """Pressing 'v' shows the full command, then 'o' approves once.""" + long_cmd = "rm -rf " + "a" * 200 + inputs = iter(["v", "o"]) + with mock_patch("builtins.input", side_effect=inputs): + result = prompt_dangerous_approval(long_cmd, "recursive delete") + assert result == "once" + + def test_view_then_deny_fallback(self): + """Pressing 'v' shows the full command, then 'd' denies.""" + long_cmd = "rm -rf " + "b" * 200 + inputs = iter(["v", "d"]) + with mock_patch("builtins.input", side_effect=inputs): + result = prompt_dangerous_approval(long_cmd, "recursive delete") + assert result == "deny" + + def test_view_then_session_fallback(self): + """Pressing 'v' shows the full command, then 's' approves for session.""" + long_cmd = "rm -rf " + "c" * 200 + inputs = iter(["v", "s"]) + with mock_patch("builtins.input", side_effect=inputs): + result = prompt_dangerous_approval(long_cmd, "recursive delete") + assert result == "session" + + def test_view_then_always_fallback(self): + """Pressing 'v' shows the full command, then 'a' approves always.""" + long_cmd = "rm -rf " + "d" * 200 + inputs = iter(["v", "a"]) + with mock_patch("builtins.input", side_effect=inputs): + result = prompt_dangerous_approval(long_cmd, "recursive delete") + assert result == "always" + + def test_view_not_shown_for_short_command(self): + """Short commands don't offer the view option; 'v' falls through to deny.""" + short_cmd = "rm -rf /tmp" + with mock_patch("builtins.input", return_value="v"): + result = prompt_dangerous_approval(short_cmd, "recursive delete") + # 'v' is not a valid choice for short commands, should deny + assert result == "deny" + + def test_once_without_view(self): + """Directly pressing 'o' without viewing still works.""" + long_cmd = "rm -rf " + "e" * 200 + with mock_patch("builtins.input", return_value="o"): + result = prompt_dangerous_approval(long_cmd, "recursive delete") + assert result == "once" + + def test_view_ignored_after_already_shown(self): + """After viewing once, 'v' on a now-untruncated display falls through to deny.""" + long_cmd = "rm -rf " + "f" * 200 + inputs = iter(["v", "v"]) # second 'v' should not match since is_truncated is False + with mock_patch("builtins.input", side_effect=inputs): + result = prompt_dangerous_approval(long_cmd, "recursive delete") + # After first 'v', is_truncated becomes False, so second 'v' -> deny + assert result == "deny" + diff --git a/tools/approval.py b/tools/approval.py index db67a74945..35a2b32bca 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -184,43 +184,52 @@ def prompt_dangerous_approval(command: str, description: str, os.environ["HERMES_SPINNER_PAUSE"] = "1" try: - print() - print(f" ⚠️ DANGEROUS COMMAND: {description}") - print(f" {command[:80]}{'...' if len(command) > 80 else ''}") - print() - print(f" [o]nce | [s]ession | [a]lways | [d]eny") - print() - sys.stdout.flush() + is_truncated = len(command) > 80 + while True: + print() + print(f" ⚠️ DANGEROUS COMMAND: {description}") + print(f" {command[:80]}{'...' if is_truncated else ''}") + print() + view_hint = " | [v]iew full" if is_truncated else "" + print(f" [o]nce | [s]ession | [a]lways | [d]eny{view_hint}") + print() + sys.stdout.flush() - result = {"choice": ""} + result = {"choice": ""} - def get_input(): - try: - result["choice"] = input(" Choice [o/s/a/D]: ").strip().lower() - except (EOFError, OSError): - result["choice"] = "" + def get_input(): + try: + result["choice"] = input(" Choice [o/s/a/D]: ").strip().lower() + except (EOFError, OSError): + result["choice"] = "" - thread = threading.Thread(target=get_input, daemon=True) - thread.start() - thread.join(timeout=timeout_seconds) + thread = threading.Thread(target=get_input, daemon=True) + thread.start() + thread.join(timeout=timeout_seconds) - if thread.is_alive(): - print("\n ⏱ Timeout - denying command") - return "deny" + if thread.is_alive(): + print("\n ⏱ Timeout - denying command") + return "deny" - choice = result["choice"] - if choice in ('o', 'once'): - print(" ✓ Allowed once") - return "once" - elif choice in ('s', 'session'): - print(" ✓ Allowed for this session") - return "session" - elif choice in ('a', 'always'): - print(" ✓ Added to permanent allowlist") - return "always" - else: - print(" ✗ Denied") - return "deny" + choice = result["choice"] + if choice in ('v', 'view') and is_truncated: + print() + print(" Full command:") + print(f" {command}") + is_truncated = False # show full on next loop iteration too + continue + if choice in ('o', 'once'): + print(" ✓ Allowed once") + return "once" + elif choice in ('s', 'session'): + print(" ✓ Allowed for this session") + return "session" + elif choice in ('a', 'always'): + print(" ✓ Added to permanent allowlist") + return "always" + else: + print(" ✗ Denied") + return "deny" except (EOFError, KeyboardInterrupt): print("\n ✗ Cancelled")