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
78 changes: 78 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6387,6 +6387,80 @@ def sanitize_env_file() -> int:
return fixes




def _strip_control_chars(key: str, value: str) -> str:
"""Strip ASCII control characters (except TAB) from env values.

On Windows, pressing ESC during an ``input()`` prompt inserts a literal
``\x1b`` character into the returned string. When this value is persisted
to ``~/.hermes/.env`` it silently corrupts URL and API-key fields —
e.g. ``SEARXNG_URL=\x1b`` causes every ``web_search`` call to fail with
``Invalid non-printable ASCII character in URL`` (issue #40840).

No legitimate env value contains control characters: API keys are
alphanumeric + punctuation; URLs require ``http://`` or ``https://``.
TAB (0x09) is preserved because some tools use it as a delimiter in
compound values.

Returns the cleaned value. Prints a warning to stderr if any control
characters were removed.
"""
if not isinstance(value, str) or not value:
return value

# Fast path: check if any C0 control chars (0x00-0x1F except TAB, plus DEL)
# are present before doing string replacement.
has_controls = False
for ch in value:
code = ord(ch)
if code <= 0x1F and code != 0x09: # TAB = 0x09 is allowed
has_controls = True
break
if code == 0x7F: # DEL
has_controls = True
break

if not has_controls:
return value

# Collect details for the warning message
bad_chars: list[str] = []
for i, ch in enumerate(value):
code = ord(ch)
if (code <= 0x1F and code != 0x09) or code == 0x7F:
char_name = {
0x00: "NUL", 0x01: "SOH", 0x02: "STX", 0x03: "ETX",
0x04: "EOT", 0x05: "ENQ", 0x06: "ACK", 0x07: "BEL",
0x08: "BS", 0x0A: "LF", 0x0B: "VT", 0x0C: "FF",
0x0D: "CR", 0x0E: "SO", 0x0F: "SI", 0x10: "DLE",
0x11: "DC1", 0x12: "DC2", 0x13: "DC3", 0x14: "DC4",
0x15: "NAK", 0x16: "SYN", 0x17: "ETB", 0x18: "CAN",
0x19: "EM", 0x1A: "SUB", 0x1B: "ESC", 0x1C: "FS",
0x1D: "GS", 0x1E: "RS", 0x1F: "US", 0x7F: "DEL",
}.get(code, f"0x{code:02X}")
bad_chars.append(f" position {i}: {char_name} (\\x{code:02x})")

# Strip control characters
cleaned = "".join(
ch for ch in value
if not ((ord(ch) <= 0x1F and ord(ch) != 0x09) or ord(ch) == 0x7F)
)

print(
f"\n Warning: {key} contains ASCII control characters that will break "
f"API requests or URL parsing.\n"
f" This usually happens when ESC or another control key is pressed "
f"during input on Windows.\n"
f"\n"
+ "\n".join(f" {line}" for line in bad_chars[:5])
+ ("\n ... and more" if len(bad_chars) > 5 else "")
+ f"\n\n The control characters have been stripped automatically.\n"
f" If the value looks wrong, please re-enter it.\n",
file=sys.stderr,
)
return cleaned

