# NousResearch/hermes-agent · LLM token redaction incorrectly masking env variable lookups

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 4 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 2 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #58915. Verify it addresses your case.
- **Recommended:** read PR #58915 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-58534`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.50`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #2852: \[Bug\]: LLM token redaction breaking on .env handling on scripts

## Prior & competing PRs

- **#2854: fix(redact): skip env-assignment redaction for os.getenv/os.environ lookups (#2852)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#2852`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#58534: fix(redact): don't mask programmatic env lookups in KEY=value redaction (salvage #2854)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `agent/redact.py`, `tests/agent/test_redact.py`
  - fix hunks:
  - `agent/redact.py`

    ```diff
    @@ -137,6 +137,14 @@
     # The colon-form URL guard (skip when ``://`` present) lives at the call site.
     _SECRET_CFG_NAMES = r"(?:api[ _.\-]?key|token|secret|passwd|password|credential|auth)"
     _CFG_VALUE = r"(['\"]?)([^\s&]+?)\2(?=[\s&]|$)"
    +
    +# Programmatic env lookups (``os.getenv(...)``, ``os.environ[...]``,
    +# ``os.environ.get(...)``, ``process.env.X``, ``$ENV{X}``) reference variable
    +# *names*, not secret values. When one appears as the VALUE of a KEY=... match
    +# it's a code snippet, not a leaked secret — skip redaction (issue #2852).
    +_ENV_LOOKUP_VALUE_RE = re.compile(
    +    r"^(?:os\.(?:getenv|environ)|process\.env|\$ENV\{)"
    +)
     # Namespaced (dotted) key: the secret word may sit anywhere in a dotted path.
     _CFG_DOTTED_RE = re.compile(
         rf"((?:[A-Za-z0-9_\-]+\.)+[A-Za-z0-9_.\-]*{_SECRET_CFG_NAMES}[A-Za-z0-9_.\-]*"
    ```

    ```diff
    @@ -541,6 +549,11 @@ def redact_sensitive_text(
             if "=" in text:
                 def _redact_env(m):
                     name, quote, value = m.group(1), m.group(2), m.group(3)
    +                # Programmatic env lookups reference variable *names*, not
    +                # secret values — masking them corrupts code snippets in
    +                # prose/log contexts (issue #2852): ``KEY=os.getenv('X')``.
    +                if _ENV_LOOKUP_VALUE_RE.match(value):
    +                    return m.group(0)
                     return f"{name}={quote}{_mask_token(value)}{quote}"
                 text = _ENV_ASSIGN_RE.sub(_redact_env, text)
                 # Lowercase/dotted config keys (issue #16413). Skip URLs entirely —
    ```
  - `tests/agent/test_redact.py`

    ```diff
    @@ -124,6 +124,52 @@ def test_export_whitespace_preserved(self):
             assert "mypassword" not in result
     
     
    +class TestEnvLookupPreserved:
    +    """Programmatic env var lookups must not be corrupted (issue #2852)."""
    +
    +    def test_os_getenv_single_quote_uppercase_key(self):
    +        text = "MY_API_KEY=os.getenv('OPENAI_API_KEY')"
    +        assert redact_sensitive_text(text, force=True) == text
    +
    +    def test_os_getenv_lowercase_config_key(self):
    +        text = "ha_token=os.getenv('HOMEASSISTANT_TOKEN')"
    +        assert redact_sensitive_text(text, force=True) == text
    +
    +    def test_os_getenv_double_quote(self):
    +        text = 'API_TOKEN=os.getenv("MY_API_TOKEN")'
    +        assert redact_sensitive_text(text, force=True) == text
    +
    +    def test_os_environ_get(self):
    +        text = "HA_TOKEN=os.environ.get('HOMEASSISTANT_TOKEN')"
    +        assert redact_sensitive_text(text, force=True) == text
    +
    +    def test_os_environ_bracket(self):
    +        text = "MY_SECRET=[redacted:secret-assignment]'MY_SECRET']"
    +        assert redact_sensitive_text(text, force=True) == text
    +
    +    def test_process_env(self):
    +        text = "api_key=[redacted:secret-assignment]"
    +        assert redact_sensitive_text(text, force=True) == text
    +
    +    def test_real_env_value_still_redacted(self):
    +        text = "HOMEASSISTANT_TOKEN=[redacted:secret-assignment]"
    +        result = redact_sensitive_text(text, force=True)
    +        assert "eyJhbGciOiJIUzI1NiJ9" not in result
    +
    +    def test_real_lowercase_value_still_redacted(self):
    +        text = "password=[redacted:secret-assignment]"
    +        result = redact_sensitive_text(text, force=True)
    +        assert "hunter2hunter2" not in result
    +
    +    def test_multiline_prose_with_code_snippet(self):
    +        text = """Set it up like this:
    +    HA_TOKEN=os.getenv('HOMEASSISTANT_TOKEN')
    +    if not HA_TOKEN:
    +        raise ValueError('Missing credentials')"""
    +        result = redact_sensitive_text(text, force=True)
    +        assert "os.getenv('HOMEASSISTANT_TOKEN')" in result
    +
    +
     class TestJsonFields:
         def test_json_api_key(self):
             text = '{"apiKey": "[redacted:openai-key]"}'
    ```
