Files
hermes-agent/agent/secret_scope.py
teknium1 bd970b0588 fix(profiles): route-only launch pin; profile delete names its own unit under multiplex
Builds on tancou's #119129 (cherry-picked above): the pin now lives in
get_routing_process_hermes_home() and only the four routed-profile DECISIONS read it.
get_process_hermes_home()/get_hermes_home() keep following HERMES_HOME, so an env-only
home switch in a multiplexed process resolves as before.

- set_multiplex_active(True) pins the launch home only when no host pin exists, and
  set_multiplex_active(False) releases only the pin it created itself. A transient toggle
  (gateway_migrate._multiplex_read_mode, cron external-worker restore) no longer drops an
  embedding host's explicit pin_process_hermes_home(launch).
- profiles._cleanup_gateway_service binds set_hermes_home_override(profile_dir) beside the
  env write. Under the previous head, DELETE /api/profiles/<x> from a multi-profile dashboard
  resolved get_service_name() against the pinned launch home -> bare `hermes-gateway`, and
  disabled/stopped/unlinked the HOST multiplexer's unit. Same path serves rename_profile.

Tests (red on the previous head): explicit pin survives True->False; env readers follow the
env while pinned; two-home delete removes hermes-gateway-victim and leaves hermes-gateway.
2026-09-23 08:09:47 -07:00

402 lines
18 KiB
Python

