Skip to content

RestApiTool writes the tool's API key / OAuth token into the caller's args, so it lands in trace spans and after-tool callbacks#7162

Description

@1aifanatic

馃敶 Required Information

Describe the Bug:

RestApiTool.call() adds the tool's credential to the args dict it was given, in place:

# src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py
api_params, api_args = self._operation_parser.get_parameters().copy(), args
...
api_args.update(auth_args)  # e.g. {"_auth_prefix_vaf_X-API-Key": "<key>"} or "Bearer <token>"

That dict is not private to the tool. The tool pipeline in flows/llm_flows/tools/_caller.py hands the same function_args object to:

  • every plugin and agent after_tool_callback (as tool_args / args), and
  • trace_tool_call(), which runs after the tool returns and writes it to the execute_tool span as gcp.vertex.agent.tool_call_args. Content capture is on by default (ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS).

So the API key, or the end user's OAuth bearer token, ends up in trace backends (Cloud Trace or any OTel exporter) and in whatever after-tool plugins record, for example analytics or logging plugins. That covers every toolset built on RestApiTool: OpenAPIToolset, APIHubToolset, GoogleApiToolset, and ApplicationIntegrationToolset.

IntegrationConnectorTool.run_async() does the same thing one level up. It writes dynamic_auth_config = {"oauth2_auth_code_flow.access_token": <end-user token>} into the caller's args, then logs the whole dict at INFO:

logger.info('Running tool: %s with args: %s', self.name, args)

AuthenticatedFunctionTool already avoids this (args_to_call = args.copy()); these two tools don't.

The session is not affected: the persisted FunctionCall args are deep-copied before the tool runs.

Steps to Reproduce:

  1. pip install google-adk (reproduced on main @ f33d492 / 2.9.0).
  2. Run the script below: a real Runner plus LlmAgent plus OpenAPIToolset with API-key auth. The model is mocked and the HTTP call is stubbed, so no API key or network is needed.

Expected Behavior:
The credential is sent in the HTTP request only. The after_tool_callback and the execute_tool span see the model's arguments: {"order_id": "A-42"}.

Observed Behavior:

HTTP headers sent        : {'User-Agent': 'google-adk/2.9.0 (tool: get_order)', 'X-API-Key': 'sk-live-SECRET-API-KEY-123'}
after_tool_callback args : {'order_id': 'A-42', '_auth_prefix_vaf_X-API-Key': 'sk-live-SECRET-API-KEY-123'}
execute_tool span args   : ['{"order_id": "A-42", "_auth_prefix_vaf_X-API-Key": "sk-live-SECRET-API-KEY-123"}']
secret in callback args  : True
secret in span attribute : True

Environment Details:

  • ADK Library Version (pip show google-adk): 2.9.0 (main @ f33d492)
  • Desktop OS: Windows 11
  • Python Version (python -V): 3.12.10

Model Information:

  • Are you using LiteLLM: No
  • Which model is being used: N/A (mocked model; the bug is in the tool layer)

馃煛 Optional Information

Minimal Reproduction Code:

import asyncio, json
from unittest.mock import MagicMock, patch

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter

exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
trace.set_tracer_provider(provider)

from google.adk.agents.llm_agent import LlmAgent
from google.adk.models.base_llm import BaseLlm
from google.adk.models.llm_response import LlmResponse
from google.adk.runners import InMemoryRunner
from google.adk.tools.openapi_tool.auth.auth_helpers import token_to_scheme_credential
from google.adk.tools.openapi_tool.openapi_spec_parser.openapi_toolset import OpenAPIToolset
from google.genai import types

SECRET = "sk-live-SECRET-API-KEY-123"
spec = {
    "openapi": "3.0.0",
    "info": {"title": "Orders", "version": "1"},
    "servers": [{"url": "https://erp.example.com"}],
    "paths": {"/orders/{order_id}": {"get": {
        "operationId": "getOrder",
        "parameters": [{"name": "order_id", "in": "path", "required": True,
                        "schema": {"type": "string"}}],
        "responses": {"200": {"description": "ok"}},
    }}},
}
scheme, cred = token_to_scheme_credential("apikey", "header", "X-API-Key", SECRET)


class FakeModel(BaseLlm):
  model: str = "fake"
  calls: int = 0

  async def generate_content_async(self, llm_request, stream=False):
    self.calls += 1
    part = (types.Part.from_function_call(name="get_order", args={"order_id": "A-42"})
            if self.calls == 1 else types.Part.from_text(text="done"))
    yield LlmResponse(content=types.Content(role="model", parts=[part]))


seen = {}
agent = LlmAgent(
    name="erp_agent",
    model=FakeModel(),
    tools=[OpenAPIToolset(spec_dict=spec, auth_scheme=scheme, auth_credential=cred)],
    after_tool_callback=lambda tool, args, tool_context, tool_response: seen.update(args),
)


async def main():
  resp = MagicMock(status_code=200)
  resp.json.return_value = {"order": "A-42", "status": "shipped"}
  with patch("google.adk.tools.openapi_tool.openapi_spec_parser.rest_api_tool._request",
             return_value=resp):
    runner = InMemoryRunner(agent=agent)
    session = await runner.session_service.create_session(app_name=runner.app_name, user_id="u")
    async for _ in runner.run_async(
        user_id="u", session_id=session.id,
        new_message=types.Content(role="user", parts=[types.Part(text="status of A-42?")])):
      pass
  span_args = [s.attributes.get("gcp.vertex.agent.tool_call_args")
               for s in exporter.get_finished_spans() if s.name.startswith("execute_tool")]
  print("after_tool_callback args :", seen)
  print("execute_tool span args   :", span_args)
  print("secret in callback args  :", SECRET in json.dumps(seen))
  print("secret in span attribute :", any(SECRET in (a or "") for a in span_args))


asyncio.run(main())

Suggested fix: do what AuthenticatedFunctionTool does and add the auth params to a copy of args, in both RestApiTool.call() and IntegrationConnectorTool.run_async(), and log the connector's args before the token is added. I have a PR ready with regression tests.

How often has this issue occurred?:

  • Always (100%): any RestApiTool-based tool call that has an auth credential configured.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

tools[Component] This issue is related to tools

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions