Skip to content

fix(vision): unify vision_analyze image-source resolution through one resolver - #35362

Closed
banditburai wants to merge 17 commits into
NousResearch:mainfrom
banditburai:worktree-fix+vision-analyze-image-sources
Closed

fix(vision): unify vision_analyze image-source resolution through one resolver#35362
banditburai wants to merge 17 commits into
NousResearch:mainfrom
banditburai:worktree-fix+vision-analyze-image-sources

Conversation

@banditburai

Copy link
Copy Markdown
Contributor

fix(vision): unify vision_analyze image-source resolution through one bytes-returning resolver

Closes #7571
Closes #25118
Closes #29643
Closes #22328
Closes #32709
Addresses #9077 (delivery path only — see Caveats)
Supersedes #30197
Supersedes #14990

TL;DR

vision_analyze / mcp_vision_analyze now load images reliably from every source — data: URLs, http(s), file://, local paths, and Docker-container-only paths — instead of returning "no image attached" / "Invalid image source". This is a byte-delivery fix, not a routing change: both call sites now funnel through one resolver tools/image_source.py that returns raw bytes through a single correctness chokepoint. Container files are reached two ways — a cache-dir host fast-path and a universal docker exec base64 exec-read fallback. Pure tools/ layer; agent/image_routing.py is deliberately untouched. Security hardening (a readable-root allowlist) is intentionally out of scope here and left as a no-op seam for a possible follow-up.

Problem

