diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 8843b9b38a6..da485155027 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -6308,12 +6308,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)) diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 127adefb39c..adaf575cb81 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -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(): diff --git a/tests/hermes_cli/test_managed_scope_surfacing.py b/tests/hermes_cli/test_managed_scope_surfacing.py new file mode 100644 index 00000000000..a8872619d76 --- /dev/null +++ b/tests/hermes_cli/test_managed_scope_surfacing.py @@ -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() == ""