"""File passthrough registry for remote terminal backends. Remote backends (Docker, Modal, SSH) create sandboxes with no host files. This module tells them which credential files (skill ``required_credential_files`` + ``terminal.credential_files`` config), skill directories, and host-side cache directories (documents, images, audio, screenshots, uploads) to mount or sync in, at sandbox creation and before each command (resync on Modal). """ from __future__ import annotations import logging import os import posixpath from contextvars import ContextVar from pathlib import Path from typing import Dict, Iterator, List, Optional, Tuple from hermes_cli.config import cfg_get from hermes_constants import get_hermes_dir, get_hermes_home from agent.skill_utils import EXCLUDED_SKILL_DIRS try: # pragma: no cover - exercised via the fail-closed test below from agent.file_safety import get_read_block_error except ImportError: # noqa: F401 - sentinel consumed in register_credential_file get_read_block_error = None # type: ignore[assignment] logger = logging.getLogger(__name__) # Session-scoped registry; ContextVar prevents cross-session bleed in the gateway. _registered_files_var: ContextVar[Dict[str, str]] = ContextVar("_registered_files") # Cache for config-based file list (loaded once per process). _config_files: List[Dict[str, str]] | None = None def _get_registered() -> Dict[str, str]: try: return _registered_files_var.get() except LookupError: val: Dict[str, str] = {} _registered_files_var.set(val) return val def _mount(host_path: Path | str, container_path: str) -> Dict[str, str]: return {"host_path": str(host_path), "container_path": container_path} def _contained_host_path( rel: str, hermes_home: Path, abs_msg: str, traversal_msg: str ) -> Optional[Path]: """Resolve *rel* under HERMES_HOME, refusing absolute paths and escapes.""" if os.path.isabs(rel): logger.warning(abs_msg, rel) return None host_path = hermes_home / rel # Resolve symlinks and ``..`` before the containment check. from tools.path_security import validate_within_dir containment_error = validate_within_dir(host_path, hermes_home) if containment_error: logger.warning(traversal_msg, rel, containment_error) return None return host_path.resolve() def register_credential_file( relative_path: str, container_base: str = "/root/.hermes", ) -> bool: """Register a HERMES_HOME-relative credential file for mounting. Returns True if the file exists on the host and was registered. Rejects absolute paths and traversal out of HERMES_HOME. Containment alone is not enough because HERMES_HOME holds the MASTER stores (``.env``, ``auth.json``, ``mcp-tokens/``): those are refused via the canonical read deny-list (``agent.file_safety.get_read_block_error``), so the mount surface cannot hand a skill what the read surface denies it. """ resolved = _contained_host_path( relative_path, get_hermes_home(), "credential_files: rejected absolute path %r (must be relative to HERMES_HOME)", "credential_files: rejected path traversal %r (%s)", ) if resolved is None: return False if not resolved.is_file(): logger.debug("credential_files: skipping %s (not found)", resolved) return False # Master stores pass the containment check above, so the deny-list is the # real gate. Fails CLOSED: if the guard can't be consulted, refuse rather # than risk bind-mounting auth.json into a sandbox; the import sentinel + # logger.exception keep guard failures debuggable, not silently swallowed. if get_read_block_error is None: logger.error( "credential_files: refusing %r — agent.file_safety could not be " "imported, so the master-store deny-list cannot be consulted", relative_path, ) return False try: denied = get_read_block_error(str(resolved)) except Exception: logger.exception( "credential_files: refusing %r — read guard raised", relative_path ) return False if denied: logger.warning( "credential_files: refused %r — it is a credential store the agent " "is denied from reading; a skill may mount its own service token, " "not the master key files", relative_path, ) return False container_path = f"{container_base.rstrip('/')}/{relative_path}" _get_registered()[container_path] = str(resolved) logger.debug("credential_files: registered %s -> %s", resolved, container_path) return True def register_credential_files( entries: list, container_base: str = "/root/.hermes", ) -> List[str]: """Register skill-frontmatter entries (str or dict with ``path``); return missing paths.""" missing = [] for entry in entries: if isinstance(entry, str): rel_path = entry.strip() elif isinstance(entry, dict): rel_path = (entry.get("path") or entry.get("name") or "").strip() else: continue if rel_path and not register_credential_file(rel_path, container_base): missing.append(rel_path) return missing def _load_config_files() -> List[Dict[str, str]]: """Load ``terminal.credential_files`` from config.yaml (cached).""" global _config_files if _config_files is not None: return _config_files result: List[Dict[str, str]] = [] try: from hermes_cli.config import read_raw_config hermes_home = get_hermes_home() cred_files = cfg_get(read_raw_config(), "terminal", "credential_files") for item in cred_files if isinstance(cred_files, list) else []: if not (isinstance(item, str) and item.strip()): continue rel = item.strip() resolved_path = _contained_host_path( rel, hermes_home, "credential_files: rejected absolute config path %r", "credential_files: rejected config path traversal %r (%s)", ) if resolved_path is not None and resolved_path.is_file(): result.append(_mount(resolved_path, f"/root/.hermes/{rel}")) except Exception as e: logger.warning("Could not read terminal.credential_files from config: %s", e) _config_files = result return _config_files def get_credential_file_mounts() -> List[Dict[str, str]]: """Skill-registered + config credential files as ``host_path``/``container_path`` dicts.""" mounts: Dict[str, str] = {} # Re-check existence (file may have been deleted since registration). for container_path, host_path in _get_registered().items(): if Path(host_path).is_file(): mounts[container_path] = host_path for entry in _load_config_files(): cp = entry["container_path"] if cp not in mounts and Path(entry["host_path"]).is_file(): mounts[cp] = entry["host_path"] return [_mount(hp, cp) for cp, hp in mounts.items()] # --- Skills directory mounts --- def _skill_dir_roots(container_base: str) -> Iterator[Tuple[Path, str]]: """Yield ``(host_dir, container_root)`` for every existing skills directory. Local skills mount at ``/skills``, external dirs at ``/external_skills/``, trusted project-local dirs at ``/project_skills/`` (separate namespace so container paths stay stable if external_dirs change). """ base = container_base.rstrip("/") skills_dir = get_hermes_home() / "skills" if skills_dir.is_dir(): yield skills_dir, f"{base}/skills" try: from agent.skill_utils import get_external_skills_dirs, get_project_skills_dirs except ImportError: return for label, dirs in (("external_skills", get_external_skills_dirs()), ("project_skills", get_project_skills_dirs())): for idx, d in enumerate(dirs): if d.is_dir(): yield d, f"{base}/{label}/{idx}" def _iter_regular_files(host_dir: Path, container_root: str) -> Iterator[Dict[str, str]]: """Per-file mount entries under *host_dir*, skipping symlinks.""" for item in host_dir.rglob("*"): if item.is_symlink() or not item.is_file(): continue yield _mount(item, f"{container_root}/{item.relative_to(host_dir)}") def get_skills_directory_mount( container_base: str = "/root/.hermes", ) -> list[Dict[str, str]]: """Directory mount entries for all skill dirs (local + external + project). Bind mounts follow symlinks, so a dir containing any symlink is replaced by a sanitized temp copy (regular files only); symlink-free dirs are returned directly with zero overhead. """ return [ _mount(_safe_skills_path(host_dir), container_path) for host_dir, container_path in _skill_dir_roots(container_base) ] _safe_skills_tempdir: Path | None = None def _safe_skills_path(skills_dir: Path) -> str: """Return *skills_dir* if symlink-free, else a sanitized temp copy.""" global _safe_skills_tempdir symlinks = [p for p in skills_dir.rglob("*") if p.is_symlink()] if not symlinks: return str(skills_dir) for link in symlinks: logger.warning("credential_files: skipping symlink in skills dir: %s -> %s", link, os.readlink(link)) import atexit import shutil import tempfile # Reuse the same temp dir across calls to avoid accumulation. if _safe_skills_tempdir and _safe_skills_tempdir.is_dir(): shutil.rmtree(_safe_skills_tempdir, ignore_errors=True) safe_dir = Path(tempfile.mkdtemp(prefix="hermes-skills-safe-")) _safe_skills_tempdir = safe_dir # Same exclusion rule as the per-file sync path (_iter_syncable_files): # the sanitized copy is what gets mounted, so it must not carry the # bookkeeping trees either. Prune before descending so a multi-GB # .curator_backups is never even walked. for dirpath, dirnames, filenames in os.walk(skills_dir): dirnames[:] = sorted(d for d in dirnames if d not in EXCLUDED_SKILL_DIRS) base = Path(dirpath) (safe_dir / base.relative_to(skills_dir)).mkdir(parents=True, exist_ok=True) for name in filenames: item = base / name if item.is_symlink() or not item.is_file(): continue shutil.copy2(str(item), str(safe_dir / item.relative_to(skills_dir))) def _cleanup(): if safe_dir.is_dir(): shutil.rmtree(safe_dir, ignore_errors=True) atexit.register(_cleanup) logger.info("credential_files: created symlink-safe skills copy at %s", safe_dir) return str(safe_dir) def _iter_syncable_files(root: Path): """Yield ``(path, rel)`` for every regular, non-symlink file under *root* that a sandbox should receive. Prunes ``agent.skill_utils.EXCLUDED_SKILL_DIRS`` *before* descending, so the walk never enters local bookkeeping and dependency trees (``.hub`` download cache, ``.archive``, ``.curator_backups``, ``node_modules``, ``__pycache__``, ``.git``, ...) that the remote agent never reads — the sync path agrees with discovery on what counts as skill content. This deliberately does not use ``is_excluded_skill_path()``, which also prunes ``references/``, ``templates/``, ``assets/`` and ``scripts/``. Those hold progressive-disclosure support files and bundled scripts the sandbox does execute, so they must keep syncing. """ for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = sorted(d for d in dirnames if d not in EXCLUDED_SKILL_DIRS) base = Path(dirpath) for name in filenames: item = base / name if item.is_symlink() or not item.is_file(): continue yield item, item.relative_to(root) def iter_skills_files( container_base: str = "/root/.hermes", ) -> List[Dict[str, str]]: """Per-file entries for all skills files (for backends that upload individually). Skips symlinks and anything under EXCLUDED_SKILL_DIRS (see _iter_syncable_files). """ return [ _mount(item, f"{container_root}/{rel}") for host_dir, container_root in _skill_dir_roots(container_base) for item, rel in _iter_syncable_files(host_dir) ] # --- Cache directory mounts (documents, images, audio, videos, screenshots) --- # (new_subpath, old_name) pairs matching hermes_constants.get_hermes_dir(). _CACHE_DIRS: list[tuple[str, str]] = [ ("cache/documents", "document_cache"), ("cache/images", "image_cache"), ("cache/audio", "audio_cache"), ("cache/videos", "video_cache"), ("cache/screenshots", "browser_screenshots"), ("cache/web", "web_cache"), ("cache/delegation", "delegation_cache"), # Oversized tool results (tools/tool_result_storage.py); host side is the # single canonical location. ("cache/spillover", "cache/spillover"), # Flat top-level desktop staging dirs (tui_gateway attach RPCs), not under # cache/; no legacy alias, so both slots match. Mounted so vision / file # tools inside sandbox containers can reach uploads and dropped files. ("images", "images"), ("attachments", "attachments"), ] def _cache_dir_roots(container_base: str, *, create_missing: bool) -> Iterator[Tuple[Path, str]]: """Yield ``(host_dir, container_root)`` per cache dir; always maps to the *new* container layout.""" base = container_base.rstrip("/") for new_subpath, old_name in _CACHE_DIRS: host_dir = get_hermes_dir(new_subpath, old_name) if not host_dir.is_dir(): if not create_missing: continue # Docker snapshots this list at container CREATION, so a dir that # appears later would dangle for the container's life: create it # now; an empty bind mount costs nothing. get_hermes_dir already # picked new-vs-legacy, so creating its answer can't shadow a # populated legacy dir. try: host_dir.mkdir(parents=True, exist_ok=True) except OSError: continue # unwritable home (tests, RO mounts) — skip as before yield host_dir, f"{base}/{new_subpath}" def get_cache_directory_mounts( container_base: str = "/root/.hermes", ) -> List[Dict[str, str]]: """Bind-mount entries for each cache directory (host layout via ``get_hermes_dir``).""" return [_mount(h, c) for h, c in _cache_dir_roots(container_base, create_missing=True)] def map_cache_path_to_container( host_path: str, container_base: str = "/root/.hermes", ) -> Optional[str]: """POSIX container path for a host path under an auto-mounted cache dir, else None.""" path = Path(host_path) for mount in get_cache_directory_mounts(container_base=container_base): try: rel = path.relative_to(mount["host_path"]) except ValueError: continue return posixpath.join(mount["container_path"], rel.as_posix()) return None def from_agent_visible_cache_path( container_path: str, container_base: str = "/root/.hermes", ) -> str: """Inverse of :func:`to_agent_visible_cache_path`; unchanged unless Docker + cache dir.""" if os.environ.get("TERMINAL_ENV", "local") != "docker": return container_path path = Path(container_path) for mount in get_cache_directory_mounts(container_base=container_base): try: rel = path.relative_to(mount["container_path"]) except ValueError: continue return str(Path(mount["host_path"]) / rel) return container_path # Backends whose file-sync lands under the remote home: ``~/.hermes`` is # expanded by the remote shell, so it resolves regardless of the actual home. _HOME_RELATIVE_BACKENDS = frozenset({"ssh", "daytona", "vercel_sandbox"}) def to_agent_visible_cache_path( host_path: str, container_base: str = "/root/.hermes", ) -> str: """Translate a host cache path to where the active backend sees it. Per-backend base (mirrors ``_agent_cache_base_for_env`` in tools/image_generation_tool.py): docker/modal mount/sync at ``/root/.hermes``; ssh/daytona/vercel_sandbox under ``~/.hermes``; plugin backends declare ``cache_path_base`` (None = host paths remain correct); local/singularity/unknown stay unchanged (Apptainer auto-binds the host home, so translation would dangle). Backend comes from TERMINAL_ENV, as in terminal_tool._get_environment_config. """ backend = (os.environ.get("TERMINAL_ENV") or "local").strip().lower() if backend in _HOME_RELATIVE_BACKENDS: container_base = "~/.hermes" elif backend not in ("docker", "modal"): try: from agent.terminal_env_registry import provider_flag plugin_base = provider_flag(backend, "cache_path_base", None) except Exception: plugin_base = None if not plugin_base: return host_path container_base = str(plugin_base) mapped = map_cache_path_to_container(host_path, container_base=container_base) return mapped if mapped is not None else host_path def iter_cache_files( container_base: str = "/root/.hermes", ) -> List[Dict[str, str]]: """Per-file cache entries (Modal upload/resync); skips symlinks.""" return [ entry for host_dir, container_root in _cache_dir_roots(container_base, create_missing=False) for entry in _iter_regular_files(host_dir, container_root) ] def clear_credential_files() -> None: """Reset the skill-scoped registry (e.g. on session reset).""" _get_registered().clear()