Limit runtime secret redaction to credentials

Filter usr/.env masking to API keys and login/password credentials so ordinary settings cannot corrupt chat text. Keep global and project secrets protected, and cover full-response, prompt, and streaming behavior with a regression test.

Alessandro committed Jul 19, 2026 at 13:22 UTC fd795bda82167b2a3bf6ad4705218987073cd0d7
3 files changed +35 -6
helpers/secrets.py
+14
@@ -17,6 +17,12 @@ if TYPE_CHECKING:
17 # New alias-based placeholder format §§secret(KEY)
18 ALIAS_PATTERN = r"§§secret\(([A-Za-z_][A-Za-z0-9_]*)\)"
19 DEFAULT_SECRETS_FILE = "usr/secrets.env"
20 +_RUNTIME_CREDENTIAL_KEYS = {
21 + dotenv.KEY_AUTH_LOGIN,
22 + dotenv.KEY_AUTH_PASSWORD,
23 + dotenv.KEY_RFC_PASSWORD,
24 + dotenv.KEY_ROOT_PASSWORD,
25 +}
26
27
28 def alias_for_key(key: str, placeholder: str = "§§secret({key})") -> str:
@@ -160,6 +166,14 @@ class SecretsManager:
166 content = ""
167
168 self._raw_snapshots[path] = content
169 + if path == dotenv.get_dotenv_file_path():
170 + content = "\n".join(
171 + line.raw
172 + for line in self.parse_env_lines(content)
173 + if line.type != "pair"
174 + or (line.key or "").upper().startswith("API_KEY_")
175 + or (line.key or "").upper() in _RUNTIME_CREDENTIAL_KEYS
176 + )
177 parts.append(content)
178
179 combined = "\n".join(parts)
helpers/secrets.py.dox.md
+3 -2
@@ -29,12 +29,12 @@
29 - `get_secrets_manager(context: 'AgentContext|None'=...) -> SecretsManager`
30 - `get_project_secrets_manager(project_name: str, merge_with_global: bool=...) -> SecretsManager`
31 - `get_default_secrets_manager() -> SecretsManager`
32 -- Notable constants/configuration names: `ALIAS_PATTERN`, `DEFAULT_SECRETS_FILE`.
32 +- Notable constants/configuration names: `ALIAS_PATTERN`, `DEFAULT_SECRETS_FILE`, `_RUNTIME_CREDENTIAL_KEYS`.
33
34 ## Runtime Contracts
35
36 - Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
37 -- The agent-facing `get_secrets_manager` masks and unpacks values from `usr/.env`, the global `usr/secrets.env`, and the active project's `secrets.env`; `get_default_secrets_manager` remains scoped to the single writable `usr/secrets.env` file.
37 +- The agent-facing `get_secrets_manager` masks and unpacks `API_KEY_*` and login/password credentials from `usr/.env`, every value from the global `usr/secrets.env`, and every value from the active project's `secrets.env`; ordinary runtime settings are not treated as secrets. `get_default_secrets_manager` remains scoped to the single writable `usr/secrets.env` file.
38 - Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
39 - Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, WebSocket state, settings/state persistence, secret handling.
40 - Imported dependency areas include: `dataclasses`, `dotenv.parser`, `helpers`, `helpers.errors`, `helpers.extension`, `io`, `os`, `re`, `threading`, `time`, `typing`.
@@ -54,6 +54,7 @@
54
55 - Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
56 - Related tests observed by source search:
57 + - `tests/test_secrets.py`
58 - `tests/test_plugin_scan_prompt.py`
59 - `tests/test_print_style.py`
60 - `tests/test_time_travel.py`
tests/test_secrets.py
+18 -4
@@ -6,18 +6,32 @@ class _Context:
6 return None
7
8
9 -def test_agent_secret_manager_masks_runtime_dotenv_values(monkeypatch):
9 +def test_agent_secret_manager_masks_runtime_credentials_only(monkeypatch):
10 monkeypatch.setattr(secrets.SecretsManager, "_instances", {})
11 monkeypatch.setattr(secrets.dotenv, "get_dotenv_file_path", lambda: "usr/.env")
12
13 contents = {
14 "usr/secrets.env": "PROJECT_SECRET=project-value\n",
15 - "usr/.env": "LLM_API_KEY=llm-secret-value\n",
15 + "usr/.env": (
16 + "API_KEY_OPENAI=llm-secret-value\n"
17 + "ANONYMIZED_TELEMETRY=false\n"
18 + "DEFAULT_USER_TIMEZONE=Europe/Rome\n"
19 + ),
20 }
21 monkeypatch.setattr(secrets.files, "read_file", contents.__getitem__)
22
23 manager = secrets.get_secrets_manager(_Context())
24
21 - assert manager.mask_values("key=llm-secret-value") == (
22 - "key=§§secret(LLM_API_KEY)"
25 + assert manager.mask_values(
26 + "project-value; key=llm-secret-value; avoid falsely accusing a utility"
27 + ) == (
28 + "§§secret(PROJECT_SECRET); key=§§secret(API_KEY_OPENAI); "
29 + "avoid falsely accusing a utility"
30 )
31 + assert "ANONYMIZED_TELEMETRY" not in manager.get_secrets_for_prompt()
32 +
33 + stream_filter = manager.create_streaming_filter()
34 + assert stream_filter.process_chunk("avoid falsely accusing a utility") == (
35 + "avoid falsely accusing a utility"
36 + )
37 + assert stream_filter.finalize() == ""