Chromium debug profile, media-cache and vision/computer-use cache dirs were
created with bare makedirs inheriting the umask (0755); their files landed
0644. Under the documented HERMES_HOME_MODE=0701 traversal hatch those stores
(Cookies, Login Data, screenshots, inbound media) were readable by every
other local account. Dirs are now created owner-only (0700) with the mode
passed to mkdir (no post-chmod window) and reconciled via the house
hermes_cli.config._secure_dir / _secure_file policy helpers, which keep the
managed/NixOS group-share carve-out and HERMES_UID/HERMES_GID ownership.
Cache files are written 0600 via os.open.
Deleting a session also left its journaled in-flight turn tail (user prompt
+ tool calls) in localStorage to age out on its own; purgeInFlightTurnJournals
now runs on both delete surfaces (sidebar removeSession and Settings
permanent delete), draining every id the delete holds.
Refs #77486
* refactor(plugins): remove the Sep 2026 decomposition compat layer on schedule
The PLUGIN-COMPAT layer (2776813df3 + d63e380324 + 0a5164cebe) kept pre-#102117 import paths
alive for external plugins until 2026-09-14. That window closed two weeks ago; since then the loader
has already been skipping plugins that use the old paths. This removes the layer itself:
- 328 appended `PLUGIN-COMPAT` blocks (lazy `__getattr__` pointer tables, re-exported third-party
names, restored dead definitions) and the three re-export stub modules
(gateway/startup_watchdog, hermes_cli/observability/relay_runtime, tools/environments/modal_utils)
- COMPAT_MANIFEST.md, compat_manifest.json, scripts/check_compat_pointers.py and its lint step
- the reporting surfaces: CLI banner notice, `hermes plugins compat`, the `hermes doctor` section,
the post-update notice, the Desktop one-time dialog, the loader's pre-import skip and the
`plugins.allow_deprecated_imports` escape hatch
An external plugin that still imports an old path now fails to load with its ImportError as the
reason in `hermes plugins list`, the same path as any broken plugin.
hermes_cli/plugin_compat.py stays as three inert stubs (compat_report, removal_in_effect,
summary_lines): an already-running pre-removal `hermes update` lazy-imports them after the checkout
swap (tests/compat/old_updater_surface.json).
In-tree fallout, both already dead: hermes_cli/setup.py::_check_espeak_ng (no callers; its
`shutil` came from a compat block) and gateway/config.py::SessionResetPolicy ("retained solely for
the scheduled plugin-compat window"). Two test_run_agent patches targeted the removed
`run_agent.handle_function_call` pointer; they now patch `model_tools.handle_function_call`, the
seam production reads, like every sibling test in that file.
* chore: retrigger CI (zero-job startup_failure phantom)
* test: drop resolution allowlist rows for the two deleted which() sites
hermes_cli/setup.py::_check_espeak_ng (dead) and tools/skillevaluator_scan.py::scanner_available
(a restored definition inside a PLUGIN-COMPAT block) no longer exist; the stale-row gate requires
their allowlist entries go with them.
Branch semantics kept where main and PM disagree: update_cmd_deps.py,
constraints-termux.txt, the Electron update-api-check module and the
post-swap hand-off test stay deleted; the pending-fleet-restart catch-up
and the local_runtime tag/download ladder stay retired (PM owns engines).
Ported from main onto the branch's shape: profile_scoped_chore for the
auto-archive and plugin-update housekeeping chores, the local-runtime
cross-process boot lock and residency cap, the checkpoint tmp_pack sweep,
the cua daemon-liveness status probe, the remote-served Desktop update
flag (posix.sh / windows.ps1), sign-in for env-pinned remote gateways
(urlDisabled on RemoteSetupFields), the uvloop extra split (uvicorn
without [standard]), and the umask-scoping spawn test.
uv.lock regenerated with pm.build_env --lock-only; new utf-8 reads from
main switched to utf-8-sig (check-windows-footguns).
The capture route (`tools/computer_use/vision_routing.py`) opened the native
multimodal lane only for providers on `_TOOL_RESULT_MEDIA_PROVIDERS` (or a
profile `supports_vision` flag), while the `vision_analyze` fast path also
accepted a catalog vision hit. For deepseek/deepseek-flash the two gates
returned opposite verdicts, so a screenshot paid a same-model auxiliary round
trip and the main model acted on prose instead of pixels, while vision_analyze
embedded the image natively (#115248).
`tools.vision_tools._accepts_tool_result_images(provider, model, cfg)` is now
the single predicate (profile veto first, then provider tool-result media OR
capability lookup) and both lanes call it. The lookup keeps the full
`_lookup_supports_vision` order (config override -> catalog -> local probes ->
profile), so Ollama/local VLMs are unaffected.
Supersedes #115276 (@Tranquil-Flow), whose direction this follows; its
models.dev-only lookup in the fast path would have dropped the local-runtime
and Ollama probes.
Resolved toward the branch: PM provisions uv/python (main's install.ps1 uv-shim
salvage + its test and workflow steps dropped), the shim re-exec stays retired,
package.json carries no electron-builder block (afterExtract identity stamp wired
into electron-builder.config.cjs instead; after-pack.mjs keeps signing only),
Desktop workspace-deps helpers stay retired. Main's scratch-dir bootstrap
(export_scratch_tmp_env) is taken and re-run after profile resolution.
When a surface (Telegram gateway, CLI, TUI, delegated child) attaches an
image natively to the user turn and the model then calls vision_analyze on
the same path, the native fast path embedded the identical pixels a second
time as a multimodal tool result in the same request (#76411).
conversation_loop.run_conversation now scopes the turn's native image
handles (the [Image attached at: ...] hints build_native_content_parts
writes) in a ContextVar for the duration of the turn; the tool answers with
a short text result instead. Region crops and other images still embed;
the scope ends with the turn so later turns can re-load after compression.
Reconcile plugin declarations and validation through PM's atomic generation publication; preserve external runtimes, target markers, and conflict refusal. Keep one source-update completion owner and port upstream lifecycle changes to the PM desktop/runtime paths.
repeat_refusal checked the counter and record_embed incremented it in separate
lock sections, so a concurrent tool batch on the same image (the incident issued
4 at once) all passed a check taken before any of them recorded and the cap of 3
let 6 embeds through. Reserve the slot inside the check's lock section and
release it when the embed then fails.
A delegate_task child asked to transcribe five screenshots called vision_analyze 158
times on those files (full loads alternating with region crops) until its provider
quota ran out; every native load bakes the image into history and is re-sent on each
later API call, and nothing refused the repeat (#112095).
tools/vision_tools_history_budget.py now keeps a per-(session id, resolved source)
embed counter. Region crops share their file's key. When the cap is reached
_vision_analyze_native returns a tool error that says the image has already been loaded
N times and to answer from what is visible or ask the user, instead of another embed.
Only successful embeds count, so a failed read never eats the budget.
Default: `vision.max_calls_per_image` unset caps only delegated subagents
(agent.delegation_context.is_delegated_child_process_context) at 3 — they run
unattended and CLI /steer targets the parent — while the main agent stays unlimited;
an explicit value applies everywhere and 0 means unlimited.
Co-authored-by: Evi Nova <66773372+Tranquil-Flow@users.noreply.github.com>
Co-authored-by: gumclaw <gumclaw@gumroad.com>
The native vision_analyze fast path (and the browser screenshot twins) downscaled every
embed to a fixed _EMBED_TARGET_BYTES = 256 KB. That is fine for photos but turns a
1080x2340 phone screenshot of a table into 540x1170, which the model then reads as
"unreadable" and re-requests (#112095).
The budget is now `vision.embed_target_bytes` in config.yaml (default unchanged: 256 KB,
clamped to 64 KiB..4 MiB so one setting cannot make every later request a multi-megabyte
resend), resolved in the new topical sibling tools/vision_tools_history_budget.py and read
by vision_analyze, browser_vision and browser_exec screenshots alike.
Ported from #112947 by @MohamadKanso (resolver + clamp), relocated out of the facade.
check_vision_requirements only asked the auxiliary resolver, so a vision-capable
main model on a provider the resolver cannot serve (minimax-oauth, local vLLM,
anything uncatalogued) lost vision_analyze and browser_vision from the tool list
even though both handlers already route to the native fast path and work when
called. The image gate now accepts the native fast path OR an aux client; the
aux-only probe becomes check_video_requirements and stays on video_analyze,
whose handler has no native path.
Fixes#47149.
check_vision_requirements (and five siblings: browser_vision, image/video
generation, x_search, browser_vault) wrapped their whole probe in
`except Exception: return False`. The registry then logged "returned False",
indistinguishable from an unconfigured backend, and the only diagnostic for a
crashed resolver was gone (#87950: named custom provider lookup failing in a
long-lived multi-profile process, reported as vision tools silently vanishing).
The registry owns the verdict: _run_check_fn_uncached and _check_fn_cached both
catch, log with traceback, and return False. Let the exception reach them.
Behaviour for the model is unchanged (tool hidden either way); agent.log now
says why.
The Sep 2026 decomposition (PR #102117) makes internal import paths a non-API: names now live in
the focused modules that define them. This commit is the ONLY thing keeping the old paths alive,
so external plugins have time to update. It is deliberately a single, unsquashed commit:
git revert <this sha>
removes every shim, stub and manifest at once on the announced date. Nothing in-tree may depend on
these pointers: scripts/check_compat_pointers.py (wired into lint.yml) fails CI if it does.
What it adds (see COMPAT_MANIFEST.md, compat_manifest.json):
- 332 facade modules get one delimited `PLUGIN-COMPAT` block appended at the end of the file
- 1,172 moved names resolved lazily via a module `__getattr__` (PEP 562) — never a top-level import,
so no import cycles; facades that already had `__getattr__` get a chained one
- 592 third-party/stdlib names the old modules used to expose, with their original import statements
- 266 public definitions that had been deleted as unused, restored byte-for-byte from the pre-decomposition
tree (+40 private helpers and 16 imports pulled in only because a restored definition needs them)
- 3 deleted modules recreated as re-export stubs (gateway/startup_watchdog, hermes_cli/observability/
relay_runtime, tools/environments/modal_utils)
- private names (`_x`) get no pointer: they were never API (3,792 skipped)
Verified: all 335 touched modules import under a fresh HERMES_HOME and every manifest name resolves;
the lint reports zero in-tree uses; ruff clean; targeted suites unchanged.
For each issue anchor present in BASE 63279301bc non-test .py and absent on HEAD, the BASE comment/docstring block was re-attached at the HEAD location of the code it explained (matched by the distinctive code line / enclosing def). Sentences already covered by an existing HEAD comment were deduped; the issue number always survives. Insert-only: no code lines changed.
ethernet8023: TestValidateImageUrl (incl. localhost block) was deleted with the sync
validator. Both names restored with BASE semantics; the async validator (the live
download path) now shares _image_url_shape_ok and gets its own localhost/malformed test.
A ProviderProfile that declares supports_vision_tool_messages=False accepts
images in user messages but rejects list-type tool-result content with 400
(xiaomi/MiMo "text is not set"). supports_vision=True alone used to flip
_supports_media_in_tool_results to True, and a vision-capable capability
lookup could re-open _should_use_native_vision_fast_path — so the native
multimodal envelope landed in a role:tool message and 400'd every turn.
Both gates now go through one _profile_rejects_tool_media() veto.
Refs #89981
(cherry picked from commit daed88f940a6a475f12bb435498e48181da60f4d, trimmed)
tools/lazy_deps.py was deleted by the pm migration but 8 call sites
still imported it — slack and feishu lazy-install were dead (ImportError
swallowed by platform_registry's ensure_deps_fn guard, platform stayed
disabled with a warning), and cua/anthropic/read_extract/vision/
dashboard self-heal paths silently skipped their installs.
- slack: pm.extras.ensure_and_bind('slack', ...) (slack extra)
- feishu: pm.extras.available / pm.ensure_import('feishu')
- cua_backend: pm.ensure_import('computer-use')
- anthropic_adapter: pm.ensure_import('anthropic')
- read_extract: pm.ensure_import('doc-extract')
- vision_tools: pm.ensure_import('vision') + new 'vision' -> PIL anchor
(extra is a no-op alias, Pillow is core; anchor keeps availability
checks working)
- web_server dashboard import: pm.ensure_import('web')
- web_server memory-provider pip install surface: the deleted
install_specs pipeline is replaced with an actionable manual remedy
status until the plugin-deps workspace bridge wires this surface
- wecom callback docstring: drop the stale LAZY_DEPS vocabulary
test_pin_lockstep now asserts the core pin and doc-extract extra pin
of firecrawl-anydoc agree (pyproject is the single pin authority);
new tests/pm/test_no_lazy_deps.py keeps the migration complete with a
tree-wide contract (scripts/release.py credit comment + the dashboard
legacy surface documented as allowed).
tests/pm 144 passed; touched lazy-install suites green; remaining
tests/plugins failures proven pre-existing on the unmodified tree
(Windows host artifacts).
Run models locally as a first-class provider. The CLI grows a managed
llama.cpp runtime (engine install, model download, server supervision);
the desktop app grows the full setup and management story on top of it.
GUI surfaces ship behind the desktop --local launch flag (hermes desktop
--local, or the flag on the packaged app); backend routes and the CLI
are always live.
Runtime (hermes_cli/local_runtime/):
- curated GGUF catalog with per-machine variant selection: hardware
probe (VRAM/RAM/UMA), fit planning with spill accounting, quant choice
by context window
- derived recommendation: quality-ranked picks gated by a predicted
decode-speed floor, bandwidth-aware on unified memory; the decision
table is pinned as a test (pick AND reason per memory class), and the
Recommended badge explains its pick in a tooltip fed by the resolver's
actual branch
- engine install + model download with resumable split parts, cumulative
plan-level progress, and staged-model integrity (a split GGUF counts
only when every part is present)
- server supervision: spawn/adopt/stop, router mode with per-model load
progress relayed over SSE, abandoned-request cleanup
Desktop:
- Settings -> Providers -> Local models: one-click quickstart (install
engine, download the recommended model, boot) plus per-model download/
activate/eject, fit-ranked catalog with context pills
- model pickers (composer dropdown + Cmd+K) show staged local models,
in-flight downloads as live progress rows, and load-into-memory bars
- local-setup campaign tip for eligible hardware; System resources
statusbar widget (GPU/VRAM/RAM); in-chat load progress during sends
- friendly dead-server errors, and failed agent builds retry on the next
send instead of wedging the session
Co-developed with NVIDIA field feedback on RTX 5090 and DGX Spark.
Pillow is an optional dependency in this codebase (every other PIL use in
tools/vision_tools.py imports lazily and falls back). Both salvaged
validators now distinguish 'PIL missing' (pass through, header-only
sniff) from 'decode failed' (reject), so a Pillow-less install keeps
working instead of rejecting every PNG.
Follow-up to salvaged #53307 (@CannibalKush) and #76896 (@HaiyiMei).
Review pass findings on the force_jpeg change:
- Broaden the JPEG mode guard from {RGBA, P} to 'not in {RGB, L}':
force_jpeg newly routes PNG inputs to the JPEG encoder, and an
LA-mode PNG (grayscale+alpha) would crash img.save() with
'cannot write mode LA as JPEG'.
- browser_use_cli's _native_screenshot_result is the THIRD native
history-embed site: it baked the data URL into a _multimodal tool
result with the 5 MB one-shot default and no dimension cap. Apply
the same 256KB/1568px/force_jpeg history-reuse policy as the two
sites already migrated.
PNG has no quality ladder, so a text-dense screenshot over the 256KB
history-embed cap (#92699 / #92783) could only shrink by halving
dimensions — 1568px dropped to ~784px and on-screen text became
unreadable, the exact fidelity screenshot QA depends on.
Add force_jpeg to _resize_image_for_vision: the two history-embed call
sites (vision_analyze native, browser_vision native) re-encode
resize-needing screenshots as JPEG so the quality ladder (85/70/50)
absorbs the byte pressure and the readable resolution survives.
Under-cap images are untouched and stay PNG; one-shot/reactive paths
keep their existing format behavior.
Flagged during the #92783 salvage review.
browser_vision's native fast path base64-encoded screenshots at full
resolution and baked them into the tool result uncapped — the exact
sibling of the vision_analyze path #92699 fixed. Apply the same
proactive 256KB/1568px resize before the embed enters reusable history.
Fail-open by design: without Pillow the resize helper falls back to raw
bytes and the compressor's keep-newest pass still retires stale embeds.
Sibling-gap follow-up for the #92725 salvage; the shared-cap approach
mirrors the policy-owner idea from #92748.
Co-authored-by: joaomarcos <joaomarcosdias444@gmail.com>
vision_analyze baked up to 4 MB / 7900px screenshots into immutable
history, so every later turn re-sent ~400K chars. Cap embeds at 256 KB
and 1568px (the long edge models actually read) so screenshot QA no
longer blows the context.
The WebP silent-drop fix covered direct image attachments but missed the
second route to the same cliff: desktop drag-and-drop attaches a note
telling the model to call vision_analyze, whose native fast path embeds
the image into conversation history through its own normalization
(_normalize_to_supported_image). That set was cloud-shaped — WebP
'supported' — so the WebP data-URL reached the managed server inside a
multimodal tool result, was dropped silently by its decoder, and the
model confabulated. Symptom: asked about a dragged-in medal photo, the
model described the Hermes desktop app itself — its training-data prior
for 'screenshot attached to a chat conversation'.
Normalization now consults the same managed-runtime accepted set as
attachment routing (one constant, hermes_cli.local_runtime.capabilities.
ACCEPTED_IMAGE_MIMES): WebP converts to PNG before it enters history for
a managed main model; cloud providers keep native WebP. This also
protects the embedded-in-history bytes, so a session resumed later
cannot replay an undecodable part.
Contract test covers both providers through the real normalization
function.
The vision tools' call_kwargs hardcode max_tokens caps (2000 for
vision_analyze/browser_vision, 4000 for video analysis), truncating
descriptions of complex images at the cap. The centralized aux client
already omits max_tokens by default (#34845) so providers use their
model max output; these three call sites were the leftovers that
bypassed that policy.
Remove the hardcoded caps entirely — the aux client handles the
mandatory-max_tokens Anthropic wire via _resolve_anthropic_messages_max_tokens
(model output ceiling) and Gemini native omits maxOutputTokens (65K ceiling),
so no wire needs an explicit cap.
Cold CLI time-to-banner was ~1.8s (hermes) / ~2.8s (hermes -w). The banner
path was paying for work the session doesn't need before first input:
- aux availability probes built REAL OpenAI/httpx clients (openai import
~0.3s + SSL context) just to answer check_fns. New aux_probe_mode()
returns a cache-excluded stub; resolution policy unchanged.
- tools/mcp_tool imported the mcp SDK (~260ms, mcp.types pydantic model
construction) at module import even with zero MCP servers configured.
SDK import is now lazy behind _ensure_mcp_sdk(); _MCP_AVAILABLE is a
find_spec probe so every existing gate/test keeps its semantics.
- banner blocked 500ms on the update-check prefetch; now waits 50ms and
defers the warning line to a daemon thread (prints above the prompt).
- banner recomputed get_tool_definitions + skills scan + git state every
launch; now snapshotted to ~/.hermes/cache/banner_snapshot.json keyed on
(config.yaml, .env, checkout rev, toolsets) and replayed on warm launches
with a background refresh. Agent tool list is still computed fresh.
- _resolve_active_context_length probed the Nous portal /models (~200ms
network) per launch; the tool-search gate now prefers the on-disk
context cache when present.
- schema reconciliation re-executed SCHEMA_SQL in a scratch SQLite DB
(~85ms) per SessionDB(); the reference parse is now disk-memoized by
DDL hash (live-DB diffing still runs every startup).
- bundled-skills sync (~120-170ms rglob/hash) moved off the startup path
to a daemon thread; plugin discovery starts in the background and every
synchronous consumer joins via discover_plugins().
- hermes_cli.auth imported httpx eagerly (~30ms); now a lazy proxy that
test monkeypatching still reaches (setattr forwards to the real module).
- fast chat launch: unambiguous 'hermes'/'hermes chat' invocations skip
building all ~40 subcommand parsers (bails to full dispatch on anything
else, incl. container mode).
- -w path: git worktree add runs with checkout.workers=8 (0.6s→0.2s) and
overlaps HermesCLI construction; --skills preload runs in the background
and is folded in at agent init (finalize_preloaded_skills, same
fail-loud contract for fully-unknown skill lists); stale-worktree prune
moved off the banner path.
Warm results (PTY time-to-banner, 5-run): hermes 1.80s → 0.38-0.40s;
hermes -w -s hermes-agent-dev --yolo 2.82s → 0.57-0.69s.
Follow-up on the salvaged commit: replace the hand-rolled file_ops python3
exec-read with tools.image_source.resolve_image_source(permitted=('video',)),
so video_analyze gets the same pipeline as vision_analyze — media-cache host
reads, bounded head -c sandbox exec (no python3 dependency in the sandbox
image, no unbounded base64 stream), lazy env bring-up (#62825), the
credential-read guard, and the 50MB ingest cap.
Add an optional `region: [x1, y1, x2, y2]` parameter to vision_analyze
(pixel coordinates in the ORIGINAL image space). The crop is applied
with Pillow BEFORE the downscale/embed-cap pipeline, so the cropped
region gets the full resolution budget — a zoom for reading small text
or UI details after a full shot.
- New `_crop_image_region` helper: clamps out-of-bounds coordinates to
the image, rejects zero-area/inverted/malformed regions with an error
naming the actual image dimensions so the model can retry sensibly.
- Wired into both the native fast path (`_vision_analyze_native`) and
the legacy aux-LLM path (`vision_analyze_tool`).
- Schema gains one static optional param (byte-stable thereafter); the
description documents the intended flow: full shot first, then zoom.
- No region supplied = behavior unchanged (regression-guarded).
Tests: tests/tools/test_vision_region.py (11 tests — crop applied,
clamping, zero-area rejection with dims, malformed input, pre-downscale
full-budget zoom, schema shape, handler pass-through, no-region
unchanged). Widened one narrow fake_native stub in test_vision_tools.py
to be kwargs-tolerant.
Ported from: QwenLM/qwen-code zoom-image.ts (Apache-2.0)
_download_image() and _download_video() both used client.get() +
response.content, buffering the entire media body into memory before
checking the size cap. A server that omits Content-Length could send
an arbitrarily large payload, causing OOM.
Extract _stream_download_to_file() shared helper: streams via
client.stream() + aiter_bytes(), writes chunks to a temp file, enforces
the running byte count against the cap after each chunk, and atomically
replaces onto the destination on success. Cleans up the temp file on
failure. Uses utils.atomic_replace() for cross-device/symlink safety.
Malformed Content-Length values are now caught and ignored instead of
crashing with ValueError; the streaming cap is the authoritative guard.
Approach adapted from PR #10440 by @WuKongAI-CMU (closed as stale —
14923 commits behind, reverted 32 commits of vision_tools.py evolution
including SSRF-safe client, retry classification, and lazy imports).
Closes#10440
Four deferrals following the established truthy-skip / PEP 562
lazy-load patterns (PRs #22681/#22859 lineage). Rebased over #74194,
which independently landed the browser_tool half of this work — that
file is dropped here; the remaining four modules are untouched by it:
- tools/vision_tools.py: defer agent.auxiliary_client
(credential_pool -> hermes_cli.auth -> httpx -> rich, ~50 ms) to
first vision handler call. async_call_llm /
extract_content_or_reasoning stay patchable module attributes;
injected test mocks win over the loader.
- agent/model_metadata.py: defer 'requests' (+urllib3, ~27 ms of the
'import cli' waterfall) to the fetch functions. PEP 562 __getattr__
keeps patch('agent.model_metadata.requests.get') working.
- tools/browser_supervisor.py: websockets (~22 ms) imports on first
CDP connect; ClientConnection type under TYPE_CHECKING.
- cron/jobs.py: croniter (~15 ms) resolves on first cron-expression
use; HAS_CRONITER stays monkeypatchable (None = unprobed sentinel).
A/B vs current main incl. #74194 (median of 7, cold subprocess):
import cli 147 -> 132 ms (-10%)
import model_tools 244 -> 224 ms (-8%)
import run_agent 264 -> 244 ms (-8%)
Lazy-verify: importing the four modules no longer pulls requests /
croniter / websockets into sys.modules. 369 targeted tests green
post-rebase.
Install connect-time DNS validation for Hermes-owned direct httpx clients so SSRF-sensitive fetch paths dial a vetted IP instead of re-resolving after preflight. This preserves Host/SNI semantics for direct HTTP(S) connections and keeps proxy routing as an explicit trusted egress boundary.
Wire the guarded clients into media cache downloads, vision downloads, Skills Hub direct/raw fetches, and platform attachment fetch paths that already perform SSRF preflight and redirect validation.
Fixes#8033
Co-authored-by: Tom Qiao <zqiao@microsoft.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.