Mask runtime .env values in agent output

Include usr/.env in the agent-facing SecretsManager sources so runtime credentials are masked in tool results, history, logs, and streamed output. Keep the writable default secrets manager scoped to usr/secrets.env. Add regression coverage for dotenv masking and document the source contract.

Alessandro committed Jul 17, 2026 at 16:23 UTC 55456df29443b1fb62ba072a611e3a320f667a4c
3 files changed +29 -5
helpers/secrets.py
+4 -4
@@ -7,7 +7,7 @@ from dataclasses import dataclass
7 from typing import Dict, Optional, List, Literal, Set, Callable, Tuple, TYPE_CHECKING
8 from dotenv.parser import parse_stream
9 from helpers.errors import RepairableException
10 -from helpers import files
10 +from helpers import dotenv, files
11 from helpers.extension import extensible
12
13 if TYPE_CHECKING:
@@ -509,8 +509,8 @@ class SecretsManager:
509 def get_secrets_manager(context: "AgentContext|None" = None) -> SecretsManager:
510 from helpers import projects
511
512 - # default secrets file
513 - secret_files = [DEFAULT_SECRETS_FILE]
512 + # Agent-facing masking covers the secret store and runtime credentials in usr/.env.
513 + secret_files = [DEFAULT_SECRETS_FILE, dotenv.get_dotenv_file_path()]
514
515 # use AgentContext from contextvars if no context provided
516 if not context:
@@ -542,4 +542,4 @@ def get_project_secrets_manager(project_name: str, merge_with_global: bool = Fal
542
543 @extensible
544 def get_default_secrets_manager() -> SecretsManager:
545 - return SecretsManager.get_instance()
\ No newline at end of file
545 + return SecretsManager.get_instance()
helpers/secrets.py.dox.md
+2 -1
@@ -34,13 +34,14 @@
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.
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`.
41
42 ## Key Concepts
43
43 -- Important called helpers/classes observed in the source: `key.upper`, `placeholder.format`, `SecretsManager.get_instance`, `self._replace_full_values`, `self._longest_suffix_prefix`, `threading.RLock`, `join`, `files.write_file`, `self._invalidate_all_caches`, `self.load_secrets`, `self.read_secrets_raw`, `self.parse_env_lines`, `self._serialize_env_lines`, `StreamingSecretsFilter`, `re.sub`, `self.parse_env_content`, `parse_stream`, `AgentContext.current`, `projects.get_context_project_name`, `files.get_abs_path`.
44 +- Important called helpers/classes observed in the source: `key.upper`, `placeholder.format`, `SecretsManager.get_instance`, `self._replace_full_values`, `self._longest_suffix_prefix`, `threading.RLock`, `join`, `files.write_file`, `self._invalidate_all_caches`, `self.load_secrets`, `self.read_secrets_raw`, `self.parse_env_lines`, `self._serialize_env_lines`, `StreamingSecretsFilter`, `re.sub`, `self.parse_env_content`, `parse_stream`, `AgentContext.current`, `projects.get_context_project_name`, `files.get_abs_path`, `dotenv.get_dotenv_file_path`.
45 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
46
47 ## Work Guidance
tests/test_secrets.py new
+23
@@ -0,0 +1,23 @@
1 +from helpers import secrets
2 +
3 +
4 +class _Context:
5 + def get_data(self, key: str):
6 + return None
7 +
8 +
9 +def test_agent_secret_manager_masks_runtime_dotenv_values(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",
16 + }
17 + monkeypatch.setattr(secrets.files, "read_file", contents.__getitem__)
18 +
19 + manager = secrets.get_secrets_manager(_Context())
20 +
21 + assert manager.mask_values("key=llm-secret-value") == (
22 + "key=§§secret(LLM_API_KEY)"
23 + )