"""Profile-scoped credential resolution for multi-profile gateway multiplexing.
The multiplexing gateway serves many profiles from one process; each profile's
``.env`` keys **cannot** be unioned into ``os.environ`` (profile A's keys would
leak into profile B's turns and subprocesses). This module is a fail-closed,
context-local secret scope: ``set_secret_scope(mapping)`` installs the active
profile's secrets for the current task (a contextvar, so it propagates into the
agent's worker thread via ``copy_context()``); ``get_secret(name)`` reads from
it and, when multiplexing is active with no scope set, RAISES rather than
falling back to ``os.environ``. Design: ``website/docs/developer-guide/multiplexing-gateway.md``.
"""
from __future__ import annotations
import codecs
import os
import re
import threading
from collections import OrderedDict
from contextvars import ContextVar, Token
from pathlib import Path
from typing import Dict, Mapping, NamedTuple, Optional, Tuple
from utils import file_signature
# Process-global (describes the deployment mode, not a per-task value): set once
# at gateway startup when gateway.multiplex_profiles is true.
_MULTIPLEX_ACTIVE: bool = False
# Launch home pinned by set_multiplex_active(True) itself (None: no auto-pin outstanding).
_AUTO_PINNED_HOME = None
def set_multiplex_active(active: bool) -> None:
"""Mark whether the process is a profile multiplexer (get_secret fails closed).
Activation also pins the launch home for routed-profile decisions
(``hermes_constants.pin_process_hermes_home``) unless an embedding host already pinned one:
from here on "is this task routed" compares the override against the home the process was
launched with, not against whatever a host later mirrors into ``os.environ["HERMES_HOME"]``.
Deactivation releases only the pin activation itself created — a transient toggle
(``gateway_migrate._multiplex_read_mode``, a cron worker restoring the caller's mode) must not
drop the host's explicit pin (#119242)."""
global _MULTIPLEX_ACTIVE, _AUTO_PINNED_HOME
from hermes_constants import (
get_routing_process_hermes_home,
pin_process_hermes_home,
process_hermes_home_is_pinned,
)
_MULTIPLEX_ACTIVE = bool(active)
if _MULTIPLEX_ACTIVE:
if not process_hermes_home_is_pinned():
_AUTO_PINNED_HOME = get_routing_process_hermes_home()
pin_process_hermes_home(_AUTO_PINNED_HOME)
elif _AUTO_PINNED_HOME is not None:
if get_routing_process_hermes_home() == _AUTO_PINNED_HOME:
pin_process_hermes_home(None)
_AUTO_PINNED_HOME = None
def is_multiplex_active() -> bool:
return _MULTIPLEX_ACTIVE
class _BoundScope(NamedTuple):
"""An installed secret scope plus the home it was built for, when the binder
declared one — the provenance ``serves_routed_profile`` needs when the binding
deliberately skips the HERMES_HOME override (kanban spawn-env builds, MCP
owner scopes)."""
mapping: Mapping[str, str]
profile_home: Optional[str]
def serves_routed_profile() -> bool:
"""True when the current task runs for a profile other than the process's own: always under
multiplexing, else when a HERMES_HOME override names another home (dashboard/desktop backend,
per-profile cron ticker) or a secret scope stamped with a foreign home is bound. The MCP
registry scope and the check_fn cache key both follow this predicate so a served profile's
view never aliases the launch profile's (#111151). A host that mirrors the turn's profile into
``HERMES_HOME`` pins its own home with ``hermes_constants.pin_process_hermes_home`` so the
mirror cannot flip this predicate."""
if is_multiplex_active():
return True
from hermes_constants import get_hermes_home_override, get_routing_process_hermes_home, hermes_home_key
own = hermes_home_key(get_routing_process_hermes_home())
bound = _SECRET_SCOPE.get()
if bound is not None and bound.profile_home and hermes_home_key(bound.profile_home) != own:
return True
override = get_hermes_home_override()
return override is not None and hermes_home_key(override) != own
_SECRET_SCOPE: ContextVar[Optional[_BoundScope]] = ContextVar("_SECRET_SCOPE", default=None)
class UnscopedSecretError(RuntimeError):
"""A secret was read in multiplex mode with no scope installed.
The fix is to wrap the call path in ``set_secret_scope(...)`` (the per-turn
/ per-adapter profile scope), not to widen the global allowlist.
``str(exc)`` is the ONE sentence an end user can act on; the developer diagnosis
(which secret, which doc) rides ``__notes__`` so tracebacks and logs keep it.
"""
def __init__(self, secret_name: str = "", developer_detail: str = ""):
# Older callers passed the whole developer sentence positionally
# (``UnscopedSecretError("get_secret('X') called with no scope ...")``); a secret
# name never contains whitespace, so treat such a string as the detail.
if secret_name and not developer_detail and any(ch.isspace() for ch in secret_name):
secret_name, developer_detail = "", secret_name
what = f"this profile's {secret_name}" if secret_name else "this profile's API key"
super().__init__(
f"Hermes could not read {what} (an internal profile-scoping bug on the multiplexed "
"gateway, not your configuration). Run `hermes gateway restart`; if it keeps happening, "
"report it with `hermes debug share`."
)
self.secret_name = secret_name
self.developer_detail = developer_detail
if developer_detail:
self.add_note(developer_detail)
def set_secret_scope(secrets: Optional[Mapping[str, str]], *, profile_home: Optional[str] = None) -> Token:
"""Install the active profile's secret mapping; ``None`` clears. Returns a reset token.
``profile_home`` stamps the home the mapping was built for so
``serves_routed_profile`` detects a foreign-home scope even when the binder
deliberately skips the HERMES_HOME override."""
if secrets is None:
return _SECRET_SCOPE.set(None)
return _SECRET_SCOPE.set(_BoundScope(secrets, str(profile_home) if profile_home else None))
def reset_secret_scope(token: Token) -> None:
_SECRET_SCOPE.reset(token)
def current_secret_scope() -> Optional[Mapping[str, str]]:
"""The active secret mapping, or None when no scope is installed."""
bound = _SECRET_SCOPE.get()
return bound.mapping if bound is not None else None
def current_secret_scope_home() -> Optional[str]:
"""The home the active scope was stamped with, or None when unstamped/unbound."""
bound = _SECRET_SCOPE.get()
return bound.profile_home if bound is not None else None
# Genuinely-global env vars: process/deployment settings, NOT profile secrets.
# They keep reading os.environ even in multiplex mode (routing them through the
# fail-closed path would wrongly crash). Keep this tight — when in doubt a
# value is a profile secret. Membership is exact name OR prefix.
_GLOBAL_ENV_EXACT = frozenset({
# Hermes runtime / deployment
"HERMES_HOME", "HERMES_PROFILE", "HERMES_GATEWAY_LOCK_DIR",
"HERMES_MAX_ITERATIONS", "HERMES_API_TIMEOUT",
"HERMES_REDACT_SECRETS", "HERMES_NOUS_TIMEOUT_SECONDS",
"_HERMES_GATEWAY",
# OS / interpreter
"PATH", "HOME", "USER", "LANG", "LC_ALL", "TZ", "PWD", "SHELL", "TMPDIR",
"VIRTUAL_ENV", "PYTHONPATH", "SSL_CERT_FILE",
# Kanban paths (per-board, not per-profile-secret)
"HERMES_KANBAN_DB", "HERMES_KANBAN_WORKSPACES_ROOT", "HERMES_KANBAN_BOARD",
# API-server LISTENER settings — deployment config (compose/systemd env),
# which the scoped runner reload must keep seeing or containers silently
# lose the api_server platform. API_SERVER_KEY is a credential: NOT here.
# See #64674, #69379.
"API_SERVER_ENABLED", "API_SERVER_HOST", "API_SERVER_PORT",
"API_SERVER_CORS_ORIGINS",
# Relay-connector ROUTING stamps injected by managed deploys. Every reader
# (gateway.config, relay_url()/registration/self-provision) must resolve
# the SAME value or the adapter registers while the platform is absent
# from config. GATEWAY_RELAY_SECRET/_ID/_DELIVERY_KEY and IDP_* are auth
# material and deliberately stay profile-scoped.
"GATEWAY_RELAY_URL", "GATEWAY_RELAY_ENDPOINT",
"GATEWAY_RELAY_ALLOW_DIRECT_PLATFORMS",
"GATEWAY_RELAY_PLATFORMS", "GATEWAY_RELAY_BOT_IDS",
"GATEWAY_RELAY_ROUTE_KEYS", "GATEWAY_RELAY_INSTANCE_ID",
"GATEWAY_RELAY_WAKE_URL", "GATEWAY_RELAY_DISPLAY_NAME",
})
_GLOBAL_ENV_PREFIXES = (
"HERMES_KANBAN_",
"HERMES_TELEGRAM_", # tuning knobs (batch delays, fallback toggles) — NOT the token
"TERMINAL_", # terminal/sandbox backend settings
)
def _is_global_env(name: str) -> bool:
"""True for genuinely process-global (non-profile-secret) env vars."""
return name in _GLOBAL_ENV_EXACT or name.startswith(_GLOBAL_ENV_PREFIXES)
def _environ_or(name: str, default: Optional[str]) -> Optional[str]:
val = os.environ.get(name)
return val if val is not None else default
def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
"""Resolve a credential by env-var name, honoring the active profile scope.
Global vars always read ``os.environ``. With a scope installed, a miss returns
``default`` under multiplexing (never another profile's ``os.environ`` value)
but falls through to ``os.environ`` otherwise — single-profile deployments
inject credentials via the process env (systemd, ``op run``), so the scope
must stay a ``.env`` overlay, not a blindfold (otherwise cron 401s). With no
scope: multiplex INACTIVE reads ``os.environ``; ACTIVE raises (fail closed).
"""
if _is_global_env(name):
return _environ_or(name, default)
bound = _SECRET_SCOPE.get()
if bound is not None:
val = bound.mapping.get(name)
if val is not None:
return val
return default if (_MULTIPLEX_ACTIVE or serves_routed_profile()) else _environ_or(name, default)
if _MULTIPLEX_ACTIVE:
raise UnscopedSecretError(
name,
f"get_secret({name!r}) called with no profile secret scope active "
f"while multiplexing is on. This credential read must run inside a "
f"set_secret_scope(...) block (the per-turn / per-adapter profile "
f"scope). Reading os.environ here would risk leaking another "
f"profile's value. See website/docs/developer-guide/multiplexing-gateway.md "
f"(Workstream A).",
)
return _environ_or(name, default)
def get_secret_str(name: str, default: str = "") -> str:
"""``get_secret`` for callers that want a ``str``: ``default`` only when the secret is genuinely
unset. Still raises ``UnscopedSecretError`` — swallowing it hides a spawn-site bug."""
val = get_secret(name, default)
return default if val is None else val
def _strip_inline_comment(value: str) -> str:
"""Strip a dotenv-style inline comment (python-dotenv semantics): quoted values
scan to the matching close quote (backslash-aware for double quotes) and drop a
trailing ``# ...``, else stay untouched; unquoted values truncate only at a
``#`` PRECEDED BY WHITESPACE (``foo#bar`` survives, ``value # c`` → ``value``)."""
value = value.strip()
if not value:
return value
quote = value[0]
if quote in ("'", '"'):
i = 1
while i < len(value):
ch = value[i]
if quote == '"' and ch == "\\":
i += 2 # skip the escaped character
continue
if ch == quote:
return value[: i + 1] if value[i + 1:].lstrip().startswith("#") else value
i += 1
return value # unterminated quote: leave as-is
return re.split(r"\s+#", value, maxsplit=1)[0].strip()
def _parse_env_value(raw_value: str) -> str:
"""Parse the small .env value subset Hermes writes itself (bare, 'single', or "double" with
``\\"`` / ``\\\\`` escapes)."""
value = raw_value.strip()
if len(value) >= 2 and value[0] == value[-1] == '"':
quoted = value[1:-1]
parsed: list[str] = []
i = 0
while i < len(quoted):
escaped = quoted[i] == "\\" and quoted[i + 1:i + 2] in ('"', "\\")
parsed.append(quoted[i + 1] if escaped else quoted[i])
i += 2 if escaped else 1
return "".join(parsed)
if len(value) >= 2 and value[0] == value[-1] == "'":
return value[1:-1]
return value
# Per-path memo of parsed ``.env`` files. ``build_profile_secret_scope()`` runs on every gateway
# turn, cron fire, MCP/browser adoption and housekeeping drain, and each call used to re-read and
# re-parse the whole file.
#
# FRESHNESS: every call still OPENS the file and keys on ``utils.file_signature`` of that
# descriptor's ``fstat`` (mtime_ns, size, inode, ctime_ns — ctime can't be backdated, so a pinned-
# timestamp rewrite is still seen), re-checked after the read. The open keeps close-to-open
# revalidation on NFS, a vanished/unreadable file fails the open and is never cached (a transient
# EACCES must not become "this profile has no secrets"), and the descriptor pins one inode so a
# symlink repointed mid-read can't file one file's contents under another's identity.
# ``invalidate_env_file_cache()`` is the explicit knob; ``hermes_cli.config.invalidate_env_cache()``
# calls it for Hermes's own .env writers.
_ENV_FILE_CACHE: "OrderedDict[str, Tuple[tuple, Dict[str, str]]]" = OrderedDict()
_ENV_FILE_CACHE_LOCK = threading.Lock()
_ENV_FILE_CACHE_MAX = 64 # one entry per profile home in practice
def invalidate_env_file_cache(env_path: Optional[Path] = None) -> None:
"""Drop one path from the ``load_env_file()`` memo, or all of them."""
with _ENV_FILE_CACHE_LOCK:
if env_path is None:
_ENV_FILE_CACHE.clear()
else:
_ENV_FILE_CACHE.pop(str(env_path), None)
def _decode_env_bytes(raw: bytes) -> str:
"""BOM stripped; invalid UTF-8 falls back to latin-1 exactly as
``env_loader._load_dotenv_with_fallback`` installs it into ``os.environ``."""
if raw.startswith(codecs.BOM_UTF8):
raw = raw[len(codecs.BOM_UTF8):]
try:
return raw.decode("utf-8")
except UnicodeDecodeError:
return raw.decode("latin-1")
def _parse_env_text(text: str) -> Dict[str, str]:
"""Tokenize already-read ``.env`` text. See :func:`load_env_file`."""
secrets: Dict[str, str] = {}
for raw_line in text.splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("export "):
line = line[len("export "):].lstrip()
key, sep, value = line.partition("=")
key = key.strip()
if sep and key:
secrets[key] = _parse_env_value(_strip_inline_comment(value))
return secrets
def load_env_file(env_path: Path) -> Dict[str, str]:
"""THE ``.env`` tokenizer: every reader (profile scope, ``hermes_cli.config.load_env``, the dashboard
scrub, skill secret capture, managed .env, setup prompts) parses through here so no two boundaries
disagree on which keys/values a file defines. Dict only — never touches ``os.environ``. ``export``
prefix, ``#`` comments, quote escapes reversed; a BOM is stripped so it doesn't prefix the first key.
Invalid UTF-8 decodes as latin-1, exactly like ``env_loader._load_dotenv_with_fallback`` installs it
into ``os.environ``. Absent/unreadable → ``{}``.
Memoised per path on the open descriptor's stat identity (see the cache comment above). Always
returns a fresh dict: callers mutate what they get back (``build_profile_secret_scope`` layers
external secrets over it).
"""
key = str(env_path)
try:
with open(env_path, "rb") as handle:
fingerprint = file_signature(os.fstat(handle.fileno()))
with _ENV_FILE_CACHE_LOCK:
cached = _ENV_FILE_CACHE.get(key)
if cached is not None and cached[0] == fingerprint:
_ENV_FILE_CACHE.move_to_end(key)
return dict(cached[1])
raw = handle.read()
# Same descriptor: a rewrite that landed between the fstat and the read is parsed but not
# stored under the pre-write fingerprint.
settled = file_signature(os.fstat(handle.fileno())) == fingerprint
except OSError:
# Gone or unreadable: drop any entry so a stale map cannot outlive the file.
invalidate_env_file_cache(env_path)
return {}
secrets = _parse_env_text(_decode_env_bytes(raw))
if settled:
with _ENV_FILE_CACHE_LOCK:
_ENV_FILE_CACHE[key] = (fingerprint, dict(secrets))
_ENV_FILE_CACHE.move_to_end(key)
while len(_ENV_FILE_CACHE) > _ENV_FILE_CACHE_MAX:
_ENV_FILE_CACHE.popitem(last=False)
return secrets
def build_profile_secret_scope(hermes_home: Path) -> Dict[str, str]:
"""Build a profile's secret mapping from ``<home>/.env`` plus its external
secret sources. Global vars are NOT copied in — ``get_secret`` reads those
from ``os.environ`` — so the scope holds only profile secrets."""
secrets = load_env_file(Path(hermes_home) / ".env")
try:
from hermes_cli.env_loader import get_secret_source_values
external_secrets = get_secret_source_values(Path(hermes_home))
except Exception:
external_secrets = {}
secrets.update((k, v) for k, v in external_secrets.items() if not _is_global_env(k))
# The DEFAULT profile's config.yaml allow_all_users grant lives only in os.environ (bridged by
# gateway.config_loader); scoped gate readers under multiplex never fall to os.environ, so seed it
# into that profile's own mapping. A secondary never inherits it (#80099 class).
from gateway.config_loader import bridged_allow_all_users
bridged = bridged_allow_all_users()
if bridged is not None and _is_process_home(hermes_home):
secrets.setdefault("GATEWAY_ALLOW_ALL_USERS", bridged)
return secrets
def _is_process_home(hermes_home: Path) -> bool:
"""Is *hermes_home* the profile this process serves as its own? Same launch-home identity as
``serves_routed_profile()``: a host that mirrors a served profile into ``HERMES_HOME`` would
otherwise seed the launch profile's bridged allow-all grant into that profile's scope."""
from hermes_constants import get_routing_process_hermes_home
try:
return Path(hermes_home).resolve() == get_routing_process_hermes_home().resolve()
except OSError:
return False