Files
hermes-agent/hermes_cli/managed_scope.py
ethernet 612d542281 Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	.gitignore
#	Dockerfile
#	agent/onboarding.py
#	apps/desktop/electron/main.ts
#	apps/desktop/electron/pool-stop.ts
#	apps/desktop/src/components/model-picker.test.tsx
#	apps/desktop/src/store/updates.ts
#	apps/desktop/vite.config.ts
#	datagen-config-examples/run_browser_tasks.sh
#	docs/rca-ssl-cacert-post-git-pull.md
#	gateway/run.py
#	hermes_cli/backup.py
#	hermes_cli/credential_lifecycle.py
#	hermes_cli/dashboard_procs.py
#	hermes_cli/doctor_state.py
#	hermes_cli/env_loader.py
#	hermes_cli/gateway_windows.py
#	hermes_cli/local_runtime/endpoint.py
#	hermes_cli/psutil_android.py
#	hermes_cli/update_cmd.py
#	hermes_cli/update_cmd_windows.py
#	hermes_cli/web_routers/local_models.py
#	hermes_cli/web_server_config.py
#	hermes_cli/web_server_cron.py
#	plugins/memory/hindsight/__init__.py
#	plugins/memory/holographic/__init__.py
#	plugins/memory/honcho/cli.py
#	plugins/memory/mem0/__init__.py
#	plugins/platforms/google_chat/oauth.py
#	plugins/platforms/photon/adapter.py
#	scripts/ci/list_os_marked_tests.py
#	scripts/run_tests.sh
#	tests/agent/test_compression_stall_fallback.py
#	tests/agent/test_create_openai_client_ssl_verify.py
#	tests/gateway/test_google_chat_oauth_dependencies.py
#	tests/hermes_cli/conftest.py
#	tests/hermes_cli/test_cli_init.py
#	tests/hermes_cli/test_gateway_migrate_multiplex.py
#	tests/hermes_cli/test_psutil_android_extract.py
#	tests/hermes_cli/test_relaunch.py
#	tests/hermes_cli/test_update_check.py
#	tests/hermes_cli/test_update_handoff_desktop_rebuild.py
#	tests/hermes_cli/test_worktree_gc.py
#	tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py
#	tests/scripts/desktop_update/test_desktop_update_windows_retry_policy.py
#	tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py
#	tests/scripts/install/test_install_autostash_conflict_recovery.py
#	tests/scripts/install/test_install_clone_throttle_fallback.py
#	tests/scripts/install/test_install_commit_pin_rollback.py
#	tests/scripts/install/test_install_diverged_update.py
#	tests/scripts/install/test_install_lockfile_churn.py
#	tests/scripts/install/test_install_macos_launcher.py
#	tests/scripts/install/test_install_no_initial_commit.py
#	tests/scripts/install/test_install_ps1_ascii_only.py
#	tests/scripts/install/test_install_ps1_browser_install.py
#	tests/scripts/install/test_install_ps1_managed_node_swap.py
#	tests/scripts/install/test_install_ps1_native_stderr_eap.py
#	tests/scripts/install/test_install_ps1_node_path_for_npm.py
#	tests/scripts/install/test_install_ps1_python_fallback_venv.py
#	tests/scripts/install/test_install_ps1_resolver_strictmode.py
#	tests/scripts/install/test_install_ps1_uv_install_fallback.py
#	tests/scripts/install/test_install_ps1_uv_powershell_host.py
#	tests/scripts/install/test_install_ps1_venv_process_tree.py
#	tests/scripts/install/test_install_ps1_venv_recreate_safety.py
#	tests/scripts/install/test_install_ps1_venv_rename_abort.py
#	tests/scripts/install/test_install_ps1_venv_transaction_boundary.py
#	tests/scripts/install/test_install_ps1_web_server_syntax_probe.py
#	tests/scripts/install/test_install_scripts_computer_use.py
#	tests/scripts/install/test_install_sh_acp_launcher.py
#	tests/scripts/install/test_install_sh_bootstrap_marker.py
#	tests/scripts/install/test_install_sh_browser_install.py
#	tests/scripts/install/test_install_sh_install_method_stamp.py
#	tests/scripts/install/test_install_sh_node_deps_failure.py
#	tests/scripts/install/test_install_sh_node_deps_workspaces.py
#	tests/scripts/install/test_install_sh_node_global_prefix.py
#	tests/scripts/install/test_install_sh_node_npm_check.py
#	tests/scripts/install/test_install_sh_node_prerelease.py
#	tests/scripts/install/test_install_sh_node_probe.py
#	tests/scripts/install/test_install_sh_node_tarball_without_xz.py
#	tests/scripts/install/test_install_sh_pythonpath_sanitization.py
#	tests/scripts/install/test_install_sh_reuse_supported_python.py
#	tests/scripts/install/test_install_sh_root_fhs_uv_python_path.py
#	tests/scripts/install/test_install_sh_setup_wizard_tty_probe.py
#	tests/scripts/install/test_install_sh_symlink_stomp.py
#	tests/scripts/install/test_install_sh_termux_network_prereqs.py
#	tests/scripts/install/test_install_sh_termux_python_bounds.py
#	tests/scripts/install/test_install_sh_uv_lock_config.py
#	tests/scripts/install/test_install_unmerged_index.py
#	tests/scripts/test_run_tests_parallel.py
#	tests/test_managed_runtime_resolution.py
#	tests/test_project_metadata.py
#	tests/tools/test_browser_use_cli.py
#	tests/tools/test_tts_pythonpath_fallback.py
#	tests/tui_gateway/test_hosted_room_driver_runtime.py
#	tests/tui_gateway/test_tui_gateway_server.py
#	tools/lazy_deps.py
#	tools/voice_mode.py
#	uv.lock
#	website/docs/developer-guide/macos-bundle-updates.md
#	website/docs/developer-guide/pm-audit-status.md
#	website/docs/developer-guide/shared-bundle-builds.md
#	website/docs/developer-guide/source-update-completion.md
#	website/docs/developer-guide/stable-releases.md
2026-09-14 15:38:34 -04:00

