Files
hermes-agent/tools/credential_files.py
T

460 lines
18 KiB
Python

"""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 ``<base>/skills``, external dirs at
``<base>/external_skills/<i>``, trusted project-local dirs at
``<base>/project_skills/<i>`` (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()