diff --git a/src/google/adk/a2a/converters/request_converter.py b/src/google/adk/a2a/converters/request_converter.py index 00af5f31af..beee00512d 100644 --- a/src/google/adk/a2a/converters/request_converter.py +++ b/src/google/adk/a2a/converters/request_converter.py @@ -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: diff --git a/src/google/adk/flows/llm_flows/request_confirmation.py b/src/google/adk/flows/llm_flows/request_confirmation.py index e87c55942f..d299e41dfd 100644 --- a/src/google/adk/flows/llm_flows/request_confirmation.py +++ b/src/google/adk/flows/llm_flows/request_confirmation.py @@ -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.""" @@ -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: diff --git a/tests/unittests/a2a/converters/test_request_converter.py b/tests/unittests/a2a/converters/test_request_converter.py index e3481b31ca..9a3c7519bb 100644 --- a/tests/unittests/a2a/converters/test_request_converter.py +++ b/tests/unittests/a2a/converters/test_request_converter.py @@ -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=[] ) @@ -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.""" diff --git a/tests/unittests/flows/llm_flows/test_request_confirmation.py b/tests/unittests/flows/llm_flows/test_request_confirmation.py index 03d4f68eba..82e1b7630e 100644 --- a/tests/unittests/flows/llm_flows/test_request_confirmation.py +++ b/tests/unittests/flows/llm_flows/test_request_confirmation.py @@ -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 @@ -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