Skip to content

Commit a5929dc

Browse files
committed
fix: validate module paths in YAML config to prevent arbitrary code execution via importlib
1 parent d3c21d7 commit a5929dc

2 files changed

Lines changed: 178 additions & 0 deletions

File tree

src/google/adk/agents/config_agent_utils.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
from __future__ import annotations
1616

1717
import importlib
18+
import logging
19+
import re
1820
import inspect
1921
import os
2022
from typing import Any
@@ -28,6 +30,80 @@
2830
from .agent_config import AgentConfig
2931
from .base_agent import BaseAgent
3032
from .base_agent_config import BaseAgentConfig
33+
34+
logger = logging.getLogger("google_adk." + __name__)
35+
36+
# Modules that must never be loaded via YAML agent configuration.
37+
# Importing these can lead to arbitrary code execution, file system
38+
# access, or process spawning.
39+
_BLOCKED_MODULE_PREFIXES: tuple[str, ...] = (
40+
"os",
41+
"sys",
42+
"subprocess",
43+
"shutil",
44+
"socket",
45+
"http",
46+
"ctypes",
47+
"multiprocessing",
48+
"signal",
49+
"importlib",
50+
"pickle",
51+
"shelve",
52+
"marshal",
53+
"code",
54+
"codeop",
55+
"compile",
56+
"compileall",
57+
"runpy",
58+
"builtins",
59+
"io",
60+
"tempfile",
61+
"glob",
62+
"pathlib",
63+
"webbrowser",
64+
"antigravity",
65+
)
66+
67+
_VALID_MODULE_PATH_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_.]*$")
68+
69+
70+
def _validate_module_path(module_path: str) -> None:
71+
"""Validates that a module path is safe to import.
72+
73+
Rejects module paths that reference dangerous standard library modules
74+
or contain suspicious patterns.
75+
76+
Args:
77+
module_path: Dotted Python module path (e.g., 'my_package.my_module').
78+
79+
Raises:
80+
ValueError: If the module path is blocked or invalid.
81+
"""
82+
if not module_path:
83+
raise ValueError("Module path must not be empty.")
84+
85+
if not _VALID_MODULE_PATH_RE.match(module_path):
86+
raise ValueError(
87+
f"Module path {module_path!r} contains invalid characters."
88+
)
89+
90+
# Check for dunder/private module segments.
91+
segments = module_path.split(".")
92+
for segment in segments:
93+
if segment.startswith("__") and segment.endswith("__"):
94+
raise ValueError(
95+
f"Module path {module_path!r} contains dunder segment"
96+
f" {segment!r}."
97+
)
98+
99+
# Block dangerous top-level modules.
100+
top_level = segments[0]
101+
if top_level in _BLOCKED_MODULE_PREFIXES:
102+
raise ValueError(
103+
f"Module path {module_path!r} references blocked module"
104+
f" {top_level!r}. Importing arbitrary standard library modules"
105+
" via YAML configuration is not permitted."
106+
)
31107
from .common_configs import AgentRefConfig
32108
from .common_configs import CodeConfig
33109

@@ -109,6 +185,7 @@ def _load_config_from_path(config_path: str) -> AgentConfig:
109185
def resolve_fully_qualified_name(name: str) -> Any:
110186
try:
111187
module_path, obj_name = name.rsplit(".", 1)
188+
_validate_module_path(module_path)
112189
module = importlib.import_module(module_path)
113190
return getattr(module, obj_name)
114191
except Exception as e:
@@ -161,6 +238,7 @@ def _resolve_agent_code_reference(code: str) -> Any:
161238
raise ValueError(f"Invalid code reference: {code}")
162239

163240
module_path, obj_name = code.rsplit(".", 1)
241+
_validate_module_path(module_path)
164242
module = importlib.import_module(module_path)
165243
obj = getattr(module, obj_name)
166244

@@ -190,6 +268,7 @@ def resolve_code_reference(code_config: CodeConfig) -> Any:
190268
raise ValueError("Invalid CodeConfig.")
191269

192270
module_path, obj_name = code_config.name.rsplit(".", 1)
271+
_validate_module_path(module_path)
193272
module = importlib.import_module(module_path)
194273
return getattr(module, obj_name)
195274

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Tests for module path validation in YAML agent config resolution."""
16+
17+
import pytest
18+
19+
from google.adk.agents.config_agent_utils import _validate_module_path
20+
21+
22+
class TestValidateModulePath:
23+
"""Tests for _validate_module_path blocklist enforcement."""
24+
25+
def test_safe_module_passes(self):
26+
"""User-defined modules should pass validation."""
27+
_validate_module_path("my_app.agents.my_agent")
28+
_validate_module_path("google.adk.agents")
29+
_validate_module_path("my_package")
30+
31+
def test_os_module_blocked(self):
32+
"""os module should be blocked."""
33+
with pytest.raises(ValueError, match="blocked module"):
34+
_validate_module_path("os")
35+
36+
def test_os_path_blocked(self):
37+
"""os.path should be blocked."""
38+
with pytest.raises(ValueError, match="blocked module"):
39+
_validate_module_path("os.path")
40+
41+
def test_subprocess_blocked(self):
42+
"""subprocess module should be blocked."""
43+
with pytest.raises(ValueError, match="blocked module"):
44+
_validate_module_path("subprocess")
45+
46+
def test_sys_blocked(self):
47+
"""sys module should be blocked."""
48+
with pytest.raises(ValueError, match="blocked module"):
49+
_validate_module_path("sys")
50+
51+
def test_shutil_blocked(self):
52+
"""shutil module should be blocked."""
53+
with pytest.raises(ValueError, match="blocked module"):
54+
_validate_module_path("shutil")
55+
56+
def test_pickle_blocked(self):
57+
"""pickle module should be blocked."""
58+
with pytest.raises(ValueError, match="blocked module"):
59+
_validate_module_path("pickle")
60+
61+
def test_importlib_blocked(self):
62+
"""importlib module should be blocked."""
63+
with pytest.raises(ValueError, match="blocked module"):
64+
_validate_module_path("importlib")
65+
66+
def test_builtins_blocked(self):
67+
"""builtins module should be blocked."""
68+
with pytest.raises(ValueError, match="blocked module"):
69+
_validate_module_path("builtins")
70+
71+
def test_socket_blocked(self):
72+
"""socket module should be blocked."""
73+
with pytest.raises(ValueError, match="blocked module"):
74+
_validate_module_path("socket")
75+
76+
def test_empty_path_blocked(self):
77+
"""Empty module path should be rejected."""
78+
with pytest.raises(ValueError, match="must not be empty"):
79+
_validate_module_path("")
80+
81+
def test_invalid_characters_blocked(self):
82+
"""Module paths with special characters should be rejected."""
83+
with pytest.raises(ValueError, match="invalid characters"):
84+
_validate_module_path("os;import sys")
85+
86+
def test_dunder_segment_blocked(self):
87+
"""Module paths with __dunder__ segments should be rejected."""
88+
with pytest.raises(ValueError, match="dunder segment"):
89+
_validate_module_path("my_app.__builtins__.evil")
90+
91+
def test_google_adk_passes(self):
92+
"""google.adk modules should pass (not blocked)."""
93+
_validate_module_path("google.adk.tools.my_tool")
94+
_validate_module_path("google.adk.agents.llm_agent")
95+
96+
def test_multiprocessing_blocked(self):
97+
"""multiprocessing should be blocked."""
98+
with pytest.raises(ValueError, match="blocked module"):
99+
_validate_module_path("multiprocessing.pool")

0 commit comments

Comments
 (0)