Compare commits

...

8 Commits

Author SHA1 Message Date
Ben
6723e85f70 Merge remote-tracking branch 'origin/main' into hermes/hermes-11bc708e 2026-06-18 14:40:10 +10:00
Ben
649f360d74 docs: add managed scope admin guide + cross-link from configuration 2026-06-18 14:20:06 +10:00
Ben
0c36f29050 feat(managed-scope): surface managed scope in config show and doctor
- show_config prints an administrator header naming the managed source and
  lists the pinned config/env keys when a scope is active (silent otherwise).
- hermes doctor gains a managed_scope_check under Configuration Files that
  reports the resolved managed dir + pinned key counts, and flags a
  HERMES_MANAGED_DIR redirect (the documented foot-gun).
2026-06-18 14:17:16 +10:00
Ben
130cc28903 feat(managed-scope): guard writes to managed config/env keys
- set_config_value hard-rejects a managed config key (D2) and names the
  source, exiting non-zero.
- save_env_value / remove_env_value refuse a managed env key.
- save_config strips managed leaves from a bulk write (mechanical safety net)
  with a warning, so the unmanaged remainder still persists.
New _strip_dotted_keys helper drives the bulk-save pruning. All guards are
distinct from and layered after the existing is_managed() package-manager
write-lock.
2026-06-18 14:11:19 +10:00
Ben
05c7d14e77 feat(managed-scope): apply managed .env last with override
load_hermes_dotenv now loads the managed-scope .env after user/project .env
and external secret sources, with override=True, so managed env values beat
the user .env and any pre-existing shell export. Reuses the existing dotenv
fallback + credential-sanitization path. Fail-open: no managed dir/.env is a
no-op and any error is swallowed so managed scope never blocks startup.
2026-06-18 14:08:51 +10:00
Ben
78be65cb1e feat(managed-scope): managed config layer wins over user config
_load_config_impl now deep-merges the managed config.yaml on top of the
expanded user config so managed leaves win while sibling keys stay
user-controlled (leaf-level merge, D3). Managed values are expanded against
the process env only, never user-defined ${VAR}, so a user can't shadow a
managed literal. The managed file's (mtime,size) is folded into the load
cache key so editing it invalidates the cache. This inverts the usual
env-over-config precedence for pinned keys by design (see design doc §4.1).
2026-06-18 14:07:37 +10:00
Ben
2becd0440a feat(managed-scope): add managed_scope module (resolver, loaders, key helpers)
New hermes_cli/managed_scope.py resolves a system-level managed directory
(HERMES_MANAGED_DIR override > /etc/hermes), parses managed config.yaml/.env
with fail-open semantics, and exposes is_key_managed/is_env_managed helpers.
The system default is ignored under pytest and HERMES_MANAGED_DIR is added to
the conftest env scrub so a real managed scope can't leak into the suite.

Not wired into the load paths yet (Phases 2-3).
2026-06-18 14:02:31 +10:00
Ben
9415dacb19 test(config): pin config/env load behavior before managed scope 2026-06-18 14:01:08 +10:00
14 changed files with 1135 additions and 11 deletions

View File

@ -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',

View File

@ -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():

View File

@ -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
View 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()

View File

@ -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",

View 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

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

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

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

View 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() == ""

View 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()

View File

@ -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:

View 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-managerlocked install
A package-managermanaged 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.

View File

@ -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',