From 5c6cc38010478a0604bc304a1d25884aed18e1f4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:52:55 -0700 Subject: [PATCH] fix(secrets): scope-aware credential reads in core tool/gateway/web-server paths TOOL_GATEWAY_USER_TOKEN (managed_tool_gateway), OPENROUTER_API_KEY presence (openrouter_client), SUDO_PASSWORD (terminal_tool), GATEWAY_PROXY_KEY (gateway/run), SLACK_BOT_TOKEN presence (gateway/session), and the ELEVENLABS_API_KEY env fallback (web_server voices endpoint) now honor the installed profile secret scope; unscoped callers keep legacy env reads via the UnscopedSecretError fallback (Slack pattern). --- gateway/run.py | 13 ++++++++++++- gateway/session.py | 14 +++++++++++++- hermes_cli/web_server.py | 15 ++++++++++++++- tools/managed_tool_gateway.py | 34 ++++++++++++++++++++++++++++------ tools/openrouter_client.py | 16 +++++++++++++++- tools/terminal_tool.py | 16 ++++++++++++++-- 6 files changed, 96 insertions(+), 12 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 6c97d02260bac..2d870b2cd8cf9 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -23074,7 +23074,18 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew "tools": [], } - proxy_key = os.getenv("GATEWAY_PROXY_KEY", "").strip() + # Scope-aware read: the proxy key is a per-profile credential; under + # multiplex honor the installed scope's verdict (Slack pattern for + # the unscoped default-profile loop). + try: + from agent.secret_scope import UnscopedSecretError, get_secret + + try: + proxy_key = (get_secret("GATEWAY_PROXY_KEY") or "").strip() + except UnscopedSecretError: + proxy_key = os.getenv("GATEWAY_PROXY_KEY", "").strip() + except Exception: + proxy_key = os.getenv("GATEWAY_PROXY_KEY", "").strip() def _run_still_current() -> bool: if run_generation is None or not session_key: diff --git a/gateway/session.py b/gateway/session.py index 2d5cebd32026c..4335ad54bcafa 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -387,7 +387,19 @@ def _slack_tools_loaded() -> bool: except Exception: pass - if not (os.environ.get("SLACK_BOT_TOKEN") or "").strip(): + # Presence check through the profile secret scope: under multiplex the + # process env may carry another profile's token (Slack pattern for the + # unscoped default-profile path). + try: + from agent.secret_scope import UnscopedSecretError, get_secret + + try: + _slack_token = get_secret("SLACK_BOT_TOKEN") or "" + except UnscopedSecretError: + _slack_token = os.environ.get("SLACK_BOT_TOKEN") or "" + except Exception: + _slack_token = os.environ.get("SLACK_BOT_TOKEN") or "" + if not _slack_token.strip(): return False try: from hermes_cli.config import load_config diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 2f4ace574b100..9e7bf8421abe7 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -4320,7 +4320,20 @@ async def get_elevenlabs_voices(profile: Optional[str] = None): # Config-only scope (await-safe): the key lookup reads the requested # profile's .env, matching the profile the settings UI writes to. with _config_profile_scope(profile): - api_key = (load_env().get("ELEVENLABS_API_KEY") or os.environ.get("ELEVENLABS_API_KEY") or "").strip() + api_key = (load_env().get("ELEVENLABS_API_KEY") or "").strip() + if not api_key: + # Fallback for env-only deployments — scope-aware (Slack pattern): + # under multiplex os.environ may hold another profile's key, so + # honor the installed scope's verdict before touching the env. + try: + from agent.secret_scope import UnscopedSecretError, get_secret + + try: + api_key = (get_secret("ELEVENLABS_API_KEY") or "").strip() + except UnscopedSecretError: + api_key = (os.environ.get("ELEVENLABS_API_KEY") or "").strip() + except Exception: + api_key = (os.environ.get("ELEVENLABS_API_KEY") or "").strip() if not api_key: return {"available": False, "voices": []} diff --git a/tools/managed_tool_gateway.py b/tools/managed_tool_gateway.py index af7f8f69748d3..c46a48975bb4b 100644 --- a/tools/managed_tool_gateway.py +++ b/tools/managed_tool_gateway.py @@ -73,6 +73,28 @@ def _access_token_is_expiring(expires_at: object, skew_seconds: int) -> bool: return remaining <= max(0, int(skew_seconds)) +def _read_user_token_override() -> Optional[str]: + """Read the TOOL_GATEWAY_USER_TOKEN env override through the secret scope. + + Availability scans run both inside agent turns (scope installed) and in + unscoped CLI paths, so this uses the Slack pattern: honor the scope's + verdict when installed (a scoped miss does NOT borrow the process env + under multiplex), fall back to ``os.environ`` only when unscoped. + """ + try: + from agent.secret_scope import UnscopedSecretError, get_secret + + try: + explicit = get_secret("TOOL_GATEWAY_USER_TOKEN") + except UnscopedSecretError: + explicit = os.getenv("TOOL_GATEWAY_USER_TOKEN") + except Exception: + explicit = os.getenv("TOOL_GATEWAY_USER_TOKEN") + if isinstance(explicit, str) and explicit.strip(): + return explicit.strip() + return None + + def peek_nous_access_token() -> Optional[str]: """Cheap probe for a Nous gateway token without triggering refresh. @@ -83,9 +105,9 @@ def peek_nous_access_token() -> Optional[str]: network calls. Truthful refresh handling stays in request/session paths that call :func:`read_nous_access_token`. """ - explicit = os.getenv("TOOL_GATEWAY_USER_TOKEN") - if isinstance(explicit, str) and explicit.strip(): - return explicit.strip() + explicit = _read_user_token_override() + if explicit: + return explicit nous_provider = _read_nous_provider_state() or {} access_token = nous_provider.get("access_token") @@ -96,9 +118,9 @@ def peek_nous_access_token() -> Optional[str]: def read_nous_access_token() -> Optional[str]: """Read a Nous Subscriber OAuth access token from auth store or env override.""" - explicit = os.getenv("TOOL_GATEWAY_USER_TOKEN") - if isinstance(explicit, str) and explicit.strip(): - return explicit.strip() + explicit = _read_user_token_override() + if explicit: + return explicit nous_provider = _read_nous_provider_state() or {} cached_token = peek_nous_access_token() diff --git a/tools/openrouter_client.py b/tools/openrouter_client.py index 0637a7db0deda..9c857076c8753 100644 --- a/tools/openrouter_client.py +++ b/tools/openrouter_client.py @@ -29,5 +29,19 @@ def get_async_client(): def check_api_key() -> bool: - """Check whether the OpenRouter API key is present.""" + """Check whether the OpenRouter API key is present. + + Scope-aware (Slack pattern): tool paths run inside an installed profile + secret scope, whose verdict is authoritative under multiplex; unscoped + CLI probes keep the legacy env read. + """ + try: + from agent.secret_scope import UnscopedSecretError, get_secret + + try: + return bool(get_secret("OPENROUTER_API_KEY")) + except UnscopedSecretError: + pass + except Exception: + pass return bool(os.getenv("OPENROUTER_API_KEY")) diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 7f9141ff8a9ef..244449ce97bcb 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -977,9 +977,21 @@ def _transform_sudo_command(command: str | None) -> tuple[str | None, str | None if sudo_count == 0: return command, None - has_configured_password = "SUDO_PASSWORD" in os.environ + # Scope-aware read (Slack pattern): under multiplex the process env may + # hold another profile's SUDO_PASSWORD, so honor the installed scope's + # verdict; unscoped callers keep the legacy os.environ read. + try: + from agent.secret_scope import UnscopedSecretError, get_secret + + try: + _configured_password = get_secret("SUDO_PASSWORD") + except UnscopedSecretError: + _configured_password = os.environ.get("SUDO_PASSWORD") + except Exception: + _configured_password = os.environ.get("SUDO_PASSWORD") + has_configured_password = _configured_password is not None sudo_password = ( - os.environ.get("SUDO_PASSWORD", "") + _configured_password if has_configured_password else _get_cached_sudo_password() )