Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 5 additions & 72 deletions src/google/adk/plugins/auto_tracing_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@

from opentelemetry import trace as trace_api

from ..utils._credential_names import is_credential_arg_name as _is_credential_arg_name
from ..utils._credential_names import is_credential_type as _is_credential_type
from ..utils._credential_names import MAX_REDACT_DEPTH as _MAX_REDACT_DEPTH
from ..utils._credential_names import MAX_REDACT_NODES as _MAX_REDACT_NODES

logger = logging.getLogger("google_adk." + __name__)

DEFAULT_MAX_REPR_LEN = 4096
Expand All @@ -43,78 +48,6 @@
_SCALAR_TYPES = frozenset({int, float, bool, str, bytes, type(None)})
_DEFAULT_REPR_RE = re.compile(r"^<.+ object at 0x[0-9a-fA-F]+>$")

# Types whose repr() renders live secrets (tokens, keys, passwords). Matched by
# name over the MRO so this module never imports ``google.adk.auth``.
_CREDENTIAL_TYPE_NAMES = frozenset({
"AuthConfig",
"AuthCredential",
"AuthToolArguments",
"Credentials",
"HttpAuth",
"HttpCredentials",
"OAuth2Auth",
"OAuth2Session",
"ServiceAccount",
"ServiceAccountCredential",
})
# Parameter names that conventionally carry secret material.
_CREDENTIAL_ARG_NAMES = frozenset({
"api_key",
"auth_config",
"auth_credential",
"authorization",
"cookie",
"cookies",
"credential",
"credentials",
"password",
"private_key",
"secret",
"token",
})
_CREDENTIAL_ARG_SUFFIXES = (
"_api_key",
"_auth_config",
"_authorization",
"_cookie",
"_cookies",
"_credential",
"_credentials",
"_password",
"_private_key",
"_secret",
"_token",
)
# Bounds for the structural walk below. Both are deliberately generous: only
# containers and objects consume node budget, so a list of a million ints
# costs one node.
_MAX_REDACT_DEPTH = 10
_MAX_REDACT_NODES = 1024


def _mro_holds_credential(cls: type) -> bool:
"""True iff ``cls`` or one of its bases is a credential-bearing type."""
return any(k.__name__ in _CREDENTIAL_TYPE_NAMES for k in cls.__mro__)


# Cached because the walk asks this of every non-scalar node it visits. The
# annotation is spelled out because lru_cache erases the wrapped signature to
# ``*args: Hashable``, which the ``type(value)`` the callers pass does not
# satisfy.
_is_credential_type: Callable[[type], bool] = functools.lru_cache(maxsize=512)(
_mro_holds_credential
)


@functools.lru_cache(maxsize=1024)
def _is_credential_arg_name(name: str) -> bool:
"""True iff a value named ``name`` conventionally holds a secret."""
# Hyphens fold so a header key like X-Api-Key matches the way api_key does.
lowered = name.lower().replace("-", "_")
return lowered in _CREDENTIAL_ARG_NAMES or lowered.endswith(
_CREDENTIAL_ARG_SUFFIXES
)


@dataclasses.dataclass(frozen=True)
class Caps:
Expand Down
92 changes: 89 additions & 3 deletions src/google/adk/telemetry/_serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,96 @@

from __future__ import annotations

from collections.abc import Mapping
import json

from google.genai import types
from opentelemetry.util.types import AnyValue
from pydantic import BaseModel

from ..utils._credential_names import is_credential_arg_name
from ..utils._credential_names import is_credential_type
from ..utils._credential_names import MAX_REDACT_DEPTH
from ..utils._credential_names import MAX_REDACT_NODES
from ..utils._credential_names import REDACTED

_ELIDED = "<elided>"


