mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
Salvage of #35362, evolved to also close the vision sandbox-escape (GHSA-gpxw-6wxv-w3qq). The two were the same root cause — vision read image bytes host-side while every other tool reads through the terminal backend — so one resolver fixes both the delivery gaps and the escape. Delivery (from #35362, re-authored against current main since the branch was 4140 commits stale and vision_tools.py had been rewritten on both sides): - tools/image_source.py: one resolver for data:/http(s)/file/local/container image sources, returning raw bytes through a single magic-byte-sniff + 50MB-ingest chokepoint. Fixes 'no image attached' / 'Invalid image source' for every source type (#7571, #25118, #29643, #22328, #32709, #9077). - tools/credential_files.py: from_agent_visible_cache_path, the container->host cache reverse-map (inverse of the existing forward twin). - tools/vision_tools.py: both vision sites route through the resolver with task_id threaded from the handler; resolved bytes are materialized to a temp file so main's evolved encode/resize/embed-cap pipeline is reused verbatim (kept over the PR's older bytes-core resize to avoid touching browser_tool / conversation_compression callers). Security (fills #35362's deliberately-stubbed _within_allowed_roots seam): - Under a non-local terminal backend the file tools are confined to the sandbox (SECURITY.md 2.2), but vision read host-side — a prompt-injected vision_analyze('/etc/passwd') exfiltrated host secrets, and read_file even redirects the model to vision_analyze for image paths. The resolver now enforces the same boundary: local backend reads any host path (chosen posture); non-local backend host-reads ONLY the media caches under HERMES_HOME (where the gateway/download media lives) and routes every other path to an in-sandbox base64 exec-read — which reads the CONTAINER's file, the same one 'cat' would, never the host's. Paths are resolve()-d so a symlink can't escape a cache; fail-closed when no sandbox env exists. This closes the escape AND delivers container-only images (#32709) with the same mechanism. Tests: unified resolver + confinement model (tests/tools/test_image_source.py, incl. proof a non-cache host path under Docker yields container bytes not the host secret); existing vision tests updated to the resolver boundary; Docker integration test verified green against a real daemon (exec-read of a tmpfs /workspace file, a root-owned mode-600 file, and the host-secret invariant). Fixes GHSA-gpxw-6wxv-w3qq. Co-authored-by: banditburai <promptsiren@gmail.com>
191 lines
7.9 KiB
Python
191 lines
7.9 KiB
Python
"""Tests for tools/image_source.py — the unified vision image-source resolver.
|
|
|
|
Covers the delivery contract (data:/http/file/local/container source handling,
|
|
size cap, magic-byte sniff) AND the terminal-backend confinement security model
|
|
(GHSA-gpxw-6wxv-w3qq): under a non-local backend, host reads are confined to the
|
|
media caches and every other path is read inside the sandbox via exec-read.
|
|
"""
|
|
|
|
import base64
|
|
import importlib
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
|
|
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
|
|
JPEG = b"\xff\xd8\xff" + b"\x00" * 64
|
|
|
|
|
|
def _reload(monkeypatch, hermes_home: Path):
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
import hermes_constants
|
|
importlib.reload(hermes_constants)
|
|
import tools.image_source as isrc
|
|
importlib.reload(isrc)
|
|
return isrc
|
|
|
|
|
|
class TestDataUrl:
|
|
@pytest.mark.asyncio
|
|
async def test_valid_data_url_resolves_to_bytes(self, tmp_path, monkeypatch):
|
|
isrc = _reload(monkeypatch, tmp_path / "hermes")
|
|
b64 = base64.b64encode(PNG).decode()
|
|
res = await isrc.resolve_image_source(
|
|
f"data:image/png;base64,{b64}", isrc.ResolveContext())
|
|
assert res.data == PNG
|
|
assert res.mime == "image/png"
|
|
assert res.origin == "data"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_image_data_url_rejected(self, tmp_path, monkeypatch):
|
|
isrc = _reload(monkeypatch, tmp_path / "hermes")
|
|
b64 = base64.b64encode(b"not an image").decode()
|
|
with pytest.raises(isrc.NotAnImage):
|
|
await isrc.resolve_image_source(
|
|
f"data:text/plain;base64,{b64}", isrc.ResolveContext())
|
|
|
|
|
|
class TestLocalBackend:
|
|
@pytest.mark.asyncio
|
|
async def test_local_backend_reads_any_host_path(self, tmp_path, monkeypatch):
|
|
isrc = _reload(monkeypatch, tmp_path / "hermes")
|
|
monkeypatch.setenv("TERMINAL_ENV", "local")
|
|
img = tmp_path / "outside" / "pic.png"
|
|
img.parent.mkdir(parents=True)
|
|
img.write_bytes(PNG)
|
|
res = await isrc.resolve_image_source(str(img), isrc.ResolveContext())
|
|
assert res.data == PNG
|
|
assert res.origin == "file"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_file_uri_scheme_stripped(self, tmp_path, monkeypatch):
|
|
isrc = _reload(monkeypatch, tmp_path / "hermes")
|
|
monkeypatch.setenv("TERMINAL_ENV", "local")
|
|
img = tmp_path / "pic.jpg"
|
|
img.write_bytes(JPEG)
|
|
res = await isrc.resolve_image_source(f"file://{img}", isrc.ResolveContext())
|
|
assert res.mime == "image/jpeg"
|
|
|
|
|
|
class TestNonLocalBackendConfinement:
|
|
"""The security model: under a sandbox backend, host reads are confined to
|
|
the media caches; every other path is read inside the sandbox."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_media_cache_path_host_read(self, tmp_path, monkeypatch):
|
|
home = tmp_path / "hermes"
|
|
isrc = _reload(monkeypatch, home)
|
|
monkeypatch.setenv("TERMINAL_ENV", "docker")
|
|
cached = home / "cache" / "images" / "inbound.png"
|
|
cached.parent.mkdir(parents=True)
|
|
cached.write_bytes(PNG)
|
|
# No sandbox env needed — a cache path is host-read directly.
|
|
res = await isrc.resolve_image_source(str(cached), isrc.ResolveContext())
|
|
assert res.data == PNG
|
|
assert res.origin == "file"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_host_secret_outside_cache_routes_to_sandbox_not_host(self, tmp_path, monkeypatch):
|
|
"""A non-cache host path (e.g. /etc/passwd) must NOT be host-read — it
|
|
routes to the in-sandbox exec-read, which reads the CONTAINER's file."""
|
|
home = tmp_path / "hermes"
|
|
isrc = _reload(monkeypatch, home)
|
|
monkeypatch.setenv("TERMINAL_ENV", "docker")
|
|
|
|
# A real host file outside the caches, holding a "secret".
|
|
secret = tmp_path / "id_rsa"
|
|
secret.write_bytes(b"HOST-PRIVATE-KEY-DO-NOT-LEAK")
|
|
|
|
# Fake sandbox env: its exec-read returns a *different* (container) image,
|
|
# proving we read the container filesystem, not the host secret.
|
|
container_png_b64 = base64.b64encode(PNG).decode()
|
|
calls = {}
|
|
|
|
def fake_execute(cmd, **kw):
|
|
calls["cmd"] = cmd
|
|
return {"returncode": 0, "output": container_png_b64}
|
|
|
|
with patch("tools.image_source._get_active_env",
|
|
return_value=SimpleNamespace(execute=fake_execute)):
|
|
res = await isrc.resolve_image_source(str(secret), isrc.ResolveContext(task_id="t1"))
|
|
|
|
# Read came from the sandbox exec-read, returning the container image —
|
|
# the host secret bytes never appear.
|
|
assert res.origin == "container"
|
|
assert res.data == PNG
|
|
assert b"HOST-PRIVATE-KEY" not in res.data
|
|
assert "base64 --" in calls["cmd"] # option-injection-safe form
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_cache_path_fails_closed_without_sandbox(self, tmp_path, monkeypatch):
|
|
"""No active sandbox env -> refuse rather than fall back to a host read."""
|
|
home = tmp_path / "hermes"
|
|
isrc = _reload(monkeypatch, home)
|
|
monkeypatch.setenv("TERMINAL_ENV", "docker")
|
|
secret = tmp_path / "id_rsa"
|
|
secret.write_bytes(b"HOST-PRIVATE-KEY")
|
|
|
|
with patch("tools.image_source._get_active_env", return_value=None):
|
|
with pytest.raises(isrc.SourceNotFound):
|
|
await isrc.resolve_image_source(str(secret), isrc.ResolveContext(task_id="t1"))
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_symlink_in_cache_pointing_outside_is_not_host_read(self, tmp_path, monkeypatch):
|
|
"""A symlink planted inside a cache dir that points at a host secret must
|
|
not be host-read (resolve() escapes the cache) — it routes to sandbox."""
|
|
home = tmp_path / "hermes"
|
|
isrc = _reload(monkeypatch, home)
|
|
monkeypatch.setenv("TERMINAL_ENV", "docker")
|
|
secret = tmp_path / "outside" / "id_rsa"
|
|
secret.parent.mkdir(parents=True)
|
|
secret.write_bytes(b"HOST-PRIVATE-KEY")
|
|
cache_dir = home / "cache" / "images"
|
|
cache_dir.mkdir(parents=True)
|
|
link = cache_dir / "sneaky.png"
|
|
try:
|
|
link.symlink_to(secret)
|
|
except (OSError, NotImplementedError):
|
|
pytest.skip("symlinks unsupported")
|
|
|
|
# Fails closed (no sandbox) rather than host-reading the symlink target.
|
|
with patch("tools.image_source._get_active_env", return_value=None):
|
|
with pytest.raises(isrc.SourceNotFound):
|
|
await isrc.resolve_image_source(str(link), isrc.ResolveContext(task_id="t1"))
|
|
|
|
|
|
class TestExecReadSafety:
|
|
@pytest.mark.asyncio
|
|
async def test_exec_read_uses_option_terminator(self, tmp_path, monkeypatch):
|
|
"""Leading-dash paths must not be parsed as base64 options."""
|
|
home = tmp_path / "hermes"
|
|
isrc = _reload(monkeypatch, home)
|
|
monkeypatch.setenv("TERMINAL_ENV", "docker")
|
|
captured = {}
|
|
|
|
def fake_execute(cmd, **kw):
|
|
captured["cmd"] = cmd
|
|
return {"returncode": 0, "output": base64.b64encode(PNG).decode()}
|
|
|
|
with patch("tools.image_source._get_active_env",
|
|
return_value=SimpleNamespace(execute=fake_execute)):
|
|
await isrc.resolve_image_source(
|
|
"/workspace/-i-etc-shadow.png", isrc.ResolveContext(task_id="t1"))
|
|
assert "base64 -- " in captured["cmd"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exec_read_nonzero_returncode_raises(self, tmp_path, monkeypatch):
|
|
home = tmp_path / "hermes"
|
|
isrc = _reload(monkeypatch, home)
|
|
monkeypatch.setenv("TERMINAL_ENV", "docker")
|
|
|
|
def fake_execute(cmd, **kw):
|
|
return {"returncode": 1, "output": ""}
|
|
|
|
with patch("tools.image_source._get_active_env",
|
|
return_value=SimpleNamespace(execute=fake_execute)):
|
|
with pytest.raises(isrc.SourceNotFound):
|
|
await isrc.resolve_image_source(
|
|
"/workspace/nope.png", isrc.ResolveContext(task_id="t1"))
|