diff --git a/src/fastmcp/server/providers/skills/_common.py b/src/fastmcp/server/providers/skills/_common.py index 95e5b2db0..d0e1177a5 100644 --- a/src/fastmcp/server/providers/skills/_common.py +++ b/src/fastmcp/server/providers/skills/_common.py @@ -86,16 +86,22 @@ def compute_file_hash(path: Path) -> str: def scan_skill_files(skill_dir: Path) -> list[SkillFileInfo]: """Scan a skill directory for all files.""" files = [] + resolved_skill_dir = skill_dir.resolve() + # Sort for deterministic ordering across platforms for file_path in sorted(skill_dir.rglob("*")): if file_path.is_file(): + resolved_file_path = file_path.resolve() + if not resolved_file_path.is_relative_to(resolved_skill_dir): + continue + rel_path = file_path.relative_to(skill_dir) files.append( SkillFileInfo( # Use POSIX paths for cross-platform URI consistency path=rel_path.as_posix(), - size=file_path.stat().st_size, - hash=compute_file_hash(file_path), + size=resolved_file_path.stat().st_size, + hash=compute_file_hash(resolved_file_path), ) ) return files diff --git a/tests/server/providers/test_skills_provider.py b/tests/server/providers/test_skills_provider.py index 40ec0fac7..3caa96cf4 100644 --- a/tests/server/providers/test_skills_provider.py +++ b/tests/server/providers/test_skills_provider.py @@ -169,6 +169,28 @@ This is my skill content. assert "reference.md" in paths assert "scripts/helper.py" in paths + async def test_manifest_ignores_symlink_target_outside_skill(self, tmp_path: Path): + skill_dir = tmp_path / "symlinked-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("# Skill\n") + + outside_file = tmp_path / "outside.txt" + outside_file.write_text("secret") + (skill_dir / "leak.txt").symlink_to(outside_file) + + mcp = FastMCP("Test") + mcp.add_provider(SkillProvider(skill_path=skill_dir)) + + async with Client(mcp) as client: + result = await client.read_resource( + AnyUrl("skill://symlinked-skill/_manifest") + ) + manifest = json.loads(result[0].text) + + paths = {f["path"] for f in manifest["files"]} + assert "SKILL.md" in paths + assert "leak.txt" not in paths + async def test_read_supporting_file_via_template(self, single_skill_dir: Path): mcp = FastMCP("Test") mcp.add_provider(SkillProvider(skill_path=single_skill_dir))