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
20 changes: 20 additions & 0 deletions src/google/adk/skills/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,30 @@ def _load_dir(directory: pathlib.Path) -> dict[str, Union[str, bytes]]:
UTF-8 text files, or raw `bytes` for binary files.
"""
files: dict[str, Union[str, bytes]] = {}
if directory.is_symlink():
# references/, assets/, and scripts/ are resolved directly against the
# skill directory and walked from there, not reached by recursing in
# from somewhere else -- so rglob("*") treats a symlinked root exactly
# like an ordinary directory and recurses straight through it, unlike a
# symlink found deeper inside the walk, which it leaves alone by
# default. Treat a symlinked references/, assets/, or scripts/ as
# absent, the same as if it didn't exist, rather than following it into
# wherever it points.
return files
if directory.exists() and directory.is_dir():
for file_path in directory.rglob("*"):
if "__pycache__" in file_path.parts:
continue
# A skill is free to ship a symlink inside references/, assets/, or
# scripts/ -- nothing in the SKILL.md format forbids it. rglob("*")
# does not recurse into a symlinked *directory* (that is pathlib's
# own default), but a symlink that is itself a *file* entry is still
# yielded here, and is_file()/read_text()/read_bytes() all follow it
# transparently. Skipping it here, before any of those calls, is what
# keeps a skill from reading a file that lives outside its own
# directory under an ordinary-looking resource name.
if file_path.is_symlink():
continue
if file_path.is_file():
relative_path = file_path.relative_to(directory)
try:
Expand Down
66 changes: 66 additions & 0 deletions tests/unittests/skills/test__utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import asyncio
import builtins
import io
import os
import pathlib
import struct
import threading
Expand Down Expand Up @@ -1400,3 +1401,68 @@ def test__validate_relative_resource_path_rejects_dot_prefixed_windows_drive():
for unsafe_path in ("./C:/evil.py", ".\\C:\\evil.py", "./c:evil.py"):
with pytest.raises(ValueError, match="Unsafe path"):
_validate_relative_resource_path(unsafe_path)


def test__load_skill_from_dir_skips_symlinked_resources(tmp_path):
"""A symlinked resource is not loaded, even if it points outside the skill.

Regression test: _load_dir previously opened whatever a symlink inside
references/, assets/, or scripts/ resolved to, which let a skill read an
arbitrary file elsewhere on disk under an ordinary-looking resource name.
"""
outside = tmp_path / "outside-secret.txt"
outside.write_text("top-secret")

skill_dir = tmp_path / "test-skill"
assets_dir = skill_dir / "assets"
assets_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: test-skill\ndescription: Test description\n---\nBody"
)
(assets_dir / "ordinary.txt").write_text("ordinary content")
os.symlink(outside, assets_dir / "link")

skill = _load_skill_from_dir(skill_dir)

assert skill.resources.get_asset("ordinary.txt") == "ordinary content"
assert skill.resources.get_asset("link") is None
assert "link" not in skill.resources.assets


def test__load_skill_from_dir_skips_symlinked_directory_contents(tmp_path):
"""A symlinked directory inside assets/ is not recursed into."""
outside = tmp_path / "outside"
outside.mkdir()
(outside / "secret.txt").write_text("top-secret")

skill_dir = tmp_path / "test-skill"
assets_dir = skill_dir / "assets"
assets_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: test-skill\ndescription: Test description\n---\nBody"
)
os.symlink(outside, assets_dir / "link_dir")

skill = _load_skill_from_dir(skill_dir)

assert "link_dir/secret.txt" not in skill.resources.assets
for content in skill.resources.assets.values():
assert content != "top-secret"


def test__load_skill_from_dir_skips_symlinked_resource_directory(tmp_path):
"""assets/ itself being a symlink is treated as absent, not followed."""
outside = tmp_path / "outside"
outside.mkdir()
(outside / "secret.txt").write_text("top-secret")

skill_dir = tmp_path / "test-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
"---\nname: test-skill\ndescription: Test description\n---\nBody"
)
os.symlink(outside, skill_dir / "assets")

skill = _load_skill_from_dir(skill_dir)

assert skill.resources.assets == {}
Loading