/ agent / file_safety.py
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