Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions hermes_cli/auth_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions hermes_cli/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 = ""

Expand Down
23 changes: 22 additions & 1 deletion hermes_cli/cli_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)})

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This removes the \x00 prefix but leaves the printable scan-code byte (K in the documented left-arrow sequence), so the corrupted secret is still modified. Consume the following character when the input contains a Windows \x00 or \xe0 special-key prefix; the paired-sequence test should then expect sk-test-abc.



def prompt(
question: str,
default: str | None = None,
Expand All @@ -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()
Expand Down
8 changes: 4 additions & 4 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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:
Expand Down
44 changes: 22 additions & 22 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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", {})
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 ""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions hermes_cli/memory_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions hermes_cli/plugins_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
4 changes: 2 additions & 2 deletions hermes_cli/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
38 changes: 38 additions & 0 deletions tests/hermes_cli/test_read_secret_line.py
Original file line number Diff line number Diff line change
@@ -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: ")