Skip to content

Commit 9d372fa

Browse files
committed
Restrict builder YAML code references
1 parent abcf14c commit 9d372fa

2 files changed

Lines changed: 289 additions & 2 deletions

File tree

src/google/adk/cli/fast_api.py

Lines changed: 151 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,8 +306,132 @@ def _has_parent_reference(path: str) -> bool:
306306
# functions, which is an RCE vector when exposed through the builder UI.
307307
# Block any upload that contains an `args` key anywhere in the document.
308308
_BLOCKED_YAML_KEYS = frozenset({"args"})
309+
_BUILDER_BUILT_IN_AGENT_CLASSES = frozenset({
310+
"LlmAgent",
311+
"LoopAgent",
312+
"ParallelAgent",
313+
"SequentialAgent",
314+
"google.adk.agents.LlmAgent",
315+
"google.adk.agents.LoopAgent",
316+
"google.adk.agents.ParallelAgent",
317+
"google.adk.agents.SequentialAgent",
318+
"google.adk.agents.llm_agent.LlmAgent",
319+
"google.adk.agents.loop_agent.LoopAgent",
320+
"google.adk.agents.parallel_agent.ParallelAgent",
321+
"google.adk.agents.sequential_agent.SequentialAgent",
322+
})
323+
_BUILDER_CODE_CONFIG_KEYS = frozenset({
324+
"after_agent_callbacks",
325+
"after_model_callbacks",
326+
"after_tool_callbacks",
327+
"before_agent_callbacks",
328+
"before_model_callbacks",
329+
"before_tool_callbacks",
330+
"input_schema",
331+
"model_code",
332+
"output_schema",
333+
})
334+
_BUILDER_RESERVED_TOP_LEVEL_MODULES = frozenset({"google"})
335+
336+
def _app_name_conflicts_with_importable_module(app_name: str) -> bool:
337+
"""Return whether app_name would make project references ambiguous."""
338+
stdlib_module_names = getattr(sys, "stdlib_module_names", frozenset())
339+
return (
340+
app_name in sys.builtin_module_names
341+
or app_name in stdlib_module_names
342+
or app_name in _BUILDER_RESERVED_TOP_LEVEL_MODULES
343+
)
344+
345+
def _check_project_code_reference(
346+
reference: str,
347+
*,
348+
app_name: str,
349+
filename: str,
350+
field_name: str,
351+
allow_short_builtin_tool: bool = False,
352+
) -> None:
353+
"""Validate that builder YAML cannot import arbitrary external code."""
354+
if "." not in reference:
355+
if allow_short_builtin_tool:
356+
return
357+
raise ValueError(
358+
f"Invalid code reference {reference!r} in {filename!r}. "
359+
f"The '{field_name}' field must use a project-local dotted path."
360+
)
361+
362+
if not reference.startswith(f"{app_name}."):
363+
raise ValueError(
364+
f"Blocked code reference {reference!r} in {filename!r}. "
365+
f"The '{field_name}' field must reference code under "
366+
f"'{app_name}.*'."
367+
)
368+
369+
if _app_name_conflicts_with_importable_module(app_name):
370+
raise ValueError(
371+
f"Blocked code reference {reference!r} in {filename!r}. "
372+
f"The app name {app_name!r} conflicts with an importable Python "
373+
"module, so project-local code references would be ambiguous."
374+
)
309375

