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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user