fix(child-env): per-profile plugin secrets, fail-closed declaration scans, dashboard auth in Tier 1

Review follow-ups on the declared-secret policy:

- A profile's user-installed platform plugins declared secrets into
  one process-wide set, read from the launch home at import. Under
  multiplex that stripped profile B's same-named user variable and
  missed profile A's own declarations. Bundled manifests stay
  process-wide; user manifests are now read per bound home, cached
  until that home's plugin dirs change, and are Tier 1 for that
  profile only.
- The declaration scans no longer fail open. A registry error is no
  longer swallowed into an empty set, non-string required_env entries
  are skipped rather than breaking the set, and a manifest that
  cannot be read raises instead of vanishing from the policy. A
  malformed manifest still declares nothing.
- A plugin manifest can no longer reclassify a core-declared name
  such as OPENAI_API_KEY; the same rule the config form applies.
- The dashboard basic-auth password and signing secret, the OIDC
  client secret and the drain bearer move to Tier 1. Credentialed
  CLIs (claude, codex) no longer receive them.
- openviking-server starts from served_profile_child_env, so it gets
  the bound profile's provider keys, never the launch profile's.
  With no bound profile under multiplex the start is refused.
- NOUS_API_KEY and QWEN_API_KEY are listed statically. Discovering
  provider plugins while the policy module imported re-mirrored them
  over a plugin's own auth registry entry
  (tests/providers/test_auth_registry_import_order.py).
