Skip to content

Commit 36198bd

Browse files
fix(providers): log resolved .env source
A stale or shadowed .env silently won the whole-process config with zero diagnostic signal, turning a misconfig into hours of triage (P08, R1). Emit one behavior-preserving INFO line at .env resolution naming which candidate slot won, plus the resolved provider/model/base. The slot is reported via a fixed symbolic label (~/.vibe-trading/.env, <AGENT_DIR>/.env, <CWD>/.env) instead of the absolute path, so the OS username / home / CWD never leak (CWE-209); the API key is never logged. agent/src/providers/ is a protected module: this change is observability-only -- the first-match latch, override=False, and packaging anchor semantics are unchanged. Semantic fixes (R3-R7: precedence, anchor, override) are deferred to issue-first discussion per the protected-module rule. Closes #123
1 parent 8d3ae19 commit 36198bd

2 files changed

Lines changed: 118 additions & 0 deletions

File tree

agent/src/providers/llm.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import json
6+
import logging
67
import os
78
from pathlib import Path
89
from typing import Any, Dict, Optional
@@ -95,9 +96,34 @@ def _get_request_payload( # type: ignore[override]
9596
Path.cwd() / ".env",
9697
]
9798

99+
# Index-aligned with _ENV_CANDIDATES. CWE-209: never log the absolute
100+
# .env path (it leaks the OS username / home / CWD). The label names
101+
# which slot won - the entire P08 R1 signal - using compile-time
102+
# constants only.
103+
_ENV_LABELS = ("~/.vibe-trading/.env", "<AGENT_DIR>/.env", "<CWD>/.env")
104+
105+
logger = logging.getLogger(__name__)
106+
98107
_dotenv_loaded: bool = False
99108

100109

110+
def _redact_env_source(loaded: Path | None) -> str:
111+
"""Map a resolved `.env` candidate to a stable, leak-free label.
112+
113+
Returns a symbolic slot label (never the absolute path) so a stale
114+
or shadowed `.env` stays diagnosable without exposing the OS
115+
username, home, or CWD (CWE-209). A candidate outside the fixed
116+
list (e.g. one injected by a test) collapses to a generic
117+
placeholder rather than echoing a real path.
118+
"""
119+
if loaded is None:
120+
return "none (no .env file found)"
121+
for label, candidate in zip(_ENV_LABELS, _ENV_CANDIDATES):
122+
if loaded == candidate:
123+
return label
124+
return "<.env>"
125+
126+
101127
def _load_env_file(path: Path) -> None:
102128
"""Load a single .env file into os.environ (setdefault, no override)."""
103129
if load_dotenv is not None:
@@ -118,11 +144,23 @@ def _ensure_dotenv() -> None:
118144
global _dotenv_loaded
119145
if _dotenv_loaded:
120146
return
147+
loaded = None
121148
for candidate in _ENV_CANDIDATES:
122149
if candidate.exists():
123150
_load_env_file(candidate)
151+
loaded = candidate
124152
break
125153
_dotenv_loaded = True
154+
# P08 R1: one-time, behavior-preserving diagnostic so a stale or
155+
# shadowed .env is observable instead of costing hours. The path is
156+
# redacted to a symbolic slot label and the API key is never logged.
157+
logger.info(
158+
"dotenv resolved from %s | provider=%s model=%s base=%s",
159+
_redact_env_source(loaded),
160+
os.getenv("LANGCHAIN_PROVIDER", "(unset)"),
161+
os.getenv("LANGCHAIN_MODEL_NAME", "(unset)"),
162+
os.getenv("OPENAI_BASE_URL") or os.getenv("OPENAI_API_BASE") or "(unset)",
163+
)
126164

127165

