The cherry-picked commit extends the config/profile/MCP/managed-scope/completer/ OAuth/skills-manifest signatures. This commit finishes the class and trims it: - `file_signature()` lives in `utils.py` next to the other stat/metadata helpers instead of `hermes_cli.managed_scope` (gateway/ and agent/ callers no longer reach into the managed-scope module for a generic stat helper). - `hermes_cli/config_effective.py` was left comparing 2-/4-wide prefixes against the widened `_RAW_CONFIG_CACHE` / `_load_config_cache_sig` records, so `load_user_config_effective()` re-parsed on every call (3 parses for 3 calls on an unchanged file, 1 before); index by the new widths. - Sibling caches keyed on the same (mtime, size) shape and reading the SAME files now use the helper: `load_env()` memo, `agent/skill_utils` raw-config and external-dirs caches, `hermes_cli/model_switch` alias identity, `agent/moa_loop` preset stamp, `hermes_cli/auth` global auth-store memo. - Tests trimmed to one invariant each (pinned-mtime replacement invalidates; an unchanged file still hits), both red on origin/main. Left alone on purpose: `tools/registry.py`, `tools/skills_tool_dedup.py`, `gateway/status.py`, `hermes_cli/banner.py`, `hermes_cli/main.py`, `hermes_cli/session_recovery.py` — those fingerprint source files, PID/lock files or write to persisted on-disk caches shared across processes, where an inode/ctime key would churn on every checkout/copy rather than catch a replaced config.
170 lines
6.5 KiB
Python
170 lines
6.5 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
|
|
|
|
import yaml
|
|
|
|
from utils import 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: yaml.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()
|