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
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ Only set this when you trust every agent config the process will load.

### 5. Run the Agent

Use `adk run` to run the agent and interact with your Notion workspace.
Use `adk run` or `adk web` to run the agent and interact with your Notion
workspace. The stdio opt-in above is required for either command.

## Example Queries

Expand Down
90 changes: 81 additions & 9 deletions src/google/adk/agents/config_agent_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@

from __future__ import annotations

from collections.abc import Callable
import importlib
import inspect
import os
import sys
from typing import Any
from typing import List
from typing import NoReturn

from typing_extensions import deprecated
import yaml
Expand Down Expand Up @@ -92,20 +94,90 @@ def _set_enforce_yaml_key_denylist(value: bool) -> None:
_ENFORCE_YAML_KEY_DENYLIST = value


def _check_config_for_blocked_keys(node: Any, filename: str) -> None:
"""Recursively check if the configuration contains any blocked keys."""
def _validate_mcp_toolset_args(args: Any) -> None:
"""Validates McpToolset args without resolving a code reference."""
from ..tools.mcp_tool.mcp_toolset import McpToolsetConfig

McpToolsetConfig.model_validate(args)


# Entries in this registry must be built-in tools whose validator treats the
# YAML input as data only. The registry key is matched literally; it is never
# imported or otherwise resolved from the configuration.
_SAFE_BUILTIN_TOOL_ARGS_VALIDATORS: dict[str, Callable[[Any], None]] = {
"McpToolset": _validate_mcp_toolset_args,
}
_BUILTIN_LLM_AGENT_NAMES = frozenset({
"LlmAgent",
"google.adk.agents.LlmAgent",
"google.adk.agents.llm_agent.LlmAgent",
})


def _raise_blocked_key(key: str, filename: str) -> NoReturn:
raise ValueError(
f"Blocked key {key!r} found in {filename!r}. "
f"The '{key}' field is not allowed in agent configurations "
"because it can execute arbitrary code."
)


def _check_node_for_blocked_keys(node: Any, filename: str) -> None:
"""Recursively checks a non-tool configuration node for blocked keys."""
if isinstance(node, dict):
for key, value in node.items():
if key in _BLOCKED_YAML_KEYS:
raise ValueError(
f"Blocked key {key!r} found in {filename!r}. "
f"The '{key}' field is not allowed in agent configurations "
"because it can execute arbitrary code."
)
_check_config_for_blocked_keys(value, filename)
_raise_blocked_key(key, filename)
_check_node_for_blocked_keys(value, filename)
elif isinstance(node, list):
for item in node:
_check_config_for_blocked_keys(item, filename)
_check_node_for_blocked_keys(item, filename)


def _check_tool_configs_for_blocked_keys(
tools: list[Any], filename: str
) -> None:
"""Checks tool configs, allowing args only for registered built-ins."""
for tool in tools:
if not isinstance(tool, dict):
_check_node_for_blocked_keys(tool, filename)
continue

tool_name = tool.get("name")
validator = (
_SAFE_BUILTIN_TOOL_ARGS_VALIDATORS.get(tool_name)
if isinstance(tool_name, str)
else None
)
for key, value in tool.items():
if key not in _BLOCKED_YAML_KEYS:
_check_node_for_blocked_keys(value, filename)
continue
if validator is None:
_raise_blocked_key(key, filename)
try:
validator(value)
except (TypeError, ValueError) as e:
raise ValueError(
f"Invalid {key!r} for safe built-in tool {tool_name!r} "
f"in {filename!r}."
) from e


def _check_config_for_blocked_keys(node: Any, filename: str) -> None:
"""Checks blocked keys with a narrow exception for safe built-in tools."""
if not isinstance(node, dict):
_check_node_for_blocked_keys(node, filename)
return

is_builtin_llm_agent = (
node.get("agent_class", "LlmAgent") in _BUILTIN_LLM_AGENT_NAMES
)
for key, value in node.items():
if key == "tools" and is_builtin_llm_agent and isinstance(value, list):
_check_tool_configs_for_blocked_keys(value, filename)
else:
_check_node_for_blocked_keys({key: value}, filename)