310-
def _check_yaml_for_blocked_keys(content: bytes, filename: str) -> None:
376+
def _check_agent_class_reference(
377+
value: Any, *, app_name: str, filename: str
378+
) -> None:
379+
if not isinstance(value, str):
380+
return
381+
if value in _BUILDER_BUILT_IN_AGENT_CLASSES:
382+
return
383+
_check_project_code_reference(
384+
value,
385+
app_name=app_name,
386+
filename=filename,
387+
field_name="agent_class",
388+
)
389+
390+
def _check_code_config_reference(
391+
value: Any, *, app_name: str, filename: str, field_name: str
392+
) -> None:
393+
if isinstance(value, list):
394+
for item in value:
395+
_check_code_config_reference(
396+
item,
397+
app_name=app_name,
398+
filename=filename,
399+
field_name=field_name,
400+
)
401+
return
402+
if not isinstance(value, dict):
403+
return
404+
405+
name = value.get("name")
406+
if isinstance(name, str):
407+
_check_project_code_reference(
408+
name,
409+
app_name=app_name,
410+
filename=filename,
411+
field_name=field_name,
412+
)
413+
414+
def _check_tool_references(
415+
value: Any, *, app_name: str, filename: str
416+
) -> None:
417+
if not isinstance(value, list):
418+
return
419+
for item in value:
420+
if not isinstance(item, dict):
421+
continue
422+
name = item.get("name")
423+
if isinstance(name, str):
424+
_check_project_code_reference(
425+
name,
426+
app_name=app_name,
427+
filename=filename,
428+
field_name="tools.name",
429+
allow_short_builtin_tool=True,
430+
)
431+
432+
def _check_yaml_for_blocked_keys(
433+
content: bytes, filename: str, app_name: str
434+
) -> None:
311435
"""Raise if the YAML document contains any blocked keys."""
312436
import yaml
313437

