Skip to content

Commit df75a3b

Browse files
committed
security: add module blocklist for YAML agent config code references
- Enable _ENFORCE_DENYLIST by default (True) so the blocklist is active out of the box, not opt-in. - Expand _BLOCKED_MODULES from 27 to 36 entries, adding network-capable stdlib modules (ftplib, smtplib, poplib, imaplib, nntplib, telnetlib, xmlrpc, asyncio) and filesystem modules (pathlib) that were identified as gaps during review. - Gate _resolve_tools() in LlmAgent (llm_agent.py L1047) with _validate_module_reference() before importlib.import_module(). This was the last ungated import site: a YAML config with tools: [{name: os.system}] would previously import the os module without restriction. - Organise _BLOCKED_MODULES by category (process/OS, code eval, native/unsafe, network, filesystem/serialisation, interactive). - Add comprehensive tests: * _resolve_tools() blocks dangerous modules (os, subprocess, builtins, pickle) and allows ADK built-in tools (no dot in name). * Newly blocked network modules are rejected (ftplib, smtplib, xmlrpc, telnetlib, poplib, imaplib, asyncio, pathlib). * _set_enforce_denylist(False) escape hatch works. Import sites gated (audit): config_agent_utils.py: resolve_fully_qualified_name (L192), _resolve_agent_code_reference (L246), resolve_code_reference (L274) llm_agent.py: _resolve_tools user-defined path (L1049) llm_agent.py: _resolve_tools built-in path (L1043) — N/A, hardcoded to google.adk.tools Fixes #5822
1 parent 9670ce2 commit df75a3b

3 files changed

Lines changed: 201 additions & 0 deletions

File tree

src/google/adk/agents/config_agent_utils.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,91 @@ def _load_config_from_path(config_path: str) -> AgentConfig:
105105
return AgentConfig.model_validate(config_data)
106106

107107

108+
_ENFORCE_DENYLIST = True
109+
110+
# Modules that must never be imported via YAML agent configuration.
111+
# These provide direct access to the operating system, process execution,
112+
# or dynamic code evaluation and could be abused to achieve arbitrary
113+
# code execution when referenced in callback, tool, schema, or model
114+
# code-reference fields.
115+
_BLOCKED_MODULES = frozenset({
116+
# Process / OS execution
117+
"os",
118+
"subprocess",
119+
"sys",
120+
"builtins",
121+
"importlib",
122+
"shutil",
123+
"signal",
124+
"multiprocessing",
125+
"threading",
126+
# Dynamic code evaluation
127+
"code",
128+
"codeop",
129+
"compileall",
130+
"runpy",
131+
# Native / unsafe extensions
132+
"ctypes",
133+
# Network access
134+
"socket",
135+
"http",
136+
"urllib",
137+
"ftplib",
138+
"smtplib",
139+
"poplib",
140+
"imaplib",
141+
"nntplib",
142+
"telnetlib",
143+
"xmlrpc",
144+
"asyncio",
145+
# Filesystem / serialisation
146+
"tempfile",
147+
"pathlib",
148+
"shelve",
149+
"pickle",
150+
"marshal",
151+
# Interactive / side-effect modules
152+
"webbrowser",
153+
"antigravity",
154+
"pty",
155+
"commands",
156+
"pdb",
157+
"profile",
158+
})
159+
160+
161+
def _validate_module_reference(fully_qualified_name: str) -> None:
162+
"""Validate that a module reference does not target a blocked module.
163+
164+
Args:
165+
fully_qualified_name: The fully-qualified Python name to validate
166+
(e.g. ``"my_package.my_module.my_func"``).
167+
168+
Raises:
169+
ValueError: If the top-level module is in ``_BLOCKED_MODULES``.
170+
"""
171+
if not _ENFORCE_DENYLIST:
172+
return
173+
# Extract the top-level package from the fully-qualified name.
174+
top_module = fully_qualified_name.split(".")[0]
175+
if top_module in _BLOCKED_MODULES:
176+
raise ValueError(
177+
f"Blocked module reference: {fully_qualified_name!r}. "
178+
f"Importing from the '{top_module}' module is not allowed in "
179+
"agent configurations because it can execute arbitrary code."
180+
)
181+
182+
183+
def _set_enforce_denylist(value: bool) -> None:
184+
global _ENFORCE_DENYLIST
185+
_ENFORCE_DENYLIST = value
186+
187+
108188
@experimental(FeatureName.AGENT_CONFIG)
109189
def resolve_fully_qualified_name(name: str) -> Any:
110190
try:
111191
module_path, obj_name = name.rsplit(".", 1)
192+
_validate_module_reference(name)
112193
module = importlib.import_module(module_path)
113194
return getattr(module, obj_name)
114195
except Exception as e:
@@ -160,6 +241,7 @@ def _resolve_agent_code_reference(code: str) -> Any:
160241
if "." not in code:
161242
raise ValueError(f"Invalid code reference: {code}")
162243

244+
_validate_module_reference(code)
163245
module_path, obj_name = code.rsplit(".", 1)
164246
module = importlib.import_module(module_path)
165247
obj = getattr(module, obj_name)
@@ -189,6 +271,7 @@ def resolve_code_reference(code_config: CodeConfig) -> Any:
189271
if not code_config or not code_config.name:
190272
raise ValueError("Invalid CodeConfig.")
191273

274+
_validate_module_reference(code_config.name)
192275
module_path, obj_name = code_config.name.rsplit(".", 1)
193276
module = importlib.import_module(module_path)
194277
return getattr(module, obj_name)

