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
82 changes: 77 additions & 5 deletions src/google/adk/agents/config_agent_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import importlib
import inspect
import os
from types import ModuleType
from typing import Any
from typing import List

Expand All @@ -30,6 +31,31 @@
from .common_configs import AgentRefConfig
from .common_configs import CodeConfig

# Allowlist for safe module prefixes that can be imported from YAML config
_SAFE_MODULE_PREFIXES: frozenset[str] = frozenset({"google.adk."})


def _is_safe_module_import(name: str) -> bool:
"""Check if a module import is from a safe/allowed namespace.

Args:
name: The fully qualified module name to check.

Returns:
True if the module is in a safe namespace, False otherwise.
"""
# Must start with google.adk. (the dot ensures it's not a partial name spoof)
if not any(name.startswith(p) for p in _SAFE_MODULE_PREFIXES):
return False

# Ensure each segment is a valid Python identifier
segments = name.split(".")
for s in segments:
if not s or not s.isidentifier():
return False

return True


@experimental(FeatureName.AGENT_CONFIG)
def from_config(config_path: str) -> BaseAgent:
Expand Down Expand Up @@ -105,10 +131,40 @@ def _load_config_from_path(config_path: str) -> AgentConfig:

@experimental(FeatureName.AGENT_CONFIG)
def resolve_fully_qualified_name(name: str) -> Any:
"""Resolve a fully qualified name to a Python object.

Args:
name: The fully qualified name (e.g., 'google.adk.agents.LlmAgent').

Returns:
The resolved Python object.

Raises:
ValueError: If the name is not in a safe namespace or cannot be resolved.
"""
try:
if "." not in name:
raise ValueError(
f"Module reference '{name}' is outside the allowed namespace. "
"Only google.adk.* references are permitted in YAML config."
)

module_path, obj_name = name.rsplit(".", 1)
module = importlib.import_module(module_path)

# Security check: only allow imports from safe namespaces and valid identifiers
if not obj_name.isidentifier() or not _is_safe_module_import(module_path):
raise ValueError(
f"Module reference '{name}' is outside the allowed namespace. "
"Only google.adk.* references are permitted in YAML config."
)

module: ModuleType = importlib.import_module(module_path)
return getattr(module, obj_name)
except ValueError as e:
# Re-raise ValueError from security check without wrapping
if "outside the allowed namespace" in str(e):
raise e
raise ValueError(f"Invalid fully qualified name: {name}") from e
except Exception as e:
raise ValueError(f"Invalid fully qualified name: {name}") from e

Expand Down Expand Up @@ -153,13 +209,21 @@ def _resolve_agent_code_reference(code: str) -> Any:
The resolved agent instance.

Raises:
ValueError: If the agent reference cannot be resolved.
ValueError: If the agent reference cannot be resolved or is outside allowed namespace.
"""
if "." not in code:
raise ValueError(f"Invalid code reference: {code}")

module_path, obj_name = code.rsplit(".", 1)
module = importlib.import_module(module_path)

# Security check: only allow imports from safe namespaces
if not _is_safe_module_import(module_path):
raise ValueError(
f"Code reference '{code}' is outside the allowed namespace. "
"Only google.adk.* references are permitted in YAML config."
)

module: ModuleType = importlib.import_module(module_path)
obj = getattr(module, obj_name)

if callable(obj):
Expand All @@ -182,13 +246,21 @@ def resolve_code_reference(code_config: CodeConfig) -> Any:
The resolved Python object.

Raises:
ValueError: If the code reference cannot be resolved.
ValueError: If the code reference cannot be resolved or is outside allowed namespace.
"""
if not code_config or not code_config.name:
raise ValueError("Invalid CodeConfig.")

module_path, obj_name = code_config.name.rsplit(".", 1)
module = importlib.import_module(module_path)

# Security check: only allow imports from safe namespaces
if not _is_safe_module_import(module_path):
raise ValueError(
f"Code reference '{code_config.name}' is outside the allowed namespace."
" Only google.adk.* references are permitted in YAML config."
)

