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
2 changes: 1 addition & 1 deletion .github/.release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "2.7.1"
".": "2.8.0"
}
2 changes: 1 addition & 1 deletion .github/release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,5 @@
]
}
},
"last-release-sha": "703cf43f6b03550ec2b02def526f3f4ba98f7abd"
"last-release-sha": "4d0d63c74cf161f90ef33c7b310b6fd6641d3249"
}
169 changes: 169 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions scripts/compliance_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,16 @@ def check_cli_import(content: str, filename: str) -> bool:
return not pattern.search(content)


# An internal shortlink resolves for nobody reading this repository. Anchored
# so that a public URL with a '/go/' path segment, or a Go file name, is not
# mistaken for one.
_INTERNAL_LINK_RE = re.compile(r'(?<![/.\w])go/[a-z0-9][-a-z0-9_]*')


def check_internal_links(content: str) -> bool:
return not _INTERNAL_LINK_RE.search(content)


def check_mtls(content: str, filename: str) -> bool:
if filename in _EXCLUDED_FROM_MTLS:
return True
Expand Down Expand Up @@ -145,6 +155,13 @@ def main() -> None:
)
failed = True

if not check_internal_links(content):
print(
f'❌ {f}: Found an internal shortlink, which resolves for nobody'
' reading this repository. Say what it says in plain words instead.'
)
failed = True