src/google/adk/agents/llm_agent.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1044,6 +1044,9 @@ def _resolve_tools(
10441044
obj = getattr(module, tool_config.name)
10451045
else:
10461046
# User-defined tools
1047+
from .config_agent_utils import _validate_module_reference
1048+
1049+
_validate_module_reference(tool_config.name)
10471050
module_path, obj_name = tool_config.name.rsplit('.', 1)
10481051
module = importlib.import_module(module_path)
10491052
obj = getattr(module, obj_name)

tests/unittests/agents/test_agent_config.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,3 +465,118 @@ def fake_from_config(path: str):
465465
)
466466
assert result == "sentinel"
467467
assert recorded["path"] == expected_path
468+
469+
# --- Security tests: module blocklist for YAML agent config code references ---
470+
471+
472+
def test_resolve_code_reference_blocks_os_when_enforced():
473+
"""Verify resolve_code_reference blocks os module directly."""
474+
from google.adk.agents.common_configs import CodeConfig
475+
476+
with pytest.raises(ValueError, match="Blocked module reference"):
477+
config_agent_utils.resolve_code_reference(
478+
CodeConfig(name="os.system")
479+
)
480+
481+
482+
def test_resolve_fully_qualified_name_blocks_subprocess_when_enforced():
483+
"""Verify resolve_fully_qualified_name blocks subprocess module.
484+
485+
resolve_fully_qualified_name wraps all exceptions in
486+
ValueError("Invalid fully qualified name: ..."), so we check the wrapper
487+
and verify the __cause__ carries the blocklist message.
488+
"""
489+
with pytest.raises(ValueError, match="Invalid fully qualified name") as exc_info:
490+
config_agent_utils.resolve_fully_qualified_name("subprocess.Popen")
491+
assert "Blocked module reference" in str(exc_info.value.__cause__)
492+
493+
494+
def test_allowed_module_passes_when_enforced(tmp_path: Path):
495+
"""Verify that google.adk modules are NOT blocked by the module denylist."""
496+
# This should NOT raise — google.adk modules must remain allowed
497+
result = config_agent_utils.resolve_fully_qualified_name(
498+
"google.adk.agents.llm_agent.LlmAgent"
499+
)
500+
assert result is LlmAgent
501+
502+
503+
@pytest.mark.parametrize(
504+
"blocked_module",
505+
[
506+
"os.system",
507+
"subprocess.call",
508+
"builtins.exec",
509+
],
510+
)
511+
def test_resolve_agent_code_reference_blocks_when_enforced(
512+
blocked_module: str,
513+
):
514+
"""Verify _resolve_agent_code_reference blocks dangerous modules."""
515+
with pytest.raises(ValueError, match="Blocked module reference"):
516+
config_agent_utils._resolve_agent_code_reference(blocked_module)
517+
518+
519+
@pytest.mark.parametrize(
520+
"blocked_ref",
521+
[
522+
"os.system",
523+
"subprocess.call",
524+
"builtins.exec",
525+
"pickle.loads",
526+
],
527+
)
528+
def test_resolve_tools_blocks_dangerous_modules(blocked_ref: str):
529+
"""Verify _resolve_tools blocks dangerous modules for user-defined tools."""
530+
from google.adk.agents.llm_agent import LlmAgent
531+
from google.adk.tools.tool_configs import ToolConfig
532+
533+
tool_config = ToolConfig(name=blocked_ref)
534+
with pytest.raises(ValueError, match="Blocked module reference"):
535+
LlmAgent._resolve_tools([tool_config], "/fake/path.yaml")
536+
537+
538+
def test_resolve_tools_allows_builtin_adk_tools():
539+
"""Verify _resolve_tools allows ADK built-in tools (no dot in name)."""
540+
from google.adk.agents.llm_agent import LlmAgent
541+
from google.adk.tools.tool_configs import ToolConfig
542+
543+
# Built-in tools have no dot — they import from google.adk.tools
544+
tool_config = ToolConfig(name="google_search")
545+
# Should NOT raise — this is a safe, hardcoded import path
546+
resolved = LlmAgent._resolve_tools([tool_config], "/fake/path.yaml")
547+
assert len(resolved) == 1
548+
549+
550+
@pytest.mark.parametrize(
551+
"blocked_ref",
552+
[
553+
"ftplib.FTP",
554+
"smtplib.SMTP",
555+
"xmlrpc.client",
556+
"telnetlib.Telnet",
557+
"poplib.POP3",
558+
"imaplib.IMAP4",
559+
"asyncio.run",
560+
"pathlib.Path",
561+
],
562+
)
563+
def test_newly_blocked_network_modules_are_rejected(blocked_ref: str):
564+
"""Verify newly added network-capable modules are blocked.
565+
566+
resolve_fully_qualified_name wraps errors, so we check the cause.
567+
"""
568+
with pytest.raises(ValueError, match="Invalid fully qualified name") as exc_info:
569+
config_agent_utils.resolve_fully_qualified_name(blocked_ref)
570+
assert "Blocked module reference" in str(exc_info.value.__cause__)
571+
572+
573+
def test_denylist_can_be_disabled():
574+
"""Verify _set_enforce_denylist(False) disables module blocking."""
575+
config_agent_utils._set_enforce_denylist(False)
576+
try:
577+
# os.getcwd is a real, importable reference — should succeed
578+
result = config_agent_utils.resolve_fully_qualified_name("os.getcwd")
579+
assert callable(result)
580+
finally:
581+
config_agent_utils._set_enforce_denylist(True)
582+

0 commit comments

Comments
 (0)