fix(cli): show masked feedback for secret prompts

This commit is contained in:
helix4u 2026-05-24 17:15:03 -06:00 committed by Teknium
parent d952b377aa
commit ec4d6f1823
20 changed files with 310 additions and 61 deletions

View file

@ -2,7 +2,6 @@
from __future__ import annotations
from getpass import getpass
import math
import sys
import time
@ -30,6 +29,7 @@ from agent.credential_pool import (
import hermes_cli.auth as auth_mod
from hermes_cli.auth import PROVIDER_REGISTRY
from hermes_constants import OPENROUTER_BASE_URL
from hermes_cli.secret_prompt import masked_secret_prompt
# Providers that support OAuth login in addition to API keys.
@ -196,7 +196,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 = masked_secret_prompt("Paste your API key: ").strip()
if not token:
raise SystemExit("No API key provided.")
default_label = _api_key_default_label(len(pool.entries()) + 1)

View file

@ -8,10 +8,10 @@ with the TUI.
import queue
import time as _time
import getpass
from hermes_cli.banner import cprint, _DIM, _RST
from hermes_cli.config import save_env_value_secure
from hermes_cli.secret_prompt import masked_secret_prompt
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 = masked_secret_prompt(f"{prompt} (hidden, ESC or empty Enter to skip): ")
except (EOFError, KeyboardInterrupt):
value = ""

View file

@ -5,9 +5,8 @@ functions previously duplicated across setup.py, tools_config.py,
mcp_config.py, and memory_setup.py.
"""
import getpass
from hermes_cli.colors import Colors, color
from hermes_cli.secret_prompt import masked_secret_prompt
# ─── Print Helpers ────────────────────────────────────────────────────────────
@ -59,7 +58,7 @@ def prompt(
try:
if password:
value = getpass.getpass(display)
value = masked_secret_prompt(display)
else:
value = input(display)
value = value.strip()

View file

@ -26,6 +26,8 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Any, Optional, List, Tuple
from hermes_cli.secret_prompt import masked_secret_prompt
logger = logging.getLogger(__name__)
# Track which (config_path, mtime_ns, size) tuples we've already warned about
@ -4004,8 +4006,7 @@ 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']}: ")
value = masked_secret_prompt(f" {var['prompt']}: ")
else:
value = input(f" {var['prompt']}: ").strip()
@ -4056,8 +4057,9 @@ 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): ")
value = masked_secret_prompt(
f" {info.get('prompt', name)} (Enter to skip): "
)
else:
value = input(f" {info.get('prompt', name)} (Enter to skip): ").strip()
if value:

View file

@ -2803,7 +2803,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.secret_prompt import masked_secret_prompt
display_name = next((name for key, name, _ in _all_aux_tasks() if key == task), task)
current_base_url = str(task_cfg.get("base_url") or "").strip()
@ -2837,7 +2837,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 = masked_secret_prompt(
"API key (optional, blank = use OPENAI_API_KEY): "
).strip()
except (KeyboardInterrupt, EOFError):
@ -3561,6 +3561,7 @@ def _model_flow_custom(config):
"""
from hermes_cli.auth import _save_model_choice, deactivate_provider
from hermes_cli.config import get_env_value, load_config, save_config
from hermes_cli.secret_prompt import masked_secret_prompt
current_url = get_env_value("OPENAI_BASE_URL") or ""
current_key = get_env_value("OPENAI_API_KEY") or ""
@ -3576,9 +3577,7 @@ 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
api_key = getpass.getpass(
api_key = masked_secret_prompt(
f"API key [{current_key[:8] + '...' if current_key else 'optional'}]: "
).strip()
except (KeyboardInterrupt, EOFError):
@ -3990,7 +3989,6 @@ def _model_flow_azure_foundry(config, current_model=""):
save_config,
)
from hermes_cli import azure_detect
import getpass
# ── Load current Azure Foundry configuration ─────────────────────
model_cfg = config.get("model", {})
@ -4153,8 +4151,10 @@ def _model_flow_azure_foundry(config, current_model=""):
token_provider = None
else:
print()
from hermes_cli.secret_prompt import masked_secret_prompt
try:
api_key = getpass.getpass(
api_key = masked_secret_prompt(
f"API key [{current_api_key[:8] + '...' if current_api_key else 'required'}]: "
).strip()
except (KeyboardInterrupt, EOFError):
@ -4725,10 +4725,10 @@ def _model_flow_copilot(config, current_model=""):
print(f" Login failed: {exc}")
return
elif choice == "2":
try:
import getpass
from hermes_cli.secret_prompt import masked_secret_prompt
new_key = getpass.getpass(" Token (COPILOT_GITHUB_TOKEN): ").strip()
try:
new_key = masked_secret_prompt(" Token (COPILOT_GITHUB_TOKEN): ").strip()
except (KeyboardInterrupt, EOFError):
print()
return
@ -4980,10 +4980,9 @@ 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.auth import LMSTUDIO_NOAUTH_PLACEHOLDER
from hermes_cli.config import save_env_value
from hermes_cli.secret_prompt import masked_secret_prompt
key_env = pconfig.api_key_env_vars[0] if pconfig.api_key_env_vars else ""
@ -4993,7 +4992,7 @@ def _prompt_api_key(pconfig, existing_key: str, provider_id: str = "") -> tuple:
else:
prompt = f"{key_env} (or Enter to cancel): "
try:
entered = getpass.getpass(prompt).strip()
entered = masked_secret_prompt(prompt).strip()
except (KeyboardInterrupt, EOFError):
print()
return ""
@ -5308,10 +5307,10 @@ def _model_flow_bedrock_api_key(config, region, current_model=""):
else:
print(f" Endpoint: {mantle_base_url}")
print()
try:
import getpass
from hermes_cli.secret_prompt import masked_secret_prompt
api_key = getpass.getpass(" Bedrock API Key: ").strip()
try:
api_key = masked_secret_prompt(" Bedrock API Key: ").strip()
except (KeyboardInterrupt, EOFError):
print()
return
@ -5883,10 +5882,10 @@ def _run_anthropic_oauth_flow(save_env_value):
print()
print(" If the setup-token was displayed above, paste it here:")
print()
try:
import getpass
from hermes_cli.secret_prompt import masked_secret_prompt
manual_token = getpass.getpass(
try:
manual_token = masked_secret_prompt(
" Paste setup-token (or Enter to cancel): "
).strip()
except (KeyboardInterrupt, EOFError):
@ -5914,10 +5913,10 @@ def _run_anthropic_oauth_flow(save_env_value):
print()
print(" Or paste an existing setup-token now (sk-ant-oat-...):")
print()
try:
import getpass
from hermes_cli.secret_prompt import masked_secret_prompt
token = getpass.getpass(" Setup-token (or Enter to cancel): ").strip()
try:
token = masked_secret_prompt(" Setup-token (or Enter to cancel): ").strip()
except (KeyboardInterrupt, EOFError):
print()
return False
@ -6032,10 +6031,10 @@ def _model_flow_anthropic(config, current_model=""):
print()
print(" Get an API key at: https://platform.claude.com/settings/keys")
print()
try:
import getpass
from hermes_cli.secret_prompt import masked_secret_prompt
api_key = getpass.getpass(" API key (sk-ant-...): ").strip()
try:
api_key = masked_secret_prompt(" API key (sk-ant-...): ").strip()
except (KeyboardInterrupt, EOFError):
print()
return

View file

@ -7,13 +7,13 @@ the provider's config schema. Writes config to config.yaml + .env.
from __future__ import annotations
import getpass
import os
import sys
import shlex
from pathlib import Path
from hermes_constants import get_hermes_home
from hermes_cli.secret_prompt import masked_secret_prompt
# ---------------------------------------------------------------------------
@ -39,12 +39,7 @@ def _prompt(label: str, default: str | None = None, secret: bool = False) -> str
"""Prompt for a value with optional default and secret masking."""
suffix = f" [{default}]" if default else ""
if secret:
sys.stdout.write(f" {label}{suffix}: ")
sys.stdout.flush()
if sys.stdin.isatty():
val = getpass.getpass(prompt="")
else:
val = sys.stdin.readline().strip()
val = masked_secret_prompt(f" {label}{suffix}: ")
else:
sys.stdout.write(f" {label}{suffix}: ")
sys.stdout.flush()

View file

@ -20,6 +20,7 @@ from typing import Any, Optional
from hermes_constants import get_hermes_home
from hermes_cli.config import cfg_get
from hermes_cli.secret_prompt import masked_secret_prompt
logger = logging.getLogger(__name__)
@ -287,8 +288,7 @@ def _prompt_plugin_env_vars(manifest: dict, console) -> None:
try:
if secret:
import getpass
value = getpass.getpass(f" {name}: ").strip()
value = masked_secret_prompt(f" {name}: ").strip()
else:
value = input(f" {name}: ").strip()
except (EOFError, KeyboardInterrupt):

126
hermes_cli/secret_prompt.py Normal file
View file

@ -0,0 +1,126 @@
"""Secret input prompts with masked typing feedback."""
from __future__ import annotations
import getpass
import os
import sys
from collections.abc import Callable
_BACKSPACE_CHARS = {"\b", "\x7f"}
_ENTER_CHARS = {"\r", "\n"}
_EOF_CHARS = {"\x04", "\x1a"}
def _collect_masked_input(
read_char: Callable[[], str],
write: Callable[[str], object],
prompt: str,
*,
mask: str = "*",
) -> str:
"""Read one secret line while writing a mask character per typed char."""
value: list[str] = []
write(prompt)
while True:
ch = read_char()
if ch == "":
write("\n")
raise EOFError
if ch in _ENTER_CHARS:
write("\n")
return "".join(value)
if ch == "\x03":
write("\n")
raise KeyboardInterrupt
if ch in _EOF_CHARS:
write("\n")
raise EOFError
if ch in _BACKSPACE_CHARS:
if value:
value.pop()
write("\b \b")
continue
if ch == "\x1b":
# Ignore escape itself. Terminals commonly send escape-prefixed
# navigation/delete sequences; they should not become secret text.
continue
value.append(ch)
if mask:
write(mask)
def masked_secret_prompt(prompt: str, *, mask: str = "*") -> str:
"""Prompt for a secret while showing masked typing feedback.
Falls back to ``getpass.getpass`` when stdin/stdout are not interactive or
when raw terminal handling is unavailable.
"""
stdin = sys.stdin
stdout = sys.stdout
if not _stream_is_tty(stdin) or not _stream_is_tty(stdout):
return getpass.getpass(prompt)
if os.name == "nt":
try:
return _masked_secret_prompt_windows(prompt, mask=mask)
except (KeyboardInterrupt, EOFError):
raise
except Exception:
return getpass.getpass(prompt)
try:
return _masked_secret_prompt_posix(prompt, mask=mask)
except (KeyboardInterrupt, EOFError):
raise
except Exception:
return getpass.getpass(prompt)
def _stream_is_tty(stream) -> bool:
try:
return bool(stream.isatty())
except Exception:
return False
def _masked_secret_prompt_windows(prompt: str, *, mask: str) -> str:
import msvcrt
def read_char() -> str:
ch = msvcrt.getwch()
if ch in {"\x00", "\xe0"}:
msvcrt.getwch()
return "\x1b"
return ch
def write(text: str) -> None:
sys.stdout.write(text)
sys.stdout.flush()
return _collect_masked_input(read_char, write, prompt, mask=mask)
def _masked_secret_prompt_posix(prompt: str, *, mask: str) -> str:
import termios
import tty
fd = sys.stdin.fileno()
old_attrs = termios.tcgetattr(fd)
def read_char() -> str:
return sys.stdin.read(1)
def write(text: str) -> None:
sys.stdout.write(text)
sys.stdout.flush()
try:
tty.setraw(fd)
return _collect_masked_input(read_char, write, prompt, mask=mask)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_attrs)

View file

@ -11,7 +11,6 @@ Subcommands:
from __future__ import annotations
import argparse
import getpass
import json
import os
import subprocess
@ -30,6 +29,7 @@ from hermes_cli.config import (
save_config,
save_env_value,
)
from hermes_cli.secret_prompt import masked_secret_prompt
# ---------------------------------------------------------------------------
@ -140,7 +140,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
token = (args.access_token or "").strip()
if not token:
token = getpass.getpass(f" Paste access token ({token_env}): ").strip()
token = masked_secret_prompt(f" Paste access token ({token_env}): ").strip()
if not token:
console.print(" [red]Empty token, aborting.[/red]")
return 1

View file

@ -161,6 +161,7 @@ from hermes_cli.cli_output import ( # noqa: E402
print_success,
print_warning,
)
from hermes_cli.secret_prompt import masked_secret_prompt # noqa: E402
def is_interactive_stdin() -> bool:
@ -202,9 +203,7 @@ def prompt(question: str, default: str = None, password: bool = False) -> str:
try:
if password:
import getpass
value = getpass.getpass(color(display, Colors.YELLOW))
value = masked_secret_prompt(color(display, Colors.YELLOW))
else:
value = input(color(display, Colors.YELLOW))