"""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 a denied Hermes path. Two categories are blocked: * Internal Hermes cache files under ``HERMES_HOME/skills/.hub`` — readable metadata that an attacker could use as a prompt-injection carrier. * Credential / secret stores under HERMES_HOME and the global Hermes root: ``auth.json``, ``auth.lock``, ``.anthropic_oauth.json``, ``.env``, ``webhook_subscriptions.json``, and anything under ``mcp-tokens/``. These hold plaintext provider keys, OAuth tokens, and HMAC secrets that the agent never needs to read directly — provider tools / gateway adapters consume them through internal channels. **This is NOT a security boundary.** The terminal tool runs as the same OS user with shell access; the agent can still ``cat auth.json`` or ``cat ~/.hermes/.env`` and exfiltrate the file. The read-deny exists as defense-in-depth that: * Returns a clear error to models that respect tool denials, which empirically prompts most modern models to stop rather than reach for the shell. * Surfaces a visible audit trail when something tries to read credentials — easier to spot in logs than a generic ``cat``. Treat any user-visible framing around this as "may help" rather than "stops attackers." A determined model or malicious instruction can always shell out. Callers that resolve relative paths against a non-process cwd (e.g. ``TERMINAL_CWD`` in ``tools/file_tools.py``) MUST pre-resolve and pass the absolute path string. This function's own ``resolve()`` is anchored at the Python process cwd, so a relative input like ``"auth.json"`` would otherwise miss the denylist when the task's terminal cwd differs from the process cwd. """ resolved = Path(path).expanduser().resolve() # Resolve BOTH the active HERMES_HOME (profile-aware) AND the global # Hermes root so credential stores at /auth.json etc. are also # blocked when running under a profile (HERMES_HOME points at # /profiles/ in profile mode). Same shape as the write # deny widening (#15981, #14157). hermes_dirs: list[Path] = [] for base in (_hermes_home_path(), _hermes_root_path()): try: real = base.resolve() if real not in hermes_dirs: hermes_dirs.append(real) except Exception: continue # Skills .hub: prompt-injection carriers. for hd in hermes_dirs: blocked_dirs = [ hd / "skills" / ".hub" / "index-cache", hd / "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." ) # Credential / secret stores. Exact-file matches under either # HERMES_HOME or . credential_file_names = ( "auth.json", "auth.lock", ".anthropic_oauth.json", ".env", "webhook_subscriptions.json", ) for hd in hermes_dirs: for name in credential_file_names: try: blocked = (hd / name).resolve() except Exception: continue if resolved == blocked: return ( f"Access denied: {path} is a Hermes credential store " "and cannot be read directly. Provider tools consume " "these credentials through internal channels. " "(Defense-in-depth — not a security boundary; the " "terminal tool can still bypass.)" ) # mcp-tokens/: directory prefix match — anything inside is OAuth # token material. for hd in hermes_dirs: try: mcp_tokens = (hd / "mcp-tokens").resolve() except Exception: continue if resolved == mcp_tokens: return ( f"Access denied: {path} is the Hermes MCP token directory " "and cannot be read directly. (Defense-in-depth — not a " "security boundary; the terminal tool can still bypass.)" ) try: resolved.relative_to(mcp_tokens) except ValueError: continue return ( f"Access denied: {path} is a Hermes MCP token file " "and cannot be read directly. (Defense-in-depth — not a " "security boundary; the terminal tool can still bypass.)" ) return None