image_generate and video_generate forwarded model-supplied local paths to
provider plugins, which read them off the HOST filesystem regardless of
terminal backend — inconsistent with the confinement boundary vision/video
analysis enforce (GHSA-gpxw-6wxv-w3qq), and broken for sandbox-only files.
New dispatch-layer chokepoint (_confine_source_images): under a non-local
backend, path-like image_url / reference_image_urls resolve through
tools.image_source (media-cache host reads, bounded in-sandbox exec-read,
lazy env bring-up, credential guard, 50MB cap) and reach every provider as
data: URLs — which all backends already accept. URLs/data: pass through;
local backend is a no-op. xai_video_edit/extend already require public
HTTPS URLs, so no change needed there.
Under the Docker terminal backend, vision_analyze's first exec-read
sometimes returned empty / non-zero against a freshly started container,
producing 'could not read <path> inside the sandbox' on a file the agent
could cat seconds later. Cold pipe setup on the first exec against a
new container, not a permissions or mount problem.
Retry once after a short delay (150 ms covers Docker exec warm-up
without making a real failure feel sluggish). When every attempt still
fails, fold the container's first stderr line into the raised error so
the user can tell 'no such file' from 'permission denied' instead of
staring at one opaque message.
Tests cover the retry-then-succeed path, the diagnostic-on-exhausted
path, and confirm the existing single-attempt raise is preserved.
vision_analyze reads container-only images by exec-reading them inside the
sandbox, but unlike terminal_tool it never triggered environment creation. Under
a non-local backend (ssh, docker, ...), a session whose first action was
vision_analyze on a remote path failed with 'no active sandbox session' until an
unrelated terminal command happened to establish the connection.
Add terminal_tool.ensure_task_env(task_id), a public lazy get-or-create that
reuses the terminal tool's own creation machinery, and call it from
image_source._resolve_container_fallback before the in-sandbox read. Extract the
ssh/container config-dict builders so both paths derive settings identically
(no duplication). Best-effort and fail-closed: a failed bring-up leaves the
existing 'no active sandbox' error intact, never a host read.
Fixes#62825
* fix(vision): mount images/ upload dir into sandboxes and permit host read (#69575)
Desktop, clipboard, and PDF uploads land in the flat top-level
HERMES_HOME/images/ dir, but Docker sandboxes only mounted the cache/
subtree and the vision resolver only permitted host reads from the media
caches. So vision_analyze on any desktop-app upload failed under a Docker
backend with "not reachable inside the sandbox".
- Add ("images", "images") to _CACHE_DIRS so the uploads dir is bind-mounted
into sandbox containers through the existing profile-scoped cache-mount and
reverse-mapping mechanism.
- Add home/"images" to _media_cache_roots() so the non-local host-read
allowlist permits reading uploads directly from the host filesystem.
- Cover the mount entry, the container path mapping, and the Docker-mode
resolver read for a profile-scoped upload.
Co-authored-by: JonthanaHanh <92574114+JonthanaHanh@users.noreply.github.com>
Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
* fix(tui_gateway): write image uploads under the session's profile home (#69575)
The attach RPCs (image.attach_bytes, clipboard.paste, pdf.attach) wrote
uploads to the gateway's module-cached launch home via _hermes_home/"images".
Those RPCs run before prompt.submit installs the session's profile HERMES_HOME
override, so in a multi-profile / root-gateway deployment the file landed in
the launch home while the sandbox mount and the vision host-read allowlist
both resolve the session profile's images/ at run time — the agent could
never see the upload it was handed.
Add _session_images_dir(session), which anchors the write on the session's
stored profile_home when present (matching the mount/read scope) and falls
back to the launch home otherwise. Route both write sites through it, keeping
per-profile isolation.
Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
---------
Co-authored-by: JonthanaHanh <92574114+JonthanaHanh@users.noreply.github.com>
Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
video_analyze_tool's local-path branch read raw bytes via
_detect_video_mime_type (extension-only, no magic-byte check) with no
call to agent.file_safety.raise_if_read_blocked, unlike the image-gen
and video-gen provider plugins that already route local inputs through
that shared chokepoint (#57698). A model could point video_url at a
credential store (e.g. .env, auth.json) renamed or symlinked to a
video-like extension and have its raw bytes base64-encoded and sent to
the vision provider.
vision_analyze_tool and its native fast path (_vision_analyze_native)
had the same gap in their local-file branches; they were only
incidentally protected by the image magic-byte sniff rejecting
non-image content, not by the intended read guard.
Add raise_if_read_blocked() to all three local-file branches, mirroring
the existing plugins/image_gen and plugins/video_gen call sites.
The container exec-read piped the whole file through base64 with no size
guard — the 50MB cap was only enforced host-side AFTER the full payload
had already streamed into host memory. A prompt-injected read of a huge
container file (or /dev/zero) could balloon the gateway process.
head -c (cap+1) bounds the read inside the sandbox; the +1 byte lets the
host distinguish at-cap from over-cap and reject with SourceTooLarge.
Input redirect replaces 'base64 --' (no argv exposure at all for
leading-dash paths). Docker integration tests re-verified live.
vision_analyze embedded SVG (and BMP/TIFF) tool-results into conversation
history with media_type image/svg+xml. Anthropic only accepts jpeg/png/
gif/webp, so the request fails with a non-retryable 400. Because the image
is baked into immutable history and re-sent every turn, the session is
permanently wedged on resume — retries re-send the same bad bytes.
Add _normalize_to_supported_image(): SVG is rasterized to PNG (best-effort
via cairosvg/svglib/rsvg-convert/inkscape), other non-supported raster
formats are re-encoded to PNG via Pillow, and if conversion is impossible
the tool returns an actionable error instead of a session-wedging payload.
Wired into both the native-vision fast path and the auxiliary-API path so
the whole bug class is covered, not just the one call site.
All 99 existing vision tests pass.
- resolve_image_source: bare cwd-relative filenames ('pic.png') resolve
again (main accepted them; the path-shape gate regressed them — review
by egilewski). Unknown explicit schemes (ftp://, s3://) still rejected.
- Local backend: nonexistent path now raises a clean 'image file not
found' instead of a misleading sandbox-fallback message.
- W4: remove the now-dead path-based _detect_image_mime_type (suffix-trust
SVG acceptance) so future callers can't reintroduce it.
- W3: SVG sources rejected with a dedicated actionable message + test.
- Polish: host read_bytes / temp-file write_bytes offloaded via
asyncio.to_thread (matches the container exec-read); unused
ResolveContext.cfg/extra_roots fields dropped; duplicate policy check
documented as intentional pre-flight short-circuit.
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>