diff --git a/agent/secret_scope.py b/agent/secret_scope.py index 26022ca9b0ef..d8730db2bd6e 100644 --- a/agent/secret_scope.py +++ b/agent/secret_scope.py @@ -127,10 +127,16 @@ def get_secret(name: str, default: Optional[str] = None) -> Optional[str]: 1. Genuinely-global vars (``_is_global_env``) always read ``os.environ`` — they are deployment settings, not profile secrets. - 2. When a secret scope is installed (multiplexed turn), read from it; an - absent key returns ``default``. The scope is authoritative — we do NOT - fall through to ``os.environ``, because in a multiplexer ``os.environ`` - may hold another profile's value. + 2. When a secret scope is installed (multiplexed turn), read from it. Under + multiplexing the scope is authoritative — an absent key returns + ``default`` and we do NOT fall through to ``os.environ``, because in a + multiplexer ``os.environ`` may hold another profile's value. When + multiplexing is OFF, a scope miss falls through to ``os.environ``: + single-profile deployments legitimately provide credentials via the + process environment (systemd ``Environment=``, secret-manager wrappers + like ``pass-cli run`` / ``op run``, plain shell exports) rather than + ``/.env``, and the scope — installed unconditionally around e.g. + every cron job — must stay a ``.env`` overlay, not a blindfold. 3. No scope installed: - multiplex INACTIVE (default deployment): read ``os.environ`` — identical to the legacy ``os.getenv`` behavior every caller had before. @@ -144,6 +150,17 @@ def get_secret(name: str, default: Optional[str] = None) -> Optional[str]: scope = _SECRET_SCOPE.get() if scope is not None: val = scope.get(name) + if val is not None: + return val + if _MULTIPLEX_ACTIVE: + return default + # Multiplex off: the scope is an overlay over the process environment, + # not an isolation boundary — there is no other profile to leak from. + # Without this fallthrough, credentials injected only into the process + # environment vanish inside any set_secret_scope(...) block (the cron + # scheduler installs one around every job), so cron jobs send a + # placeholder API key and 401 while interactive turns keep working. + val = os.environ.get(name) return val if val is not None else default if _MULTIPLEX_ACTIVE: diff --git a/tests/agent/test_secret_scope.py b/tests/agent/test_secret_scope.py index 1b8a1cace40e..43dae703c4f5 100644 --- a/tests/agent/test_secret_scope.py +++ b/tests/agent/test_secret_scope.py @@ -72,6 +72,53 @@ class TestMultiplexActiveFailClosed: assert ss.get_secret("HERMES_KANBAN_DB") == "/x/kanban.db" +class TestScopedSingleProfile: + """Multiplex OFF with a scope installed: the scope is an overlay, not a + blindfold. The cron scheduler installs a ``/.env`` scope around every + job unconditionally, and single-profile deployments legitimately supply + credentials via the process environment only (systemd ``Environment=``, + ``pass-cli run`` / ``op run`` wrappers) — those must keep resolving.""" + + def test_scope_hit_wins_over_environ(self, monkeypatch): + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-from-environ") + token = ss.set_secret_scope({"ANTHROPIC_API_KEY": "sk-from-env-file"}) + try: + assert ss.get_secret("ANTHROPIC_API_KEY") == "sk-from-env-file" + finally: + ss.reset_secret_scope(token) + + def test_scope_miss_falls_back_to_environ(self, monkeypatch): + # Regression: provider key injected into the process env but absent + # from /.env made every cron job send a placeholder API key + # (401) while interactive turns kept working. + monkeypatch.setenv("GLM_VOOY_API_KEY", "sk-from-process-env") + token = ss.set_secret_scope({"UNRELATED_KEY": "x"}) + try: + assert ss.get_secret("GLM_VOOY_API_KEY") == "sk-from-process-env" + finally: + ss.reset_secret_scope(token) + + def test_scope_miss_absent_everywhere_returns_default(self, monkeypatch): + monkeypatch.delenv("NOPE_KEY", raising=False) + token = ss.set_secret_scope({}) + try: + assert ss.get_secret("NOPE_KEY") is None + assert ss.get_secret("NOPE_KEY", "d") == "d" + finally: + ss.reset_secret_scope(token) + + def test_multiplex_on_still_authoritative(self, monkeypatch): + # The fallthrough is strictly multiplex-off behavior: turning + # multiplexing on must restore scope-authoritative semantics. + monkeypatch.setenv("OPENAI_API_KEY", "sk-other-profile") + ss.set_multiplex_active(True) + token = ss.set_secret_scope({}) + try: + assert ss.get_secret("OPENAI_API_KEY") is None + finally: + ss.reset_secret_scope(token) + + class TestScopeIsolation: """Two scopes never see each other's secrets."""