Compare commits

...
Author SHA1 Message Date
Ben 5e6a0cff10 fix(managed-scope): honor managed scope in config→env bridges too
Manual verification surfaced a second bypass class beyond the standalone
config loaders: several code paths bridge config.yaml values into os.environ
(HERMES_TIMEZONE, HERMES_REDACT_SECRETS, HERMES_MAX_ITERATIONS, TERMINAL_*,
network.force_ipv4, ...) by reading the raw user YAML, so the env the whole
process reads carried the USER's value even when an administrator pinned it —
e.g. a managed timezone was overridden because gateway/run.py wrote the user's
timezone into HERMES_TIMEZONE, and _resolve_timezone_name() checks the env var
first.

Wired the shared apply_managed_overlay() into every config→env bridge:

- gateway/run.py module-level startup bridge (timezone, redact_secrets,
  max_turns, terminal, display, gateway.strict, ...)
- gateway/run.py _reload_runtime_env_preserving_config_authority (the per-turn
  re-bridge that keeps config authoritative over reloaded .env — must keep
  MANAGED authoritative on every turn, not just startup)
- hermes_cli/main.py early security.redact_secrets / network.force_ipv4 bridge
  (runs before load_config is usable, at import time)
- hermes_cli/send_cmd.py top-level scalar config→env bridge

Verified end-to-end against a writable managed dir (12/12 checks incl. timezone,
logging, model, skin, gateway settings, write-guard) and in a clean process the
gateway per-turn bridge writes HERMES_TIMEZONE=<managed>. Adds an
order-independent regression test for the bridge overlay.
2026-06-19 15:35:21 +10:00
Ben 871a7afca4 fix(managed-scope): honor managed scope in all standalone config loaders
The skin bug was one instance of a class: several subsystems build their
config dict directly from config.yaml instead of routing through
hermes_cli.config.load_config (which carries the managed merge), so they
silently ignored administrator-pinned values. Audited every config.yaml
reader and fixed the behavioral-read bypasses:

- gateway/config.py load_gateway_config (messaging gateway: session_reset,
  quick_commands, stt, model, ...)
- gateway/run.py _load_gateway_config (its read_raw_config fast path also
  skipped the merge — read_raw_config returns raw user YAML)
- tui_gateway/server.py _load_cfg (new TUI + desktop backend: skin,
  reasoning_effort, service_tier, provider_routing)
- cron/scheduler.py (scheduled-job model/reasoning/toolsets/provider_routing)
- hermes_logging.py (logging.level/max_size_mb/backup_count)
- hermes_time.py (timezone)
- hermes_cli/doctor.py (memory-provider diagnostic reads effective config)

All route through a new shared managed_scope.apply_managed_overlay() helper
that mirrors _load_config_impl (env-only expansion so a user ${VAR} can't
shadow a managed literal, root-model-string normalization, leaf-merge) and is
fail-open. cli.py's earlier inline fix is refactored onto the same helper.

Write-back paths (slash_commands, telegram/yuanbao dm_topics, profile
distribution) are deliberately left reading raw user YAML — overlaying managed
values there would persist them into the user file. The dashboard
(web_server.py) already routes through load_config and needed no change.

TUI loader caches the RAW config so _save_cfg never writes managed values to
disk. Adds test_managed_scope_overlay.py (helper) and
test_managed_scope_loaders.py (per-surface integration); mutation-checked.
2026-06-19 13:01:48 +10:00
Ben 12d930266b fix(managed-scope): apply managed layer in cli.py's standalone config loader
cli.py's load_cli_config() builds CLI_CONFIG independently of
hermes_cli.config._load_config_impl (it reads config.yaml directly and merges
into hardcoded defaults), so the Phase 2 managed merge never reached the
interactive CLI/TUI surface. Symptom: a managed display.skin (and any other
display/CLI pref read from CLI_CONFIG) was silently ignored by the TUI while
`hermes config`/`doctor`/write-guards — which go through load_config — correctly
honored it. Found via manual testing: the skin engine kept using 'default'.

Fix: overlay the managed config last in load_cli_config(), mirroring
_load_config_impl — expand against the process env only (so a user ${VAR} can't
shadow a managed literal), normalize the root model key so a managed
`model: x/y` string can't clobber the dict shape callers expect, then
leaf-merge. Fail-open so managed scope can never block CLI startup.

