feat(skills): refuse skill_manage writes on pinned skills (#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.
This commit is contained in:
Teknium
2026-04-29 10:28:25 -07:00
committed by GitHub
parent b01656d116
commit c61b2e0af7
2 changed files with 162 additions and 1 deletions

View File

@@ -131,6 +131,33 @@ def _containing_skills_root(skill_path: Path) -> Path:
return SKILLS_DIR
def _pinned_guard(name: str) -> Optional[str]:
"""Return a refusal message if *name* is pinned, else None.
Pinned skills are off-limits to the agent's skill_manage tool. The only
way to modify one is for the user to unpin it via
``hermes curator unpin <name>`` (or edit it directly by hand). This
mirrors the curator's own pinned-skip behavior but extends the guard
to tool-driven writes as well, giving users a hard fence against
accidental agent edits.
Best-effort: if the sidecar is unreadable we let the write through
rather than block on a broken telemetry file.
"""
try:
from tools import skill_usage
rec = skill_usage.get_record(name)
if rec.get("pinned"):
return (
f"Skill '{name}' is pinned and cannot be modified by "
f"skill_manage. Ask the user to run "
f"`hermes curator unpin {name}` if they want the change."
)
except Exception:
logger.debug("pinned-guard lookup failed for %s", name, exc_info=True)
return None
MAX_SKILL_CONTENT_CHARS = 100_000 # ~36k tokens at 2.75 chars/token
MAX_SKILL_FILE_BYTES = 1_048_576 # 1 MiB per supporting file
@@ -409,6 +436,10 @@ def _edit_skill(name: str, content: str) -> Dict[str, Any]:
if not existing:
return {"success": False, "error": f"Skill '{name}' not found. Use skills_list() to see available skills."}
pinned_err = _pinned_guard(name)
if pinned_err:
return {"success": False, "error": pinned_err}
skill_md = existing["path"] / "SKILL.md"
# Back up original content for rollback
original_content = skill_md.read_text(encoding="utf-8") if skill_md.exists() else None
@@ -449,6 +480,10 @@ def _patch_skill(
if not existing:
return {"success": False, "error": f"Skill '{name}' not found."}
pinned_err = _pinned_guard(name)
if pinned_err:
return {"success": False, "error": pinned_err}
skill_dir = existing["path"]
if file_path:
@@ -528,6 +563,10 @@ def _delete_skill(name: str) -> Dict[str, Any]:
if not existing:
return {"success": False, "error": f"Skill '{name}' not found."}
pinned_err = _pinned_guard(name)
if pinned_err:
return {"success": False, "error": pinned_err}
skill_dir = existing["path"]
skills_root = _containing_skills_root(skill_dir)
shutil.rmtree(skill_dir)
@@ -571,6 +610,10 @@ def _write_file(name: str, file_path: str, file_content: str) -> Dict[str, Any]:
if not existing:
return {"success": False, "error": f"Skill '{name}' not found. Create it first with action='create'."}
pinned_err = _pinned_guard(name)
if pinned_err:
return {"success": False, "error": pinned_err}
target, err = _resolve_skill_target(existing["path"], file_path)
if err:
return {"success": False, "error": err}
@@ -605,6 +648,10 @@ def _remove_file(name: str, file_path: str) -> Dict[str, Any]:
if not existing:
return {"success": False, "error": f"Skill '{name}' not found."}
pinned_err = _pinned_guard(name)
if pinned_err:
return {"success": False, "error": pinned_err}
skill_dir = existing["path"]
target, err = _resolve_skill_target(skill_dir, file_path)
@@ -737,7 +784,10 @@ SKILL_MANAGE_SCHEMA = {
"After difficult/iterative tasks, offer to save as a skill. "
"Skip for simple one-offs. Confirm with user before creating/deleting.\n\n"
"Good skills: trigger conditions, numbered steps with exact commands, "
"pitfalls section, verification steps. Use skill_view() to see format examples."
"pitfalls section, verification steps. Use skill_view() to see format examples.\n\n"
"Pinned skills are off-limits — all write actions refuse with a message "
"pointing the user to `hermes curator unpin <name>`. Don't try to route "
"around this by renaming or recreating."
),
"parameters": {
"type": "object",