Files
hermes-agent/gateway/media_policy.py
Teknium 819517fbac fix(gateway): routed profiles get their own max_turns, fallback chain, hooks, aux auth and media policy
One multiplexed gateway process serves every profile, but several per-turn
reads still went through state frozen from the LAUNCH profile:

- `_current_max_iterations` re-bridged `agent.max_turns`/`sessions.*` from the
  module constant `_hermes_home` into one process-wide HERMES_MAX_ITERATIONS,
  so every secondary ran with the default profile's turn budget. A routed turn
  (HERMES_HOME override) now resolves `agent.max_turns` from its own config.
- `_refresh_fallback_model` read `_hermes_home/config.yaml` into one runner-wide
  slot, so secondaries fell back through the default's provider/model with their
  own keys. It now reads the active gateway home and keeps a last-known-good
  chain per home.
- `_load_prefill_messages` resolved relative paths against the launch home.
- `agent/auxiliary_client._AUTH_JSON_PATH` was an import-time constant, so a
  secondary's compression/title/vision calls authenticated to Nous with the
  default profile's token when it had no pool entry. Resolved per call via
  `hermes_cli.auth._auth_file_path()` (patched constant still wins in tests).
- `gateway/hooks.HOOKS_DIR` was frozen at import and one `HookRegistry` was
  loaded outside any profile scope, so secondaries' `hooks/` never ran and the
  default profile's handlers received every profile's messages, responses and
  user ids. `HOOKS_DIR` now resolves per call (salvaged from #56508) and the
  runner holds one registry per served home, picked from the active scope at
  emit time and front-loaded under each secondary's startup scope.
- Shell-hook subprocesses inherited the launch `os.environ` (default HERMES_HOME
  and the default profile's secrets). They now get the routed HERMES_HOME via
  `build_subprocess_env`, scrubbed under multiplexing, and the stdin payload
  carries `profile` so one script can tell which profile fired it.
- Media-delivery policy (`gateway.strict`, `media_delivery_allow_dirs`,
  `trust_recent_files*`) was bridged once into env at startup and read from env
  per delivery; under a HERMES_HOME override the validator now reads the routed
  profile's config. Single-profile runs keep the env-bridge contract.

Audit: /tmp/mux_audit F3, F4, F6 (auth.json half), F7, F12 (media). Live repro
(temp HERMES_HOME A with profiles/B): before, B saw max_iterations 7,
fallback A/fallback, TOKEN_A, A's hooks, strict=A; after, all B's values.
2026-09-11 15:44:00 -07:00

113 lines
4.4 KiB
Python

"""Shared config→env bridge for media-delivery policy.
``validate_media_delivery_path`` reads ``HERMES_MEDIA_DELIVERY_STRICT`` (gateway.strict),
``HERMES_MEDIA_ALLOW_DIRS`` (gateway.media_delivery_allow_dirs) and
``HERMES_MEDIA_TRUST_RECENT_FILES`` (gateway.trust_recent_files). Every delivery
entrypoint (gateway startup, ``hermes cron run``, ``hermes send``) calls
:func:`apply_media_policy_env` first so standalone paths filter under the gateway's
policy instead of silently dropping attachments in strict/allowlisted deployments.
An explicitly-set env var WINS over config.yaml, so shell overrides survive.
"""
from __future__ import annotations
import logging
import os
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
_FLAG_ENVS = (("strict", "HERMES_MEDIA_DELIVERY_STRICT"), ("trust_recent_files", "HERMES_MEDIA_TRUST_RECENT_FILES"))
_ALLOW_DIRS_ENV = "HERMES_MEDIA_ALLOW_DIRS"
_TRUST_RECENT_SECONDS_ENV = "HERMES_MEDIA_TRUST_RECENT_SECONDS"
_TRUTHY = frozenset({"1", "true", "yes", "on"})
def _routed_gateway_cfg() -> Optional[Dict[str, Any]]:
"""``gateway`` section of the ROUTED profile's config when a HERMES_HOME override is active
(multiplexed turn), else None. The env bridge is one process-wide copy of the launch profile's
policy, so a secondary's deliveries must read their own config instead of ``os.environ``."""
from hermes_constants import get_hermes_home_override
if not get_hermes_home_override():
return None
try:
from hermes_cli.config import load_config_readonly
gateway_cfg = load_config_readonly().get("gateway")
except Exception:
return {}
return gateway_cfg if isinstance(gateway_cfg, dict) else {}
def media_delivery_strict() -> bool:
cfg = _routed_gateway_cfg()
if cfg is not None:
return bool(cfg.get("strict", False))
return os.environ.get(_FLAG_ENVS[0][1], "0").strip().lower() in _TRUTHY
def media_delivery_allow_dirs() -> str:
"""Operator allowlist as the ``os.pathsep``-joined string the validator splits."""
cfg = _routed_gateway_cfg()
if cfg is not None:
return _allow_dirs_str(cfg.get("media_delivery_allow_dirs"))
return os.environ.get(_ALLOW_DIRS_ENV, "")
def media_delivery_trust_recent() -> bool:
cfg = _routed_gateway_cfg()
if cfg is not None:
return bool(cfg.get("trust_recent_files", True))
return os.environ.get(_FLAG_ENVS[1][1], "1").strip().lower() not in ("0", "false", "no", "off", "")
def media_delivery_trust_recent_seconds() -> str:
"""Raw recency window (``""`` = validator default); the caller parses/floors it."""
cfg = _routed_gateway_cfg()
if cfg is not None:
raw = cfg.get("trust_recent_files_seconds")
return "" if raw is None else str(raw)
return os.environ.get(_TRUST_RECENT_SECONDS_ENV, "")
def _load_gateway_cfg(config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
if config is None:
try:
from hermes_cli.config import load_config
config = load_config() or {}
except Exception:
return {}
gateway_cfg = config.get("gateway", {})
return gateway_cfg if isinstance(gateway_cfg, dict) else {}
def _set_env_default(env: str, value: str) -> None:
"""Set ``env`` only when unset/empty and ``value`` is non-empty (env wins)."""
if value and not os.environ.get(env):
os.environ[env] = value
def _allow_dirs_str(allow_dirs: Any) -> str:
if isinstance(allow_dirs, (list, tuple)):
return os.pathsep.join(str(p) for p in allow_dirs if p)
return allow_dirs if isinstance(allow_dirs, str) else ""
def apply_media_policy_env(config: Optional[Dict[str, Any]] = None) -> None:
"""Bridge gateway media-policy settings from config.yaml into the env. Idempotent,
env-wins, never raises — a bridge failure must not break delivery (validator defaults apply)."""
try:
gateway_cfg = _load_gateway_cfg(config)
if not gateway_cfg:
return
for key, env in _FLAG_ENVS:
flag = gateway_cfg.get(key)
if flag is not None:
_set_env_default(env, "1" if flag else "0")
allow_dirs = gateway_cfg.get("media_delivery_allow_dirs")
if allow_dirs:
_set_env_default(_ALLOW_DIRS_ENV, _allow_dirs_str(allow_dirs))
except Exception: # noqa: BLE001 - policy bridge must never break delivery
logger.debug("apply_media_policy_env failed", exc_info=True)