file_safety.py
1 """Shared file safety rules used by both tools and ACP shims.""" 2 3 from __future__ import annotations 4 5 import os 6 from pathlib import Path 7 from typing import Optional 8 9 10 def _hermes_home_path() -> Path: 11 """Resolve the active HERMES_HOME (profile-aware) without circular imports.""" 12 try: 13 from hermes_constants import get_hermes_home # local import to avoid cycles 14 return get_hermes_home() 15 except Exception: 16 return Path(os.path.expanduser("~/.hermes")) 17 18 19 def build_write_denied_paths(home: str) -> set[str]: 20 """Return exact sensitive paths that must never be written.""" 21 hermes_home = _hermes_home_path() 22 return { 23 os.path.realpath(p) 24 for p in [ 25 os.path.join(home, ".ssh", "authorized_keys"), 26 os.path.join(home, ".ssh", "id_rsa"), 27 os.path.join(home, ".ssh", "id_ed25519"), 28 os.path.join(home, ".ssh", "config"), 29 str(hermes_home / ".env"), 30 os.path.join(home, ".bashrc"), 31 os.path.join(home, ".zshrc"), 32 os.path.join(home, ".profile"), 33 os.path.join(home, ".bash_profile"), 34 os.path.join(home, ".zprofile"), 35 os.path.join(home, ".netrc"), 36 os.path.join(home, ".pgpass"), 37 os.path.join(home, ".npmrc"), 38 os.path.join(home, ".pypirc"), 39 "/etc/sudoers", 40 "/etc/passwd", 41 "/etc/shadow", 42 ] 43 } 44 45 46 def build_write_denied_prefixes(home: str) -> list[str]: 47 """Return sensitive directory prefixes that must never be written.""" 48 return [ 49 os.path.realpath(p) + os.sep 50 for p in [ 51 os.path.join(home, ".ssh"), 52 os.path.join(home, ".aws"), 53 os.path.join(home, ".gnupg"), 54 os.path.join(home, ".kube"), 55 "/etc/sudoers.d", 56 "/etc/systemd", 57 os.path.join(home, ".docker"), 58 os.path.join(home, ".azure"), 59 os.path.join(home, ".config", "gh"), 60 ] 61 ] 62 63 64 def get_safe_write_root() -> Optional[str]: 65 """Return the resolved HERMES_WRITE_SAFE_ROOT path, or None if unset.""" 66 root = os.getenv("HERMES_WRITE_SAFE_ROOT", "") 67 if not root: 68 return None 69 try: 70 return os.path.realpath(os.path.expanduser(root)) 71 except Exception: 72 return None 73 74 75 def is_write_denied(path: str) -> bool: 76 """Return True if path is blocked by the write denylist or safe root.""" 77 home = os.path.realpath(os.path.expanduser("~")) 78 resolved = os.path.realpath(os.path.expanduser(str(path))) 79 80 if resolved in build_write_denied_paths(home): 81 return True 82 for prefix in build_write_denied_prefixes(home): 83 if resolved.startswith(prefix): 84 return True 85 86 safe_root = get_safe_write_root() 87 if safe_root and not (resolved == safe_root or resolved.startswith(safe_root + os.sep)): 88 return True 89 90 return False 91 92 93 def get_read_block_error(path: str) -> Optional[str]: 94 """Return an error message when a read targets internal Hermes cache files.""" 95 resolved = Path(path).expanduser().resolve() 96 hermes_home = _hermes_home_path().resolve() 97 blocked_dirs = [ 98 hermes_home / "skills" / ".hub" / "index-cache", 99 hermes_home / "skills" / ".hub", 100 ] 101 for blocked in blocked_dirs: 102 try: 103 resolved.relative_to(blocked) 104 except ValueError: 105 continue 106 return ( 107 f"Access denied: {path} is an internal Hermes cache file " 108 "and cannot be read directly to prevent prompt injection. " 109 "Use the skills_list or skill_view tools instead." 110 ) 111 return None