{"repo":"NousResearch/hermes-agent","public_id":"2852","display_label":"LLM token redaction incorrectly masking env variable lookups","summary":"A bug where the redaction logic for LLM tokens or secrets incorrectly masks programmatic environment variable lookups (e.g., os.getenv, os.environ) and related KEY=value patterns. Multiple fixes attempt to exempt env-lookup expressions from redaction rules that were intended for assignment or config contexts.","subsystem":"agent","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":58915,"merged_at":"2026-07-05T20:58:20+00:00"},"resolution":{"state":"resolved","by":"pr-58534"},"freshness":{"state":"verified","last_verified_at":"2026-07-05T20:58:20+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":2852,"pr_number":58534,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-05T07:45:01+00:00"},{"issue_number":2852,"pr_number":58915,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-05T20:58:20+00:00"}],"members":[{"kind":"issue","number":2852,"title":"[Bug]: LLM token redaction breaking on .env handling on scripts","url":null,"state":"closed"},{"kind":"pr","number":2854,"title":"fix(redact): skip env-assignment redaction for os.getenv/os.environ lookups (#2852)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#2852","number":2852}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":58534,"title":"fix(redact): don't mask programmatic env lookups in KEY=value redaction (salvage #2854)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/redact.py","tests/agent/test_redact.py"],"fix_hunks":[{"file":"agent/redact.py","hunks":["@@ -137,6 +137,14 @@\n # The colon-form URL guard (skip when ``://`` present) lives at the call site.\n _SECRET_CFG_NAMES = r\"(?:api[ _.\\-]?key|token|secret|passwd|password|credential|auth)\"\n _CFG_VALUE = r\"(['\\\"]?)([^\\s&]+?)\\2(?=[\\s&]|$)\"\n+\n+# Programmatic env lookups (``os.getenv(...)``, ``os.environ[...]``,\n+# ``os.environ.get(...)``, ``process.env.X``, ``$ENV{X}``) reference variable\n+# *names*, not secret values. When one appears as the VALUE of a KEY=... match\n+# it's a code snippet, not a leaked secret — skip redaction (issue #2852).\n+_ENV_LOOKUP_VALUE_RE = re.compile(\n+    r\"^(?:os\\.(?:getenv|environ)|process\\.env|\\$ENV\\{)\"\n+)\n # Namespaced (dotted) key: the secret word may sit anywhere in a dotted path.\n _CFG_DOTTED_RE = re.compile(\n     rf\"((?:[A-Za-z0-9_\\-]+\\.)+[A-Za-z0-9_.\\-]*{_SECRET_CFG_NAMES}[A-Za-z0-9_.\\-]*\"","@@ -541,6 +549,11 @@ def redact_sensitive_text(\n         if \"=\" in text:\n             def _redact_env(m):\n                 name, quote, value = m.group(1), m.group(2), m.group(3)\n+                # Programmatic env lookups reference variable *names*, not\n+                # secret values — masking them corrupts code snippets in\n+                # prose/log contexts (issue #2852): ``KEY=os.getenv('X')``.\n+                if _ENV_LOOKUP_VALUE_RE.match(value):\n+                    return m.group(0)\n                 return f\"{name}={quote}{_mask_token(value)}{quote}\"\n             text = _ENV_ASSIGN_RE.sub(_redact_env, text)\n             # Lowercase/dotted config keys (issue #16413). Skip URLs entirely —"]},{"file":"tests/agent/test_redact.py","hunks":["@@ -124,6 +124,52 @@ def test_export_whitespace_preserved(self):\n         assert \"mypassword\" not in result\n \n \n+class TestEnvLookupPreserved:\n+    \"\"\"Programmatic env var lookups must not be corrupted (issue #2852).\"\"\"\n+\n+    def test_os_getenv_single_quote_uppercase_key(self):\n+        text = \"MY_API_KEY=os.getenv('OPENAI_API_KEY')\"\n+        assert redact_sensitive_text(text, force=True) == text\n+\n+    def test_os_getenv_lowercase_config_key(self):\n+        text = \"ha_token=os.getenv('HOMEASSISTANT_TOKEN')\"\n+        assert redact_sensitive_text(text, force=True) == text\n+\n+    def test_os_getenv_double_quote(self):\n+        text = 'API_TOKEN=os.getenv(\"MY_API_TOKEN\")'\n+        assert redact_sensitive_text(text, force=True) == text\n+\n+    def test_os_environ_get(self):\n+        text = \"HA_TOKEN=os.environ.get('HOMEASSISTANT_TOKEN')\"\n+        assert redact_sensitive_text(text, force=True) == text\n+\n+    def test_os_environ_bracket(self):\n+        text = \"MY_SECRET=[redacted:secret-assignment]'MY_SECRET']\"\n+        assert redact_sensitive_text(text, force=True) == text\n+\n+    def test_process_env(self):\n+        text = \"api_key=[redacted:secret-assignment]\"\n+        assert redact_sensitive_text(text, force=True) == text\n+\n+    def test_real_env_value_still_redacted(self):\n+        text = \"HOMEASSISTANT_TOKEN=[redacted:secret-assignment]\"\n+        result = redact_sensitive_text(text, force=True)\n+        assert \"eyJhbGciOiJIUzI1NiJ9\" not in result\n+\n+    def test_real_lowercase_value_still_redacted(self):\n+        text = \"password=[redacted:secret-assignment]\"\n+        result = redact_sensitive_text(text, force=True)\n+        assert \"hunter2hunter2\" not in result\n+\n+    def test_multiline_prose_with_code_snippet(self):\n+        text = \"\"\"Set it up like this:\n+    HA_TOKEN=os.getenv('HOMEASSISTANT_TOKEN')\n+    if not HA_TOKEN:\n+        raise ValueError('Missing credentials')\"\"\"\n+        result = redact_sensitive_text(text, force=True)\n+        assert \"os.getenv('HOMEASSISTANT_TOKEN')\" in result\n+\n+\n class TestJsonFields:\n     def test_json_api_key(self):\n         text = '{\"apiKey\": \"[redacted:openai-key]\"}'\n"]}]},{"kind":"pr","number":58915,"title":"fix(redact): skip env-lookup exception for JSON/YAML config field redaction","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/redact.py","tests/agent/test_redact.py"],"fix_hunks":[{"file":"agent/redact.py","hunks":["@@ -570,6 +570,11 @@ def _redact_env(m):\n         if \":\" in text and '\"' in text:\n             def _redact_json(m):\n                 key, value = m.group(1), m.group(2)\n+                # Same programmatic-env-lookup exception as _redact_env above\n+                # (issue #2852): \"apiKey\": \"os.getenv('X')\" is a code snippet,\n+                # not a leaked secret value.\n+                if _ENV_LOOKUP_VALUE_RE.match(value):\n+                    return m.group(0)\n                 return f'{key}: \"{_mask_token(value)}\"'\n             text = _JSON_FIELD_RE.sub(_redact_json, text)\n ","@@ -579,6 +584,11 @@ def _redact_json(m):\n         if \":\" in text and \"://\" not in text:\n             def _redact_yaml(m):\n                 key, sep, value = m.group(1), m.group(2), m.group(3)\n+                # Same programmatic-env-lookup exception as _redact_env above\n+                # (issue #2852): api_key: os.getenv('X') is a code snippet,\n+                # not a leaked secret value.\n+                if _ENV_LOOKUP_VALUE_RE.match(value):\n+                    return m.group(0)\n                 return f\"{key}{sep}{_mask_token(value)}\"\n             text = _YAML_ASSIGN_RE.sub(_redact_yaml, text)\n "]},{"file":"tests/agent/test_redact.py","hunks":["@@ -185,6 +185,34 @@ def test_multiline_prose_with_code_snippet(self):\n         result = redact_sensitive_text(text, force=True)\n         assert \"os.getenv('HOMEASSISTANT_TOKEN')\" in result\n \n+    def test_json_field_os_getenv_preserved(self):\n+        # _redact_env has the env-lookup exception; _redact_json (a separate\n+        # closure, JSON key: \"value\" syntax) did not, and mangled this into\n+        # '\"apiKey\": \"[redacted:secret-assignment]')\"'.\n+        text = '{\"apiKey\": \"[redacted:secret-assignment]'OPENAI_API_KEY\\')\"}'\n+        assert redact_sensitive_text(text, force=True) == text\n+\n+    def test_json_field_os_environ_get_preserved(self):\n+        text = '{\"token\": \"[redacted:secret-assignment]'MY_TOKEN\\')\"}'\n+        assert redact_sensitive_text(text, force=True) == text\n+\n+    def test_json_field_real_value_still_redacted(self):\n+        text = '{\"apiKey\": \"[redacted:openai-key]\"}'\n+        result = redact_sensitive_text(text, force=True)\n+        assert \"[redacted:openai-key]\" not in result\n+\n+    def test_yaml_field_os_getenv_preserved(self):\n+        # Same exception missing from _redact_yaml (unquoted key: value\n+        # syntax) — mangled 'api_key: os.getenv(\"OPENAI_API_KEY\")' into\n+        # 'api_key: [redacted:secret-assignment]\")'.\n+        text = 'api_key: os.getenv(\"OPENAI_API_KEY\")'\n+        assert redact_sensitive_text(text, force=True) == text\n+\n+    def test_yaml_field_real_value_still_redacted(self):\n+        text = \"api_key: [redacted:openai-key]\"\n+        result = redact_sensitive_text(text, force=True)\n+        assert \"[redacted:openai-key]\" not in result\n+\n \n class TestJsonFields:\n     def test_json_api_key(self):\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}