π΄ Required Information
Describe the Bug:
google.adk.telemetry._serialization.safe_json_serialize serializes pydantic
models with model_dump(mode="json"), which renders every field β including
the ones AuthCredential, OAuth2Auth, HttpCredentials and
ServiceAccountCredential declare as Field(repr=False).
adk_request_credential carries an AuthConfig as its arguments
(AuthToolArguments.auth_config) and receives one back as its function
response, and AuthConfig.exchanged_auth_credential is a full
AuthCredential. So the tool-call span for that flow exports the OAuth
access_token, refresh_token and client_secret in the clear, in
gcp.vertex.agent.tool_call_args and gcp.vertex.agent.tool_response.
This is the default configuration, not an opt-in:
ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS defaults on
(TelemetryContext.should_add_content_to_legacy_spans). The spans go wherever
the exporter points β Cloud Trace, or any third-party OTel backend.
ADK already decides in three places that credentials must not reach an
exported span attribute:
telemetry/tracing.py, _build_llm_request_for_trace excludes
http_options.headers, extra_body and *client_args from the dumped
llm_request.config, with the comment "http_options carries
caller-supplied credentials β¦ None of it may reach an exported span
attribute."
- The MCP HTTP exchange record takes headers that "arrive already redacted,
so allowlisting a credential header yields the redaction marker rather
than the secret."
plugins/auto_tracing_helpers.py masks captured arguments through
_CREDENTIAL_TYPE_NAMES β which already names AuthConfig,
AuthCredential, AuthToolArguments and OAuth2Auth β plus a field-name
list and a bounded structural walk.
The rule and the list both already exist. They are applied in
AutoTracingPlugin's argument capture and not on the default tracing.py
path.
To be explicit about what is not being claimed: Field(repr=False) is a
repr control, and model_dump() rendering those fields is documented
pydantic behaviour, not a pydantic bug. The point is that ADK marks these
fields as not-for-display and maintains a redaction list naming their types,
and the telemetry serializer consults neither.
Steps to Reproduce:
pip install google-adk (or run from a checkout of main).
- Save the script under Minimal Reproduction Code below as
repro.py.
- Run
python repro.py with no ADK environment variables set.
- It exits 1 and prints one
LEAK line per secret that reached a span
attribute.
Expected Behavior:
No credential value reaches an exported span attribute. A trace may show
that a credential was present β the key, a redaction marker β but not its
value, consistent with what _build_llm_request_for_trace and the MCP header
record already do.
Observed Behavior:
LEAK span='execute_tool' attribute='gcp.vertex.agent.tool_call_args' secret=access_token
LEAK span='execute_tool' attribute='gcp.vertex.agent.tool_call_args' secret=refresh_token
LEAK span='execute_tool' attribute='gcp.vertex.agent.tool_call_args' secret=client_secret
LEAK span='execute_tool' attribute='gcp.vertex.agent.tool_response' secret=access_token
LEAK span='execute_tool' attribute='gcp.vertex.agent.tool_response' secret=refresh_token
LEAK span='execute_tool' attribute='gcp.vertex.agent.tool_response' secret=client_secret
repr() on the same object masks all three, which is the contrast worth
seeing:
access_token in repr(): False
refresh_token in repr(): False
client_secret in repr(): False
safe_json_serialize -> leaked: ['access_token', 'refresh_token', 'client_secret']
Environment Details:
- ADK Library Version:
2.10.0 (reproduced from source at commit 044a1ec,
2026-09-25)
- Desktop OS: Windows 11
- Python Version: 3.12.3
Model Information:
- Are you using LiteLLM: No
- Which model is being used: N/A β the repro calls
trace_tool_call directly
and needs no model.
π‘ Optional Information
Regression: Not a regression; safe_json_serialize has dumped models this
way for as long as it has existed.
Minimal Reproduction Code:
"""An OAuth access token lands in an exported ADK span attribute.
Default config: ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS defaults on.
Exits 1 if a secret reaches a span.
"""
import sys
from types import SimpleNamespace
from fastapi.openapi.models import OAuth2, OAuthFlows, OAuthFlowAuthorizationCode
from google.genai import types
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
from google.adk.auth.auth_credential import (
AuthCredential, AuthCredentialTypes, OAuth2Auth)
from google.adk.auth.auth_tool import AuthConfig, AuthToolArguments
from google.adk.telemetry.tracing import trace_tool_call
TOKEN = "ya29.LEAKED-ACCESS-TOKEN"
REFRESH = "1//LEAKED-REFRESH-TOKEN"
SECRET = "LEAKED-CLIENT-SECRET"
SECRETS = {"access_token": TOKEN, "refresh_token": REFRESH,
"client_secret": SECRET}
exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
trace.set_tracer_provider(provider)
scheme = OAuth2(flows=OAuthFlows(authorizationCode=OAuthFlowAuthorizationCode(
authorizationUrl="https://example.com/auth",
tokenUrl="https://example.com/token", scopes={})))
cred = AuthCredential(
auth_type=AuthCredentialTypes.OAUTH2,
oauth2=OAuth2Auth(client_id="cid", client_secret=SECRET,
access_token=TOKEN, refresh_token=REFRESH))
auth_config = AuthConfig(auth_scheme=scheme, exchanged_auth_credential=cred)
# What ADK puts on the wire: adk_request_credential's args, and the client's
# auth response coming back as that call's function_response.
args = AuthToolArguments(function_call_id="fc-1",
auth_config=auth_config).model_dump(mode="json")
response_event = SimpleNamespace(
id="ev-1",
content=types.Content(role="user", parts=[types.Part(
function_response=types.FunctionResponse(
id="fc-1", name="adk_request_credential",
response=auth_config.model_dump(mode="json")))]))
tool = SimpleNamespace(name="adk_request_credential",
description="request end user credentials",
custom_metadata=None)
tracer = provider.get_tracer("repro")
with tracer.start_as_current_span("execute_tool") as span:
trace_tool_call(tool=tool, args=args,
function_response_event=response_event, span=span)
leaks = []
for s in exporter.get_finished_spans():
for key, value in (s.attributes or {}).items():
for name, secret in SECRETS.items():
if secret in str(value):
leaks.append((s.name, key, name))
for span_name, key, name in leaks:
print(f"LEAK span={span_name!r} attribute={key!r} secret={name}")
if not leaks:
print("no secret reached any span attribute")
sys.exit(1 if leaks else 0)
How often has this issue occurred?: Always (100%)
Additional Context β suggested fix:
Apply the existing redaction rule at the serializer, reusing
auto_tracing_helpers' type and field-name lists rather than adding a second
set β which means lifting those lists into a module both sites can import.
Mask by declared credential type and by field name, keep the key so a trace
still shows a credential was present, and bound the walk so hitting a bound
elides rather than leaks.
I have this working with tests and no regressions against
tests/unittests/telemetry and tests/unittests/plugins, and can open a PR.
Disclosure: Reported to the Google VRP as issue 558689693; assessed as
below their security-tracking threshold, with public disclosure on GitHub
invited.
π΄ Required Information
Describe the Bug:
google.adk.telemetry._serialization.safe_json_serializeserializes pydanticmodels with
model_dump(mode="json"), which renders every field β includingthe ones
AuthCredential,OAuth2Auth,HttpCredentialsandServiceAccountCredentialdeclare asField(repr=False).adk_request_credentialcarries anAuthConfigas its arguments(
AuthToolArguments.auth_config) and receives one back as its functionresponse, and
AuthConfig.exchanged_auth_credentialis a fullAuthCredential. So the tool-call span for that flow exports the OAuthaccess_token,refresh_tokenandclient_secretin the clear, ingcp.vertex.agent.tool_call_argsandgcp.vertex.agent.tool_response.This is the default configuration, not an opt-in:
ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANSdefaults on(
TelemetryContext.should_add_content_to_legacy_spans). The spans go whereverthe exporter points β Cloud Trace, or any third-party OTel backend.
ADK already decides in three places that credentials must not reach an
exported span attribute:
telemetry/tracing.py,_build_llm_request_for_traceexcludeshttp_options.headers,extra_bodyand*client_argsfrom the dumpedllm_request.config, with the comment "http_optionscarriescaller-supplied credentials β¦ None of it may reach an exported span
attribute."
so allowlisting a credential header yields the redaction marker rather
than the secret."
plugins/auto_tracing_helpers.pymasks captured arguments through_CREDENTIAL_TYPE_NAMESβ which already namesAuthConfig,AuthCredential,AuthToolArgumentsandOAuth2Authβ plus a field-namelist and a bounded structural walk.
The rule and the list both already exist. They are applied in
AutoTracingPlugin's argument capture and not on the defaulttracing.pypath.
To be explicit about what is not being claimed:
Field(repr=False)is areprcontrol, andmodel_dump()rendering those fields is documentedpydantic behaviour, not a pydantic bug. The point is that ADK marks these
fields as not-for-display and maintains a redaction list naming their types,
and the telemetry serializer consults neither.
Steps to Reproduce:
pip install google-adk(or run from a checkout ofmain).repro.py.python repro.pywith no ADK environment variables set.LEAKline per secret that reached a spanattribute.
Expected Behavior:
No credential value reaches an exported span attribute. A trace may show
that a credential was present β the key, a redaction marker β but not its
value, consistent with what
_build_llm_request_for_traceand the MCP headerrecord already do.
Observed Behavior:
repr()on the same object masks all three, which is the contrast worthseeing:
Environment Details:
2.10.0(reproduced from source at commit044a1ec,2026-09-25)
Model Information:
trace_tool_calldirectlyand needs no model.
π‘ Optional Information
Regression: Not a regression;
safe_json_serializehas dumped models thisway for as long as it has existed.
Minimal Reproduction Code:
How often has this issue occurred?: Always (100%)
Additional Context β suggested fix:
Apply the existing redaction rule at the serializer, reusing
auto_tracing_helpers' type and field-name lists rather than adding a secondset β which means lifting those lists into a module both sites can import.
Mask by declared credential type and by field name, keep the key so a trace
still shows a credential was present, and bound the walk so hitting a bound
elides rather than leaks.
I have this working with tests and no regressions against
tests/unittests/telemetryandtests/unittests/plugins, and can open a PR.Disclosure: Reported to the Google VRP as issue 558689693; assessed as
below their security-tracking threshold, with public disclosure on GitHub
invited.