Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e6a0cff10 | ||
|
|
871a7afca4 | ||
|
|
12d930266b | ||
|
|
6723e85f70 | ||
|
|
649f360d74 | ||
|
|
0c36f29050 | ||
|
|
130cc28903 | ||
|
|
05c7d14e77 | ||
|
|
78be65cb1e | ||
|
|
2becd0440a | ||
|
|
9415dacb19 |
@@ -562,6 +562,18 @@ def load_cli_config() -> Dict[str, Any]:
|
||||
from hermes_cli.config import _expand_env_vars
|
||||
defaults = _expand_env_vars(defaults)
|
||||
|
||||
# Managed scope: overlay administrator-pinned values LAST so they win over
|
||||
# the user's config here too. cli.py builds its config independently of
|
||||
# hermes_cli.config._load_config_impl (which has its own managed merge), so
|
||||
# without this the entire interactive CLI/TUI surface — skin, display prefs,
|
||||
# etc. read from CLI_CONFIG — would silently ignore managed scope while
|
||||
# `hermes config`/`doctor`/guards (which use load_config) honor it. The
|
||||
# shared helper mirrors _load_config_impl (env-only expansion, root-model
|
||||
# normalization, leaf-merge) and is fail-open.
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
defaults = managed_scope.apply_managed_overlay(defaults)
|
||||
|
||||
# Apply terminal config to environment variables (so terminal_tool picks them up)
|
||||
terminal_config = defaults.get("terminal", {})
|
||||
|
||||
|
||||
@@ -1587,6 +1587,15 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
if os.path.exists(_cfg_path):
|
||||
with open(_cfg_path, encoding="utf-8") as _f:
|
||||
_cfg = yaml.safe_load(_f) or {}
|
||||
# Managed scope: a scheduled job must honor administrator-pinned
|
||||
# model / reasoning / toolsets / provider_routing too. This loader
|
||||
# builds its own dict, so overlay managed values via the shared
|
||||
# helper (fail-open, no-op when no managed scope).
|
||||
try:
|
||||
from hermes_cli import managed_scope
|
||||
_cfg = managed_scope.apply_managed_overlay(_cfg)
|
||||
except Exception:
|
||||
pass
|
||||
_cfg = _expand_env_vars(_cfg)
|
||||
_model_cfg = _cfg.get("model", {})
|
||||
if not job.get("model"):
|
||||
|
||||
@@ -796,6 +796,14 @@ def load_gateway_config() -> GatewayConfig:
|
||||
with open(config_yaml_path, encoding="utf-8") as f:
|
||||
yaml_cfg = yaml.safe_load(f) or {}
|
||||
|
||||
# Managed scope: overlay administrator-pinned values so the gateway
|
||||
# honors them too. This loader builds its own dict instead of going
|
||||
# through hermes_cli.config.load_config, so without this a managed
|
||||
# session_reset / quick_commands / stt / model would be ignored by
|
||||
# the messaging gateway. Fail-open via the shared helper.
|
||||
from hermes_cli import managed_scope
|
||||
yaml_cfg = managed_scope.apply_managed_overlay(yaml_cfg)
|
||||
|
||||
# Map config.yaml keys → GatewayConfig.from_dict() schema.
|
||||
# Each key overwrites whatever gateway.json may have set.
|
||||
sr = yaml_cfg.get("session_reset")
|
||||
|
||||
+46
-7
@@ -1188,6 +1188,15 @@ def _reload_runtime_env_preserving_config_authority() -> None:
|
||||
cfg = _yaml.safe_load(f) or {}
|
||||
from hermes_cli.config import _expand_env_vars
|
||||
cfg = _expand_env_vars(cfg)
|
||||
# Managed scope: keep administrator-pinned values authoritative on every
|
||||
# turn too. This per-turn reload re-bridges config→env, so without the
|
||||
# overlay a managed agent.max_turns / timezone / redact_secrets would be
|
||||
# replaced by the user's value after the first turn. Fail-open.
|
||||
try:
|
||||
from hermes_cli import managed_scope
|
||||
cfg = managed_scope.apply_managed_overlay(cfg)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
return
|
||||
|
||||
@@ -1210,6 +1219,17 @@ if _config_path.exists():
|
||||
# Expand ${ENV_VAR} references before bridging to env vars.
|
||||
from hermes_cli.config import _expand_env_vars
|
||||
_cfg = _expand_env_vars(_cfg)
|
||||
# Managed scope: overlay administrator-pinned values BEFORE bridging to
|
||||
# env vars, so a managed timezone / redact_secrets / max_turns / terminal
|
||||
# setting wins over the user's value at the env layer too. This bridge
|
||||
# reads config.yaml directly (not via load_config), so without the
|
||||
# overlay every HERMES_*/TERMINAL_* env var below would carry the user's
|
||||
# value even when an administrator pinned it. Fail-open via the helper.
|
||||
try:
|
||||
from hermes_cli import managed_scope
|
||||
_cfg = managed_scope.apply_managed_overlay(_cfg)
|
||||
except Exception:
|
||||
pass
|
||||
# Top-level simple values (fallback only — don't override .env)
|
||||
for _key, _val in _cfg.items():
|
||||
if isinstance(_val, (str, int, float, bool)) and _key not in os.environ:
|
||||
@@ -1880,8 +1900,14 @@ def _load_gateway_config() -> dict:
|
||||
Uses the module-level ``_hermes_home`` (so tests that monkeypatch it
|
||||
still see their fixture) and shares the mtime-keyed raw-yaml cache
|
||||
from ``hermes_cli.config.read_raw_config`` when the paths match.
|
||||
|
||||
Managed scope is overlaid on the result (via the shared helper) so the
|
||||
gateway honors administrator-pinned values — neither read_raw_config nor a
|
||||
direct yaml.safe_load carries the managed merge on its own. Fail-open.
|
||||
"""
|
||||
config_path = _hermes_home / 'config.yaml'
|
||||
raw: dict = {}
|
||||
used_canonical = False
|
||||
try:
|
||||
from hermes_cli.config import get_config_path, read_raw_config
|
||||
# Fast path: if _hermes_home agrees with the canonical config
|
||||
@@ -1889,18 +1915,31 @@ def _load_gateway_config() -> dict:
|
||||
# direct read (keeps test fixtures with a monkeypatched
|
||||
# _hermes_home working).
|
||||
if config_path == get_config_path():
|
||||
return read_raw_config()
|
||||
raw = read_raw_config()
|
||||
used_canonical = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not used_canonical:
|
||||
try:
|
||||
if config_path.exists():
|
||||
import yaml
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
except Exception:
|
||||
logger.debug("Could not load gateway config from %s", config_path)
|
||||
raw = {}
|
||||
|
||||
# Overlay managed scope. read_raw_config() returns the user's raw YAML
|
||||
# WITHOUT the managed merge (that lives in load_config/_load_config_impl),
|
||||
# so the overlay is required on both paths for the gateway to honor pinned
|
||||
# values. Helper is fail-open and a no-op when no managed scope exists.
|
||||
try:
|
||||
if config_path.exists():
|
||||
import yaml
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
from hermes_cli import managed_scope
|
||||
raw = managed_scope.apply_managed_overlay(raw if isinstance(raw, dict) else {})
|
||||
except Exception:
|
||||
logger.debug("Could not load gateway config from %s", config_path)
|
||||
return {}
|
||||
pass
|
||||
return raw if isinstance(raw, dict) else {}
|
||||
|
||||
|
||||
def _load_gateway_runtime_config() -> dict:
|
||||
|
||||
+157
-11
@@ -223,7 +223,10 @@ _LAST_EXPANDED_CONFIG_BY_PATH: Dict[str, Any] = {}
|
||||
# save_config() + migrate_config() write via atomic_yaml_write which
|
||||
# produces a fresh inode, so stat() sees a new mtime_ns and the next
|
||||
# load repopulates automatically — no explicit invalidation hook.
|
||||
_LOAD_CONFIG_CACHE: Dict[str, Tuple[int, int, Dict[str, Any]]] = {}
|
||||
# Cached tuple is (user_mtime_ns, user_size, managed_mtime_ns, managed_size,
|
||||
# merged_value) — the managed-file signature is folded in so editing the
|
||||
# managed-scope config.yaml invalidates the cache (see managed_scope).
|
||||
_LOAD_CONFIG_CACHE: Dict[str, Tuple[int, int, int, int, Dict[str, Any]]] = {}
|
||||
# (path, mtime_ns, size) -> cached raw yaml dict. Same pattern as
|
||||
# _LOAD_CONFIG_CACHE but for read_raw_config() — used when callers want
|
||||
# the user's on-disk values without defaults merged in.
|
||||
@@ -5168,6 +5171,29 @@ def _deep_merge(base: dict, override: dict) -> dict:
|
||||
return result
|
||||
|
||||
|
||||
def _strip_dotted_keys(cfg: dict, dotted_keys: set) -> Tuple[dict, set]:
|
||||
"""Remove the given dotted leaf keys from a nested config dict.
|
||||
|
||||
Returns ``(pruned_cfg, set_of_stripped_keys_that_were_present)``. Used by
|
||||
``save_config`` to drop managed-scope leaves before persisting, so a bulk
|
||||
write never writes a user value that would lose to the managed layer on the
|
||||
next load. Only keys actually present in ``cfg`` are reported as stripped.
|
||||
"""
|
||||
stripped: set = set()
|
||||
for dotted in dotted_keys:
|
||||
parts = dotted.split(".")
|
||||
node = cfg
|
||||
for p in parts[:-1]:
|
||||
if not isinstance(node, dict) or p not in node:
|
||||
node = None
|
||||
break
|
||||
node = node[p]
|
||||
if isinstance(node, dict) and parts[-1] in node:
|
||||
del node[parts[-1]]
|
||||
stripped.add(dotted)
|
||||
return cfg, stripped
|
||||
|
||||
|
||||
def _expand_env_vars(obj):
|
||||
"""Recursively expand ``${VAR}`` references in config values.
|
||||
|
||||
@@ -5534,17 +5560,44 @@ def _load_config_impl(*, want_deepcopy: bool) -> Dict[str, Any]:
|
||||
|
||||
try:
|
||||
st = config_path.stat()
|
||||
cache_key: Optional[Tuple[int, int]] = (st.st_mtime_ns, st.st_size)
|
||||
user_sig: Optional[Tuple[int, int]] = (st.st_mtime_ns, st.st_size)
|
||||
except FileNotFoundError:
|
||||
cache_key = None
|
||||
user_sig = None
|
||||
|
||||
# Managed scope: fold the managed config file's (mtime, size) into the
|
||||
# cache signature so editing /etc/hermes/config.yaml invalidates the
|
||||
# cached merged result. (0, 0) means "no managed config file".
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
managed_dir = managed_scope.get_managed_dir()
|
||||
managed_cfg_path = (managed_dir / "config.yaml") if managed_dir else None
|
||||
try:
|
||||
mst = managed_cfg_path.stat() if managed_cfg_path else None
|
||||
managed_sig = (mst.st_mtime_ns, mst.st_size) if mst else (0, 0)
|
||||
except OSError:
|
||||
managed_sig = (0, 0)
|
||||
|
||||
# Combined cache signature: user file + managed file. None only when the
|
||||
# user config is absent AND no managed file exists (nothing to cache on).
|
||||
if user_sig is not None:
|
||||
cache_sig: Optional[Tuple[int, int, int, int]] = (
|
||||
user_sig[0],
|
||||
user_sig[1],
|
||||
managed_sig[0],
|
||||
managed_sig[1],
|
||||
)
|
||||
elif managed_sig != (0, 0):
|
||||
cache_sig = (0, 0, managed_sig[0], managed_sig[1])
|
||||
else:
|
||||
cache_sig = None
|
||||
|
||||
cached = _LOAD_CONFIG_CACHE.get(path_key)
|
||||
if cached is not None and cache_key is not None and cached[:2] == cache_key:
|
||||
return copy.deepcopy(cached[2]) if want_deepcopy else cached[2]
|
||||
if cached is not None and cache_sig is not None and cached[:4] == cache_sig:
|
||||
return copy.deepcopy(cached[4]) if want_deepcopy else cached[4]
|
||||
|
||||
config = copy.deepcopy(DEFAULT_CONFIG)
|
||||
|
||||
if cache_key is not None:
|
||||
if user_sig is not None:
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
user_config = yaml.safe_load(f) or {}
|
||||
@@ -5562,14 +5615,24 @@ def _load_config_impl(*, want_deepcopy: bool) -> Dict[str, Any]:
|
||||
|
||||
normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))
|
||||
expanded = _expand_env_vars(normalized)
|
||||
# Managed scope wins at the leaf. Applied AFTER user expansion so a user
|
||||
# ${VAR} cannot shadow a managed literal: managed values are expanded only
|
||||
# against the process environment, never against user-config-defined refs.
|
||||
# This deliberately inverts the usual env-over-config precedence for the
|
||||
# keys the managed layer pins — see docs/design/managed-scope.md §4.1.
|
||||
managed_config = managed_scope.load_managed_config()
|
||||
if managed_config:
|
||||
managed_expanded = _expand_env_vars(managed_config)
|
||||
expanded = _deep_merge(expanded, managed_expanded)
|
||||
_LAST_EXPANDED_CONFIG_BY_PATH[path_key] = copy.deepcopy(expanded)
|
||||
if cache_key is not None:
|
||||
if cache_sig is not None:
|
||||
# Cache stores a separate deepcopy so subsequent ``load_config()``
|
||||
# (deepcopy=True) callers can mutate freely without affecting the
|
||||
# cached value, and ``load_config_readonly()`` (deepcopy=False)
|
||||
# callers all see the same stable cached object.
|
||||
# callers all see the same stable cached object. The cached tuple is
|
||||
# (user_mtime, user_size, managed_mtime, managed_size, value).
|
||||
cached_copy = copy.deepcopy(expanded)
|
||||
_LOAD_CONFIG_CACHE[path_key] = (cache_key[0], cache_key[1], cached_copy)
|
||||
_LOAD_CONFIG_CACHE[path_key] = (*cache_sig, cached_copy)
|
||||
# On the readonly path return the same cached object subsequent
|
||||
# calls will see — keeps "two readonly calls return the same
|
||||
# object" invariant that callers may rely on for identity checks.
|
||||
@@ -5666,6 +5729,22 @@ def save_config(config: Dict[str, Any]):
|
||||
if is_managed():
|
||||
managed_error("save configuration")
|
||||
return
|
||||
# Managed scope: strip any leaf the managed layer pins, so a bulk write
|
||||
# (wizard / programmatic save) never persists a user value that would
|
||||
# silently lose to managed on the next load. Single-key `config set`
|
||||
# hard-rejects (see set_config_value); this is the mechanical safety net
|
||||
# for bulk writes so the unmanaged remainder still lands.
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
managed_keys = managed_scope.managed_config_keys()
|
||||
if managed_keys:
|
||||
config, _stripped = _strip_dotted_keys(copy.deepcopy(config), managed_keys)
|
||||
if _stripped:
|
||||
print(
|
||||
f"Note: {len(_stripped)} managed setting(s) were not saved "
|
||||
f"(managed by your administrator): {', '.join(sorted(_stripped))}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
from utils import atomic_yaml_write
|
||||
|
||||
ensure_hermes_home()
|
||||
@@ -5932,6 +6011,19 @@ def save_env_value(key: str, value: str):
|
||||
if is_managed():
|
||||
managed_error(f"set {key}")
|
||||
return
|
||||
# Managed scope guard: a managed env key can't be set by the user — the
|
||||
# managed .env wins at load anyway. Distinct from is_managed() above.
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
if managed_scope.is_env_managed(key):
|
||||
managed_dir = managed_scope.get_managed_dir()
|
||||
src = (managed_dir / ".env") if managed_dir else "the managed scope"
|
||||
print(
|
||||
f"Cannot set {key}: it is managed by your administrator ({src}) "
|
||||
f"and cannot be changed.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
if not _ENV_VAR_NAME_RE.match(key):
|
||||
raise ValueError(f"Invalid environment variable name: {key!r}")
|
||||
_reject_denylisted_env_var(key)
|
||||
@@ -6009,6 +6101,18 @@ def remove_env_value(key: str) -> bool:
|
||||
if is_managed():
|
||||
managed_error(f"remove {key}")
|
||||
return False
|
||||
# Managed scope guard: a managed env key can't be removed by the user.
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
if managed_scope.is_env_managed(key):
|
||||
managed_dir = managed_scope.get_managed_dir()
|
||||
src = (managed_dir / ".env") if managed_dir else "the managed scope"
|
||||
print(
|
||||
f"Cannot remove {key}: it is managed by your administrator ({src}) "
|
||||
f"and cannot be changed.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return False
|
||||
if not _ENV_VAR_NAME_RE.match(key):
|
||||
raise ValueError(f"Invalid environment variable name: {key!r}")
|
||||
env_path = get_env_path()
|
||||
@@ -6143,12 +6247,38 @@ def redact_key(key: str) -> str:
|
||||
def show_config():
|
||||
"""Display current configuration."""
|
||||
config = load_config()
|
||||
|
||||
|
||||
print()
|
||||
print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN))
|
||||
print(color("│ ⚕ Hermes Configuration │", Colors.CYAN))
|
||||
print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN))
|
||||
|
||||
|
||||
# Managed scope: surface that some settings are administrator-pinned so the
|
||||
# user understands why their config.yaml value may not be the effective one.
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
_managed_keys = managed_scope.managed_config_keys()
|
||||
_managed_env = managed_scope.load_managed_env()
|
||||
if _managed_keys or _managed_env:
|
||||
_managed_dir = managed_scope.get_managed_dir()
|
||||
print()
|
||||
print(color(
|
||||
f" ⚷ Some settings are managed by your administrator ({_managed_dir}) "
|
||||
f"and cannot be changed",
|
||||
Colors.YELLOW,
|
||||
Colors.BOLD,
|
||||
))
|
||||
if _managed_keys:
|
||||
print(color(
|
||||
f" Managed config keys: {', '.join(sorted(_managed_keys))}",
|
||||
Colors.YELLOW,
|
||||
))
|
||||
if _managed_env:
|
||||
print(color(
|
||||
f" Managed env keys: {', '.join(sorted(_managed_env))}",
|
||||
Colors.YELLOW,
|
||||
))
|
||||
|
||||
# Paths
|
||||
print()
|
||||
print(color("◆ Paths", Colors.CYAN, Colors.BOLD))
|
||||
@@ -6366,6 +6496,22 @@ def set_config_value(key: str, value: str):
|
||||
if is_managed():
|
||||
managed_error("set configuration values")
|
||||
return
|
||||
# Managed scope guard (D2): a key pinned by the managed layer cannot be set by
|
||||
# the user — the next load would override it anyway. Hard-reject and name the
|
||||
# source. Distinct from is_managed() above (the package-manager write-lock).
|
||||
# Env-shaped keys (API keys / tokens) route to save_env_value below, which has
|
||||
# its own managed-env-key guard; this catches the config.yaml keys.
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
if managed_scope.is_key_managed(key):
|
||||
managed_dir = managed_scope.get_managed_dir()
|
||||
src = (managed_dir / "config.yaml") if managed_dir else "the managed scope"
|
||||
print(
|
||||
f"Cannot set '{key}': it is managed by your administrator ({src}) "
|
||||
f"and cannot be changed. Contact your administrator to modify it.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
# Check if it's an API key (goes to .env)
|
||||
api_keys = [
|
||||
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY',
|
||||
|
||||
@@ -462,6 +462,31 @@ def _build_apikey_providers_list() -> list:
|
||||
return _static
|
||||
|
||||
|
||||
def managed_scope_check() -> None:
|
||||
"""Report the active managed scope (resolved dir + pinned key counts).
|
||||
|
||||
Silent when no managed scope is present. When the managed directory was
|
||||
resolved from the HERMES_MANAGED_DIR override (rather than the system
|
||||
default), that is surfaced too — a redirected scope is the documented
|
||||
foot-gun (see docs/design/managed-scope.md §7) and an operator should see it.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli import managed_scope
|
||||
managed_dir = managed_scope.get_managed_dir()
|
||||
except Exception: # noqa: BLE001 — diagnostics must never crash
|
||||
return
|
||||
if managed_dir is None:
|
||||
return
|
||||
n_cfg = len(managed_scope.managed_config_keys())
|
||||
n_env = len(managed_scope.load_managed_env())
|
||||
check_ok(
|
||||
f"Managed scope active: {n_cfg} config key(s), {n_env} env key(s) "
|
||||
f"pinned by {managed_dir}"
|
||||
)
|
||||
if os.environ.get("HERMES_MANAGED_DIR", "").strip():
|
||||
check_info(f"managed dir set via HERMES_MANAGED_DIR={managed_dir}")
|
||||
|
||||
|
||||
def run_doctor(args):
|
||||
"""Run diagnostic checks."""
|
||||
should_fix = getattr(args, 'fix', False)
|
||||
@@ -642,6 +667,8 @@ def run_doctor(args):
|
||||
check_warn(name, "(optional, not installed)")
|
||||
|
||||
_section("Configuration Files")
|
||||
# Managed scope (administrator-pinned config/env), when present.
|
||||
managed_scope_check()
|
||||
# Check ~/.hermes/.env (primary location for user config)
|
||||
env_path = HERMES_HOME / '.env'
|
||||
if env_path.exists():
|
||||
@@ -2152,6 +2179,11 @@ def run_doctor(args):
|
||||
if _mem_cfg_path.exists():
|
||||
with open(_mem_cfg_path, encoding="utf-8") as _f:
|
||||
_raw_cfg = _yaml.safe_load(_f) or {}
|
||||
try:
|
||||
from hermes_cli import managed_scope
|
||||
_raw_cfg = managed_scope.apply_managed_overlay(_raw_cfg)
|
||||
except Exception:
|
||||
pass
|
||||
_active_memory_provider = (_raw_cfg.get("memory") or {}).get("provider", "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -243,10 +243,43 @@ def load_hermes_dotenv(
|
||||
loaded.append(project_env_path)
|
||||
|
||||
_apply_external_secret_sources(home_path)
|
||||
_apply_managed_env()
|
||||
|
||||
return loaded
|
||||
|
||||
|
||||
def _apply_managed_env() -> None:
|
||||
"""Apply the managed-scope .env last, with override, so it beats user/shell.
|
||||
|
||||
Managed scope is machine-global (independent of HERMES_HOME / profile). v1
|
||||
enforcement is "applied last with override=True" — at the end of startup load
|
||||
``os.environ`` holds the managed value for every managed key, beating both the
|
||||
user ``.env`` and any pre-existing shell export. This deliberately inverts the
|
||||
usual env-over-config precedence for the pinned keys (see
|
||||
``docs/design/managed-scope.md`` §4.1).
|
||||
|
||||
This does NOT prevent the agent from later mutating ``os.environ`` in-process
|
||||
or ``export``-ing in a subprocess shell; that hard boundary is a documented
|
||||
v2 item (design §8.1). v1 relies on filesystem permissions only.
|
||||
|
||||
Fail-open: a missing managed dir or .env is the common case and a no-op; any
|
||||
error here is swallowed so managed scope can never block startup.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
managed_dir = managed_scope.get_managed_dir()
|
||||
except Exception: # noqa: BLE001 — managed scope must never block startup
|
||||
return
|
||||
if managed_dir is None:
|
||||
return
|
||||
managed_env = managed_dir / ".env"
|
||||
if not managed_env.exists():
|
||||
return
|
||||
_sanitize_env_file_if_needed(managed_env)
|
||||
_load_dotenv_with_fallback(managed_env, override=True)
|
||||
|
||||
|
||||
def _apply_external_secret_sources(home_path: Path) -> None:
|
||||
"""Pull secrets from external sources (currently Bitwarden) into env.
|
||||
|
||||
|
||||
@@ -531,6 +531,16 @@ try:
|
||||
if _cfg_path.exists():
|
||||
with open(_cfg_path, encoding="utf-8") as _f:
|
||||
_early_cfg_raw = _yaml_early.safe_load(_f) or {}
|
||||
# Managed scope: overlay administrator-pinned values so a managed
|
||||
# security.redact_secrets / network.force_ipv4 wins here too. This early
|
||||
# bridge reads config.yaml directly (before load_config is usable), so
|
||||
# without the overlay a managed redact_secrets toggle would be ignored.
|
||||
# Fail-open via the shared helper.
|
||||
try:
|
||||
from hermes_cli import managed_scope
|
||||
_early_cfg_raw = managed_scope.apply_managed_overlay(_early_cfg_raw)
|
||||
except Exception:
|
||||
pass
|
||||
if "HERMES_REDACT_SECRETS" not in os.environ:
|
||||
_early_sec_cfg = _early_cfg_raw.get("security", {})
|
||||
if isinstance(_early_sec_cfg, dict):
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Managed scope — IT-pushed, user-immutable config & env layer.
|
||||
|
||||
A system-level directory (default ``/etc/hermes``, root-owned and not
|
||||
user-writable) supplies ``config.yaml`` and ``.env`` values that WIN over the
|
||||
user's ``~/.hermes/config.yaml`` and ``~/.hermes/.env`` on a per-leaf-key basis.
|
||||
|
||||
This is DISTINCT from ``hermes_cli.config.is_managed()`` / ``HERMES_MANAGED``,
|
||||
which is a coarse package-manager write-lock (declarative-distro / formula
|
||||
installs). That lock 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. v1 is Linux/POSIX-first; ``get_managed_dir()``
|
||||
is the single seam for adding macOS / Windows native locations later.
|
||||
|
||||
Attribution: do not reference any third-party product by name in this file.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# POSIX default. Other-platform locations are a deliberate v2 item; when added,
|
||||
# they belong ONLY inside get_managed_dir().
|
||||
_DEFAULT_MANAGED_DIR = Path("/etc/hermes")
|
||||
|
||||
_CACHE_LOCK = threading.Lock()
|
||||
# path_key -> (mtime_ns, size, parsed)
|
||||
_CONFIG_CACHE: Dict[str, tuple] = {}
|
||||
_ENV_CACHE: Dict[str, tuple] = {}
|
||||
|
||||
|
||||
def _under_pytest() -> bool:
|
||||
"""True when running inside the test suite.
|
||||
|
||||
Used to ignore the system default ``/etc/hermes`` during tests so a real
|
||||
managed scope on a developer/CI box can't leak policy into the suite. Tests
|
||||
that exercise managed scope set ``HERMES_MANAGED_DIR`` explicitly, which is
|
||||
still honored (the override path below runs before this guard takes effect).
|
||||
"""
|
||||
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.
|
||||
|
||||
Resolution (highest priority first):
|
||||
1. ``$HERMES_MANAGED_DIR`` — deployment/bootstrap path override (IT-only;
|
||||
never persisted to any .env). Honored only when set to a non-empty value
|
||||
AND the directory exists.
|
||||
2. ``/etc/hermes`` — POSIX default, when it exists. Ignored under pytest so
|
||||
a real system managed scope can't leak into the test suite.
|
||||
|
||||
A non-existent directory at either tier resolves to None (no managed scope),
|
||||
which is the common case and must be cheap + side-effect-free.
|
||||
"""
|
||||
override = os.environ.get("HERMES_MANAGED_DIR", "").strip()
|
||||
if override:
|
||||
p = Path(override)
|
||||
return p if p.is_dir() else None
|
||||
if _under_pytest():
|
||||
return None
|
||||
return _DEFAULT_MANAGED_DIR if _DEFAULT_MANAGED_DIR.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 (mtime_ns, size)-keyed read. Returns a deepcopy of the parsed value.
|
||||
|
||||
Returns ``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
|
||||
being applied — but never raises, so a malformed managed file can't brick
|
||||
startup.
|
||||
"""
|
||||
try:
|
||||
st = path.stat()
|
||||
except OSError:
|
||||
return None # absent
|
||||
key = (st.st_mtime_ns, st.st_size)
|
||||
path_key = str(path)
|
||||
with _CACHE_LOCK:
|
||||
hit = cache.get(path_key)
|
||||
if hit is not None and hit[:2] == key:
|
||||
return copy.deepcopy(hit[2])
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
parsed = parse(f)
|
||||
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[0], key[1], copy.deepcopy(parsed))
|
||||
return parsed
|
||||
|
||||
|
||||
def load_managed_config() -> dict:
|
||||
"""Parsed managed config.yaml, or {} when absent/malformed (fail-open)."""
|
||||
managed_dir = get_managed_dir()
|
||||
if managed_dir is None:
|
||||
return {}
|
||||
parsed = _cached_read(
|
||||
managed_dir / "config.yaml",
|
||||
_CONFIG_CACHE,
|
||||
lambda f: yaml.safe_load(f) or {},
|
||||
)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
def load_managed_env() -> Dict[str, str]:
|
||||
"""Parsed managed .env (KEY=VALUE), or {} when absent (fail-open)."""
|
||||
managed_dir = get_managed_dir()
|
||||
if managed_dir is None:
|
||||
return {}
|
||||
parsed = _cached_read(managed_dir / ".env", _ENV_CACHE, _parse_env)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
def apply_managed_overlay(config: dict) -> dict:
|
||||
"""Overlay administrator-pinned config values on top of an already-built dict.
|
||||
|
||||
The single, shared way for any config loader that builds its own dict
|
||||
(rather than going through hermes_cli.config.load_config) to honor managed
|
||||
scope. Mirrors hermes_cli.config._load_config_impl's managed merge exactly:
|
||||
|
||||
* expand the managed config's ``${VAR}`` refs against the PROCESS env only
|
||||
(never user-config-defined refs), so a user cannot shadow a managed
|
||||
literal via a ${VAR} they control;
|
||||
* normalize the managed config's root ``model`` key (a bare ``model: x/y``
|
||||
string is promoted to ``model.default``) so it can't clobber the dict
|
||||
shape callers expect;
|
||||
* leaf-level deep-merge managed ON TOP, so managed wins per-leaf while
|
||||
sibling keys stay user-controlled.
|
||||
|
||||
Fail-open: returns ``config`` unchanged if no managed scope is present or on
|
||||
any error — managed scope must never break a caller's startup. Mutates and
|
||||
returns ``config`` (callers pass a dict they own).
|
||||
"""
|
||||
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))
|
||||
# A bare ``model: x/y`` string in the managed file must merge as
|
||||
# ``model.default`` — otherwise _deep_merge would replace the caller's
|
||||
# ``model`` dict with a string and break every ``cfg["model"]["..."]``
|
||||
# read. _normalize_root_model_keys only promotes the string when there
|
||||
# are root provider/base_url keys to migrate, so handle the bare case
|
||||
# here (matches cli.py's own string-model handling).
|
||||
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 _parse_env(f) -> Dict[str, str]:
|
||||
out: Dict[str, str] = {}
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
out[key.strip()] = value.strip().strip("\"'")
|
||||
return out
|
||||
|
||||
|
||||
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()
|
||||
@@ -276,6 +276,14 @@ def _load_hermes_env() -> None:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Managed scope: overlay administrator-pinned values before bridging to env,
|
||||
# so a managed top-level scalar wins here too. Fail-open via the helper.
|
||||
try:
|
||||
from hermes_cli import managed_scope
|
||||
raw = managed_scope.apply_managed_overlay(raw if isinstance(raw, dict) else {})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
return
|
||||
|
||||
|
||||
@@ -553,6 +553,13 @@ def _read_logging_config():
|
||||
if config_path.exists():
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
# Managed scope: an administrator can pin logging.* too. Overlay via
|
||||
# the shared helper (fail-open) since this reads config.yaml directly.
|
||||
try:
|
||||
from hermes_cli import managed_scope
|
||||
cfg = managed_scope.apply_managed_overlay(cfg)
|
||||
except Exception:
|
||||
pass
|
||||
log_cfg = cfg.get("logging", {})
|
||||
if isinstance(log_cfg, dict):
|
||||
return (
|
||||
|
||||
@@ -52,6 +52,13 @@ def _resolve_timezone_name() -> str:
|
||||
if config_path.exists():
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
# Managed scope: an administrator can pin ``timezone`` too. Overlay
|
||||
# via the shared helper (fail-open) since this reads config.yaml directly.
|
||||
try:
|
||||
from hermes_cli import managed_scope
|
||||
cfg = managed_scope.apply_managed_overlay(cfg)
|
||||
except Exception:
|
||||
pass
|
||||
tz_cfg = cfg.get("timezone", "")
|
||||
if isinstance(tz_cfg, str) and tz_cfg.strip():
|
||||
return tz_cfg.strip()
|
||||
|
||||
@@ -190,6 +190,7 @@ _HERMES_BEHAVIORAL_VARS = frozenset({
|
||||
"HERMES_INFERENCE_PROVIDER",
|
||||
"HERMES_TUI_PROVIDER",
|
||||
"HERMES_MANAGED",
|
||||
"HERMES_MANAGED_DIR",
|
||||
"HERMES_DEV",
|
||||
"HERMES_CONTAINER",
|
||||
"HERMES_EPHEMERAL_SYSTEM_PROMPT",
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Unit tests for hermes_cli.managed_scope (resolver + loaders + key helpers)."""
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ── Directory resolver ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_get_managed_dir_env_override(tmp_path, monkeypatch):
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
managed = tmp_path / "managed"
|
||||
managed.mkdir()
|
||||
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed))
|
||||
assert managed_scope.get_managed_dir() == managed
|
||||
|
||||
|
||||
def test_get_managed_dir_absent_override_returns_none(tmp_path, monkeypatch):
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
monkeypatch.setenv("HERMES_MANAGED_DIR", str(tmp_path / "nope"))
|
||||
# Override points at a non-existent dir → no managed scope.
|
||||
assert managed_scope.get_managed_dir() is None
|
||||
|
||||
|
||||
def test_get_managed_dir_empty_override_falls_through(tmp_path, monkeypatch):
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
monkeypatch.setenv("HERMES_MANAGED_DIR", " ") # whitespace = unset
|
||||
# Under pytest the /etc/hermes default is ignored, so this is None; the
|
||||
# assertion that matters is that it does NOT raise.
|
||||
result = managed_scope.get_managed_dir()
|
||||
assert result is None or result.exists()
|
||||
|
||||
|
||||
def test_get_managed_dir_default_ignored_under_pytest(monkeypatch):
|
||||
"""The system default must be inert in the test suite (isolation guard)."""
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
monkeypatch.delenv("HERMES_MANAGED_DIR", raising=False)
|
||||
assert managed_scope.get_managed_dir() is None
|
||||
|
||||
|
||||
# ── Loaders + key helpers ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _write_managed(tmp_path, monkeypatch, *, config=None, env=None):
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
managed = tmp_path / "managed"
|
||||
managed.mkdir(exist_ok=True)
|
||||
if config is not None:
|
||||
(managed / "config.yaml").write_text(textwrap.dedent(config), encoding="utf-8")
|
||||
if env is not None:
|
||||
(managed / ".env").write_text(textwrap.dedent(env), encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed))
|
||||
managed_scope.invalidate_managed_cache()
|
||||
return managed
|
||||
|
||||
|
||||
def test_load_managed_config(tmp_path, monkeypatch):
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
_write_managed(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
config="""
|
||||
model:
|
||||
default: managed/model
|
||||
""",
|
||||
)
|
||||
assert managed_scope.load_managed_config() == {"model": {"default": "managed/model"}}
|
||||
|
||||
|
||||
def test_load_managed_config_absent_is_empty(tmp_path, monkeypatch):
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
monkeypatch.setenv("HERMES_MANAGED_DIR", str(tmp_path / "nope"))
|
||||
managed_scope.invalidate_managed_cache()
|
||||
assert managed_scope.load_managed_config() == {}
|
||||
|
||||
|
||||
def test_load_managed_config_malformed_fails_open(tmp_path, monkeypatch):
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
_write_managed(tmp_path, monkeypatch, config="model: : : not yaml :")
|
||||
assert managed_scope.load_managed_config() == {} # fail-open, no raise
|
||||
|
||||
|
||||
def test_managed_config_keys_are_dotted_leaves(tmp_path, monkeypatch):
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
_write_managed(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
config="""
|
||||
model:
|
||||
default: m
|
||||
security:
|
||||
redact_secrets: true
|
||||
""",
|
||||
)
|
||||
assert managed_scope.managed_config_keys() == {
|
||||
"model.default",
|
||||
"security.redact_secrets",
|
||||
}
|
||||
|
||||
|
||||
def test_is_key_managed(tmp_path, monkeypatch):
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
_write_managed(tmp_path, monkeypatch, config="model:\n default: m\n")
|
||||
assert managed_scope.is_key_managed("model.default") is True
|
||||
assert managed_scope.is_key_managed("model.fallback") is False
|
||||
|
||||
|
||||
def test_load_managed_env_and_is_env_managed(tmp_path, monkeypatch):
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
_write_managed(
|
||||
tmp_path, monkeypatch, env="OPENAI_API_BASE=https://org.example/v1\n"
|
||||
)
|
||||
assert managed_scope.load_managed_env() == {
|
||||
"OPENAI_API_BASE": "https://org.example/v1"
|
||||
}
|
||||
assert managed_scope.is_env_managed("OPENAI_API_BASE") is True
|
||||
assert managed_scope.is_env_managed("OTHER") is False
|
||||
|
||||
|
||||
def test_editing_managed_config_invalidates_cache(tmp_path, monkeypatch):
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
managed = _write_managed(tmp_path, monkeypatch, config="model:\n default: v1\n")
|
||||
assert managed_scope.load_managed_config()["model"]["default"] == "v1"
|
||||
(managed / "config.yaml").write_text("model:\n default: v2\n", encoding="utf-8")
|
||||
managed_scope.invalidate_managed_cache()
|
||||
assert managed_scope.load_managed_config()["model"]["default"] == "v2"
|
||||
|
||||
|
||||
def test_managed_dir_env_scrubbed_by_default():
|
||||
"""conftest must scrub HERMES_MANAGED_DIR so a dev-shell value can't leak in."""
|
||||
import os
|
||||
|
||||
assert "HERMES_MANAGED_DIR" not in os.environ
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Managed scope must reach cli.py's independent config loader (CLI_CONFIG).
|
||||
|
||||
cli.py's load_cli_config() builds config separately from
|
||||
hermes_cli.config._load_config_impl, so the managed-scope merge has to be
|
||||
applied in BOTH places or the interactive CLI/TUI surface (skin, display prefs)
|
||||
silently ignores administrator-pinned values while `hermes config`/`doctor`
|
||||
honor them. This locks the cli.py path.
|
||||
"""
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def homes(tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
managed = tmp_path / "managed"
|
||||
managed.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed))
|
||||
import hermes_cli.config as cfg
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
cfg._LOAD_CONFIG_CACHE.clear()
|
||||
cfg._RAW_CONFIG_CACHE.clear()
|
||||
managed_scope.invalidate_managed_cache()
|
||||
return home, managed
|
||||
|
||||
|
||||
def _load_cli_config(home):
|
||||
"""Call cli.py's standalone loader fresh.
|
||||
|
||||
cli.py binds ``_hermes_home = get_hermes_home()`` at import time (module
|
||||
singleton), so monkeypatching HERMES_HOME after import doesn't move it.
|
||||
Point the module's cached home at the test's home for the duration of the
|
||||
call. (In real use cli is imported once per process with the real home, so
|
||||
this only matters for tests that swap HERMES_HOME.)
|
||||
"""
|
||||
import cli
|
||||
|
||||
cli._hermes_home = home
|
||||
return cli.load_cli_config()
|
||||
|
||||
|
||||
def test_cli_config_honors_managed_skin(homes):
|
||||
"""A managed display.skin must reach CLI_CONFIG (the TUI's source)."""
|
||||
home, managed = homes
|
||||
(home / "config.yaml").write_text("display:\n skin: user_skin\n", encoding="utf-8")
|
||||
(managed / "config.yaml").write_text("display:\n skin: charizard\n", encoding="utf-8")
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
managed_scope.invalidate_managed_cache()
|
||||
cfg = _load_cli_config(home)
|
||||
assert (cfg.get("display") or {}).get("skin") == "charizard"
|
||||
|
||||
|
||||
def test_cli_config_managed_leaf_preserves_user_siblings(homes):
|
||||
"""Managed display.skin must not wipe a user's other display.* prefs."""
|
||||
home, managed = homes
|
||||
(home / "config.yaml").write_text(
|
||||
"display:\n skin: user_skin\n show_reasoning: true\n", encoding="utf-8"
|
||||
)
|
||||
(managed / "config.yaml").write_text("display:\n skin: charizard\n", encoding="utf-8")
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
managed_scope.invalidate_managed_cache()
|
||||
cfg = _load_cli_config(home)
|
||||
display = cfg.get("display") or {}
|
||||
assert display.get("skin") == "charizard" # managed wins
|
||||
assert display.get("show_reasoning") is True # user sibling preserved
|
||||
|
||||
|
||||
def test_cli_config_no_managed_scope_uses_user_value(homes):
|
||||
"""With no managed config, CLI_CONFIG reflects the user's value."""
|
||||
home, managed = homes # managed dir exists but empty
|
||||
(home / "config.yaml").write_text("display:\n skin: user_skin\n", encoding="utf-8")
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
managed_scope.invalidate_managed_cache()
|
||||
cfg = _load_cli_config(home)
|
||||
assert (cfg.get("display") or {}).get("skin") == "user_skin"
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Config integration tests — managed scope wins over user config at the leaf."""
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def homes(tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
managed = tmp_path / "managed"
|
||||
managed.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed))
|
||||
import hermes_cli.config as cfg
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
cfg._LOAD_CONFIG_CACHE.clear()
|
||||
cfg._RAW_CONFIG_CACHE.clear()
|
||||
managed_scope.invalidate_managed_cache()
|
||||
return home, managed
|
||||
|
||||
|
||||
def _write(path, body):
|
||||
path.write_text(textwrap.dedent(body), encoding="utf-8")
|
||||
import hermes_cli.config as cfg
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
cfg._LOAD_CONFIG_CACHE.clear()
|
||||
cfg._RAW_CONFIG_CACHE.clear()
|
||||
managed_scope.invalidate_managed_cache()
|
||||
|
||||
|
||||
def test_managed_beats_user(homes):
|
||||
from hermes_cli.config import load_config, cfg_get
|
||||
|
||||
home, managed = homes
|
||||
_write(home / "config.yaml", "model:\n default: user/model\n")
|
||||
_write(managed / "config.yaml", "model:\n default: managed/model\n")
|
||||
assert cfg_get(load_config(), "model", "default") == "managed/model"
|
||||
|
||||
|
||||
def test_managed_leaf_does_not_freeze_siblings(homes):
|
||||
"""D3/Q4: pinning model.default leaves model.fallback user-controlled."""
|
||||
from hermes_cli.config import load_config, cfg_get
|
||||
|
||||
home, managed = homes
|
||||
_write(home / "config.yaml", "model:\n default: user/model\n fallback: user/fb\n")
|
||||
_write(managed / "config.yaml", "model:\n default: managed/model\n")
|
||||
cfg = load_config()
|
||||
assert cfg_get(cfg, "model", "default") == "managed/model"
|
||||
assert cfg_get(cfg, "model", "fallback") == "user/fb" # sibling preserved
|
||||
|
||||
|
||||
def test_no_managed_config_is_unchanged(homes):
|
||||
from hermes_cli.config import load_config, cfg_get
|
||||
|
||||
home, _ = homes
|
||||
_write(home / "config.yaml", "model:\n default: user/model\n")
|
||||
assert cfg_get(load_config(), "model", "default") == "user/model"
|
||||
|
||||
|
||||
def test_managed_list_wins_wholesale(homes):
|
||||
"""D3: a managed list value replaces the user's wholesale."""
|
||||
from hermes_cli.config import load_config, cfg_get
|
||||
|
||||
home, managed = homes
|
||||
_write(home / "config.yaml", "toolsets:\n enabled: [a, b, c]\n")
|
||||
_write(managed / "config.yaml", "toolsets:\n enabled: [x]\n")
|
||||
assert cfg_get(load_config(), "toolsets", "enabled") == ["x"]
|
||||
|
||||
|
||||
def test_editing_managed_file_invalidates_cache(homes):
|
||||
from hermes_cli.config import load_config, cfg_get
|
||||
|
||||
home, managed = homes
|
||||
_write(home / "config.yaml", "model:\n default: user/model\n")
|
||||
_write(managed / "config.yaml", "model:\n default: managed/v1\n")
|
||||
assert cfg_get(load_config(), "model", "default") == "managed/v1"
|
||||
_write(managed / "config.yaml", "model:\n default: managed/v2\n")
|
||||
assert cfg_get(load_config(), "model", "default") == "managed/v2"
|
||||
|
||||
|
||||
def test_user_cannot_shadow_managed_literal_via_envref(homes, monkeypatch):
|
||||
"""A managed literal must NOT be expandable via a ${VAR} the user controls.
|
||||
|
||||
The managed value is a plain literal 'managed/locked' with no ${...}, so a
|
||||
user-defined env var has nothing to substitute. This asserts the managed
|
||||
literal survives verbatim regardless of user env, and that managed wins.
|
||||
"""
|
||||
from hermes_cli.config import load_config, cfg_get
|
||||
|
||||
home, managed = homes
|
||||
monkeypatch.setenv("EVIL", "user/override")
|
||||
_write(home / "config.yaml", "model:\n default: ${EVIL}\n")
|
||||
_write(managed / "config.yaml", "model:\n default: managed/locked\n")
|
||||
assert cfg_get(load_config(), "model", "default") == "managed/locked"
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Env integration tests — managed .env applied last with override."""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env_homes(tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
managed = tmp_path / "managed"
|
||||
managed.mkdir()
|
||||
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed))
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
managed_scope.invalidate_managed_cache()
|
||||
return home, managed
|
||||
|
||||
|
||||
def test_managed_env_beats_user_env(env_homes, monkeypatch):
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
|
||||
home, managed = env_homes
|
||||
(home / ".env").write_text("OPENAI_API_BASE=https://user.example/v1\n", encoding="utf-8")
|
||||
(managed / ".env").write_text("OPENAI_API_BASE=https://org.example/v1\n", encoding="utf-8")
|
||||
load_hermes_dotenv(hermes_home=str(home))
|
||||
assert os.environ["OPENAI_API_BASE"] == "https://org.example/v1"
|
||||
|
||||
|
||||
def test_managed_env_beats_shell(env_homes, monkeypatch):
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
|
||||
home, managed = env_homes
|
||||
monkeypatch.setenv("OPENAI_API_BASE", "https://shell.example/v1")
|
||||
(managed / ".env").write_text("OPENAI_API_BASE=https://org.example/v1\n", encoding="utf-8")
|
||||
load_hermes_dotenv(hermes_home=str(home))
|
||||
assert os.environ["OPENAI_API_BASE"] == "https://org.example/v1"
|
||||
|
||||
|
||||
def test_managed_env_leaves_unmanaged_keys_alone(env_homes, monkeypatch):
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
|
||||
home, managed = env_homes
|
||||
(home / ".env").write_text("USER_ONLY=keepme\n", encoding="utf-8")
|
||||
(managed / ".env").write_text("OPENAI_API_BASE=https://org.example/v1\n", encoding="utf-8")
|
||||
load_hermes_dotenv(hermes_home=str(home))
|
||||
assert os.environ["USER_ONLY"] == "keepme"
|
||||
assert os.environ["OPENAI_API_BASE"] == "https://org.example/v1"
|
||||
|
||||
|
||||
def test_no_managed_env_is_noop(env_homes, monkeypatch):
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
|
||||
home, managed = env_homes # managed dir exists but has no .env
|
||||
monkeypatch.setenv("SOME_VALUE", "from_shell")
|
||||
(home / ".env").write_text("SOME_VALUE=from_user\n", encoding="utf-8")
|
||||
load_hermes_dotenv(hermes_home=str(home))
|
||||
assert os.environ["SOME_VALUE"] == "from_user"
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Each standalone config loader (gateway, TUI/desktop, cron) must honor managed scope.
|
||||
|
||||
These loaders build their own config dict instead of routing through
|
||||
hermes_cli.config.load_config, so the managed overlay has to be wired into each.
|
||||
This is the regression guard for the whole bug class (a managed display.skin was
|
||||
silently ignored by the TUI; the same gap existed in the gateway and cron).
|
||||
"""
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def homes(tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
managed = tmp_path / "managed"
|
||||
managed.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed))
|
||||
import hermes_cli.config as cfg
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
cfg._LOAD_CONFIG_CACHE.clear()
|
||||
cfg._RAW_CONFIG_CACHE.clear()
|
||||
managed_scope.invalidate_managed_cache()
|
||||
return home, managed
|
||||
|
||||
|
||||
def _seed(home, managed, *, user, mgd):
|
||||
(home / "config.yaml").write_text(textwrap.dedent(user), encoding="utf-8")
|
||||
(managed / "config.yaml").write_text(textwrap.dedent(mgd), encoding="utf-8")
|
||||
import hermes_cli.config as cfg
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
cfg._LOAD_CONFIG_CACHE.clear()
|
||||
cfg._RAW_CONFIG_CACHE.clear()
|
||||
managed_scope.invalidate_managed_cache()
|
||||
|
||||
|
||||
def test_gateway_run_loader_honors_managed(homes, monkeypatch):
|
||||
home, managed = homes
|
||||
_seed(home, managed, user="model:\n default: user/m\n", mgd="model:\n default: org/m\n")
|
||||
import gateway.run as gr
|
||||
|
||||
monkeypatch.setattr(gr, "_hermes_home", home, raising=False)
|
||||
cfg = gr._load_gateway_config()
|
||||
assert (cfg.get("model") or {}).get("default") == "org/m"
|
||||
|
||||
|
||||
def test_gateway_config_loader_honors_managed(homes, monkeypatch):
|
||||
home, managed = homes
|
||||
_seed(
|
||||
home,
|
||||
managed,
|
||||
user="group_sessions_per_user: false\n",
|
||||
mgd="group_sessions_per_user: true\n",
|
||||
)
|
||||
import gateway.config as gc
|
||||
|
||||
# load_gateway_config resolves home via get_hermes_home() (HERMES_HOME env).
|
||||
cfg = gc.load_gateway_config()
|
||||
# Managed value should have flowed into the GatewayConfig.
|
||||
assert cfg.group_sessions_per_user is True
|
||||
|
||||
|
||||
def test_tui_loader_honors_managed(homes, monkeypatch):
|
||||
home, managed = homes
|
||||
_seed(home, managed, user="display:\n skin: user\n", mgd="display:\n skin: charizard\n")
|
||||
import tui_gateway.server as ts
|
||||
|
||||
monkeypatch.setattr(ts, "_hermes_home", home, raising=False)
|
||||
monkeypatch.setattr(ts, "_cfg_cache", None, raising=False)
|
||||
monkeypatch.setattr(ts, "_cfg_mtime", None, raising=False)
|
||||
monkeypatch.setattr(ts, "get_hermes_home_override", lambda: None, raising=False)
|
||||
cfg = ts._load_cfg()
|
||||
assert (cfg.get("display") or {}).get("skin") == "charizard"
|
||||
|
||||
|
||||
def test_tui_loader_does_not_persist_managed_back(homes, monkeypatch):
|
||||
"""The TUI caches RAW config so _save_cfg never writes managed values to disk."""
|
||||
home, managed = homes
|
||||
_seed(home, managed, user="display:\n skin: user\n", mgd="display:\n skin: charizard\n")
|
||||
import tui_gateway.server as ts
|
||||
|
||||
monkeypatch.setattr(ts, "_hermes_home", home, raising=False)
|
||||
monkeypatch.setattr(ts, "_cfg_cache", None, raising=False)
|
||||
monkeypatch.setattr(ts, "_cfg_mtime", None, raising=False)
|
||||
monkeypatch.setattr(ts, "get_hermes_home_override", lambda: None, raising=False)
|
||||
ts._load_cfg() # populates the cache
|
||||
# The cache must hold the RAW user value, not the managed overlay, so a
|
||||
# subsequent _save_cfg can't bake the managed skin into the user file.
|
||||
assert (ts._cfg_cache.get("display") or {}).get("skin") == "user"
|
||||
|
||||
|
||||
def test_logging_config_honors_managed(homes, monkeypatch):
|
||||
home, managed = homes
|
||||
_seed(home, managed, user="logging:\n level: INFO\n", mgd="logging:\n level: DEBUG\n")
|
||||
import hermes_logging
|
||||
|
||||
level, _max, _bk = hermes_logging._read_logging_config()
|
||||
assert level == "DEBUG"
|
||||
|
||||
|
||||
def test_timezone_honors_managed(homes, monkeypatch):
|
||||
home, managed = homes
|
||||
# hermes_time checks an env override first; ensure it's unset so config wins.
|
||||
monkeypatch.delenv("HERMES_TIMEZONE", raising=False)
|
||||
monkeypatch.delenv("TZ", raising=False)
|
||||
_seed(home, managed, user="timezone: America/New_York\n", mgd="timezone: Asia/Tokyo\n")
|
||||
import hermes_time
|
||||
|
||||
assert hermes_time._resolve_timezone_name() == "Asia/Tokyo"
|
||||
|
||||
|
||||
def test_gateway_env_bridge_honors_managed(homes, monkeypatch):
|
||||
"""The gateway config→env bridge must bridge MANAGED values, not user ones.
|
||||
|
||||
gateway/run.py bridges config.yaml settings into os.environ at startup and on
|
||||
every turn (HERMES_TIMEZONE, HERMES_REDACT_SECRETS, HERMES_MAX_ITERATIONS,
|
||||
...). A managed value must win at that env layer too — otherwise the bridge
|
||||
writes the user's value into the env that the whole process then reads. This
|
||||
is the regression that manual verification caught (managed timezone was
|
||||
overridden by the user's value via the env bridge).
|
||||
|
||||
We assert on the managed-overlaid config the bridge consumes (rather than the
|
||||
os.environ side effect, which leaks across same-process tests under the
|
||||
runner) — the bridge writes whatever this dict carries, so a managed value
|
||||
here proves the env var gets the managed value.
|
||||
"""
|
||||
home, managed = homes
|
||||
_seed(home, managed, user="timezone: America/New_York\n", mgd="timezone: Asia/Tokyo\n")
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
managed_scope.invalidate_managed_cache()
|
||||
# The bridge loads config.yaml, expands env, then applies this overlay before
|
||||
# writing HERMES_TIMEZONE = cfg["timezone"]. Prove the overlay flips the value.
|
||||
import yaml
|
||||
|
||||
raw = yaml.safe_load((home / "config.yaml").read_text())
|
||||
bridged = managed_scope.apply_managed_overlay(raw)
|
||||
assert bridged.get("timezone") == "Asia/Tokyo"
|
||||
@@ -0,0 +1,69 @@
|
||||
"""apply_managed_overlay() — the shared helper used by every standalone loader."""
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def managed(tmp_path, monkeypatch):
|
||||
md = tmp_path / "managed"
|
||||
md.mkdir()
|
||||
monkeypatch.setenv("HERMES_MANAGED_DIR", str(md))
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
managed_scope.invalidate_managed_cache()
|
||||
return md
|
||||
|
||||
|
||||
def _write(md, body):
|
||||
(md / "config.yaml").write_text(textwrap.dedent(body), encoding="utf-8")
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
managed_scope.invalidate_managed_cache()
|
||||
|
||||
|
||||
def test_overlay_noop_without_scope(tmp_path, monkeypatch):
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
monkeypatch.setenv("HERMES_MANAGED_DIR", str(tmp_path / "nope"))
|
||||
managed_scope.invalidate_managed_cache()
|
||||
src = {"display": {"skin": "user"}}
|
||||
assert managed_scope.apply_managed_overlay(src) == {"display": {"skin": "user"}}
|
||||
|
||||
|
||||
def test_overlay_managed_wins(managed):
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
_write(managed, "display:\n skin: charizard\n")
|
||||
out = managed_scope.apply_managed_overlay({"display": {"skin": "user"}})
|
||||
assert out["display"]["skin"] == "charizard"
|
||||
|
||||
|
||||
def test_overlay_preserves_user_siblings(managed):
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
_write(managed, "display:\n skin: charizard\n")
|
||||
out = managed_scope.apply_managed_overlay(
|
||||
{"display": {"skin": "user", "show_reasoning": True}}
|
||||
)
|
||||
assert out["display"]["skin"] == "charizard"
|
||||
assert out["display"]["show_reasoning"] is True
|
||||
|
||||
|
||||
def test_overlay_normalizes_root_model_string(managed):
|
||||
"""A managed bare `model: x/y` must promote to model.default, not clobber the dict."""
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
_write(managed, "model: org/locked\n")
|
||||
out = managed_scope.apply_managed_overlay({"model": {"default": "user/m", "fallback": "u/fb"}})
|
||||
assert out["model"]["default"] == "org/locked" # managed wins
|
||||
assert out["model"]["fallback"] == "u/fb" # user sibling preserved (dict shape intact)
|
||||
|
||||
|
||||
def test_overlay_user_envref_cannot_shadow_managed_literal(managed, monkeypatch):
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
monkeypatch.setenv("EVIL", "user/override")
|
||||
_write(managed, "model:\n default: managed/locked\n")
|
||||
out = managed_scope.apply_managed_overlay({"model": {"default": "${EVIL}"}})
|
||||
assert out["model"]["default"] == "managed/locked"
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Regression harness — pins config/env load behavior BEFORE managed scope exists.
|
||||
|
||||
Every test here must keep passing through all later phases when NO managed scope
|
||||
is present. They are the 'managed scope is invisible when absent' contract.
|
||||
"""
|
||||
import os
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hermes_home(tmp_path, monkeypatch):
|
||||
home = tmp_path / "hermes_home"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
# No managed dir: point the override at a guaranteed-absent path so a real
|
||||
# /etc/hermes on the dev/CI box can't influence the test.
|
||||
monkeypatch.setenv("HERMES_MANAGED_DIR", str(tmp_path / "no_such_managed_dir"))
|
||||
# Clear caches so each test re-reads from disk.
|
||||
import hermes_cli.config as cfg
|
||||
|
||||
cfg._LOAD_CONFIG_CACHE.clear()
|
||||
cfg._RAW_CONFIG_CACHE.clear()
|
||||
cfg.invalidate_env_cache()
|
||||
return home
|
||||
|
||||
|
||||
def _write_user_config(home, body: str):
|
||||
(home / "config.yaml").write_text(textwrap.dedent(body), encoding="utf-8")
|
||||
import hermes_cli.config as cfg
|
||||
|
||||
cfg._LOAD_CONFIG_CACHE.clear()
|
||||
cfg._RAW_CONFIG_CACHE.clear()
|
||||
|
||||
|
||||
def test_user_config_overrides_default(hermes_home, monkeypatch):
|
||||
from hermes_cli.config import load_config, cfg_get
|
||||
|
||||
_write_user_config(
|
||||
hermes_home,
|
||||
"""
|
||||
model:
|
||||
default: user/model-x
|
||||
""",
|
||||
)
|
||||
cfg = load_config()
|
||||
assert cfg_get(cfg, "model", "default") == "user/model-x"
|
||||
|
||||
|
||||
def test_env_expansion_in_user_config(hermes_home, monkeypatch):
|
||||
from hermes_cli.config import load_config, cfg_get
|
||||
|
||||
monkeypatch.setenv("MY_BASE", "https://example.test")
|
||||
_write_user_config(
|
||||
hermes_home,
|
||||
"""
|
||||
providers:
|
||||
custom:
|
||||
base_url: ${MY_BASE}/v1
|
||||
""",
|
||||
)
|
||||
cfg = load_config()
|
||||
assert cfg_get(cfg, "providers", "custom", "base_url") == "https://example.test/v1"
|
||||
|
||||
|
||||
def test_no_managed_dir_means_user_value_wins(hermes_home):
|
||||
"""Sanity: with the managed override pointing at an absent dir, nothing changes."""
|
||||
from hermes_cli.config import load_config, cfg_get
|
||||
|
||||
_write_user_config(
|
||||
hermes_home,
|
||||
"""
|
||||
model:
|
||||
default: user/model-y
|
||||
""",
|
||||
)
|
||||
assert cfg_get(load_config(), "model", "default") == "user/model-y"
|
||||
|
||||
|
||||
def test_user_env_overrides_shell(tmp_path, monkeypatch):
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
(home / ".env").write_text("FOO_TOKEN=from_user_env\n", encoding="utf-8")
|
||||
monkeypatch.setenv("FOO_TOKEN", "from_shell")
|
||||
load_hermes_dotenv(hermes_home=str(home))
|
||||
assert os.environ["FOO_TOKEN"] == "from_user_env"
|
||||
|
||||
|
||||
def test_missing_user_env_is_noop(tmp_path, monkeypatch):
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("BAR_TOKEN", "from_shell")
|
||||
load_hermes_dotenv(hermes_home=str(home))
|
||||
assert os.environ["BAR_TOKEN"] == "from_shell"
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Surfacing tests — managed scope shown in `config show` and `hermes doctor`."""
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def homes(tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
managed = tmp_path / "managed"
|
||||
managed.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed))
|
||||
(home / "config.yaml").write_text("model:\n default: user/model\n", encoding="utf-8")
|
||||
(managed / "config.yaml").write_text(
|
||||
"model:\n default: managed/model\n", encoding="utf-8"
|
||||
)
|
||||
import hermes_cli.config as cfg
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
cfg._LOAD_CONFIG_CACHE.clear()
|
||||
cfg._RAW_CONFIG_CACHE.clear()
|
||||
managed_scope.invalidate_managed_cache()
|
||||
return home, managed
|
||||
|
||||
|
||||
def test_config_show_flags_managed(homes, capsys):
|
||||
from hermes_cli.config import show_config
|
||||
|
||||
show_config()
|
||||
out = capsys.readouterr().out.lower()
|
||||
assert "managed" in out # header + key list present
|
||||
assert "model.default" in out # the pinned key is named
|
||||
assert "managed/model" in out # effective (managed) value, not user/model
|
||||
|
||||
|
||||
def test_config_show_no_managed_scope_silent(tmp_path, monkeypatch, capsys):
|
||||
"""With no managed scope, the managed header must not appear."""
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setenv("HERMES_MANAGED_DIR", str(tmp_path / "nope"))
|
||||
(home / "config.yaml").write_text("model:\n default: user/model\n", encoding="utf-8")
|
||||
import hermes_cli.config as cfg
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
cfg._LOAD_CONFIG_CACHE.clear()
|
||||
cfg._RAW_CONFIG_CACHE.clear()
|
||||
managed_scope.invalidate_managed_cache()
|
||||
from hermes_cli.config import show_config
|
||||
|
||||
show_config()
|
||||
out = capsys.readouterr().out.lower()
|
||||
assert "managed by your administrator" not in out
|
||||
|
||||
|
||||
def test_doctor_reports_managed_scope(homes, capsys):
|
||||
# homes fixture has 1 managed config key (model.default) and 0 managed env keys.
|
||||
from hermes_cli import doctor
|
||||
|
||||
doctor.managed_scope_check()
|
||||
out = capsys.readouterr().out.lower()
|
||||
assert "managed scope active" in out
|
||||
assert str(homes[1]).lower() in out # resolved dir reported
|
||||
assert "1 config key" in out
|
||||
|
||||
|
||||
def test_doctor_silent_with_no_managed_scope(tmp_path, monkeypatch, capsys):
|
||||
monkeypatch.setenv("HERMES_MANAGED_DIR", str(tmp_path / "nope"))
|
||||
from hermes_cli import managed_scope, doctor
|
||||
|
||||
managed_scope.invalidate_managed_cache()
|
||||
doctor.managed_scope_check()
|
||||
assert capsys.readouterr().out.strip() == ""
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Write-guard tests — managed keys can't be set/removed by the user."""
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def homes(tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
managed = tmp_path / "managed"
|
||||
managed.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed))
|
||||
import hermes_cli.config as cfg
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
cfg._LOAD_CONFIG_CACHE.clear()
|
||||
cfg._RAW_CONFIG_CACHE.clear()
|
||||
managed_scope.invalidate_managed_cache()
|
||||
(managed / "config.yaml").write_text(
|
||||
"model:\n default: managed/model\n", encoding="utf-8"
|
||||
)
|
||||
managed_scope.invalidate_managed_cache()
|
||||
return home, managed
|
||||
|
||||
|
||||
def test_config_set_managed_key_rejected(homes, capsys):
|
||||
from hermes_cli.config import set_config_value
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
set_config_value("model.default", "user/override")
|
||||
assert exc.value.code != 0
|
||||
captured = capsys.readouterr()
|
||||
assert "managed" in (captured.out + captured.err).lower()
|
||||
|
||||
|
||||
def test_config_set_managed_key_does_not_write(homes):
|
||||
from hermes_cli.config import set_config_value, read_raw_config
|
||||
|
||||
try:
|
||||
set_config_value("model.default", "user/override")
|
||||
except SystemExit:
|
||||
pass
|
||||
raw = read_raw_config()
|
||||
assert raw.get("model", {}).get("default") != "user/override"
|
||||
|
||||
|
||||
def test_config_set_unmanaged_key_still_works(homes):
|
||||
from hermes_cli.config import set_config_value, read_raw_config
|
||||
|
||||
set_config_value("model.fallback", "user/fb") # not managed
|
||||
assert read_raw_config().get("model", {}).get("fallback") == "user/fb"
|
||||
|
||||
|
||||
# ── env write guards ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env_homes(tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
managed = tmp_path / "managed"
|
||||
managed.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed))
|
||||
(managed / ".env").write_text(
|
||||
"OPENAI_API_BASE=https://org.example/v1\n", encoding="utf-8"
|
||||
)
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
managed_scope.invalidate_managed_cache()
|
||||
return home, managed
|
||||
|
||||
|
||||
def test_save_env_value_managed_key_rejected(env_homes, capsys):
|
||||
from hermes_cli.config import save_env_value, get_env_path
|
||||
|
||||
save_env_value("OPENAI_API_BASE", "https://user.example/v1")
|
||||
assert "managed" in capsys.readouterr().err.lower()
|
||||
env_path = get_env_path()
|
||||
body = env_path.read_text() if env_path.exists() else ""
|
||||
assert "user.example" not in body
|
||||
|
||||
|
||||
def test_remove_env_value_managed_key_rejected(env_homes, capsys):
|
||||
from hermes_cli.config import remove_env_value
|
||||
|
||||
result = remove_env_value("OPENAI_API_BASE")
|
||||
assert result is False
|
||||
assert "managed" in capsys.readouterr().err.lower()
|
||||
|
||||
|
||||
def test_save_env_value_unmanaged_key_still_works(env_homes):
|
||||
from hermes_cli.config import save_env_value, get_env_value
|
||||
|
||||
save_env_value("SOME_OTHER_VALUE", "abc123")
|
||||
assert get_env_value("SOME_OTHER_VALUE") == "abc123"
|
||||
|
||||
|
||||
# ── bulk save strips managed leaves ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_save_config_strips_managed_leaves(homes, capsys):
|
||||
from hermes_cli.config import save_config, read_raw_config
|
||||
|
||||
# 'model.default' is managed (homes fixture); 'model.fallback' is not.
|
||||
save_config({"model": {"default": "user/override", "fallback": "user/fb"}})
|
||||
raw = read_raw_config()
|
||||
assert raw.get("model", {}).get("default") != "user/override" # stripped
|
||||
assert raw.get("model", {}).get("fallback") == "user/fb" # kept
|
||||
assert "managed" in capsys.readouterr().err.lower()
|
||||
+22
-2
@@ -1312,22 +1312,42 @@ def _load_cfg() -> dict:
|
||||
mtime = p.stat().st_mtime if p.exists() else None
|
||||
with _cfg_lock:
|
||||
if _cfg_cache is not None and _cfg_mtime == mtime and _cfg_path == p:
|
||||
return copy.deepcopy(_cfg_cache)
|
||||
return _apply_managed(copy.deepcopy(_cfg_cache))
|
||||
if p.exists():
|
||||
with open(p, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
else:
|
||||
data = {}
|
||||
with _cfg_lock:
|
||||
# Cache the RAW user config (no managed overlay) so _save_cfg, which
|
||||
# writes _cfg_cache back to disk, never persists managed values into
|
||||
# the user's file. The managed overlay is applied on every return
|
||||
# path instead (read-side only).
|
||||
_cfg_cache = copy.deepcopy(data)
|
||||
_cfg_mtime = mtime
|
||||
_cfg_path = p
|
||||
return data
|
||||
return _apply_managed(data)
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _apply_managed(cfg: dict) -> dict:
|
||||
"""Overlay administrator-pinned managed-scope values on a config dict.
|
||||
|
||||
The TUI/desktop backend builds config independently of
|
||||
hermes_cli.config.load_config, so without this a managed skin / reasoning_effort
|
||||
/ service_tier / provider_routing would be silently ignored here. Read-side
|
||||
only — the raw user config is what gets cached and saved. Fail-open.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
return managed_scope.apply_managed_overlay(cfg if isinstance(cfg, dict) else {})
|
||||
except Exception:
|
||||
return cfg
|
||||
|
||||
|
||||
def _save_cfg(cfg: dict):
|
||||
global _cfg_cache, _cfg_mtime, _cfg_path
|
||||
import yaml
|
||||
|
||||
@@ -59,6 +59,12 @@ Settings are resolved in this order (highest priority first):
|
||||
Secrets (API keys, bot tokens, passwords) go in `.env`. Everything else (model, terminal backend, compression settings, memory limits, toolsets) goes in `config.yaml`. When both are set, `config.yaml` wins for non-secret settings.
|
||||
:::
|
||||
|
||||
:::tip Org deployments
|
||||
An administrator can pin specific config and secret values that a standard user
|
||||
cannot override, via a system-level managed directory. See
|
||||
[Managed Scope](/user-guide/managed-scope).
|
||||
:::
|
||||
|
||||
## Environment Variable Substitution
|
||||
|
||||
You can reference environment variables in `config.yaml` using `${VAR_NAME}` syntax:
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
title: "Managed Scope"
|
||||
description: "Administrator-pinned, user-immutable config and secrets via a system-level managed directory"
|
||||
---
|
||||
|
||||
# Managed Scope
|
||||
|
||||
**Managed scope** lets an administrator push a baseline of configuration and
|
||||
secrets that a standard (non-root) user **cannot override**. It is intended for
|
||||
fleet/org deployments where IT needs to pin, for example, the model provider, a
|
||||
shared API base URL, or `security.redact_secrets: true` across every user on a
|
||||
machine.
|
||||
|
||||
When a managed scope is present, the values it specifies win over the user's
|
||||
`~/.hermes/config.yaml`, `~/.hermes/.env`, and even the shell environment — for
|
||||
exactly the keys it pins. Everything else stays fully user-controlled.
|
||||
|
||||
:::note Different from a package-manager–locked install
|
||||
A package-manager–managed install (declarative-distro / formula) blocks *all*
|
||||
config mutation and tells you to use your package manager. Managed scope is a
|
||||
separate mechanism: it injects *specific immutable values* on a per-key basis
|
||||
rather than locking the whole config. The two are independent and can coexist.
|
||||
:::
|
||||
|
||||
## Where it lives
|
||||
|
||||
Managed scope is read from a system-level directory, default `/etc/hermes`:
|
||||
|
||||
```text
|
||||
/etc/hermes/
|
||||
├── config.yaml # managed config layer (wins over ~/.hermes/config.yaml)
|
||||
└── .env # managed env layer (wins over ~/.hermes/.env + shell)
|
||||
```
|
||||
|
||||
The directory and files are owned by `root` (directory mode `0755`, files
|
||||
`0644`): readable by everyone, writable only by an administrator. **That
|
||||
filesystem permission is the enforcement mechanism** — a standard user can read
|
||||
the managed files but cannot edit them.
|
||||
|
||||
Either file is optional. A missing managed directory or missing file simply
|
||||
means "no managed scope," and configuration resolves exactly as it does without
|
||||
the feature.
|
||||
|
||||
### Relocating the directory
|
||||
|
||||
The location can be relocated with the `HERMES_MANAGED_DIR` environment variable
|
||||
(for containers or non-`/etc` deployments). This is a deployment/bootstrap path
|
||||
knob — like `HERMES_HOME` — set by the same administrator who owns the managed
|
||||
files. It is **never persisted** to any `.env` by Hermes.
|
||||
|
||||
```bash
|
||||
# Point managed scope at a custom directory (set by IT / the deployment, not the user)
|
||||
export HERMES_MANAGED_DIR=/opt/org/hermes-policy
|
||||
```
|
||||
|
||||
:::warning
|
||||
A user who can set `HERMES_MANAGED_DIR` can repoint managed scope at a directory
|
||||
they control, defeating it. In a real deployment this variable should be fixed
|
||||
by the administrator (e.g. baked into the service unit / container image), not
|
||||
left user-settable. `hermes doctor` reports the *resolved* managed directory so
|
||||
a redirect is visible.
|
||||
:::
|
||||
|
||||
## Precedence
|
||||
|
||||
For the keys a managed layer specifies, the order is (highest wins):
|
||||
|
||||
| Tier | config.yaml | .env |
|
||||
|---|---|---|
|
||||
| 1 | `/etc/hermes/config.yaml` (managed) | `/etc/hermes/.env` (managed) |
|
||||
| 2 | `~/.hermes/config.yaml` (user) | `~/.hermes/.env` (user) |
|
||||
| 3 | built-in defaults | pre-existing shell environment |
|
||||
|
||||
Merging is **leaf-level**: pinning `model.default` does not freeze the rest of
|
||||
`model.*`. A managed `config.yaml` of:
|
||||
|
||||
```yaml
|
||||
model:
|
||||
default: org/standard-model
|
||||
```
|
||||
|
||||
forces `model.default` for every user while leaving `model.fallback` (and every
|
||||
other key) under user control.
|
||||
|
||||
:::note Precedence note
|
||||
For the keys it pins, managed scope deliberately wins over the shell environment
|
||||
too — otherwise it would not be "managed." This is the one place that inverts the
|
||||
usual "an environment variable overrides config.yaml" rule, and it applies only
|
||||
to the specific keys the managed layer specifies.
|
||||
:::
|
||||
|
||||
## Seeing what's managed
|
||||
|
||||
```bash
|
||||
hermes config # shows a header naming the managed source + the pinned keys
|
||||
hermes doctor # reports the resolved managed dir + pinned key counts
|
||||
```
|
||||
|
||||
If you try to change a managed value, Hermes refuses and names the source:
|
||||
|
||||
```bash
|
||||
$ hermes config set model.default my/model
|
||||
Cannot set 'model.default': it is managed by your administrator
|
||||
(/etc/hermes/config.yaml) and cannot be changed.
|
||||
```
|
||||
|
||||
The same applies to managed secrets — `hermes config set` / setup will not write
|
||||
a user value for an env key pinned by the managed `.env`.
|
||||
|
||||
## Setting up a managed scope (administrators)
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/hermes
|
||||
|
||||
# Pin some config values for every user on this machine
|
||||
sudo tee /etc/hermes/config.yaml >/dev/null <<'YAML'
|
||||
model:
|
||||
provider: nous
|
||||
security:
|
||||
redact_secrets: true
|
||||
YAML
|
||||
|
||||
# Optionally pin a shared, non-sensitive env value
|
||||
sudo tee /etc/hermes/.env >/dev/null <<'ENV'
|
||||
OPENAI_API_BASE=https://inference.example.com/v1
|
||||
ENV
|
||||
|
||||
sudo chmod 0755 /etc/hermes
|
||||
sudo chmod 0644 /etc/hermes/config.yaml /etc/hermes/.env
|
||||
```
|
||||
|
||||
Changes take effect on the next Hermes start (a malformed managed file is logged
|
||||
loudly and ignored — it never blocks startup, but the admin should check
|
||||
`hermes doctor` to confirm the policy is being applied).
|
||||
|
||||
## Security model and limitations (v1)
|
||||
|
||||
- **Enforcement is filesystem permissions only.** If a user has write access to
|
||||
the managed directory (or runs Hermes as `root`), managed scope is advisory.
|
||||
- **The managed `.env` is world-readable** (`0644`), so any local user can read
|
||||
secrets pushed through it. Use it for shared, non-sensitive values (an org API
|
||||
base URL, feature defaults) rather than high-sensitivity secrets.
|
||||
- **The agent's own tools are not hard-blocked from a managed *env* value.** A
|
||||
managed environment variable is applied at startup, but nothing stops the
|
||||
agent from setting a different value inside its own subprocess shell. v1 is a
|
||||
management-convenience boundary against a normal user, not an un-escapable
|
||||
sandbox.
|
||||
|
||||
The following are intentionally **out of scope for v1** and may come later:
|
||||
|
||||
- A hard boundary that the agent itself cannot escape.
|
||||
- Native managed locations on macOS and Windows (v1 is Linux/POSIX-first).
|
||||
- Drop-in fragment directories (`managed.d/`) for layered policy.
|
||||
- Signed / integrity-checked managed files.
|
||||
- Remote / device-management (MDM) delivery.
|
||||
- Tighter (group-scoped) permissions for managed secrets.
|
||||
@@ -27,6 +27,7 @@ const sidebars: SidebarsConfig = {
|
||||
'user-guide/windows-native',
|
||||
'user-guide/windows-wsl-quickstart',
|
||||
'user-guide/configuration',
|
||||
'user-guide/managed-scope',
|
||||
'user-guide/configuring-models',
|
||||
{
|
||||
type: 'category',
|
||||
|
||||
Reference in New Issue
Block a user