fix(agent): give generate_image a first-class native tool schema (validated args, model enum, owner-scoped)#4123
Conversation
…covery Addresses RaresKeY's review on the generate_image PR (odysseus-dev#4123): P1 (security): native generate_image executed through the image MCP bridge without the caller's owner — _resolve_model(cand) ran with no owner and the gallery row saved ownerless. In a multi-user deployment a request could resolve against another user's private image endpoint/API key and spend their quota. Thread a TRUSTED server-side owner through the bridge: _call_mcp_tool injects `_owner` (only for generate_image, after arg parsing, never a schema field), and the MCP server uses it for _resolve_model(..., owner=owner) and GalleryImage.owner. A model can't spoof it — _parse_generate_image drops any model-supplied owner and the schema doesn't expose one. P2 (perf): the installed-image-model lookup (a live /models probe) was hoisted to run once per turn unconditionally, so image-free turns probed image endpoints. Make it lazy: resolve the model list only once generate_image is actually in the schemas sent this round (still cached + off the event loop). Tests cover: server uses the trusted `_owner` and ignores a model-supplied owner; the args channel/schema can't carry owner. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Strengthen coverage for the odysseus-dev#4123 P1 fix: - Source-inspection guards (matching the repo's owner-scope idiom): the image MCP server reads the trusted `_owner` and threads it into _resolve_model + GalleryImage; the tool-execution bridge injects `_owner` only for generate_image. (The existing test_ai_interaction_owner_scope pins do_generate_image — the UI path — but not the MCP server the agent actually uses.) - Gold-standard runtime isolation: a private image endpoint owned by "alice" on a real in-memory DB is invisible to "bob" via _resolve_model (real owner_filter SQL) — bob is blocked with zero probes to alice's endpoint, so the prompt and alice's API key are never sent; the ownerless path (pre-fix behavior) resolves it, documenting the leak the fix closes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks @RaresKeY — both findings were spot on. Addressed in d67a712 (fix) + 46451ea (tests). P1 (owner-scoping). Took your second option — thread a trusted server-side owner through the MCP bridge, rather than re-routing to the in-process
The existing P2 (eager discovery). Made the installed-model lookup lazy — it now runs only once |
…covery Addresses RaresKeY's review on the generate_image PR (odysseus-dev#4123): P1 (security): native generate_image executed through the image MCP bridge without the caller's owner — _resolve_model(cand) ran with no owner and the gallery row saved ownerless. In a multi-user deployment a request could resolve against another user's private image endpoint/API key and spend their quota. Thread a TRUSTED server-side owner through the bridge: _call_mcp_tool injects `_owner` (only for generate_image, after arg parsing, never a schema field), and the MCP server uses it for _resolve_model(..., owner=owner) and GalleryImage.owner. A model can't spoof it — _parse_generate_image drops any model-supplied owner and the schema doesn't expose one. P2 (perf): the installed-image-model lookup (a live /models probe) was hoisted to run once per turn unconditionally, so image-free turns probed image endpoints. Make it lazy: resolve the model list only once generate_image is actually in the schemas sent this round (still cached + off the event loop). Tests cover: server uses the trusted `_owner` and ignores a model-supplied owner; the args channel/schema can't carry owner. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Strengthen coverage for the odysseus-dev#4123 P1 fix: - Source-inspection guards (matching the repo's owner-scope idiom): the image MCP server reads the trusted `_owner` and threads it into _resolve_model + GalleryImage; the tool-execution bridge injects `_owner` only for generate_image. (The existing test_ai_interaction_owner_scope pins do_generate_image — the UI path — but not the MCP server the agent actually uses.) - Gold-standard runtime isolation: a private image endpoint owned by "alice" on a real in-memory DB is invisible to "bob" via _resolve_model (real owner_filter SQL) — bob is blocked with zero probes to alice's endpoint, so the prompt and alice's API key are never sent; the ownerless path (pre-fix behavior) resolves it, documenting the leak the fix closes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…path (P2, odysseus-dev#4123 review) The wrapper path (_call_mcp_tool) injected the trusted _owner only for the builtin "generate_image" name. A model could instead issue the fully-qualified mcp__image_gen__generate_image directly, which the generic mcp__ dispatch branch passed straight to MCP with the model's JSON args — letting a model-supplied _owner through to resolve another user's private image endpoint/API key and tag the gallery row under the spoofed owner. Centralise the contract in _apply_trusted_owner(name, args, owner): drop any model-supplied _owner (server-side-only field), then inject the trusted caller owner for owner-scoped tools — keyed by both the builtin name and its qualified mcp__ name. Both dispatch paths now call it, so they enforce identically. Tests: qualified-name and builtin-name spoof regressions on the real helper, a strip on a non-owner-scoped mcp__ tool, the no-owner case, and a guard that both dispatch paths call the helper. Full suite green (3544 passed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
46451ea to
0a002f8
Compare
0a002f8 to
fd30229
Compare
…covery Addresses RaresKeY's review on the generate_image PR (odysseus-dev#4123): P1 (security): native generate_image executed through the image MCP bridge without the caller's owner — _resolve_model(cand) ran with no owner and the gallery row saved ownerless. In a multi-user deployment a request could resolve against another user's private image endpoint/API key and spend their quota. Thread a TRUSTED server-side owner through the bridge: _call_mcp_tool injects `_owner` (only for generate_image, after arg parsing, never a schema field), and the MCP server uses it for _resolve_model(..., owner=owner) and GalleryImage.owner. A model can't spoof it — _parse_generate_image drops any model-supplied owner and the schema doesn't expose one. P2 (perf): the installed-image-model lookup (a live /models probe) was hoisted to run once per turn unconditionally, so image-free turns probed image endpoints. Make it lazy: resolve the model list only once generate_image is actually in the schemas sent this round (still cached + off the event loop). Tests cover: server uses the trusted `_owner` and ignores a model-supplied owner; the args channel/schema can't carry owner. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Strengthen coverage for the odysseus-dev#4123 P1 fix: - Source-inspection guards (matching the repo's owner-scope idiom): the image MCP server reads the trusted `_owner` and threads it into _resolve_model + GalleryImage; the tool-execution bridge injects `_owner` only for generate_image. (The existing test_ai_interaction_owner_scope pins do_generate_image — the UI path — but not the MCP server the agent actually uses.) - Gold-standard runtime isolation: a private image endpoint owned by "alice" on a real in-memory DB is invisible to "bob" via _resolve_model (real owner_filter SQL) — bob is blocked with zero probes to alice's endpoint, so the prompt and alice's API key are never sent; the ownerless path (pre-fix behavior) resolves it, documenting the leak the fix closes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…path (P2, odysseus-dev#4123 review) The wrapper path (_call_mcp_tool) injected the trusted _owner only for the builtin "generate_image" name. A model could instead issue the fully-qualified mcp__image_gen__generate_image directly, which the generic mcp__ dispatch branch passed straight to MCP with the model's JSON args — letting a model-supplied _owner through to resolve another user's private image endpoint/API key and tag the gallery row under the spoofed owner. Centralise the contract in _apply_trusted_owner(name, args, owner): drop any model-supplied _owner (server-side-only field), then inject the trusted caller owner for owner-scoped tools — keyed by both the builtin name and its qualified mcp__ name. Both dispatch paths now call it, so they enforce identically. Tests: qualified-name and builtin-name spoof regressions on the real helper, a strip on a non-owner-scoped mcp__ tool, the no-owner case, and a guard that both dispatch paths call the helper. Full suite green (3544 passed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks — fixed in P2 (qualified Good timing on #4335 — it landed the same owner-injection for One small heads-up from being in there: One extra from the rebase: it surfaced a pre-existing CI-only isolation flake in the existing |
RaresKeY
left a comment
There was a problem hiding this comment.
Findings
issue (agent): Resolved text models still stop image-model fallback
-
Problem: The fallback loop accepts the first candidate that
_resolve_model()can find, but_resolve_model()searches enabled chat/text endpoints too. If a native call supplies a real text model name such asgpt-4o-mini, the loop breaks before trying the configured image model, then posts to that text endpoint's/images/generationsURL and raises on the provider error. -
Impact: Image generation can still fail for native-tool models that emit a known text model, providers that ignore the injected enum, or turns where enum discovery failed open, even when a valid configured image model is available.
-
Ask: Treat a candidate as selected only after it resolves to an image-capable endpoint/model, or continue to the next candidate when the resolved endpoint rejects
/images/generationsas non-image. Please add a focused regression where a requested text model resolves first and the configured image endpoint is tried next. -
Location:
mcp_servers/image_gen_server.py:85
Validation
Focused native/model-enum/owner tests pass on the current head, and a fake text-endpoint/configured-image-endpoint probe reproduces the fallback gap. I did not run a live image endpoint.
generate_image was fenced-text only — it had no entry in FUNCTION_TOOL_SCHEMAS, so models on supports_tools=True endpoints (native function-calling, e.g. nemotron-vl) were never offered it. The tool-RAG classifier surfaced it into relevant_tools, but the native tool array is built solely from FUNCTION_TOOL_SCHEMAS, so it was dropped before reaching the model, which then thrashed on ask_user/edit_image. - Add a generate_image native schema (prompt required; optional model/size/quality), mirroring edit_image. - Teach _parse_generate_image to accept the JSON args a native call delivers (function_call_to_tool_block's default serialization) in addition to the legacy 4-line fenced form, so local/fenced models keep working. - Add tests covering the native schema, the native-call seam end to end, fenced back-compat, and a brace-prefixed prompt that must not be swallowed as JSON. Co-Authored-By: Claude <noreply@anthropic.com>
Native-tool models (e.g. nemotron) filled generate_image's free-form optional `model` param with famous names from pretraining (DALL-E 3, stable-diffusion-xena, comicbook-legacy). Those don't exist here, so _resolve_model raised and the call hard-failed with no image — and the MCP server RETURNED the error as TextContent (isError stays False), so the agent saw "Error: model not found" paired with exit_code=0 and sometimes gave up entirely (the X-Men session produced zero images; Sky Pride only recovered by luck, retrying without the param). Two compounding fixes: - Enum-constrain the model param to the installed image models on the agent path: list_image_model_ids() (cached) + with_image_model_enum() inject `enum` into the native generate_image schema at send time (fails open if none found). Also reword the fenced prompt and the tool-RAG description so nothing primes a model name. - Harden mcp_servers/image_gen_server.py: an unknown/stale `model` now falls back through configured -> auto-detect instead of erroring, and genuine errors RAISE (SDK sets isError -> honest exit_code=1) instead of returning exit_code=0 + an "Error:" string. Verified live: bad "DALL-E 3" falls back to Qwen-Image-NVFP4 and generates; empty prompt raises (exit_code 1); native schema ships enum=["Qwen-Image-NVFP4"]. Co-Authored-By: Claude <noreply@anthropic.com>
…w-ups) Follow-ups from an adversarial review of 371ad38 (no must-fix blockers; these close the confirmed medium/low findings): - Fenced path (M1/M2): the reworded prompt asked fenced/local models to emit a bare blank line 2 while the positional parser still mapped line 2 -> model; a model that dropped the blank line shifted size into the model slot and silently lost the requested size. Drop the model slot from the fenced form entirely (3 lines: prompt/size/quality) and map the parser to [size, quality] — the fenced path has no enum and the server auto-selects, so a positional model slot was pure downside. Update/add fenced parser tests (3-line form, prompt+size only, and a size-never-lands-in-model regression). - list_image_model_ids (L1/L2): probe image endpoints' /models WITH auth headers (resolve_endpoint_runtime + build_headers) so auth-gated cloud endpoints aren't silently dropped; prefer actually-served ids and fall back to the configured model only when probing yields nothing (don't advertise an unvalidated/stale configured name beside real ids; case-insensitive dedup). - Enum injection (L3): compute the installed-model list once per turn via asyncio.to_thread (was per-round, synchronous) so a slow/down image endpoint can't stall the event loop. Native path unchanged (enum still ships); full suite green sans the known Spark compose tests. Verified live: fenced 1536x1024 lands in size; enum intact. Co-Authored-By: Claude <noreply@anthropic.com>
…covery Addresses RaresKeY's review on the generate_image PR (odysseus-dev#4123): P1 (security): native generate_image executed through the image MCP bridge without the caller's owner — _resolve_model(cand) ran with no owner and the gallery row saved ownerless. In a multi-user deployment a request could resolve against another user's private image endpoint/API key and spend their quota. Thread a TRUSTED server-side owner through the bridge: _call_mcp_tool injects `_owner` (only for generate_image, after arg parsing, never a schema field), and the MCP server uses it for _resolve_model(..., owner=owner) and GalleryImage.owner. A model can't spoof it — _parse_generate_image drops any model-supplied owner and the schema doesn't expose one. P2 (perf): the installed-image-model lookup (a live /models probe) was hoisted to run once per turn unconditionally, so image-free turns probed image endpoints. Make it lazy: resolve the model list only once generate_image is actually in the schemas sent this round (still cached + off the event loop). Tests cover: server uses the trusted `_owner` and ignores a model-supplied owner; the args channel/schema can't carry owner. Co-Authored-By: Claude <noreply@anthropic.com>
Strengthen coverage for the odysseus-dev#4123 P1 fix: - Source-inspection guards (matching the repo's owner-scope idiom): the image MCP server reads the trusted `_owner` and threads it into _resolve_model + GalleryImage; the tool-execution bridge injects `_owner` only for generate_image. (The existing test_ai_interaction_owner_scope pins do_generate_image — the UI path — but not the MCP server the agent actually uses.) - Gold-standard runtime isolation: a private image endpoint owned by "alice" on a real in-memory DB is invisible to "bob" via _resolve_model (real owner_filter SQL) — bob is blocked with zero probes to alice's endpoint, so the prompt and alice's API key are never sent; the ownerless path (pre-fix behavior) resolves it, documenting the leak the fix closes. Co-Authored-By: Claude <noreply@anthropic.com>
…path (P2, odysseus-dev#4123 review) The wrapper path (_call_mcp_tool) injected the trusted _owner only for the builtin "generate_image" name. A model could instead issue the fully-qualified mcp__image_gen__generate_image directly, which the generic mcp__ dispatch branch passed straight to MCP with the model's JSON args — letting a model-supplied _owner through to resolve another user's private image endpoint/API key and tag the gallery row under the spoofed owner. Centralise the contract in _apply_trusted_owner(name, args, owner): drop any model-supplied _owner (server-side-only field), then inject the trusted caller owner for owner-scoped tools — keyed by both the builtin name and its qualified mcp__ name. Both dispatch paths now call it, so they enforce identically. Tests: qualified-name and builtin-name spoof regressions on the real helper, a strip on a non-owner-scoped mcp__ tool, the no-owner case, and a guard that both dispatch paths call the helper. Full suite green (3544 passed). Co-Authored-By: Claude <noreply@anthropic.com>
4650994 to
e2c5eba
Compare
|
Sorry for going quiet for a couple of weeks! Rebased onto current
Also rewrote the body: testing on current |
Summary
generate_imagepredates native tool-calling and was never promoted: it has a proseguide section, a fenced-text parser, an MCP execution bridge and a retrieval-index
entry, but no entry in
FUNCTION_TOOL_SCHEMAS— and the bundled image server isnot a registered MCP server, so
mcp_schemasnever carries it either.edit_image(added later) has a schema. Since native advertisement is
FUNCTION_TOOL_SCHEMAS ∩ relevant_tools, a native-tool model can be offerededit_imagebut nevergenerate_image, even when selection put both on the table:Two failure classes follow. Models that only act on advertised schemas can't generate
images at all — the tool is never in the
toolsarray they're sent. Loose models callit from the prose guide with hallucinated parameters (nothing ever validated the
arguments); observed live on
dev:(ToolBench training-data residue; it "worked" only because the MCP server ignores
unknown keys.)
What this PR does:
generate_imageschema toFUNCTION_TOOL_SCHEMAS(prompt / model / size / quality) — advertisable through the existing filter exactly
like
edit_image; models compose arguments against the real parameter shape.modelparameter to the actually-installed image models,resolved lazily (once per turn, only when generate_image is being sent). Fails open
if discovery fails.
_owneris injected server-side by the tool-execution bridge — never a model-controlled
schema field — and threads through
_resolve_model(..., owner=...)so a request cannever resolve against another user's private image endpoint or spend their API key.
The contract is centralised in
_apply_trusted_owner, so a model-issued fullyqualified
mcp__image_gen__generate_imagecall takes the identical scrub-and-injectpath (addresses the P2 from review).
Since the last review round: rebased onto current
dev; adopted upstream'snon-blocking resolution (
await asyncio.to_thread(_resolve_model, cand, owner=owner)—upstream's
_resolve_modelacceptsownernatively since the rebase); owner-scopetests pin their settings reads hermetically (a dirty process-wide settings cache could
short-circuit
call_toolbefore resolution, which also let one test pass vacuously —it now asserts the resolver was reached; supersedes the inline pins from 4650994).
Companion PR #3606 fixes the classifier half of the linked issue (image requests never
deterministically select the tool); this PR makes the call first-class once selection
happens.
Target branch
dev, notmain.Linked Issue
Part of #3605 (defect 2 of 2 — see the issue for the full pipeline analysis)
Type of Change
Checklist
devdocker compose up) and verified the change works end-to-end. Type-checks and unit tests are not enough.How to Test
python -m pytest tests/test_generate_image_native.py tests/test_generate_image_model_enum.py tests/test_generate_image_owner_scope.py -q(includes qualified-name and builtin-name
_ownerspoof regressions on bothdispatch paths).
devwithout this PR (native-tool endpoint): in an existingconversation — or with web search on; both avoid the unrelated classifier gate
(fix(agent): add image domain to tool-RAG classifier #3606) — ask the agent to generate an image and watch the
[agent-debug] round=1 ... tool_names=log line:generate_imageis absent evenwhen
relevant_toolscontains it;edit_imageis present.generate_imageappears intool_names, the call arrives as a realnative
tool_callsobject with schema-shaped arguments, and themodelargument isconstrained to installed image models (observed live:
{"model": "Qwen-Image-NVFP4", "prompt": ..., "size": ...}).agent image request must not resolve to it — covered by
test_resolve_model_isolates_private_image_endpoint_by_owner, which reproduces thepre-fix leak against a real in-memory DB.
Visual / UI changes — REQUIRED if you touched anything that renders
Not applicable — schema/advertisement/execution layer only; no rendering code touched.