Skip to content

Commit 4832d54

Browse files
Merge branch 'main' into fix/dev-server-eval-set-legacy-nameerror
2 parents 517ecbc + fd44a63 commit 4832d54

7 files changed

Lines changed: 606 additions & 19 deletions

File tree

src/google/adk/plugins/auto_tracing_helpers.py

Lines changed: 262 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from __future__ import annotations
1818

1919
import asyncio
20+
from collections.abc import Mapping
2021
import dataclasses
2122
import functools
2223
import inspect
@@ -41,6 +42,77 @@
4142
_SCALAR_TYPES = frozenset({int, float, bool, str, bytes, type(None)})
4243
_DEFAULT_REPR_RE = re.compile(r"^<.+ object at 0x[0-9a-fA-F]+>$")
4344

45+
# Types whose repr() renders live secrets (tokens, keys, passwords). Matched by
46+
# name over the MRO so this module never imports ``google.adk.auth``.
47+
_CREDENTIAL_TYPE_NAMES = frozenset({
48+
"AuthConfig",
49+
"AuthCredential",
50+
"AuthToolArguments",
51+
"Credentials",
52+
"HttpAuth",
53+
"HttpCredentials",
54+
"OAuth2Auth",
55+
"OAuth2Session",
56+
"ServiceAccount",
57+
"ServiceAccountCredential",
58+
})
59+
# Parameter names that conventionally carry secret material.
60+
_CREDENTIAL_ARG_NAMES = frozenset({
61+
"api_key",
62+
"auth_config",
63+
"auth_credential",
64+
"authorization",
65+
"cookie",
66+
"cookies",
67+
"credential",
68+
"credentials",
69+
"password",
70+
"private_key",
71+
"secret",
72+
"token",
73+
})
74+
_CREDENTIAL_ARG_SUFFIXES = (
75+
"_api_key",
76+
"_auth_config",
77+
"_authorization",
78+
"_cookie",
79+
"_cookies",
80+
"_credential",
81+
"_credentials",
82+
"_password",
83+
"_private_key",
84+
"_secret",
85+
"_token",
86+
)
87+
# Bounds for the structural walk below. Both are deliberately generous: only
88+
# containers and objects consume node budget, so a list of a million ints
89+
# costs one node.
90+
_MAX_REDACT_DEPTH = 10
91+
_MAX_REDACT_NODES = 1024
92+
93+
94+
def _mro_holds_credential(cls: type) -> bool:
95+
"""True iff ``cls`` or one of its bases is a credential-bearing type."""
96+
return any(k.__name__ in _CREDENTIAL_TYPE_NAMES for k in cls.__mro__)
97+
98+
99+
# Cached because the walk asks this of every non-scalar node it visits. The
100+
# annotation is spelled out because lru_cache erases the wrapped signature to
101+
# ``*args: Hashable``, which the ``type(value)`` the callers pass does not
102+
# satisfy.
103+
_is_credential_type: Callable[[type], bool] = functools.lru_cache(maxsize=512)(
104+
_mro_holds_credential
105+
)
106+
107+
108+
@functools.lru_cache(maxsize=1024)
109+
def _is_credential_arg_name(name: str) -> bool:
110+
"""True iff a parameter called ``name`` conventionally holds a secret."""
111+
lowered = name.lower()
112+
return lowered in _CREDENTIAL_ARG_NAMES or lowered.endswith(
113+
_CREDENTIAL_ARG_SUFFIXES
114+
)
115+
44116

45117
@dataclasses.dataclass(frozen=True)
46118
class Caps:
@@ -73,8 +145,158 @@ def __repr__(self) -> str:
73145
)
74146

75147