This commit is contained in:
kshitijk4poor
2026-09-29 02:06:58 +05:30
committed by kshitij
parent 43800d55f2
commit 3a6406137d
9 changed files with 186 additions and 72 deletions
+2 -1
View File
@@ -353,7 +353,8 @@ class PlatformRegistry:
loading deferred adapters; the child-env scrub reads this on every spawn."""
with self._lock:
entries, _deferred = self._scope_maps(self.current_scope_key())
return {n for e in (*self._entries.values(), *entries.values()) for n in e.required_env}
return {n for e in (*self._entries.values(), *entries.values())
for n in e.required_env if isinstance(n, str)}
def is_registered(self, name: str) -> bool:
# A deferred (not-yet-imported) platform still counts as registered so cheap membership
+58 -22
View File
@@ -3990,16 +3990,21 @@ def _inject_profile_env_vars() -> None:
_inject_profile_env_vars()
def _platform_plugin_manifests():
def _platform_plugin_manifests(home: Optional[Path] = None, *, bundled: bool = True,
user: bool = True, strict: bool = False):
"""Yield ``(dir_name, manifest_dict)`` for every platform plugin manifest: bundled
``plugins/platforms/*``, the user's ``<HERMES_HOME>/plugins/platforms/*`` category dir, and flat
user installs ``<HERMES_HOME>/plugins/*`` that declare ``kind: platform`` (#46600)."""
user_plugins = get_hermes_home() / "plugins"
roots = (
(get_project_root() / "plugins" / "platforms", False),
(user_plugins / "platforms", False),
(user_plugins, True), # flat layout: only manifests that say they are platforms
)
``plugins/platforms/*``, the user's ``<home>/plugins/platforms/*`` category dir, and flat
user installs ``<home>/plugins/*`` that declare ``kind: platform`` (#46600). ``home``
defaults to the bound Hermes home. ``strict`` raises when a manifest cannot be read
instead of skipping it: the child-env scrub must not lose a declared secret to an I/O error.
A manifest that does not parse declares nothing (its adapter cannot load either) and is skipped."""
user_plugins = (home if home is not None else get_hermes_home()) / "plugins"
roots = []
if bundled:
roots.append((get_project_root() / "plugins" / "platforms", False))
if user:
roots += [(user_plugins / "platforms", False),
(user_plugins, True)] # flat layout: only manifests that say they are platforms
for root, require_kind in roots:
if not root.is_dir():
continue
@@ -4011,6 +4016,10 @@ def _platform_plugin_manifests():
try:
with open(manifest_path, "r", encoding="utf-8-sig") as f:
manifest = fast_safe_load(f) or {}
except OSError:
if strict: # a file we cannot read may declare secrets; a malformed one declares none
raise
continue
except Exception:
continue
if not isinstance(manifest, dict) or (require_kind and manifest.get("kind") != "platform"):
@@ -4018,27 +4027,54 @@ def _platform_plugin_manifests():
yield child.name, manifest
def _platform_manifest_env_entries(manifest: dict):
"""Yield ``(name, is_secret, meta)`` for a manifest's ``requires_env`` / ``optional_env``
entries (a bare name or a dict with ``name`` plus optional ``description``/``url``/
``password``/``prompt``/``category``). *TOKEN / *SECRET / *KEY / *PASSWORD / *JSON are
password fields unless the entry says ``password: false``."""
for entry in [*(manifest.get("requires_env") or []), *(manifest.get("optional_env") or [])]:
meta = {"name": entry} if isinstance(entry, str) else entry if isinstance(entry, dict) else {}
name = meta.get("name")
if not name or not isinstance(name, str):
continue
is_secret = bool(meta.get("password") or meta.get("secret"))
if not is_secret and not meta.get("password") is False:
is_secret = name.upper().endswith(("_TOKEN", "_SECRET", "_KEY", "_PASSWORD", "_JSON"))
yield name, is_secret, meta
# Names declared in core (OPTIONAL_ENV_VARS before any platform manifest is read). A manifest
# never reclassifies one of these: the config form keeps the core entry, and the child-env scrub
# keeps a plugin that lists OPENAI_API_KEY from turning a provider key into an adapter secret.
_CORE_DECLARED_ENV_NAMES: frozenset[str] = frozenset()
def platform_manifest_secret_envs(home: Optional[Path] = None, *, bundled: bool,
strict: bool = False) -> frozenset[str]:
"""Upper-cased secret env names the platform plugin manifests declare, minus core-declared
names. ``bundled=True`` reads only the shipped plugins (process-wide); ``bundled=False`` reads
only ``home``'s user-installed platform plugins, which belong to that profile alone."""
names: set[str] = set()
for _dir, manifest in _platform_plugin_manifests(home, bundled=bundled, user=not bundled, strict=strict):
names.update(name.upper() for name, is_secret, meta in _platform_manifest_env_entries(manifest)
if is_secret and (meta.get("category") or "messaging") == "messaging")
return frozenset(names - {n.upper() for n in _CORE_DECLARED_ENV_NAMES})
def _inject_platform_plugin_env_vars() -> None:
"""Populate OPTIONAL_ENV_VARS from platform plugin manifests (bundled AND user-installed) so
Teams / IRC / Google Chat and third-party platforms are configurable in the ``hermes config`` /
Desktop Gateway form without the core knowing they exist.
``requires_env`` / ``optional_env`` entries are a bare name or a dict with ``name`` plus
optional ``description``/``url``/``password``/``prompt``/``category``. Failures are swallowed
so a malformed plugin.yaml can't break CLI import.
Desktop Gateway form without the core knowing they exist. Failures are swallowed so a
malformed plugin.yaml can't break CLI import.
"""
global _CORE_DECLARED_ENV_NAMES
_CORE_DECLARED_ENV_NAMES = frozenset(OPTIONAL_ENV_VARS)
try:
for dir_name, manifest in _platform_plugin_manifests():
label = manifest.get("label") or manifest.get("name") or dir_name
for entry in [*(manifest.get("requires_env") or []), *(manifest.get("optional_env") or [])]:
meta = {"name": entry} if isinstance(entry, str) else entry if isinstance(entry, dict) else {}
name = meta.get("name")
if not name or name in OPTIONAL_ENV_VARS:
for name, is_secret, meta in _platform_manifest_env_entries(manifest):
if name in OPTIONAL_ENV_VARS:
continue # hardcoded entry wins (back-compat)
# *TOKEN / *SECRET / *KEY / *PASSWORD / *JSON are password fields unless overridden.
is_secret = bool(meta.get("password") or meta.get("secret"))
if not is_secret and not meta.get("password") is False:
is_secret = name.upper().endswith(("_TOKEN", "_SECRET", "_KEY", "_PASSWORD", "_JSON"))
OPTIONAL_ENV_VARS[name] = {
"description": meta.get("description") or f"{label} configuration",
"prompt": meta.get("prompt") or name,
+6 -4
View File
@@ -979,10 +979,12 @@ def _start_local_openviking_server(endpoint: str) -> tuple[str, str]:
# would import aiohttp and friends from the Hermes venv instead of its own (its venv's site-packages
# are shadowed because PYTHONPATH precedes them) — and on Windows the loaded DLLs then lock the
# Hermes venv, aborting `hermes update` with access-denied on .pyd files. (#78153)
# The server's embedding/VLM models may read provider keys, so those pass; bot, gateway
# and relay tokens never do. HOME stays the user's: ov.conf defaults to ~/.openviking.
from tools.environments.local import hermes_subprocess_env
child_env = hermes_subprocess_env(inherit_credentials=True)
# The server's embedding/VLM models may read provider keys, so the bound profile's pass
# (never the launch profile's: under multiplex the process env belongs to whoever started
# the gateway, and with no bound profile the builder refuses); bot, gateway and relay
# tokens never do. HOME stays the user's: ov.conf defaults to ~/.openviking.
from tools.environments.local import served_profile_child_env
child_env = served_profile_child_env(inherit_credentials=True)
child_env["HOME"] = child_env["HERMES_REAL_HOME"]
child_env.pop("PYTHONPATH", None)
with log_path.open("ab") as log_file:
-8
View File
@@ -233,14 +233,6 @@ def list_providers() -> list[ProviderProfile]:
return result
def bundled_provider_profiles() -> list[ProviderProfile]:
"""The profiles shipped with hermes-agent, from the process-wide layer only: unlike
:func:`list_providers` the answer never depends on which profile home is bound."""
if not _discovered:
_discover_providers()
return [p for name, p in _REGISTRY.items() if _SOURCES.get(name) == "bundled"]
def _home_layer(*, force_stamp_check: bool = False) -> _HomeLayer:
"""The layer for the home bound right now, importing plugin dirs it has not seen yet."""
layer, home, key = _bound_home_layer()
@@ -123,6 +123,11 @@ class TestTierInvariants:
def test_tier1_covers_infra_secrets(self):
assert {"MODAL_TOKEN_ID", "MODAL_TOKEN_SECRET", "DAYTONA_API_KEY"} <= _ALWAYS_STRIP_KEYS
def test_tier1_covers_dashboard_auth(self):
# Credentialed CLIs (claude/codex) must not be able to mint dashboard sessions.
assert {"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", "HERMES_DASHBOARD_BASIC_AUTH_SECRET",
"HERMES_DASHBOARD_OIDC_CLIENT_SECRET", "HERMES_DASHBOARD_DRAIN_SECRET"} <= _ALWAYS_STRIP_KEYS
class TestBrowserPassthroughPattern:
def test_browser_keys_recoverable_after_strip(self):
+56 -3
View File
@@ -110,10 +110,11 @@ OPERATOR_SECRETS = ["MY_APP_KEY", "DEPLOY_WEBHOOK_SECRET", "SLACK_USER_TOKEN", "
@pytest.mark.parametrize("builder", ["foreground", "background", "nonterminal"])
def test_adapter_and_provider_profile_secrets_never_reach_children(child_env, monkeypatch, builder):
from providers import bundled_provider_profiles
from providers import list_providers
from tools.env_passthrough import is_env_passthrough, register_env_passthrough
secrets = set(ADAPTER_SECRETS)
secrets.update(name for profile in bundled_provider_profiles() for name in (profile.env_vars or ()))
# child_env's HERMES_HOME has no provider plugins, so these are the bundled profiles.
secrets.update(name for profile in list_providers() for name in (profile.env_vars or ()))
secrets.discard("CLAUDE_CODE_OAUTH_TOKEN") # operator's subscription, not Hermes inference
for name in [*secrets, *OPERATOR_SECRETS]:
monkeypatch.setenv(name, "fake-" + name)
@@ -130,6 +131,58 @@ def test_adapter_and_provider_profile_secrets_never_reach_children(child_env, mo
assert all(is_env_passthrough(name) for name in OPERATOR_SECRETS)
def _user_platform_plugin(home, name, secret):
plugin = home / "plugins" / "platforms" / name
plugin.mkdir(parents=True)
(plugin / "plugin.yaml").write_text(
f"name: {name}\nkind: platform\nrequires_env:\n - name: {secret}\n password: true\n",
encoding="utf-8")
def test_user_platform_plugin_secrets_belong_to_their_own_profile(child_env, monkeypatch):
"""A profile's user-installed platform plugin declares secrets for that profile only: bound to
it, the name is stripped from every child and refused for passthrough; bound to a sibling
profile, the sibling's own same-named value is its user variable and reaches its children."""
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
from tools.env_passthrough import is_env_passthrough, register_env_passthrough
a, b = child_env / "profiles" / "a", child_env / "profiles" / "b"
_user_platform_plugin(a, "chatx", "CHATX_SIGNING_SECRET")
b.mkdir(parents=True)
monkeypatch.setenv("CHATX_SIGNING_SECRET", "fake-value")
register_env_passthrough(["CHATX_SIGNING_SECRET"])
seen = {}
for home in (a, b):
token = set_hermes_home_override(home)
try:
seen[home.name] = (
"CHATX_SIGNING_SECRET" in local.hermes_subprocess_env(inherit_credentials=True),
"CHATX_SIGNING_SECRET" in local._make_run_env({}),
is_env_passthrough("CHATX_SIGNING_SECRET"))
finally:
reset_hermes_home_override(token)
assert seen == {"a": (False, False, False), "b": (True, True, True)}
@pytest.mark.skipif(os.name == "nt" or os.geteuid() == 0, # windows-footgun: ok — short-circuits on nt
reason="needs POSIX permissions as non-root")
def test_unreadable_platform_manifest_fails_closed(tmp_path):
"""A manifest that cannot be read might declare secrets, so the policy scan raises rather than
returning a set without them. A malformed one declares nothing and is skipped."""
from hermes_cli.config import platform_manifest_secret_envs
_user_platform_plugin(tmp_path, "chatx", "CHATX_SIGNING_SECRET")
broken = tmp_path / "plugins" / "platforms" / "broken"
broken.mkdir()
(broken / "plugin.yaml").write_text("name: [unclosed\n", encoding="utf-8")
assert platform_manifest_secret_envs(tmp_path, bundled=False, strict=True) == {"CHATX_SIGNING_SECRET"}
manifest = tmp_path / "plugins" / "platforms" / "chatx" / "plugin.yaml"
manifest.chmod(0)
try:
with pytest.raises(PermissionError):
platform_manifest_secret_envs(tmp_path, bundled=False, strict=True)
finally:
manifest.chmod(0o644)
def test_inheriting_child_gets_provider_keys_but_never_adapter_secrets(child_env, monkeypatch):
# inherit_credentials is the narrow grant for model-driving CLIs: provider keys only.
granted = ["OPENAI_API_KEY", "NOUS_API_KEY", *OPERATOR_SECRETS]
@@ -185,7 +238,7 @@ def test_passthrough_accepted_before_an_adapter_owns_the_name_stops_forwarding_i
else:
home = child_env / "hermes"
home.mkdir(exist_ok=True)
(home / "config.yaml").write_text("terminal:\n env_passthrough: [%s]\n" % ", ".join(names))
(home / "config.yaml").write_text("terminal:\n env_passthrough: [%s]\n" % ", ".join(names), encoding="utf-8")
builders = [lambda: local._make_run_env({}), lambda: local._sanitize_subprocess_env(dict(os.environ)),
lambda: _scrub_child_env(dict(os.environ))]
assert [observe_child(b(), names) for b in builders] == [
+2 -2
View File
@@ -61,7 +61,7 @@ def _openviking_server_seen(child_env, monkeypatch, names):
state, _ = ov._start_local_openviking_server("http://127.0.0.1:1933")
assert state == ov._LOCAL_SERVER_STARTED
children[0].wait(timeout=30)
return json.loads(out.read_text(encoding="utf-8"))
return json.loads(out.read_text(encoding="utf-8-sig"))
def test_compute_host_is_hermes_and_keeps_its_full_environment(child_env, monkeypatch):
@@ -136,7 +136,7 @@ def test_third_party_children_never_see_hermes_credentials(child_env, monkeypatc
adapter._spawn_bridge(4321)
adapter._bridge_process.wait(timeout=30)
seen = json.loads(out.read_text(encoding="utf-8"))
seen = json.loads(out.read_text(encoding="utf-8-sig"))
assert {k: seen[k] for k in (*_TIER1, _PROVIDER)} == dict.fromkeys((*_TIER1, _PROVIDER))
assert seen[own] # the child's own configuration still arrives
user_home = site in ("raft_bridge", "buzz_cli")
+4 -2
View File
@@ -23,7 +23,8 @@ from hermes_cli._subprocess_compat import windows_hide_flags
from tools.environments.local_env_policy import ( # noqa: F401 — _HERMES_PROVIDER_ENV_BLOCKLIST stays importable from here
_ALWAYS_STRIP_KEYS, _HERMES_PROVIDER_ENV_BLOCKLIST, _HERMES_PROVIDER_ENV_FORCE_PREFIX,
_is_hermes_internal_secret, _is_provider_env_blocklisted, _is_terminal_first_party_env,
_matches_terminal_first_party_prefix, _plugin_terminal_env_strip_keys, _registered_adapter_secret_env,
_home_adapter_secret_env, _matches_terminal_first_party_prefix, _plugin_terminal_env_strip_keys,
_registered_adapter_secret_env,
strip_profile_gate_env)
from tools.environments.local_pythonpath import (
_build_hermes_repo_root_aliases, _strip_hermes_owned_pythonpath_and_runtime_markers)
@@ -334,7 +335,8 @@ def _scrub_credentials(env: dict, *, inherit_credentials: bool) -> dict:
"""Tier 1 (always) and, unless ``inherit_credentials``, Tier 2 provider/tool credentials, in place."""
# Credential names fold to uppercase for membership: on Windows the env block
# itself is case-insensitive, so a lowercase-stored ``gh_token`` IS GH_TOKEN.
strip_folded = frozenset(k.upper() for k in (_ALWAYS_STRIP_KEYS | _plugin_terminal_env_strip_keys()))
strip_folded = frozenset(k.upper() for k in (
_ALWAYS_STRIP_KEYS | _plugin_terminal_env_strip_keys() | _home_adapter_secret_env()))
registered = _registered_adapter_secret_env()
for key in list(env):
if (key.upper() in strip_folded
+53 -30
View File
@@ -40,6 +40,10 @@ _STATIC_PROVIDER_ENV_BLOCKLIST = frozenset({
"DAYTONA_API_KEY", "GATEWAY_RELAY_ID", "GATEWAY_RELAY_SECRET",
"GATEWAY_RELAY_DELIVERY_KEY", "VERCEL_OIDC_TOKEN", "VERCEL_TOKEN",
"VERCEL_PROJECT_ID", "VERCEL_TEAM_ID",
# Keys the OAuth provider profiles (nous, qwen-oauth) also accept when pasted. The auth
# registry mirrors env_vars only for api_key profiles, and discovering the provider plugins
# from here, at import, would re-mirror them over a plugin's own registry entry.
"NOUS_API_KEY", "QWEN_API_KEY",
# Hermes' own secrets read in code: the anonymous-inference secret, dashboard auth
# (basic, OIDC, drain) and the Google Meet realtime key.
"HERMES_ANON_API_SECRET", "HERMES_DASHBOARD_BASIC_AUTH_PASSWORD",
@@ -61,16 +65,6 @@ def _build_provider_env_blocklist() -> frozenset:
blocked.add(pconfig.base_url_env_var)
except ImportError:
pass
try:
# The registry mirror copies env_vars only for api_key profiles, but OAuth
# profiles (nous, qwen-oauth) also accept a pasted key under theirs. Bundled
# profiles only: this frozenset is process-wide, and a home's own provider
# plugins would freeze the home bound at import into every profile's policy.
from providers import bundled_provider_profiles
for profile in bundled_provider_profiles():
blocked.update(profile.env_vars or ())
except ImportError:
pass
try:
from hermes_cli.config import OPTIONAL_ENV_VARS
for name, metadata in OPTIONAL_ENV_VARS.items():
@@ -100,19 +94,22 @@ _SECRET_ENV_SUFFIXES = ("_TOKEN", "_SECRET", "_PASSWORD", "_KEY")
def _build_adapter_secret_env() -> frozenset:
"""Secrets the messaging adapters declare: ``password`` messaging entries of OPTIONAL_ENV_VARS
(built-ins plus every platform plugin manifest's ``requires_env`` / ``optional_env``) and the
"""Secrets the messaging adapters declare, process-wide: core ``password`` messaging entries
of OPTIONAL_ENV_VARS, the bundled platform plugin manifests' secret entries, and the
secret-named keys the gateway env-override table reads (WEIXIN_TOKEN, FEISHU_ENCRYPT_KEY, ...).
Declared names only: a user's own ``SLACK_USER_TOKEN`` or ``LOCAL_LLM_API_KEY`` is not Hermes's."""
Declared names only: a user's own ``SLACK_USER_TOKEN`` or ``LOCAL_LLM_API_KEY`` is not Hermes's.
A profile's user-installed platform plugins are per home: :func:`_home_adapter_secret_env`.
The bundled manifests are read strictly: an unreadable one fails the import rather than
dropping its secrets from the policy."""
# Read in code only, declared nowhere else: the Microsoft Graph app secret and webhook
# clientState, and the QQ bot's speech-to-text key.
names: set[str] = {"MSGRAPH_CLIENT_SECRET", "MSGRAPH_WEBHOOK_CLIENT_STATE", "QQ_STT_API_KEY"}
try:
from hermes_cli.config import OPTIONAL_ENV_VARS
names.update(name.upper() for name, meta in OPTIONAL_ENV_VARS.items()
if meta.get("category") == "messaging" and meta.get("password"))
except ImportError:
pass
from hermes_cli.config import (
_CORE_DECLARED_ENV_NAMES, OPTIONAL_ENV_VARS, platform_manifest_secret_envs)
names.update(name.upper() for name, meta in OPTIONAL_ENV_VARS.items()
if name in _CORE_DECLARED_ENV_NAMES
and meta.get("category") == "messaging" and meta.get("password"))
names |= platform_manifest_secret_envs(bundled=True, strict=True)
try:
from gateway import config_env
from hermes_cli.profile_channels import _cred_row_envs
@@ -126,21 +123,42 @@ def _build_adapter_secret_env() -> frozenset:
return frozenset(names)
# Provider blocklist first: it imports hermes_cli.auth before hermes_cli.config, whose import
# discovers provider plugins that expect a fully initialized auth registry.
_PROVIDER_ENV_BLOCKLIST = _build_provider_env_blocklist()
_ADAPTER_SECRET_ENV = _build_adapter_secret_env()
_HERMES_PROVIDER_ENV_BLOCKLIST = _build_provider_env_blocklist() | _ADAPTER_SECRET_ENV
_HERMES_PROVIDER_ENV_BLOCKLIST = _PROVIDER_ENV_BLOCKLIST | _ADAPTER_SECRET_ENV
_HOME_ADAPTER_SECRET_CACHE: dict = {}
def _home_adapter_secret_env() -> frozenset:
"""Secrets declared by the bound profile's own user-installed platform plugins. Per home, not
process-wide: under multiplex profile A's plugin must neither strip a same-named value from
profile B's children nor be missing from A's. Cached per home until its plugin dirs change;
an unreadable manifest raises instead of silently dropping the declaration."""
from hermes_cli.config import platform_manifest_secret_envs
from hermes_constants import get_hermes_home
home = get_hermes_home()
plugins = home / "plugins"
stamp = tuple(d.stat().st_mtime_ns if d.is_dir() else None for d in (plugins, plugins / "platforms"))
cached = _HOME_ADAPTER_SECRET_CACHE.get(str(home))
if cached is None or cached[0] != stamp:
cached = (stamp, platform_manifest_secret_envs(home, bundled=False, strict=True) - _ADAPTER_SECRET_ENV)
_HOME_ADAPTER_SECRET_CACHE[str(home)] = cached
return cached[1]
def _registered_adapter_secret_env() -> frozenset:
"""Secret-named ``required_env`` of the adapters registered in the current profile scope. Read
per call: plugin adapters register late and per profile, and a profile's own user plugins are
not in the import-time OPTIONAL_ENV_VARS. Tier 2 only: ``required_env`` is an unchecked setup
list, so a plugin naming OPENAI_API_KEY must not strip it from credentialed children."""
try:
from gateway.platform_registry import platform_registry
return frozenset(n.upper() for n in platform_registry.required_env_names()
if n.upper().endswith(_SECRET_ENV_SUFFIXES))
except Exception:
return frozenset()
"""Per-call adapter secrets: the bound profile's user-plugin declarations (Tier 1, see
:func:`_home_adapter_secret_env`) plus the secret-named ``required_env`` of the adapters
registered in the current profile scope. Registered names are Tier 2 only: ``required_env`` is
an unchecked setup list, so a plugin naming OPENAI_API_KEY must not strip it from credentialed
children. No blanket fallback: a registry error surfaces instead of an empty (fail-open) set."""
from gateway.platform_registry import platform_registry
registered = {n.upper() for n in platform_registry.required_env_names()
if n.upper().endswith(_SECRET_ENV_SUFFIXES)}
return frozenset(registered) | _home_adapter_secret_env()
def _is_provider_env_blocklisted(name: str, _registered: "frozenset | None" = None) -> bool:
@@ -356,6 +374,11 @@ _ALWAYS_STRIP_KEYS: frozenset[str] = frozenset({
# enumerated here to stay stripped on the inherit_credentials=True path.
"GATEWAY_RELAY_ID", "GATEWAY_RELAY_SECRET", "GATEWAY_RELAY_DELIVERY_KEY",
"HASS_TOKEN", "EMAIL_PASSWORD", "HERMES_DASHBOARD_SESSION_TOKEN",
# Dashboard auth: the basic-auth password and session-signing secret, the OIDC client
# secret and the drain bearer. They let a holder mint or forge dashboard sessions, and no
# child (credentialed CLIs included) consumes them.
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", "HERMES_DASHBOARD_BASIC_AUTH_SECRET",
"HERMES_DASHBOARD_OIDC_CLIENT_SECRET", "HERMES_DASHBOARD_DRAIN_SECRET",
# Remote-compute / infrastructure secrets
"MODAL_TOKEN_ID", "MODAL_TOKEN_SECRET", "DAYTONA_API_KEY",
}) | _ADAPTER_SECRET_ENV # every declared adapter secret is Tier 1, like the bot tokens above