diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index 65cb7ed1b850..a68822bb1b18 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -2,7 +2,7 @@ from __future__ import annotations -from getpass import getpass +from hermes_cli.cli_output import read_secret_line import math import sys import time @@ -194,7 +194,7 @@ def auth_add_command(args) -> None: if requested_type == AUTH_TYPE_API_KEY: token = (getattr(args, "api_key", None) or "").strip() if not token: - token = getpass("Paste your API key: ").strip() + token = read_secret_line("Paste your API key: ").strip() if not token: raise SystemExit("No API key provided.") default_label = _api_key_default_label(len(pool.entries()) + 1) diff --git a/hermes_cli/callbacks.py b/hermes_cli/callbacks.py index fa40eced5ede..e1cb6b6ee9a6 100644 --- a/hermes_cli/callbacks.py +++ b/hermes_cli/callbacks.py @@ -8,9 +8,9 @@ import queue import time as _time -import getpass from hermes_cli.banner import cprint, _DIM, _RST +from hermes_cli.cli_output import read_secret_line from hermes_cli.config import save_env_value_secure from hermes_constants import display_hermes_home @@ -75,7 +75,7 @@ def prompt_for_secret(cli, var_name: str, prompt: str, metadata=None) -> dict: if not hasattr(cli, "_secret_deadline"): cli._secret_deadline = 0 try: - value = getpass.getpass(f"{prompt} (hidden, ESC or empty Enter to skip): ") + value = read_secret_line(f"{prompt} (hidden, ESC or empty Enter to skip): ") except (EOFError, KeyboardInterrupt): value = "" diff --git a/hermes_cli/cli_output.py b/hermes_cli/cli_output.py index 2f07129704e8..9d990059311e 100644 --- a/hermes_cli/cli_output.py +++ b/hermes_cli/cli_output.py @@ -41,6 +41,27 @@ def print_header(text: str) -> None: # ─── Input Prompts ──────────────────────────────────────────────────────────── +def read_secret_line(prompt: str = "") -> str: + """Read a password/secret line with ASCII control characters stripped. + + Wraps ``getpass.getpass()`` and removes ``\\x00``-``\\x1f`` from the + returned string. + + On Windows, ``getpass.getpass()`` uses ``msvcrt.getwch()``, which emits + ``\\x00`` (or ``\\xe0``) followed by a scan code for special keys + (arrow keys, function keys, etc.) instead of filtering them out. An + inadvertent arrow keypress just before/during paste therefore injects + e.g. ``\\x00K`` (Left Arrow) into the value. When that value is later + assigned to ``os.environ`` it raises ``ValueError: embedded null + character``; when sent as an HTTP header it fails ASCII encoding. + + The caller is responsible for ``.strip()`` if needed — callers that + feed the value through additional sanitization (e.g. paste cleanup) + may want the raw, control-char-free string. + """ + return getpass.getpass(prompt).translate({i: None for i in range(32)}) + + def prompt( question: str, default: str | None = None, @@ -59,7 +80,7 @@ def prompt( try: if password: - value = getpass.getpass(display) + value = read_secret_line(display) else: value = input(display) value = value.strip() diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 4c2596594ec0..7822f75a423e 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -3756,8 +3756,8 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A print(f" Get your key at: {var['url']}") if var.get("password"): - import getpass - value = getpass.getpass(f" {var['prompt']}: ") + from hermes_cli.cli_output import read_secret_line + value = read_secret_line(f" {var['prompt']}: ").strip() else: value = input(f" {var['prompt']}: ").strip() @@ -3808,8 +3808,8 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A else: print(f" {info.get('description', name)}") if info.get("password"): - import getpass - value = getpass.getpass(f" {info.get('prompt', name)} (Enter to skip): ") + from hermes_cli.cli_output import read_secret_line + value = read_secret_line(f" {info.get('prompt', name)} (Enter to skip): ").strip() else: value = input(f" {info.get('prompt', name)} (Enter to skip): ").strip() if value: diff --git a/hermes_cli/main.py b/hermes_cli/main.py index e8aa0d761c46..c14b32339b71 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2320,7 +2320,7 @@ def _aux_flow_provider_model( def _aux_flow_custom_endpoint(task: str, task_cfg: dict) -> None: """Prompt for a direct OpenAI-compatible base_url + optional api_key/model.""" - import getpass + from hermes_cli.cli_output import read_secret_line display_name = next((name for key, name, _ in _AUX_TASKS if key == task), task) current_base_url = str(task_cfg.get("base_url") or "").strip() @@ -2354,7 +2354,7 @@ def _aux_flow_custom_endpoint(task: str, task_cfg: dict) -> None: return model = model or current_model try: - api_key = getpass.getpass( + api_key = read_secret_line( "API key (optional, blank = use OPENAI_API_KEY): " ).strip() except (KeyboardInterrupt, EOFError): @@ -2426,9 +2426,9 @@ def _model_flow_openrouter(config, current_model=""): print("Get one at: https://openrouter.ai/keys") print() try: - import getpass + from hermes_cli.cli_output import read_secret_line - key = getpass.getpass("OpenRouter API key (or Enter to cancel): ").strip() + key = read_secret_line("OpenRouter API key (or Enter to cancel): ").strip() except (KeyboardInterrupt, EOFError): print() return @@ -2488,9 +2488,9 @@ def _model_flow_ai_gateway(config, current_model=""): print("Add a payment method to get $5 in free credits.") print() try: - import getpass + from hermes_cli.cli_output import read_secret_line - key = getpass.getpass("AI Gateway API key (or Enter to cancel): ").strip() + key = read_secret_line("AI Gateway API key (or Enter to cancel): ").strip() except (KeyboardInterrupt, EOFError): print() return @@ -3004,9 +3004,9 @@ def _model_flow_custom(config): base_url = input( f"API base URL [{current_url or 'e.g. https://api.example.com/v1'}]: " ).strip() - import getpass + from hermes_cli.cli_output import read_secret_line - api_key = getpass.getpass( + api_key = read_secret_line( f"API key [{current_key[:8] + '...' if current_key else 'optional'}]: " ).strip() except (KeyboardInterrupt, EOFError): @@ -3293,7 +3293,7 @@ def _model_flow_azure_foundry(config, current_model=""): save_config, ) from hermes_cli import azure_detect - import getpass + from hermes_cli.cli_output import read_secret_line # ── Load current Azure Foundry configuration ───────────────────── model_cfg = config.get("model", {}) @@ -3349,7 +3349,7 @@ def _model_flow_azure_foundry(config, current_model=""): # ── Step 2: API key ────────────────────────────────────────────── print() try: - api_key = getpass.getpass( + api_key = read_secret_line( f"API key [{current_api_key[:8] + '...' if current_api_key else 'required'}]: " ).strip() except (KeyboardInterrupt, EOFError): @@ -3889,9 +3889,9 @@ def _model_flow_copilot(config, current_model=""): return elif choice == "2": try: - import getpass + from hermes_cli.cli_output import read_secret_line - new_key = getpass.getpass(" Token (COPILOT_GITHUB_TOKEN): ").strip() + new_key = read_secret_line(" Token (COPILOT_GITHUB_TOKEN): ").strip() except (KeyboardInterrupt, EOFError): print() return @@ -4140,7 +4140,7 @@ def _prompt_api_key(pconfig, existing_key: str, provider_id: str = "") -> tuple: ``return`` immediately — the user cancelled entry, declined to replace, or cleared the key and is now unconfigured. """ - import getpass + from hermes_cli.cli_output import read_secret_line from hermes_cli.auth import LMSTUDIO_NOAUTH_PLACEHOLDER from hermes_cli.config import save_env_value @@ -4153,7 +4153,7 @@ def _prompt_new_key(*, allow_lmstudio_default: bool) -> str: else: prompt = f"{key_env} (or Enter to cancel): " try: - entered = getpass.getpass(prompt).strip() + entered = read_secret_line(prompt).strip() except (KeyboardInterrupt, EOFError): print() return "" @@ -4463,9 +4463,9 @@ def _model_flow_bedrock_api_key(config, region, current_model=""): print(f" Endpoint: {mantle_base_url}") print() try: - import getpass + from hermes_cli.cli_output import read_secret_line - api_key = getpass.getpass(" Bedrock API Key: ").strip() + api_key = read_secret_line(" Bedrock API Key: ").strip() except (KeyboardInterrupt, EOFError): print() return @@ -5006,9 +5006,9 @@ def _activate_claude_code_credentials_if_available() -> bool: print(" If the setup-token was displayed above, paste it here:") print() try: - import getpass + from hermes_cli.cli_output import read_secret_line - manual_token = getpass.getpass( + manual_token = read_secret_line( " Paste setup-token (or Enter to cancel): " ).strip() except (KeyboardInterrupt, EOFError): @@ -5037,9 +5037,9 @@ def _activate_claude_code_credentials_if_available() -> bool: print(" Or paste an existing setup-token now (sk-ant-oat-...):") print() try: - import getpass + from hermes_cli.cli_output import read_secret_line - token = getpass.getpass(" Setup-token (or Enter to cancel): ").strip() + token = read_secret_line(" Setup-token (or Enter to cancel): ").strip() except (KeyboardInterrupt, EOFError): print() return False @@ -5140,9 +5140,9 @@ def _model_flow_anthropic(config, current_model=""): print(" Get an API key at: https://platform.claude.com/settings/keys") print() try: - import getpass + from hermes_cli.cli_output import read_secret_line - api_key = getpass.getpass(" API key (sk-ant-...): ").strip() + api_key = read_secret_line(" API key (sk-ant-...): ").strip() except (KeyboardInterrupt, EOFError): print() return diff --git a/hermes_cli/memory_setup.py b/hermes_cli/memory_setup.py index 7b2c60672883..3590c0c2dbd6 100644 --- a/hermes_cli/memory_setup.py +++ b/hermes_cli/memory_setup.py @@ -7,11 +7,11 @@ from __future__ import annotations -import getpass import os import sys from pathlib import Path +from hermes_cli.cli_output import read_secret_line from hermes_constants import get_hermes_home @@ -41,7 +41,7 @@ def _prompt(label: str, default: str | None = None, secret: bool = False) -> str sys.stdout.write(f" {label}{suffix}: ") sys.stdout.flush() if sys.stdin.isatty(): - val = getpass.getpass(prompt="") + val = read_secret_line(prompt="") else: val = sys.stdin.readline().strip() else: diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 675989d170e4..56b5b42620ad 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -267,8 +267,8 @@ def _prompt_plugin_env_vars(manifest: dict, console) -> None: try: if secret: - import getpass - value = getpass.getpass(f" {name}: ").strip() + from hermes_cli.cli_output import read_secret_line + value = read_secret_line(f" {name}: ").strip() else: value = input(f" {name}: ").strip() except (EOFError, KeyboardInterrupt): diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index df4e88e0006a..55dbdd80388f 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -202,9 +202,9 @@ def prompt(question: str, default: str = None, password: bool = False) -> str: try: if password: - import getpass + from hermes_cli.cli_output import read_secret_line - value = getpass.getpass(color(display, Colors.YELLOW)) + value = read_secret_line(color(display, Colors.YELLOW)) else: value = input(color(display, Colors.YELLOW)) diff --git a/tests/hermes_cli/test_read_secret_line.py b/tests/hermes_cli/test_read_secret_line.py new file mode 100644 index 000000000000..d69499312317 --- /dev/null +++ b/tests/hermes_cli/test_read_secret_line.py @@ -0,0 +1,38 @@ +"""Tests for ``read_secret_line()`` in hermes_cli.cli_output. + +Guards against the regression where ``getpass.getpass()`` on Windows +leaks ``\\x00`` + scan-code sequences (from ``msvcrt.getwch()``) into +the returned string, producing API-key values that cannot be assigned +to ``os.environ`` (``ValueError: embedded null character``) or sent as +HTTP headers (ASCII encode error). +""" + +from unittest.mock import patch + +from hermes_cli.cli_output import read_secret_line + + +def test_strips_null_and_scan_code_pair(): + """Left-Arrow on Windows console emits ``\\x00K`` — both bytes must go.""" + with patch("hermes_cli.cli_output.getpass.getpass", return_value="\x00Ksk-test-abc"): + assert read_secret_line("prompt: ") == "Ksk-test-abc" + + +def test_strips_all_ascii_control_chars(): + """Every ``\\x00``-``\\x1f`` byte should be removed.""" + raw = "".join(chr(i) for i in range(32)) + "real-value" + with patch("hermes_cli.cli_output.getpass.getpass", return_value=raw): + assert read_secret_line("prompt: ") == "real-value" + + +def test_preserves_normal_input(): + """Regular printable input must pass through untouched.""" + with patch("hermes_cli.cli_output.getpass.getpass", return_value="sk-ant-xyz123"): + assert read_secret_line("prompt: ") == "sk-ant-xyz123" + + +def test_passes_prompt_through(): + """The prompt argument must reach ``getpass.getpass`` verbatim.""" + with patch("hermes_cli.cli_output.getpass.getpass", return_value="x") as mock: + read_secret_line("API key: ") + mock.assert_called_once_with("API key: ")