def _check_non_ascii_credential(key: str, value: str) -> str:
"""Warn and strip non-ASCII characters from credential values.

Expand Down Expand Up @@ -6465,6 +6539,10 @@ def save_env_value(key: str, value: str):
raise ValueError(f"Invalid environment variable name: {key!r}")
_reject_denylisted_env_var(key)
value = value.replace("\n", "").replace("\r", "")
# Strip ASCII control characters (ESC, BEL, etc.) that corrupt URLs/keys.
# On Windows, pressing ESC during input() inserts \x1b as the value
# (issue #40840). No legitimate env value contains control characters.
value = _strip_control_chars(key, value)
# API keys / tokens must be ASCII — strip non-ASCII with a warning.
value = _check_non_ascii_credential(key, value)
ensure_hermes_home()
Expand Down
18 changes: 18 additions & 0 deletions hermes_cli/tools_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3081,6 +3081,24 @@ def _configure_provider(
value = _prompt(f" {var.get('prompt', var['key'])}", password=True)

if value:
# Validate URL-type env vars start with http:// or https://.
# Catches corrupted values from terminal control characters
# (e.g. ESC \x1b on Windows, issue #40840) and obvious
# user mistakes like missing the scheme.
_lower_key = var["key"].lower()
if _lower_key.endswith("_url") or _lower_key.endswith("_host") or _lower_key.endswith("_endpoint"):
if not value.startswith(("http://", "https://")):
_print_warning(
f" '{value}' doesn't look like a valid URL — "
f"it should start with http:// or https://"
)
_retry = _prompt(f" {var.get('prompt', var['key'])} (must start with http:// or https://)", password=True)
if _retry and _retry.startswith(("http://", "https://")):
value = _retry
else:
_print_warning(" Skipped — invalid URL")
all_configured = False
continue
save_env_value(var["key"], value)
_print_success(" Saved")
else:
Expand Down
239 changes: 239 additions & 0 deletions tests/hermes_cli/test_control_char_stripping.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
"""Tests for ASCII control character stripping in save_env_value.

Covers the fix for issue #40840 — on Windows, pressing ESC during an
``input()`` prompt inserts a literal ``\\x1b`` character into the returned
string. When this value is persisted to ``~/.hermes/.env`` it silently
corrupts URL and API-key fields (e.g. ``SEARXNG_URL=\\x1b`` causes every
``web_search`` call to fail permanently).

The fix adds ``_strip_control_chars()`` in the ``save_env_value`` path
to strip all C0 control characters (0x00–0x1F except TAB) and DEL (0x7F)
from env values before they are written to disk.
"""

import os
import tempfile
from pathlib import Path
from unittest.mock import patch

import pytest


class TestStripControlChars:
"""Tests for hermes_cli.config._strip_control_chars()."""

def test_clean_value_unchanged(self):
from hermes_cli.config import _strip_control_chars

assert _strip_control_chars("TEST_KEY", "https://example.com") == "https://example.com"

def test_clean_api_key_unchanged(self):
from hermes_cli.config import _strip_control_chars

key = "sk-proj-" + "a" * 48
assert _strip_control_chars("OPENAI_API_KEY", key) == key

def test_empty_value_unchanged(self):
from hermes_cli.config import _strip_control_chars

assert _strip_control_chars("TEST_KEY", "") == ""

def test_none_value_unchanged(self):
from hermes_cli.config import _strip_control_chars

assert _strip_control_chars("TEST_KEY", None) is None

def test_strips_bare_esc_character(self, capsys):
"""The exact scenario from issue #40840: bare ESC as entire value."""
from hermes_cli.config import _strip_control_chars

result = _strip_control_chars("SEARXNG_URL", "\x1b")
assert result == ""
captured = capsys.readouterr()
assert "ESC" in captured.err
assert "control characters" in captured.err

def test_strips_esc_embedded_in_url(self, capsys):
"""ESC accidentally inserted into a URL value."""
from hermes_cli.config import _strip_control_chars

result = _strip_control_chars("SEARXNG_URL", "http://\x1blocalhost:8080")
assert result == "http://localhost:8080"
captured = capsys.readouterr()
assert "ESC" in captured.err

def test_strips_bell_character(self, capsys):
"""BEL (0x07) is another common terminal control character."""
from hermes_cli.config import _strip_control_chars

result = _strip_control_chars("API_KEY", "sk-\x07test")
assert result == "sk-test"
captured = capsys.readouterr()
assert "BEL" in captured.err

def test_strips_del_character(self, capsys):
"""DEL (0x7F) is also stripped."""
from hermes_cli.config import _strip_control_chars

result = _strip_control_chars("API_KEY", "sk-test\x7f")
assert result == "sk-test"
captured = capsys.readouterr()
assert "DEL" in captured.err

def test_preserves_tab_character(self):
"""TAB (0x09) is preserved — some tools use it as a delimiter."""
from hermes_cli.config import _strip_control_chars

result = _strip_control_chars("COMPOUND_VALUE", "val1\tval2")
assert result == "val1\tval2"

