fix(skills): reject symlinks in skill bundles before install
This commit is contained in:
parent
fe9744cbee
commit
c26af46811
@ -1913,3 +1913,50 @@ class TestInstallPathSafety:
|
|||||||
assert ok is False
|
assert ok is False
|
||||||
assert victim.exists()
|
assert victim.exists()
|
||||||
assert (victim / "important").read_text() == "don't delete me"
|
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"
|
||||||
|
|||||||
@ -3040,6 +3040,21 @@ def install_from_quarantine(
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
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)
|
install_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||||
shutil.move(str(quarantine_path), str(install_dir))
|
shutil.move(str(quarantine_path), str(install_dir))
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user