168 lines
6.4 KiB
Python

"""Managed scope — IT-pushed, user-immutable config & env layer.
DISTINCT from ``hermes_cli.config.is_managed()`` / ``HERMES_MANAGED`` (a coarse package-manager
write-lock that 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); ``get_managed_dir()`` is the single seam for adding
macOS / Windows native locations later.
"""
from __future__ import annotations
import copy
import logging
import os
import threading
from pathlib import Path
from typing import Dict, Optional
import hermes_yaml as yaml
logger = logging.getLogger(__name__)
# POSIX default. Other-platform locations 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 inside the test suite: ignore the system ``/etc/hermes`` so a real managed scope on a
dev/CI box can't leak policy into the suite. An explicit ``HERMES_MANAGED_DIR`` still wins."""
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.
Priority: ``$HERMES_MANAGED_DIR`` (IT-only bootstrap override; never persisted to any .env;
honored only when non-empty AND the directory exists), then ``/etc/hermes`` when it exists.
A missing directory resolves to None — the common case, so it must be cheap + side-effect-free.
"""
override = os.environ.get("HERMES_MANAGED_DIR", "").strip()
if override:
p = Path(override)
elif _under_pytest():
return None
else:
p = _DEFAULT_MANAGED_DIR
return p if p.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.
``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 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:
parsed = parse(path)
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, copy.deepcopy(parsed))
return parsed
def _load_managed_file(name: str, cache: Dict[str, tuple], parse) -> dict:
managed_dir = get_managed_dir()
if managed_dir is None:
return {}
parsed = _cached_read(managed_dir / name, cache, parse)
return parsed if isinstance(parsed, dict) else {}
def load_managed_config() -> dict:
"""Parsed managed config.yaml, or {} when absent/malformed (fail-open)."""
return _load_managed_file("config.yaml", _CONFIG_CACHE, lambda p: yaml.safe_load(p.read_text(encoding="utf-8-sig")) or {})
def load_managed_env() -> Dict[str, str]:
"""Parsed managed .env (KEY=VALUE), or {} when absent (fail-open)."""
return _load_managed_file(".env", _ENV_CACHE, _parse_managed_env)
def _parse_managed_env(path: Path) -> Dict[str, str]:
from agent.secret_scope import load_env_file
path.read_text(encoding="utf-8-sig") # load_env_file swallows decode errors; an admin file must fail LOUD
return load_env_file(path)
def apply_managed_overlay(config: dict) -> dict:
"""Overlay administrator-pinned config values on top of an already-built dict.
``${VAR}`` refs in the managed config expand against the PROCESS env only, so a user cannot
shadow a managed literal via a ref they control; a bare root ``model: x/y`` string is promoted
to ``model.default`` so it can't clobber the dict shape callers expect; managed values
deep-merge ON TOP per leaf while sibling keys stay user-controlled. Fail-open: returns
``config`` unchanged when no scope is present or on any error. Mutates and returns ``config``.
"""
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))
# _normalize_root_model_keys only promotes the string when root provider/base_url
# keys exist to migrate; handle the bare case here (matches cli.py) so _deep_merge
# never replaces the caller's ``model`` dict with a string.
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 _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()