Compare commits
8 Commits
main
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6723e85f70 | ||
|
|
649f360d74 | ||
|
|
0c36f29050 | ||
|
|
130cc28903 | ||
|
|
05c7d14e77 | ||
|
|
78be65cb1e | ||
|
|
2becd0440a | ||
|
|
9415dacb19 |
@ -223,7 +223,10 @@ _LAST_EXPANDED_CONFIG_BY_PATH: Dict[str, Any] = {}
|
|||||||
# save_config() + migrate_config() write via atomic_yaml_write which
|
# save_config() + migrate_config() write via atomic_yaml_write which
|
||||||
# produces a fresh inode, so stat() sees a new mtime_ns and the next
|
# produces a fresh inode, so stat() sees a new mtime_ns and the next
|
||||||
# load repopulates automatically — no explicit invalidation hook.
|
# 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
|
# (path, mtime_ns, size) -> cached raw yaml dict. Same pattern as
|
||||||
# _LOAD_CONFIG_CACHE but for read_raw_config() — used when callers want
|
# _LOAD_CONFIG_CACHE but for read_raw_config() — used when callers want
|
||||||
# the user's on-disk values without defaults merged in.
|
# the user's on-disk values without defaults merged in.
|
||||||
@ -5168,6 +5171,29 @@ def _deep_merge(base: dict, override: dict) -> dict:
|
|||||||
return result
|
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):
|
def _expand_env_vars(obj):
|
||||||
"""Recursively expand ``${VAR}`` references in config values.
|
"""Recursively expand ``${VAR}`` references in config values.
|
||||||
|
|
||||||
@ -5534,17 +5560,44 @@ def _load_config_impl(*, want_deepcopy: bool) -> Dict[str, Any]:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
st = config_path.stat()
|
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:
|
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)
|
cached = _LOAD_CONFIG_CACHE.get(path_key)
|
||||||
if cached is not None and cache_key is not None and cached[:2] == cache_key:
|
if cached is not None and cache_sig is not None and cached[:4] == cache_sig:
|
||||||
return copy.deepcopy(cached[2]) if want_deepcopy else cached[2]
|
return copy.deepcopy(cached[4]) if want_deepcopy else cached[4]
|
||||||
|
|
||||||
config = copy.deepcopy(DEFAULT_CONFIG)
|
config = copy.deepcopy(DEFAULT_CONFIG)
|
||||||
|
|
||||||
if cache_key is not None:
|
if user_sig is not None:
|
||||||
try:
|
try:
|
||||||
with open(config_path, encoding="utf-8") as f:
|
with open(config_path, encoding="utf-8") as f:
|
||||||
user_config = yaml.safe_load(f) or {}
|
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))
|
normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))
|
||||||
expanded = _expand_env_vars(normalized)
|
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)
|
_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()``
|
# Cache stores a separate deepcopy so subsequent ``load_config()``
|
||||||
# (deepcopy=True) callers can mutate freely without affecting the
|
# (deepcopy=True) callers can mutate freely without affecting the
|
||||||
# cached value, and ``load_config_readonly()`` (deepcopy=False)
|
# 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)
|
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
|
# On the readonly path return the same cached object subsequent
|
||||||
# calls will see — keeps "two readonly calls return the same
|
# calls will see — keeps "two readonly calls return the same
|
||||||
# object" invariant that callers may rely on for identity checks.
|
# object" invariant that callers may rely on for identity checks.
|
||||||
@ -5666,6 +5729,22 @@ def save_config(config: Dict[str, Any]):
|
|||||||
if is_managed():
|
if is_managed():
|
||||||
managed_error("save configuration")
|
managed_error("save configuration")
|
||||||
return
|
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
|
from utils import atomic_yaml_write
|
||||||
|
|
||||||
ensure_hermes_home()
|
ensure_hermes_home()
|
||||||
@ -5932,6 +6011,19 @@ def save_env_value(key: str, value: str):
|
|||||||
if is_managed():
|
if is_managed():
|
||||||
managed_error(f"set {key}")
|
managed_error(f"set {key}")
|
||||||
return
|
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):
|
if not _ENV_VAR_NAME_RE.match(key):
|
||||||
raise ValueError(f"Invalid environment variable name: {key!r}")
|
raise ValueError(f"Invalid environment variable name: {key!r}")
|
||||||
_reject_denylisted_env_var(key)
|
_reject_denylisted_env_var(key)
|
||||||
@ -6009,6 +6101,18 @@ def remove_env_value(key: str) -> bool:
|
|||||||
if is_managed():
|
if is_managed():
|
||||||
managed_error(f"remove {key}")
|
managed_error(f"remove {key}")
|
||||||
return False
|
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):
|
if not _ENV_VAR_NAME_RE.match(key):
|
||||||
raise ValueError(f"Invalid environment variable name: {key!r}")
|
raise ValueError(f"Invalid environment variable name: {key!r}")
|
||||||
env_path = get_env_path()
|
env_path = get_env_path()
|
||||||
@ -6143,12 +6247,38 @@ def redact_key(key: str) -> str:
|
|||||||
def show_config():
|
def show_config():
|
||||||
"""Display current configuration."""
|
"""Display current configuration."""
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN))
|
print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN))
|
||||||
print(color("│ ⚕ Hermes Configuration │", Colors.CYAN))
|
print(color("│ ⚕ Hermes Configuration │", Colors.CYAN))
|
||||||
print(color("└─────────────────────────────────────────────────────────┘", 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
|
# Paths
|
||||||
print()
|
print()
|
||||||
print(color("◆ Paths", Colors.CYAN, Colors.BOLD))
|
print(color("◆ Paths", Colors.CYAN, Colors.BOLD))
|
||||||
@ -6366,6 +6496,22 @@ def set_config_value(key: str, value: str):
|
|||||||
if is_managed():
|
if is_managed():
|
||||||
managed_error("set configuration values")
|
managed_error("set configuration values")
|
||||||
return
|
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)
|
# Check if it's an API key (goes to .env)
|
||||||
api_keys = [
|
api_keys = [
|
||||||
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY',
|
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY',
|
||||||
|
|||||||
@ -462,6 +462,31 @@ def _build_apikey_providers_list() -> list:
|
|||||||
return _static
|
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):
|
def run_doctor(args):
|
||||||
"""Run diagnostic checks."""
|
"""Run diagnostic checks."""
|
||||||
should_fix = getattr(args, 'fix', False)
|
should_fix = getattr(args, 'fix', False)
|
||||||
@ -642,6 +667,8 @@ def run_doctor(args):
|
|||||||
check_warn(name, "(optional, not installed)")
|
check_warn(name, "(optional, not installed)")
|
||||||
|
|
||||||
_section("Configuration Files")
|
_section("Configuration Files")
|
||||||
|
# Managed scope (administrator-pinned config/env), when present.
|
||||||
|
managed_scope_check()
|
||||||
# Check ~/.hermes/.env (primary location for user config)
|
# Check ~/.hermes/.env (primary location for user config)
|
||||||
env_path = HERMES_HOME / '.env'
|
env_path = HERMES_HOME / '.env'
|
||||||
if env_path.exists():
|
if env_path.exists():
|
||||||
|
|||||||
@ -243,10 +243,43 @@ def load_hermes_dotenv(
|
|||||||
loaded.append(project_env_path)
|
loaded.append(project_env_path)
|
||||||
|
|
||||||
_apply_external_secret_sources(home_path)
|
_apply_external_secret_sources(home_path)
|
||||||
|
_apply_managed_env()
|
||||||
|
|
||||||
return loaded
|
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:
|
def _apply_external_secret_sources(home_path: Path) -> None:
|
||||||
"""Pull secrets from external sources (currently Bitwarden) into env.
|
"""Pull secrets from external sources (currently Bitwarden) into env.
|
||||||
|
|
||||||
|
|||||||
171
hermes_cli/managed_scope.py
Normal file
171
hermes_cli/managed_scope.py
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
"""Managed scope — IT-pushed, user-immutable config & env layer.
|
||||||
|
|
||||||
|
A system-level directory (default ``/etc/hermes``, root-owned and not
|
||||||
|
user-writable) supplies ``config.yaml`` and ``.env`` values that WIN over the
|
||||||
|
user's ``~/.hermes/config.yaml`` and ``~/.hermes/.env`` on a per-leaf-key basis.
|
||||||
|
|
||||||
|
This is DISTINCT from ``hermes_cli.config.is_managed()`` / ``HERMES_MANAGED``,
|
||||||
|
which is a coarse package-manager write-lock (declarative-distro / formula
|
||||||
|
installs). That lock blocks all mutation; this layer injects specific immutable
|
||||||
|
values. The two are independent and may coexist.
|
||||||
|
|
||||||
|
v1 enforcement is filesystem permissions only — see
|
||||||
|
``docs/design/managed-scope.md`` §7. v1 is Linux/POSIX-first; ``get_managed_dir()``
|
||||||
|
is the single seam for adding macOS / Windows native locations later.
|
||||||
|
|
||||||
|
Attribution: do not reference any third-party product by name in this file.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# POSIX default. Other-platform locations are a deliberate v2 item; when added,
|
||||||
|
# they belong ONLY inside get_managed_dir().
|
||||||
|
_DEFAULT_MANAGED_DIR = Path("/etc/hermes")
|
||||||
|
|
||||||
|
_CACHE_LOCK = threading.Lock()
|
||||||
|
# path_key -> (mtime_ns, size, parsed)
|
||||||
|
_CONFIG_CACHE: Dict[str, tuple] = {}
|
||||||
|
_ENV_CACHE: Dict[str, tuple] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _under_pytest() -> bool:
|
||||||
|
"""True when running inside the test suite.
|
||||||
|
|
||||||
|
Used to ignore the system default ``/etc/hermes`` during tests so a real
|
||||||
|
managed scope on a developer/CI box can't leak policy into the suite. Tests
|
||||||
|
that exercise managed scope set ``HERMES_MANAGED_DIR`` explicitly, which is
|
||||||
|
still honored (the override path below runs before this guard takes effect).
|
||||||
|
"""
|
||||||
|
return "PYTEST_CURRENT_TEST" in os.environ
|
||||||
|
|
||||||
|
|
||||||
|
def get_managed_dir() -> Optional[Path]:
|
||||||
|
"""Resolve the managed-scope directory, or None when no scope is present.
|
||||||
|
|
||||||
|
Resolution (highest priority first):
|
||||||
|
1. ``$HERMES_MANAGED_DIR`` — deployment/bootstrap path override (IT-only;
|
||||||
|
never persisted to any .env). Honored only when set to a non-empty value
|
||||||
|
AND the directory exists.
|
||||||
|
2. ``/etc/hermes`` — POSIX default, when it exists. Ignored under pytest so
|
||||||
|
a real system managed scope can't leak into the test suite.
|
||||||
|
|
||||||
|
A non-existent directory at either tier resolves to None (no managed scope),
|
||||||
|
which is the common case and must be cheap + side-effect-free.
|
||||||
|
"""
|
||||||
|
override = os.environ.get("HERMES_MANAGED_DIR", "").strip()
|
||||||
|
if override:
|
||||||
|
p = Path(override)
|
||||||
|
return p if p.is_dir() else None
|
||||||
|
if _under_pytest():
|
||||||
|
return None
|
||||||
|
return _DEFAULT_MANAGED_DIR if _DEFAULT_MANAGED_DIR.is_dir() else None
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_managed_cache() -> None:
|
||||||
|
"""Drop cached managed config/env. For tests and post-edit reloads."""
|
||||||
|
with _CACHE_LOCK:
|
||||||
|
_CONFIG_CACHE.clear()
|
||||||
|
_ENV_CACHE.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _cached_read(path: Path, cache: Dict[str, tuple], parse):
|
||||||
|
"""Shared (mtime_ns, size)-keyed read. Returns a deepcopy of the parsed value.
|
||||||
|
|
||||||
|
Returns ``None`` when the file is absent or fails to parse (fail-open). A
|
||||||
|
parse failure is logged LOUDLY — the admin needs to know their policy isn't
|
||||||
|
being applied — but never raises, so a malformed managed file can't brick
|
||||||
|
startup.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
st = path.stat()
|
||||||
|
except OSError:
|
||||||
|
return None # absent
|
||||||
|
key = (st.st_mtime_ns, st.st_size)
|
||||||
|
path_key = str(path)
|
||||||
|
with _CACHE_LOCK:
|
||||||
|
hit = cache.get(path_key)
|
||||||
|
if hit is not None and hit[:2] == key:
|
||||||
|
return copy.deepcopy(hit[2])
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
parsed = parse(f)
|
||||||
|
except Exception as exc: # noqa: BLE001 — fail-open, but LOUD
|
||||||
|
logger.warning(
|
||||||
|
"managed scope: failed to parse %s: %s — IGNORING this managed file. "
|
||||||
|
"Admin policy from this file is NOT being applied. Fix and restart.",
|
||||||
|
path,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
with _CACHE_LOCK:
|
||||||
|
cache[path_key] = (key[0], key[1], copy.deepcopy(parsed))
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def load_managed_config() -> dict:
|
||||||
|
"""Parsed managed config.yaml, or {} when absent/malformed (fail-open)."""
|
||||||
|
managed_dir = get_managed_dir()
|
||||||
|
if managed_dir is None:
|
||||||
|
return {}
|
||||||
|
parsed = _cached_read(
|
||||||
|
managed_dir / "config.yaml",
|
||||||
|
_CONFIG_CACHE,
|
||||||
|
lambda f: yaml.safe_load(f) or {},
|
||||||
|
)
|
||||||
|
return parsed if isinstance(parsed, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def load_managed_env() -> Dict[str, str]:
|
||||||
|
"""Parsed managed .env (KEY=VALUE), or {} when absent (fail-open)."""
|
||||||
|
managed_dir = get_managed_dir()
|
||||||
|
if managed_dir is None:
|
||||||
|
return {}
|
||||||
|
parsed = _cached_read(managed_dir / ".env", _ENV_CACHE, _parse_env)
|
||||||
|
return parsed if isinstance(parsed, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_env(f) -> Dict[str, str]:
|
||||||
|
out: Dict[str, str] = {}
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
out[key.strip()] = value.strip().strip("\"'")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten_keys(d: dict, prefix: str = "") -> set:
|
||||||
|
keys: set = set()
|
||||||
|
for k, v in d.items():
|
||||||
|
dotted = f"{prefix}.{k}" if prefix else str(k)
|
||||||
|
if isinstance(v, dict) and v:
|
||||||
|
keys |= _flatten_keys(v, dotted)
|
||||||
|
else:
|
||||||
|
keys.add(dotted)
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
def managed_config_keys() -> set:
|
||||||
|
"""Dotted leaf keys pinned by the managed config (e.g. {'model.default'})."""
|
||||||
|
return _flatten_keys(load_managed_config())
|
||||||
|
|
||||||
|
|
||||||
|
def is_key_managed(dotted_key: str) -> bool:
|
||||||
|
"""True if the exact dotted config key is pinned by the managed layer."""
|
||||||
|
return dotted_key in managed_config_keys()
|
||||||
|
|
||||||
|
|
||||||
|
def is_env_managed(name: str) -> bool:
|
||||||
|
"""True if the env var name is pinned by the managed .env layer."""
|
||||||
|
return name in load_managed_env()
|
||||||
@ -190,6 +190,7 @@ _HERMES_BEHAVIORAL_VARS = frozenset({
|
|||||||
"HERMES_INFERENCE_PROVIDER",
|
"HERMES_INFERENCE_PROVIDER",
|
||||||
"HERMES_TUI_PROVIDER",
|
"HERMES_TUI_PROVIDER",
|
||||||
"HERMES_MANAGED",
|
"HERMES_MANAGED",
|
||||||
|
"HERMES_MANAGED_DIR",
|
||||||
"HERMES_DEV",
|
"HERMES_DEV",
|
||||||
"HERMES_CONTAINER",
|
"HERMES_CONTAINER",
|
||||||
"HERMES_EPHEMERAL_SYSTEM_PROMPT",
|
"HERMES_EPHEMERAL_SYSTEM_PROMPT",
|
||||||
|
|||||||
145
tests/hermes_cli/test_managed_scope.py
Normal file
145
tests/hermes_cli/test_managed_scope.py
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
"""Unit tests for hermes_cli.managed_scope (resolver + loaders + key helpers)."""
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# ── Directory resolver ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_managed_dir_env_override(tmp_path, monkeypatch):
|
||||||
|
from hermes_cli import managed_scope
|
||||||
|
|
||||||
|
managed = tmp_path / "managed"
|
||||||
|
managed.mkdir()
|
||||||
|
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed))
|
||||||
|
assert managed_scope.get_managed_dir() == managed
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_managed_dir_absent_override_returns_none(tmp_path, monkeypatch):
|
||||||
|
from hermes_cli import managed_scope
|
||||||
|
|
||||||
|
monkeypatch.setenv("HERMES_MANAGED_DIR", str(tmp_path / "nope"))
|
||||||
|
# Override points at a non-existent dir → no managed scope.
|
||||||
|
assert managed_scope.get_managed_dir() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_managed_dir_empty_override_falls_through(tmp_path, monkeypatch):
|
||||||
|
from hermes_cli import managed_scope
|
||||||
|
|
||||||
|
monkeypatch.setenv("HERMES_MANAGED_DIR", " ") # whitespace = unset
|
||||||
|
# Under pytest the /etc/hermes default is ignored, so this is None; the
|
||||||
|
# assertion that matters is that it does NOT raise.
|
||||||
|
result = managed_scope.get_managed_dir()
|
||||||
|
assert result is None or result.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_managed_dir_default_ignored_under_pytest(monkeypatch):
|
||||||
|
"""The system default must be inert in the test suite (isolation guard)."""
|
||||||
|
from hermes_cli import managed_scope
|
||||||
|
|
||||||
|
monkeypatch.delenv("HERMES_MANAGED_DIR", raising=False)
|
||||||
|
assert managed_scope.get_managed_dir() is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Loaders + key helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _write_managed(tmp_path, monkeypatch, *, config=None, env=None):
|
||||||
|
from hermes_cli import managed_scope
|
||||||
|
|
||||||
|
managed = tmp_path / "managed"
|
||||||
|
managed.mkdir(exist_ok=True)
|
||||||
|
if config is not None:
|
||||||
|
(managed / "config.yaml").write_text(textwrap.dedent(config), encoding="utf-8")
|
||||||
|
if env is not None:
|
||||||
|
(managed / ".env").write_text(textwrap.dedent(env), encoding="utf-8")
|
||||||
|
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed))
|
||||||
|
managed_scope.invalidate_managed_cache()
|
||||||
|
return managed
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_managed_config(tmp_path, monkeypatch):
|
||||||
|
from hermes_cli import managed_scope
|
||||||
|
|
||||||
|
_write_managed(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
config="""
|
||||||
|
model:
|
||||||
|
default: managed/model
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
assert managed_scope.load_managed_config() == {"model": {"default": "managed/model"}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_managed_config_absent_is_empty(tmp_path, monkeypatch):
|
||||||
|
from hermes_cli import managed_scope
|
||||||
|
|
||||||
|
monkeypatch.setenv("HERMES_MANAGED_DIR", str(tmp_path / "nope"))
|
||||||
|
managed_scope.invalidate_managed_cache()
|
||||||
|
assert managed_scope.load_managed_config() == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_managed_config_malformed_fails_open(tmp_path, monkeypatch):
|
||||||
|
from hermes_cli import managed_scope
|
||||||
|
|
||||||
|
_write_managed(tmp_path, monkeypatch, config="model: : : not yaml :")
|
||||||
|
assert managed_scope.load_managed_config() == {} # fail-open, no raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_managed_config_keys_are_dotted_leaves(tmp_path, monkeypatch):
|
||||||
|
from hermes_cli import managed_scope
|
||||||
|
|
||||||
|
_write_managed(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
config="""
|
||||||
|
model:
|
||||||
|
default: m
|
||||||
|
security:
|
||||||
|
redact_secrets: true
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
assert managed_scope.managed_config_keys() == {
|
||||||
|
"model.default",
|
||||||
|
"security.redact_secrets",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_key_managed(tmp_path, monkeypatch):
|
||||||
|
from hermes_cli import managed_scope
|
||||||
|
|
||||||
|
_write_managed(tmp_path, monkeypatch, config="model:\n default: m\n")
|
||||||
|
assert managed_scope.is_key_managed("model.default") is True
|
||||||
|
assert managed_scope.is_key_managed("model.fallback") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_managed_env_and_is_env_managed(tmp_path, monkeypatch):
|
||||||
|
from hermes_cli import managed_scope
|
||||||
|
|
||||||
|
_write_managed(
|
||||||
|
tmp_path, monkeypatch, env="OPENAI_API_BASE=https://org.example/v1\n"
|
||||||
|
)
|
||||||
|
assert managed_scope.load_managed_env() == {
|
||||||
|
"OPENAI_API_BASE": "https://org.example/v1"
|
||||||
|
}
|
||||||
|
assert managed_scope.is_env_managed("OPENAI_API_BASE") is True
|
||||||
|
assert managed_scope.is_env_managed("OTHER") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_editing_managed_config_invalidates_cache(tmp_path, monkeypatch):
|
||||||
|
from hermes_cli import managed_scope
|
||||||
|
|
||||||
|
managed = _write_managed(tmp_path, monkeypatch, config="model:\n default: v1\n")
|
||||||
|
assert managed_scope.load_managed_config()["model"]["default"] == "v1"
|
||||||
|
(managed / "config.yaml").write_text("model:\n default: v2\n", encoding="utf-8")
|
||||||
|
managed_scope.invalidate_managed_cache()
|
||||||
|
assert managed_scope.load_managed_config()["model"]["default"] == "v2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_managed_dir_env_scrubbed_by_default():
|
||||||
|
"""conftest must scrub HERMES_MANAGED_DIR so a dev-shell value can't leak in."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
assert "HERMES_MANAGED_DIR" not in os.environ
|
||||||
97
tests/hermes_cli/test_managed_scope_config.py
Normal file
97
tests/hermes_cli/test_managed_scope_config.py
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
"""Config integration tests — managed scope wins over user config at the leaf."""
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def homes(tmp_path, monkeypatch):
|
||||||
|
home = tmp_path / "home"
|
||||||
|
home.mkdir()
|
||||||
|
managed = tmp_path / "managed"
|
||||||
|
managed.mkdir()
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||||
|
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed))
|
||||||
|
import hermes_cli.config as cfg
|
||||||
|
from hermes_cli import managed_scope
|
||||||
|
|
||||||
|
cfg._LOAD_CONFIG_CACHE.clear()
|
||||||
|
cfg._RAW_CONFIG_CACHE.clear()
|
||||||
|
managed_scope.invalidate_managed_cache()
|
||||||
|
return home, managed
|
||||||
|
|
||||||
|
|
||||||
|
def _write(path, body):
|
||||||
|
path.write_text(textwrap.dedent(body), encoding="utf-8")
|
||||||
|
import hermes_cli.config as cfg
|
||||||
|
from hermes_cli import managed_scope
|
||||||
|
|
||||||
|
cfg._LOAD_CONFIG_CACHE.clear()
|
||||||
|
cfg._RAW_CONFIG_CACHE.clear()
|
||||||
|
managed_scope.invalidate_managed_cache()
|
||||||
|
|
||||||
|
|
||||||
|
def test_managed_beats_user(homes):
|
||||||
|
from hermes_cli.config import load_config, cfg_get
|
||||||
|
|
||||||
|
home, managed = homes
|
||||||
|
_write(home / "config.yaml", "model:\n default: user/model\n")
|
||||||
|
_write(managed / "config.yaml", "model:\n default: managed/model\n")
|
||||||
|
assert cfg_get(load_config(), "model", "default") == "managed/model"
|
||||||
|
|
||||||
|
|
||||||
|
def test_managed_leaf_does_not_freeze_siblings(homes):
|
||||||
|
"""D3/Q4: pinning model.default leaves model.fallback user-controlled."""
|
||||||
|
from hermes_cli.config import load_config, cfg_get
|
||||||
|
|
||||||
|
home, managed = homes
|
||||||
|
_write(home / "config.yaml", "model:\n default: user/model\n fallback: user/fb\n")
|
||||||
|
_write(managed / "config.yaml", "model:\n default: managed/model\n")
|
||||||
|
cfg = load_config()
|
||||||
|
assert cfg_get(cfg, "model", "default") == "managed/model"
|
||||||
|
assert cfg_get(cfg, "model", "fallback") == "user/fb" # sibling preserved
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_managed_config_is_unchanged(homes):
|
||||||
|
from hermes_cli.config import load_config, cfg_get
|
||||||
|
|
||||||
|
home, _ = homes
|
||||||
|
_write(home / "config.yaml", "model:\n default: user/model\n")
|
||||||
|
assert cfg_get(load_config(), "model", "default") == "user/model"
|
||||||
|
|
||||||
|
|
||||||
|
def test_managed_list_wins_wholesale(homes):
|
||||||
|
"""D3: a managed list value replaces the user's wholesale."""
|
||||||
|
from hermes_cli.config import load_config, cfg_get
|
||||||
|
|
||||||
|
home, managed = homes
|
||||||
|
_write(home / "config.yaml", "toolsets:\n enabled: [a, b, c]\n")
|
||||||
|
_write(managed / "config.yaml", "toolsets:\n enabled: [x]\n")
|
||||||
|
assert cfg_get(load_config(), "toolsets", "enabled") == ["x"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_editing_managed_file_invalidates_cache(homes):
|
||||||
|
from hermes_cli.config import load_config, cfg_get
|
||||||
|
|
||||||
|
home, managed = homes
|
||||||
|
_write(home / "config.yaml", "model:\n default: user/model\n")
|
||||||
|
_write(managed / "config.yaml", "model:\n default: managed/v1\n")
|
||||||
|
assert cfg_get(load_config(), "model", "default") == "managed/v1"
|
||||||
|
_write(managed / "config.yaml", "model:\n default: managed/v2\n")
|
||||||
|
assert cfg_get(load_config(), "model", "default") == "managed/v2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_cannot_shadow_managed_literal_via_envref(homes, monkeypatch):
|
||||||
|
"""A managed literal must NOT be expandable via a ${VAR} the user controls.
|
||||||
|
|
||||||
|
The managed value is a plain literal 'managed/locked' with no ${...}, so a
|
||||||
|
user-defined env var has nothing to substitute. This asserts the managed
|
||||||
|
literal survives verbatim regardless of user env, and that managed wins.
|
||||||
|
"""
|
||||||
|
from hermes_cli.config import load_config, cfg_get
|
||||||
|
|
||||||
|
home, managed = homes
|
||||||
|
monkeypatch.setenv("EVIL", "user/override")
|
||||||
|
_write(home / "config.yaml", "model:\n default: ${EVIL}\n")
|
||||||
|
_write(managed / "config.yaml", "model:\n default: managed/locked\n")
|
||||||
|
assert cfg_get(load_config(), "model", "default") == "managed/locked"
|
||||||
58
tests/hermes_cli/test_managed_scope_env.py
Normal file
58
tests/hermes_cli/test_managed_scope_env.py
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
"""Env integration tests — managed .env applied last with override."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def env_homes(tmp_path, monkeypatch):
|
||||||
|
home = tmp_path / "home"
|
||||||
|
home.mkdir()
|
||||||
|
managed = tmp_path / "managed"
|
||||||
|
managed.mkdir()
|
||||||
|
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed))
|
||||||
|
from hermes_cli import managed_scope
|
||||||
|
|
||||||
|
managed_scope.invalidate_managed_cache()
|
||||||
|
return home, managed
|
||||||
|
|
||||||
|
|
||||||
|
def test_managed_env_beats_user_env(env_homes, monkeypatch):
|
||||||
|
from hermes_cli.env_loader import load_hermes_dotenv
|
||||||
|
|
||||||
|
home, managed = env_homes
|
||||||
|
(home / ".env").write_text("OPENAI_API_BASE=https://user.example/v1\n", encoding="utf-8")
|
||||||
|
(managed / ".env").write_text("OPENAI_API_BASE=https://org.example/v1\n", encoding="utf-8")
|
||||||
|
load_hermes_dotenv(hermes_home=str(home))
|
||||||
|
assert os.environ["OPENAI_API_BASE"] == "https://org.example/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_managed_env_beats_shell(env_homes, monkeypatch):
|
||||||
|
from hermes_cli.env_loader import load_hermes_dotenv
|
||||||
|
|
||||||
|
home, managed = env_homes
|
||||||
|
monkeypatch.setenv("OPENAI_API_BASE", "https://shell.example/v1")
|
||||||
|
(managed / ".env").write_text("OPENAI_API_BASE=https://org.example/v1\n", encoding="utf-8")
|
||||||
|
load_hermes_dotenv(hermes_home=str(home))
|
||||||
|
assert os.environ["OPENAI_API_BASE"] == "https://org.example/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_managed_env_leaves_unmanaged_keys_alone(env_homes, monkeypatch):
|
||||||
|
from hermes_cli.env_loader import load_hermes_dotenv
|
||||||
|
|
||||||
|
home, managed = env_homes
|
||||||
|
(home / ".env").write_text("USER_ONLY=keepme\n", encoding="utf-8")
|
||||||
|
(managed / ".env").write_text("OPENAI_API_BASE=https://org.example/v1\n", encoding="utf-8")
|
||||||
|
load_hermes_dotenv(hermes_home=str(home))
|
||||||
|
assert os.environ["USER_ONLY"] == "keepme"
|
||||||
|
assert os.environ["OPENAI_API_BASE"] == "https://org.example/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_managed_env_is_noop(env_homes, monkeypatch):
|
||||||
|
from hermes_cli.env_loader import load_hermes_dotenv
|
||||||
|
|
||||||
|
home, managed = env_homes # managed dir exists but has no .env
|
||||||
|
monkeypatch.setenv("SOME_VALUE", "from_shell")
|
||||||
|
(home / ".env").write_text("SOME_VALUE=from_user\n", encoding="utf-8")
|
||||||
|
load_hermes_dotenv(hermes_home=str(home))
|
||||||
|
assert os.environ["SOME_VALUE"] == "from_user"
|
||||||
99
tests/hermes_cli/test_managed_scope_regression.py
Normal file
99
tests/hermes_cli/test_managed_scope_regression.py
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
"""Regression harness — pins config/env load behavior BEFORE managed scope exists.
|
||||||
|
|
||||||
|
Every test here must keep passing through all later phases when NO managed scope
|
||||||
|
is present. They are the 'managed scope is invisible when absent' contract.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def hermes_home(tmp_path, monkeypatch):
|
||||||
|
home = tmp_path / "hermes_home"
|
||||||
|
home.mkdir()
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||||
|
# No managed dir: point the override at a guaranteed-absent path so a real
|
||||||
|
# /etc/hermes on the dev/CI box can't influence the test.
|
||||||
|
monkeypatch.setenv("HERMES_MANAGED_DIR", str(tmp_path / "no_such_managed_dir"))
|
||||||
|
# Clear caches so each test re-reads from disk.
|
||||||
|
import hermes_cli.config as cfg
|
||||||
|
|
||||||
|
cfg._LOAD_CONFIG_CACHE.clear()
|
||||||
|
cfg._RAW_CONFIG_CACHE.clear()
|
||||||
|
cfg.invalidate_env_cache()
|
||||||
|
return home
|
||||||
|
|
||||||
|
|
||||||
|
def _write_user_config(home, body: str):
|
||||||
|
(home / "config.yaml").write_text(textwrap.dedent(body), encoding="utf-8")
|
||||||
|
import hermes_cli.config as cfg
|
||||||
|
|
||||||
|
cfg._LOAD_CONFIG_CACHE.clear()
|
||||||
|
cfg._RAW_CONFIG_CACHE.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_config_overrides_default(hermes_home, monkeypatch):
|
||||||
|
from hermes_cli.config import load_config, cfg_get
|
||||||
|
|
||||||
|
_write_user_config(
|
||||||
|
hermes_home,
|
||||||
|
"""
|
||||||
|
model:
|
||||||
|
default: user/model-x
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
cfg = load_config()
|
||||||
|
assert cfg_get(cfg, "model", "default") == "user/model-x"
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_expansion_in_user_config(hermes_home, monkeypatch):
|
||||||
|
from hermes_cli.config import load_config, cfg_get
|
||||||
|
|
||||||
|
monkeypatch.setenv("MY_BASE", "https://example.test")
|
||||||
|
_write_user_config(
|
||||||
|
hermes_home,
|
||||||
|
"""
|
||||||
|
providers:
|
||||||
|
custom:
|
||||||
|
base_url: ${MY_BASE}/v1
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
cfg = load_config()
|
||||||
|
assert cfg_get(cfg, "providers", "custom", "base_url") == "https://example.test/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_managed_dir_means_user_value_wins(hermes_home):
|
||||||
|
"""Sanity: with the managed override pointing at an absent dir, nothing changes."""
|
||||||
|
from hermes_cli.config import load_config, cfg_get
|
||||||
|
|
||||||
|
_write_user_config(
|
||||||
|
hermes_home,
|
||||||
|
"""
|
||||||
|
model:
|
||||||
|
default: user/model-y
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
assert cfg_get(load_config(), "model", "default") == "user/model-y"
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_env_overrides_shell(tmp_path, monkeypatch):
|
||||||
|
from hermes_cli.env_loader import load_hermes_dotenv
|
||||||
|
|
||||||
|
home = tmp_path / "home"
|
||||||
|
home.mkdir()
|
||||||
|
(home / ".env").write_text("FOO_TOKEN=from_user_env\n", encoding="utf-8")
|
||||||
|
monkeypatch.setenv("FOO_TOKEN", "from_shell")
|
||||||
|
load_hermes_dotenv(hermes_home=str(home))
|
||||||
|
assert os.environ["FOO_TOKEN"] == "from_user_env"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_user_env_is_noop(tmp_path, monkeypatch):
|
||||||
|
from hermes_cli.env_loader import load_hermes_dotenv
|
||||||
|
|
||||||
|
home = tmp_path / "home"
|
||||||
|
home.mkdir()
|
||||||
|
monkeypatch.setenv("BAR_TOKEN", "from_shell")
|
||||||
|
load_hermes_dotenv(hermes_home=str(home))
|
||||||
|
assert os.environ["BAR_TOKEN"] == "from_shell"
|
||||||
73
tests/hermes_cli/test_managed_scope_surfacing.py
Normal file
73
tests/hermes_cli/test_managed_scope_surfacing.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
"""Surfacing tests — managed scope shown in `config show` and `hermes doctor`."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def homes(tmp_path, monkeypatch):
|
||||||
|
home = tmp_path / "home"
|
||||||
|
home.mkdir()
|
||||||
|
managed = tmp_path / "managed"
|
||||||
|
managed.mkdir()
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||||
|
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed))
|
||||||
|
(home / "config.yaml").write_text("model:\n default: user/model\n", encoding="utf-8")
|
||||||
|
(managed / "config.yaml").write_text(
|
||||||
|
"model:\n default: managed/model\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
import hermes_cli.config as cfg
|
||||||
|
from hermes_cli import managed_scope
|
||||||
|
|
||||||
|
cfg._LOAD_CONFIG_CACHE.clear()
|
||||||
|
cfg._RAW_CONFIG_CACHE.clear()
|
||||||
|
managed_scope.invalidate_managed_cache()
|
||||||
|
return home, managed
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_show_flags_managed(homes, capsys):
|
||||||
|
from hermes_cli.config import show_config
|
||||||
|
|
||||||
|
show_config()
|
||||||
|
out = capsys.readouterr().out.lower()
|
||||||
|
assert "managed" in out # header + key list present
|
||||||
|
assert "model.default" in out # the pinned key is named
|
||||||
|
assert "managed/model" in out # effective (managed) value, not user/model
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_show_no_managed_scope_silent(tmp_path, monkeypatch, capsys):
|
||||||
|
"""With no managed scope, the managed header must not appear."""
|
||||||
|
home = tmp_path / "home"
|
||||||
|
home.mkdir()
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||||
|
monkeypatch.setenv("HERMES_MANAGED_DIR", str(tmp_path / "nope"))
|
||||||
|
(home / "config.yaml").write_text("model:\n default: user/model\n", encoding="utf-8")
|
||||||
|
import hermes_cli.config as cfg
|
||||||
|
from hermes_cli import managed_scope
|
||||||
|
|
||||||
|
cfg._LOAD_CONFIG_CACHE.clear()
|
||||||
|
cfg._RAW_CONFIG_CACHE.clear()
|
||||||
|
managed_scope.invalidate_managed_cache()
|
||||||
|
from hermes_cli.config import show_config
|
||||||
|
|
||||||
|
show_config()
|
||||||
|
out = capsys.readouterr().out.lower()
|
||||||
|
assert "managed by your administrator" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_doctor_reports_managed_scope(homes, capsys):
|
||||||
|
# homes fixture has 1 managed config key (model.default) and 0 managed env keys.
|
||||||
|
from hermes_cli import doctor
|
||||||
|
|
||||||
|
doctor.managed_scope_check()
|
||||||
|
out = capsys.readouterr().out.lower()
|
||||||
|
assert "managed scope active" in out
|
||||||
|
assert str(homes[1]).lower() in out # resolved dir reported
|
||||||
|
assert "1 config key" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_doctor_silent_with_no_managed_scope(tmp_path, monkeypatch, capsys):
|
||||||
|
monkeypatch.setenv("HERMES_MANAGED_DIR", str(tmp_path / "nope"))
|
||||||
|
from hermes_cli import managed_scope, doctor
|
||||||
|
|
||||||
|
managed_scope.invalidate_managed_cache()
|
||||||
|
doctor.managed_scope_check()
|
||||||
|
assert capsys.readouterr().out.strip() == ""
|
||||||
110
tests/hermes_cli/test_managed_scope_writeguard.py
Normal file
110
tests/hermes_cli/test_managed_scope_writeguard.py
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
"""Write-guard tests — managed keys can't be set/removed by the user."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def homes(tmp_path, monkeypatch):
|
||||||
|
home = tmp_path / "home"
|
||||||
|
home.mkdir()
|
||||||
|
managed = tmp_path / "managed"
|
||||||
|
managed.mkdir()
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||||
|
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed))
|
||||||
|
import hermes_cli.config as cfg
|
||||||
|
from hermes_cli import managed_scope
|
||||||
|
|
||||||
|
cfg._LOAD_CONFIG_CACHE.clear()
|
||||||
|
cfg._RAW_CONFIG_CACHE.clear()
|
||||||
|
managed_scope.invalidate_managed_cache()
|
||||||
|
(managed / "config.yaml").write_text(
|
||||||
|
"model:\n default: managed/model\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
managed_scope.invalidate_managed_cache()
|
||||||
|
return home, managed
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_set_managed_key_rejected(homes, capsys):
|
||||||
|
from hermes_cli.config import set_config_value
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit) as exc:
|
||||||
|
set_config_value("model.default", "user/override")
|
||||||
|
assert exc.value.code != 0
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "managed" in (captured.out + captured.err).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_set_managed_key_does_not_write(homes):
|
||||||
|
from hermes_cli.config import set_config_value, read_raw_config
|
||||||
|
|
||||||
|
try:
|
||||||
|
set_config_value("model.default", "user/override")
|
||||||
|
except SystemExit:
|
||||||
|
pass
|
||||||
|
raw = read_raw_config()
|
||||||
|
assert raw.get("model", {}).get("default") != "user/override"
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_set_unmanaged_key_still_works(homes):
|
||||||
|
from hermes_cli.config import set_config_value, read_raw_config
|
||||||
|
|
||||||
|
set_config_value("model.fallback", "user/fb") # not managed
|
||||||
|
assert read_raw_config().get("model", {}).get("fallback") == "user/fb"
|
||||||
|
|
||||||
|
|
||||||
|
# ── env write guards ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def env_homes(tmp_path, monkeypatch):
|
||||||
|
home = tmp_path / "home"
|
||||||
|
home.mkdir()
|
||||||
|
managed = tmp_path / "managed"
|
||||||
|
managed.mkdir()
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||||
|
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed))
|
||||||
|
(managed / ".env").write_text(
|
||||||
|
"OPENAI_API_BASE=https://org.example/v1\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
from hermes_cli import managed_scope
|
||||||
|
|
||||||
|
managed_scope.invalidate_managed_cache()
|
||||||
|
return home, managed
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_env_value_managed_key_rejected(env_homes, capsys):
|
||||||
|
from hermes_cli.config import save_env_value, get_env_path
|
||||||
|
|
||||||
|
save_env_value("OPENAI_API_BASE", "https://user.example/v1")
|
||||||
|
assert "managed" in capsys.readouterr().err.lower()
|
||||||
|
env_path = get_env_path()
|
||||||
|
body = env_path.read_text() if env_path.exists() else ""
|
||||||
|
assert "user.example" not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_env_value_managed_key_rejected(env_homes, capsys):
|
||||||
|
from hermes_cli.config import remove_env_value
|
||||||
|
|
||||||
|
result = remove_env_value("OPENAI_API_BASE")
|
||||||
|
assert result is False
|
||||||
|
assert "managed" in capsys.readouterr().err.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_env_value_unmanaged_key_still_works(env_homes):
|
||||||
|
from hermes_cli.config import save_env_value, get_env_value
|
||||||
|
|
||||||
|
save_env_value("SOME_OTHER_VALUE", "abc123")
|
||||||
|
assert get_env_value("SOME_OTHER_VALUE") == "abc123"
|
||||||
|
|
||||||
|
|
||||||
|
# ── bulk save strips managed leaves ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_config_strips_managed_leaves(homes, capsys):
|
||||||
|
from hermes_cli.config import save_config, read_raw_config
|
||||||
|
|
||||||
|
# 'model.default' is managed (homes fixture); 'model.fallback' is not.
|
||||||
|
save_config({"model": {"default": "user/override", "fallback": "user/fb"}})
|
||||||
|
raw = read_raw_config()
|
||||||
|
assert raw.get("model", {}).get("default") != "user/override" # stripped
|
||||||
|
assert raw.get("model", {}).get("fallback") == "user/fb" # kept
|
||||||
|
assert "managed" in capsys.readouterr().err.lower()
|
||||||
@ -59,6 +59,12 @@ Settings are resolved in this order (highest priority first):
|
|||||||
Secrets (API keys, bot tokens, passwords) go in `.env`. Everything else (model, terminal backend, compression settings, memory limits, toolsets) goes in `config.yaml`. When both are set, `config.yaml` wins for non-secret settings.
|
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
|
## Environment Variable Substitution
|
||||||
|
|
||||||
You can reference environment variables in `config.yaml` using `${VAR_NAME}` syntax:
|
You can reference environment variables in `config.yaml` using `${VAR_NAME}` syntax:
|
||||||
|
|||||||
157
website/docs/user-guide/managed-scope.md
Normal file
157
website/docs/user-guide/managed-scope.md
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
---
|
||||||
|
sidebar_position: 3
|
||||||
|
title: "Managed Scope"
|
||||||
|
description: "Administrator-pinned, user-immutable config and secrets via a system-level managed directory"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Managed Scope
|
||||||
|
|
||||||
|
**Managed scope** lets an administrator push a baseline of configuration and
|
||||||
|
secrets that a standard (non-root) user **cannot override**. It is intended for
|
||||||
|
fleet/org deployments where IT needs to pin, for example, the model provider, a
|
||||||
|
shared API base URL, or `security.redact_secrets: true` across every user on a
|
||||||
|
machine.
|
||||||
|
|
||||||
|
When a managed scope is present, the values it specifies win over the user's
|
||||||
|
`~/.hermes/config.yaml`, `~/.hermes/.env`, and even the shell environment — for
|
||||||
|
exactly the keys it pins. Everything else stays fully user-controlled.
|
||||||
|
|
||||||
|
:::note Different from a package-manager–locked install
|
||||||
|
A package-manager–managed install (declarative-distro / formula) blocks *all*
|
||||||
|
config mutation and tells you to use your package manager. Managed scope is a
|
||||||
|
separate mechanism: it injects *specific immutable values* on a per-key basis
|
||||||
|
rather than locking the whole config. The two are independent and can coexist.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Where it lives
|
||||||
|
|
||||||
|
Managed scope is read from a system-level directory, default `/etc/hermes`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/etc/hermes/
|
||||||
|
├── config.yaml # managed config layer (wins over ~/.hermes/config.yaml)
|
||||||
|
└── .env # managed env layer (wins over ~/.hermes/.env + shell)
|
||||||
|
```
|
||||||
|
|
||||||
|
The directory and files are owned by `root` (directory mode `0755`, files
|
||||||
|
`0644`): readable by everyone, writable only by an administrator. **That
|
||||||
|
filesystem permission is the enforcement mechanism** — a standard user can read
|
||||||
|
the managed files but cannot edit them.
|
||||||
|
|
||||||
|
Either file is optional. A missing managed directory or missing file simply
|
||||||
|
means "no managed scope," and configuration resolves exactly as it does without
|
||||||
|
the feature.
|
||||||
|
|
||||||
|
### Relocating the directory
|
||||||
|
|
||||||
|
The location can be relocated with the `HERMES_MANAGED_DIR` environment variable
|
||||||
|
(for containers or non-`/etc` deployments). This is a deployment/bootstrap path
|
||||||
|
knob — like `HERMES_HOME` — set by the same administrator who owns the managed
|
||||||
|
files. It is **never persisted** to any `.env` by Hermes.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Point managed scope at a custom directory (set by IT / the deployment, not the user)
|
||||||
|
export HERMES_MANAGED_DIR=/opt/org/hermes-policy
|
||||||
|
```
|
||||||
|
|
||||||
|
:::warning
|
||||||
|
A user who can set `HERMES_MANAGED_DIR` can repoint managed scope at a directory
|
||||||
|
they control, defeating it. In a real deployment this variable should be fixed
|
||||||
|
by the administrator (e.g. baked into the service unit / container image), not
|
||||||
|
left user-settable. `hermes doctor` reports the *resolved* managed directory so
|
||||||
|
a redirect is visible.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Precedence
|
||||||
|
|
||||||
|
For the keys a managed layer specifies, the order is (highest wins):
|
||||||
|
|
||||||
|
| Tier | config.yaml | .env |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `/etc/hermes/config.yaml` (managed) | `/etc/hermes/.env` (managed) |
|
||||||
|
| 2 | `~/.hermes/config.yaml` (user) | `~/.hermes/.env` (user) |
|
||||||
|
| 3 | built-in defaults | pre-existing shell environment |
|
||||||
|
|
||||||
|
Merging is **leaf-level**: pinning `model.default` does not freeze the rest of
|
||||||
|
`model.*`. A managed `config.yaml` of:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
model:
|
||||||
|
default: org/standard-model
|
||||||
|
```
|
||||||
|
|
||||||
|
forces `model.default` for every user while leaving `model.fallback` (and every
|
||||||
|
other key) under user control.
|
||||||
|
|
||||||
|
:::note Precedence note
|
||||||
|
For the keys it pins, managed scope deliberately wins over the shell environment
|
||||||
|
too — otherwise it would not be "managed." This is the one place that inverts the
|
||||||
|
usual "an environment variable overrides config.yaml" rule, and it applies only
|
||||||
|
to the specific keys the managed layer specifies.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Seeing what's managed
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes config # shows a header naming the managed source + the pinned keys
|
||||||
|
hermes doctor # reports the resolved managed dir + pinned key counts
|
||||||
|
```
|
||||||
|
|
||||||
|
If you try to change a managed value, Hermes refuses and names the source:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ hermes config set model.default my/model
|
||||||
|
Cannot set 'model.default': it is managed by your administrator
|
||||||
|
(/etc/hermes/config.yaml) and cannot be changed.
|
||||||
|
```
|
||||||
|
|
||||||
|
The same applies to managed secrets — `hermes config set` / setup will not write
|
||||||
|
a user value for an env key pinned by the managed `.env`.
|
||||||
|
|
||||||
|
## Setting up a managed scope (administrators)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /etc/hermes
|
||||||
|
|
||||||
|
# Pin some config values for every user on this machine
|
||||||
|
sudo tee /etc/hermes/config.yaml >/dev/null <<'YAML'
|
||||||
|
model:
|
||||||
|
provider: nous
|
||||||
|
security:
|
||||||
|
redact_secrets: true
|
||||||
|
YAML
|
||||||
|
|
||||||
|
# Optionally pin a shared, non-sensitive env value
|
||||||
|
sudo tee /etc/hermes/.env >/dev/null <<'ENV'
|
||||||
|
OPENAI_API_BASE=https://inference.example.com/v1
|
||||||
|
ENV
|
||||||
|
|
||||||
|
sudo chmod 0755 /etc/hermes
|
||||||
|
sudo chmod 0644 /etc/hermes/config.yaml /etc/hermes/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Changes take effect on the next Hermes start (a malformed managed file is logged
|
||||||
|
loudly and ignored — it never blocks startup, but the admin should check
|
||||||
|
`hermes doctor` to confirm the policy is being applied).
|
||||||
|
|
||||||
|
## Security model and limitations (v1)
|
||||||
|
|
||||||
|
- **Enforcement is filesystem permissions only.** If a user has write access to
|
||||||
|
the managed directory (or runs Hermes as `root`), managed scope is advisory.
|
||||||
|
- **The managed `.env` is world-readable** (`0644`), so any local user can read
|
||||||
|
secrets pushed through it. Use it for shared, non-sensitive values (an org API
|
||||||
|
base URL, feature defaults) rather than high-sensitivity secrets.
|
||||||
|
- **The agent's own tools are not hard-blocked from a managed *env* value.** A
|
||||||
|
managed environment variable is applied at startup, but nothing stops the
|
||||||
|
agent from setting a different value inside its own subprocess shell. v1 is a
|
||||||
|
management-convenience boundary against a normal user, not an un-escapable
|
||||||
|
sandbox.
|
||||||
|
|
||||||
|
The following are intentionally **out of scope for v1** and may come later:
|
||||||
|
|
||||||
|
- A hard boundary that the agent itself cannot escape.
|
||||||
|
- Native managed locations on macOS and Windows (v1 is Linux/POSIX-first).
|
||||||
|
- Drop-in fragment directories (`managed.d/`) for layered policy.
|
||||||
|
- Signed / integrity-checked managed files.
|
||||||
|
- Remote / device-management (MDM) delivery.
|
||||||
|
- Tighter (group-scoped) permissions for managed secrets.
|
||||||
@ -27,6 +27,7 @@ const sidebars: SidebarsConfig = {
|
|||||||
'user-guide/windows-native',
|
'user-guide/windows-native',
|
||||||
'user-guide/windows-wsl-quickstart',
|
'user-guide/windows-wsl-quickstart',
|
||||||
'user-guide/configuration',
|
'user-guide/configuration',
|
||||||
|
'user-guide/managed-scope',
|
||||||
'user-guide/configuring-models',
|
'user-guide/configuring-models',
|
||||||
{
|
{
|
||||||
type: 'category',
|
type: 'category',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user