Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
162 changes: 162 additions & 0 deletions tests/hermes_cli/test_control_char_stripping.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"""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")

# The value written to .env should be empty (ESC stripped)
if env_path.exists():

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 conditional makes the integration test pass if save_env_value() returns without creating .env. Assert env_path.exists() unconditionally before reading it, then verify the persisted value (and ideally os.environ); the identical guards below should be changed too.

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")

if env_path.exists():
content = env_path.read_text(encoding="utf-8")
assert "\x1b" not in content
assert "SEARXNG_URL=http://localhost:8080" in content

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")

if env_path.exists():
content = env_path.read_text(encoding="utf-8")
assert "SEARXNG_URL=http://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