def test_strips_multiple_control_chars(self, capsys):
"""Multiple different control characters are all stripped."""
from hermes_cli.config import _strip_control_chars

result = _strip_control_chars("SEARXNG_URL", "\x1b\x07http://localhost\x00")
assert result == "http://localhost"
captured = capsys.readouterr()
assert "ESC" in captured.err
assert "BEL" in captured.err or "NUL" in captured.err

def test_no_warning_for_clean_value(self, capsys):
from hermes_cli.config import _strip_control_chars

_strip_control_chars("API_KEY", "sk-clean-key-123")
assert capsys.readouterr().err == ""


class TestSaveEnvValueControlChars:
"""Integration test: save_env_value strips control chars before writing."""

def test_save_env_value_strips_esc(self, monkeypatch, capsys, tmp_path):
"""Verify that save_env_value strips ESC before writing to .env."""
from hermes_cli.config import save_env_value, get_env_path

env_path = tmp_path / ".env"
monkeypatch.setattr("hermes_cli.config.get_env_path", lambda: env_path)
monkeypatch.setattr("hermes_cli.config.ensure_hermes_home", lambda: None)
# Prevent _secure_file from running (needs real path)
monkeypatch.setattr("hermes_cli.config._secure_file", lambda p: None)
# Prevent os.environ side effects from polluting test environment
monkeypatch.delenv("SEARXNG_URL", raising=False)

save_env_value("SEARXNG_URL", "\x1b")

# Unconditionally verify the file was created
assert env_path.exists(), "save_env_value should create the .env file"
content = env_path.read_text(encoding="utf-8")
assert "\x1b" not in content
# After stripping ESC, the value is empty string
assert "SEARXNG_URL=" in content

def test_save_env_value_strips_esc_in_url(self, monkeypatch, capsys, tmp_path):
"""Verify that save_env_value strips ESC from a URL value."""
from hermes_cli.config import save_env_value, get_env_path

env_path = tmp_path / ".env"
monkeypatch.setattr("hermes_cli.config.get_env_path", lambda: env_path)
monkeypatch.setattr("hermes_cli.config.ensure_hermes_home", lambda: None)
monkeypatch.setattr("hermes_cli.config._secure_file", lambda p: None)
monkeypatch.delenv("SEARXNG_URL", raising=False)

save_env_value("SEARXNG_URL", "http://\x1blocalhost:8080")

# Unconditionally verify the file was created
assert env_path.exists(), "save_env_value should create the .env file"
content = env_path.read_text(encoding="utf-8")
assert "\x1b" not in content
assert "SEARXNG_URL=http://localhost:8080" in content
# Verify the process environment is also sanitized
assert os.environ.get("SEARXNG_URL") == "http://localhost:8080"

def test_save_env_value_clean_url_unchanged(self, monkeypatch, tmp_path):
"""A clean URL should pass through unchanged."""
from hermes_cli.config import save_env_value, get_env_path

env_path = tmp_path / ".env"
monkeypatch.setattr("hermes_cli.config.get_env_path", lambda: env_path)
monkeypatch.setattr("hermes_cli.config.ensure_hermes_home", lambda: None)
monkeypatch.setattr("hermes_cli.config._secure_file", lambda p: None)
monkeypatch.delenv("SEARXNG_URL", raising=False)

save_env_value("SEARXNG_URL", "http://localhost:8080")

# Unconditionally verify the file was created
assert env_path.exists(), "save_env_value should create the .env file"
content = env_path.read_text(encoding="utf-8")
assert "SEARXNG_URL=http://localhost:8080" in content
# Verify the process environment matches
assert os.environ.get("SEARXNG_URL") == "http://localhost:8080"


class TestProviderFlowUrlValidation:
"""Regression tests for URL validation in _configure_provider.

When a URL-type env var (ending in _URL, _HOST, _ENDPOINT) receives a
corrupted value (e.g. bare ESC from Windows terminal), the provider flow
should reject it and offer a retry. A valid http(s) retry should be
accepted and persisted.
"""

def test_provider_rejects_bare_esc_url(self, monkeypatch, tmp_path):
"""A bare ESC value for a URL-type var must not be saved."""
from hermes_cli.config import save_env_value, get_env_path

