Files
hermes-agent/hermes_cli/managed_scope.py
kshitijk4poor 1572836184 perf(gateway): parse readiness and bundled-manifest YAML with the C loader
Same class as the config/manifest loader swap earlier in this stack. The
/ready probe re-read and pure-Python-parsed config.yaml on every poll, inside
the aiohttp handler (110 KB seeded config: 2332 -> 487 ms per probe on a
loaded runner). It now uses utils.load_yaml_file_readonly: the C loader, and
repeat probes reuse the parse until the file signature changes. Parse errors
are not cached, so an edit that breaks or fixes the file shows on the next
probe. The bundled-platform manifest reader was the one startup manifest
reader left on yaml.safe_load. managed_scope imports fast_safe_load at module
level next to file_signature instead of lazily.
2026-09-23 21:26:49 +05:30

177 lines
6.8 KiB
Python

"""Managed scope — IT-pushed, user-immutable config & env layer.
DISTINCT from ``hermes_cli.config.is_managed()`` / ``HERMES_MANAGED`` (a coarse package-manager
write-lock that blocks all mutation); this layer injects specific immutable values. The two are
independent and may coexist. v1 enforcement is filesystem permissions only (see
``docs/design/managed-scope.md`` §7); ``get_managed_dir()`` is the single seam for adding
macOS / Windows native locations later.
"""
from __future__ import annotations
import copy
import logging
import os
import threading
from pathlib import Path
from typing import Dict, Optional
# Stale-module bridge: this module binds ``utils.file_signature`` at import time, so a fresh
# import in a post-pull updater process (pre-handoff purge keeps root modules cached) dies
# unless the stale ``utils`` is dropped first. See hermes_cli.stale_modules.
from hermes_cli.stale_modules import drop_stale_root_modules
drop_stale_root_modules()
from utils import fast_safe_load, file_signature
logger = logging.getLogger(__name__)
# POSIX default. Other-platform locations belong ONLY inside get_managed_dir().
_DEFAULT_MANAGED_DIR = Path("/etc/hermes")
_CACHE_LOCK = threading.Lock()
# path_key -> (*file_signature, parsed)
_CONFIG_CACHE: Dict[str, tuple] = {}
_ENV_CACHE: Dict[str, tuple] = {}
def _under_pytest() -> bool:
"""True inside the test suite: ignore the system ``/etc/hermes`` so a real managed scope on a
dev/CI box can't leak policy into the suite. An explicit ``HERMES_MANAGED_DIR`` still wins."""
return "PYTEST_CURRENT_TEST" in os.environ
def get_managed_dir() -> Optional[Path]:
"""Resolve the managed-scope directory, or None when no scope is present.
Priority: ``$HERMES_MANAGED_DIR`` (IT-only bootstrap override; never persisted to any .env;
honored only when non-empty AND the directory exists), then ``/etc/hermes`` when it exists.
A missing directory resolves to None — the common case, so it must be cheap + side-effect-free.
"""
override = os.environ.get("HERMES_MANAGED_DIR", "").strip()
if override:
p = Path(override)
elif _under_pytest():
return None
else:
p = _DEFAULT_MANAGED_DIR
return p if p.is_dir() else None
def invalidate_managed_cache() -> None:
"""Drop cached managed config/env. For tests and post-edit reloads."""
with _CACHE_LOCK:
_CONFIG_CACHE.clear()
_ENV_CACHE.clear()
def _cached_read(path: Path, cache: Dict[str, tuple], parse):
"""Shared stat-signature-keyed read; returns a deepcopy of the parsed value.
``None`` when the file is absent or fails to parse (fail-open). A parse failure is logged
LOUDLY — the admin needs to know their policy isn't applied — but never raises, so a malformed
managed file can't brick startup.
"""
try:
st = path.stat()
except OSError:
return None # absent
key = file_signature(st)
path_key = str(path)
with _CACHE_LOCK:
hit = cache.get(path_key)
if hit is not None and hit[:len(key)] == key:
return copy.deepcopy(hit[len(key)])
try:
parsed = parse(path)
except Exception as exc: # noqa: BLE001 — fail-open, but LOUD
logger.warning(
"managed scope: failed to parse %s: %s — IGNORING this managed file. "
"Admin policy from this file is NOT being applied. Fix and restart.",
path, exc)
return None
with _CACHE_LOCK:
cache[path_key] = (*key, copy.deepcopy(parsed))
return parsed
def _load_managed_file(name: str, cache: Dict[str, tuple], parse) -> dict:
managed_dir = get_managed_dir()
if managed_dir is None:
return {}
parsed = _cached_read(managed_dir / name, cache, parse)
return parsed if isinstance(parsed, dict) else {}
def load_managed_config() -> dict:
"""Parsed managed config.yaml, or {} when absent/malformed (fail-open)."""
return _load_managed_file("config.yaml", _CONFIG_CACHE, lambda p: fast_safe_load(p.read_text(encoding="utf-8")) or {})
def load_managed_env() -> Dict[str, str]:
"""Parsed managed .env (KEY=VALUE), or {} when absent (fail-open)."""
return _load_managed_file(".env", _ENV_CACHE, _parse_managed_env)
def _parse_managed_env(path: Path) -> Dict[str, str]:
from agent.secret_scope import load_env_file
path.read_text(encoding="utf-8-sig") # load_env_file swallows decode errors; an admin file must fail LOUD
return load_env_file(path)
def apply_managed_overlay(config: dict) -> dict:
"""Overlay administrator-pinned config values on top of an already-built dict.
``${VAR}`` refs in the managed config expand against the PROCESS env only, so a user cannot
shadow a managed literal via a ref they control; a bare root ``model: x/y`` string is promoted
to ``model.default`` so it can't clobber the dict shape callers expect; managed values
deep-merge ON TOP per leaf while sibling keys stay user-controlled. Fail-open: returns
``config`` unchanged when no scope is present or on any error. Mutates and returns ``config``.
"""
try:
managed = load_managed_config()
if not managed:
return config
# Imported lazily to avoid an import cycle (config imports managed_scope).
from hermes_cli.config import _deep_merge, _expand_env_vars, _normalize_root_model_keys
managed_expanded = _normalize_root_model_keys(_expand_env_vars(managed))
# _normalize_root_model_keys only promotes the string when root provider/base_url
# keys exist to migrate; handle the bare case here (matches cli.py) so _deep_merge
# never replaces the caller's ``model`` dict with a string.
if isinstance(managed_expanded.get("model"), str):
managed_expanded = dict(managed_expanded)
managed_expanded["model"] = {"default": managed_expanded["model"]}
return _deep_merge(config, managed_expanded)
except Exception: # noqa: BLE001 — overlay must never break a caller
logger.warning("managed scope: failed to apply config overlay", exc_info=True)
return config
def _flatten_keys(d: dict, prefix: str = "") -> set:
keys: set = set()
for k, v in d.items():
dotted = f"{prefix}.{k}" if prefix else str(k)
if isinstance(v, dict) and v:
keys |= _flatten_keys(v, dotted)
else:
keys.add(dotted)
return keys
def managed_config_keys() -> set:
"""Dotted leaf keys pinned by the managed config (e.g. {'model.default'})."""
return _flatten_keys(load_managed_config())
def is_key_managed(dotted_key: str) -> bool:
"""True if the exact dotted config key is pinned by the managed layer."""
return dotted_key in managed_config_keys()
def is_env_managed(name: str) -> bool:
"""True if the env var name is pinned by the managed .env layer."""
return name in load_managed_env()