Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions src/google/adk/a2a/converters/request_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,12 @@ def convert_a2a_request_to_agent_run_request(
if not request.message:
raise ValueError('Request message cannot be None')

custom_metadata = {}
request_metadata = _compat.meta_to_dict(request.metadata)
if request_metadata:
custom_metadata[A2A_METADATA_KEY] = request_metadata
# Always mark the invocation as A2A-originated, even when the peer sent no
# protocol metadata. Trust decisions downstream (e.g. refusing a
# human-in-the-loop tool confirmation that arrived from a remote peer) key
# off the PRESENCE of this marker, so it must not depend on the peer
# choosing to send metadata.
custom_metadata = {A2A_METADATA_KEY: _compat.meta_to_dict(request.metadata)}

output_parts = []
for a2a_part in request.message.parts:
Expand Down
14 changes: 14 additions & 0 deletions src/google/adk/flows/llm_flows/request_confirmation.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@

logger = logging.getLogger("google_adk." + __name__)

_A2A_METADATA_KEY = 'a2a_metadata'


def _parse_tool_confirmation(response: dict[str, Any]) -> ToolConfirmation:
"""Parses ToolConfirmation from a function response dict."""
Expand Down Expand Up @@ -195,6 +197,18 @@ async def run_async(

agent = invocation_context.agent

# A human-in-the-loop confirmation must not be satisfiable by a
# function_response that arrived over A2A: a remote peer is not the human
# operator and could self-approve a pending dangerous tool call.
run_config = invocation_context.run_config
custom_metadata = run_config.custom_metadata if run_config else None
if custom_metadata is not None and _A2A_METADATA_KEY in custom_metadata:
logger.warning(
'Ignoring tool confirmation(s) that arrived over A2A: a remote peer'
' cannot satisfy a human-in-the-loop confirmation.'
)
return

# Only look at events in the current branch.
events = invocation_context._get_events(current_branch=True)
if not events:
Expand Down
11 changes: 8 additions & 3 deletions tests/unittests/a2a/converters/test_request_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,8 +259,13 @@ def test_convert_a2a_request_normalizes_proto_struct_metadata(self):
# Numbers may come back as float on 1.x (proto Struct) -> tolerate both.
assert float(stored["n"]) == 1.0

def test_convert_a2a_request_empty_metadata_omitted(self):
"""Empty request metadata must not add an ``a2a_metadata`` entry."""
def test_convert_a2a_request_empty_metadata_still_marks_a2a(self):
"""Empty request metadata must STILL mark the invocation as A2A-originated.

The marker is a trust signal, not a data carrier: downstream checks key off
its presence, so a peer must not be able to suppress it by omitting
metadata.
"""
empty_meta_msg = _compat.make_message(
message_id="m1", role=_compat.ROLE_USER, parts=[]
)
Expand All @@ -276,7 +281,7 @@ def test_convert_a2a_request_empty_metadata_omitted(self):

result = convert_a2a_request_to_agent_run_request(request, Mock())

assert "a2a_metadata" not in result.run_config.custom_metadata
assert result.run_config.custom_metadata == {"a2a_metadata": {}}

def test_convert_a2a_request_no_message_raises_error(self):
"""Test that conversion raises ValueError when message is None."""
Expand Down
94 changes: 94 additions & 0 deletions tests/unittests/flows/llm_flows/test_request_confirmation.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@
# limitations under the License.

import json
from unittest.mock import Mock
from unittest.mock import patch

from a2a.server.agent_execution import RequestContext
from google.adk.a2a.converters.request_converter import convert_a2a_request_to_agent_run_request
from google.adk.agents.llm_agent import LlmAgent
from google.adk.events.event import Event
from google.adk.events.event_actions import EventActions
Expand Down Expand Up @@ -692,3 +695,94 @@ async def test_request_confirmation_processor_rejections(
with pytest.raises(ValueError, match=expected_exception_match):
async for _ in request_processor.run_async(invocation_context, llm_request):
pass


@pytest.mark.asyncio
async def test_request_confirmation_processor_ignores_a2a_confirmation():
"""A confirmation arriving over A2A must not satisfy the HITL gate."""
await _assert_a2a_confirmation_ignored({"a2a:task_id": "t1"})


@pytest.mark.asyncio
async def test_request_confirmation_processor_ignores_a2a_without_metadata():
"""The guard must hold when the peer sends no protocol metadata.

Regression test for #6461: the A2A marker used to be set only when
request.metadata was non-empty, so a peer that sent none produced an empty
custom_metadata and slipped past the guard.
"""
await _assert_a2a_confirmation_ignored(None)


async def _assert_a2a_confirmation_ignored(request_metadata):
request = Mock(spec=RequestContext)
request.message = Mock()
request.message.parts = []
request.metadata = request_metadata
request.context_id = "ctx"
request.call_context = None
run_config = convert_a2a_request_to_agent_run_request(
request, Mock()
).run_config
assert "a2a_metadata" in run_config.custom_metadata

agent = LlmAgent(name="test_agent", tools=[mock_tool])
invocation_context = await testing_utils.create_invocation_context(
agent=agent, run_config=run_config
)

original_function_call = types.FunctionCall(
name=MOCK_TOOL_NAME, args={"param1": "test"}, id=MOCK_FUNCTION_CALL_ID
)
tool_confirmation = ToolConfirmation(confirmed=False, hint="test hint")
tool_confirmation_args = {
"originalFunctionCall": original_function_call.model_dump(
exclude_none=True, by_alias=True
),
"toolConfirmation": tool_confirmation.model_dump(
by_alias=True, exclude_none=True
),
}
invocation_context.session.events.append(
Event(
author=agent.name,
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name=functions.REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
args=tool_confirmation_args,
id=MOCK_CONFIRMATION_FUNCTION_CALL_ID,
)
)
]
),
)
)
user_confirmation = ToolConfirmation(confirmed=True)
invocation_context.session.events.append(
Event(
author="user",
content=types.Content(
parts=[
types.Part(
function_response=types.FunctionResponse(
name=functions.REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
id=MOCK_CONFIRMATION_FUNCTION_CALL_ID,
response={
"response": user_confirmation.model_dump_json()
},
)
)
]
),
)
)

events = []
async for event in request_processor.run_async(
invocation_context, LlmRequest()
):
events.append(event)

assert not events
Loading