From 9e872db7d7eae9b962afc094a55a484890558a6b Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:24:19 -0700 Subject: [PATCH] fix(redact): skip env-assignment redaction for programmatic env lookups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'KEY=os.getenv(...)' / 'os.environ[...]' / 'process.env.X' values are variable-name references in code snippets, not leaked secrets. Masking them corrupted pasted code in prose/log contexts (issue #2852): ha_token=os.getenv('HOMEASSISTANT_TOKEN') -> ha_token=os.get...EN'). Skip these values inside _redact_env, which covers all three passes that share the closure (_ENV_ASSIGN_RE, _CFG_DOTTED_RE, _CFG_ANCHORED_RE). Real secret values are still masked. Salvage of PR #2852-fix #2854 — the PR's own placement (an unconditional pass before the code_file gate) would have reintroduced the code-file false-positive class; the skip is applied inside the existing gated pass instead. Tests adapted from the PR. Co-authored-by: crazywriter1 --- agent/redact.py | 13 +++++++++++ tests/agent/test_redact.py | 46 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/agent/redact.py b/agent/redact.py index c917ceb5b79..47bd99aece5 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -137,6 +137,14 @@ _ENV_ASSIGN_RE = re.compile( # 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_.\-]*" @@ -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 — diff --git a/tests/agent/test_redact.py b/tests/agent/test_redact.py index ed7e4255887..57892e3fba0 100644 --- a/tests/agent/test_redact.py +++ b/tests/agent/test_redact.py @@ -124,6 +124,52 @@ class TestEnvAssignments: 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=os.environ['MY_SECRET']" + assert redact_sensitive_text(text, force=True) == text + + def test_process_env(self): + text = "api_key=process.env.API_KEY" + assert redact_sensitive_text(text, force=True) == text + + def test_real_env_value_still_redacted(self): + text = "HOMEASSISTANT_TOKEN=eyJhbGciOiJIUzI1NiJ9.abc123.xyz" + result = redact_sensitive_text(text, force=True) + assert "eyJhbGciOiJIUzI1NiJ9" not in result + + def test_real_lowercase_value_still_redacted(self): + text = "password=hunter2hunter2" + 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": "sk-proj-abc123def456ghi789jkl012"}'