@@ -325,6 +449,29 @@ def _walk(node: Any) -> None:
325449
f"The '{key}' field is not allowed in builder uploads "
326450
"because it can execute arbitrary code."
327451
)
452+
if key == "agent_class":
453+
_check_agent_class_reference(
454+
value, app_name=app_name, filename=filename
455+
)
456+
elif key == "code":
457+
if isinstance(value, str):
458+
_check_project_code_reference(
459+
value,
460+
app_name=app_name,
461+
filename=filename,
462+
field_name="code",
463+
)
464+
elif key == "tools":
465+
_check_tool_references(
466+
value, app_name=app_name, filename=filename
467+
)
468+
elif key in _BUILDER_CODE_CONFIG_KEYS:
469+
_check_code_config_reference(
470+
value,
471+
app_name=app_name,
472+
filename=filename,
473+
field_name=key,
474+
)
328475
_walk(value)
329476
elif isinstance(node, list):
330477
for item in node:
@@ -490,7 +637,9 @@ async def builder_build(
490637

491638
# Phase 2: validate every file *before* writing anything to disk.
492639
for rel_path, content in uploads:
493-
_check_yaml_for_blocked_keys(content, f"{app_name}/{rel_path}")
640+
_check_yaml_for_blocked_keys(
641+
content, f"{app_name}/{rel_path}", app_name
642+
)
494643

495644
# Phase 3: write validated files to disk.
496645
if tmp:

tests/unittests/cli/test_fast_api.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2113,6 +2113,144 @@ def test_builder_save_rejects_nested_args_key(builder_test_client, tmp_path):
21132113
assert "args" in response.json()["detail"]
21142114

21152115

2116+
def test_builder_save_rejects_external_tool_reference(
2117+
builder_test_client, tmp_path
2118+
):
2119+
"""Uploading YAML with an external dotted tool reference is rejected."""
2120+
yaml_with_external_tool = b"""\
2121+
agent_class: LlmAgent
2122+
name: app
2123+
model: gemini-2.5-flash
2124+
instruction: test
2125+
tools:
2126+
- name: os.system
2127+
"""
2128+
response = builder_test_client.post(
2129+
"/builder/save?tmp=true",
2130+
files=[(
2131+
"files",
2132+
(
2133+
"app/root_agent.yaml",
2134+
yaml_with_external_tool,
2135+
"application/x-yaml",
2136+
),
2137+
)],
2138+
)
2139+
assert response.status_code == 400
2140+
assert "os.system" in response.json()["detail"]
2141+
assert not (tmp_path / "app" / "tmp" / "app" / "root_agent.yaml").exists()
2142+
2143+
2144+
def test_builder_save_allows_project_tool_reference(
2145+
builder_test_client, tmp_path
2146+
):
2147+
"""Project-local dotted tool references are allowed."""
2148+
yaml_with_project_tool = b"""\
2149+
agent_class: LlmAgent
2150+
name: app
2151+
model: gemini-2.5-flash
2152+
instruction: test
2153+
tools:
2154+
- name: app.tools.safe_tool.run
2155+
"""
2156+
response = builder_test_client.post(
2157+
"/builder/save?tmp=true",
2158+
files=[(
2159+
"files",
2160+
(
2161+
"app/root_agent.yaml",
2162+
yaml_with_project_tool,
2163+
"application/x-yaml",
2164+
),
2165+
)],
2166+
)
2167+
assert response.status_code == 200
2168+
assert response.json() is True
2169+
assert (tmp_path / "app" / "tmp" / "app" / "root_agent.yaml").is_file()
2170+
2171+
2172+
def test_builder_save_rejects_stdlib_named_project_reference(
2173+
builder_test_client, tmp_path
2174+
):
2175+
"""Stdlib app names cannot make stdlib imports look project-local."""
2176+
yaml_with_stdlib_tool = b"""\
2177+
agent_class: LlmAgent
2178+
name: os
2179+
model: gemini-2.5-flash
2180+
instruction: test
2181+
tools:
2182+
- name: os.system
2183+
"""
2184+
response = builder_test_client.post(
2185+
"/builder/save?tmp=true",
2186+
files=[(
2187+
"files",
2188+
(
2189+
"os/root_agent.yaml",
2190+
yaml_with_stdlib_tool,
2191+
"application/x-yaml",
2192+
),
2193+
)],
2194+
)
2195+
assert response.status_code == 400
2196+
assert "os.system" in response.json()["detail"]
2197+
assert not (tmp_path / "os" / "tmp" / "os" / "root_agent.yaml").exists()
2198+
2199+
2200+
def test_builder_save_rejects_external_callback_reference(
2201+
builder_test_client, tmp_path
2202+
):
2203+
"""Uploading YAML with an external callback reference is rejected."""
2204+
yaml_with_external_callback = b"""\
2205+
agent_class: LlmAgent
2206+
name: app
2207+
model: gemini-2.5-flash
2208+
instruction: test
2209+
before_agent_callbacks:
2210+
- name: os.system
2211+
"""
2212+
response = builder_test_client.post(
2213+
"/builder/save?tmp=true",
2214+
files=[(
2215+
"files",
2216+
(
2217+
"app/root_agent.yaml",
2218+
yaml_with_external_callback,
2219+
"application/x-yaml",
2220+
),
2221+
)],
2222+
)
2223+
assert response.status_code == 400
2224+
assert "os.system" in response.json()["detail"]
2225+
assert not (tmp_path / "app" / "tmp" / "app" / "root_agent.yaml").exists()
2226+
2227+
2228+
def test_builder_save_rejects_external_agent_ref_code(
2229+
builder_test_client, tmp_path
2230+
):
2231+
"""Uploading YAML with an external sub-agent code reference is rejected."""
2232+
yaml_with_external_sub_agent = b"""\
2233+
agent_class: SequentialAgent
2234+
name: app
2235+
sub_agents:
2236+
- code: os.system
2237+
"""
2238+
response = builder_test_client.post(
2239+
"/builder/save?tmp=true",
2240+
files=[(
2241+
"files",
2242+
(
2243+
"app/root_agent.yaml",
2244+
yaml_with_external_sub_agent,
2245+
"application/x-yaml",
2246+
),
2247+
)],
2248+
)
2249+
assert response.status_code == 400
2250+
assert "os.system" in response.json()["detail"]
2251+
assert not (tmp_path / "app" / "tmp" / "app" / "root_agent.yaml").exists()
2252+
2253+
21162254
def test_builder_get_rejects_non_yaml_file_paths(builder_test_client, tmp_path):
21172255
"""GET /builder/app/{app_name}?file_path=... rejects non-YAML extensions."""
21182256
app_root = tmp_path / "app"

0 commit comments

Comments
 (0)