fix(skills): reject symlinked SKILL.md on save (#4240) (#4245)

* fix(skills): reject symlinked skill saves

* chore(release): stamp v0.51.434 (Release OU) for #4240 skill-save symlink guard

---------

Co-authored-by: 峯岸 亮 <1920071390@campus.ouj.ac.jp>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
This commit is contained in:
nesquena-hermes
2026-06-15 04:22:17 -07:00
committed by GitHub
parent 60ad36dab7
commit 4abf3bf5ef
3 changed files with 68 additions and 0 deletions
+6
View File
@@ -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
+2
View File
@@ -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)})
+60
View File
@@ -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"