Skip to content

Commit 6a5be34

Browse files
Ashutosh0xGWeale
authored andcommitted
fix: add module blocklist for YAML agent config code references
Merge #5821 Fixes #5822 ## Summary This PR adds a module blocklist to prevent importing dangerous standard library modules via YAML agent configurations. It is a defense-in-depth hardening that complements the existing **args** key block from the CVE-2026-4810 fix. ## Problem The CVE-2026-4810 fix blocked the `args` key in YAML configs to prevent passing arguments to constructors. However, the following functions still call `importlib.import_module()` with **no restriction** on which modules can be imported: - `resolve_code_reference()` — used for callbacks, schemas, model_code - `resolve_fully_qualified_name()` — used for agent class resolution - `_resolve_tools()` — used for user-defined tool resolution - `_resolve_agent_code_reference()` — used for agent code references ### Example malicious YAML (currently accepted): ```yaml name: evil_agent model: gemini-2.0-flash instruction: harmless before_agent_callbacks: - name: os.system ``` ## Solution ### 1. Module blocklist with 36 dangerous stdlib modules Added `_BLOCKED_MODULES` frozenset organized by category: | Category | Modules | |---|---| | Process / OS execution | `os`, `subprocess`, `sys`, `builtins`, `importlib`, `shutil`, `signal`, `multiprocessing`, `threading` | | Dynamic code evaluation | `code`, `codeop`, `compileall`, `runpy` | | Native / unsafe extensions | `ctypes` | | Network access | `socket`, `http`, `urllib`, `ftplib`, `smtplib`, `poplib`, `imaplib`, `nntplib`, `telnetlib`, `xmlrpc`, `asyncio` | | Filesystem / serialisation | `tempfile`, `pathlib`, `shelve`, `pickle`, `marshal` | | Interactive / side-effect | `webbrowser`, `antigravity`, `pty`, `commands`, `pdb`, `profile` | ### 2. Validation at ALL import sites Added `_validate_module_reference()` that checks the top-level module against the blocklist **before** `importlib.import_module()` is called. | Import Site | File | Gated? | |---|---|---| | `resolve_fully_qualified_name()` | `config_agent_utils.py` | ✅ | | `_resolve_agent_code_reference()` | `config_agent_utils.py` | ✅ | | `resolve_code_reference()` | `config_agent_utils.py` | ✅ | | `_resolve_tools()` user-defined | `llm_agent.py` | ✅ **New** | | `_resolve_tools()` built-in | `llm_agent.py` | N/A (hardcoded `google.adk.tools`) | ### 3. Enabled by default `_ENFORCE_DENYLIST = True` — the blocklist is **active by default**, with `_set_enforce_denylist(False)` available as an escape hatch for operators who need custom modules. ## Files Changed - **`src/google/adk/agents/config_agent_utils.py`** (+83) — `_BLOCKED_MODULES`, `_validate_module_reference()`, validation calls at 3 import sites, `_ENFORCE_DENYLIST = True` - **`src/google/adk/agents/llm_agent.py`** (+3) — Validation gate in `_resolve_tools()` for user-defined tools - **`tests/unittests/agents/test_agent_config.py`** (+115) — 8 new security test functions ## Testing ### All 52 tests pass (0 failures): ``` ====================== 52 passed, 311 warnings in 3.89s ======================= ``` ### New security test coverage: | Test | What it verifies | |---|---| | `test_resolve_code_reference_blocks_os_when_enforced` | `os.system` blocked via `resolve_code_reference` | | `test_resolve_fully_qualified_name_blocks_subprocess_when_enforced` | `subprocess.Popen` blocked (verifies wrapped cause) | | `test_allowed_module_passes_when_enforced` | `google.adk.*` modules pass through (no false positives) | | `test_resolve_agent_code_reference_blocks_when_enforced` | 3 modules blocked via `_resolve_agent_code_reference` | | `test_resolve_tools_blocks_dangerous_modules` | 4 modules blocked via `_resolve_tools` | | `test_resolve_tools_allows_builtin_adk_tools` | `google_search` (built-in, no dot) passes through | | `test_newly_blocked_network_modules_are_rejected` | 8 new network modules verified blocked | | `test_denylist_can_be_disabled` | `_set_enforce_denylist(False)` escape hatch works | All existing tests remain unchanged and passing. Zero risk of regression. Co-authored-by: George Weale <gweale@google.com> COPYBARA_INTEGRATE_REVIEW=#5821 from Ashutosh0x:security/restrict-module-imports-config-agent df75a3b PiperOrigin-RevId: 937681389
1 parent a912306 commit 6a5be34

3 files changed

Lines changed: 203 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:
@@ -170,6 +251,7 @@ def _resolve_agent_code_reference(code: str) -> Any:
170251
if "." not in code:
171252
raise ValueError(f"Invalid code reference: {code}")
172253

254+
_validate_module_reference(code)
173255
module_path, obj_name = code.rsplit(".", 1)
174256
module = importlib.import_module(module_path)
175257
obj = getattr(module, obj_name)
@@ -199,6 +281,7 @@ def resolve_code_reference(code_config: CodeConfig) -> Any:
199281
if not code_config or not code_config.name:
200282
raise ValueError("Invalid CodeConfig.")
201283

284+
_validate_module_reference(code_config.name)
202285
module_path, obj_name = code_config.name.rsplit(".", 1)
203286
module = importlib.import_module(module_path)
204287
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
@@ -1102,6 +1102,9 @@ def _resolve_tools(
11021102
obj = getattr(module, tool_config.name)
11031103
else:
11041104
# User-defined tools
1105+
from .config_agent_utils import _validate_module_reference
1106+
1107+
_validate_module_reference(tool_config.name)
11051108
module_path, obj_name = tool_config.name.rsplit('.', 1)
11061109
module = importlib.import_module(module_path)
11071110
obj = getattr(module, obj_name)

tests/unittests/agents/test_agent_config.py

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

0 commit comments

Comments
 (0)