The six clustered issues looked like four unrelated bugs (data:, file://, local paths, sandbox paths), but they share one root cause.

vision_analyze read image bytes in-process on the host (Path.read_bytes(), no subprocess), while read_file / terminal / execute_code read through the sandbox via env.executedocker exec. So when a file lives in the Docker terminal backend, every other tool can see it — and vision is the only tool reading the wrong filesystem. The reporter of #22328 attributed this to "vision runs in an isolated mount/process namespace"; that is incorrect (vision is in-process on the host). The real boundary is host-vs-sandbox, and it is the same mechanism behind #32709.

A secondary problem: each source branch re-implemented its own ingestion (tools/vision_tools.py:551 _vision_analyze_native and :605 vision_analyze_tool diverged), so size caps, MIME sniffing, and error handling drifted between them. Routing was a red herring — the routing flip was investigated and dropped as non-load-bearing; all six issues fail at byte-delivery.

Solution / Architecture

One resolver, both sites delegate to it:

  • resolve_image_source(src, ctx: ResolveContext) -> ResolvedImage (tools/image_source.py:66). ResolvedImage(data: bytes, mime, origin) with origin ∈ {data, http, file, local, container} (:60). Both vision sites call it (tools/vision_tools.py:575, :669); the resolver always returns raw bytes regardless of source.
  • Branches: data: (base64) · http(s) (reuses tools/url_safety.is_safe_url SSRF guard + the existing 50 MB download cap) · file:// · local path · container.
  • _finalize chokepoint (:204): the single place enforcing intrinsic correctness — a generous _MAX_INGEST_BYTES = 50 MB ingest cap (:23) plus a magic-byte sniff. The cap is deliberately the 50 MB ingest budget, not the 20 MB provider payload cap, so a 20–50 MB image survives to be resized rather than hard-rejected.
  • Container delivery, two mechanisms:
    1. host fast-path — cache-dir reverse map tools/credential_files.py:405 from_agent_visible_cache_path (container→host, beside its existing forward twin), called at image_source.py:160.
    2. universal fallback — _resolve_container_fallback (:174) runs base64 -- <shlex.quoted path> | tr -d '\n' via env.execute, wrapped in asyncio.to_thread, fail-closed when no active env.
  • bytes-core refactor: encode/resize/sniff helpers now operate on bytes; the Path-signature wrappers (_resize_image_for_vision, _image_to_base64_data_url) are preserved so external callers (tools/browser_tool.py, agent/conversation_compression.py) are untouched.
  • Pure tools/ layer: agent/image_routing.py is intentionally unchanged.

Scope & Stats

Issue → fix mapping

Issue Symptom Fixed by Confidence
#7571 no file:// / local path support strip file:// + _looks_like_path → file/local branch reads bytes (image_source.py:79) fully fixed
#25118 TG image, macOS local backend cache lands host-readable (cache/images/), direct read; reverse-map not load-bearing on local backend fully fixed
#29643 TG cached image "no image attached", Ubuntu local same path as #25118 + unified encode via _finalize (:204) fully fixed
#22328 all local files + browser screenshots fail (zh) reframe: host↔sandbox boundary (= #32709), not namespace isolation → cache reverse-map (:160) + exec-read (:174) fixed; inherits #32709 preconditions
#32709 Docker terminal backend, image not sent exec-read fallback _resolve_container_fallback (:174) + cache reverse-map; task_id threaded dispatch→handler→resolver, seam locked by regression test fixed; exec-read verified against real Docker (integration test, not in CI — see Caveats)
#9077 "no image" for URL/local/screenshot/data: unified resolver covers all source types + _finalize delivery only (see Caveats)

#22328 reframe: the reporter's stated cause (vision in an isolated mount/process namespace) is wrong — vision_analyze runs in-process on the host. The actual cause is the host-vs-sandbox filesystem boundary, identical to #32709, and the same exec-read mechanism resolves both.

Supersedes

  • fix(vision): accept base64 data: URLs in vision_analyze #30197 (data: URL support via a temp file, validate=False, no magic-byte check) → ours: _resolve_data_url (:98) decodes straight to bytes (no temp file), validate=True, with an authoritative magic-byte sniff in _finalize. Strict superset — nothing lost, validation added.
  • Fix/vision sandbox path resolution #14990 (sandbox path translation via TERMINAL_CWD / docker_volumes host rewrites — only worked when a host mount backed the path) → ours: cache reverse-map + universal exec-read reaches container-only files (tmpfs, root-owned mode-600) that Fix/vision sandbox path resolution #14990 could never read. The explicit docker_volumes / TERMINAL_CWD host rewrite is absorbed by exec-read (same bytes); the allowlist portion is out of scope here (see below).

(No other source-resolver PRs exist — searched vision_analyze in:title, all states.)

Caveats / honest scope

Behavior changes

Change Before → After Why Risk / mitigation User-visible
20–50 MB images hard-reject → resize through _MAX_INGEST_BYTES=50 MB (:23); 20 MB provider cap enforced post-resize at call sites covered by an incompressible-PNG resize regression test yes
Policy-block error message generic → specific website-policy message preserved _http_block_reason (:113) keeps the reason unit-tested in test_image_source.py yes
Bare relative path (cat.png, no /, ./, ~) probed against process CWD (nondeterministic) → UnsupportedScheme (:91) a clear deterministic error beats CWD-guessing newly disclosed; use ./cat.png or an absolute path yes
SVG for vision (varied) → rejected (magic-byte sniff → None) no provider ingests SVG as vision; the path detector keeps SVG-by-text for non-resolver callers deliberate; isolated to the resolver yes
Vision-site temp-file bookkeeping manual should_cleanup at both sites → lifecycle in _download_to_bytes finally + unlink(missing_ok=True) (:141) single ownership, no leak / double-unlink internal refactor no

Security

  • Exec-read injection neutralized: base64 -- {shlex.quote(path)} (:194) — -- stops a leading-dash path being parsed as a base64 option; tr -d '\n' handles BusyBox (no GNU -w0).
  • Fail-closed: no active sandbox env → resolution refuses rather than probing the host (covered by a fail-closed unit test).
  • SSRF retained: redirect re-validation and the 50 MB stream cap stay in _download_image (called from _download_to_bytes, :138).

Testing

236 tests pass.

python -m pytest \
  tests/tools/test_image_source.py \
  tests/tools/test_credential_files.py \
  tests/tools/test_vision_tools.py \
  tests/tools/test_vision_bytes_helpers.py \
  tests/tools/test_vision_native_fast_path.py \
  tests/agent/test_image_routing.py \
  tests/test_model_tools_async_bridge.py -q
  • test_image_source.py (new) — every resolve branch: data: / http / file / local / container, SSRF reject, oversize, leading-dash injection neutralization, fail-closed (no env); asserts the policy-block message is preserved.
  • test_vision_bytes_helpers.py (new) — magic-byte sniff (incl. SVG → None), base64 encode, resize-noop under cap.
  • test_vision_native_fast_path.py (mod) — fast-path gating matrix, 20–50 MB oversize-resize regression (incompressible PNG), and the task_id seam.
  • test_credential_files.py (mod) — from_agent_visible_cache_path reverse-map: docker / non-docker / unmapped.
  • test_model_tools_async_bridge.py (mod) — [Bug]: 'Exception Event loop is closed' after vision_analyze used as first call to hermes in chat session/some chained tool calls #2104 loop-safety, repatched to resolver seams _http_block_reason / _download_to_bytes.
  • test_vision_tools.py (mod) — call-site integration.

Docker integration (integration-marked, excluded from the default suite): tests/integration/test_vision_docker_resolve.py (new) carries pytest.mark.integration, so addopts = -m 'not integration' deselects it from normal runs; it auto-skips when no Docker daemon is present and gets a 180s timeout (repo convention, mirroring tests/docker/conftest.py). It exercises the real-Docker exec-read round-trip for #32709 — a tmpfs /workspace file (no host path) and a root-owned mode-600 file — and is not part of the 236. Verified green against a real Docker daemon (29.4.0), so #32709 / #22328 are fixed-by-test, not just by construction. Run:

pytest -m integration tests/integration/test_vision_docker_resolve.py

Out of scope

This PR is scoped to byte delivery. The items below are intentionally not included — the resolver leaves a clean seam (_within_allowed_roots) so the security piece can be added later without reworking the delivery path.

  • Readable-root allowlist — the natural next step: replace the no-op _within_allowed_roots seam (:83 / :148) with real root enforcement, a vision.allowed_image_roots config, and a threat model. The fail-open-vs-fail-closed choice is an open design decision, and any such work must not regress the user-typed-path workflows that mcp_vision_analyze does not support local file paths (file://) #7571 / [Bug]: vision_analyze 工具无法读取任何本地文件(browser 截图也无法分析) #22328 / Vision tool cannot analyze images sent via Telegram gateway #25118 depend on, nor reject an untranslated /workspace path before the container → exec-read route runs.
  • docker_volumes / /workspaceTERMINAL_CWD host rewrite — not added; no issue exercises it and the exec-read fallback already reaches those paths (same bytes).
  • Decompression-bomb guard — not added. It is only meaningful at the Pillow decode site (_resize_image_bytes_for_vision), and the 50 MB ingest cap already bounds file size; if ever wanted, it belongs as a conditional check there, not a blanket requirement.
  • Pillow stays optional — resize degrades gracefully (skips and returns a clear "install Pillow" error). Forcing a heavy native dependency on installs that never use vision is not justified, and a bomb guard only matters when Pillow is already present.

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists tool/vision Vision analysis and image generation backend/docker Docker container execution labels May 30, 2026

@tonydwb tonydwb left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary: PR #35362

Verdict: APPROVE

Overall: Significant, well-architected refactor that unifies vision image-source resolution through a single resolve_image_source bytes-returning chokepoint in tools/image_source.py. Closes 6 issues, supersedes 2 previous PRs. 851 additions, 306 deletions across 10 files.

✅ Looks Good

  • Architecture: Single resolve_image_source(src, ctx)ResolvedImage(data, mime, origin) — a single correctness chokepoint replacing divergent implementations in two vision call sites
  • Source coverage: Handles data: (base64), http(s) (with existing SSRF guard reuse), file://, local path, and container paths — closes all 6 linked issues
  • Container delivery, dual mechanism:
    1. Host fast-path via from_agent_visible_cache_path reverse map
    2. Universal exec-read fallback with base64 -- injection neutralization + shlex.quote
  • Fail-closed: No active sandbox env → resolution refuses rather than probing host
  • _finalize chokepoint: Magic-byte sniff + 50 MB ingest cap (20-50 MB images resize through rather than hard-rejecting)
  • SSRF retained: Reuses existing is_safe_url and redirect re-validation
  • Honest scope documentation: Clearly marks #9077 as "Addresses" not "Closes", acknowledges CDP-level and routing-level gaps
  • 236 tests pass + dedicated Docker integration test for exec-read round-trip
  • Test coverage: New test_image_source.py (every resolve branch, SSRF reject, oversize, injection neutralization), test_vision_bytes_helpers.py (magic-byte sniff, base64, resize)
  • SVG rejection: Rejected by magic-byte sniff — correct, no provider ingests SVG as vision
  • Clean behavior changes table: Every change in behavior documented with before/after/why/risk
  • Out-of-scope documentation: Lists readable-root allowlist, docker_volumes rewrite, decompression-bomb guard as future work — prevents scope creep

💡 Suggestions

  1. The Docker integration test (test_vision_docker_resolve.py) is marked integration and excluded from CI — consider adding a lightweight smoke test that can run without Docker (e.g., mocking env.execute) to gate CI
  2. Consider adding _within_allowed_roots as a no-op seam for now but documenting the expected vision.allowed_image_roots config key name for future use — makes the follow-up simpler

Testing Completeness

  • 236 unit tests pass
  • Integration test verified against real Docker 29.4.0
  • Regression tests for: resize (incompressible PNG), injection neutralization, fail-closed, oversize, all resolve branches

Reviewed by Hermes Agent

@tonydwb

tonydwb commented May 30, 2026

Copy link
Copy Markdown

Code Review Summary: PR #35362

Verdict: APPROVE

Overall: Significant, well-architected refactor that unifies vision image-source resolution through a single resolve_image_source bytes-returning chokepoint in tools/image_source.py. Closes 6 issues, supersedes 2 previous PRs. 851 additions, 306 deletions across 10 files.

✅ Looks Good

  • Architecture: Single resolve_image_source(src, ctx)ResolvedImage(data, mime, origin) — a single correctness chokepoint replacing divergent implementations in two vision call sites
  • Source coverage: Handles data: (base64), http(s) (with existing SSRF guard reuse), file://, local path, and container paths — closes all 6 linked issues
  • Container delivery, dual mechanism:
    1. Host fast-path via from_agent_visible_cache_path reverse map
    2. Universal exec-read fallback with base64 -- injection neutralization + shlex.quote
  • Fail-closed: No active sandbox env → resolution refuses rather than probing host
  • _finalize chokepoint: Magic-byte sniff + 50 MB ingest cap (20-50 MB images resize through rather than hard-rejecting)
  • SSRF retained: Reuses existing is_safe_url and redirect re-validation
  • Honest scope documentation: Clearly marks [vision_analyze] Cannot read local images or URL images - tool returns "no image" for all image sources #9077 as "Addresses" not "Closes", acknowledges CDP-level and routing-level gaps
  • 236 tests pass + dedicated Docker integration test for exec-read round-trip
  • Test coverage: New test_image_source.py (every resolve branch, SSRF reject, oversize, injection neutralization), test_vision_bytes_helpers.py (magic-byte sniff, base64, resize)
  • SVG rejection: Rejected by magic-byte sniff — correct, no provider ingests SVG as vision
  • Clean behavior changes table: Every change in behavior documented with before/after/why/risk
  • Out-of-scope documentation: Lists readable-root allowlist, docker_volumes rewrite, decompression-bomb guard as future work — prevents scope creep

💡 Suggestions

  1. The Docker integration test (test_vision_docker_resolve.py) is marked integration and excluded from CI — consider adding a lightweight smoke test that can run without Docker (e.g., mocking env.execute) to gate CI
  2. Consider adding _within_allowed_roots as a no-op seam for now but documenting the expected vision.allowed_image_roots config key name for future use — makes the follow-up simpler

Testing Completeness

  • 236 unit tests pass
  • Integration test verified against real Docker 29.4.0
  • Regression tests for: resize (incompressible PNG), injection neutralization, fail-closed, oversize, all resolve branches

Reviewed by Hermes Agent

…rsions wrap them

Task: fix+vision-analyze-image-sources-1f3.15
Task: fix+vision-analyze-image-sources-1f3.27
…d errors

Task: fix+vision-analyze-image-sources-1f3.16
Task: fix+vision-analyze-image-sources-1f3.17
…PR1)

Task: fix+vision-analyze-image-sources-1f3.18
…fail-closed

Task: fix+vision-analyze-image-sources-1f3.19
…a leading-dash path

Adds '--' to terminate option parsing so a path like './-i/etc/shadow' (which
expanduser normalizes to '-i/etc/shadow') can't be parsed as a base64 flag.
Found in adversarial review of the NousResearch#32709 exec-read fallback.

Task: fix+vision-analyze-image-sources-1f3.31
Task: fix+vision-analyze-image-sources-1f3.20
Routes vision_analyze_tool through resolve_image_source (adds data:/Docker,
unifies SSRF/policy with the native path). Removes the temp-file/cleanup
bookkeeping the bytes contract makes unreachable; threads task_id. Updates
the safety-guard tests to assert the same behavior at the resolver boundary
and drops the obsolete temp-cleanup test.

Task: fix+vision-analyze-image-sources-1f3.21
… source handling (review .33)

Task: fix+vision-analyze-image-sources-1f3.33
… regression tests)