module: ModuleType = importlib.import_module(module_path)
obj = getattr(module, obj_name)

if code_config.args and callable(obj):
Expand Down
25 changes: 25 additions & 0 deletions src/google/adk/cli/adk_web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
from opentelemetry.sdk.trace import SpanProcessor
from opentelemetry.sdk.trace import TracerProvider
from pydantic import Field
from pydantic import field_validator
from pydantic import ValidationError
from starlette.types import Lifespan
from typing_extensions import deprecated
Expand Down Expand Up @@ -372,6 +373,30 @@ class RunAgentRequest(common.BaseModel):
# for resume long-running functions
invocation_id: Optional[str] = None

@field_validator("app_name")
@classmethod
def validate_app_name(cls, v: str) -> str:
"""Validate app_name to prevent path traversal attacks.

Args:
v: The app_name value to validate.

Returns:
The validated app_name.

Raises:
ValueError: If the app_name contains path traversal characters.
"""
if not v:
raise ValueError("app_name cannot be empty")
# Check for path traversal attempts and ensure it is a valid identifier
if not re.match(r"^[a-zA-Z0-9_]+$", v):
raise ValueError(
f"Invalid app_name: {v!r}. "
"Must contain only letters, digits, and underscores."
)
return v


class CreateSessionRequest(common.BaseModel):
session_id: Optional[str] = Field(
Expand Down
33 changes: 30 additions & 3 deletions src/google/adk/cli/utils/agent_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ def _load_from_module_or_package(

def _load_from_submodule(
self, agent_name: str
) -> Optional[Union[BaseAgent], App]:
) -> Optional[Union[BaseAgent, App]]:
# Load for case: Import "{agent_name}.agent" and look for "root_agent"
# Covers structure: agents_dir/{agent_name}/agent.py (with root_agent defined in the module)
try:
Expand Down Expand Up @@ -167,6 +167,8 @@ def _load_from_submodule(
def _load_from_yaml_config(
self, agent_name: str, agents_dir: str
) -> Optional[BaseAgent]:
# Validate agent_name doesn't escape agents_dir
self._validate_agent_path(agents_dir, agent_name)
# Load from the config file at agents_dir/{agent_name}/root_agent.yaml
config_path = os.path.join(agents_dir, agent_name, "root_agent.yaml")
try:
Expand All @@ -188,7 +190,32 @@ def _load_from_yaml_config(
) + e.args[1:]
raise e

_VALID_AGENT_NAME_RE = re.compile(r"^[a-zA-Z0-9_]+$")
def _validate_agent_path(self, agents_dir: str, agent_name: str) -> None:
"""Validate that the agent path resolves within agents_dir.

Args:
agents_dir: The base directory for agents.
agent_name: The agent name/path to validate.

Raises:
ValueError: If the resolved path would escape agents_dir.
"""
# Normalize paths to absolute, resolved paths
base_path = Path(agents_dir).resolve()
# Handle both forward and backward slashes by using Path
agent_path = base_path / agent_name
resolved_path = agent_path.resolve()

# Check if the resolved path is still within the base directory
try:
resolved_path.relative_to(base_path)
except ValueError as e:
raise ValueError(
f"Agent '{agent_name}' resolves outside agents_dir. "
"Path traversal is not permitted."
) from e

_VALID_AGENT_NAME_RE: re.Pattern[str] = re.compile(r"^[a-zA-Z0-9_]+$")

def _validate_agent_name(self, agent_name: str) -> None:
"""Validate agent name to prevent arbitrary module imports."""
Expand Down Expand Up @@ -423,7 +450,7 @@ def _determine_agent_language(

raise ValueError(f"Could not determine agent type for '{agent_name}'.")

def remove_agent_from_cache(self, agent_name: str):
def remove_agent_from_cache(self, agent_name: str) -> None:
# Clear module cache for the agent and its submodules
keys_to_delete = [
module_name
Expand Down
1 change: 1 addition & 0 deletions tests/unittests/security/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Security tests package.
Loading