diff --git a/CHANGELOG.md b/CHANGELOG.md index 84bcad828..8e39531ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.434] — 2026-06-15 — Release OU (reject symlinked skill files on save) + +### Fixed + +- **Saving a skill no longer writes through a symlinked `SKILL.md`.** `_handle_skill_save` now rejects a symlinked skill file with a 400 instead of following it and overwriting the link's target — extending the workspace symlink hardening (#4217 / #4234) to the skills surface. (#4240) + ## [v0.51.433] — 2026-06-15 — Release OT (reject symlinked entries in /api/file/save, #4234) ### Fixed diff --git a/api/routes.py b/api/routes.py index 10de8e9c8..f82ea13c8 100644 --- a/api/routes.py +++ b/api/routes.py @@ -16602,6 +16602,8 @@ def _handle_skill_save(handler, body): return bad(handler, "Invalid skill path") skill_dir.mkdir(parents=True, exist_ok=True) skill_file = skill_dir / "SKILL.md" + if skill_file.is_symlink(): + return bad(handler, "Cannot save to a symlinked skill file") skill_file.write_text(body["content"], encoding="utf-8") return j(handler, {"ok": True, "name": skill_name, "path": str(skill_file)}) diff --git a/tests/test_skill_save_symlink_guard.py b/tests/test_skill_save_symlink_guard.py new file mode 100644 index 000000000..3af776370 --- /dev/null +++ b/tests/test_skill_save_symlink_guard.py @@ -0,0 +1,60 @@ +import os + +import pytest + +import api.routes as routes + + +class _FakeHandler: + pass + + +def _patch_skill_routes(monkeypatch, skills_dir): + cap = {} + monkeypatch.setattr(routes, "_active_skills_dir", lambda: skills_dir) + monkeypatch.setattr(routes, "j", lambda h, o: (cap.__setitem__("ok", o), True)[1]) + monkeypatch.setattr( + routes, + "bad", + lambda h, m, c=400: (cap.__setitem__("bad", (m, c)), True)[1], + ) + return cap + + +def test_skill_save_rejects_symlinked_skill_file(tmp_path, monkeypatch): + skills_dir = tmp_path / "skills" + skill_dir = skills_dir / "demo" + skill_dir.mkdir(parents=True) + outside = tmp_path / "outside.md" + outside.write_text("important", encoding="utf-8") + link = skill_dir / "SKILL.md" + try: + os.symlink(str(outside), str(link)) + except (OSError, NotImplementedError): + pytest.skip("platform does not support symlinks") + + cap = _patch_skill_routes(monkeypatch, skills_dir) + routes._handle_skill_save( + _FakeHandler(), + {"name": "demo", "content": "changed"}, + ) + + assert "bad" in cap, f"expected 400, got {cap}" + assert cap["bad"][1] == 400 + assert "Cannot save to a symlinked skill file" in cap["bad"][0] + assert outside.read_text(encoding="utf-8") == "important" + + +def test_skill_save_real_file_still_works(tmp_path, monkeypatch): + skills_dir = tmp_path / "skills" + + cap = _patch_skill_routes(monkeypatch, skills_dir) + routes._handle_skill_save( + _FakeHandler(), + {"name": "Demo Skill", "content": "# Demo\n"}, + ) + + skill_file = skills_dir / "demo-skill" / "SKILL.md" + assert "ok" in cap, f"expected success, got {cap}" + assert cap["ok"]["ok"] is True + assert skill_file.read_text(encoding="utf-8") == "# Demo\n"