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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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] == [
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user