Compare commits
8 Commits
main
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6723e85f70 | ||
|
|
649f360d74 | ||
|
|
0c36f29050 | ||
|
|
130cc28903 | ||
|
|
05c7d14e77 | ||
|
|
78be65cb1e | ||
|
|
2becd0440a | ||
|
|
9415dacb19 |
@ -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():
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
171
hermes_cli/managed_scope.py
Normal file
171
hermes_cli/managed_scope.py
Normal file
@ -0,0 +1,171 @@
|
||||
"""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 _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()
|
||||
@ -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",
|
||||
|
||||
145
tests/hermes_cli/test_managed_scope.py
Normal file
145
tests/hermes_cli/test_managed_scope.py
Normal file
@ -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
|
||||
97
tests/hermes_cli/test_managed_scope_config.py
Normal file
97
tests/hermes_cli/test_managed_scope_config.py
Normal file
@ -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"
|
||||
58
tests/hermes_cli/test_managed_scope_env.py
Normal file
58
tests/hermes_cli/test_managed_scope_env.py
Normal file
@ -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"
|
||||
99
tests/hermes_cli/test_managed_scope_regression.py
Normal file
99
tests/hermes_cli/test_managed_scope_regression.py
Normal file
@ -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"
|
||||
73
tests/hermes_cli/test_managed_scope_surfacing.py
Normal file
73
tests/hermes_cli/test_managed_scope_surfacing.py
Normal file
@ -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() == ""
|
||||
110
tests/hermes_cli/test_managed_scope_writeguard.py
Normal file
110
tests/hermes_cli/test_managed_scope_writeguard.py
Normal file
@ -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()
|
||||
@ -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:
|
||||
|
||||
157
website/docs/user-guide/managed-scope.md
Normal file
157
website/docs/user-guide/managed-scope.md
Normal file
@ -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',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user