def _load_config_from_path(config_path: str) -> AgentConfig:
Expand Down
116 changes: 116 additions & 0 deletions tests/unittests/agents/test_agent_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from google.adk.agents.parallel_agent import ParallelAgent
from google.adk.agents.sequential_agent import SequentialAgent
from google.adk.models.lite_llm import LiteLlm
from google.adk.tools.mcp_tool.mcp_toolset import McpToolset
from pydantic import BaseModel
import pytest
import yaml
Expand Down Expand Up @@ -721,6 +722,121 @@ def test_load_config_from_path_blocks_args_when_enforced(tmp_path: Path):
config_agent_utils._set_enforce_yaml_key_denylist(False)


def test_load_config_from_path_allows_registered_mcp_toolset_args(
tmp_path: Path,
):
"""A registered remote McpToolset loads through the public config API."""
config_file = tmp_path / "agent.yaml"
config_file.write_text(dedent("""\
name: mcp_agent
instruction: Use the MCP tools.
tools:
- name: McpToolset
args:
streamable_http_connection_params:
url: https://example.com/mcp
"""))
config_agent_utils._set_enforce_yaml_key_denylist(True)
try:
agent = config_agent_utils.from_config(str(config_file))
finally:
config_agent_utils._set_enforce_yaml_key_denylist(False)

assert isinstance(agent.tools[0], McpToolset)


def test_load_config_from_path_allows_opted_in_stdio_mcp_toolset_args(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
"""The stdio opt-in also permits its nested command args under web."""
config_file = tmp_path / "agent.yaml"
config_file.write_text(dedent("""\
name: mcp_agent
instruction: Use the MCP tools.
tools:
- name: McpToolset
args:
stdio_connection_params:
server_params:
command: npx
args:
- -y
- example-mcp-server
"""))
monkeypatch.setenv("ADK_ALLOW_CONFIG_STDIO_MCP_SERVERS", "1")
config_agent_utils._set_enforce_yaml_key_denylist(True)
try:
agent = config_agent_utils.from_config(str(config_file))
finally:
config_agent_utils._set_enforce_yaml_key_denylist(False)

assert isinstance(agent.tools[0], McpToolset)


def test_load_config_from_path_blocks_unregistered_tool_args(tmp_path: Path):
"""Tool args remain blocked unless the built-in has a safe validator."""
config_file = tmp_path / "agent.yaml"
config_file.write_text(dedent("""\
name: custom_agent
instruction: Use the custom tool.
tools:
- name: my_package.create_tool
args:
command: unsafe
"""))
config_agent_utils._set_enforce_yaml_key_denylist(True)
try:
with pytest.raises(ValueError, match="Blocked key 'args' found"):
config_agent_utils._load_config_from_path(str(config_file))
finally:
config_agent_utils._set_enforce_yaml_key_denylist(False)


def test_load_config_from_path_rejects_invalid_mcp_toolset_args(
tmp_path: Path,
):
"""McpToolset args cannot supply executable callback fields."""
config_file = tmp_path / "agent.yaml"
config_file.write_text(dedent("""\
name: mcp_agent
instruction: Use the MCP tools.
tools:
- name: McpToolset
args:
streamable_http_connection_params:
url: https://example.com/mcp
httpx_client_factory: os.system
"""))
config_agent_utils._set_enforce_yaml_key_denylist(True)
try:
with pytest.raises(ValueError, match="Invalid 'args' for safe built-in"):
config_agent_utils._load_config_from_path(str(config_file))
finally:
config_agent_utils._set_enforce_yaml_key_denylist(False)


def test_load_config_from_path_does_not_allow_args_outside_tool_list(
tmp_path: Path,
):
"""A custom agent's tools field cannot opt into the built-in exception."""
config_file = tmp_path / "agent.yaml"
config_file.write_text(dedent("""\
agent_class: my_package.CustomAgent
name: custom_agent
tools:
- name: McpToolset
args:
streamable_http_connection_params:
url: https://example.com/mcp
"""))
config_agent_utils._set_enforce_yaml_key_denylist(True)
try:
with pytest.raises(ValueError, match="Blocked key 'args' found"):
config_agent_utils._load_config_from_path(str(config_file))
finally:
config_agent_utils._set_enforce_yaml_key_denylist(False)


# --- Discriminator contract ---------------------------------------------


Expand Down