"""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)