Instructions to use Wellz1000/hermes-2 with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- HERMES
How to use Wellz1000/hermes-2 with HERMES:
# No code snippets available yet for this library. # To use this model, check the repository files and the library's documentation. # Want to help? PRs adding snippets are welcome at: # https://github.com/huggingface/huggingface.js
- Notebooks
- Google Colab
- Kaggle
Download tools/image_source.py from Wellz1000/hermes-2: direct link, hf CLI and curl.
- Browser
- Download file 17 kB
-
https://huggingface.co/Wellz1000/hermes-2/resolve/main/tools/image_source.py
- Command line
-
hf download hf://Wellz1000/hermes-2/tools/image_source.py
-
curl -L -o image_source.py https://huggingface.co/Wellz1000/hermes-2/resolve/main/tools/image_source.py
17 kB
| """Single resolver for every media source -> bytes + mime. | |
| All source handling (data:/http(s)/file/local/container) funnels through | |
| :func:`resolve_image_source` so size and magic-byte checks are enforced exactly | |
| once. Returns raw bytes (not a path): the downstream step is base64 -> data URL | |
| (RFC 2397) and provider base64 content blocks. | |
| Images are the default and the historical purpose. Callers whose argument | |
| takes video opt in via ``permitted=("video",)`` — the same confinement and | |
| credential-guard pipeline applies, and only the type check at the end differs | |
| (extension-table typing plus an mp4 magic sniff, rather than image magic | |
| bytes). Every existing call site keeps the image-only default unchanged. | |
| Security (terminal-backend confinement, GHSA-gpxw-6wxv-w3qq): under a non-local | |
| terminal backend the file tools are confined to the sandbox (SECURITY.md 2.2), | |
| but vision read images host-side. This resolver enforces the same boundary: | |
| * local backend -> read any host path (chosen posture, unchanged) | |
| * non-local backend: | |
| path in a media cache -> host-read (the gateway/download caches live on | |
| the host and are bind-mounted into the sandbox) | |
| path anywhere else -> read the bytes *inside the sandbox* via exec-read | |
| (the agent can already ``cat`` any container file; | |
| this stays within the sandbox boundary and never | |
| reaches the host's ``/etc/passwd`` / ``~/.ssh``). | |
| So a prompt-injected ``vision_analyze('/etc/passwd')`` under Docker reads the | |
| *container's* file (what every other tool sees), not the host's — no escape — | |
| while container-only images (tmpfs ``/workspace``, root-owned) are still | |
| deliverable. This is the unified delivery + confinement model: the same | |
| mechanism that fixes "vision can't see container files" also closes the escape. | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import base64 | |
| import os | |
| import re | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Optional | |
| # Raw-bytes INGEST budget — what the resolver will load before handing off. | |
| # This is deliberately the 50MB download cap (tools/vision_tools._VISION_MAX_DOWNLOAD_BYTES), | |
| # NOT the 20MB provider payload cap. The 20MB cap (_MAX_BASE64_BYTES) is a | |
| # *post-resize* limit enforced at the call sites: an oversized raw image must | |
| # still reach the resizer so it can be downscaled under the payload cap. Capping | |
| # raw bytes at 20MB here would reject every 20-50MB photo before resize can run. | |
| _MAX_INGEST_BYTES = 50 * 1024 * 1024 | |
| class ImageResolutionError(Exception): | |
| def __init__(self, message: str, *, src: str = "", origin: str = ""): | |
| super().__init__(message) | |
| self.src, self.origin = src, origin | |
| class UnsupportedScheme(ImageResolutionError): | |
| pass | |
| class SourceUnsafe(ImageResolutionError): # SSRF / path-allowlist | |
| pass | |
| class SourceTooLarge(ImageResolutionError): | |
| pass | |
| class SourceNotFound(ImageResolutionError): | |
| pass | |
| class NotAnImage(ImageResolutionError): | |
| pass | |
| class ResolveContext: | |
| task_id: Optional[str] = None | |
| class ResolvedImage: | |
| data: bytes | |
| mime: str | |
| origin: str # one of: data | http | file | local | container | |
| # Explicit URL scheme, e.g. "ftp://", "s3://". Bare Windows drive paths | |
| # ("C:\x.png") don't match because they lack the "//". | |
| _SCHEME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]*://") | |
| async def resolve_image_source( | |
| src: str, | |
| ctx: ResolveContext, | |
| *, | |
| permitted: tuple = ("image",), | |
| ) -> ResolvedImage: | |
| if not isinstance(src, str) or not src.strip(): | |
| raise SourceNotFound("image_url is required", src=str(src)) | |
| s = src.strip() | |
| if s.startswith("data:"): | |
| data, mime = _resolve_data_url(s) | |
| return _finalize(data, mime, "data", s, permitted) | |
| if s.startswith(("http://", "https://")): | |
| reason = _http_block_reason(s) | |
| if reason: | |
| raise SourceUnsafe(reason, src=s) | |
| return _finalize(await _download_to_bytes(s), "", "http", s, permitted) | |
| if _SCHEME_RE.match(s) and not s.lower().startswith("file://"): | |
| raise UnsupportedScheme( | |
| "Unrecognized image source scheme. Use an http(s) URL, a local " | |
| "file path, a file:// URI, or a data: URL.", | |
| src=s, | |
| ) | |
| # Everything else is a filesystem path — including bare relative names | |
| # like "pic.png" (accepted on main; a path-shape gate here regressed them). | |
| candidate = s[len("file://"):] if s.lower().startswith("file://") else s | |
| p = Path(os.path.expanduser(candidate)) | |
| # Confinement decision (see module docstring). Under a non-local backend | |
| # a path is host-readable ONLY if it lands in a media cache (after | |
| # translating a container-visible cache path back to its host mount); | |
| # every other path is read inside the sandbox via exec-read, so a host | |
| # path outside the caches never yields the host's bytes. | |
| host_target = _permitted_host_read_target(p, ctx) | |
| if host_target is not None and host_target.is_file(): | |
| # Shared credential-read guard (agent.file_safety, #57698): refuse | |
| # secret-bearing files (.env, auth.json, ...) with an intentional, | |
| # specific error instead of relying on the magic-byte sniff to | |
| # reject them incidentally. Same chokepoint the image-gen/video-gen | |
| # provider plugins enforce on model-supplied local paths. Import is | |
| # best-effort (guard unavailability must not break image loading); | |
| # a real block always propagates. | |
| try: | |
| from agent.file_safety import raise_if_read_blocked | |
| except Exception: # noqa: BLE001 — guard unavailable: proceed | |
| raise_if_read_blocked = None | |
| if raise_if_read_blocked is not None: | |
| try: | |
| raise_if_read_blocked(str(host_target)) | |
| except ValueError as exc: | |
| raise SourceUnsafe(str(exc), src=s, origin="file") | |
| data = await asyncio.to_thread(host_target.read_bytes) | |
| return _finalize(data, "", "file", s, permitted) | |
| if _is_local_terminal_backend(): | |
| # Local backend: any path was host-readable, so a miss simply means | |
| # the file doesn't exist — no sandbox to fall back to. | |
| raise SourceNotFound(f"media file not found: '{p}'", src=s, origin="file") | |
| # Not a permitted host read (or the host file is absent) -> read the | |
| # bytes inside the sandbox. Under a sandbox this reads the container's | |
| # filesystem, never the host's. | |
| return await _resolve_container_fallback(p, ctx, s, permitted) | |
| def _resolve_data_url(s: str) -> tuple[bytes, str]: | |
| header, _, payload = s.partition(",") | |
| if ";base64" not in header: | |
| raise NotAnImage("data: URL must be base64-encoded", src=s[:64]) | |
| declared = header[len("data:"):].split(";", 1)[0].strip() or "application/octet-stream" | |
| # Cheap pre-decode size gate on the encoded length (~4/3 expansion). | |
| if (len(payload) * 3) // 4 > _MAX_INGEST_BYTES: | |
| raise SourceTooLarge("data: URL exceeds size limit", src=s[:64]) | |
| try: | |
| data = base64.b64decode(payload, validate=True) | |
| except Exception as exc: | |
| raise NotAnImage(f"invalid base64 in data: URL: {exc}", src=s[:64]) | |
| return data, declared # real mime verified in _finalize via magic bytes | |
| def _http_block_reason(url: str) -> Optional[str]: | |
| """Return a human-readable block reason, or None when the URL is allowed. | |
| Pre-flight short-circuit: policy-blocked URLs are refused BEFORE any | |
| network I/O. ``_download_image`` re-checks policy internally (per attempt | |
| and against the final redirect target) — that second evaluation is | |
| intentional, not redundant: this one guarantees no bytes move for a | |
| blocked URL; the inner one covers redirects and non-resolver callers. | |
| Preserves the specific website-policy message so the agent sees *why*. | |
| """ | |
| from tools.url_safety import is_safe_url | |
| from tools.website_policy import check_website_access | |
| if not is_safe_url(url): | |
| return "blocked: unsafe or private URL" | |
| blocked = check_website_access(url) | |
| if blocked: | |
| return blocked.get("message") or "blocked by website policy" | |
| return None | |
| async def _download_to_bytes(url: str) -> bytes: | |
| import tempfile | |
| from tools.vision_tools import _download_image | |
| with tempfile.NamedTemporaryFile(suffix=".img", delete=False) as tf: | |
| tmp = Path(tf.name) | |
| try: | |
| # Enforces the 50MB stream cap, redirect SSRF guard, and website policy. | |
| await _download_image(url, tmp) | |
| return await asyncio.to_thread(tmp.read_bytes) | |
| except PermissionError as exc: # website policy block | |
| raise SourceUnsafe(str(exc), src=url, origin="http") | |
| finally: | |
| tmp.unlink(missing_ok=True) | |
| def _is_local_terminal_backend() -> bool: | |
| """True when the terminal backend runs directly on the host. | |
| Mirrors ``tools.browser_tool._is_local_backend`` and terminal_tool's own | |
| dispatch, which key off ``TERMINAL_ENV``. | |
| """ | |
| return os.getenv("TERMINAL_ENV", "local").strip().lower() in ("local", "") | |
| def _media_cache_roots() -> list: | |
| """Agent-managed media cache directories under HERMES_HOME (host side). | |
| The only host paths vision may read under a non-local backend: gateway- | |
| downloaded inbound media and the tools' own URL-download temp dirs. Covers | |
| the consolidated ``cache/`` layout and the legacy flat directories. | |
| """ | |
| from hermes_constants import get_hermes_home | |
| home = get_hermes_home() | |
| return [ | |
| home / "cache", # cache/images, cache/vision, cache/video(s), cache/audio | |
| home / "images", # desktop/clipboard/PDF uploads (tui_gateway) — #69575 | |
| home / "image_cache", | |
| home / "audio_cache", | |
| home / "video_cache", | |
| home / "temp_vision_images", | |
| home / "temp_video_files", | |
| ] | |
| def _permitted_host_read_target(p: Path, ctx: ResolveContext) -> Optional[Path]: | |
| """Return the host path to read, or ``None`` if a host read is not permitted. | |
| - Local backend: any path is permitted (chosen posture). Returns ``p``. | |
| - Non-local backend: permitted only if the path resolves inside a media | |
| cache root. A container-visible cache path (e.g. ``/root/.hermes/cache/ | |
| images/x.png``) is first translated back to its host mount; anything that | |
| is not under a cache returns ``None`` so the caller routes it to the | |
| in-sandbox exec-read instead of reading the host filesystem. | |
| """ | |
| if _is_local_terminal_backend(): | |
| try: | |
| return p.resolve() | |
| except Exception: # noqa: BLE001 — unresolved path: let is_file() fail downstream | |
| return p | |
| from tools.credential_files import from_agent_visible_cache_path | |
| host_candidate = Path(from_agent_visible_cache_path(str(p))) | |
| try: | |
| real = host_candidate.resolve() | |
| except Exception: # noqa: BLE001 — cannot resolve -> not a safe host read | |
| return None | |
| for root in _media_cache_roots(): | |
| try: | |
| real.relative_to(root.resolve()) | |
| return real | |
| except ValueError: | |
| continue | |
| return None | |
| def _get_active_env(task_id: Optional[str]): | |
| if not task_id: | |
| return None | |
| try: | |
| from tools.terminal_tool import get_active_env | |
| return get_active_env(task_id) | |
| except Exception: | |
| return None | |
| async def _resolve_container_fallback( | |
| p: Path, ctx: ResolveContext, src: str, permitted: tuple = ("image",) | |
| ) -> ResolvedImage: | |
| """Read the image bytes inside the sandbox (fail-closed when none exists). | |
| Reached when a host read is not permitted or the host file is absent. The | |
| agent can already ``cat`` any container file (file_operations.py reads | |
| root-owned mode-600 files this way), so this stays within the same sandbox | |
| boundary and never touches the host filesystem. ``--`` stops a leading-dash | |
| path from being parsed as a ``base64`` option; ``base64 -w0`` is GNU-only, | |
| so pipe through ``tr -d`` for BusyBox. | |
| Fail-closed: if there is no active sandbox env we refuse rather than falling | |
| back to a host read, so a non-cache host path under a sandbox never leaks. | |
| """ | |
| import asyncio | |
| import shlex | |
| env = _get_active_env(ctx.task_id) | |
| if env is None: | |
| raise SourceNotFound( | |
| f"'{p}' is not reachable inside the sandbox and no active sandbox " | |
| f"session is available to read it", | |
| src=src, origin="container") | |
| # Bound the read INSIDE the sandbox: head -c caps at ingest-limit+1 bytes | |
| # so a huge file (or /dev/zero) can't stream unbounded base64 into host | |
| # memory — the +1 byte lets us distinguish "exactly at the cap" from | |
| # "over the cap" after decode. The input redirect (< path) avoids argv | |
| # entirely, so leading-dash paths can't be parsed as options; base64 | |
| # -w0 is GNU-only, so pipe through tr -d for BusyBox. | |
| # env.execute is a blocking backend exec; keep it off the event loop so a | |
| # multi-MB base64 read doesn't stall every other coroutine. | |
| qp = shlex.quote(str(p)) | |
| res = await asyncio.to_thread( | |
| env.execute, | |
| f"head -c {_MAX_INGEST_BYTES + 1} < {qp} | base64 | tr -d '\\n'") | |
| if res.get("returncode", 1) != 0: | |
| raise SourceNotFound(f"could not read '{p}' inside the sandbox", src=src, origin="container") | |
| try: | |
| data = base64.b64decode(res.get("output", ""), validate=True) | |
| except Exception as exc: | |
| raise NotAnImage(f"sandbox returned non-image data for '{p}': {exc}", src=src) | |
| if len(data) > _MAX_INGEST_BYTES: | |
| raise SourceTooLarge("media exceeds size limit", src=src, origin="container") | |
| return _finalize(data, "", "container", src, permitted) | |
| def _finalize( | |
| data: bytes, declared_mime: str, origin: str, src: str, permitted: tuple = ("image",) | |
| ) -> ResolvedImage: | |
| """Intrinsic-correctness chokepoint: ingest byte cap + type check. | |
| The cap here is the generous 50MB *ingest* budget, not the 20MB provider | |
| payload cap — a 20-50MB image must survive this step so the call site can | |
| resize it under the payload cap. See ``_MAX_INGEST_BYTES``. | |
| Images are typed by magic bytes. Video (opt-in via ``permitted``) is typed | |
| by the extension table plus an mp4 container sniff: extension typing is | |
| sufficient because every downstream consumer re-validates — the upload | |
| gateway signs the content type into its presigned URL and the vendor | |
| rejects undecodable input — so a wrong guess is a clean rejection there | |
| rather than a hole here. | |
| """ | |
| from tools.vision_tools import _detect_image_mime_type_from_bytes | |
| if len(data) > _MAX_INGEST_BYTES: | |
| raise SourceTooLarge("media exceeds size limit", src=src, origin=origin) | |
| sniffed = _detect_image_mime_type_from_bytes(data) | |
| if sniffed is not None: | |
| if "image" not in permitted: | |
| raise NotAnImage("source is an image, but this argument takes a video", src=src, origin=origin) | |
| return ResolvedImage(data=data, mime=sniffed, origin=origin) | |
| if "image" in permitted and b"<svg" in data[:4096].lower(): | |
| # Pass SVG through — the vision call sites rasterize it to PNG | |
| # via _normalize_to_supported_image before embedding (providers | |
| # only ingest raster images). | |
| return ResolvedImage(data=data, mime="image/svg+xml", origin=origin) | |
| if "video" in permitted: | |
| video_mime = _detect_video_mime(data, src) | |
| if video_mime is not None: | |
| return ResolvedImage(data=data, mime=video_mime, origin=origin) | |
| raise NotAnImage("source is not a recognized video (mp4 expected)", src=src, origin=origin) | |
| raise NotAnImage("source is not a recognized image", src=src, origin=origin) | |
| def _detect_video_mime(data: bytes, src: str) -> Optional[str]: | |
| """Video MIME from the extension table, else the mp4/mov container magic. | |
| The magic fallback covers extensionless sources (data: URLs, URLs with | |
| query strings): ISO base-media files carry ``ftyp`` at offset 4. | |
| """ | |
| from urllib.parse import urlsplit | |
| from tools.vision_tools import _detect_video_mime_type | |
| path_part = urlsplit(src).path if _SCHEME_RE.match(src) else src | |
| by_extension = _detect_video_mime_type(Path(path_part)) | |
| if by_extension is not None: | |
| return by_extension | |
| if len(data) > 12 and data[4:8] == b"ftyp": | |
| return "video/mp4" | |
| return None | |