fix(sandbox): enforce execution authorization for image reads (#5799)

* fix(sandbox): enforce execution authorization for image reads

* refactor(sandbox): share image injection authorization plan

* docs(agents): trim image middleware guidance to fit CI budget

---------

Co-authored-by: YxinMiracle <“939157765@qq.com”>
This commit is contained in:
YxinMiracle 2026-09-24 09:30:43 +08:00 committed by GitHub
parent a0d9744d5a
commit 45cf6e86b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 282 additions and 13 deletions

View File

@ -1275,6 +1275,8 @@ Each message can still select up to 1000 documents. When more than 100 documents
Advanced deployments can enable pluggable authorization with `authorization.enabled` in `config.yaml`. A configured `AuthorizationProvider` filters denied tools before they reach the model or deferred-tool catalog, then the same provider is checked again before every business-tool execution through the existing guardrail middleware. Gateway `threads:*` and `runs:*` route permissions are derived from the same provider, while existing owner checks and admin-only management gates remain in force. Every HTTP route that starts or enables a future Agent run requires `runs:create`: this includes the stateless `POST /api/runs/stream` and `POST /api/runs/wait` endpoints plus scheduled-task create, update, resume, and manual-trigger mutations. Scheduled-task mutations retain their existing `threads:write` requirement, and the stateless routes separately enforce ownership when the optional thread ID is supplied in the request body. A generated `tool_search` may bypass the second tool check only when it fronts the current build's already-filtered deferred catalog. Model access follows the same provider: the Gateway `models` list is filtered per principal, `model:use` is enforced on model detail requests and again when the runtime resolves the agent's model, and a denied default model falls back to the first remaining candidate that also passes `model:use`. The built-in RBAC provider supports per-role `tools`, `routes`, `models`, `skills`, and `sandbox` allow/deny policies and validates that `default_role` names a configured role; authorization is disabled by default. See `config.example.yaml` and the [authorization RFC](docs/plans/2026-07-10-pluggable-authorization-rfc.md).
For vision-capable agents, `view_image` and the subsequent model-context image read also require `sandbox:execute`. Allowing the tool name alone does not grant image-file access; a role denied sandbox execution cannot reread previously recorded image metadata after its permissions change.
Advanced deployments can also extend the agent runtime itself by declaring `AgentMiddleware` classes under `extensions.middlewares` in `config.yaml` or `extensions_config.json`. Each entry is a `module.path:ClassName` string (zero-argument constructor) or an object `{class, kwargs}` whose `kwargs` are passed to the constructor. `kwargs` values must be JSON types (object, array, string, number, boolean, or null); YAML dates and timestamps are coerced to ISO strings so they match JSON. DeerFlow loads the same configured list into the lead-agent and subagent pipelines after their built-in runtime middlewares and loop/token guards, but before the terminal-response/safety/clarification tail, so enterprise forks can add domain guardrails, tool-call governance, or observability hooks without patching the built-in middleware builders. Missing packages, invalid classes, broken modules, and constructor errors fail loudly at agent creation. Treat `config.yaml` and `extensions_config.json` as trusted operator-controlled files: middleware paths are code execution, just like custom tool, model, sandbox, guardrail, MCP server, and MCP interceptor declarations. Gateway skill/MCP toggle endpoints preserve this field but do not expose an API write path for `extensions.middlewares`. Separate lead-only/subagent-only middleware lists are not supported yet.
For packaged and configurable runtime integrations, use DeerFlow's extension manager.

View File

@ -109,7 +109,7 @@ Before changing a later authorization phase, read the [authorization RFC](../../
21. **TokenUsageMiddleware** - *(optional, if `token_usage.enabled`)* Records token usage metrics; subagent usage is read from terminal `ToolMessage.additional_kwargs` in the current run and merged back into the dispatching AIMessage by message position. The same state update marks the ToolMessage with `subagent_token_usage_attributed=true`, so checkpoint replay or middleware re-entry cannot add the cumulative snapshot twice; missing/malformed usage or a result with no matching dispatch remains unmarked and retryable.
22. **TitleMiddleware** - Auto-generates the thread title after the first complete exchange and normalizes structured message content before prompting the title model. If a first-turn run is interrupted before this middleware can write a title, `runtime/runs/worker.py` keeps the run in a finalizing state, persists a local fallback title from the latest checkpoint or original run input, and then syncs it to `threads_meta.display_name`. Replacement runs admitted by `multitask_strategy="interrupt"` / `"rollback"` wait for older same-thread finalization before entering the graph; the interrupted run only skips the fallback title write once a later run has started and may have advanced the checkpoint.
23. **MemoryMiddleware** - Queues conversations for async memory update (filters to user + final AI responses); captures the runtime-resolved user so standalone LangGraph Server reads and writes stay in the same bucket
24. **ViewImageMiddleware** - *(optional, if the model supports vision)* Appends a hidden HumanMessage with base64 image data, identified by a reserved ID prefix plus a server-owned metadata marker, to `ModelRequest.messages` in `wrap_model_call` / `awrap_model_call`. The payload lives only in that request and is never returned as a state update, so no checkpoint carries it and an interrupted run cannot strand it in history; state keeps only the lightweight `viewed_images` metadata. It owns that context and rebuilds it per call: its own message is swept out of the request first — a thread checkpointed by the earlier `before_model`/`after_model` pair (which wrote the payload into state and took it back out with `RemoveMessage`) can carry one that reached state but was never removed, and leaving it in would resend that base64 in every later request for the life of the thread — then a freshly built one is appended when warranted. The sweep requires both the reserved ID prefix and the server-owned marker, so a client cannot get its own message dropped; unmarked leftovers predating the marker are left in place and merely not duplicated
24. **ViewImageMiddleware** - *(vision models only)* Rebuilds hidden base64 image context in `ModelRequest.messages`, never in new checkpoint state; state retains lightweight `viewed_images` metadata. `_image_injection_plan` first sweeps stale server-marked context (including payloads stranded by the old `before_model`/`after_model` lifecycle), then shares one read-eligibility predicate between sync injection and async authorization. The sweep requires both the reserved ID prefix and server-owned marker, preserving user messages and unmarked legacy payloads. Before reading images, both paths recheck `sandbox:execute`, including restored views after role changes; denial skips I/O and removes marked stale context. Only the async wrapper passes `authorization_checked=True` after its gate, avoiding a duplicate synchronous provider call in its worker.
25. **McpRoutingMiddleware** - Auto-promotes deferred schemas matching the latest real user message before `DeferredToolFilterMiddleware`, without executing tools. New names emit `middleware:tool_promotion` with `source=routing_hint`; repeat passes emit nothing
26. **DeferredToolPromotionAuditMiddleware** - Observes final `tool_search` `Command`s; keep it outer of `SkillToolPolicyMiddleware` so denied names are excluded. It atomically claims new names per lead run or subagent execution to dedupe parallel searches, derives subagent attribution from the server-installed recorder, returns the original `Command`, and omits private payloads
27. **DeferredToolFilterMiddleware** - *(optional, if `tool_search.enabled`)* Hides deferred (MCP) tool schemas from the bound model until `tool_search` or `McpRoutingMiddleware` promotes them (reads per-thread promotions from `ThreadState.promoted`, hash-scoped)

View File

@ -3,7 +3,7 @@
import base64
import hashlib
import logging
from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Mapping
from pathlib import Path
from typing import override
from uuid import uuid4
@ -365,11 +365,26 @@ class ViewImageMiddleware(AgentMiddleware[ViewImageMiddlewareState]):
},
)
def _inject(self, request: ModelRequest) -> ModelRequest:
@staticmethod
def _authorization_context(request: ModelRequest) -> Mapping:
context = getattr(request.runtime, "context", None)
return context if isinstance(context, Mapping) else {}
def _image_injection_plan(self, request: ModelRequest) -> tuple[list[AnyMessage], bool, bool]:
"""Share message cleanup and read eligibility across sync/async paths."""
messages = [message for message in request.messages if not self._is_image_context_message(message)]
should_inject = self._should_inject_image_message(messages)
needs_authorization = should_inject and bool((request.state or {}).get("viewed_images"))
return messages, should_inject, needs_authorization
def _inject(self, request: ModelRequest, *, authorization_checked: bool = False) -> ModelRequest:
"""Rebuild the request's image context from ``viewed_images``.
Args:
request: The pending model request
authorization_checked: True only after ``awrap_model_call`` has
enforced ``sandbox:execute`` for this exact request; skips the
synchronous recheck inside its worker thread.
Returns:
A request whose messages carry exactly the image context this call
@ -384,14 +399,25 @@ class ViewImageMiddleware(AgentMiddleware[ViewImageMiddlewareState]):
# request for the life of the thread. Matching requires both the reserved
# ID prefix and the server-owned marker, and Gateway strips that marker
# from client input, so this can never drop a user-authored message.
messages = [message for message in request.messages if not self._is_image_context_message(message)]
messages, should_inject, needs_authorization = self._image_injection_plan(request)
dropped_stranded = len(messages) != len(request.messages)
if dropped_stranded:
logger.debug("Dropping %d stranded image context message(s) from the model request", len(request.messages) - len(messages))
if not self._should_inject_image_message(messages):
if not should_inject:
return request.override(messages=messages) if dropped_stranded else request
if needs_authorization and not authorization_checked:
from deerflow.authz.sandbox_authz import authorize_sandbox_execution, safe_app_config
from deerflow.sandbox.exceptions import SandboxAuthorizationError
try:
authorize_sandbox_execution(context=self._authorization_context(request), app_config=safe_app_config())
except SandboxAuthorizationError:
# A restored view may predate a role/policy change. Never read
# its host mirror or remote sandbox for an unauthorized request.
return request.override(messages=messages) if dropped_stranded else request
# Mixed content (text + images) for the model only, so hide it from the
# chat UI and IM channels (matches the other middleware-injected context
# messages) even though it never leaves this request.
@ -421,5 +447,17 @@ class ViewImageMiddleware(AgentMiddleware[ViewImageMiddlewareState]):
# blocking work without allowing cancellation to outlive a sandbox
# client operation. The outer run lease may release the client as soon as
# cancellation propagates.
injected_request = await run_sync_lifecycle_operation(self._inject, request)
messages, _, needs_authorization = self._image_injection_plan(request)
if needs_authorization:
from deerflow.authz.sandbox_authz import authorize_sandbox_execution_async, safe_app_config_async
from deerflow.sandbox.exceptions import SandboxAuthorizationError
try:
await authorize_sandbox_execution_async(context=self._authorization_context(request), app_config=await safe_app_config_async())
except SandboxAuthorizationError:
# Still sweep any old model-only image context before handing
# the request on; do not schedule a read in a worker.
return await handler(request.override(messages=messages))
injected_request = await run_sync_lifecycle_operation(self._inject, request, authorization_checked=True)
return await handler(injected_request)

View File

@ -23,7 +23,7 @@ Only standalone tool discovery without a model falls back to the base profile.
3. **Built-in tools**:
- `present_files` - Make output files visible to user (only `/mnt/user-data/outputs`); virtual paths use `resolve_runtime_user_id(runtime)` so validation resolves the same user-scoped outputs directory established by `ThreadDataMiddleware`
- `ask_clarification` - Request clarification (intercepted by ClarificationMiddleware, which preserves text fallback and adds `artifact.human_input` for Web UI Human Input Cards). Beyond free text and single choice, the request-side v2 protocol supports `fields` (structured form card collecting several values at once; field types: text/textarea/number/select/multi_select/checkbox/date, validated and normalized server-side in the middleware — invalid entries are dropped, unknown types degrade to `text`; a standalone multi-select question is a one-field form). Replies stay on the v1 response protocol (`text`/`option`): the form card submits a readable text summary
- `view_image` - Read image bytes for vision-capable models; live sandbox bytes win for the same sandbox generation, replacement-sandbox recovery uses only SHA-256-verified synchronized host bytes, and async tool invocation drains blocking reads before cancellation may release the sandbox lease
- `view_image` - Read image bytes for vision-capable models; both sync and async entry points require `sandbox:execute` before any host or sandbox read. Live sandbox bytes win for the same sandbox generation, replacement-sandbox recovery uses only SHA-256-verified synchronized host bytes, and async tool invocation drains blocking reads before cancellation may release the sandbox lease
- `setup_agent` - Bootstrap-only: persist a custom agent's `SOUL.md` and `config.yaml`. Re-bootstrapping preserves the owner's existing `display_name`. Bound only when `is_bootstrap=True`.
- `update_agent` - Custom-agent-only: persist self-updates to the current agent's `SOUL.md` / `config.yaml` from inside a normal chat (partial update + atomic write). Bound when `agent_name` is set and `is_bootstrap=False`.
4. **Subagent tool** (if enabled):

View File

@ -122,6 +122,14 @@ def _view_image(
Args:
image_path: Absolute /mnt/user-data virtual path to the image file. Common formats supported: jpg, jpeg, png, webp, gif.
"""
from deerflow.sandbox.tools import sandbox_authorization_scope
with sandbox_authorization_scope(runtime):
return _view_image_authorized(runtime, image_path, tool_call_id)
def _view_image_authorized(runtime: Runtime, image_path: str, tool_call_id: str) -> Command:
"""Read image bytes only after the sandbox execution gate has passed."""
from deerflow.sandbox.exceptions import SandboxRuntimeError
from deerflow.sandbox.overwrite import unwrap_sandbox
from deerflow.sandbox.sandbox_provider import get_sandbox_provider
@ -288,13 +296,15 @@ async def _aview_image(
) -> Command:
"""Run the blocking image read without letting cancellation outlive it."""
from deerflow.sandbox.lease import run_sync_lifecycle_operation
from deerflow.sandbox.tools import sandbox_authorization_scope_async
return await run_sync_lifecycle_operation(
_view_image,
runtime,
image_path,
tool_call_id,
)
async with sandbox_authorization_scope_async(runtime):
return await run_sync_lifecycle_operation(
_view_image_authorized,
runtime,
image_path,
tool_call_id,
)
view_image_tool = StructuredTool.from_function(

View File

@ -0,0 +1,219 @@
"""Offline regression and CLI probe for view_image sandbox authorization.
From ``backend/``::
.venv/bin/python tests/test_view_image_sandbox_authorization_regression.py --expect vulnerable
.venv/bin/python tests/test_view_image_sandbox_authorization_regression.py --expect blocked
.venv/bin/python -m pytest tests/test_view_image_sandbox_authorization_regression.py -q
The probe uses one synthetic GIF in a temporary directory. It needs no server,
model provider, credentials, or network access.
"""
import argparse
import base64
import json
import tempfile
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from langchain.agents.middleware.types import ModelRequest
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from deerflow.agents.middlewares.view_image_middleware import ViewImageMiddleware
from deerflow.authz.adapter import GuardrailAuthorizationAdapter
from deerflow.authz.rbac import RbacAuthorizationProvider
from deerflow.authz.sandbox_authz import authorize_sandbox_execution
from deerflow.authz.tool_filter import apply_tool_authorization
from deerflow.config.app_config import AppConfig
from deerflow.config.authorization_config import AuthorizationConfig
from deerflow.config.model_config import ModelConfig
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.guardrails.provider import GuardrailRequest
from deerflow.sandbox.exceptions import SandboxAuthorizationError
from deerflow.tools.builtins.view_image_tool import view_image_tool
GIF_BYTES = base64.b64decode("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7")
IMAGE_PATH = "/mnt/user-data/uploads/synthetic.gif"
def _setup(root: Path, monkeypatch: pytest.MonkeyPatch, *, sandbox_allowed: bool):
uploads = root / "uploads"
uploads.mkdir()
image_file = uploads / "synthetic.gif"
image_file.write_bytes(GIF_BYTES)
context = {"thread_id": "synthetic-thread", "user_id": "synthetic-user", "user_role": "reviewer"}
runtime = SimpleNamespace(
state={"thread_data": {"workspace_path": str(root / "workspace"), "uploads_path": str(uploads), "outputs_path": str(root / "outputs")}},
context=context,
config={},
)
config = AppConfig(
models=[ModelConfig(name="synthetic", model="synthetic", use="langchain_openai:ChatOpenAI", supports_vision=True)],
sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"),
authorization=AuthorizationConfig(enabled=True, fail_closed=True, default_role="reviewer"),
)
provider = RbacAuthorizationProvider(roles={"reviewer": {"tools": {"allow": ["view_image"]}, "sandbox": {"allow": sandbox_allowed}}})
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
monkeypatch.setattr("deerflow.authz.sandbox_authz.resolve_authorization_provider", lambda _: provider)
authorized_tools, _ = apply_tool_authorization([view_image_tool], context=context, app_config=config, authorization_provider=provider)
assert [tool.name for tool in authorized_tools] == ["view_image"]
tool_decision = GuardrailAuthorizationAdapter(provider, default_role="reviewer").evaluate(
GuardrailRequest(tool_name="view_image", tool_input={"image_path": IMAGE_PATH}, thread_id=context["thread_id"], user_id=context["user_id"], user_role=context["user_role"])
)
assert tool_decision.allow
return runtime, config, image_file
def _model_request(runtime, viewed_images: dict, tool_message: ToolMessage | None = None) -> ModelRequest:
messages = [
HumanMessage(content="Describe this synthetic image"),
AIMessage(content="", tool_calls=[{"name": "view_image", "args": {"image_path": IMAGE_PATH}, "id": "image-call"}]),
tool_message or ToolMessage(content="Successfully read image", tool_call_id="image-call"),
]
return ModelRequest(
model=FakeMessagesListChatModel(responses=[AIMessage(content="ok")]),
messages=messages,
system_message=None,
tool_choice=None,
tools=[],
response_format=None,
state={"messages": messages, "viewed_images": viewed_images},
runtime=SimpleNamespace(context=runtime.context),
model_settings={},
)
def _image_payloads(request: ModelRequest) -> list[dict]:
return [block for message in request.messages for block in (message.content if isinstance(message.content, list) else []) if isinstance(block, dict) and block.get("type") == "image_url"]
def _image_metadata(image_file: Path) -> dict:
return {IMAGE_PATH: {"actual_path": str(image_file), "mime_type": "image/gif", "size": len(GIF_BYTES)}}
def test_allowed_sync_read_still_reaches_model(tmp_path, monkeypatch):
runtime, config, _ = _setup(tmp_path, monkeypatch, sandbox_allowed=True)
authorize_sandbox_execution(context=runtime.context, app_config=config)
result = view_image_tool.func(runtime=runtime, image_path=IMAGE_PATH, tool_call_id="image-call")
assert result.update["messages"][0].content == "Successfully read image"
request = ViewImageMiddleware()._inject(_model_request(runtime, result.update["viewed_images"], result.update["messages"][0]))
assert _image_payloads(request)[0]["image_url"]["url"] == f"data:image/gif;base64,{base64.b64encode(GIF_BYTES).decode()}"
def test_denied_sync_tool_stops_before_image_read(tmp_path, monkeypatch):
runtime, config, image_file = _setup(tmp_path, monkeypatch, sandbox_allowed=False)
with pytest.raises(SandboxAuthorizationError):
authorize_sandbox_execution(context=runtime.context, app_config=config)
original_open = open
reads = []
def record_open(file, *args, **kwargs):
if isinstance(file, (str, bytes, Path)) and Path(file).resolve() == image_file.resolve():
reads.append(file)
return original_open(file, *args, **kwargs)
with patch("builtins.open", side_effect=record_open):
with pytest.raises(SandboxAuthorizationError):
view_image_tool.func(runtime=runtime, image_path=IMAGE_PATH, tool_call_id="image-call")
assert reads == []
def test_denied_restored_view_does_not_reenter_model(tmp_path, monkeypatch):
runtime, _, _ = _setup(tmp_path, monkeypatch, sandbox_allowed=True)
viewed = view_image_tool.func(runtime=runtime, image_path=IMAGE_PATH, tool_call_id="image-call").update["viewed_images"]
denied_provider = RbacAuthorizationProvider(roles={"reviewer": {"tools": {"allow": ["view_image"]}, "sandbox": {"allow": False}}})
monkeypatch.setattr("deerflow.authz.sandbox_authz.resolve_authorization_provider", lambda _: denied_provider)
stranded = ViewImageMiddleware._create_image_context_message([{"type": "image_url", "image_url": {"url": "data:image/gif;base64,stale"}}])
original_request = _model_request(runtime, viewed)
original_request = original_request.override(messages=[*original_request.messages, stranded])
with patch("builtins.open", side_effect=AssertionError("denied image read")):
request = ViewImageMiddleware()._inject(original_request)
assert _image_payloads(request) == []
assert stranded not in request.messages
def test_denied_tool_does_not_touch_live_sandbox(tmp_path, monkeypatch):
runtime, _, _ = _setup(tmp_path, monkeypatch, sandbox_allowed=False)
runtime.state["sandbox"] = {"sandbox_id": "synthetic-remote"}
with patch("deerflow.sandbox.sandbox_provider.get_sandbox_provider", side_effect=AssertionError("denied sandbox lookup")):
with pytest.raises(SandboxAuthorizationError):
view_image_tool.func(runtime=runtime, image_path=IMAGE_PATH, tool_call_id="image-call")
@pytest.mark.asyncio
async def test_denied_async_tool_and_restored_view_do_not_read(tmp_path, monkeypatch):
runtime, _, image_file = _setup(tmp_path, monkeypatch, sandbox_allowed=False)
stale_context = ViewImageMiddleware._create_image_context_message([{"type": "image_url", "image_url": {"url": "data:image/gif;base64,stale"}}])
user_message = HumanMessage(id="view-image-context:user-authored", content="Keep this message")
original_request = _model_request(runtime, _image_metadata(image_file))
original_request = original_request.override(messages=[*original_request.messages, stale_context, user_message])
with patch("builtins.open", side_effect=AssertionError("denied image read")):
with pytest.raises(SandboxAuthorizationError):
await view_image_tool.coroutine(runtime=runtime, image_path=IMAGE_PATH, tool_call_id="image-call")
async def handler(request):
return request
request = await ViewImageMiddleware().awrap_model_call(original_request, handler)
assert _image_payloads(request) == []
assert stale_context not in request.messages
assert user_message in request.messages
@pytest.mark.asyncio
async def test_allowed_async_tool_and_model_injection(tmp_path, monkeypatch):
runtime, _, _ = _setup(tmp_path, monkeypatch, sandbox_allowed=True)
result = await view_image_tool.coroutine(runtime=runtime, image_path=IMAGE_PATH, tool_call_id="image-call")
async def handler(request):
return request
request = await ViewImageMiddleware().awrap_model_call(_model_request(runtime, result.update["viewed_images"], result.update["messages"][0]), handler)
assert len(_image_payloads(request)) == 1
def _run_probe() -> dict[str, object]:
with tempfile.TemporaryDirectory(prefix="deerflow-image-authz-") as temp_dir, pytest.MonkeyPatch.context() as monkeypatch:
runtime, config, image_file = _setup(Path(temp_dir), monkeypatch, sandbox_allowed=False)
with pytest.raises(SandboxAuthorizationError):
authorize_sandbox_execution(context=runtime.context, app_config=config)
original_open = open
image_reads = 0
def record_open(file, *args, **kwargs):
nonlocal image_reads
if isinstance(file, (str, bytes, Path)) and Path(file).resolve() == image_file.resolve():
image_reads += 1
return original_open(file, *args, **kwargs)
with patch("builtins.open", side_effect=record_open):
try:
result = view_image_tool.func(runtime=runtime, image_path=IMAGE_PATH, tool_call_id="image-call")
except SandboxAuthorizationError:
tool_denied = True
viewed_images = {}
else:
tool_denied = False
viewed_images = result.update.get("viewed_images", {})
request = ViewImageMiddleware()._inject(_model_request(runtime, viewed_images or _image_metadata(image_file)))
return {"tool_denied": tool_denied, "sandbox_denied": True, "image_reads": image_reads, "model_received_image": bool(_image_payloads(request))}
def _main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--expect", choices=("vulnerable", "blocked"), required=True)
args = parser.parse_args()
observed = _run_probe()
vulnerable = not observed["tool_denied"] and observed["image_reads"] >= 2 and observed["model_received_image"]
blocked = observed["tool_denied"] and observed["image_reads"] == 0 and not observed["model_received_image"]
status = "vulnerable" if vulnerable else "blocked" if blocked else "inconclusive"
print(json.dumps({**observed, "observed": status, "expected": args.expect}, sort_keys=True))
return 0 if status == args.expect else 2
if __name__ == "__main__":
raise SystemExit(_main())