Repository navigation
fix(config): strip ASCII control characters from env values before writing to .env #40887
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
yubingz
wants to merge
2
commits into
NousResearch:main
Choose a base branch
from
yubingz:fix/strip-control-chars-env-values
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 1 commit
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(): | ||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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. Assertenv_path.exists()unconditionally before reading it, then verify the persisted value (and ideallyos.environ); the identical guards below should be changed too.