{"repo":"NousResearch/hermes-agent","public_id":"30355","display_label":"Quote .env values to prevent hash truncation","summary":"These issues and PRs address a bug where special characters like # in .env values (e.g., API tokens) cause truncation when written to .env files. The fix involves quoting .env values to preserve their full content.","subsystem":"hermes_cli","counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":52653},"resolution":{"state":"resolved","by":"pr-52653"},"freshness":{"state":"verified","last_verified_at":"2026-06-25T20:02:50+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.2857142857142857},"fix_edges":[{"issue_number":30355,"pr_number":52653,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":30355,"title":"ANTHROPIC_TOKEN containing # character gets truncated in .env file","url":null,"state":"closed"},{"kind":"pr","number":30357,"title":"fix(config): quote .env values containing # to prevent truncation (#30355)","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":30473,"title":"fix(config): quote dotenv values containing hash","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#30357","number":30357},{"kind":"pr","value":"#30355","number":30355}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":30724,"title":"fix(cli): quote .env values to preserve special characters like #","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#52653","number":52653},{"kind":"pr","value":"#33814","number":33814}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":30784,"title":"fix(cli): quote .env writes, widen Gemini vision allowlist, propagate skills install exit code","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#30357","number":30357},{"kind":"pr","value":"#30724","number":30724},{"kind":"pr","value":"#30712","number":30712},{"kind":"pr","value":"#30633","number":30633}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":33814,"title":"fix(config): quote .env values containing # to prevent truncation","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#30357","number":30357},{"kind":"pr","value":"#30355","number":30355},{"kind":"pr","value":"#30747","number":30747},{"kind":"pr","value":"#30473","number":30473}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":52653,"title":"fix(config): quote .env values containing # to prevent token truncation (#30355)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/config.py","tests/hermes_cli/test_config.py"],"fix_hunks":[{"file":"hermes_cli/config.py","hunks":["@@ -6095,6 +6095,29 @@ def save_config(config: Dict[str, Any]):\n         _LAST_EXPANDED_CONFIG_BY_PATH[str(config_path)] = copy.deepcopy(current_normalized)\n \n \n+def _parse_env_value(raw_value: str) -> str:\n+    \"\"\"Parse the small .env value subset Hermes writes itself.\"\"\"\n+    value = raw_value.strip()\n+    if len(value) >= 2 and value[0] == value[-1] == '\"':\n+        quoted = value[1:-1]\n+        parsed: list[str] = []\n+        i = 0\n+        while i < len(quoted):\n+            ch = quoted[i]\n+            if ch == \"\\\\\" and i + 1 < len(quoted):\n+                next_ch = quoted[i + 1]\n+                if next_ch in {'\"', \"\\\\\"}:\n+                    parsed.append(next_ch)\n+                    i += 2\n+                    continue\n+            parsed.append(ch)\n+            i += 1\n+        return \"\".join(parsed)\n+    if len(value) >= 2 and value[0] == value[-1] == \"'\":\n+        return value[1:-1]\n+    return value\n+\n+\n def load_env() -> Dict[str, str]:\n     \"\"\"Load environment variables from ~/.hermes/.env.\n ","@@ -6144,7 +6167,7 @@ def load_env() -> Dict[str, str]:\n             line = line.strip()\n             if line and not line.startswith('#') and '=' in line:\n                 key, _, value = line.partition('=')\n-                env_vars[key.strip()] = value.strip().strip('\"\\'')\n+                env_vars[key.strip()] = _parse_env_value(value)\n \n     if cache_key is not None:\n         _env_cache = (cache_key, dict(env_vars))","@@ -6318,6 +6341,22 @@ def _check_non_ascii_credential(key: str, value: str) -> str:\n     return sanitized\n \n \n+def _quote_env_value(value: str) -> str:\n+    \"\"\"Quote .env values containing characters with special dotenv meaning.\"\"\"\n+    if value == \"\":\n+        return value\n+    needs_quoting = (\n+        \"#\" in value\n+        or '\"' in value\n+        or \"'\" in value\n+        or value != value.strip()\n+    )\n+    if not needs_quoting:\n+        return value\n+    escaped = value.replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n+    return f'\"{escaped}\"'\n+\n+\n def save_env_value(key: str, value: str):\n     \"\"\"Save or update a value in ~/.hermes/.env.\"\"\"\n     if is_managed():","@@ -6357,11 +6396,13 @@ def save_env_value(key: str, value: str):\n         # Sanitize on every read: split concatenated keys, drop stale placeholders\n         lines = _sanitize_env_lines(lines)\n \n+    serialized_value = _quote_env_value(value)\n+\n     # Find and update or append\n     found = False\n     for i, line in enumerate(lines):\n         if line.strip().startswith(f\"{key}=\"):\n-            lines[i] = f\"{key}={value}\\n\"\n+            lines[i] = f\"{key}={serialized_value}\\n\"\n             found = True\n             break\n ","@@ -6369,7 +6410,7 @@ def save_env_value(key: str, value: str):\n         # Ensure there's a newline at the end of the file before appending\n         if lines and not lines[-1].endswith(\"\\n\"):\n             lines[-1] += \"\\n\"\n-        lines.append(f\"{key}={value}\\n\")\n+        lines.append(f\"{key}={serialized_value}\\n\")\n     \n     fd, tmp_path = tempfile.mkstemp(dir=str(env_path.parent), suffix='.tmp', prefix='.env_')\n     # Preserve original permissions so Docker volume mounts aren't clobbered."]},{"file":"tests/hermes_cli/test_config.py","hunks":["@@ -354,6 +354,55 @@ def test_save_env_value_preserves_existing_file_mode_on_posix(self, tmp_path):\n         env_mode = env_path.stat().st_mode & 0o777\n         assert env_mode == 0o640, f\"expected 0o640, got {oct(env_mode)}\"\n \n+    def test_save_env_value_quotes_values_containing_hash(self, tmp_path):\n+        \"\"\"Regression test for #30355.\"\"\"\n+        from dotenv import dotenv_values\n+\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}, clear=False):\n+            os.environ.pop(\"ANTHROPIC_TOKEN\", None)\n+            token = \"[redacted:secret-assignment]\"\n+            save_env_value(\"ANTHROPIC_TOKEN\", token)\n+\n+            content = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n+            assert f'ANTHROPIC_TOKEN=\"[redacted:secret-assignment]\"' in content\n+\n+            parsed = dotenv_values(str(tmp_path / \".env\"))\n+            assert parsed[\"ANTHROPIC_TOKEN\"] == token\n+            assert load_env()[\"ANTHROPIC_TOKEN\"] == token\n+\n+    def test_save_env_value_hash_value_round_trips_quotes_and_backslashes(self, tmp_path):\n+        from dotenv import dotenv_values\n+\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}, clear=False):\n+            os.environ.pop(\"ANTHROPIC_TOKEN\", None)\n+            token = 'abc\"def\\\\ghi#jkl'\n+            save_env_value(\"ANTHROPIC_TOKEN\", token)\n+\n+            content = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n+            assert 'ANTHROPIC_TOKEN=\"abc\\\\\"def\\\\\\\\ghi#jkl\"' in content\n+\n+            parsed = dotenv_values(str(tmp_path / \".env\"))\n+            assert parsed[\"ANTHROPIC_TOKEN\"] == token\n+            assert load_env()[\"ANTHROPIC_TOKEN\"] == token\n+\n+    def test_save_env_value_updates_hash_value_with_quotes(self, tmp_path):\n+        from dotenv import dotenv_values\n+\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}, clear=False):\n+            os.environ.pop(\"ANTHROPIC_TOKEN\", None)\n+            save_env_value(\"ANTHROPIC_TOKEN\", \"old-token\")\n+\n+            token = 'abc\"def\\\\ghi#jkl'\n+            save_env_value(\"ANTHROPIC_TOKEN\", token)\n+\n+            content = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n+            assert content.count(\"ANTHROPIC_TOKEN=\") == 1\n+            assert 'ANTHROPIC_TOKEN=\"abc\\\\\"def\\\\\\\\ghi#jkl\"' in content\n+\n+            parsed = dotenv_values(str(tmp_path / \".env\"))\n+            assert parsed[\"ANTHROPIC_TOKEN\"] == token\n+            assert load_env()[\"ANTHROPIC_TOKEN\"] == token\n+\n \n class TestRemoveEnvValue:\n     def test_removes_key_from_env_file(self, tmp_path):\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}