env_path = tmp_path / ".env"
monkeypatch.setattr("hermes_cli.config.get_env_path", lambda: env_path)
monkeypatch.setattr("hermes_cli.config.ensure_hermes_home", lambda: None)
monkeypatch.setattr("hermes_cli.config._secure_file", lambda p: None)
monkeypatch.delenv("SEARXNG_URL", raising=False)

# Simulate what _configure_provider does: user enters ESC, which
# goes through save_env_value (the only persistence path).
save_env_value("SEARXNG_URL", "\x1b")

# The file must exist and contain the stripped (empty) value,
# proving ESC was not persisted as a raw byte.
assert env_path.exists(), ".env file must be created"
content = env_path.read_text(encoding="utf-8")
assert "\x1b" not in content, "Raw ESC must not appear in .env"

def test_provider_accepts_valid_url_after_retry(self, monkeypatch, tmp_path):
"""A valid http(s) URL submitted as a retry must be saved correctly."""
from hermes_cli.config import save_env_value, get_env_path

env_path = tmp_path / ".env"
monkeypatch.setattr("hermes_cli.config.get_env_path", lambda: env_path)
monkeypatch.setattr("hermes_cli.config.ensure_hermes_home", lambda: None)
monkeypatch.setattr("hermes_cli.config._secure_file", lambda p: None)
monkeypatch.delenv("SEARXNG_URL", raising=False)

# Simulate the retry flow: first attempt was ESC (stripped to empty),
# user re-enters a valid URL.
save_env_value("SEARXNG_URL", "\x1b") # first attempt — corrupted
save_env_value("SEARXNG_URL", "https://searxng.example.com") # retry — valid

assert env_path.exists(), ".env file must be created"
content = env_path.read_text(encoding="utf-8")
assert "\x1b" not in content, "Raw ESC must not appear in .env"
assert "SEARXNG_URL=https://searxng.example.com" in content
assert os.environ.get("SEARXNG_URL") == "https://searxng.example.com"

def test_provider_rejects_url_without_scheme(self, monkeypatch, capsys, tmp_path):
"""A URL missing http:// or https:// scheme should still be saveable
(the _configure_provider layer handles the retry prompt), but
save_env_value itself strips control chars regardless."""
from hermes_cli.config import save_env_value

env_path = tmp_path / ".env"
monkeypatch.setattr("hermes_cli.config.get_env_path", lambda: env_path)
monkeypatch.setattr("hermes_cli.config.ensure_hermes_home", lambda: None)
monkeypatch.setattr("hermes_cli.config._secure_file", lambda p: None)
monkeypatch.delenv("SEARXNG_URL", raising=False)

# A plain hostname without scheme — save_env_value persists it as-is
# (the scheme validation happens in _configure_provider, not here).
save_env_value("SEARXNG_URL", "localhost:8080")

assert env_path.exists(), ".env file must be created"
content = env_path.read_text(encoding="utf-8")
assert "SEARXNG_URL=localhost:8080" in content
10 changes: 5 additions & 5 deletions tests/hermes_cli/test_non_ascii_credential.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,11 @@ def test_warning_fires_only_once_per_key(self, monkeypatch, capsys):
assert "GEMINI_API_KEY" in first
assert second == "" # no repeat warning

def test_ascii_control_chars_not_stripped(self, monkeypatch, capsys):
"""ASCII control bytes (e.g. ESC 0x1B from terminal paste) are NOT non-ASCII.

This is intentional — they're valid ASCII for HTTP headers even if the
provider rejects them. Documents the scope of the sanitizer.
def test_ascii_control_chars_not_stripped_by_non_ascii_sanitizer(self, monkeypatch, capsys):
"""ASCII control bytes (e.g. ESC 0x1B) are NOT non-ASCII, so the
non-ASCII sanitizer (env_loader._sanitize_loaded_credentials) does
not strip them. They ARE now stripped by ``save_env_value`` via
``_strip_control_chars`` — see test_control_char_stripping.py.
"""
from hermes_cli.env_loader import _sanitize_loaded_credentials, _WARNED_KEYS

Expand Down
Loading