Skip to content

Commit 7cd6a42

Browse files
teknium1zy
authored andcommitted
feat(skills): refuse skill_manage writes on pinned skills (NousResearch#17562)
Extend curator's pin flag from 'skip auto-transitions' to 'no agent edits at all'. All five skill_manage mutation actions (edit, patch, delete, write_file, remove_file) now refuse pinned skills with a message pointing the user at `hermes curator unpin <name>`. Motivation: pin used to only stop the curator's own maintenance pass from touching a skill. Nothing prevented the main agent from editing or deleting a pinned skill via skill_manage in-session. This gives users a hard fence against unwanted agent edits — same semantics as curator pinning, extended to the write tool. Create is unaffected (you can't pin a name that doesn't exist yet, and name collisions already error out). Broken sidecars fail open rather than lock the agent out. The schema description advertises the new refusal so models know not to route around it with rename/recreate tricks.
1 parent 221a401 commit 7cd6a42

2 files changed

Lines changed: 162 additions & 1 deletion

File tree

tests/tools/test_skill_manager_tool.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,3 +714,114 @@ def test_create_still_writes_to_local_root(self, tmp_path):
714714
assert (local / "fresh-skill" / "SKILL.md").exists()
715715
assert not (external / "fresh-skill").exists()
716716

717+
718+
719+
# ---------------------------------------------------------------------------
720+
# Pinned-skill guard — skill_manage refuses all writes to pinned skills.
721+
# The user unpins via `hermes curator unpin <name>`.
722+
# ---------------------------------------------------------------------------
723+
724+
class TestPinnedGuard:
725+
"""Every mutation action must refuse when the skill is pinned."""
726+
727+
@staticmethod
728+
def _pin(name: str):
729+
"""Return a patch context that marks *name* as pinned in skill_usage."""
730+
def _fake_get_record(skill_name, _name=name):
731+
return {"pinned": True} if skill_name == _name else {"pinned": False}
732+
return patch("tools.skill_usage.get_record", side_effect=_fake_get_record)
733+
734+
def test_edit_refuses_pinned(self, tmp_path):
735+
with _skill_dir(tmp_path):
736+
_create_skill("my-skill", VALID_SKILL_CONTENT)
737+
with self._pin("my-skill"):
738+
result = _edit_skill("my-skill", VALID_SKILL_CONTENT_2)
739+
assert result["success"] is False
740+
assert "pinned" in result["error"].lower()
741+
assert "hermes curator unpin my-skill" in result["error"]
742+
# Original content preserved
743+
content = (tmp_path / "my-skill" / "SKILL.md").read_text()
744+
assert "A test skill" in content
745+
746+
def test_patch_refuses_pinned(self, tmp_path):
747+
with _skill_dir(tmp_path):
748+
_create_skill("my-skill", VALID_SKILL_CONTENT)
749+
with self._pin("my-skill"):
750+
result = _patch_skill("my-skill", "Do the thing.", "Do the new thing.")
751+
assert result["success"] is False
752+
assert "pinned" in result["error"].lower()
753+
assert "hermes curator unpin my-skill" in result["error"]
754+
content = (tmp_path / "my-skill" / "SKILL.md").read_text()
755+
assert "Do the thing." in content # unchanged
756+
757+
def test_patch_supporting_file_refuses_pinned(self, tmp_path):
758+
"""Pin covers supporting files too, not just SKILL.md."""
759+
with _skill_dir(tmp_path):
760+
_create_skill("my-skill", VALID_SKILL_CONTENT)
761+
_write_file("my-skill", "references/api.md", "original")
762+
with self._pin("my-skill"):
763+
result = _patch_skill(
764+
"my-skill", "original", "modified",
765+
file_path="references/api.md",
766+
)
767+
assert result["success"] is False
768+
assert "pinned" in result["error"].lower()
769+
assert (tmp_path / "my-skill" / "references" / "api.md").read_text() == "original"
770+
771+
def test_delete_refuses_pinned(self, tmp_path):
772+
with _skill_dir(tmp_path):
773+
_create_skill("my-skill", VALID_SKILL_CONTENT)
774+
with self._pin("my-skill"):
775+
result = _delete_skill("my-skill")
776+
assert result["success"] is False
777+
assert "pinned" in result["error"].lower()
778+
# Skill still exists
779+
assert (tmp_path / "my-skill" / "SKILL.md").exists()
780+
781+
def test_write_file_refuses_pinned(self, tmp_path):
782+
with _skill_dir(tmp_path):
783+
_create_skill("my-skill", VALID_SKILL_CONTENT)
784+
with self._pin("my-skill"):
785+
result = _write_file("my-skill", "references/api.md", "content")
786+
assert result["success"] is False
787+
assert "pinned" in result["error"].lower()
788+
assert not (tmp_path / "my-skill" / "references" / "api.md").exists()
789+
790+
def test_remove_file_refuses_pinned(self, tmp_path):
791+
with _skill_dir(tmp_path):
792+
_create_skill("my-skill", VALID_SKILL_CONTENT)
793+
_write_file("my-skill", "references/api.md", "content")
794+
with self._pin("my-skill"):
795+
result = _remove_file("my-skill", "references/api.md")
796+
assert result["success"] is False
797+
assert "pinned" in result["error"].lower()
798+
# File still there
799+
assert (tmp_path / "my-skill" / "references" / "api.md").exists()
800+
801+
def test_unpinned_skills_still_editable(self, tmp_path):
802+
"""Sanity check: the guard doesn't fire for unpinned skills.
803+
804+
Only the specifically-pinned skill is refused; a sibling skill must
805+
still be freely editable.
806+
"""
807+
with _skill_dir(tmp_path):
808+
_create_skill("pinned-one", VALID_SKILL_CONTENT)
809+
_create_skill("free-one", VALID_SKILL_CONTENT)
810+
with self._pin("pinned-one"):
811+
blocked = _edit_skill("pinned-one", VALID_SKILL_CONTENT_2)
812+
allowed = _edit_skill("free-one", VALID_SKILL_CONTENT_2)
813+
assert blocked["success"] is False
814+
assert allowed["success"] is True
815+
816+
def test_broken_sidecar_fails_open(self, tmp_path):
817+
"""If skill_usage.get_record raises, we allow the write through.
818+
819+
Rationale: a corrupted telemetry file shouldn't lock the agent out
820+
of skills it would otherwise be allowed to touch.
821+
"""
822+
with _skill_dir(tmp_path):
823+
_create_skill("my-skill", VALID_SKILL_CONTENT)
824+
with patch("tools.skill_usage.get_record",
825+
side_effect=RuntimeError("sidecar broken")):
826+
result = _edit_skill("my-skill", VALID_SKILL_CONTENT_2)
827+
assert result["success"] is True

tools/skill_manager_tool.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,33 @@ def _containing_skills_root(skill_path: Path) -> Path:
130130
return SKILLS_DIR
131131

132132

133+
def _pinned_guard(name: str) -> Optional[str]:
134+
"""Return a refusal message if *name* is pinned, else None.
135+
136+
Pinned skills are off-limits to the agent's skill_manage tool. The only
137+
way to modify one is for the user to unpin it via
138+
``hermes curator unpin <name>`` (or edit it directly by hand). This
139+
mirrors the curator's own pinned-skip behavior but extends the guard
140+
to tool-driven writes as well, giving users a hard fence against
141+
accidental agent edits.
142+
143+
Best-effort: if the sidecar is unreadable we let the write through
144+
rather than block on a broken telemetry file.
145+
"""
146+
try:
147+
from tools import skill_usage
148+
rec = skill_usage.get_record(name)
149+
if rec.get("pinned"):
150+
return (
151+
f"Skill '{name}' is pinned and cannot be modified by "
152+
f"skill_manage. Ask the user to run "
153+
f"`hermes curator unpin {name}` if they want the change."
154+
)
155+
except Exception:
156+
logger.debug("pinned-guard lookup failed for %s", name, exc_info=True)
157+
return None
158+
159+
133160
MAX_SKILL_CONTENT_CHARS = 100_000 # ~36k tokens at 2.75 chars/token
134161
MAX_SKILL_FILE_BYTES = 1_048_576 # 1 MiB per supporting file
135162

@@ -408,6 +435,10 @@ def _edit_skill(name: str, content: str) -> Dict[str, Any]:
408435
if not existing:
409436
return {"success": False, "error": f"Skill '{name}' not found. Use skills_list() to see available skills."}
410437

438+
pinned_err = _pinned_guard(name)
439+
if pinned_err:
440+
return {"success": False, "error": pinned_err}
441+
411442
skill_md = existing["path"] / "SKILL.md"
412443
# Back up original content for rollback
413444
original_content = skill_md.read_text(encoding="utf-8") if skill_md.exists() else None
@@ -448,6 +479,10 @@ def _patch_skill(
448479
if not existing:
449480
return {"success": False, "error": f"Skill '{name}' not found."}
450481

482+
pinned_err = _pinned_guard(name)
483+
if pinned_err:
484+
return {"success": False, "error": pinned_err}
485+
451486
skill_dir = existing["path"]
452487

453488
if file_path:
@@ -527,6 +562,10 @@ def _delete_skill(name: str) -> Dict[str, Any]:
527562
if not existing:
528563
return {"success": False, "error": f"Skill '{name}' not found."}
529564

565+
pinned_err = _pinned_guard(name)
566+
if pinned_err:
567+
return {"success": False, "error": pinned_err}
568+
530569
skill_dir = existing["path"]
531570
skills_root = _containing_skills_root(skill_dir)
532571
shutil.rmtree(skill_dir)
@@ -570,6 +609,10 @@ def _write_file(name: str, file_path: str, file_content: str) -> Dict[str, Any]:
570609
if not existing:
571610
return {"success": False, "error": f"Skill '{name}' not found. Create it first with action='create'."}
572611

612+
pinned_err = _pinned_guard(name)
613+
if pinned_err:
614+
return {"success": False, "error": pinned_err}
615+
573616
target, err = _resolve_skill_target(existing["path"], file_path)
574617
if err:
575618
return {"success": False, "error": err}
@@ -604,6 +647,10 @@ def _remove_file(name: str, file_path: str) -> Dict[str, Any]:
604647
if not existing:
605648
return {"success": False, "error": f"Skill '{name}' not found."}
606649

650+
pinned_err = _pinned_guard(name)
651+
if pinned_err:
652+
return {"success": False, "error": pinned_err}
653+
607654
skill_dir = existing["path"]
608655

609656
target, err = _resolve_skill_target(skill_dir, file_path)
@@ -736,7 +783,10 @@ def skill_manage(
736783
"After difficult/iterative tasks, offer to save as a skill. "
737784
"Skip for simple one-offs. Confirm with user before creating/deleting.\n\n"
738785
"Good skills: trigger conditions, numbered steps with exact commands, "
739-
"pitfalls section, verification steps. Use skill_view() to see format examples."
786+
"pitfalls section, verification steps. Use skill_view() to see format examples.\n\n"
787+
"Pinned skills are off-limits — all write actions refuse with a message "
788+
"pointing the user to `hermes curator unpin <name>`. Don't try to route "
789+
"around this by renaming or recreating."
740790
),
741791
"parameters": {
742792
"type": "object",

0 commit comments

Comments
 (0)