Skip to content

Commit 4b002c4

Browse files
xuanyang15copybara-github
authored andcommitted
fix: Prevent continuation forgery in tool confirmation
An attacker who could manipulate or inject events into the session history could execute unauthorized tools by forging a tool confirmation response. This fixes the vulnerability by: - When resolving confirmation targets, the processor verifies if the tool is registered in the executing agent's tools_dict - Validate that the tool actually requires confirmation, supporting both static definitions and dynamic confirmation requests - Verify that the original tool call event exists in the session history with the matching ID, and that its name and arguments match the confirmation request's originalFunctionCall exactly to prevent argument tampering. Co-authored-by: Xuan Yang <xygoogle@google.com> PiperOrigin-RevId: 953540969
1 parent 40cf97b commit 4b002c4

6 files changed

Lines changed: 290 additions & 103 deletions

File tree

src/google/adk/flows/llm_flows/request_confirmation.py

Lines changed: 130 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
# limitations under the License.
1414
from __future__ import annotations
1515

16-
import json
1716
import logging
1817
from typing import Any
1918
from typing import AsyncGenerator
@@ -27,68 +26,157 @@
2726
from ...agents.readonly_context import ReadonlyContext
2827
from ...events.event import Event
2928
from ...models.llm_request import LlmRequest
29+
from ...tools.base_tool import BaseTool
3030
from ...tools.tool_confirmation import ToolConfirmation
31+
from ...tools.tool_context import ToolContext
3132
from ._base_llm_processor import BaseLlmRequestProcessor
3233
from .functions import REQUEST_CONFIRMATION_FUNCTION_CALL_NAME
3334

3435
if TYPE_CHECKING:
3536
pass
3637

3738

38-
logger = logging.getLogger('google_adk.' + __name__)
39+
logger = logging.getLogger("google_adk." + __name__)
3940

4041

