fix(secrets): fall back to os.environ on scope miss when multiplexing is off

fdab380a1 wraps every cron job in a <home>/.env secret scope regardless of
deployment mode. get_secret() treats any installed scope as authoritative,
so in single-profile deployments where provider keys live only in the
process environment (systemd Environment=, pass-cli/op run wrappers, shell
exports) every cron credential read returns empty, the OpenAI client is
built with the no-key-required placeholder, and each scheduled job 401s —
while interactive turns keep working. Scope-miss reads now fall through to
os.environ when multiplexing is off; multiplexed scopes stay authoritative.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Soju06 2026-07-20 02:48:56 +00:00 committed by Teknium
parent c7b0c0d35f
commit c758ded6d2
2 changed files with 68 additions and 4 deletions

View file

@ -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
``<home>/.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:

View file

@ -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 ``<home>/.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 <home>/.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."""