fix(skills): pin protects against deletion only, not edits (#20220)

Previously, pinning a skill blocked every skill_manage write action
(edit, patch, delete, write_file, remove_file). The 'hard fence'
design conflated two concerns:

  1. Pin as deletion protection — don't let the curator archive
     or the agent delete a stable skill.
  2. Pin as content freeze — don't let the agent rewrite it mid-conversation.

In practice (1) is what users pin for: they want a skill to survive
curator passes. (2) created friction — agents finding a new pitfall
in a pinned skill had to ask the user to unpin, then the agent
patches, then the user re-pins. The dance discouraged skill
maintenance and pinned skills went stale.

This narrows the _pinned_guard to skill_manage(action='delete') only.
Patches, edits, and supporting-file writes go through on pinned
skills so the agent can keep improving them. The curator's own
pinned-skip behavior (agent/curator.py:271 for auto-archive,
line 349 for the LLM review prompt) is unchanged — curator still
never touches pinned skills.

Changes:
- tools/skill_manager_tool.py: remove _pinned_guard calls from
  _edit_skill, _patch_skill, _write_file, _remove_file; keep on
  _delete_skill. Updated _pinned_guard docstring and error message.
- tools/skill_manager_tool.py: updated skill_manage model-facing tool
  description to reflect the new semantic.
- website/docs/user-guide/features/curator.md: updated pinning
  section.
- tests/tools/test_skill_manager_tool.py: flipped refuses-pinned
  tests for edit/patch/write_file/remove_file into allowed-when-pinned;
  kept test_delete_refuses_pinned (strengthened assertion to check the
  'cannot be deleted' wording).

Closes #18354
This commit is contained in:
Teknium 2026-05-05 05:43:10 -07:00 committed by GitHub
parent fe8560fc12
commit b10e38e392
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 48 additions and 66 deletions

View file

@ -838,12 +838,13 @@ class TestExternalSkillMutations:
# ---------------------------------------------------------------------------
# Pinned-skill guard — skill_manage refuses all writes to pinned skills.
# The user unpins via `hermes curator unpin <name>`.
# Pinned-skill guard — skill_manage refuses only `delete` on pinned skills.
# Patches and edits go through so pinned skills can still evolve as pitfalls
# come up. The user unpins via `hermes curator unpin <name>` to delete.
# ---------------------------------------------------------------------------
class TestPinnedGuard:
"""Every mutation action must refuse when the skill is pinned."""
"""Delete is refused on pinned skills; patch/edit/write_file/remove_file are allowed."""
@staticmethod
def _pin(name: str):
@ -852,31 +853,28 @@ class TestPinnedGuard:
return {"pinned": True} if skill_name == _name else {"pinned": False}
return patch("tools.skill_usage.get_record", side_effect=_fake_get_record)
def test_edit_refuses_pinned(self, tmp_path):
def test_edit_allowed_when_pinned(self, tmp_path):
"""Pin does NOT block edit — agent can still improve pinned skills."""
with _skill_dir(tmp_path):
_create_skill("my-skill", VALID_SKILL_CONTENT)
with self._pin("my-skill"):
result = _edit_skill("my-skill", VALID_SKILL_CONTENT_2)
assert result["success"] is False
assert "pinned" in result["error"].lower()
assert "hermes curator unpin my-skill" in result["error"]
# Original content preserved
assert result["success"] is True, result
# Content updated
content = (tmp_path / "my-skill" / "SKILL.md").read_text()
assert "A test skill" in content
assert "A test skill" not in content
def test_patch_refuses_pinned(self, tmp_path):
def test_patch_allowed_when_pinned(self, tmp_path):
with _skill_dir(tmp_path):
_create_skill("my-skill", VALID_SKILL_CONTENT)
with self._pin("my-skill"):
result = _patch_skill("my-skill", "Do the thing.", "Do the new thing.")
assert result["success"] is False
assert "pinned" in result["error"].lower()
assert "hermes curator unpin my-skill" in result["error"]
assert result["success"] is True, result
content = (tmp_path / "my-skill" / "SKILL.md").read_text()
assert "Do the thing." in content # unchanged
assert "Do the new thing." in content
def test_patch_supporting_file_refuses_pinned(self, tmp_path):
"""Pin covers supporting files too, not just SKILL.md."""
def test_patch_supporting_file_allowed_when_pinned(self, tmp_path):
"""Supporting-file patches also go through on pinned skills."""
with _skill_dir(tmp_path):
_create_skill("my-skill", VALID_SKILL_CONTENT)
_write_file("my-skill", "references/api.md", "original")
@ -885,57 +883,56 @@ class TestPinnedGuard:
"my-skill", "original", "modified",
file_path="references/api.md",
)
assert result["success"] is False
assert "pinned" in result["error"].lower()
assert (tmp_path / "my-skill" / "references" / "api.md").read_text() == "original"
assert result["success"] is True, result
assert (tmp_path / "my-skill" / "references" / "api.md").read_text() == "modified"
def test_delete_refuses_pinned(self, tmp_path):
"""Delete is the one action pin still blocks — it's the irrecoverable one."""
with _skill_dir(tmp_path):
_create_skill("my-skill", VALID_SKILL_CONTENT)
with self._pin("my-skill"):
result = _delete_skill("my-skill")
assert result["success"] is False
assert "pinned" in result["error"].lower()
assert "cannot be deleted" in result["error"]
assert "hermes curator unpin my-skill" in result["error"]
# Skill still exists
assert (tmp_path / "my-skill" / "SKILL.md").exists()
def test_write_file_refuses_pinned(self, tmp_path):
def test_write_file_allowed_when_pinned(self, tmp_path):
with _skill_dir(tmp_path):
_create_skill("my-skill", VALID_SKILL_CONTENT)
with self._pin("my-skill"):
result = _write_file("my-skill", "references/api.md", "content")
assert result["success"] is False
assert "pinned" in result["error"].lower()
assert not (tmp_path / "my-skill" / "references" / "api.md").exists()
assert result["success"] is True, result
assert (tmp_path / "my-skill" / "references" / "api.md").read_text() == "content"
def test_remove_file_refuses_pinned(self, tmp_path):
def test_remove_file_allowed_when_pinned(self, tmp_path):
with _skill_dir(tmp_path):
_create_skill("my-skill", VALID_SKILL_CONTENT)
_write_file("my-skill", "references/api.md", "content")
with self._pin("my-skill"):
result = _remove_file("my-skill", "references/api.md")
assert result["success"] is False
assert "pinned" in result["error"].lower()
# File still there
assert (tmp_path / "my-skill" / "references" / "api.md").exists()
assert result["success"] is True, result
assert not (tmp_path / "my-skill" / "references" / "api.md").exists()
def test_unpinned_skills_still_editable(self, tmp_path):
"""Sanity check: the guard doesn't fire for unpinned skills.
"""Sanity check: the guard doesn't fire for unpinned skills on delete.
Only the specifically-pinned skill is refused; a sibling skill must
still be freely editable.
Only the specifically-pinned skill is refused from delete; a sibling
skill must still be freely deletable.
"""
with _skill_dir(tmp_path):
_create_skill("pinned-one", VALID_SKILL_CONTENT)
_create_skill("free-one", VALID_SKILL_CONTENT)
with self._pin("pinned-one"):
blocked = _edit_skill("pinned-one", VALID_SKILL_CONTENT_2)
allowed = _edit_skill("free-one", VALID_SKILL_CONTENT_2)
blocked = _delete_skill("pinned-one")
allowed = _delete_skill("free-one")
assert blocked["success"] is False
assert allowed["success"] is True
def test_broken_sidecar_fails_open(self, tmp_path):
"""If skill_usage.get_record raises, we allow the write through.
"""If skill_usage.get_record raises, we allow delete through.
Rationale: a corrupted telemetry file shouldn't lock the agent out
of skills it would otherwise be allowed to touch.
@ -944,5 +941,5 @@ class TestPinnedGuard:
_create_skill("my-skill", VALID_SKILL_CONTENT)
with patch("tools.skill_usage.get_record",
side_effect=RuntimeError("sidecar broken")):
result = _edit_skill("my-skill", VALID_SKILL_CONTENT_2)
result = _delete_skill("my-skill")
assert result["success"] is True