4142
def _parse_tool_confirmation(response: dict[str, Any]) -> ToolConfirmation:
42-
"""Parse ToolConfirmation from a function response dict.
43+
"""Parses ToolConfirmation from a function response dict."""
44+
return ToolConfirmation.from_response_dict(response)
4345

44-
Handles both the direct dict format and the ADK client's
45-
``{'response': json_string}`` wrapper format.
4646

47-
"""
48-
if response and len(response.values()) == 1 and 'response' in response.keys():
49-
return ToolConfirmation.model_validate(json.loads(response['response']))
50-
return ToolConfirmation.model_validate(response)
51-
52-
53-
def _resolve_confirmation_targets(
47+
async def _resolve_confirmation_targets(
48+
invocation_context: InvocationContext,
5449
events: list[Event],
5550
confirmation_fc_ids: set[str],
5651
confirmations_by_fc_id: dict[str, ToolConfirmation],
52+
tools_dict: dict[str, BaseTool],
5753
) -> tuple[dict[str, ToolConfirmation], dict[str, types.FunctionCall]]:
58-
"""Find original function calls for confirmed tools.
54+
"""Find original function calls for confirmed tools and validate them.
5955
6056
Scans events for ``adk_request_confirmation`` function calls whose IDs
6157
are in *confirmation_fc_ids*, extracts the ``originalFunctionCall`` from
62-
their args, and maps each confirmation to the original FC ID.
58+
their args, validates that they are registered, actually require confirmation,
59+
and match the original function calls in history, and maps each confirmation
60+
to the original FC ID.
6361
6462
Args:
63+
invocation_context: Current invocation context.
6564
events: Session events to scan.
6665
confirmation_fc_ids: IDs of ``adk_request_confirmation`` function calls.
6766
confirmations_by_fc_id: Mapping of confirmation FC ID ->
6867
``ToolConfirmation``.
68+
tools_dict: Dictionary of registered tools.
6969
7070
Returns:
7171
Tuple of ``(tool_confirmation_dict, original_fcs_dict)`` where both
7272
are keyed by the ORIGINAL function call IDs.
73+
74+
Raises:
75+
ValueError: If validation of any confirmation target fails.
7376
"""
7477
tool_confirmation_dict: dict[str, ToolConfirmation] = {}
7578
original_fcs_dict: dict[str, types.FunctionCall] = {}
7679

80+
history_fcs = {
81+
fc.id: (fc, ev)
82+
for ev in events
83+
for fc in ev.get_function_calls()
84+
if fc.id and fc.name != REQUEST_CONFIRMATION_FUNCTION_CALL_NAME
85+
}
86+
history_fr_events = {
87+
fr.id: ev for ev in events for fr in ev.get_function_responses() if fr.id
88+
}
89+
7790
for event in events:
7891
event_function_calls = event.get_function_calls()
7992
if not event_function_calls:
8093
continue
8194

8295
for function_call in event_function_calls:
83-
if function_call.id not in confirmation_fc_ids:
96+
if not function_call.id or function_call.id not in confirmation_fc_ids:
8497
continue
8598

8699
args = function_call.args
87-
if 'originalFunctionCall' not in args:
100+
if not args or "originalFunctionCall" not in args:
88101
continue
89102
original_function_call = types.FunctionCall(
90-
**args['originalFunctionCall']
103+
**args["originalFunctionCall"]
104+
)
105+
if not original_function_call.id:
106+
raise ValueError("Original function call ID is missing.")
107+
tool_name = original_function_call.name
108+
if not tool_name:
109+
raise ValueError("Original function call name is missing.")
110+
111+
# Check 1: Is the tool registered?
112+
original_fc_info = history_fcs.get(original_function_call.id)
113+
if not original_fc_info:
114+
raise ValueError(
115+
f"Original function call for ID '{original_function_call.id}' not"
116+
" found in session history."
117+
)
118+
original_fc_in_history, original_fc_event = original_fc_info
119+
120+
# If this tool call was authored by another agent, skip it to let that
121+
# agent's processor handle it.
122+
agent = invocation_context.agent
123+
if agent and original_fc_event.author != agent.name:
124+
continue
125+
126+
tool = tools_dict.get(tool_name)
127+
if not tool:
128+
raise ValueError(
129+
f"Tool '{original_function_call.name}' is not registered."
130+
)
131+
132+
# Check 2: Does the tool require confirmation for these arguments?
133+
# We check if it is either statically required, or if it was dynamically
134+
# requested in the session history.
135+
temp_tool_context = ToolContext(
136+
invocation_context=invocation_context,
137+
function_call_id=original_function_call.id,
91138
)
139+
requires_confirmation = await tool.check_require_confirmation(
140+
original_function_call.args or {}, temp_tool_context
141+
)
142+
143+
requested_in_history = False
144+
if not requires_confirmation:
145+
# Search the history for the response event of the original tool call
146+
original_response_event = history_fr_events.get(
147+
original_function_call.id
148+
)
149+
if (
150+
original_response_event
151+
and original_response_event.actions.requested_tool_confirmations
152+
):
153+
requested_in_history = (
154+
original_function_call.id
155+
in original_response_event.actions.requested_tool_confirmations
156+
)
157+
158+
if not requires_confirmation and not requested_in_history:
159+
raise ValueError(
160+
f"Tool '{original_function_call.name}' does not require"
161+
" confirmation."
162+
)
163+
164+
# Check 3: Does the original function call match name and arguments?
165+
if original_fc_in_history.name != original_function_call.name:
166+
raise ValueError(
167+
f"Function call name mismatch for ID '{original_function_call.id}':"
168+
f" history has '{original_fc_in_history.name}', confirmation has"
169+
f" '{original_function_call.name}'."
170+
)
171+
172+
hist_args = original_fc_in_history.args or {}
173+
conf_args = original_function_call.args or {}
174+
if hist_args != conf_args:
175+
raise ValueError(
176+
"Function call arguments mismatch for ID"
177+
f" '{original_function_call.id}'."
178+
)
179+
92180
tool_confirmation_dict[original_function_call.id] = (
93181
confirmations_by_fc_id[function_call.id]
94182
)
@@ -115,10 +203,9 @@ async def run_async(
115203
# Step 1: Find the last user-authored event and parse confirmation
116204
# responses from it.
117205
confirmations_by_fc_id: dict[str, ToolConfirmation] = {}
118-
confirmation_event_index = -1
119206
for k in range(len(events) - 1, -1, -1):
120207
event = events[k]
121-
if not event.author or event.author != 'user':
208+
if not event.author or event.author != "user":
122209
continue
123210
responses = event.get_function_responses()
124211
if not responses:
@@ -127,29 +214,45 @@ async def run_async(
127214
for function_response in responses:
128215
if function_response.name != REQUEST_CONFIRMATION_FUNCTION_CALL_NAME:
129216
continue
217+
if not function_response.id or function_response.response is None:
218+
continue
130219
confirmations_by_fc_id[function_response.id] = _parse_tool_confirmation(
131220
function_response.response
132221
)
133-
confirmation_event_index = k
134222
break
135223

136224
if not confirmations_by_fc_id:
137225
return
138226

227+
# Resolve all canonical tools and build tools_dict
228+
tools_dict = {}
229+
if agent is not None and hasattr(agent, "canonical_tools"):
230+
tools_dict = {
231+
tool.name: tool
232+
for tool in await agent.canonical_tools(
233+
ReadonlyContext(invocation_context)
234+
)
235+
}
236+
139237
# Step 2: Resolve confirmation targets using extracted helper.
140238
confirmation_fc_ids = set(confirmations_by_fc_id.keys())
141239
tools_to_resume_with_confirmation, tools_to_resume_with_args = (
142-
_resolve_confirmation_targets(
143-
events, confirmation_fc_ids, confirmations_by_fc_id
240+
await _resolve_confirmation_targets(
241+
invocation_context,
242+
events,
243+
confirmation_fc_ids,
244+
confirmations_by_fc_id,
245+
tools_dict,
144246
)
145247
)
146248

147249
if not tools_to_resume_with_confirmation:
148250
return
149251

150252
# Step 3: Remove tools that have already been confirmed (dedup).
151-
for i in range(len(events) - 1, confirmation_event_index, -1):
152-
event = events[i]
253+
for event in reversed(events):
254+
if event.author == "user":
255+
break
153256
fr_list = event.get_function_responses()
154257
if not fr_list:
155258
continue
@@ -167,14 +270,9 @@ async def run_async(
167270
# Step 4: Re-execute the confirmed tools.
168271
if function_response_event := await functions.handle_function_call_list_async(
169272
invocation_context,
170-
tools_to_resume_with_args.values(),
171-
{
172-
tool.name: tool
173-
for tool in await agent.canonical_tools(
174-
ReadonlyContext(invocation_context)
175-
)
176-
},
177-
tools_to_resume_with_confirmation.keys(),
273+
list(tools_to_resume_with_args.values()),
274+
tools_dict,
275+
set(tools_to_resume_with_confirmation.keys()),
178276
tools_to_resume_with_confirmation,
179277
):
180278
yield function_response_event

src/google/adk/tools/base_tool.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,12 @@ async def process_llm_request(
168168
# Use the consolidated logic in LlmRequest.append_tools
169169
llm_request.append_tools([self])
170170

171+
async def check_require_confirmation(
172+
self, args: dict[str, Any], tool_context: ToolContext
173+
) -> bool:
174+
"""Returns whether the tool requires confirmation for the given args."""
175+
return False
176+
171177
@property
172178
def _api_variant(self) -> GoogleLLMVariant:
173179
return get_google_llm_variant()

src/google/adk/tools/function_tool.py

Lines changed: 42 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import logging
1919
from typing import Any
2020
from typing import Callable
21+
from typing import cast
2122
from typing import get_args
2223
from typing import get_origin
2324
from typing import get_type_hints
@@ -194,36 +195,52 @@ def _preprocess_args(self, args: dict[str, Any]) -> dict[str, Any]:
194195
args[param_name], list
195196
):
196197
item_type = get_list_inner_type(target_type)
197-
try:
198-
converted_args[param_name] = [
199-
item_type.model_validate(item)
200-
if isinstance(item, dict)
201-
else item
202-
for item in args[param_name]
203-
]
204-
except Exception as e:
205-
logger.warning(
206-
f"Failed to convert argument '{param_name}' to"
207-
f' list[{item_type.__name__}]: {e}'
208-
)
209-
pass
198+
if item_type is not None:
199+
try:
200+
converted_args[param_name] = [
201+
item_type.model_validate(item)
202+
if isinstance(item, dict)
203+
else item
204+
for item in args[param_name]
205+
]
206+
except Exception as e:
207+
logger.warning(
208+
f"Failed to convert argument '{param_name}' to"
209+
f' list[{item_type.__name__}]: {e}'
210+
)
211+
pass
210212

211213
return converted_args
212214

213-
@override
214-
async def run_async(
215-
self, *, args: dict[str, Any], tool_context: ToolContext
216-
) -> Any:
217-
# Preprocess arguments (includes Pydantic model conversion)
215+
def _prepare_invocation_args(
216+
self, args: dict[str, Any], tool_context: ToolContext
217+
) -> dict[str, Any]:
218+
"""Prepare args for function invocation (preprocesses, injects context and filters)."""
218219
args_to_call = self._preprocess_args(args)
219-
220220
signature = inspect.signature(self.func)
221-
valid_params = {param for param in signature.parameters}
221+
valid_params = set(signature.parameters.keys())
222222
if self._context_param_name in valid_params:
223223
args_to_call[self._context_param_name] = tool_context
224+
return {k: v for k, v in args_to_call.items() if k in valid_params}
224225

225-
# Filter args_to_call to only include valid parameters for the function
226-
args_to_call = {k: v for k, v in args_to_call.items() if k in valid_params}
226+
@override
227+
async def check_require_confirmation(
228+
self, args: dict[str, Any], tool_context: ToolContext
229+
) -> bool:
230+
if callable(self._require_confirmation):
231+
args_to_call = self._prepare_invocation_args(args, tool_context)
232+
return cast(
233+
bool,
234+
await self._invoke_callable(self._require_confirmation, args_to_call),
235+
)
236+
return bool(self._require_confirmation)
237+
238+
@override
239+
async def run_async(
240+
self, *, args: dict[str, Any], tool_context: ToolContext
241+
) -> Any:
242+
# Preprocess arguments (includes Pydantic model conversion)
243+
args_to_call = self._prepare_invocation_args(args, tool_context)
227244

228245
# Before invoking the function, we check for if the list of args passed in
229246
# has all the mandatory arguments or not.
@@ -242,12 +259,9 @@ async def run_async(
242259
You could retry calling this tool, but it is IMPORTANT for you to provide all the mandatory parameters."""
243260
return {'error': error_str}
244261

245-
if isinstance(self._require_confirmation, Callable):
246-
require_confirmation = await self._invoke_callable(
247-
self._require_confirmation, args_to_call
248-
)
249-
else:
250-
require_confirmation = bool(self._require_confirmation)
262+
require_confirmation = await self.check_require_confirmation(
263+
args, tool_context
264+
)
251265

252266
if require_confirmation:
253267
if not tool_context.tool_confirmation:

0 commit comments

Comments
 (0)