"""Managed-files, chat image upload, /api/media and /api/fs dashboard routes. Extracted from ``hermes_cli.web_server``; helpers/state that tests monkeypatch on ``web_server`` stay there and are imported lazily at call time (cycle-safe). """ import base64 import binascii import mimetypes import os import re import stat import tempfile import asyncio import secrets import shutil import subprocess import sys from datetime import datetime from fastapi import APIRouter from hermes_cli.web_deps import late from fastapi import File, Form, HTTPException, Request, UploadFile from fastapi.responses import FileResponse from hermes_cli._subprocess_compat import windows_hide_flags from hermes_cli.web_models import ManagedFileUpload, ChatImageUpload, ManagedDirectoryCreate, ManagedFileDelete, FsWriteText from pathlib import Path from typing import Any, Dict, Optional router = APIRouter() # web_server helpers, late-bound so monkeypatch.setattr(web_server, ...) stays authoritative. _fs_path = late("_fs_path") _managed_file_entry = late("_managed_file_entry") _managed_response_meta = late("_managed_response_meta") _profile_scope = late("_profile_scope") _resolve_managed_path = late("_resolve_managed_path") get_hermes_home = late("get_hermes_home") load_config = late("load_config") # Image MIME types this endpoint will serve. Extension-allowlisted so an # authenticated caller can't pull non-image files through it. _MEDIA_CONTENT_TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml", ".bmp": "image/bmp", ".ico": "image/x-icon", } _MEDIA_MAX_BYTES = 25 * 1024 * 1024 _STREAMABLE_MEDIA_EXTENSIONS = frozenset( { ".avi", ".flac", ".m4a", ".mkv", ".mov", ".mp3", ".mp4", ".ogg", ".opus", ".wav", ".webm", } ) _FS_READDIR_HIDDEN = { ".git", ".hg", ".svn", ".cache", ".next", ".turbo", ".venv", "__pycache__", "build", "dist", "node_modules", "target", "venv", } # Filenames that must never be listed, read, or downloaded through the # managed-files API. These typically contain credentials (API keys, tokens) # and exposing them through the dashboard file browser is a security leak — # see issue #57505. The set mirrors the credential-file basenames of the two # canonical credential guards elsewhere in the codebase # (agent.file_safety.get_read_block_error and # gateway.platforms.base._ROOT_CREDENTIAL_FILES) so the dashboard Files tab # doesn't lag behind them — an operator can point the managed root at # HERMES_HOME itself, at which point every one of these basenames is a live # secret store sitting in the browsable tree. _SENSITIVE_MANAGED_FILE_BASENAMES = frozenset({ "auth.json", "auth.lock", "credentials", "config.yaml", ".anthropic_oauth.json", "google_token.json", "google_oauth_pending.json", "google_oauth.json", "webhook_subscriptions.json", "bws_cache.json", "bws_cache.enc.json", # git's credential-store helper cache (agent.file_safety blocks this too). ".git-credentials", }) # Directory names whose entire subtree is credential material. Both canonical # guards deny these as directory trees, not basenames: # * gateway.platforms.base._ROOT_CREDENTIAL_DIRS = {"pairing", "mcp-tokens"} # * agent.file_safety.get_read_block_error (mcp-tokens/ prefix match) # The managed-files API lets the browser descend into subdirs, so a # basename-only guard would still expose e.g. ``mcp-tokens/.json`` # (live MCP OAuth tokens) and ``pairing/``. We match on ANY path component # so these trees are blocked wherever they appear under the browsable root, # without needing to resolve them relative to HERMES_HOME. _SENSITIVE_MANAGED_DIR_NAMES = frozenset({ "mcp-tokens", "pairing", }) def _is_sensitive_filename(name: str) -> bool: """Return True for a basename the managed-files API must never expose. Covers ``.env`` / ``.env.`` / ``.envrc`` variants plus the canonical Hermes credential-store basenames (see ``_SENSITIVE_MANAGED_FILE_BASENAMES`` above). Case-insensitive so ``.ENV`` / ``.Env.local`` / ``Auth.JSON`` on case-insensitive filesystems (macOS/Windows mounts) can't slip past the guard. Basename-only: for the directory-tree credential stores (``mcp-tokens/``, ``pairing/``) that the canonical guards also deny, use :func:`_is_sensitive_path`, which the API call sites route through. """ lowered = name.lower() if lowered == ".env" or lowered.startswith(".env.") or lowered == ".envrc": return True return lowered in _SENSITIVE_MANAGED_FILE_BASENAMES def _is_sensitive_path(path: Path) -> bool: """Return True for any path the managed-files API must never expose. Combines the basename denylist (:func:`_is_sensitive_filename`) with a credential-directory-tree check: a path is sensitive if its own basename is sensitive OR any of its path components is a credential directory (``mcp-tokens`` / ``pairing``). The component match is case-insensitive and needs no HERMES_HOME resolution, so it blocks these trees wherever they sit under the operator-configured managed root — closing the gap the canonical guards cover as directory trees but a basename-only check would miss. Read-side only: this guards list/read/download (the #57505 exfil surface). The write endpoints (upload/mkdir/delete) are a separate threat class handled by the write-path checks; extending this guard to them is out of scope for this fix. """ if _is_sensitive_filename(path.name): return True return any(part.lower() in _SENSITIVE_MANAGED_DIR_NAMES for part in path.parts) _FS_TEXT_SOURCE_MAX_BYTES = 64 * 1024 * 1024 _FS_TEXT_PREVIEW_MAX_BYTES = 512 * 1024 # Upper bound for the in-app spot editor's save. The editor only opens # non-truncated text (<= the preview cap), so this is a safety ceiling against # a pasted-in megablob, not the expected payload size. _FS_TEXT_WRITE_MAX_BYTES = 8 * 1024 * 1024 _FS_PREVIEW_LANGUAGE_BY_EXT = { ".c": "c", ".conf": "ini", ".cpp": "cpp", ".css": "css", ".csv": "csv", ".go": "go", ".graphql": "graphql", ".h": "c", ".hpp": "cpp", ".html": "html", ".java": "java", ".js": "javascript", ".json": "json", ".jsx": "jsx", ".kt": "kotlin", ".lua": "lua", ".md": "markdown", ".mjs": "javascript", ".py": "python", ".rb": "ruby", ".rs": "rust", ".sh": "shell", ".sql": "sql", ".svg": "xml", ".toml": "toml", ".ts": "typescript", ".tsx": "tsx", ".txt": "text", ".xml": "xml", ".yaml": "yaml", ".yml": "yaml", ".zsh": "shell", } _FS_MIME_TYPES = { ".avi": "video/x-msvideo", ".bmp": "image/bmp", ".flac": "audio/flac", ".gif": "image/gif", ".jpeg": "image/jpeg", ".jpg": "image/jpeg", ".m4a": "audio/mp4", ".mkv": "video/x-matroska", ".mov": "video/quicktime", ".mp3": "audio/mpeg", ".mp4": "video/mp4", ".ogg": "audio/ogg", ".opus": "audio/ogg; codecs=opus", ".png": "image/png", ".svg": "image/svg+xml", ".wav": "audio/wav", ".webm": "video/webm", ".webp": "image/webp", } def _fs_mime_type(path: Path) -> str: suffix = path.suffix.lower() if suffix in _FS_MIME_TYPES: return _FS_MIME_TYPES[suffix] guessed, _ = mimetypes.guess_type(str(path)) return guessed or "application/octet-stream" def _fs_looks_binary(data: bytes) -> bool: if not data: return False if b"\0" in data: return True suspicious = sum(1 for byte in data if byte < 32 and byte not in {9, 10, 13}) return suspicious / len(data) > 0.12 def _fs_regular_file(path: Path) -> tuple[Path, os.stat_result]: target = _fs_path(str(path)) try: st = target.stat() except FileNotFoundError: raise HTTPException(status_code=404, detail="File not found") except NotADirectoryError: raise HTTPException(status_code=404, detail="File not found") except PermissionError: raise HTTPException(status_code=403, detail="File is not readable") except OSError as exc: raise HTTPException(status_code=400, detail=str(exc) or "Invalid path") if stat.S_ISDIR(st.st_mode): raise HTTPException(status_code=400, detail="Path points to a directory") if not stat.S_ISREG(st.st_mode): raise HTTPException(status_code=400, detail="Only regular files can be read") return target, st def _fs_find_git_root(start: Path) -> str | None: directory = start for _ in range(50): try: if (directory / ".git").exists(): return str(directory) except OSError: return None parent = directory.parent if parent == directory: return None directory = parent return None def _fs_default_cwd() -> str: cfg_terminal = load_config().get("terminal") or {} raw = str(cfg_terminal.get("cwd") or os.environ.get("TERMINAL_CWD") or "").strip() if raw and raw not in {".", "auto", "cwd"}: try: candidate = Path(raw).expanduser().resolve(strict=False) if candidate.is_dir(): return str(candidate) except (OSError, RuntimeError): pass return str(Path.cwd()) def _fs_git_branch(cwd: str) -> str: try: run_kwargs: Dict[str, Any] = { "capture_output": True, "text": True, "timeout": 2, "check": False, } if sys.platform == "win32": run_kwargs["creationflags"] = windows_hide_flags() result = subprocess.run( ["git", "-C", cwd, "branch", "--show-current"], **run_kwargs, ) return result.stdout.strip() if result.returncode == 0 else "" except Exception: return "" def _media_serve_roots() -> list[Path]: """Directories ``GET /api/media`` is allowed to read from. Confined to where the agent and attach pipeline actually write media on the gateway host — its images dir and cache subtree. This stops an authenticated client from reading image-extension files anywhere on disk (e.g. a renamed key or a screenshot outside the cache) merely because the suffix passes the allowlist. """ home = get_hermes_home() roots = [home / "images", home / "screenshots", home / "cache"] out: list[Path] = [] for root in roots: try: out.append(root.resolve()) except (OSError, RuntimeError): continue return out @router.get("/api/media") async def get_media(path: str): """Return a gateway-local image file as a base64 data URL. Lets remote clients (the desktop app over the network, or the web dashboard in a browser) display images the agent wrote to *this* machine's filesystem — they can't read the gateway's local disk directly. Auth-gated by the session token like every other /api route. Restricted to an image-extension allowlist, a size cap, AND the gateway's own media roots (resolved, symlink-safe) so it can't be used to read arbitrary files. """ try: target = Path(path).expanduser().resolve() except (OSError, RuntimeError): raise HTTPException(status_code=400, detail="Invalid path") if target.suffix.lower() not in _MEDIA_CONTENT_TYPES: raise HTTPException(status_code=415, detail="Unsupported media type") roots = _media_serve_roots() if not any(target == root or root in target.parents for root in roots): raise HTTPException(status_code=403, detail="Path outside media roots") if not target.is_file(): raise HTTPException(status_code=404, detail="File not found") if target.stat().st_size > _MEDIA_MAX_BYTES: raise HTTPException(status_code=413, detail="File too large") encoded = base64.b64encode(target.read_bytes()).decode("ascii") return {"data_url": f"data:{_MEDIA_CONTENT_TYPES[target.suffix.lower()]};base64,{encoded}"} def _local_dashboard_request(request: Request) -> bool: if getattr(request.app.state, "auth_required", False): return False host = (request.url.hostname or "").lower() client_host = (request.client.host if request.client else "").lower() local_hosts = {"", "localhost", "127.0.0.1", "::1", "testserver", "testclient"} return host in local_hosts or client_host in local_hosts def _decode_data_url(data_url: str) -> tuple[bytes, str]: from hermes_cli.web_server import _MANAGED_FILE_MAX_BYTES text = (data_url or "").strip() if not text.startswith("data:") or "," not in text: raise HTTPException(status_code=400, detail="Upload payload must be a data URL") header, encoded = text.split(",", 1) mime_type = header[5:].split(";", 1)[0] or "application/octet-stream" if ";base64" not in header: raise HTTPException(status_code=400, detail="Upload payload must be base64 encoded") try: data = base64.b64decode(encoded, validate=True) except (binascii.Error, ValueError): raise HTTPException(status_code=400, detail="Upload payload is not valid base64") if len(data) > _MANAGED_FILE_MAX_BYTES: raise HTTPException(status_code=413, detail="File is too large") return data, mime_type _CHAT_IMAGE_UPLOAD_MAX_BYTES = 25 * 1024 * 1024 _CHAT_IMAGE_ALLOWED_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}) _CHAT_IMAGE_MAGIC: tuple[tuple[bytes, str], ...] = ( (b"\x89PNG\r\n\x1a\n", ".png"), (b"\xff\xd8\xff", ".jpg"), (b"GIF87a", ".gif"), (b"GIF89a", ".gif"), (b"BM", ".bmp"), ) def _sanitize_chat_image_filename(filename: str | None) -> str: candidate = Path(str(filename or "").strip()).name candidate = re.sub(r"[\x00-\x1f]+", "_", candidate) candidate = candidate.strip().strip(".") return candidate or "pasted-image" def _chat_image_extension(data: bytes) -> str | None: head = data[:16] if head.startswith(b"RIFF") and head[8:12] == b"WEBP": return ".webp" for sig, ext in _CHAT_IMAGE_MAGIC: if head.startswith(sig): return ext return None def _decode_chat_image_upload(payload: ChatImageUpload) -> tuple[bytes, str, str]: data, mime_type = _decode_data_url(payload.data_url) if not mime_type.lower().startswith("image/"): raise HTTPException(status_code=400, detail="Upload payload must be an image") if len(data) > _CHAT_IMAGE_UPLOAD_MAX_BYTES: mb = _CHAT_IMAGE_UPLOAD_MAX_BYTES // (1024 * 1024) raise HTTPException(status_code=413, detail=f"Image is too large; cap is {mb} MB") ext = _chat_image_extension(data) if ext not in _CHAT_IMAGE_ALLOWED_EXTENSIONS: raise HTTPException(status_code=400, detail="Unsupported image type") return data, mime_type, ext @router.post("/api/chat/image-upload") async def upload_chat_image(payload: ChatImageUpload, profile: Optional[str] = None): """Persist a browser-provided chat image where the embedded TUI can read it. The dashboard /chat page runs Hermes inside an xterm.js PTY. Browser clipboard image bytes are not visible to the server-side clipboard, so the page uploads them here, then drives the TUI's ``/image `` command with the returned gateway-visible path. Files land under ``HERMES_HOME/images/`` — the same directory ``clipboard.paste`` / ``image.attach`` already use. """ def _run(): data, mime_type, ext = _decode_chat_image_upload(payload) with _profile_scope(profile) as scoped_home: home = scoped_home or get_hermes_home() img_dir = Path(home) / "images" try: img_dir.mkdir(parents=True, exist_ok=True) except PermissionError: raise HTTPException(status_code=403, detail="Image directory is not writable") except OSError as exc: raise HTTPException(status_code=500, detail=f"Could not create image directory: {exc}") stem = Path(_sanitize_chat_image_filename(payload.filename)).stem or "pasted-image" stem = re.sub(r"[^A-Za-z0-9_.-]+", "_", stem).strip("._-") or "pasted-image" ts = datetime.now().strftime("%Y%m%d_%H%M%S") target = img_dir / f"dashboard_{ts}_{secrets.token_hex(4)}_{stem}{ext}" try: target.write_bytes(data) except PermissionError: raise HTTPException(status_code=403, detail="Image directory is not writable") except OSError as exc: raise HTTPException(status_code=500, detail=f"Could not write image: {exc}") return { "ok": True, "path": str(target), "name": target.name, "bytes": len(data), "mime_type": mime_type, } # _profile_scope acquires _SKILLS_PROFILE_LOCK and the body does file I/O — # keep both off the event loop (asyncio.to_thread copies the contextvar # context, so the profile override stays scoped to the worker thread). return await asyncio.to_thread(_run) @router.get("/api/files") async def list_managed_files(request: Request, path: Optional[str] = None): policy, target, display_path = _resolve_managed_path(path, request) if not target.exists(): raise HTTPException(status_code=404, detail="Path not found") if not target.is_dir(): raise HTTPException(status_code=400, detail="Path is not a directory") try: with os.scandir(target) as scan: entries = [ _managed_file_entry(policy, Path(entry.path)) for entry in scan if not _is_sensitive_path(Path(entry.path)) ] except PermissionError: raise HTTPException(status_code=403, detail="Directory is not readable") except OSError as exc: raise HTTPException(status_code=500, detail=f"Could not read directory: {exc}") entries.sort(key=lambda item: (not item["is_directory"], str(item["name"]).lower())) locked_root = policy.locked_root parent = None if target.parent != target and (locked_root is None or target != locked_root): parent = str(target.parent) return { "path": display_path, "parent": parent, "entries": entries, **_managed_response_meta(policy), } @router.get("/api/files/read") async def read_managed_file(request: Request, path: str): from hermes_cli.web_server import _MANAGED_FILE_MAX_BYTES policy, target, display_path = _resolve_managed_path(path, request) if not target.exists(): raise HTTPException(status_code=404, detail="File not found") if not target.is_file(): raise HTTPException(status_code=400, detail="Path is not a file") if _is_sensitive_path(target): raise HTTPException(status_code=403, detail="Access to sensitive files is not allowed") try: size = target.stat().st_size except OSError as exc: raise HTTPException(status_code=500, detail=f"Could not stat file: {exc}") if size > _MANAGED_FILE_MAX_BYTES: raise HTTPException(status_code=413, detail="File is too large") mime_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream" try: encoded = base64.b64encode(target.read_bytes()).decode("ascii") except PermissionError: raise HTTPException(status_code=403, detail="File is not readable") except OSError as exc: raise HTTPException(status_code=500, detail=f"Could not read file: {exc}") return { "name": target.name, "path": display_path, "size": size, "mime_type": mime_type, "data_url": f"data:{mime_type};base64,{encoded}", **_managed_response_meta(policy), } def _managed_file_response( request: Request, path: str, *, content_disposition_type: str, media_only: bool = False, ) -> FileResponse: """Build a range-aware response after applying managed-file policy.""" from hermes_cli.web_server import _MANAGED_FILE_MAX_BYTES policy, target, _display_path = _resolve_managed_path(path, request) if not target.exists(): raise HTTPException(status_code=404, detail="File not found") if not target.is_file(): raise HTTPException(status_code=400, detail="Path is not a file") if _is_sensitive_path(target): raise HTTPException(status_code=403, detail="Access to sensitive files is not allowed") if media_only and target.suffix.lower() not in _STREAMABLE_MEDIA_EXTENSIONS: raise HTTPException(status_code=415, detail="Unsupported media type") try: size = target.stat().st_size except OSError as exc: raise HTTPException(status_code=500, detail=f"Could not stat file: {exc}") if size > _MANAGED_FILE_MAX_BYTES: raise HTTPException(status_code=413, detail="File is too large") mime_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream" return FileResponse( path=str(target), media_type=mime_type, filename=target.name, content_disposition_type=content_disposition_type, headers={"X-Content-Type-Options": "nosniff"} if media_only else None, ) @router.get("/api/files/download") async def download_managed_file(request: Request, path: str): """Stream a managed file as an attachment download. Remote clients (desktop app, browser dashboard) open agent-written files that live on *this* gateway's disk, not theirs. Auth-gated like every other managed-files route — ``auth_middleware`` additionally accepts the session token as a ``?token=`` query param here so a shell/browser-opened download (which can't set the session header) still authenticates. See ``/api/pty`` for the same query-token precedent. Chromium identifies ``