Adds tests/hermes_cli/test_managed_scope_cli_config.py locking that CLI_CONFIG
honors managed values, preserves user siblings, and is inert with no scope.
2026-06-19 11:45:31 +10:00
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
26 changed files with 1605 additions and 20 deletions
+12
View File
@@ -562,6 +562,18 @@ def load_cli_config() -> Dict[str, Any]:
from hermes_cli.config import _expand_env_vars
defaults = _expand_env_vars(defaults)
# Managed scope: overlay administrator-pinned values LAST so they win over
# the user's config here too. cli.py builds its config independently of
# hermes_cli.config._load_config_impl (which has its own managed merge), so
# without this the entire interactive CLI/TUI surface — skin, display prefs,
# etc. read from CLI_CONFIG — would silently ignore managed scope while
# `hermes config`/`doctor`/guards (which use load_config) honor it. The
# shared helper mirrors _load_config_impl (env-only expansion, root-model
# normalization, leaf-merge) and is fail-open.
from hermes_cli import managed_scope
defaults = managed_scope.apply_managed_overlay(defaults)
# Apply terminal config to environment variables (so terminal_tool picks them up)
terminal_config = defaults.get("terminal", {})
+9
View File
@@ -1587,6 +1587,15 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
if os.path.exists(_cfg_path):
with open(_cfg_path, encoding="utf-8") as _f:
_cfg = yaml.safe_load(_f) or {}
# Managed scope: a scheduled job must honor administrator-pinned
# model / reasoning / toolsets / provider_routing too. This loader
# builds its own dict, so overlay managed values via the shared
# helper (fail-open, no-op when no managed scope).
try:
from hermes_cli import managed_scope
_cfg = managed_scope.apply_managed_overlay(_cfg)
except Exception:
pass
_cfg = _expand_env_vars(_cfg)
_model_cfg = _cfg.get("model", {})
if not job.get("model"):
+8
View File
@@ -796,6 +796,14 @@ def load_gateway_config() -> GatewayConfig:
with open(config_yaml_path, encoding="utf-8") as f:
yaml_cfg = yaml.safe_load(f) or {}
# Managed scope: overlay administrator-pinned values so the gateway
# honors them too. This loader builds its own dict instead of going
# through hermes_cli.config.load_config, so without this a managed
# session_reset / quick_commands / stt / model would be ignored by
# the messaging gateway. Fail-open via the shared helper.
from hermes_cli import managed_scope
yaml_cfg = managed_scope.apply_managed_overlay(yaml_cfg)
# Map config.yaml keys → GatewayConfig.from_dict() schema.
# Each key overwrites whatever gateway.json may have set.
sr = yaml_cfg.get("session_reset")
+46 -7
View File
@@ -1188,6 +1188,15 @@ def _reload_runtime_env_preserving_config_authority() -> None:
cfg = _yaml.safe_load(f) or {}
from hermes_cli.config import _expand_env_vars
cfg = _expand_env_vars(cfg)
# Managed scope: keep administrator-pinned values authoritative on every
# turn too. This per-turn reload re-bridges config→env, so without the
# overlay a managed agent.max_turns / timezone / redact_secrets would be
# replaced by the user's value after the first turn. Fail-open.
try:
from hermes_cli import managed_scope
cfg = managed_scope.apply_managed_overlay(cfg)
except Exception:
pass
except Exception:
return
@@ -1210,6 +1219,17 @@ if _config_path.exists():
# Expand ${ENV_VAR} references before bridging to env vars.
from hermes_cli.config import _expand_env_vars
_cfg = _expand_env_vars(_cfg)
# Managed scope: overlay administrator-pinned values BEFORE bridging to
# env vars, so a managed timezone / redact_secrets / max_turns / terminal
# setting wins over the user's value at the env layer too. This bridge
# reads config.yaml directly (not via load_config), so without the
# overlay every HERMES_*/TERMINAL_* env var below would carry the user's
# value even when an administrator pinned it. Fail-open via the helper.
try:
from hermes_cli import managed_scope
_cfg = managed_scope.apply_managed_overlay(_cfg)
except Exception:
pass
# Top-level simple values (fallback only — don't override .env)
for _key, _val in _cfg.items():
if isinstance(_val, (str, int, float, bool)) and _key not in os.environ:
@@ -1880,8 +1900,14 @@ def _load_gateway_config() -> dict:
Uses the module-level ``_hermes_home`` (so tests that monkeypatch it
still see their fixture) and shares the mtime-keyed raw-yaml cache
from ``hermes_cli.config.read_raw_config`` when the paths match.
Managed scope is overlaid on the result (via the shared helper) so the
gateway honors administrator-pinned values — neither read_raw_config nor a
direct yaml.safe_load carries the managed merge on its own. Fail-open.
"""
config_path = _hermes_home / 'config.yaml'
raw: dict = {}
used_canonical = False
try:
from hermes_cli.config import get_config_path, read_raw_config
# Fast path: if _hermes_home agrees with the canonical config
@@ -1889,18 +1915,31 @@ def _load_gateway_config() -> dict:
# direct read (keeps test fixtures with a monkeypatched
# _hermes_home working).
if config_path == get_config_path():
return read_raw_config()
raw = read_raw_config()
used_canonical = True
except Exception:
pass
if not used_canonical:
try:
if config_path.exists():
import yaml
with open(config_path, 'r', encoding='utf-8') as f:
raw = yaml.safe_load(f) or {}
except Exception:
logger.debug("Could not load gateway config from %s", config_path)
raw = {}
# Overlay managed scope. read_raw_config() returns the user's raw YAML
# WITHOUT the managed merge (that lives in load_config/_load_config_impl),
# so the overlay is required on both paths for the gateway to honor pinned
# values. Helper is fail-open and a no-op when no managed scope exists.
try:
if config_path.exists():
import yaml
with open(config_path, 'r', encoding='utf-8') as f:
return yaml.safe_load(f) or {}
from hermes_cli import managed_scope
raw = managed_scope.apply_managed_overlay(raw if isinstance(raw, dict) else {})
except Exception:
logger.debug("Could not load gateway config from %s", config_path)
return {}
pass
return raw if isinstance(raw, dict) else {}
def _load_gateway_runtime_config() -> dict:
+157 -11
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',
+32
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():
@@ -2152,6 +2179,11 @@ def run_doctor(args):
if _mem_cfg_path.exists():
with open(_mem_cfg_path, encoding="utf-8") as _f:
_raw_cfg = _yaml.safe_load(_f) or {}
try:
from hermes_cli import managed_scope
_raw_cfg = managed_scope.apply_managed_overlay(_raw_cfg)
except Exception:
pass
_active_memory_provider = (_raw_cfg.get("memory") or {}).get("provider", "")
except Exception:
pass
+33
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.
+10
View File
@@ -531,6 +531,16 @@ try:
if _cfg_path.exists():
with open(_cfg_path, encoding="utf-8") as _f:
_early_cfg_raw = _yaml_early.safe_load(_f) or {}
# Managed scope: overlay administrator-pinned values so a managed
# security.redact_secrets / network.force_ipv4 wins here too. This early
# bridge reads config.yaml directly (before load_config is usable), so
# without the overlay a managed redact_secrets toggle would be ignored.
# Fail-open via the shared helper.
try:
from hermes_cli import managed_scope
_early_cfg_raw = managed_scope.apply_managed_overlay(_early_cfg_raw)
except Exception:
pass
if "HERMES_REDACT_SECRETS" not in os.environ:
_early_sec_cfg = _early_cfg_raw.get("security", {})
if isinstance(_early_sec_cfg, dict):
+214
View File
@@ -0,0 +1,214 @@
"""Managed scope — IT-pushed, user-immutable config & env layer.
A system-level directory (default ``/etc/hermes``, root-owned and not
user-writable) supplies ``config.yaml`` and ``.env`` values that WIN over the
user's ``~/.hermes/config.yaml`` and ``~/.hermes/.env`` on a per-leaf-key basis.
This is DISTINCT from ``hermes_cli.config.is_managed()`` / ``HERMES_MANAGED``,
which is a coarse package-manager write-lock (declarative-distro / formula
installs). That lock blocks all mutation; this layer injects specific immutable
values. The two are independent and may coexist.
v1 enforcement is filesystem permissions only — see
``docs/design/managed-scope.md`` §7. v1 is Linux/POSIX-first; ``get_managed_dir()``
is the single seam for adding macOS / Windows native locations later.
Attribution: do not reference any third-party product by name in this file.
"""
from __future__ import annotations
import copy
import logging
import os
import threading
from pathlib import Path
from typing import Dict, Optional
import yaml
logger = logging.getLogger(__name__)
# POSIX default. Other-platform locations are a deliberate v2 item; when added,
# they belong ONLY inside get_managed_dir().
_DEFAULT_MANAGED_DIR = Path("/etc/hermes")
_CACHE_LOCK = threading.Lock()
# path_key -> (mtime_ns, size, parsed)
_CONFIG_CACHE: Dict[str, tuple] = {}
_ENV_CACHE: Dict[str, tuple] = {}
def _under_pytest() -> bool:
"""True when running inside the test suite.
Used to ignore the system default ``/etc/hermes`` during tests so a real
managed scope on a developer/CI box can't leak policy into the suite. Tests
that exercise managed scope set ``HERMES_MANAGED_DIR`` explicitly, which is
still honored (the override path below runs before this guard takes effect).
"""
return "PYTEST_CURRENT_TEST" in os.environ
def get_managed_dir() -> Optional[Path]:
"""Resolve the managed-scope directory, or None when no scope is present.
Resolution (highest priority first):
1. ``$HERMES_MANAGED_DIR`` — deployment/bootstrap path override (IT-only;
never persisted to any .env). Honored only when set to a non-empty value
AND the directory exists.
2. ``/etc/hermes`` — POSIX default, when it exists. Ignored under pytest so
a real system managed scope can't leak into the test suite.
A non-existent directory at either tier resolves to None (no managed scope),
which is the common case and must be cheap + side-effect-free.
"""
override = os.environ.get("HERMES_MANAGED_DIR", "").strip()
if override:
p = Path(override)
return p if p.is_dir() else None
if _under_pytest():
return None
return _DEFAULT_MANAGED_DIR if _DEFAULT_MANAGED_DIR.is_dir() else None
def invalidate_managed_cache() -> None:
"""Drop cached managed config/env. For tests and post-edit reloads."""
with _CACHE_LOCK:
_CONFIG_CACHE.clear()
_ENV_CACHE.clear()
def _cached_read(path: Path, cache: Dict[str, tuple], parse):
"""Shared (mtime_ns, size)-keyed read. Returns a deepcopy of the parsed value.
Returns ``None`` when the file is absent or fails to parse (fail-open). A
parse failure is logged LOUDLY — the admin needs to know their policy isn't
being applied — but never raises, so a malformed managed file can't brick
startup.
"""
try:
st = path.stat()
except OSError:
return None # absent
key = (st.st_mtime_ns, st.st_size)
path_key = str(path)
with _CACHE_LOCK:
hit = cache.get(path_key)
if hit is not None and hit[:2] == key:
return copy.deepcopy(hit[2])
try:
with open(path, encoding="utf-8") as f:
parsed = parse(f)
except Exception as exc: # noqa: BLE001 — fail-open, but LOUD
logger.warning(
"managed scope: failed to parse %s: %s — IGNORING this managed file. "
"Admin policy from this file is NOT being applied. Fix and restart.",
path,
exc,
)
return None
with _CACHE_LOCK:
cache[path_key] = (key[0], key[1], copy.deepcopy(parsed))
return parsed
def load_managed_config() -> dict:
"""Parsed managed config.yaml, or {} when absent/malformed (fail-open)."""
managed_dir = get_managed_dir()
if managed_dir is None:
return {}
parsed = _cached_read(
managed_dir / "config.yaml",
_CONFIG_CACHE,
lambda f: yaml.safe_load(f) or {},
)
return parsed if isinstance(parsed, dict) else {}
def load_managed_env() -> Dict[str, str]:
"""Parsed managed .env (KEY=VALUE), or {} when absent (fail-open)."""
managed_dir = get_managed_dir()
if managed_dir is None:
return {}
parsed = _cached_read(managed_dir / ".env", _ENV_CACHE, _parse_env)
return parsed if isinstance(parsed, dict) else {}
def apply_managed_overlay(config: dict) -> dict:
"""Overlay administrator-pinned config values on top of an already-built dict.
The single, shared way for any config loader that builds its own dict
(rather than going through hermes_cli.config.load_config) to honor managed
scope. Mirrors hermes_cli.config._load_config_impl's managed merge exactly:
* expand the managed config's ``${VAR}`` refs against the PROCESS env only
(never user-config-defined refs), so a user cannot shadow a managed
literal via a ${VAR} they control;
* normalize the managed config's root ``model`` key (a bare ``model: x/y``
string is promoted to ``model.default``) so it can't clobber the dict
shape callers expect;
* leaf-level deep-merge managed ON TOP, so managed wins per-leaf while
sibling keys stay user-controlled.
Fail-open: returns ``config`` unchanged if no managed scope is present or on
any error — managed scope must never break a caller's startup. Mutates and
returns ``config`` (callers pass a dict they own).
"""
try:
managed = load_managed_config()
if not managed:
return config
# Imported lazily to avoid an import cycle (config imports managed_scope).
from hermes_cli.config import _deep_merge, _expand_env_vars, _normalize_root_model_keys
managed_expanded = _normalize_root_model_keys(_expand_env_vars(managed))
# A bare ``model: x/y`` string in the managed file must merge as
# ``model.default`` — otherwise _deep_merge would replace the caller's
# ``model`` dict with a string and break every ``cfg["model"]["..."]``
# read. _normalize_root_model_keys only promotes the string when there
# are root provider/base_url keys to migrate, so handle the bare case
# here (matches cli.py's own string-model handling).
if isinstance(managed_expanded.get("model"), str):
managed_expanded = dict(managed_expanded)
managed_expanded["model"] = {"default": managed_expanded["model"]}
return _deep_merge(config, managed_expanded)
except Exception: # noqa: BLE001 — overlay must never break a caller
logger.warning("managed scope: failed to apply config overlay", exc_info=True)
return config
def _parse_env(f) -> Dict[str, str]:
out: Dict[str, str] = {}
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
out[key.strip()] = value.strip().strip("\"'")
return out
def _flatten_keys(d: dict, prefix: str = "") -> set:
keys: set = set()
for k, v in d.items():
dotted = f"{prefix}.{k}" if prefix else str(k)
if isinstance(v, dict) and v:
keys |= _flatten_keys(v, dotted)
else:
keys.add(dotted)
return keys
def managed_config_keys() -> set:
"""Dotted leaf keys pinned by the managed config (e.g. {'model.default'})."""
return _flatten_keys(load_managed_config())
def is_key_managed(dotted_key: str) -> bool:
"""True if the exact dotted config key is pinned by the managed layer."""
return dotted_key in managed_config_keys()
def is_env_managed(name: str) -> bool:
"""True if the env var name is pinned by the managed .env layer."""
return name in load_managed_env()
+8
View File
@@ -276,6 +276,14 @@ def _load_hermes_env() -> None:
except Exception:
pass
# Managed scope: overlay administrator-pinned values before bridging to env,
# so a managed top-level scalar wins here too. Fail-open via the helper.
try:
from hermes_cli import managed_scope
raw = managed_scope.apply_managed_overlay(raw if isinstance(raw, dict) else {})
except Exception:
pass
if not isinstance(raw, dict):
return
+7
View File
@@ -553,6 +553,13 @@ def _read_logging_config():
if config_path.exists():
with open(config_path, "r", encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}
# Managed scope: an administrator can pin logging.* too. Overlay via
# the shared helper (fail-open) since this reads config.yaml directly.
try:
from hermes_cli import managed_scope
cfg = managed_scope.apply_managed_overlay(cfg)
except Exception:
pass
log_cfg = cfg.get("logging", {})
if isinstance(log_cfg, dict):
return (
+7
View File
@@ -52,6 +52,13 @@ def _resolve_timezone_name() -> str:
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}
# Managed scope: an administrator can pin ``timezone`` too. Overlay
# via the shared helper (fail-open) since this reads config.yaml directly.
try:
from hermes_cli import managed_scope
cfg = managed_scope.apply_managed_overlay(cfg)
except Exception:
pass
tz_cfg = cfg.get("timezone", "")
if isinstance(tz_cfg, str) and tz_cfg.strip():
return tz_cfg.strip()
+1
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",
+145
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
@@ -0,0 +1,82 @@
"""Managed scope must reach cli.py's independent config loader (CLI_CONFIG).
cli.py's load_cli_config() builds config separately from
hermes_cli.config._load_config_impl, so the managed-scope merge has to be
applied in BOTH places or the interactive CLI/TUI surface (skin, display prefs)
silently ignores administrator-pinned values while `hermes config`/`doctor`
honor them. This locks the cli.py path.
"""
import importlib
import pytest
@pytest.fixture
def homes(tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
managed = tmp_path / "managed"
managed.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed))
import hermes_cli.config as cfg
from hermes_cli import managed_scope
cfg._LOAD_CONFIG_CACHE.clear()
cfg._RAW_CONFIG_CACHE.clear()
managed_scope.invalidate_managed_cache()
return home, managed
def _load_cli_config(home):
"""Call cli.py's standalone loader fresh.
cli.py binds ``_hermes_home = get_hermes_home()`` at import time (module
singleton), so monkeypatching HERMES_HOME after import doesn't move it.
Point the module's cached home at the test's home for the duration of the
call. (In real use cli is imported once per process with the real home, so
this only matters for tests that swap HERMES_HOME.)
"""
import cli
cli._hermes_home = home
return cli.load_cli_config()
def test_cli_config_honors_managed_skin(homes):
"""A managed display.skin must reach CLI_CONFIG (the TUI's source)."""
home, managed = homes
(home / "config.yaml").write_text("display:\n skin: user_skin\n", encoding="utf-8")
(managed / "config.yaml").write_text("display:\n skin: charizard\n", encoding="utf-8")
from hermes_cli import managed_scope
managed_scope.invalidate_managed_cache()
cfg = _load_cli_config(home)
assert (cfg.get("display") or {}).get("skin") == "charizard"
def test_cli_config_managed_leaf_preserves_user_siblings(homes):
"""Managed display.skin must not wipe a user's other display.* prefs."""
home, managed = homes
(home / "config.yaml").write_text(
"display:\n skin: user_skin\n show_reasoning: true\n", encoding="utf-8"
)
(managed / "config.yaml").write_text("display:\n skin: charizard\n", encoding="utf-8")
from hermes_cli import managed_scope
managed_scope.invalidate_managed_cache()
cfg = _load_cli_config(home)
display = cfg.get("display") or {}
assert display.get("skin") == "charizard" # managed wins
assert display.get("show_reasoning") is True # user sibling preserved
def test_cli_config_no_managed_scope_uses_user_value(homes):
"""With no managed config, CLI_CONFIG reflects the user's value."""
home, managed = homes # managed dir exists but empty
(home / "config.yaml").write_text("display:\n skin: user_skin\n", encoding="utf-8")
from hermes_cli import managed_scope
managed_scope.invalidate_managed_cache()
cfg = _load_cli_config(home)
assert (cfg.get("display") or {}).get("skin") == "user_skin"
@@ -0,0 +1,97 @@
"""Config integration tests — managed scope wins over user config at the leaf."""
import textwrap
import pytest
@pytest.fixture
def homes(tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
managed = tmp_path / "managed"
managed.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed))
import hermes_cli.config as cfg
from hermes_cli import managed_scope
cfg._LOAD_CONFIG_CACHE.clear()
cfg._RAW_CONFIG_CACHE.clear()
managed_scope.invalidate_managed_cache()
return home, managed
def _write(path, body):
path.write_text(textwrap.dedent(body), encoding="utf-8")
import hermes_cli.config as cfg
from hermes_cli import managed_scope
cfg._LOAD_CONFIG_CACHE.clear()
cfg._RAW_CONFIG_CACHE.clear()
managed_scope.invalidate_managed_cache()
def test_managed_beats_user(homes):
from hermes_cli.config import load_config, cfg_get
home, managed = homes
_write(home / "config.yaml", "model:\n default: user/model\n")
_write(managed / "config.yaml", "model:\n default: managed/model\n")
assert cfg_get(load_config(), "model", "default") == "managed/model"
def test_managed_leaf_does_not_freeze_siblings(homes):
"""D3/Q4: pinning model.default leaves model.fallback user-controlled."""
from hermes_cli.config import load_config, cfg_get
home, managed = homes
_write(home / "config.yaml", "model:\n default: user/model\n fallback: user/fb\n")
_write(managed / "config.yaml", "model:\n default: managed/model\n")
cfg = load_config()
assert cfg_get(cfg, "model", "default") == "managed/model"
assert cfg_get(cfg, "model", "fallback") == "user/fb" # sibling preserved
def test_no_managed_config_is_unchanged(homes):
from hermes_cli.config import load_config, cfg_get
home, _ = homes
_write(home / "config.yaml", "model:\n default: user/model\n")
assert cfg_get(load_config(), "model", "default") == "user/model"
def test_managed_list_wins_wholesale(homes):
"""D3: a managed list value replaces the user's wholesale."""
from hermes_cli.config import load_config, cfg_get
home, managed = homes
_write(home / "config.yaml", "toolsets:\n enabled: [a, b, c]\n")
_write(managed / "config.yaml", "toolsets:\n enabled: [x]\n")
assert cfg_get(load_config(), "toolsets", "enabled") == ["x"]
def test_editing_managed_file_invalidates_cache(homes):
from hermes_cli.config import load_config, cfg_get
home, managed = homes
_write(home / "config.yaml", "model:\n default: user/model\n")
_write(managed / "config.yaml", "model:\n default: managed/v1\n")
assert cfg_get(load_config(), "model", "default") == "managed/v1"
_write(managed / "config.yaml", "model:\n default: managed/v2\n")
assert cfg_get(load_config(), "model", "default") == "managed/v2"
def test_user_cannot_shadow_managed_literal_via_envref(homes, monkeypatch):
"""A managed literal must NOT be expandable via a ${VAR} the user controls.
The managed value is a plain literal 'managed/locked' with no ${...}, so a
user-defined env var has nothing to substitute. This asserts the managed
literal survives verbatim regardless of user env, and that managed wins.
"""
from hermes_cli.config import load_config, cfg_get
home, managed = homes
monkeypatch.setenv("EVIL", "user/override")
_write(home / "config.yaml", "model:\n default: ${EVIL}\n")
_write(managed / "config.yaml", "model:\n default: managed/locked\n")
assert cfg_get(load_config(), "model", "default") == "managed/locked"
@@ -0,0 +1,58 @@
"""Env integration tests — managed .env applied last with override."""
import os
import pytest
@pytest.fixture
def env_homes(tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
managed = tmp_path / "managed"
managed.mkdir()
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed))
from hermes_cli import managed_scope
managed_scope.invalidate_managed_cache()
return home, managed
def test_managed_env_beats_user_env(env_homes, monkeypatch):
from hermes_cli.env_loader import load_hermes_dotenv
home, managed = env_homes
(home / ".env").write_text("OPENAI_API_BASE=https://user.example/v1\n", encoding="utf-8")
(managed / ".env").write_text("OPENAI_API_BASE=https://org.example/v1\n", encoding="utf-8")
load_hermes_dotenv(hermes_home=str(home))
assert os.environ["OPENAI_API_BASE"] == "https://org.example/v1"
def test_managed_env_beats_shell(env_homes, monkeypatch):
from hermes_cli.env_loader import load_hermes_dotenv
home, managed = env_homes
monkeypatch.setenv("OPENAI_API_BASE", "https://shell.example/v1")
(managed / ".env").write_text("OPENAI_API_BASE=https://org.example/v1\n", encoding="utf-8")
load_hermes_dotenv(hermes_home=str(home))
assert os.environ["OPENAI_API_BASE"] == "https://org.example/v1"
def test_managed_env_leaves_unmanaged_keys_alone(env_homes, monkeypatch):
from hermes_cli.env_loader import load_hermes_dotenv
home, managed = env_homes
(home / ".env").write_text("USER_ONLY=keepme\n", encoding="utf-8")
(managed / ".env").write_text("OPENAI_API_BASE=https://org.example/v1\n", encoding="utf-8")
load_hermes_dotenv(hermes_home=str(home))
assert os.environ["USER_ONLY"] == "keepme"
assert os.environ["OPENAI_API_BASE"] == "https://org.example/v1"
def test_no_managed_env_is_noop(env_homes, monkeypatch):
from hermes_cli.env_loader import load_hermes_dotenv
home, managed = env_homes # managed dir exists but has no .env
monkeypatch.setenv("SOME_VALUE", "from_shell")
(home / ".env").write_text("SOME_VALUE=from_user\n", encoding="utf-8")
load_hermes_dotenv(hermes_home=str(home))
assert os.environ["SOME_VALUE"] == "from_user"
@@ -0,0 +1,142 @@
"""Each standalone config loader (gateway, TUI/desktop, cron) must honor managed scope.
These loaders build their own config dict instead of routing through
hermes_cli.config.load_config, so the managed overlay has to be wired into each.
This is the regression guard for the whole bug class (a managed display.skin was
silently ignored by the TUI; the same gap existed in the gateway and cron).
"""
import textwrap
import pytest
@pytest.fixture
def homes(tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
managed = tmp_path / "managed"
managed.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed))
import hermes_cli.config as cfg
from hermes_cli import managed_scope
cfg._LOAD_CONFIG_CACHE.clear()
cfg._RAW_CONFIG_CACHE.clear()
managed_scope.invalidate_managed_cache()
return home, managed
def _seed(home, managed, *, user, mgd):
(home / "config.yaml").write_text(textwrap.dedent(user), encoding="utf-8")
(managed / "config.yaml").write_text(textwrap.dedent(mgd), encoding="utf-8")
import hermes_cli.config as cfg
from hermes_cli import managed_scope
cfg._LOAD_CONFIG_CACHE.clear()
cfg._RAW_CONFIG_CACHE.clear()
managed_scope.invalidate_managed_cache()
def test_gateway_run_loader_honors_managed(homes, monkeypatch):
home, managed = homes
_seed(home, managed, user="model:\n default: user/m\n", mgd="model:\n default: org/m\n")
import gateway.run as gr
monkeypatch.setattr(gr, "_hermes_home", home, raising=False)
cfg = gr._load_gateway_config()
assert (cfg.get("model") or {}).get("default") == "org/m"
def test_gateway_config_loader_honors_managed(homes, monkeypatch):
home, managed = homes
_seed(
home,
managed,
user="group_sessions_per_user: false\n",
mgd="group_sessions_per_user: true\n",
)
import gateway.config as gc
# load_gateway_config resolves home via get_hermes_home() (HERMES_HOME env).
cfg = gc.load_gateway_config()
# Managed value should have flowed into the GatewayConfig.
assert cfg.group_sessions_per_user is True
def test_tui_loader_honors_managed(homes, monkeypatch):
home, managed = homes
_seed(home, managed, user="display:\n skin: user\n", mgd="display:\n skin: charizard\n")
import tui_gateway.server as ts
monkeypatch.setattr(ts, "_hermes_home", home, raising=False)
monkeypatch.setattr(ts, "_cfg_cache", None, raising=False)
monkeypatch.setattr(ts, "_cfg_mtime", None, raising=False)
monkeypatch.setattr(ts, "get_hermes_home_override", lambda: None, raising=False)
cfg = ts._load_cfg()
assert (cfg.get("display") or {}).get("skin") == "charizard"
def test_tui_loader_does_not_persist_managed_back(homes, monkeypatch):
"""The TUI caches RAW config so _save_cfg never writes managed values to disk."""
home, managed = homes
_seed(home, managed, user="display:\n skin: user\n", mgd="display:\n skin: charizard\n")
import tui_gateway.server as ts
monkeypatch.setattr(ts, "_hermes_home", home, raising=False)
monkeypatch.setattr(ts, "_cfg_cache", None, raising=False)
monkeypatch.setattr(ts, "_cfg_mtime", None, raising=False)
monkeypatch.setattr(ts, "get_hermes_home_override", lambda: None, raising=False)
ts._load_cfg() # populates the cache
# The cache must hold the RAW user value, not the managed overlay, so a
# subsequent _save_cfg can't bake the managed skin into the user file.
assert (ts._cfg_cache.get("display") or {}).get("skin") == "user"
def test_logging_config_honors_managed(homes, monkeypatch):
home, managed = homes
_seed(home, managed, user="logging:\n level: INFO\n", mgd="logging:\n level: DEBUG\n")
import hermes_logging
level, _max, _bk = hermes_logging._read_logging_config()
assert level == "DEBUG"
def test_timezone_honors_managed(homes, monkeypatch):
home, managed = homes
# hermes_time checks an env override first; ensure it's unset so config wins.
monkeypatch.delenv("HERMES_TIMEZONE", raising=False)
monkeypatch.delenv("TZ", raising=False)
_seed(home, managed, user="timezone: America/New_York\n", mgd="timezone: Asia/Tokyo\n")
import hermes_time
assert hermes_time._resolve_timezone_name() == "Asia/Tokyo"
def test_gateway_env_bridge_honors_managed(homes, monkeypatch):
"""The gateway config→env bridge must bridge MANAGED values, not user ones.
gateway/run.py bridges config.yaml settings into os.environ at startup and on
every turn (HERMES_TIMEZONE, HERMES_REDACT_SECRETS, HERMES_MAX_ITERATIONS,
...). A managed value must win at that env layer too — otherwise the bridge
writes the user's value into the env that the whole process then reads. This
is the regression that manual verification caught (managed timezone was
overridden by the user's value via the env bridge).
We assert on the managed-overlaid config the bridge consumes (rather than the
os.environ side effect, which leaks across same-process tests under the
runner) — the bridge writes whatever this dict carries, so a managed value
here proves the env var gets the managed value.
"""
home, managed = homes
_seed(home, managed, user="timezone: America/New_York\n", mgd="timezone: Asia/Tokyo\n")
from hermes_cli import managed_scope
managed_scope.invalidate_managed_cache()
# The bridge loads config.yaml, expands env, then applies this overlay before
# writing HERMES_TIMEZONE = cfg["timezone"]. Prove the overlay flips the value.
import yaml
raw = yaml.safe_load((home / "config.yaml").read_text())
bridged = managed_scope.apply_managed_overlay(raw)
assert bridged.get("timezone") == "Asia/Tokyo"
@@ -0,0 +1,69 @@
"""apply_managed_overlay() — the shared helper used by every standalone loader."""
import textwrap
import pytest
@pytest.fixture
def managed(tmp_path, monkeypatch):
md = tmp_path / "managed"
md.mkdir()
monkeypatch.setenv("HERMES_MANAGED_DIR", str(md))
from hermes_cli import managed_scope
managed_scope.invalidate_managed_cache()
return md
def _write(md, body):
(md / "config.yaml").write_text(textwrap.dedent(body), encoding="utf-8")
from hermes_cli import managed_scope
managed_scope.invalidate_managed_cache()
def test_overlay_noop_without_scope(tmp_path, monkeypatch):
from hermes_cli import managed_scope
monkeypatch.setenv("HERMES_MANAGED_DIR", str(tmp_path / "nope"))
managed_scope.invalidate_managed_cache()
src = {"display": {"skin": "user"}}
assert managed_scope.apply_managed_overlay(src) == {"display": {"skin": "user"}}
def test_overlay_managed_wins(managed):
from hermes_cli import managed_scope
_write(managed, "display:\n skin: charizard\n")
out = managed_scope.apply_managed_overlay({"display": {"skin": "user"}})
assert out["display"]["skin"] == "charizard"
def test_overlay_preserves_user_siblings(managed):
from hermes_cli import managed_scope
_write(managed, "display:\n skin: charizard\n")
out = managed_scope.apply_managed_overlay(
{"display": {"skin": "user", "show_reasoning": True}}
)
assert out["display"]["skin"] == "charizard"
assert out["display"]["show_reasoning"] is True
def test_overlay_normalizes_root_model_string(managed):
"""A managed bare `model: x/y` must promote to model.default, not clobber the dict."""
from hermes_cli import managed_scope
_write(managed, "model: org/locked\n")
out = managed_scope.apply_managed_overlay({"model": {"default": "user/m", "fallback": "u/fb"}})
assert out["model"]["default"] == "org/locked" # managed wins
assert out["model"]["fallback"] == "u/fb" # user sibling preserved (dict shape intact)
def test_overlay_user_envref_cannot_shadow_managed_literal(managed, monkeypatch):
from hermes_cli import managed_scope
monkeypatch.setenv("EVIL", "user/override")
_write(managed, "model:\n default: managed/locked\n")
out = managed_scope.apply_managed_overlay({"model": {"default": "${EVIL}"}})
assert out["model"]["default"] == "managed/locked"
@@ -0,0 +1,99 @@
"""Regression harness — pins config/env load behavior BEFORE managed scope exists.
Every test here must keep passing through all later phases when NO managed scope
is present. They are the 'managed scope is invisible when absent' contract.
"""
import os
import textwrap
import pytest
@pytest.fixture
def hermes_home(tmp_path, monkeypatch):
home = tmp_path / "hermes_home"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
# No managed dir: point the override at a guaranteed-absent path so a real
# /etc/hermes on the dev/CI box can't influence the test.
monkeypatch.setenv("HERMES_MANAGED_DIR", str(tmp_path / "no_such_managed_dir"))
# Clear caches so each test re-reads from disk.
import hermes_cli.config as cfg
cfg._LOAD_CONFIG_CACHE.clear()
cfg._RAW_CONFIG_CACHE.clear()
cfg.invalidate_env_cache()
return home
def _write_user_config(home, body: str):
(home / "config.yaml").write_text(textwrap.dedent(body), encoding="utf-8")
import hermes_cli.config as cfg
cfg._LOAD_CONFIG_CACHE.clear()
cfg._RAW_CONFIG_CACHE.clear()
def test_user_config_overrides_default(hermes_home, monkeypatch):
from hermes_cli.config import load_config, cfg_get
_write_user_config(
hermes_home,
"""
model:
default: user/model-x
""",
)
cfg = load_config()
assert cfg_get(cfg, "model", "default") == "user/model-x"
def test_env_expansion_in_user_config(hermes_home, monkeypatch):
from hermes_cli.config import load_config, cfg_get
monkeypatch.setenv("MY_BASE", "https://example.test")
_write_user_config(
hermes_home,
"""
providers:
custom:
base_url: ${MY_BASE}/v1
""",
)
cfg = load_config()
assert cfg_get(cfg, "providers", "custom", "base_url") == "https://example.test/v1"
def test_no_managed_dir_means_user_value_wins(hermes_home):
"""Sanity: with the managed override pointing at an absent dir, nothing changes."""
from hermes_cli.config import load_config, cfg_get
_write_user_config(
hermes_home,
"""
model:
default: user/model-y
""",
)
assert cfg_get(load_config(), "model", "default") == "user/model-y"
def test_user_env_overrides_shell(tmp_path, monkeypatch):
from hermes_cli.env_loader import load_hermes_dotenv
home = tmp_path / "home"
home.mkdir()
(home / ".env").write_text("FOO_TOKEN=from_user_env\n", encoding="utf-8")
monkeypatch.setenv("FOO_TOKEN", "from_shell")
load_hermes_dotenv(hermes_home=str(home))
assert os.environ["FOO_TOKEN"] == "from_user_env"
def test_missing_user_env_is_noop(tmp_path, monkeypatch):
from hermes_cli.env_loader import load_hermes_dotenv
home = tmp_path / "home"
home.mkdir()
monkeypatch.setenv("BAR_TOKEN", "from_shell")
load_hermes_dotenv(hermes_home=str(home))
assert os.environ["BAR_TOKEN"] == "from_shell"
@@ -0,0 +1,73 @@
"""Surfacing tests — managed scope shown in `config show` and `hermes doctor`."""
import pytest
@pytest.fixture
def homes(tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
managed = tmp_path / "managed"
managed.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed))
(home / "config.yaml").write_text("model:\n default: user/model\n", encoding="utf-8")
(managed / "config.yaml").write_text(
"model:\n default: managed/model\n", encoding="utf-8"
)
import hermes_cli.config as cfg
from hermes_cli import managed_scope
cfg._LOAD_CONFIG_CACHE.clear()
cfg._RAW_CONFIG_CACHE.clear()
managed_scope.invalidate_managed_cache()
return home, managed
def test_config_show_flags_managed(homes, capsys):
from hermes_cli.config import show_config
show_config()
out = capsys.readouterr().out.lower()
assert "managed" in out # header + key list present
assert "model.default" in out # the pinned key is named
assert "managed/model" in out # effective (managed) value, not user/model
def test_config_show_no_managed_scope_silent(tmp_path, monkeypatch, capsys):
"""With no managed scope, the managed header must not appear."""
home = tmp_path / "home"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("HERMES_MANAGED_DIR", str(tmp_path / "nope"))
(home / "config.yaml").write_text("model:\n default: user/model\n", encoding="utf-8")
import hermes_cli.config as cfg
from hermes_cli import managed_scope
cfg._LOAD_CONFIG_CACHE.clear()
cfg._RAW_CONFIG_CACHE.clear()
managed_scope.invalidate_managed_cache()
from hermes_cli.config import show_config
show_config()
out = capsys.readouterr().out.lower()
assert "managed by your administrator" not in out
def test_doctor_reports_managed_scope(homes, capsys):
# homes fixture has 1 managed config key (model.default) and 0 managed env keys.
from hermes_cli import doctor
doctor.managed_scope_check()
out = capsys.readouterr().out.lower()
assert "managed scope active" in out
assert str(homes[1]).lower() in out # resolved dir reported
assert "1 config key" in out
def test_doctor_silent_with_no_managed_scope(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("HERMES_MANAGED_DIR", str(tmp_path / "nope"))
from hermes_cli import managed_scope, doctor
managed_scope.invalidate_managed_cache()
doctor.managed_scope_check()
assert capsys.readouterr().out.strip() == ""
@@ -0,0 +1,110 @@
"""Write-guard tests — managed keys can't be set/removed by the user."""
import pytest
@pytest.fixture
def homes(tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
managed = tmp_path / "managed"
managed.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed))
import hermes_cli.config as cfg
from hermes_cli import managed_scope
cfg._LOAD_CONFIG_CACHE.clear()
cfg._RAW_CONFIG_CACHE.clear()
managed_scope.invalidate_managed_cache()
(managed / "config.yaml").write_text(
"model:\n default: managed/model\n", encoding="utf-8"
)
managed_scope.invalidate_managed_cache()
return home, managed
def test_config_set_managed_key_rejected(homes, capsys):
from hermes_cli.config import set_config_value
with pytest.raises(SystemExit) as exc:
set_config_value("model.default", "user/override")
assert exc.value.code != 0
captured = capsys.readouterr()
assert "managed" in (captured.out + captured.err).lower()
def test_config_set_managed_key_does_not_write(homes):
from hermes_cli.config import set_config_value, read_raw_config
try:
set_config_value("model.default", "user/override")
except SystemExit:
pass
raw = read_raw_config()
assert raw.get("model", {}).get("default") != "user/override"
def test_config_set_unmanaged_key_still_works(homes):
from hermes_cli.config import set_config_value, read_raw_config
set_config_value("model.fallback", "user/fb") # not managed
assert read_raw_config().get("model", {}).get("fallback") == "user/fb"
# ── env write guards ─────────────────────────────────────────────────────────
@pytest.fixture
def env_homes(tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
managed = tmp_path / "managed"
managed.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed))
(managed / ".env").write_text(
"OPENAI_API_BASE=https://org.example/v1\n", encoding="utf-8"
)
from hermes_cli import managed_scope
managed_scope.invalidate_managed_cache()
return home, managed
def test_save_env_value_managed_key_rejected(env_homes, capsys):
from hermes_cli.config import save_env_value, get_env_path
save_env_value("OPENAI_API_BASE", "https://user.example/v1")
assert "managed" in capsys.readouterr().err.lower()
env_path = get_env_path()
body = env_path.read_text() if env_path.exists() else ""
assert "user.example" not in body
def test_remove_env_value_managed_key_rejected(env_homes, capsys):
from hermes_cli.config import remove_env_value
result = remove_env_value("OPENAI_API_BASE")
assert result is False
assert "managed" in capsys.readouterr().err.lower()
def test_save_env_value_unmanaged_key_still_works(env_homes):
from hermes_cli.config import save_env_value, get_env_value
save_env_value("SOME_OTHER_VALUE", "abc123")
assert get_env_value("SOME_OTHER_VALUE") == "abc123"
# ── bulk save strips managed leaves ──────────────────────────────────────────
def test_save_config_strips_managed_leaves(homes, capsys):
from hermes_cli.config import save_config, read_raw_config
# 'model.default' is managed (homes fixture); 'model.fallback' is not.
save_config({"model": {"default": "user/override", "fallback": "user/fb"}})
raw = read_raw_config()
assert raw.get("model", {}).get("default") != "user/override" # stripped
assert raw.get("model", {}).get("fallback") == "user/fb" # kept
assert "managed" in capsys.readouterr().err.lower()
+22 -2
View File
@@ -1312,22 +1312,42 @@ def _load_cfg() -> dict:
mtime = p.stat().st_mtime if p.exists() else None
with _cfg_lock:
if _cfg_cache is not None and _cfg_mtime == mtime and _cfg_path == p:
return copy.deepcopy(_cfg_cache)
return _apply_managed(copy.deepcopy(_cfg_cache))
if p.exists():
with open(p, encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
else:
data = {}
with _cfg_lock:
# Cache the RAW user config (no managed overlay) so _save_cfg, which
# writes _cfg_cache back to disk, never persists managed values into
# the user's file. The managed overlay is applied on every return
# path instead (read-side only).
_cfg_cache = copy.deepcopy(data)
_cfg_mtime = mtime
_cfg_path = p
return data
return _apply_managed(data)
except Exception:
pass
return {}
def _apply_managed(cfg: dict) -> dict:
"""Overlay administrator-pinned managed-scope values on a config dict.
The TUI/desktop backend builds config independently of
hermes_cli.config.load_config, so without this a managed skin / reasoning_effort
/ service_tier / provider_routing would be silently ignored here. Read-side
only — the raw user config is what gets cached and saved. Fail-open.
"""
try:
from hermes_cli import managed_scope
return managed_scope.apply_managed_overlay(cfg if isinstance(cfg, dict) else {})
except Exception:
return cfg
def _save_cfg(cfg: dict):
global _cfg_cache, _cfg_mtime, _cfg_path
import yaml
+6
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:
+157
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-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.
+1
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',