Files
hermes-agent/tools/skill_manager_guards.py

408 lines
16 KiB
Python

"""Write/delete guards for ``skill_manage`` (extracted from skill_manager_tool).
Every guard returns ``None`` when the operation may proceed, otherwise a
refusal (error dict or message). Origin-owned state (``_find_skill``,
``_skills_dir``) is reached lazily through ``tools.skill_manager_tool`` so
test patches on that module keep working.
"""
import contextvars as _ctxvars
import logging
import threading
from pathlib import Path
from typing import Any, Dict, Optional
logger = logging.getLogger("tools.skill_manager_tool")
_ERR_KEY = "error"
def _refusal(message: str, **extra: Any) -> Dict[str, Any]:
out: Dict[str, Any] = {"success": False, _ERR_KEY: message}
out.update(extra)
return out
def _is_background_review() -> bool:
"""True inside the autonomous curator review fork; False on any lookup failure."""
try:
from tools.skill_provenance import is_background_review
return bool(is_background_review())
except Exception:
return False
def _resolved_str(path: Path) -> str:
try:
return str(path.resolve())
except Exception:
return str(path)
# ---------------------------------------------------------------------------
# Background-review read marks
# ---------------------------------------------------------------------------
class _BackgroundReviewReadMarks:
"""Read marks shared by copied tool contexts within one review run."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._paths: set[str] = set()
def add(self, path: str) -> None:
with self._lock:
self._paths.add(path)
def contains(self, path: str) -> bool:
with self._lock:
return path in self._paths
_background_review_read_paths: (
"_ctxvars.ContextVar[Optional[_BackgroundReviewReadMarks]]"
) = _ctxvars.ContextVar("background_review_read_paths", default=None)
def mark_background_review_skill_read(path: Path) -> None:
"""Record that the active background-review fork has read a skill file.
The review fork may evolve skills but must not patch content it only
inferred from the transcript: skill_view calls this after returning file
content, and the write guards below require the target to be marked.
"""
if not _is_background_review():
return
marks = _background_review_read_paths.get()
if marks is None:
marks = _BackgroundReviewReadMarks()
_background_review_read_paths.set(marks)
marks.add(_resolved_str(path))
def _background_review_has_read(path: Path) -> bool:
marks = _background_review_read_paths.get()
return marks is not None and marks.contains(_resolved_str(path))
def _reset_background_review_read_marks() -> None:
"""Start a fresh, isolated read set for the current review context."""
_background_review_read_paths.set(_BackgroundReviewReadMarks())
# ---------------------------------------------------------------------------
# Delete-target safety
# ---------------------------------------------------------------------------
def _containing_skills_root(skill_path: Path) -> Path:
"""Skills root (local or external_dirs entry) containing ``skill_path``;
falls back to the local skills dir when no root matches."""
from agent.skill_utils import get_all_skills_dirs
from tools import skill_manager_tool as _smt
try:
resolved = skill_path.resolve()
except OSError:
resolved = skill_path
for root in get_all_skills_dirs():
try:
resolved.relative_to(root.resolve())
return root
except (ValueError, OSError):
continue
return _smt._skills_dir()
def _is_path_redirect(path: Path) -> bool:
"""True when ``path`` is a symlink or (Windows 3.12+) a junction — either
lets a poisoned tree redirect ``shutil.rmtree`` outside the skills root."""
try:
return path.is_symlink() or (hasattr(path, "is_junction") and path.is_junction())
except OSError:
return False
def _validate_delete_target(skill_dir: Path) -> Optional[str]:
"""Last-line guard before ``shutil.rmtree(skill_dir)``.
``_find_skill`` already restricts the dir to a real SKILL.md parent, but
even a poisoned tree must never recursively delete (1) a path outside every
known skills root, (2) a skills root itself, or (3) a symlink/junction
(rmtree would follow it). Returns a refusal string or ``None``.
"""
from agent.skill_utils import get_all_skills_dirs
if _is_path_redirect(skill_dir):
return (
f"Refusing to delete '{skill_dir}': the skill directory is a "
f"symlink/junction. Remove the link target manually if intended."
)
try:
resolved = skill_dir.resolve()
except OSError as exc:
return f"Refusing to delete '{skill_dir}': could not resolve path ({exc})."
for root in get_all_skills_dirs():
try:
root = root.resolve()
except OSError:
continue
if resolved == root:
return (
f"Refusing to delete '{skill_dir}': resolves to the skills root "
f"itself, which would remove every installed skill."
)
try:
rel = resolved.relative_to(root)
except ValueError:
continue
if rel.parts:
return None
return (
f"Refusing to delete '{skill_dir}': path does not resolve inside any "
f"known skills root."
)
# ---------------------------------------------------------------------------
# Ownership / provenance guards
# ---------------------------------------------------------------------------
def _pinned_guard(name: str) -> Optional[str]:
"""Refusal message if *name* is pinned or essential, else None.
Pin only guards against **deletion** (curator auto-archive and
``skill_manage(delete)``); patches/edits stay allowed. Essential skills
(``ESSENTIAL_SKILLS``) are permanently pinned because the system prompt
references them. Best-effort: an unreadable sidecar lets the delete through.
"""
try:
from agent.skill_utils import ESSENTIAL_SKILLS
if name in ESSENTIAL_SKILLS:
return (
f"Skill '{name}' is essential to Hermes (the agent's own "
f"operating manual referenced by the system prompt) and "
f"cannot be deleted. Patches and edits are still allowed."
)
except Exception:
logger.debug("essential-guard lookup failed for %s", name, exc_info=True)
try:
from tools import skill_usage
if skill_usage.get_record(name).get("pinned"):
return (
f"Skill '{name}' is pinned and cannot be deleted by "
f"skill_manage. Ask the user to run "
f"`hermes curator unpin {name}` if they want to delete it. "
f"Patches and edits are allowed on pinned skills; only "
f"deletion is blocked."
)
except Exception:
logger.debug("pinned-guard lookup failed for %s", name, exc_info=True)
return None
def _background_review_write_guard(
name: str,
skill_dir: Path,
action: str,
) -> Optional[Dict[str, Any]]:
"""Refuse autonomous curator writes to anything but curator-owned sediment.
Foreground agents may edit external/bundled/hub skills at the user's
direction; the background review fork has no user in the loop, so it is
also blocked on pinned skills (stricter than ``_pinned_guard``).
"""
if not _is_background_review():
return None
try:
from tools import skill_usage
if skill_usage.get_record(name).get("pinned"):
return _refusal(
f"Refusing background curator {action} for pinned skill "
f"'{name}': pinned skills are off-limits to autonomous "
"maintenance. Ask the user to run "
f"`hermes curator unpin {name}` if they want it changed."
)
except Exception:
logger.debug("pinned skill guard lookup failed for %s", name, exc_info=True)
try:
from agent.skill_utils import is_external_skill_path
if is_external_skill_path(skill_dir):
return _refusal(
f"Refusing background curator {action} for skill '{name}': "
"the skill lives in skills.external_dirs, which are "
"externally owned and read-only to autonomous curation."
)
except Exception:
logger.debug("external skill guard lookup failed for %s", name, exc_info=True)
try:
from tools import skill_usage
for predicate, label in (
(skill_usage.is_protected_builtin, "protected built-in"),
(skill_usage.is_hub_installed, "hub-installed"),
(skill_usage.is_bundled, "bundled"),
):
if predicate(name):
return _refusal(
f"Refusing background curator {action} for {label} "
f"skill '{name}'."
)
# Not curator-managed (no `created_by: "agent"` marker) => user-owned.
# A MISSING record and an explicit `created_by: null` must resolve
# IDENTICALLY: keying on record presence made the policy depend on the
# guard's own side effect (the first successful write created a null
# record and the next identical write was refused). Fail closed for
# both; `hermes curator adopt <name>` is the supported way in.
usage_rec = skill_usage.load_usage().get(name)
if not skill_usage._is_curator_managed_record(usage_rec):
if isinstance(usage_rec, dict):
_detail = f"created_by={usage_rec.get('created_by')!r}"
else:
_detail = "no usage record"
return _refusal(
f"Refusing background curator {action} for skill "
f"'{name}': the skill is not curator-managed ({_detail}). "
"User-owned skills are off-limits to autonomous curation. "
f"Run `hermes curator adopt {name}` to opt it in."
)
except Exception:
logger.warning("owned skill guard lookup failed for %s", name, exc_info=True)
return _refusal(
f"Refusing background curator {action} for skill '{name}': "
"agent ownership could not be verified because the provenance "
"record is unavailable or unreadable."
)
return None
def _background_review_read_before_write_guard(
name: str,
target: Path,
action: str,
file_label: str,
) -> Optional[Dict[str, Any]]:
"""Require review forks to load the exact target before mutating it."""
if not _is_background_review() or _background_review_has_read(target):
return None
return _refusal(
f"Refusing background curator {action} for skill '{name}': "
f"the current {file_label} content has not been loaded in this "
"review turn. Call skill_view(name) for SKILL.md, or "
"skill_view(name, file_path=...) for a supporting file, then "
"retry the write using the content just returned.",
_read_before_write_required=True,
)
def _background_review_preflight(action: str, name: str) -> Optional[Dict[str, Any]]:
if action not in {"edit", "patch", "delete", "write_file", "remove_file"}:
return None
from tools import skill_manager_tool as _smt
existing = _smt._find_skill(name)
if not existing:
return None
return _background_review_write_guard(name, existing["path"], action)
def _curator_consolidation_delete_guard(
name: str, absorbed_into: Optional[str]
) -> Optional[Dict[str, Any]]:
"""Fail closed on unverified deletes during the curator consolidation pass.
The review fork's only legitimate ``skill_manage(delete)`` is a verified
consolidation declared via ``absorbed_into=<umbrella>`` (existence is
validated in ``_delete_skill``). A bare delete (``None`` or ``""``) is the
fail-open behavior that once archived whole clusters of active skills; the
deterministic inactivity prune archives via ``skill_usage.archive_skill``
without ever calling ``skill_manage``, so a bare prune here can only be the
LLM pass pruning without evidence. Refuse it; keep the skill active.
"""
if not _is_background_review():
return None
if isinstance(absorbed_into, str) and absorbed_into.strip():
return None
return _refusal(
f"Refusing background curator delete of skill '{name}': the "
"consolidation pass may only archive a skill it has absorbed into "
"an umbrella. Pass absorbed_into=<umbrella> (the umbrella must "
"already exist) to record a verified consolidation. Pruning a "
"skill with no forwarding target is not permitted here — the "
"deterministic inactivity prune handles staleness archival "
"separately. Keeping '{name}' active.".format(name=name),
_fail_closed=True,
)
# ---------------------------------------------------------------------------
# Org-mirror handling
# ---------------------------------------------------------------------------
def _maybe_auto_propose_org_edit(name: str, skill_path: Path) -> Optional[str]:
"""Submit an org-skill edit upstream when `sync.org_auto_propose` is on.
Returns a short note for the tool result, or None when nothing happened.
Never raises: the edit is already saved locally and can be proposed later.
"""
from tools import skill_manager_tool as _smt
try:
from agent.skill_utils import is_org_mirror_path
from tools import skills_sync_client as ssc
if not is_org_mirror_path(skill_path, _smt._skills_dir()):
return None
if not ssc.sync_org_auto_propose():
return (
f"This skill is shared by your organisation. Your edit is "
f"saved locally and will not be overwritten by org updates. "
f"Run `hermes sync propose {name}` to share it back."
)
result = ssc.propose_skill(name)
if result.get("proposal_pending"):
return (
f"Auto-proposed to your organisation as proposal "
f"#{result.get('proposal_id')} (pending admin review)."
)
return "Auto-proposed to your organisation (merged into the shared set)."
except Exception as e:
logger.debug("auto-propose skipped for %s: %s", name, e)
return (
f"Edit saved locally. Could not submit it to your organisation "
f"right now — run `hermes sync propose {name}` to retry."
)
def _org_mirror_write_guard(name: str, skill_path: Path, action: str) -> Optional[Dict[str, Any]]:
"""Org-shared skills are EDITABLE IN PLACE — this only blocks deletion.
Refusing every write to `_org/` froze shared skills while personal ones
kept improving (agents don't fork mid-task). Edits now land in the mirror,
survive the next org pull (baseline sidecar in skills_sync_client), and
reach the org via `hermes sync propose` or `sync.org_auto_propose`.
Deletion stays refused: the mirror is a view of org HEAD, so a local
delete just comes back, and removing for everyone is an admin action.
"""
if action not in {"delete", "remove_file"}:
return None
from tools import skill_manager_tool as _smt
try:
from agent.skill_utils import is_org_mirror_path
if is_org_mirror_path(skill_path, _smt._skills_dir()):
return _refusal(
f"Cannot {action} '{name}' locally: it is shared by your "
"organisation, so a local delete would just come back on "
"the next sync. Ask an org admin to remove it for "
"everyone. (Editing it IS allowed — your changes are kept "
"and can be proposed back with `hermes sync propose "
f"{name}`.)"
)
except Exception:
logger.debug("org mirror guard lookup failed for %s", name, exc_info=True)
return None