Task: fix+vision-analyze-image-sources-1f3.22
…finalize split (Task 12 skip)

Task: fix+vision-analyze-image-sources-1f3.23
…rm + tmpfs cases

Task: fix+vision-analyze-image-sources-1f3.24
…(not reject)

- _MAX_BYTES(20MB) -> _MAX_INGEST_BYTES(50MB); the 20MB provider payload cap
  stays a post-resize limit at the call sites, so a 20-50MB photo reaches the
  resizer instead of being hard-rejected by _finalize (the plan's byte-cap
  reconciliation correction).
- preserve the specific website-policy block message via _http_block_reason
  instead of collapsing every block to a generic string.
- run the blocking container exec-read off the event loop (asyncio.to_thread).
- regression tests: oversize-resize end-to-end through _vision_analyze_native,
  within-ingest-budget resolves, over-ingest-budget rejected, policy message.
…sync + assertions

- test_model_tools_async_bridge: the URL-safety gate moved into the resolver,
  so the NousResearch#2104 loop-safety tests now patch tools.image_source._http_block_reason
  and _download_to_bytes (was patching the moved-away tools.vision_tools seams,
  which failed on a live DNS lookup).
- test_vision_native_fast_path: convert get_event_loop().run_until_complete to
  @pytest.mark.asyncio/await (matches suite style); strengthen the two fall-
  through gating tests to assert the aux path is actually taken (mock called +
  sentinel result), not merely 'not fast-path'; drop unused monkeypatch params
  and the now-dead asyncio import.
