"""Shared file safety rules used by both tools and ACP shims.""" from __future__ import annotations import os from pathlib import Path from typing import Optional def _hermes_home_path() -> Path: """Resolve the active HERMES_HOME (profile-aware) without circular imports.""" try: from hermes_constants import get_hermes_home # local import to avoid cycles return get_hermes_home() except Exception: return Path(os.path.expanduser("~/.hermes")) def _hermes_root_path() -> Path: """Resolve the Hermes root dir (always the parent of any profile, never per-profile).""" try: from hermes_constants import get_default_hermes_root # local import to avoid cycles return get_default_hermes_root() except Exception: return Path(os.path.expanduser("~/.hermes")) def build_write_denied_paths(home: str) -> set[str]: """Return exact sensitive paths that must never be written.""" hermes_home = _hermes_home_path() hermes_root = _hermes_root_path() return { os.path.realpath(p) for p in [ os.path.join(home, ".ssh", "authorized_keys"), os.path.join(home, ".ssh", "id_rsa"), os.path.join(home, ".ssh", "id_ed25519"), os.path.join(home, ".ssh", "config"), # Active profile .env (or top-level .env when not in profile mode). str(hermes_home / ".env"), # Top-level .env, even when running under a profile — overwriting it # leaks credentials across every profile that inherits from root (#15981). str(hermes_root / ".env"), os.path.join(home, ".bashrc"), os.path.join(home, ".zshrc"), os.path.join(home, ".profile"), os.path.join(home, ".bash_profile"), os.path.join(home, ".zprofile"), os.path.join(home, ".netrc"), os.path.join(home, ".pgpass"), os.path.join(home, ".npmrc"), os.path.join(home, ".pypirc"), "/etc/sudoers", "/etc/passwd", "/etc/shadow", ] } def build_write_denied_prefixes(home: str) -> list[str]: """Return sensitive directory prefixes that must never be written.""" return [ os.path.realpath(p) + os.sep for p in [ os.path.join(home, ".ssh"), os.path.join(home, ".aws"), os.path.join(home, ".gnupg"), os.path.join(home, ".kube"), "/etc/sudoers.d", "/etc/systemd", os.path.join(home, ".docker"), os.path.join(home, ".azure"), os.path.join(home, ".config", "gh"), ] ] def get_safe_write_root() -> Optional[str]: """Return the resolved HERMES_WRITE_SAFE_ROOT path, or None if unset.""" root = os.getenv("HERMES_WRITE_SAFE_ROOT", "") if not root: return None try: return os.path.realpath(os.path.expanduser(root)) except Exception: return None def is_write_denied(path: str) -> bool: """Return True if path is blocked by the write denylist or safe root.""" home = os.path.realpath(os.path.expanduser("~")) resolved = os.path.realpath(os.path.expanduser(str(path))) if resolved in build_write_denied_paths(home): return True for prefix in build_write_denied_prefixes(home): if resolved.startswith(prefix): return True # Hermes control-plane files: block both the ACTIVE profile's view # (hermes_home) AND the global root view. Without the root pass, a # profile-mode session leaves /auth.json + /config.yaml # writable — letting a prompt-injected write_file overwrite the global # files that every profile inherits from (same shape as #15981). control_file_names = ("auth.json", "config.yaml", "webhook_subscriptions.json") mcp_tokens_dir_name = "mcp-tokens" hermes_dirs = [] for base in (_hermes_home_path(), _hermes_root_path()): try: real = os.path.realpath(base) if real not in hermes_dirs: hermes_dirs.append(real) except Exception: continue for base_real in hermes_dirs: for name in control_file_names: try: if resolved == os.path.realpath(os.path.join(base_real, name)): return True except Exception: continue try: mcp_real = os.path.realpath(os.path.join(base_real, mcp_tokens_dir_name)) if resolved == mcp_real or resolved.startswith(mcp_real + os.sep): return True except Exception: pass safe_root = get_safe_write_root() if safe_root and not (resolved == safe_root or resolved.startswith(safe_root + os.sep)): return True return False def get_read_block_error(path: str) -> Optional[str]: """Return an error message when a read targets internal Hermes cache files.""" resolved = Path(path).expanduser().resolve() hermes_home = _hermes_home_path().resolve() blocked_dirs = [ hermes_home / "skills" / ".hub" / "index-cache", hermes_home / "skills" / ".hub", ] for blocked in blocked_dirs: try: resolved.relative_to(blocked) except ValueError: continue return ( f"Access denied: {path} is an internal Hermes cache file " "and cannot be read directly to prevent prompt injection. " "Use the skills_list or skill_view tools instead." ) return None