148+
def _plain_repr(value: Any) -> str:
149+
"""``repr(value)`` that never raises."""
150+
try:
151+
return repr(value)
152+
except Exception: # pylint: disable=broad-exception-caught
153+
return f"<unrepr-able {type(value).__name__}>"
154+
155+
156+
def _redacted_repr(value: Any) -> str | None:
157+
"""Renders ``value`` with nested credentials masked, or ``None`` if clean.
158+
159+
``None`` means "no secret material anywhere in here", and the caller keeps
160+
plain ``repr()``. Otherwise the walk rebuilds the rendering element by
161+
element -- through mappings, sequences, sets, NamedTuples, dataclasses,
162+
pydantic models and plain objects -- so a credential is masked wherever it
163+
sits rather than only at the top level. Clean subtrees are still rendered
164+
with ``repr()``, so the text of an ordinary value is unchanged.
165+
166+
The walk is bounded three ways: nesting depth, the number of container
167+
nodes visited, and an id set that stops cycles. A subtree it refuses to
168+
walk is elided rather than repr'd, so hitting a bound can never uncover a
169+
secret. An object that hides state behind a leading underscore is
170+
*inspected* there but only ever *rendered* from its public attributes, so
171+
the redacted form never shows more than the original repr would have.
172+
"""
173+
budget = [_MAX_REDACT_NODES]
174+
active: set[int] = set()
175+
176+
def member(name: Any, v: Any, depth: int) -> str | None:
177+
"""Like ``walk`` but masks by field/key name too."""
178+
if isinstance(name, str) and _is_credential_arg_name(name):
179+
return f"<{type(v).__name__}>"
180+
return walk(v, depth)
181+
182+
def members(items: Any, depth: int) -> list[str] | None:
183+
"""``["name=text", ...]``, or ``None`` when nothing needed masking.
184+
185+
Clean children are only rendered once the node is known to be dirty, so a
186+
value with no secret in it costs a traversal and not a repr per node.
187+
"""
188+
walked = [(name, v, member(name, v, depth)) for name, v in items]
189+
if all(text is None for _, _, text in walked):
190+
return None
191+
return [
192+
f"{name}={text if text is not None else _plain_repr(v)}"
193+
for name, v, text in walked
194+
]
195+
196+
def walk(v: Any, depth: int) -> str | None:
197+
if type(v) in _SCALAR_TYPES:
198+
return None
199+
cls = type(v)
200+
if _is_credential_type(cls):
201+
return f"<{cls.__name__}>"
202+
if isinstance(v, type) or inspect.ismodule(v):
203+
return None
204+
# StreamResult already renders each sampled item through safe_repr, so
205+
# walking it would only cost the yield count its own repr reports.
206+
if isinstance(v, StreamResult):
207+
return None
208+
budget[0] -= 1
209+
marker = id(v)
210+
if budget[0] < 0 or depth >= _MAX_REDACT_DEPTH or marker in active:
211+
return f"<{cls.__name__} ...>"
212+
active.add(marker)
213+
try:
214+
return descend(v, depth + 1)
215+
finally:
216+
active.discard(marker)
217+
218+
def descend(v: Any, depth: int) -> str | None:
219+
cls = type(v)
220+
name = cls.__name__
221+
speaks_for_itself = getattr(cls, "__repr__", None) is not object.__repr__
222+
if speaks_for_itself and isinstance(v, tuple) and hasattr(cls, "_fields"):
223+
parts = members(zip(cls._fields, v), depth)
224+
return f"{name}({', '.join(parts)})" if parts else None
225+
if speaks_for_itself and isinstance(v, Mapping):
226+
walked = [
227+
(k, walk(k, depth), item, member(k, item, depth))
228+
for k, item in v.items()
229+
]
230+
if all(kt is None and vt is None for _, kt, _, vt in walked):
231+
return None
232+
body = ", ".join(
233+
f"{kt if kt is not None else _plain_repr(k)}:"
234+
f" {vt if vt is not None else _plain_repr(item)}"
235+
for k, kt, item, vt in walked
236+
)
237+
return "{" + body + "}"
238+
if isinstance(v, (list, tuple, set, frozenset)):
239+
elements = [(item, walk(item, depth)) for item in v]
240+
if all(text is None for _, text in elements):
241+
return None
242+
parts = [
243+
text if text is not None else _plain_repr(item)
244+
for item, text in elements
245+
]
246+
if isinstance(v, list):
247+
return f"[{', '.join(parts)}]"
248+
if isinstance(v, tuple):
249+
return f"({parts[0]},)" if len(parts) == 1 else f"({', '.join(parts)})"
250+
body = "{" + ", ".join(parts) + "}"
251+
return body if isinstance(v, set) else f"frozenset({body})"
252+
if (
253+
speaks_for_itself
254+
and dataclasses.is_dataclass(v)
255+
and not isinstance(v, type)
256+
):
257+
parts = members(
258+
((f.name, getattr(v, f.name, None)) for f in dataclasses.fields(v)),
259+
depth,
260+
)
261+
return f"{name}({', '.join(parts)})" if parts else None
262+
if speaks_for_itself and isinstance(
263+
getattr(cls, "model_fields", None), dict
264+
):
265+
declared = getattr(v, "__dict__", None) or {}
266+
extra = getattr(v, "__pydantic_extra__", None) or {}
267+
parts = members(list(declared.items()) + list(extra.items()), depth)
268+
return f"{name}({', '.join(parts)})" if parts else None
269+
return summarize_object(v, depth)
270+
271+
def summarize_object(v: Any, depth: int) -> str | None:
272+
"""Public-attribute summary; private state is inspected but never shown."""
273+
held: list[tuple[str, Any]] = []
274+
instance_dict = getattr(v, "__dict__", None)
275+
if isinstance(instance_dict, dict):
276+
held.extend(instance_dict.items())
277+
for slot in sorted(public_slot_names(type(v))):
278+
try:
279+
held.append((slot, getattr(v, slot)))
280+
except AttributeError:
281+
continue
282+
walked = [(name, item, member(name, item, depth)) for name, item in held]
283+
if all(text is None for _, _, text in walked):
284+
return None
285+
parts = [
286+
f"{name}={text if text is not None else _plain_repr(item)}"
287+
for name, item, text in walked
288+
if not name.startswith("_")
289+
]
290+
cls_name = type(v).__name__
291+
if not parts:
292+
return f"<{cls_name}>"
293+
return f"<{cls_name} fields={{{', '.join(parts)}}}>"
294+
295+
return walk(value, 0)
296+
297+
76298
def safe_repr(value: Any, caps: Caps) -> str:
77-
"""``repr(value)`` capped, resilient, with default-form objects summarized."""
299+
"""``repr(value)`` capped, resilient, credential-masked, defaults summarized."""
78300
max_len = caps.max_repr_len
79301
# Fast path: scalars never hit the default-repr regex or summary.
80302
if type(value) in _SCALAR_TYPES:
@@ -84,17 +306,33 @@ def safe_repr(value: Any, caps: Caps) -> str:
84306
if len(r) <= max_len
85307
else r[:max_len] + f"...[{len(r) - max_len} more chars]"
86308
)
309+
if _is_credential_type(type(value)):
310+
return f"<{type(value).__name__}>"
87311
try:
88-
r = repr(value)
312+
redacted = _redacted_repr(value)
89313
except Exception as exc: # pylint: disable=broad-exception-caught
314+
# Elided rather than repr'd: the walk stopped partway, so nothing here
315+
# says the value is free of secrets.
90316
logger.warning(
91-
"AutoTracingPlugin: repr() failed for %s: %s",
317+
"AutoTracingPlugin: redaction failed for %s: %s",
92318
type(value).__name__,
93319
exc,
94320
)
95-
r = f"<unrepr-able {type(value).__name__}: {exc!r}>"
96-
if _DEFAULT_REPR_RE.match(r):
97-
r = _summarize_default(value)
321+
return f"<{type(value).__name__} ...>"
322+
if redacted is not None:
323+
r = redacted
324+
else:
325+
try:
326+
r = repr(value)
327+
except Exception as exc: # pylint: disable=broad-exception-caught
328+
logger.warning(
329+
"AutoTracingPlugin: repr() failed for %s: %s",
330+
type(value).__name__,
331+
exc,
332+
)
333+
r = f"<unrepr-able {type(value).__name__}: {exc!r}>"
334+
if _DEFAULT_REPR_RE.match(r):
335+
r = _summarize_default(value)
98336
if len(r) > max_len:
99337
r = r[:max_len] + f"...[{len(r) - max_len} more chars]"
100338
return r
@@ -137,6 +375,9 @@ def _summarize_default(value: Any) -> str:
137375
return f"<{cls}>"
138376
fields = []
139377
for k, v in public:
378+
if _is_credential_arg_name(k) or _is_credential_type(type(v)):
379+
fields.append(f"{k}=<{type(v).__name__}>")
380+
continue
140381
try:
141382
vr = repr(v)
142383
except Exception as exc: # pylint: disable=broad-exception-caught
@@ -174,14 +415,23 @@ def name_value_pairs(
174415
kwargs: dict[str, Any],
175416
caps: Caps,
176417
) -> list[NamedArg]:
177-
"""Returns ``[(name, repr)]`` for args + kwargs (no self/cls)."""
418+
"""Returns ``[(name, repr)]`` for args + kwargs (no self/cls).
419+
420+
An argument whose name marks it as secret material is dropped outright
421+
rather than masked: at the top level the key alone already says the call
422+
took a token, and no rendering of the value is worth recording. Values
423+
*nested* inside a recorded argument are masked in place instead, because
424+
dropping them would misreport the shape of the value that is being traced.
425+
"""
178426
pairs: list[NamedArg] = []
179427
for i, v in enumerate(args):
180428
name = param_names[i] if i < len(param_names) else f"arg{i}"
181-
if name in _SELF_OR_CLS:
429+
if name in _SELF_OR_CLS or _is_credential_arg_name(name):
182430
continue
183431
pairs.append((name, safe_repr(v, caps)))
184432
for k, v in kwargs.items():
433+
if _is_credential_arg_name(k):
434+
continue
185435
pairs.append((k, safe_repr(v, caps)))
186436
return pairs
187437

