From cb1aceb4e036827d64eebf1d404f7c2d9bb95583 Mon Sep 17 00:00:00 2001 From: Layau Eulizier Jr <130326664+cybrdude@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:37:11 -0400 Subject: [PATCH 1/5] fix(a2a): reject tool confirmations arriving over A2A (HITL confused deputy) A human-in-the-loop tool confirmation (ToolContext.request_confirmation -> ToolConfirmation.confirmed) is enforced only by checking the last user-authored event's function_response for {confirmed: true}. Inbound A2A messages are converted to author='user' turns (request_converter.py), so a remote A2A peer can supply the approval and self-approve a pending dangerous tool call (e.g. ExecuteBashTool) - a confused deputy across the A2A trust boundary. Refuse to honor confirmations on A2A-originated invocations (detected via run_config.custom_metadata['a2a_metadata']); approval must come from the operator's own (non-A2A) channel. Note: this does not remove the need to authenticate the ADK API server (/run) in production; HITL confirmation is not a substitute for network auth. --- .../adk/flows/llm_flows/request_confirmation.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/google/adk/flows/llm_flows/request_confirmation.py b/src/google/adk/flows/llm_flows/request_confirmation.py index 9492f334e09..be0d7816214 100644 --- a/src/google/adk/flows/llm_flows/request_confirmation.py +++ b/src/google/adk/flows/llm_flows/request_confirmation.py @@ -37,6 +37,8 @@ logger = logging.getLogger('google_adk.' + __name__) +_A2A_METADATA_KEY = 'a2a_metadata' + def _parse_tool_confirmation(response: dict[str, Any]) -> ToolConfirmation: """Parse ToolConfirmation from a function response dict. @@ -107,6 +109,21 @@ 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 + if ( + run_config + and run_config.custom_metadata + and _A2A_METADATA_KEY in run_config.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: From 6b54013580a21a2002bf9ccc93aed675bd264b6a Mon Sep 17 00:00:00 2001 From: Layau Eulizier Jr <130326664+cybrdude@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:37:08 -0400 Subject: [PATCH 2/5] Refactor custom_metadata handling in request_converter Ensure invocation is marked as A2A-originated regardless of peer metadata. --- src/google/adk/a2a/converters/request_converter.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/google/adk/a2a/converters/request_converter.py b/src/google/adk/a2a/converters/request_converter.py index 00af5f31af8..beee00512d8 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: From 309f912bb10e746cc425a6da001ae5f51b59e172 Mon Sep 17 00:00:00 2001 From: Layau Eulizier Jr <130326664+cybrdude@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:39:39 -0400 Subject: [PATCH 3/5] fix(a2a): key the A2A confirmation guard off marker presence Checking truthiness of custom_metadata meant an A2A peer that sent no protocol metadata produced an empty dict and skipped the guard. Check for the key itself instead. --- src/google/adk/flows/llm_flows/request_confirmation.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/google/adk/flows/llm_flows/request_confirmation.py b/src/google/adk/flows/llm_flows/request_confirmation.py index be0d7816214..cabe8c7d74b 100644 --- a/src/google/adk/flows/llm_flows/request_confirmation.py +++ b/src/google/adk/flows/llm_flows/request_confirmation.py @@ -113,11 +113,8 @@ async def run_async( # 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 - if ( - run_config - and run_config.custom_metadata - and _A2A_METADATA_KEY in run_config.custom_metadata - ): + 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.' From d2784d2362fd0d77efe9bbaf994fb4f649c4d006 Mon Sep 17 00:00:00 2001 From: Layau Eulizier Jr <130326664+cybrdude@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:41:20 -0400 Subject: [PATCH 4/5] test(a2a): assert the A2A marker survives empty peer metadata This test asserted the behavior that caused the bypass: that empty peer metadata omits the a2a_metadata marker. Invert it, since the marker is a trust signal a peer must not be able to suppress. --- .../a2a/converters/test_request_converter.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/unittests/a2a/converters/test_request_converter.py b/tests/unittests/a2a/converters/test_request_converter.py index e3481b31ca7..9a3c7519bba 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.""" From 7201d24d1bd7102b63f11ff7ce944638ff6bc45c Mon Sep 17 00:00:00 2001 From: Layau Eulizier Jr <130326664+cybrdude@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:42:27 -0400 Subject: [PATCH 5/5] test(a2a): cover A2A confirmations with and without peer metadata Adds regression coverage for #6461, including the no-metadata case that bypassed the guard. Non-A2A behavior stays covered by the existing success test, which uses the default RunConfig. --- .../llm_flows/test_request_confirmation.py | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/tests/unittests/flows/llm_flows/test_request_confirmation.py b/tests/unittests/flows/llm_flows/test_request_confirmation.py index 85d3e44cbf0..abc15786b3c 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 @@ -547,3 +550,94 @@ async def test_request_confirmation_processor_dynamic_success(): assert ( args[4][MOCK_FUNCTION_CALL_ID] == user_confirmation ) # tool_confirmation_dict + + +@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", + 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