Skip to content

Commit e2c5eba

Browse files
nsgdsclaude
andcommitted
fix(agent): scrub model-supplied _owner on the qualified mcp__ image path (P2, #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>
1 parent d8fd9ed commit e2c5eba

2 files changed

Lines changed: 71 additions & 13 deletions

File tree

src/tool_execution.py

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,31 @@ def _parse_qualified_mcp_args(tool: str, content: str) -> tuple[Dict, Optional[s
357357
return {}, None
358358
return parsed, None
359359

360+
# Builtin tools that must run under the TRUSTED, server-side caller owner — never a
361+
# model-supplied one. The image generator resolves the caller's private endpoints/keys
362+
# and tags the gallery row with the owner, so `_owner` is a server-only arg.
363+
_OWNER_SCOPED_TOOLS = {"generate_image"}
364+
# Their fully-qualified mcp__ names, so a model that calls the tool by its qualified
365+
# name through the generic mcp__ dispatch path gets the same enforcement.
366+
_OWNER_SCOPED_QUALIFIED = {
367+
f"mcp__{_MCP_TOOL_MAP[t][0]}__{_MCP_TOOL_MAP[t][1]}" for t in _OWNER_SCOPED_TOOLS
368+
}
369+
370+
371+
def _apply_trusted_owner(name: str, args: Dict, owner: Optional[str]) -> Dict:
372+
"""Enforce the trusted-owner contract for owner-scoped tools on BOTH dispatch
373+
paths — the builtin name (via _call_mcp_tool) and a model-issued fully-qualified
374+
``mcp__…`` call. `_owner` is server-side-only: drop any model-supplied value, then
375+
inject the trusted caller owner for owner-scoped tools. Without this a direct
376+
``mcp__image_gen__generate_image`` call could spoof `_owner` to resolve another
377+
user's image endpoint/key and mis-tag the gallery row (#4123 review)."""
378+
if not isinstance(args, dict):
379+
return args
380+
args = {k: v for k, v in args.items() if k != "_owner"}
381+
if owner and (name in _OWNER_SCOPED_TOOLS or name in _OWNER_SCOPED_QUALIFIED):
382+
args["_owner"] = owner
383+
return args
384+
360385

361386
def _parse_generate_image(content: str) -> Dict:
362387
# Native tool-callers (supports_tools=True endpoints) deliver args as a JSON
@@ -479,14 +504,11 @@ async def _call_mcp_tool(
479504
server_id, tool_name = _MCP_TOOL_MAP[tool]
480505
qualified = f"mcp__{server_id}__{tool_name}"
481506
args = _build_mcp_args(tool, content)
482-
# Inject the TRUSTED, server-side owner for owner-scoped MCP tools. The image
483-
# generator must resolve the *caller's* endpoints (not whichever endpoint
484-
# happens to resolve first) and tag the gallery row with the owner. This is
485-
# set here — after arg parsing, never in the tool schema — so the model can't
486-
# control or spoof it (a model-supplied "_owner" is dropped by _build_mcp_args
487-
# and overwritten here).
488-
if owner and tool == "generate_image":
489-
args = {**args, "_owner": owner}
507+
# Enforce the trusted, server-side owner for owner-scoped tools (the image
508+
# generator must resolve the *caller's* endpoints and tag the gallery row with
509+
# the owner, never a model-supplied value). Centralised in _apply_trusted_owner
510+
# so the generic mcp__ path applies the identical scrub-and-inject (#4123 review).
511+
args = _apply_trusted_owner(tool, args, owner)
490512
result = await mcp.call_tool(qualified, args)
491513

492514
# If MCP server not connected, try direct fallback
@@ -961,6 +983,10 @@ async def _execute_tool_block_impl(
961983
if parse_error:
962984
result = {"error": parse_error, "exit_code": 1}
963985
else:
986+
# A model can issue a fully-qualified mcp__… call directly; apply the
987+
# trusted-owner scrub/inject the builtin path uses so it can't spoof
988+
# `_owner` on an owner-scoped builtin (e.g. mcp__image_gen__generate_image).
989+
args = _apply_trusted_owner(tool, args, owner)
964990
if tool.startswith("mcp__email__") and owner:
965991
args = dict(args)
966992
args[_EMAIL_MCP_OWNER_ARG] = owner

tests/test_generate_image_owner_scope.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,43 @@ def test_mcp_image_server_threads_trusted_owner():
9494
assert "owner=owner," in src # GalleryImage tagged with the owner
9595

9696

97-
def test_bridge_injects_trusted_owner_for_generate_image_only():
98-
"""_call_mcp_tool injects `_owner` only for generate_image, gated by tool name."""
99-
src = inspect.getsource(te._call_mcp_tool)
100-
assert 'tool == "generate_image"' in src
101-
assert '"_owner": owner' in src
97+
def test_bridge_applies_trusted_owner_via_helper():
98+
"""Both dispatch paths delegate owner enforcement to _apply_trusted_owner so they
99+
scrub/inject identically: the builtin-name wrapper (_call_mcp_tool) and the generic
100+
qualified mcp__ branch (_execute_tool_block_impl). Guards the #4123 P2 — a direct
101+
`mcp__image_gen__generate_image` call must not bypass owner injection."""
102+
assert "_apply_trusted_owner(tool, args, owner)" in inspect.getsource(te._call_mcp_tool)
103+
assert "_apply_trusted_owner(tool, args, owner)" in inspect.getsource(te._execute_tool_block_impl)
104+
105+
106+
def test_apply_trusted_owner_qualified_image_call_cannot_spoof_owner():
107+
"""P2 regression (qualified name): a model-issued `mcp__image_gen__generate_image`
108+
call carrying a spoofed `_owner` has it dropped and replaced with the trusted owner."""
109+
qualified = "mcp__image_gen__generate_image"
110+
assert qualified in te._OWNER_SCOPED_QUALIFIED
111+
out = te._apply_trusted_owner(qualified, {"prompt": "a cat", "_owner": "attacker"}, "alice")
112+
assert out["_owner"] == "alice" # trusted owner wins
113+
assert out["prompt"] == "a cat" # real args preserved
114+
115+
116+
def test_apply_trusted_owner_builtin_name_cannot_spoof_owner():
117+
"""P1 path (builtin name): same scrub/inject for the friendly `generate_image` name."""
118+
out = te._apply_trusted_owner("generate_image", {"_owner": "attacker"}, "alice")
119+
assert out["_owner"] == "alice"
120+
121+
122+
def test_apply_trusted_owner_strips_owner_from_non_scoped_tool():
123+
"""A model-supplied `_owner` is never honoured for a non-owner-scoped mcp__ tool —
124+
it's stripped, not re-injected (the field is server-side-only for any tool)."""
125+
out = te._apply_trusted_owner("mcp__filesystem__read_file", {"path": "/x", "_owner": "attacker"}, "alice")
126+
assert "_owner" not in out
127+
assert out["path"] == "/x"
128+
129+
130+
def test_apply_trusted_owner_no_owner_strips_spoofed():
131+
"""With no trusted owner to inject, a spoofed `_owner` is still dropped."""
132+
out = te._apply_trusted_owner("mcp__image_gen__generate_image", {"_owner": "attacker"}, None)
133+
assert "_owner" not in out
102134

103135

104136
def test_resolve_model_isolates_private_image_endpoint_by_owner():

0 commit comments

Comments
 (0)