From c26af46811c1d32373700ecf80d181d3405f49e8 Mon Sep 17 00:00:00 2001 From: MorAlekss Date: Mon, 25 May 2026 20:51:55 -0700 Subject: [PATCH] fix(skills): reject symlinks in skill bundles before install --- tests/tools/test_skills_hub.py | 47 ++++++++++++++++++++++++++++++++++ tools/skills_hub.py | 15 +++++++++++ 2 files changed, 62 insertions(+) diff --git a/tests/tools/test_skills_hub.py b/tests/tools/test_skills_hub.py index 432ac521316..9c1c1b72a64 100644 --- a/tests/tools/test_skills_hub.py +++ b/tests/tools/test_skills_hub.py @@ -1913,3 +1913,50 @@ class TestInstallPathSafety: assert ok is False assert victim.exists() assert (victim / "important").read_text() == "don't delete me" + + def test_install_from_quarantine_rejects_symlinks(self, tmp_path): + """Skill install must not follow symlinks that leak file contents + from outside the quarantine directory.""" + import tools.skills_hub as hub + from tools.skills_guard import ScanResult + + skills_dir = tmp_path / "skills" + quarantine_root = skills_dir / ".hub" / "quarantine" + quarantine_root.mkdir(parents=True) + + q_dir = quarantine_root / "pending" + q_dir.mkdir() + (q_dir / "SKILL.md").write_text("---\nname: bad-skill\n---\n") + + secret = tmp_path / "secret.txt" + secret.write_text("data exfiltration payload\n") + + leak = q_dir / "leak.txt" + try: + leak.symlink_to(secret) + except (OSError, NotImplementedError): + pytest.skip("symlink creation unsupported on this platform") + + bundle = hub.SkillBundle( + name="bad-skill", + files={"SKILL.md": "---\nname: bad-skill\n---\n"}, + source="community", + identifier="x", + trust_level="community", + ) + scan_result = ScanResult( + skill_name="bad-skill", + source="community", + trust_level="community", + verdict="safe", + ) + + with patch.object(hub, "SKILLS_DIR", skills_dir), \ + patch.object(hub, "QUARANTINE_DIR", quarantine_root): + with pytest.raises(ValueError, match="symlink"): + hub.install_from_quarantine( + q_dir, "bad-skill", "", bundle, scan_result, + ) + + assert not (skills_dir / "bad-skill" / "leak.txt").exists() + assert secret.read_text() == "data exfiltration payload\n" diff --git a/tools/skills_hub.py b/tools/skills_hub.py index 9f58e96284b..1dadc99495b 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -3040,6 +3040,21 @@ def install_from_quarantine( except OSError: pass + # Reject symlinks inside the quarantined skill before moving it. + # A malicious skill bundle could include a symlink pointing outside the + # skills tree; its target contents would then be copied into skills/ and + # leaked to the agent on the next skill_view call. + for entry in quarantine_path.rglob("*"): + if not _is_path_redirect(entry): + continue + try: + rel = entry.relative_to(quarantine_resolved) + except ValueError: + rel = entry + raise ValueError( + f"Installed skill contains symlinks, which is not allowed: {rel}" + ) + install_dir.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(quarantine_path), str(install_dir))