def _redact_credentials(
obj: object, depth: int, budget: list[int], active: set[int]
) -> object:
"""Returns ``obj`` as JSON-ready data with credential material masked.

`model_dump()` renders every field, including the ones `AuthCredential` and
friends declare `Field(repr=False)`, so a credential that reaches a span
attribute through this serializer would be exported in the clear even though
`repr()` masks it. Masking is by declared credential type and by field name,
from `..utils._credential_names`, so the rule here is the same rule
`AutoTracingPlugin` applies to captured arguments.

A masked value keeps its key, so a trace still shows that a credential was
present without showing what it was.

The walk is bounded by depth and by a node budget. A subtree it refuses to
walk is elided rather than emitted, so hitting a bound can never uncover a
secret.

Args:
obj: The value to redact.
depth: Current nesting depth.
budget: Single-element remaining-node budget, mutated as containers are
visited.
active: Ids of the containers on the path to `obj`, so a cycle is caught
rather than rebuilt into a finite tree.

Returns:
A JSON-ready copy of `obj` with credentials replaced by a marker.

Raises:
ValueError: If `obj` contains a reference cycle. `json.dumps` would raise
the same error on it, and `safe_json_serialize` reports it the same way.
"""
if is_credential_type(type(obj)):
return REDACTED
if isinstance(obj, (str, int, float, bool, type(None))):
return obj
if depth >= MAX_REDACT_DEPTH or budget[0] <= 0:
return _ELIDED
budget[0] -= 1
if id(obj) in active:
raise ValueError("Circular reference detected")
active.add(id(obj))
try:
if isinstance(obj, BaseModel):
return _redact_credentials(
obj.model_dump(mode="json"), depth + 1, budget, active
)
if isinstance(obj, Mapping):
return {
str(key): (
REDACTED
if is_credential_arg_name(str(key))
else _redact_credentials(value, depth + 1, budget, active)
)
for key, value in obj.items()
}
if isinstance(obj, (list, tuple)):
return [
_redact_credentials(item, depth + 1, budget, active) for item in obj
]
finally:
active.discard(id(obj))
# Anything else is left to `json.dumps`'s `default` hook below.
return obj


def safe_json_serialize(obj: object) -> str:
"""Convert any Python object to a JSON-serializable type or string.

Handles Pydantic `BaseModel` instances (common as tool return types) by
calling `model_dump(mode="json")` before JSON encoding.
calling `model_dump(mode="json")` before JSON encoding, and masks credential
material on the way (see `_redact_credentials`).

Args:
obj: The object to serialize.
Expand All @@ -38,12 +116,20 @@ def safe_json_serialize(obj: object) -> str:
"""

def _default(o: object) -> object:
if is_credential_type(type(o)):
return REDACTED
if isinstance(o, BaseModel):
return o.model_dump(mode="json")
return _redact_credentials(
o.model_dump(mode="json"), 0, [MAX_REDACT_NODES], set()
)
return "<not serializable>"

try:
return json.dumps(obj, ensure_ascii=False, default=_default)
return json.dumps(
_redact_credentials(obj, 0, [MAX_REDACT_NODES], set()),
ensure_ascii=False,
default=_default,
)
except (TypeError, ValueError, OverflowError, RecursionError):
return "<not serializable>"

Expand Down
102 changes: 102 additions & 0 deletions src/google/adk/utils/_credential_names.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Which types and field names carry secrets, for every redaction site.