@@ -196,6 +446,10 @@ def record_io_on_span(
196446
"""Writes ``adk.fn.*`` attributes onto ``span`` for the call's IO."""
197447
s = span.set_attribute
198448
for k, v in pairs:
449+
# Repeats the filter in name_value_pairs on purpose: both functions are
450+
# public, so pairs may come from a caller that never ran that filter.
451+
if _is_credential_arg_name(k):
452+
continue
199453
s(f"adk.fn.arg.{k}", v)
200454
if exc is not None:
201455
s("adk.fn.exc_type", type(exc).__qualname__)

src/google/adk/sessions/database_session_service.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -682,7 +682,11 @@ async def get_session(
682682
)
683683

684684
if config and config.after_timestamp:
685-
after_dt = datetime.fromtimestamp(config.after_timestamp)
685+
after_dt = datetime.fromtimestamp(
686+
config.after_timestamp, tz=timezone.utc
687+
)
688+
if self._uses_naive_datetime():
689+
after_dt = after_dt.replace(tzinfo=None)
686690
stmt = stmt.filter(schema.StorageEvent.timestamp >= after_dt)
687691

688692
# Break timestamp ties on id, matching the ordering the stale-session
@@ -932,12 +936,9 @@ async def append_event(self, session: Session, event: Event) -> Event:
932936
storage_session.state.update(state_deltas["session"])
933937

934938
is_postgresql = self.db_engine.dialect.name == _POSTGRESQL_DIALECT
935-
if is_sqlite or is_postgresql:
936-
update_time = datetime.fromtimestamp(
937-
event.timestamp, timezone.utc
938-
).replace(tzinfo=None)
939-
else:
940-
update_time = datetime.fromtimestamp(event.timestamp)
939+
update_time = datetime.fromtimestamp(event.timestamp, timezone.utc)
940+
if self._uses_naive_datetime():
941+
update_time = update_time.replace(tzinfo=None)
941942
storage_session.update_time = update_time
942943
sql_session.add(schema.StorageEvent.from_event(session, event))
943944

src/google/adk/sessions/schemas/shared.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,20 @@
3131
DEFAULT_MAX_VARCHAR_LENGTH = 256
3232

3333

34+
def timestamp_to_utc_datetime(timestamp: float) -> datetime.datetime:
35+
"""Converts a POSIX timestamp to a naive UTC database value."""
36+
return datetime.datetime.fromtimestamp(
37+
timestamp, tz=datetime.timezone.utc
38+
).replace(tzinfo=None)
39+
40+
41+
def utc_datetime_to_timestamp(value: datetime.datetime) -> float:
42+
"""Converts a database UTC datetime to a POSIX timestamp."""
43+
if value.tzinfo is None:
44+
value = value.replace(tzinfo=datetime.timezone.utc)
45+
return value.timestamp()
46+
47+
3448
class DynamicJSON(TypeDecorator[dict[str, Any]]): # type: ignore[misc]
3549
"""A JSON-like type that uses JSONB on PostgreSQL and TEXT with JSON serialization for other databases."""
3650

0 commit comments

Comments
 (0)