1717from __future__ import annotations
1818
1919import asyncio
20+ from collections .abc import Mapping
2021import dataclasses
2122import functools
2223import inspect
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 )
46118class 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+
76298def 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__ )
0 commit comments