fix(secret-scope): mem0, langfuse and azure credential readers stop swallowing UnscopedSecretError
Three shims caught UnscopedSecretError and degraded to "" (mem0._scoped_env) or to os.environ (langfuse._secret, azure_identity_adapter._scoped_env). Under multiplex os.environ holds the DEFAULT profile's .env, so the langfuse/azure fallback could ship another profile's keys, and the mem0 fallback silently routed a mis-spawned turn's memories into the default profile's account. The exception exists to surface exactly that spawn-site bug (agent/AGENTS.md: never add environ fallthrough, never swallow it). All three now call agent.secret_scope.get_secret directly: with a scope installed a miss returns the default; single-profile deployments (multiplex off) still read the process env inside get_secret; a scope-less multiplex caller raises. Behavior change: a mis-spawned child under gateway.multiplex_profiles now fails loud with UnscopedSecretError instead of running silently unauthenticated / on the default profile's identity. The #99121 contract (OSS mode needs no MEM0_API_KEY in scope) is unchanged and its test now installs an empty profile scope, which is the situation the issue described; a genuinely scope-less caller is asserted to raise in a new test. Tests: tests/plugins/test_scoped_secret_readers_fail_closed.py (scope wins over environ; scope-less multiplex raises) for langfuse + azure, sabotage red when the langfuse fallthrough is restored; tests/plugins/memory/test_mem0_v3.py:: test_load_config_fails_closed_without_scope_even_for_identity_settings, sabotage red with a swallowing wrapper reinstated.
This commit is contained in:
@@ -207,12 +207,11 @@ def _env(name: str) -> str:
|
||||
|
||||
def _scoped_env(name: str) -> str:
|
||||
"""Credential-bearing env read via the profile secret scope so a multiplexed profile never reports
|
||||
another profile's env-bridged credentials; unscoped CLI probes fall back to plain env."""
|
||||
try:
|
||||
from agent.secret_scope import get_secret
|
||||
return (get_secret(name) or "").strip()
|
||||
except Exception: # UnscopedSecretError, import failure, or any scope error
|
||||
return _env(name)
|
||||
another profile's env-bridged credentials. Unscoped CLI probes (multiplex off) read the process
|
||||
env through ``get_secret`` itself; a scope-less multiplex caller raises — spawn-site bug."""
|
||||
from agent.secret_scope import get_secret
|
||||
|
||||
return (get_secret(name) or "").strip()
|
||||
|
||||
|
||||
# (label, predicate) for env-var-driven credential sources, in chain order.
|
||||
|
||||
@@ -19,7 +19,7 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from agent.memory_provider import MemoryProvider, spawn_context_thread
|
||||
from agent.secret_scope import UnscopedSecretError, get_secret
|
||||
from agent.secret_scope import get_secret
|
||||
from tools.registry import tool_error
|
||||
from utils import atomic_json_write, read_json_or_empty
|
||||
|
||||
@@ -73,15 +73,6 @@ def _is_client_error(exc: Exception) -> bool:
|
||||
return type(exc).__name__ in _CLIENT_ERROR_TYPES or any(s in err_str for s in ("404", "not found", "valid uuid"))
|
||||
|
||||
|
||||
def _scoped_env(name: str) -> str:
|
||||
"""Profile-scoped read of a non-secret mem0 setting; no scope under multiplex = unset (never
|
||||
``os.environ``). Only the API key may fail closed — OSS mode has none to read (#99121)."""
|
||||
try:
|
||||
return get_secret(name, "") or ""
|
||||
except UnscopedSecretError:
|
||||
return ""
|
||||
|
||||
|
||||
def _load_config() -> dict:
|
||||
"""Env vars provide defaults; $HERMES_HOME/mem0.json overrides individual keys.
|
||||
Layering avoids a silent failure when the JSON file exists but lacks fields
|
||||
@@ -89,9 +80,11 @@ def _load_config() -> dict:
|
||||
from hermes_constants import get_hermes_home
|
||||
# Identity (user/agent id), host and mode are .env values like the key: read them through the
|
||||
# profile scope too, or a secondary profile's memories land in the default profile's account.
|
||||
config = {"mode": _scoped_env("MEM0_MODE") or "platform", "host": _scoped_env("MEM0_HOST"),
|
||||
"agent_id": _scoped_env("MEM0_AGENT_ID") or "hermes", "oss": {}}
|
||||
if user_id := _scoped_env("MEM0_USER_ID"): # only when explicitly configured, so initialize() can fall back to the gateway-native id
|
||||
# A scope-less multiplex caller raises here on purpose — that is a spawn-site bug, and
|
||||
# swallowing it would silently route the turn's memories to the default profile.
|
||||
config = {"mode": get_secret("MEM0_MODE", "") or "platform", "host": get_secret("MEM0_HOST", "") or "",
|
||||
"agent_id": get_secret("MEM0_AGENT_ID", "") or "hermes", "oss": {}}
|
||||
if user_id := get_secret("MEM0_USER_ID", ""): # only when explicitly configured, so initialize() can fall back to the gateway-native id
|
||||
config["user_id"] = user_id
|
||||
file_cfg = read_json_or_empty(get_hermes_home() / "mem0.json")
|
||||
config.update({k: v for k, v in file_cfg.items() if v is not None and v != ""})
|
||||
|
||||
@@ -88,16 +88,12 @@ def _env(name: str, default: str = "") -> str:
|
||||
|
||||
|
||||
def _secret(name: str) -> str:
|
||||
"""Credential read honoring the active profile's secret scope; plain os.environ when unscoped."""
|
||||
try:
|
||||
from agent.secret_scope import UnscopedSecretError, get_secret
|
||||
try:
|
||||
return (get_secret(name) or "").strip()
|
||||
except UnscopedSecretError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
return _env(name)
|
||||
"""Credential read through the profile secret scope. A scope-less multiplex caller raises
|
||||
(``UnscopedSecretError``): that is a spawn-site bug, and reading ``os.environ`` instead would
|
||||
ship this profile's traces with the DEFAULT profile's keys."""
|
||||
from agent.secret_scope import get_secret
|
||||
|
||||
return (get_secret(name) or "").strip()
|
||||
|
||||
|
||||
def _debug(message: str) -> None:
|
||||
|
||||
@@ -335,7 +335,7 @@ class TestMem0V3Config:
|
||||
|
||||
class TestMem0ModeSwitch:
|
||||
|
||||
def test_oss_mode_initializes_without_unscoped_platform_key(
|
||||
def test_oss_mode_initializes_without_platform_key_in_scope(
|
||||
self, monkeypatch, tmp_path
|
||||
):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
@@ -349,7 +349,9 @@ class TestMem0ModeSwitch:
|
||||
)
|
||||
)
|
||||
|
||||
token = secret_scope.set_secret_scope(None)
|
||||
# A profile scope WITHOUT the platform key: OSS mode must not demand MEM0_API_KEY. (A
|
||||
# scope-less caller is a spawn-site bug and raises; see test_load_config_fails_closed_without_scope.)
|
||||
token = secret_scope.set_secret_scope({})
|
||||
secret_scope.set_multiplex_active(True)
|
||||
try:
|
||||
provider = Mem0MemoryProvider()
|
||||
@@ -379,6 +381,23 @@ class TestMem0ModeSwitch:
|
||||
secret_scope.set_multiplex_active(False)
|
||||
secret_scope.reset_secret_scope(token)
|
||||
|
||||
def test_load_config_fails_closed_without_scope_even_for_identity_settings(
|
||||
self, monkeypatch, tmp_path
|
||||
):
|
||||
"""A scope-less multiplex caller is a spawn-site bug: identity/mode reads must surface it,
|
||||
not degrade to '' and route the turn's memories into the default profile's account."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "mem0.json").write_text(json.dumps({"mode": "oss", "oss": {"vector_store": {"provider": "qdrant"}}}))
|
||||
|
||||
token = secret_scope.set_secret_scope(None)
|
||||
secret_scope.set_multiplex_active(True)
|
||||
try:
|
||||
with pytest.raises(secret_scope.UnscopedSecretError):
|
||||
mem0_plugin._load_config()
|
||||
finally:
|
||||
secret_scope.set_multiplex_active(False)
|
||||
secret_scope.reset_secret_scope(token)
|
||||
|
||||
def test_file_api_key_still_overrides_environment(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("MEM0_API_KEY", "env-key")
|
||||
|
||||
49
tests/plugins/test_scoped_secret_readers_fail_closed.py
Normal file
49
tests/plugins/test_scoped_secret_readers_fail_closed.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Credential shims outside the memory plugins honour the secret-scope contract.
|
||||
|
||||
``langfuse._secret`` and ``azure_identity_adapter._scoped_env`` used to catch ``UnscopedSecretError``
|
||||
and fall back to ``os.environ`` / ``""``. Under multiplex ``os.environ`` is the DEFAULT profile's
|
||||
``.env``, so that fallback either shipped another profile's credentials or hid the spawn-site bug the
|
||||
exception exists to surface. Contract: scope wins over environ; no scope while multiplexing raises.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from agent import secret_scope
|
||||
from agent.azure_identity_adapter import _scoped_env as azure_scoped_env
|
||||
from plugins.observability.langfuse import _secret as langfuse_secret
|
||||
|
||||
_READERS = {"langfuse": (langfuse_secret, "LANGFUSE_SECRET_KEY"),
|
||||
"azure": (azure_scoped_env, "AZURE_CLIENT_SECRET")}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def multiplex(monkeypatch):
|
||||
secret_scope.set_multiplex_active(True)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
secret_scope.set_multiplex_active(False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", sorted(_READERS))
|
||||
def test_scoped_read_prefers_profile_scope_over_default_environ(name, monkeypatch, multiplex):
|
||||
reader, var = _READERS[name]
|
||||
monkeypatch.setenv(var, "default-profile-value")
|
||||
token = secret_scope.set_secret_scope({var: " profile-b-value "})
|
||||
try:
|
||||
assert reader(var) == "profile-b-value"
|
||||
finally:
|
||||
secret_scope.reset_secret_scope(token)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", sorted(_READERS))
|
||||
def test_scopeless_multiplex_read_fails_loud(name, monkeypatch, multiplex):
|
||||
reader, var = _READERS[name]
|
||||
monkeypatch.setenv(var, "default-profile-value")
|
||||
token = secret_scope.set_secret_scope(None)
|
||||
try:
|
||||
with pytest.raises(secret_scope.UnscopedSecretError):
|
||||
reader(var)
|
||||
finally:
|
||||
secret_scope.reset_secret_scope(token)
|
||||
Reference in New Issue
Block a user