…espoke env var

Align with the repo convention instead of the invented HERMES_DOCKER_TESTS gate:
- pytest.mark.integration → excluded from the default suite by addopts
  (-m 'not integration'), as every other tests/integration/ file is.
- skipif(not _docker_available()) → auto-skip without a daemon (mirrors
  tests/docker/conftest.py's shutil.which + 'docker info' probe).
- pytest.mark.timeout(180) → container spin-up exceeds the 30s suite default;
  the bespoke gate would have let a real run be killed mid image-pull.
- Per-test unique task_id (request.node.name): DockerEnvironment derives the
  container from task_id, so the shared id made one test's teardown remove the
  other's container — the second test failed with 'No such container' when both
  ran. Surfaced only once the test actually executed against real Docker.

Both tests now pass against a real Docker daemon (verified locally); default
runs deselect them cleanly.
@banditburai
banditburai force-pushed the worktree-fix+vision-analyze-image-sources branch from c18c2d8 to d61a577 Compare June 1, 2026 17:07
@NovoG93

NovoG93 commented Jun 23, 2026

Copy link
Copy Markdown

any update on when this might be merged?

teknium1 pushed a commit that referenced this pull request Jul 4, 2026
…ment

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>
teknium1 pushed a commit that referenced this pull request Jul 4, 2026
…ment

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>
@teknium1

teknium1 commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Merged via PR #57890 — your unified image-source resolver design was the foundation of the final fix. The PR was 4140 commits stale and vision_tools.py had been rewritten on both sides, so the resolver was re-authored against current main rather than cherry-picked, but the architecture (single resolver for data:/http/file/local/container sources with one size/magic-byte chokepoint) is yours, and the deliberately-stubbed _within_allowed_roots seam you left became the terminal-backend confinement that closed GHSA-gpxw-6wxv-w3qq. Credited in the PR body. Thanks for the design work — it fixed six open bug reports and a security advisory in one shape.

habarmc1223-sudo pushed a commit to habarmc1223-sudo/hermes-agent-fluxmem that referenced this pull request Jul 8, 2026
…ment

Salvage of NousResearch#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 NousResearch#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 (NousResearch#7571, NousResearch#25118, NousResearch#29643, NousResearch#22328, NousResearch#32709, NousResearch#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 NousResearch#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 (NousResearch#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>
santhreal pushed a commit to santhreal/hermes-agent that referenced this pull request Jul 13, 2026
…ment

Salvage of NousResearch#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 NousResearch#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 (NousResearch#7571, NousResearch#25118, NousResearch#29643, NousResearch#22328, NousResearch#32709, NousResearch#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 NousResearch#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 (NousResearch#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>
Gravezzz pushed a commit to Gravezzz/hermes-agent that referenced this pull request Jul 21, 2026
…ment

Salvage of NousResearch#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 NousResearch#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 (NousResearch#7571, NousResearch#25118, NousResearch#29643, NousResearch#22328, NousResearch#32709, NousResearch#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 NousResearch#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 (NousResearch#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>
leewenjie pushed a commit to leewenjie/hermes-agent that referenced this pull request Aug 7, 2026
…ment

Salvage of NousResearch#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 NousResearch#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 (NousResearch#7571, NousResearch#25118, NousResearch#29643, NousResearch#22328, NousResearch#32709, NousResearch#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 NousResearch#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 (NousResearch#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend/docker Docker container execution P2 Medium — degraded but workaround exists tool/vision Vision analysis and image generation type/bug Something isn't working

Projects

None yet

5 participants