diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 06e3ad5d7b9..d4dc111be97 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -6095,6 +6095,29 @@ def save_config(config: Dict[str, Any]): _LAST_EXPANDED_CONFIG_BY_PATH[str(config_path)] = copy.deepcopy(current_normalized) +def _parse_env_value(raw_value: str) -> str: + """Parse the small .env value subset Hermes writes itself.""" + value = raw_value.strip() + if len(value) >= 2 and value[0] == value[-1] == '"': + quoted = value[1:-1] + parsed: list[str] = [] + i = 0 + while i < len(quoted): + ch = quoted[i] + if ch == "\\" and i + 1 < len(quoted): + next_ch = quoted[i + 1] + if next_ch in {'"', "\\"}: + parsed.append(next_ch) + i += 2 + continue + parsed.append(ch) + i += 1 + return "".join(parsed) + if len(value) >= 2 and value[0] == value[-1] == "'": + return value[1:-1] + return value + + def load_env() -> Dict[str, str]: """Load environment variables from ~/.hermes/.env. @@ -6144,7 +6167,7 @@ def load_env() -> Dict[str, str]: line = line.strip() if line and not line.startswith('#') and '=' in line: key, _, value = line.partition('=') - env_vars[key.strip()] = value.strip().strip('"\'') + env_vars[key.strip()] = _parse_env_value(value) if cache_key is not None: _env_cache = (cache_key, dict(env_vars)) @@ -6318,6 +6341,22 @@ def _check_non_ascii_credential(key: str, value: str) -> str: return sanitized +def _quote_env_value(value: str) -> str: + """Quote .env values containing characters with special dotenv meaning.""" + if value == "": + return value + needs_quoting = ( + "#" in value + or '"' in value + or "'" in value + or value != value.strip() + ) + if not needs_quoting: + return value + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + def save_env_value(key: str, value: str): """Save or update a value in ~/.hermes/.env.""" if is_managed(): @@ -6357,11 +6396,13 @@ def save_env_value(key: str, value: str): # Sanitize on every read: split concatenated keys, drop stale placeholders lines = _sanitize_env_lines(lines) + serialized_value = _quote_env_value(value) + # Find and update or append found = False for i, line in enumerate(lines): if line.strip().startswith(f"{key}="): - lines[i] = f"{key}={value}\n" + lines[i] = f"{key}={serialized_value}\n" found = True break @@ -6369,7 +6410,7 @@ def save_env_value(key: str, value: str): # Ensure there's a newline at the end of the file before appending if lines and not lines[-1].endswith("\n"): lines[-1] += "\n" - lines.append(f"{key}={value}\n") + lines.append(f"{key}={serialized_value}\n") fd, tmp_path = tempfile.mkstemp(dir=str(env_path.parent), suffix='.tmp', prefix='.env_') # Preserve original permissions so Docker volume mounts aren't clobbered. diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index b343249bd1d..7e938836b81 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -354,6 +354,55 @@ class TestSaveEnvValueSecure: env_mode = env_path.stat().st_mode & 0o777 assert env_mode == 0o640, f"expected 0o640, got {oct(env_mode)}" + def test_save_env_value_quotes_values_containing_hash(self, tmp_path): + """Regression test for #30355.""" + from dotenv import dotenv_values + + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False): + os.environ.pop("ANTHROPIC_TOKEN", None) + token = "sk-ant-oat01-abc#xyz#more" + save_env_value("ANTHROPIC_TOKEN", token) + + content = (tmp_path / ".env").read_text(encoding="utf-8") + assert f'ANTHROPIC_TOKEN="{token}"' in content + + parsed = dotenv_values(str(tmp_path / ".env")) + assert parsed["ANTHROPIC_TOKEN"] == token + assert load_env()["ANTHROPIC_TOKEN"] == token + + def test_save_env_value_hash_value_round_trips_quotes_and_backslashes(self, tmp_path): + from dotenv import dotenv_values + + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False): + os.environ.pop("ANTHROPIC_TOKEN", None) + token = 'abc"def\\ghi#jkl' + save_env_value("ANTHROPIC_TOKEN", token) + + content = (tmp_path / ".env").read_text(encoding="utf-8") + assert 'ANTHROPIC_TOKEN="abc\\"def\\\\ghi#jkl"' in content + + parsed = dotenv_values(str(tmp_path / ".env")) + assert parsed["ANTHROPIC_TOKEN"] == token + assert load_env()["ANTHROPIC_TOKEN"] == token + + def test_save_env_value_updates_hash_value_with_quotes(self, tmp_path): + from dotenv import dotenv_values + + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False): + os.environ.pop("ANTHROPIC_TOKEN", None) + save_env_value("ANTHROPIC_TOKEN", "old-token") + + token = 'abc"def\\ghi#jkl' + save_env_value("ANTHROPIC_TOKEN", token) + + content = (tmp_path / ".env").read_text(encoding="utf-8") + assert content.count("ANTHROPIC_TOKEN=") == 1 + assert 'ANTHROPIC_TOKEN="abc\\"def\\\\ghi#jkl"' in content + + parsed = dotenv_values(str(tmp_path / ".env")) + assert parsed["ANTHROPIC_TOKEN"] == token + assert load_env()["ANTHROPIC_TOKEN"] == token + class TestRemoveEnvValue: def test_removes_key_from_env_file(self, tmp_path):