mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-21 07:00:23 +00:00
* 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:
@@ -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
|
||||
|
||||
@@ -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)})
|
||||
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user