- **#58915: fix(redact): skip env-lookup exception for JSON/YAML config field redaction**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `agent/redact.py`, `tests/agent/test_redact.py`
  - fix hunks:
  - `agent/redact.py`

    ```diff
    @@ -570,6 +570,11 @@ def _redact_env(m):
             if ":" in text and '"' in text:
                 def _redact_json(m):
                     key, value = m.group(1), m.group(2)
    +                # Same programmatic-env-lookup exception as _redact_env above
    +                # (issue #2852): "apiKey": "os.getenv('X')" is a code snippet,
    +                # not a leaked secret value.
    +                if _ENV_LOOKUP_VALUE_RE.match(value):
    +                    return m.group(0)
                     return f'{key}: "{_mask_token(value)}"'
                 text = _JSON_FIELD_RE.sub(_redact_json, text)
     
    ```

    ```diff
    @@ -579,6 +584,11 @@ def _redact_json(m):
             if ":" in text and "://" not in text:
                 def _redact_yaml(m):
                     key, sep, value = m.group(1), m.group(2), m.group(3)
    +                # Same programmatic-env-lookup exception as _redact_env above
    +                # (issue #2852): api_key: os.getenv('X') is a code snippet,
    +                # not a leaked secret value.
    +                if _ENV_LOOKUP_VALUE_RE.match(value):
    +                    return m.group(0)
                     return f"{key}{sep}{_mask_token(value)}"
                 text = _YAML_ASSIGN_RE.sub(_redact_yaml, text)
     
    ```
  - `tests/agent/test_redact.py`

    ```diff
    @@ -185,6 +185,34 @@ def test_multiline_prose_with_code_snippet(self):
             result = redact_sensitive_text(text, force=True)
             assert "os.getenv('HOMEASSISTANT_TOKEN')" in result
     
    +    def test_json_field_os_getenv_preserved(self):
    +        # _redact_env has the env-lookup exception; _redact_json (a separate
    +        # closure, JSON key: "value" syntax) did not, and mangled this into
    +        # '"apiKey": "[redacted:secret-assignment]')"'.
    +        text = '{"apiKey": "[redacted:secret-assignment]'OPENAI_API_KEY\')"}'
    +        assert redact_sensitive_text(text, force=True) == text
    +
    +    def test_json_field_os_environ_get_preserved(self):
    +        text = '{"token": "[redacted:secret-assignment]'MY_TOKEN\')"}'
    +        assert redact_sensitive_text(text, force=True) == text
    +
    +    def test_json_field_real_value_still_redacted(self):
    +        text = '{"apiKey": "[redacted:openai-key]"}'
    +        result = redact_sensitive_text(text, force=True)
    +        assert "[redacted:openai-key]" not in result
    +
    +    def test_yaml_field_os_getenv_preserved(self):
    +        # Same exception missing from _redact_yaml (unquoted key: value
    +        # syntax) — mangled 'api_key: os.getenv("OPENAI_API_KEY")' into
    +        # 'api_key: [redacted:secret-assignment]")'.
    +        text = 'api_key: os.getenv("OPENAI_API_KEY")'
    +        assert redact_sensitive_text(text, force=True) == text
    +
    +    def test_yaml_field_real_value_still_redacted(self):
    +        text = "api_key: [redacted:openai-key]"
    +        result = redact_sensitive_text(text, force=True)
    +        assert "[redacted:openai-key]" not in result
    +
     
     class TestJsonFields:
         def test_json_api_key(self):
    ```

## Fix status

- Merged to main in PR #58915 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-05`.
- Subsystem: `agent`