Shared so that the redaction rule is one rule: a type or field name added
here is masked by `AutoTracingPlugin`'s argument capture and by the telemetry
serializer alike, rather than by whichever of them was edited last.
"""

from __future__ import annotations

import functools
from typing import Callable

# The marker recorded in place of a secret. Same spelling as the one
# `BaseModelWithConfig.__repr_args__` uses for unmodeled credential fields.
REDACTED = '<redacted>'

# Types whose repr() or model_dump() renders live secrets (tokens, keys,
# passwords). Matched by name over the MRO so this module never imports
# ``google.adk.auth``.
CREDENTIAL_TYPE_NAMES = frozenset({
'AuthConfig',
'AuthCredential',
'AuthToolArguments',
'Credentials',
'HttpAuth',
'HttpCredentials',
'OAuth2Auth',
'OAuth2Session',
'ServiceAccount',
'ServiceAccountCredential',
})
# Parameter and field names that conventionally carry secret material.
CREDENTIAL_ARG_NAMES = frozenset({
'api_key',
'auth_config',
'auth_credential',
'authorization',
'cookie',
'cookies',
'credential',
'credentials',
'password',
'private_key',
'secret',
'token',
})
CREDENTIAL_ARG_SUFFIXES = (
'_api_key',
'_auth_config',
'_authorization',
'_cookie',
'_cookies',
'_credential',
'_credentials',
'_password',
'_private_key',
'_secret',
'_token',
)
# Bounds for the structural walks that use these names. Both are deliberately
# generous: only containers and objects consume node budget, so a list of a
# million ints costs one node.
MAX_REDACT_DEPTH = 10
MAX_REDACT_NODES = 1024


def _mro_holds_credential(cls: type) -> bool:
"""True iff ``cls`` or one of its bases is a credential-bearing type."""
return any(k.__name__ in CREDENTIAL_TYPE_NAMES for k in cls.__mro__)


# Cached because a walk asks this of every non-scalar node it visits. The
# annotation is spelled out because lru_cache erases the wrapped signature to
# ``*args: Hashable``, which the ``type(value)`` the callers pass does not
# satisfy.
is_credential_type: Callable[[type], bool] = functools.lru_cache(maxsize=512)(
_mro_holds_credential
)


@functools.lru_cache(maxsize=1024)
def is_credential_arg_name(name: str) -> bool:
"""True iff a parameter or field called ``name`` conventionally holds a secret."""
# Hyphens fold so a header key like X-Api-Key matches the way api_key does.
lowered = name.lower().replace('-', '_')
return lowered in CREDENTIAL_ARG_NAMES or lowered.endswith(
CREDENTIAL_ARG_SUFFIXES
)
57 changes: 57 additions & 0 deletions tests/unittests/telemetry/test_serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,14 @@

from __future__ import annotations

import json

from google.adk.auth.auth_credential import AuthCredential
from google.adk.auth.auth_credential import AuthCredentialTypes
from google.adk.auth.auth_credential import OAuth2Auth
from google.adk.telemetry._serialization import safe_json_serialize
from google.adk.telemetry._serialization import serialize_content
from google.adk.utils._credential_names import MAX_REDACT_DEPTH
from google.genai import types


Expand Down Expand Up @@ -84,3 +91,53 @@ def test_serialize_content_unserializable_value_yields_the_sentinel():
raising out of the telemetry path.
"""
assert serialize_content(object()) == '"<not serializable>"'


_TOKEN = 'ya29.access-token-value'


def _oauth_credential() -> AuthCredential:
return AuthCredential(
auth_type=AuthCredentialTypes.OAUTH2,
oauth2=OAuth2Auth(client_id='cid', access_token=_TOKEN),
)


def test_safe_json_serialize_masks_a_credential_model():
"""A credential model must not reach a span attribute in the clear.

`Field(repr=False)` keeps these values out of `repr()`, but `model_dump()`
renders them, so the serializer has to mask them itself.
"""
assert _TOKEN not in safe_json_serialize(_oauth_credential())


def test_safe_json_serialize_masks_a_credential_nested_in_tool_args():
"""Tool args and tool responses arrive as plain dicts, so the type is gone.

`adk_request_credential` carries its `AuthConfig` under `auth_config`, which
is why the field name has to be masked as well as the declared type.
"""
args = {
'function_call_id': 'fc-1',
'auth_config': {'exchanged_auth_credential': {'access_token': _TOKEN}},
}
serialized = safe_json_serialize(args)
assert _TOKEN not in serialized
# The key survives, so a trace still shows a credential was present.
assert 'auth_config' in serialized
assert 'fc-1' in serialized


def test_safe_json_serialize_leaves_ordinary_content_unchanged():
"""Redaction must not alter a payload that holds no credential."""
payload = {'city': 'Paris', 'temps': [1, 2.5, None, True], 'n': {'k': 'v'}}
assert json.loads(safe_json_serialize(payload)) == payload


def test_safe_json_serialize_elides_past_its_depth_bound():
"""Hitting the walk's bound must elide the subtree, never emit it raw."""
nested: dict[str, object] = {'access_token': _TOKEN}
for _ in range(MAX_REDACT_DEPTH + 2):
nested = {'wrap': nested}
assert _TOKEN not in safe_json_serialize(nested)
Loading