128166
def _sync_provider_env() -> None:
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Regression test for P08 (R1) — .env resolution must be observable.
2+
3+
A stale / shadowed .env silently won the config for the whole process with
4+
zero diagnostic, costing hours. _ensure_dotenv now emits one behavior-
5+
preserving INFO line naming the resolved slot via a redacted symbolic
6+
label (or "none") plus the resolved provider/model/base. The absolute
7+
path, OS username, and API key are never logged (CWE-209).
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import getpass
13+
import logging
14+
from pathlib import Path
15+
16+
import pytest
17+
18+
import src.providers.llm as llm
19+
20+
LOGGER = "src.providers.llm"
21+
22+
23+
@pytest.fixture
24+
def fresh(monkeypatch):
25+
# Drop the once-per-process latch so the resolver actually runs.
26+
monkeypatch.setattr(llm, "_dotenv_loaded", False)
27+
28+
29+
def test_logs_redacted_label_not_path(tmp_path, fresh, monkeypatch, caplog):
30+
"""The resolved slot is logged as a symbolic label; the absolute path
31+
and OS username never appear (CWE-209)."""
32+
env = tmp_path / ".env"
33+
env.write_text("FOO=bar\n", encoding="utf-8")
34+
monkeypatch.setattr(llm, "_ENV_CANDIDATES", [env])
35+
monkeypatch.setattr(llm, "_ENV_LABELS", ("<TEST_SLOT>",))
36+
with caplog.at_level(logging.INFO, logger=LOGGER):
37+
llm._ensure_dotenv()
38+
msg = "\n".join(r.getMessage() for r in caplog.records)
39+
assert "dotenv resolved from" in msg
40+
assert "<TEST_SLOT>" in msg # redacted slot label is logged
41+
assert str(env) not in msg # absolute path never logged
42+
assert str(tmp_path) not in msg
43+
assert getpass.getuser() not in msg # OS username never leaks
44+
assert "sk-" not in msg # key must never be logged
45+
46+
47+
def test_redact_env_source_maps_real_candidates():
48+
"""_redact_env_source maps fixed candidates to stable leak-free
49+
labels, None to the no-file sentinel, and any unknown path to a
50+
generic placeholder (never the real path)."""
51+
# The home slot never collides with AGENT_DIR / CWD -> exact mapping.
52+
assert llm._redact_env_source(llm._ENV_CANDIDATES[0]) == llm._ENV_LABELS[0]
53+
# Every fixed candidate resolves to a known redacted label. When CWD
54+
# == AGENT_DIR the earlier slot legitimately wins (matches the
55+
# first-match order of _ensure_dotenv) — still leak-free.
56+
for candidate in llm._ENV_CANDIDATES:
57+
result = llm._redact_env_source(candidate)
58+
assert result in llm._ENV_LABELS
59+
assert str(candidate) not in result
60+
assert llm._redact_env_source(None) == "none (no .env file found)"
61+
unknown = Path("/some/secret/home/user/.env")
62+
assert llm._redact_env_source(unknown) == "<.env>"
63+
assert str(unknown) not in llm._redact_env_source(unknown)
64+
65+
66+
def test_logs_none_when_no_env_found(tmp_path, fresh, monkeypatch, caplog):
67+
monkeypatch.setattr(llm, "_ENV_CANDIDATES", [tmp_path / "does-not-exist.env"])
68+
with caplog.at_level(logging.INFO, logger=LOGGER):
69+
llm._ensure_dotenv()
70+
msg = "\n".join(r.getMessage() for r in caplog.records)
71+
assert "none (no .env file found)" in msg
72+
73+
74+
def test_latch_still_skips_second_call(tmp_path, fresh, monkeypatch, caplog):
75+
"""Behavior preserved: still loads once per process (no log on re-entry)."""
76+
monkeypatch.setattr(llm, "_ENV_CANDIDATES", [tmp_path / "nope.env"])
77+
llm._ensure_dotenv()
78+
with caplog.at_level(logging.INFO, logger=LOGGER):
79+
llm._ensure_dotenv() # latched -> early return, no new log
80+
assert not [r for r in caplog.records if "dotenv resolved" in r.getMessage()]

0 commit comments

Comments
 (0)