1616
1717from __future__ import annotations
1818
19+ from datetime import date
1920from datetime import datetime
21+ from datetime import time
22+ from enum import Enum
2023import logging
24+ import os
2125from pathlib import Path
2226from typing import Any
2327from typing import TYPE_CHECKING
3034
3135from ..agents .base_agent import BaseAgent
3236from ..agents .callback_context import CallbackContext
37+ from ..auth .auth_credential import AuthCredential
38+ from ..auth .auth_credential import HttpAuth
39+ from ..auth .auth_credential import HttpCredentials
40+ from ..auth .auth_credential import OAuth2Auth
41+ from ..auth .auth_credential import ServiceAccount
42+ from ..auth .auth_credential import ServiceAccountCredential
3343from ..events .event import Event
3444from ..models .llm_request import LlmRequest
3545from ..models .llm_response import LlmResponse
46+ from ..sessions .state import State
3647from ..tools .base_tool import BaseTool
3748from .base_plugin import BasePlugin
3849
4253
4354logger = logging .getLogger ("google_adk." + __name__ )
4455
56+ _REDACTED = "[REDACTED]"
57+
58+ # Models that exist to carry a secret; an instance is replaced wholesale
59+ # rather than dumped field by field.
60+ _CREDENTIAL_MODELS = (
61+ AuthCredential ,
62+ HttpAuth ,
63+ HttpCredentials ,
64+ OAuth2Auth ,
65+ ServiceAccount ,
66+ ServiceAccountCredential ,
67+ )
68+
69+ # Mapping keys whose value is a secret, for credentials that reach the plugin
70+ # as plain dicts rather than as models: session state rehydrated from a session
71+ # service, or the already dumped credential the OpenAPI tool auth handler keeps
72+ # in state. Starts from the set bigquery_agent_analytics_plugin applies, plus
73+ # the OAuth2 authorization-code fields, which ADK itself populates and which
74+ # are enough on their own to complete a token exchange.
75+ _SENSITIVE_KEYS = frozenset ({
76+ "access_token" ,
77+ "api_key" ,
78+ "auth_code" ,
79+ "auth_response_uri" ,
80+ "authorization" ,
81+ "client_secret" ,
82+ "code_verifier" ,
83+ "google_access_id" ,
84+ "id_token" ,
85+ "password" ,
86+ "private_key" ,
87+ "private_key_id" ,
88+ "proxy_authorization" ,
89+ "refresh_token" ,
90+ "secret" ,
91+ "sig" ,
92+ "signature" ,
93+ "token" ,
94+ "x_amz_credential" ,
95+ "x_amz_signature" ,
96+ "x_api_key" ,
97+ "x_goog_credential" ,
98+ "x_goog_security_token" ,
99+ "x_goog_signature" ,
100+ })
101+
102+ # The debug file is written with the process umask otherwise, which commonly
103+ # leaves it world-readable.
104+ _OUTPUT_FILE_MODE = 0o600
105+
106+ # Bounds both walks below, which are otherwise unterminated on a
107+ # self-referential object. Deeper than any credential model nests.
108+ _MAX_WALK_DEPTH = 20
109+
110+
111+ def _is_sensitive_key (key : Any ) -> bool :
112+ """Whether a mapping key names a credential-bearing value."""
113+ if not isinstance (key , str ):
114+ return False
115+ # `str.__str__` drops a subclass override of `lower`; hyphens are folded so
116+ # that header spellings such as `X-Api-Key` match, as the analytics plugin
117+ # does.
118+ normalized = str .__str__ (key ).lower ().replace ("-" , "_" )
119+ # ADK stores exchanged auth credentials under a `temp:`-prefixed state key.
120+ return normalized in _SENSITIVE_KEYS or normalized .startswith (
121+ State .TEMP_PREFIX
122+ )
123+
124+
125+ def _model_items (model : BaseModel ) -> list [tuple [str , Any ]]:
126+ """The (name, value) pairs held by a model, including any extra fields."""
127+ return [
128+ * model .__dict__ .items (),
129+ * (model .__pydantic_extra__ or {}).items (),
130+ ]
131+
132+
133+ def _holds_credential (obj : Any , depth : int = 0 ) -> bool :
134+ """Whether a credential model instance is reachable from `obj`.
135+
136+ Dumping a model flattens a credential nested inside it into a plain dict, at
137+ which point only its key name could still identify it. A model that carries
138+ one anywhere below it therefore has to be walked field by field instead.
139+ """
140+ if depth > _MAX_WALK_DEPTH :
141+ return False
142+ if isinstance (obj , _CREDENTIAL_MODELS ):
143+ return True
144+ child_depth = depth + 1
145+ if isinstance (obj , BaseModel ):
146+ return any (_holds_credential (v , child_depth ) for _ , v in _model_items (obj ))
147+ if isinstance (obj , dict ):
148+ return any (_holds_credential (v , child_depth ) for v in obj .values ())
149+ if isinstance (obj , (list , tuple , set , frozenset )):
150+ return any (_holds_credential (v , child_depth ) for v in obj )
151+ return False
152+
45153
46154class _DebugEntry (BaseModel ):
47155 """A single debug log entry."""
@@ -77,7 +185,14 @@ class DebugLoggingPlugin(BasePlugin):
77185
78186 The output is written as YAML format for human readability. Each invocation
79187 is appended to the file as a separate YAML document (separated by ---).
80- This format is easy to read and can be shared for debugging purposes.
188+ This format is easy to read. Credentials are redacted, but the file still
189+ holds whole prompts and responses, so it is created readable only by its
190+ owner and is not safe to hand around.
191+
192+ Redaction covers credential models wherever they appear, mapping keys that
193+ name a secret, and every `temp:`-prefixed state key. That last rule blanks
194+ all temporary state, not only credentials, so an intermediate value passed
195+ between agents under a `temp:` key reads as `[REDACTED]` here.
81196
82197 Example:
83198 >>> debug_plugin = DebugLoggingPlugin(output_path="/tmp/adk_debug.yaml")
@@ -113,6 +228,7 @@ def __init__(
113228 self ._include_session_state = include_session_state
114229 self ._include_system_instruction = include_system_instruction
115230 self ._invocation_states : dict [str , _InvocationDebugState ] = {}
231+ self ._warned_about_output_mode = False
116232
117233 def _get_timestamp (self ) -> str :
118234 """Get current timestamp in ISO format."""
@@ -135,7 +251,7 @@ def _serialize_content(
135251 part_data ["function_call" ] = {
136252 "id" : part .function_call .id ,
137253 "name" : part .function_call .name ,
138- "args" : part .function_call .args ,
254+ "args" : self . _safe_serialize ( part .function_call .args ) ,
139255 }
140256 if part .function_response :
141257 part_data ["function_response" ] = {
@@ -170,21 +286,64 @@ def _serialize_content(
170286
171287 return {"role" : content .role , "parts" : parts }
172288
173- def _safe_serialize (self , obj : Any ) -> Any :
174- """Safely serialize an object to JSON-compatible format."""
289+ def _safe_serialize (self , obj : Any , depth : int = 0 ) -> Any :
290+ """Safely serialize an object to JSON-compatible format.
291+
292+ A credential model is replaced with a redaction marker wherever it sits:
293+ at the top level, or nested inside a dict, list, tuple or another model,
294+ under any key name. Mapping keys that name a secret are redacted too, for
295+ credentials that arrive already dumped to a plain dict.
296+ """
175297 if obj is None :
176298 return None
299+ if isinstance (obj , _CREDENTIAL_MODELS ):
300+ return _REDACTED
301+ if depth > _MAX_WALK_DEPTH :
302+ # Terminates a self-referential object. Only the type name survives, so
303+ # reaching the bound cannot uncover a value.
304+ return f"<{ type (obj ).__name__ } ...>"
305+ child_depth = depth + 1
306+ if isinstance (obj , Enum ):
307+ # A member of a `str` or `int` subclass enum passes the scalar check
308+ # below unchanged, and then reaches `yaml.dump` as a Python object,
309+ # which writes a `!!python/object` tag that `yaml.safe_load` refuses.
310+ return self ._safe_serialize (obj .value , child_depth )
177311 if isinstance (obj , (str , int , float , bool )):
178312 return obj
313+ if isinstance (obj , (date , time )):
314+ return obj .isoformat ()
179315 if isinstance (obj , (list , tuple )):
180- return [self ._safe_serialize (item ) for item in obj ]
316+ return [self ._safe_serialize (item , child_depth ) for item in obj ]
181317 if isinstance (obj , dict ):
182- return {k : self ._safe_serialize (v ) for k , v in obj .items ()}
318+ return {
319+ k : (
320+ _REDACTED
321+ if _is_sensitive_key (k )
322+ else self ._safe_serialize (v , child_depth )
323+ )
324+ for k , v in obj .items ()
325+ }
183326 if isinstance (obj , BaseModel ):
327+ if _holds_credential (obj ):
328+ # Serialize the raw field values, so that the nested credential is
329+ # still a model instance when it is reached. Dumping first would leave
330+ # only its key name to go on, and that name is caller-chosen. The
331+ # values skip `model_dump`, so each one is normalized on the way
332+ # through the branches above rather than by pydantic.
333+ return self ._safe_serialize (
334+ {
335+ name : value
336+ for name , value in _model_items (obj )
337+ if value is not None
338+ },
339+ depth ,
340+ )
184341 try :
185- return obj .model_dump (mode = "json" , exclude_none = True )
342+ dumped = obj .model_dump (mode = "json" , exclude_none = True )
186343 except Exception :
187344 return str (obj )
345+ # Recurse so that credential-named keys within the dump are redacted.
346+ return self ._safe_serialize (dumped , depth )
188347 if isinstance (obj , bytes ):
189348 return f"<bytes: { len (obj )} bytes>"
190349 try :
@@ -349,7 +508,22 @@ async def after_run_callback(
349508 # Write to file as YAML
350509 try :
351510 output_data = state .model_dump (mode = "json" , exclude_none = True )
352- with self ._output_path .open ("a" , encoding = "utf-8" ) as f :
511+ fd = os .open (
512+ self ._output_path ,
513+ os .O_WRONLY | os .O_CREAT | os .O_APPEND ,
514+ _OUTPUT_FILE_MODE ,
515+ )
516+ # The mode above only applies to a file this call creates. A file left
517+ # behind by an earlier run keeps whatever mode it had, so say so rather
518+ # than silently changing permissions the user may have chosen.
519+ if not self ._warned_about_output_mode and os .fstat (fd ).st_mode & 0o077 :
520+ self ._warned_about_output_mode = True
521+ logger .warning (
522+ "Debug output file %s is readable beyond its owner and holds"
523+ " whole prompts and responses; restrict it to mode 600." ,
524+ self ._output_path ,
525+ )
526+ with os .fdopen (fd , "a" , encoding = "utf-8" ) as f :
353527 f .write ("---\n " )
354528 yaml .dump (
355529 output_data ,
0 commit comments