diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 30be8e08e4a..8843b9b38a6 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -5232,6 +5232,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. @@ -5767,6 +5790,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() @@ -6033,6 +6072,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) @@ -6110,6 +6162,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() @@ -6467,6 +6531,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', diff --git a/tests/hermes_cli/test_managed_scope_writeguard.py b/tests/hermes_cli/test_managed_scope_writeguard.py new file mode 100644 index 00000000000..d8c755743ce --- /dev/null +++ b/tests/hermes_cli/test_managed_scope_writeguard.py @@ -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()