if failed:
sys.exit(1)
sys.exit(0)
Expand Down
30 changes: 23 additions & 7 deletions src/google/adk/agents/parallel_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,21 @@ def _create_branch_ctx_for_sub_agent(
return invocation_context


def _has_escalate_action(event: Event) -> bool:
"""Returns whether the event asks the parent workflow to exit early."""
return bool(event.actions.escalate)
def _asks_this_agent_to_exit(event: Event, sub_agent_names: set[str]) -> bool:
"""Returns whether the event asks this parallel agent to exit early.

An escalation ends the workflow that directly encloses the escalating agent,
and that workflow re-yields the event while unwinding, so only an escalation
authored by a direct sub-agent is addressed to this one.

Args:
event: The event to inspect.
sub_agent_names: Names of this agent's direct sub-agents.

Returns:
Whether this parallel agent should stop its remaining branches.
"""
return bool(event.actions.escalate) and event.author in sub_agent_names


def _cancel_tasks(tasks: list[asyncio.Task[None]]) -> None:
Expand All @@ -69,6 +81,7 @@ def _cancel_tasks(tasks: list[asyncio.Task[None]]) -> None:

async def _merge_agent_run(
agent_runs: list[AsyncGenerator[Event, None]],
sub_agent_names: set[str],
) -> AsyncGenerator[Event, None]:
"""Merges agent runs using asyncio.TaskGroup on Python 3.11+."""
sentinel = _AgentRunComplete()
Expand Down Expand Up @@ -121,7 +134,7 @@ async def process_an_agent(
raise payload
else:
yield event
if _has_escalate_action(event):
if _asks_this_agent_to_exit(event, sub_agent_names):
_cancel_tasks(tasks)
return
# Signal to agent that it should generate next event.
Expand All @@ -135,6 +148,7 @@ async def process_an_agent(
# TODO - remove once Python <3.11 is no longer supported.
async def _merge_agent_run_pre_3_11(
agent_runs: list[AsyncGenerator[Event, None]],
sub_agent_names: set[str],
) -> AsyncGenerator[Event, None]:
"""Merges agent runs for Python 3.10 without asyncio.TaskGroup.

Expand All @@ -143,6 +157,7 @@ async def _merge_agent_run_pre_3_11(

Args:
agent_runs: Async generators that yield events from each agent.
sub_agent_names: Names of the parallel agent's direct sub-agents.

Yields:
Event: The next event from the merged generator.
Expand Down Expand Up @@ -190,7 +205,7 @@ async def process_an_agent(
raise payload
else:
yield event
if _has_escalate_action(event):
if _asks_this_agent_to_exit(event, sub_agent_names):
_cancel_tasks(tasks)
return
# Signal to agent that event has been processed by runner and it can
Expand Down Expand Up @@ -246,6 +261,7 @@ async def _run_async_impl(
yield self._create_agent_state_event(ctx)

agent_runs = []
sub_agent_names = {sub_agent.name for sub_agent in self.sub_agents}
# Prepare and collect async generators for each sub-agent.
for sub_agent in self.sub_agents:
sub_agent_ctx = _create_branch_ctx_for_sub_agent(self, sub_agent, ctx)
Expand All @@ -261,10 +277,10 @@ async def _run_async_impl(
if sys.version_info >= (3, 11)
else _merge_agent_run_pre_3_11
)
async with Aclosing(merge_func(agent_runs)) as agen:
async with Aclosing(merge_func(agent_runs, sub_agent_names)) as agen:
async for event in agen:
yield event
if _has_escalate_action(event):
if _asks_this_agent_to_exit(event, sub_agent_names):
escalated = True
if ctx.should_pause_invocation(event):
pause_invocation = True
Expand Down
17 changes: 12 additions & 5 deletions src/google/adk/evaluation/agent_evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from typing import List
from typing import Optional
from typing import Protocol
from typing import TYPE_CHECKING
from typing import Union
import uuid

Expand Down Expand Up @@ -60,11 +61,11 @@
from .evaluator import EvalStatus
from .in_memory_eval_sets_manager import InMemoryEvalSetsManager
from .local_eval_sets_manager import convert_eval_set_to_pydantic_schema
from .metric_evaluator_registry import DEFAULT_METRIC_EVALUATOR_REGISTRY
from .metric_evaluator_registry import MetricEvaluatorRegistry
from .metric_evaluator_registry import register_custom_metrics_from_config
from .simulation.user_simulator_provider import UserSimulatorProvider

if TYPE_CHECKING:
from .metric_evaluator_registry import MetricEvaluatorRegistry # pylint: disable=g-import-not-at-top

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


Expand Down Expand Up @@ -208,6 +209,12 @@ async def evaluate_eval_set(
# on the default registry resolvable, which is the only way to plug in a
# custom `Evaluator` subclass since an eval config can only name a scoring
# function.
try:
from .metric_evaluator_registry import DEFAULT_METRIC_EVALUATOR_REGISTRY # pylint: disable=g-import-not-at-top
from .metric_evaluator_registry import register_custom_metrics_from_config # pylint: disable=g-import-not-at-top
except ModuleNotFoundError as e:
raise ModuleNotFoundError(MISSING_EVAL_DEPENDENCIES_MESSAGE) from e

metric_evaluator_registry = register_custom_metrics_from_config(
eval_config, DEFAULT_METRIC_EVALUATOR_REGISTRY.fork()
)
Expand Down Expand Up @@ -546,8 +553,8 @@ def _print_details(
threshold: float,
) -> None:
try:
from pandas import pandas as pd
from tabulate import tabulate
import pandas as pd # pylint: disable=g-import-not-at-top
from tabulate import tabulate # pylint: disable=g-import-not-at-top
except ModuleNotFoundError as e:
raise ModuleNotFoundError(MISSING_EVAL_DEPENDENCIES_MESSAGE) from e
print(
Expand Down
24 changes: 10 additions & 14 deletions src/google/adk/models/google_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -732,21 +732,17 @@ def _build_request_log(req: LlmRequest) -> str:
exclude={
'system_instruction': True,
'tools': tools_exclusion if req.config.tools else True,
# `http_options` carries caller-supplied credentials:
# `http_options` is excluded whole, not field by field.
# `headers` commonly holds an Authorization bearer token,
# and `extra_body` / `*client_args` are free-form
# passthroughs that can hold auth material too. None of
# it may reach a debug log. Mirrors the same exclusion
# applied to trace spans in telemetry/tracing.py.
'http_options': {
'httpx_client': True,
'httpx_async_client': True,
'aiohttp_client': True,
'headers': True,
'extra_body': True,
'client_args': True,
'async_client_args': True,
},
# `extra_body` and the `*client_args` passthroughs can hold
# auth material, and `base_url` carries the credential
# itself when the caller points at a signed endpoint or an
# authenticating proxy. Naming the sensitive fields instead
# would also start logging every field the genai SDK adds
# later. The live path excludes it the same way; the trace
# spans built in telemetry/tracing.py still name the fields
# one by one.
'http_options': True,
},
)
)
Expand Down
90 changes: 78 additions & 12 deletions src/google/adk/plugins/debug_logging_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import logging
import os
from pathlib import Path
import re
from typing import Any
from typing import TYPE_CHECKING

Expand Down Expand Up @@ -99,6 +100,49 @@
"x_goog_signature",
})

# Substrings that name a secret wherever they sit in a key. Matched as
# substrings, not whole keys, so that the spellings the exact set above cannot
# enumerate are covered too: `openai_api_key`, `secret_key`,
# `service_account_credentials`.
_SENSITIVE_SUBSTRINGS = (
"api_key",
"credentials",
"passwd",
"password",
"private_key",
"secret",
)

# A key ending in one of these names a secret: `bearer_token`,
# `session_token`. Matched as a suffix rather than as a substring so that the
# usage counters, `prompt_token_count` and its siblings, keep their values.
_SENSITIVE_SUFFIXES = ("_token",)

# Session state keys are namespaced by scope. The scope says nothing about
# whether the value is a secret, so it is stripped before matching, otherwise
# `api_key` is redacted while `user:api_key` is written out.
_STATE_PREFIXES = (State.APP_PREFIX, State.USER_PREFIX)

# Splits a camel-cased key so that `apiKey` and `XApiKey` normalize to the
# same `api_key` that `api-key` and `api_key` do.
_CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")

# A credential pasted into session state or a tool argument arrives as one
# long string, and a service account file keeps its private key inside that
# string, so no key name identifies it. Only an armored private key block is
# looked for. It is unambiguous, where a general secret scan would be both slow
# and prone to blanking ordinary text. The armor header is matched as a unit so
# that prose quoting one of its fragments is left alone, and only the block
# itself is replaced, so the rest of the string stays readable. A block whose
# footer never arrives is redacted to the end of the string rather than left in
# place.
_PRIVATE_KEY_BLOCK = re.compile(
r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY( BLOCK)?-----"
r".*?"
r"(-----END [A-Z0-9 ]*PRIVATE KEY( BLOCK)?-----|\Z)",
re.DOTALL,
)

# The debug file is written with the process umask otherwise, which commonly
# leaves it world-readable.
_OUTPUT_FILE_MODE = 0o600
Expand All @@ -112,14 +156,30 @@ def _is_sensitive_key(key: Any) -> bool:
"""Whether a mapping key names a credential-bearing value."""
if not isinstance(key, str):
return False
# `str.__str__` drops a subclass override of `lower`; hyphens are folded so
# that header spellings such as `X-Api-Key` match, as the analytics plugin
# does.
normalized = str.__str__(key).lower().replace("-", "_")
# ADK stores exchanged auth credentials under a `temp:`-prefixed state key.
return normalized in _SENSITIVE_KEYS or normalized.startswith(
State.TEMP_PREFIX
# `str.__str__` drops a subclass override of `lower`; hyphens and case
# boundaries are folded so that `X-Api-Key` and `apiKey` match the same way
# `api_key` does.
normalized = (
_CAMEL_BOUNDARY.sub("_", str.__str__(key)).lower().replace("-", "_")
)
# ADK stores exchanged auth credentials under a `temp:`-prefixed state key.
if normalized.startswith(State.TEMP_PREFIX):
return True
for prefix in _STATE_PREFIXES:
if normalized.startswith(prefix):
normalized = normalized[len(prefix) :]
break
if normalized in _SENSITIVE_KEYS or normalized.endswith(_SENSITIVE_SUFFIXES):
return True
return any(marker in normalized for marker in _SENSITIVE_SUBSTRINGS)


def _redact_private_keys(value: str) -> str:
"""Blanks any armored private key block, leaving the rest of the string."""
# `str.__str__` drops a subclass override of `__contains__`, which `in`
# would otherwise dispatch to, the same way `_is_sensitive_key` drops one
# of `lower`.
return _PRIVATE_KEY_BLOCK.sub(_REDACTED, str.__str__(value))


def _model_items(model: BaseModel) -> list[tuple[str, Any]]:
Expand Down Expand Up @@ -190,9 +250,11 @@ class DebugLoggingPlugin(BasePlugin):
owner and is not safe to hand around.

Redaction covers credential models wherever they appear, mapping keys that
name a secret, and every `temp:`-prefixed state key. That last rule blanks
all temporary state, not only credentials, so an intermediate value passed
between agents under a `temp:` key reads as `[REDACTED]` here.
name a secret with the `app:` or `user:` state scope stripped first, an
armored private key block found inside any string, and every
`temp:`-prefixed state key. That last rule blanks all temporary state, not
only credentials, so an intermediate value passed between agents under a
`temp:` key reads as `[REDACTED]` here.

Example:
>>> debug_plugin = DebugLoggingPlugin(output_path="/tmp/adk_debug.yaml")
Expand Down Expand Up @@ -292,7 +354,9 @@ def _safe_serialize(self, obj: Any, depth: int = 0) -> Any:
A credential model is replaced with a redaction marker wherever it sits:
at the top level, or nested inside a dict, list, tuple or another model,
under any key name. Mapping keys that name a secret are redacted too, for
credentials that arrive already dumped to a plain dict.
credentials that arrive already dumped to a plain dict. An armored private
key block, which no key name identifies, is cut out of whatever string it
sits in.
"""
if obj is None:
return None
Expand All @@ -308,7 +372,9 @@ def _safe_serialize(self, obj: Any, depth: int = 0) -> Any:
# below unchanged, and then reaches `yaml.dump` as a Python object,
# which writes a `!!python/object` tag that `yaml.safe_load` refuses.
return self._safe_serialize(obj.value, child_depth)
if isinstance(obj, (str, int, float, bool)):
if isinstance(obj, str):
return _redact_private_keys(obj)
if isinstance(obj, (int, float, bool)):
return obj
if isinstance(obj, (date, time)):
return obj.isoformat()
Expand Down
28 changes: 25 additions & 3 deletions src/google/adk/sessions/_restricted_pickle.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@
are inert data types: unpickling one only calls `__setstate__` or the enum
constructor. Deriving them keeps the set correct as the ADK and `google.genai`
models gain fields, which a hand-written list does not.
* A static part, for globals the walk cannot see: primitives and `datetime`
types, which are not Pydantic models, and model classes reachable only by
subclassing rather than through an annotation.
* A static part, for globals the walk cannot see: the builtin and stdlib data
types a `state_delta` holds, which are not Pydantic models, and model
classes reachable only by subclassing rather than through an annotation.

Anything else - notably arbitrary callables that older versions allowed into
`state_delta` - is refused rather than resolved. See `_RestrictedUnpickler`.
Expand Down Expand Up @@ -62,9 +62,31 @@
("builtins", "int"),
("builtins", "float"),
("builtins", "bool"),
("builtins", "complex"),
# Stdlib data types. Each reconstructs by calling the type on plain data,
# so resolving one cannot run attacker-chosen code. A `defaultdict`'s
# factory is only stored while loading, never called, and it has to be a
# global this allowlist already admits.
("collections", "OrderedDict"),
("collections", "defaultdict"),
("datetime", "date"),
("datetime", "datetime"),
("datetime", "time"),
("datetime", "timedelta"),
("datetime", "timezone"),
("decimal", "Decimal"),
("uuid", "UUID"),
# Python 3.13 moved the concrete `pathlib` classes into a private
# submodule, and a payload names whichever module the interpreter that
# wrote it recorded.
("pathlib", "PurePosixPath"),
("pathlib", "PureWindowsPath"),
("pathlib", "PosixPath"),
("pathlib", "WindowsPath"),
("pathlib._local", "PurePosixPath"),
("pathlib._local", "PureWindowsPath"),
("pathlib._local", "PosixPath"),
("pathlib._local", "WindowsPath"),
# Auth models reachable only by subclassing or as a union base, so the
# annotation walk below does not reach them.
("fastapi.openapi.models", "OAuthFlow"),
Expand Down
Loading
Loading