diff --git a/agent/curator.py b/agent/curator.py index ab0adddae1..9668e76a46 100644 --- a/agent/curator.py +++ b/agent/curator.py @@ -369,7 +369,22 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int continue if anchor <= archive_cutoff and current != _u.STATE_ARCHIVED: - ok, _msg = _u.archive_skill(name) + # Tag the ledger entry with the curator actor: this archive is an + # autonomous curator transition, not a foreground agent/user call. + try: + from tools.skill_ledger import reset_ledger_actor, set_ledger_actor + _tok = set_ledger_actor("curator") + except Exception: + _tok = None + reset_ledger_actor = None # type: ignore[assignment] + try: + ok, _msg = _u.archive_skill(name) + finally: + if _tok is not None and reset_ledger_actor is not None: + try: + reset_ledger_actor(_tok) + except Exception: + pass if ok: counts["archived"] += 1 elif anchor <= stale_cutoff and current == _u.STATE_ACTIVE: diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 6166718a87..4ea20ad87e 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -2001,6 +2001,14 @@ DEFAULT_CONFIG = { # never crammed into a chat bubble), apply with # /skills approve or drop with /skills reject . "write_approval": False, + # Per-mutation audit ledger (tracker #79686 P3). Every skill mutation + # — curator, agent, or user — appends one JSONL entry to + # ~/.hermes/skills/.curator_ledger.jsonl with before/after file + # hashes; file contents are stored content-addressed (deduped) under + # ~/.hermes/.curator_backups/blobs/. Enables `hermes curator ledger` + # and single-mutation `hermes curator rollback `. + # Telemetry, never a gate: ledger failures cannot block a mutation. + "ledger": True, }, # Curator — background skill maintenance. @@ -2043,6 +2051,11 @@ DEFAULT_CONFIG = { # genuine non-use (never a mass-prune on the first run). Set to false # to keep all bundled built-ins permanently. "prune_builtins": True, + # TTL purge of skills/.archive/. 0 (default) = never purge — archived + # skills are kept forever. When > 0, `hermes curator purge` deletes + # archived skills older than this many days (explicit command only, + # never automatic; every purge is recorded in the audit ledger). + "archive_ttl_days": 0, # Pre-run backup: before every real curator pass (dry-run is # skipped), snapshot ~/.hermes/skills/ into # ~/.hermes/skills/.curator_backups//skills.tar.gz so the diff --git a/hermes_cli/curator.py b/hermes_cli/curator.py index 26d419ffef..8f2cc7a11b 100644 --- a/hermes_cli/curator.py +++ b/hermes_cli/curator.py @@ -398,8 +398,12 @@ def _cmd_adopt(args) -> int: def _cmd_restore(args) -> int: - from tools import skill_usage - ok, msg = skill_usage.restore_skill(args.skill) + from tools import skill_ledger, skill_usage + tok = skill_ledger.set_ledger_actor("user") + try: + ok, msg = skill_usage.restore_skill(args.skill) + finally: + skill_ledger.reset_ledger_actor(tok) print(f"curator: {msg}") return 0 if ok else 1 @@ -410,14 +414,18 @@ def _cmd_archive(args) -> int: The auto-curator archives stale skills on its own schedule; this verb is for the user who wants to archive *now* without waiting for a run. """ - from tools import skill_usage + from tools import skill_ledger, skill_usage if skill_usage.get_record(args.skill).get("pinned"): print( f"curator: '{args.skill}' is pinned — unpin first with " f"`hermes curator unpin {args.skill}`" ) return 1 - ok, msg = skill_usage.archive_skill(args.skill) + tok = skill_ledger.set_ledger_actor("user") + try: + ok, msg = skill_usage.archive_skill(args.skill) + finally: + skill_ledger.reset_ledger_actor(tok) print(f"curator: {msg}") return 0 if ok else 1 @@ -528,16 +536,162 @@ def _cmd_backup(args) -> int: return 0 -def _cmd_rollback(args) -> int: - """Restore the skills tree from a snapshot. Defaults to newest. +def _cmd_ledger(args) -> int: + """List per-mutation audit ledger entries (newest first).""" + from tools import skill_ledger - ``--list`` prints available snapshots and exits. ``--id `` picks - a specific one. Without ``-y``, prompts for confirmation. A safety - snapshot of the current tree is always taken first, so rollbacks are - themselves undoable. + rows = skill_ledger.list_entries( + skill=getattr(args, "skill", None), + limit=getattr(args, "limit", None) or 20, + ) + if not rows: + print("curator: ledger is empty (or skills.ledger is disabled).") + return 0 + print(f"{'id':<14} {'when':<12} {'actor':<8} {'action':<12} skill") + for r in rows: + evidence = r.get("evidence") or {} + extra = "" + if evidence.get("absorbed_into"): + extra = f" → absorbed into '{evidence['absorbed_into']}'" + elif evidence.get("rollback_target"): + extra = f" → rollback of {evidence['rollback_target']}" + print( + f"{r.get('id', '?'):<14} {_fmt_ts(r.get('ts')):<12} " + f"{r.get('actor', '?'):<8} {r.get('action', '?'):<12} " + f"{r.get('skill', '?')}{extra}" + ) + print( + "\nRoll back a single mutation with `hermes curator rollback `; " + "whole-tree snapshots remain available via `hermes curator rollback --list`." + ) + return 0 + + +def _cmd_purge(args) -> int: + """Delete archived skills older than curator.archive_ttl_days. + + Explicit command only — never runs automatically. Respects the ledger: + each purged skill is captured (before-blobs) and recorded as a 'purge' + entry, so even a purge is auditable and blob-recoverable. + """ + from hermes_cli.config import cfg_get, load_config + from tools import skill_ledger + from tools.skill_usage import _archive_dir + + ttl_days = getattr(args, "days", None) + if ttl_days is None: + ttl_days = int(cfg_get(load_config(), "curator", "archive_ttl_days", default=0) or 0) + if ttl_days <= 0: + print( + "curator: purge disabled (curator.archive_ttl_days is 0). Set the " + "config key or pass --days N to purge archives older than N days." + ) + return 1 + + archive_root = _archive_dir() + if not archive_root.exists(): + print("curator: no archive directory — nothing to purge.") + return 0 + + import shutil + import time + + cutoff = time.time() - ttl_days * 86400 + candidates = [ + p for p in archive_root.iterdir() + if p.is_dir() and p.stat().st_mtime < cutoff + ] + if not candidates: + print(f"curator: no archived skills older than {ttl_days}d.") + return 0 + + print(f"Archived skills older than {ttl_days}d:") + for p in sorted(candidates): + print(f" {p.name}") + if getattr(args, "dry_run", False): + print("(dry run — nothing deleted)") + return 0 + if not getattr(args, "yes", False): + try: + ans = input(f"Permanently delete {len(candidates)} archived skill(s)? [y/N] ").strip().lower() + except (EOFError, KeyboardInterrupt): + print("\ncancelled") + return 1 + if ans not in {"y", "yes"}: + print("cancelled") + return 1 + + purged = 0 + for p in sorted(candidates): + before = skill_ledger.capture_before(p) + try: + shutil.rmtree(p) + except OSError as e: + print(f"curator: failed to purge {p.name}: {e}") + continue + skill_ledger.append_entry( + "purge", + p.name, + before=before or [], + after=[], + actor="user", + evidence={"ttl_days": ttl_days}, + ) + purged += 1 + print(f"curator: purged {purged} archived skill(s). Ledger entries recorded.") + return 0 + + +def _cmd_rollback(args) -> int: + """Restore the skills tree from a snapshot, or a single mutation from + the audit ledger. + + With a positional ``entry_id``, restores exactly the files touched by + that one ledger entry (from content-addressed blobs), taking a + pre-rollback safety ledger entry first — and failing closed when that + safety capture fails. Without it, behaves as before: whole-tree tarball + restore. ``--list`` prints available snapshots and exits. ``--id + `` picks a specific snapshot. Without ``-y``, prompts for + confirmation. A safety snapshot of the current tree is always taken + first, so rollbacks are themselves undoable. """ from agent import curator_backup + entry_id = getattr(args, "entry_id", None) + if entry_id: + from tools import skill_ledger + + entry = skill_ledger.get_entry(entry_id) + if entry is None: + print( + f"curator: no ledger entry '{entry_id}'. " + "See `hermes curator ledger` for entry ids, or use " + "`--id ` for whole-tree snapshot rollback." + ) + return 1 + print(f"Rollback target: ledger entry {entry_id}") + print(f" action: {entry.get('action', '?')}") + print(f" skill: {entry.get('skill', '?')}") + print(f" actor: {entry.get('actor', '?')}") + print(f" when: {entry.get('ts', '?')}") + touched = {i.get("path") for i in (entry.get("before") or []) + (entry.get("after") or [])} + print(f" files: {len(touched)}") + if not getattr(args, "yes", False): + try: + ans = input("Restore this mutation's before-state? [y/N] ").strip().lower() + except (EOFError, KeyboardInterrupt): + print("\ncancelled") + return 1 + if ans not in {"y", "yes"}: + print("cancelled") + return 1 + ok, msg = skill_ledger.rollback_entry(entry_id) + if ok: + print(f"curator: {msg}") + return 0 + print(f"curator: rollback failed — {msg}") + return 1 + if getattr(args, "list", False): print(curator_backup.summarize_backups()) return 0 @@ -816,8 +970,13 @@ def register_cli(parent: argparse.ArgumentParser) -> None: p_rollback = subs.add_parser( "rollback", - help="Restore ~/.hermes/skills/ from a curator snapshot " - "(defaults to the newest)", + help="Restore ~/.hermes/skills/ from a curator snapshot, or a single " + "mutation by ledger entry id (see `hermes curator ledger`)", + ) + p_rollback.add_argument( + "entry_id", nargs="?", default=None, + help="Ledger entry id for single-mutation rollback (from " + "`hermes curator ledger`). Omit for whole-tree snapshot rollback.", ) p_rollback.add_argument( "--list", action="store_true", @@ -833,6 +992,40 @@ def register_cli(parent: argparse.ArgumentParser) -> None: ) p_rollback.set_defaults(func=_cmd_rollback) + p_ledger = subs.add_parser( + "ledger", + help="List the per-mutation skill audit ledger (all actors: " + "curator/agent/user)", + ) + p_ledger.add_argument( + "--skill", default=None, + help="Only show entries for this skill", + ) + p_ledger.add_argument( + "--limit", type=int, default=20, + help="Max entries to show (default: 20)", + ) + p_ledger.set_defaults(func=_cmd_ledger) + + p_purge = subs.add_parser( + "purge", + help="Delete archived skills older than curator.archive_ttl_days " + "(explicit only — never automatic; recorded in the ledger)", + ) + p_purge.add_argument( + "--days", type=int, default=None, + help="Override curator.archive_ttl_days for this invocation", + ) + p_purge.add_argument( + "--dry-run", dest="dry_run", action="store_true", + help="Show what would be purged without deleting", + ) + p_purge.add_argument( + "-y", "--yes", action="store_true", + help="Skip the confirmation prompt", + ) + p_purge.set_defaults(func=_cmd_purge) + def cli_main(argv=None) -> int: """Standalone entry (also usable by hermes_cli.main fallthrough).""" diff --git a/tests/tools/test_skill_ledger.py b/tests/tools/test_skill_ledger.py new file mode 100644 index 0000000000..0fd748403b --- /dev/null +++ b/tests/tools/test_skill_ledger.py @@ -0,0 +1,367 @@ +"""Tests for tools/skill_ledger.py — per-mutation audit ledger + rollback. + +Covers tracker #79686 P3: ledger entries on patch/edit/delete/archive, blob +dedupe, single-entry rollback (incl. fail-closed safety capture), actor +tagging, and the skills.ledger config gate. + +The first four tests are adapted from PR #50261 by @yu-xin-c (autonomous +skill history), reshaped for the all-actor JSONL ledger design. +""" + +import json +from pathlib import Path + +import pytest + + +VALID_SKILL_CONTENT = """--- +name: my-skill +description: test skill +--- + +# My Skill + +Original body. +""" + + +@pytest.fixture +def ledger_env(tmp_path, monkeypatch): + """Isolated HERMES_HOME + skills dir for skill_manage and the ledger.""" + from agent import skill_utils + from tools import skill_ledger, skill_manager_tool, skill_usage + + home = tmp_path / "home" + skills_dir = home / "skills" + skills_dir.mkdir(parents=True) + + monkeypatch.setattr(skill_ledger, "get_hermes_home", lambda: home) + monkeypatch.setattr(skill_usage, "get_hermes_home", lambda: home) + monkeypatch.setattr(skill_manager_tool, "SKILLS_DIR", skills_dir) + monkeypatch.setattr(skill_utils, "get_all_skills_dirs", lambda: [skills_dir]) + return {"home": home, "skills": skills_dir} + + +def _create(name="my-skill", content=VALID_SKILL_CONTENT): + from tools.skill_manager_tool import skill_manage + + return json.loads(skill_manage(action="create", name=name, content=content)) + + +# --------------------------------------------------------------------------- +# Adapted from PR #50261 (@yu-xin-c) +# --------------------------------------------------------------------------- + + +def test_background_review_patch_ledgers_and_rolls_back(ledger_env, monkeypatch): + """A curator-pass patch lands in the ledger tagged 'curator', and a + single-entry rollback restores the exact pre-patch content.""" + from tools import skill_ledger + from tools.skill_manager_tool import skill_manage + from tools.skill_provenance import ( + BACKGROUND_REVIEW, + reset_current_write_origin, + set_current_write_origin, + ) + from tools.skill_manager_tool import mark_background_review_skill_read + + token = set_current_write_origin(BACKGROUND_REVIEW) + try: + # Created under the review fork → marked created_by: agent, so the + # curator pass is allowed to patch it (curator invariant unchanged). + assert _create()["success"] is True + skill_md = ledger_env["skills"] / "my-skill" / "SKILL.md" + original = skill_md.read_text(encoding="utf-8") + mark_background_review_skill_read(skill_md) + patched = json.loads( + skill_manage( + action="patch", + name="my-skill", + old_string="Original body.", + new_string="Updated body.", + ) + ) + finally: + reset_current_write_origin(token) + + assert patched["success"] is True + assert "Updated body." in skill_md.read_text(encoding="utf-8") + + rows = skill_ledger.list_entries(skill="my-skill") + patch_rows = [r for r in rows if r["action"] == "patch"] + assert len(patch_rows) == 1 + entry = patch_rows[0] + assert entry["actor"] == "curator" + assert any(i["path"].endswith("SKILL.md") for i in entry["before"]) + + ok, msg = skill_ledger.rollback_entry(entry["id"]) + assert ok is True, msg + assert skill_md.read_text(encoding="utf-8") == original + + +def test_foreground_patch_is_ledgered_as_agent(ledger_env): + """Foreground skill_manage patches are ledgered too (all-actor design — + unlike #50261's autonomous-only history) and tagged 'agent'.""" + from tools import skill_ledger + from tools.skill_manager_tool import skill_manage + + assert _create()["success"] is True + patched = json.loads( + skill_manage( + action="patch", + name="my-skill", + old_string="Original body.", + new_string="Updated body.", + ) + ) + assert patched["success"] is True + + rows = [r for r in skill_ledger.list_entries(skill="my-skill") if r["action"] == "patch"] + assert len(rows) == 1 + assert rows[0]["actor"] == "agent" + + +def test_rollback_refuses_paths_outside_hermes_home(ledger_env): + """A hand-edited ledger entry pointing outside HERMES_HOME must not + become a write-anywhere primitive.""" + from tools import skill_ledger + + entry_id = skill_ledger.append_entry( + "patch", + "evil", + before=[{"path": "/etc/passwd", "sha256": "0" * 64}], + after=[], + ) + assert entry_id is not None + ok, msg = skill_ledger.rollback_entry(entry_id) + assert ok is False + assert "outside" in msg + + +def test_missing_blob_aborts_rollback_before_any_change(ledger_env): + from tools import skill_ledger + + assert _create()["success"] is True + skill_md = ledger_env["skills"] / "my-skill" / "SKILL.md" + entry_id = skill_ledger.append_entry( + "patch", + "my-skill", + before=[{"path": str(skill_md), "sha256": "a" * 64}], + after=[], + ) + current = skill_md.read_bytes() + ok, msg = skill_ledger.rollback_entry(entry_id) + assert ok is False + assert "missing blob" in msg + assert skill_md.read_bytes() == current + + +# --------------------------------------------------------------------------- +# New-design coverage +# --------------------------------------------------------------------------- + + +def test_ledger_entry_on_edit_and_delete(ledger_env): + from tools import skill_ledger + from tools.skill_manager_tool import skill_manage + + assert _create()["success"] is True + edited = json.loads( + skill_manage( + action="edit", + name="my-skill", + content=VALID_SKILL_CONTENT.replace("Original body.", "Edited body."), + ) + ) + assert edited["success"] is True + deleted = json.loads( + skill_manage(action="delete", name="my-skill", absorbed_into="") + ) + assert deleted["success"] is True + + actions = [r["action"] for r in skill_ledger.list_entries(skill="my-skill")] + assert actions == ["delete", "edit", "create"] # newest first + + delete_entry = skill_ledger.list_entries(skill="my-skill")[0] + # Delete intent recorded: explicit prune (absorbed_into="") + hard delete. + assert delete_entry["evidence"]["absorbed_into"] == "" + assert delete_entry["evidence"]["archived"] is False + # Before-state captured, after empty (skill gone). + assert delete_entry["before"] + assert delete_entry["after"] == [] + + +def test_deleted_skill_recoverable_from_ledger(ledger_env): + """A foreground hard delete stays a hard delete — but the ledger entry + can restore the skill's files from blobs.""" + from tools import skill_ledger + from tools.skill_manager_tool import skill_manage + + assert _create()["success"] is True + skill_md = ledger_env["skills"] / "my-skill" / "SKILL.md" + original = skill_md.read_bytes() + + assert json.loads(skill_manage(action="delete", name="my-skill"))["success"] + assert not skill_md.exists() + + entry = skill_ledger.list_entries(skill="my-skill")[0] + ok, msg = skill_ledger.rollback_entry(entry["id"]) + assert ok is True, msg + assert skill_md.read_bytes() == original + + +def test_archive_lands_in_ledger_with_curator_actor(ledger_env, monkeypatch): + from tools import skill_ledger, skill_usage + + assert _create()["success"] is True + # Curator auto-transition path tags the actor explicitly. + tok = skill_ledger.set_ledger_actor("curator") + try: + ok, msg = skill_usage.archive_skill("my-skill") + finally: + skill_ledger.reset_ledger_actor(tok) + assert ok, msg + + rows = [r for r in skill_ledger.list_entries(skill="my-skill") if r["action"] == "archive"] + assert len(rows) == 1 + assert rows[0]["actor"] == "curator" + assert rows[0]["before"] and rows[0]["after"] + + # And restore is ledgered as well. + ok, msg = skill_usage.restore_skill("my-skill") + assert ok, msg + assert any( + r["action"] == "restore" for r in skill_ledger.list_entries(skill="my-skill") + ) + + +def test_blob_dedupe_same_content_one_blob(ledger_env): + from tools import skill_ledger + + d = ledger_env["skills"] / "dedupe-src" + d.mkdir() + (d / "a.md").write_text("identical content", encoding="utf-8") + (d / "b.md").write_text("identical content", encoding="utf-8") + + manifest = skill_ledger.snapshot_paths(d) + assert len(manifest) == 2 + hashes = {m["sha256"] for m in manifest} + assert len(hashes) == 1 # same content → same hash + blobs = list(skill_ledger.blobs_dir().iterdir()) + assert len(blobs) == 1 # → one blob on disk + + +def test_rollback_fails_closed_when_safety_capture_fails(ledger_env, monkeypatch): + """If the pre-rollback safety ledger entry can't be written, the rollback + must abort with nothing changed (consistent with #63366).""" + from tools import skill_ledger + from tools.skill_manager_tool import skill_manage + + assert _create()["success"] is True + skill_md = ledger_env["skills"] / "my-skill" / "SKILL.md" + patched = json.loads( + skill_manage( + action="patch", + name="my-skill", + old_string="Original body.", + new_string="Updated body.", + ) + ) + assert patched["success"] is True + entry = [r for r in skill_ledger.list_entries("my-skill") if r["action"] == "patch"][0] + current = skill_md.read_bytes() + + monkeypatch.setattr(skill_ledger, "append_entry", lambda *a, **k: None) + ok, msg = skill_ledger.rollback_entry(entry["id"]) + assert ok is False + assert "safety capture failed" in msg + assert skill_md.read_bytes() == current # nothing changed + + +def test_rollback_removes_files_created_by_the_mutation(ledger_env): + from tools import skill_ledger + from tools.skill_manager_tool import skill_manage + + assert _create()["success"] is True + wrote = json.loads( + skill_manage( + action="write_file", + name="my-skill", + file_path="references/extra.md", + file_content="new supporting file", + ) + ) + assert wrote["success"] is True + extra = ledger_env["skills"] / "my-skill" / "references" / "extra.md" + assert extra.exists() + + entry = [r for r in skill_ledger.list_entries("my-skill") if r["action"] == "write_file"][0] + ok, msg = skill_ledger.rollback_entry(entry["id"]) + assert ok is True, msg + assert not extra.exists() # created by the mutation → removed on rollback + + +def test_config_gate_off_no_ledger_writes(ledger_env, monkeypatch): + from tools import skill_ledger + from tools.skill_manager_tool import skill_manage + + import hermes_cli.config as _cfg + + monkeypatch.setattr(_cfg, "load_config", lambda *a, **k: {"skills": {"ledger": False}}) + + assert _create()["success"] is True + patched = json.loads( + skill_manage( + action="patch", + name="my-skill", + old_string="Original body.", + new_string="Updated body.", + ) + ) + assert patched["success"] is True # mutation unaffected + assert not skill_ledger.ledger_path().exists() + assert not skill_ledger.blobs_dir().exists() + + +def test_ledger_failure_never_blocks_the_mutation(ledger_env, monkeypatch): + from tools import skill_ledger + from tools.skill_manager_tool import skill_manage + + def _boom(*a, **k): + raise OSError("disk full") + + monkeypatch.setattr(skill_ledger, "snapshot_paths", _boom) + + assert _create()["success"] is True + patched = json.loads( + skill_manage( + action="patch", + name="my-skill", + old_string="Original body.", + new_string="Updated body.", + ) + ) + assert patched["success"] is True + + +def test_list_entries_filtering_and_limit(ledger_env): + from tools import skill_ledger + + for i in range(5): + skill_ledger.append_entry("patch", f"skill-{i % 2}", before=[], after=[]) + assert len(skill_ledger.list_entries(limit=3)) == 3 + only_zero = skill_ledger.list_entries(skill="skill-0") + assert len(only_zero) == 3 + assert all(r["skill"] == "skill-0" for r in only_zero) + + +def test_user_actor_override(ledger_env): + from tools import skill_ledger + + tok = skill_ledger.set_ledger_actor("user") + try: + entry_id = skill_ledger.append_entry("archive", "some-skill") + finally: + skill_ledger.reset_ledger_actor(tok) + entry = skill_ledger.get_entry(entry_id) + assert entry["actor"] == "user" diff --git a/tools/skill_ledger.py b/tools/skill_ledger.py new file mode 100644 index 0000000000..bff84f0b56 --- /dev/null +++ b/tools/skill_ledger.py @@ -0,0 +1,388 @@ +"""Per-mutation skill audit ledger + single-edit rollback (tracker #79686 P3). + +Every skill mutation — regardless of actor — appends one JSONL entry to +``~/.hermes/skills/.curator_ledger.jsonl`` describing who changed what, with +before/after file manifests whose contents are stored content-addressed +(sha256-deduped) under ``~/.hermes/.curator_backups/blobs/``. + +Design decisions (Teknium-approved): + - JSONL, not the state DB: the ledger is a durable, human-greppable audit + trail that survives DB resets and is trivially rsync/backup friendly. + - The ledger covers ALL actors, tagged ``curator`` / ``agent`` / ``user``. + The curator *invariant* (never hard-delete autonomously) is unchanged and + applies only to autonomous actors; foreground user deletes stay + hard-delete — but they are still ledgered so they're recoverable via + ``hermes curator rollback ``. + - Per-file content-addressed blobs (not tarballs): a mutation typically + touches one file, so a whole-tree tarball per mutation would be wasteful, + and identical content across entries dedupes to a single blob. + +The ledger is TELEMETRY, NOT A GATE: a ledger failure must never block the +mutation it describes. Every public write path here is wrapped so exceptions +are logged and swallowed. The one deliberate exception is ``rollback_entry``, +which FAILS CLOSED when its own pre-rollback safety capture fails (consistent +with the whole-run tarball rollback in agent/curator_backup.py). +""" + +from __future__ import annotations + +import contextvars +import hashlib +import json +import logging +import os +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from hermes_constants import get_hermes_home + +logger = logging.getLogger(__name__) + +ACTOR_CURATOR = "curator" +ACTOR_AGENT = "agent" +ACTOR_USER = "user" +_VALID_ACTORS = {ACTOR_CURATOR, ACTOR_AGENT, ACTOR_USER} + +# Explicit actor override for call sites that know who they are acting for: +# the CLI sets "user", the curator's automatic-transition walk sets "curator". +_actor_override: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar( + "skill_ledger_actor", default=None +) + + +def set_ledger_actor(actor: Optional[str]) -> contextvars.Token: + """Bind an explicit actor for subsequent ledger records in this context. + + Returns a Token; callers must reset_ledger_actor(token) in a finally. + """ + return _actor_override.set(actor) + + +def reset_ledger_actor(token: contextvars.Token) -> None: + _actor_override.reset(token) + + +def derive_actor() -> str: + """Best-effort actor derivation. + + Priority: explicit override (CLI → user, curator walk → curator), then + the background-review provenance signal (→ curator), else agent. + """ + override = _actor_override.get() + if override in _VALID_ACTORS: + return override + try: + from tools.skill_provenance import is_background_review + + if is_background_review(): + return ACTOR_CURATOR + except Exception: + pass + return ACTOR_AGENT + + +# --------------------------------------------------------------------------- +# Paths + config gate +# --------------------------------------------------------------------------- + +def ledger_path() -> Path: + return get_hermes_home() / "skills" / ".curator_ledger.jsonl" + + +def blobs_dir() -> Path: + return get_hermes_home() / ".curator_backups" / "blobs" + + +def ledger_enabled() -> bool: + """Config gate ``skills.ledger`` (default True). Lazy import so this + module stays importable without the CLI config layer.""" + try: + from hermes_cli.config import cfg_get, load_config + + return bool(cfg_get(load_config(), "skills", "ledger", default=True)) + except Exception as e: # pragma: no cover — best-effort config read + logger.debug("skill_ledger: config read failed (%s); defaulting on", e) + return True + + +# --------------------------------------------------------------------------- +# Blob store (content-addressed, deduped) +# --------------------------------------------------------------------------- + +def _store_blob(data: bytes) -> str: + """Write *data* to the blob store keyed by its sha256. Dedupes: an + existing blob with the same hash is left alone. Returns the hash.""" + digest = hashlib.sha256(data).hexdigest() + dest = blobs_dir() / digest + if not dest.exists(): + dest.parent.mkdir(parents=True, exist_ok=True) + tmp = dest.with_name(f".tmp-{uuid.uuid4().hex[:8]}-{digest}") + tmp.write_bytes(data) + os.replace(tmp, dest) + return digest + + +def read_blob(sha256: str) -> Optional[bytes]: + """Return blob content or None when missing/invalid.""" + if not sha256 or not all(c in "0123456789abcdef" for c in sha256): + return None + p = blobs_dir() / sha256 + try: + return p.read_bytes() if p.exists() else None + except OSError: + return None + + +def snapshot_paths(root: Optional[Path]) -> List[Dict[str, str]]: + """Capture {path, sha256} for every file under *root* (recursively), + storing each file's content as a blob. Empty list when root is None or + doesn't exist. Raises on I/O failure — callers decide whether that is + fatal (rollback safety capture) or swallowed (telemetry hooks).""" + if root is None: + return [] + root = Path(root) + if root.is_file(): + files = [root] + elif root.is_dir(): + files = sorted(p for p in root.rglob("*") if p.is_file()) + else: + return [] + out: List[Dict[str, str]] = [] + for f in files: + data = f.read_bytes() + out.append({"path": str(f), "sha256": _store_blob(data)}) + return out + + +# --------------------------------------------------------------------------- +# Append + read +# --------------------------------------------------------------------------- + +def append_entry( + action: str, + skill: str, + before: Optional[List[Dict[str, str]]] = None, + after: Optional[List[Dict[str, str]]] = None, + actor: Optional[str] = None, + evidence: Optional[Dict[str, Any]] = None, +) -> Optional[str]: + """Append one ledger entry. Returns the entry id, or None when the + ledger is disabled or the write failed (never raises).""" + if not ledger_enabled(): + return None + try: + entry = { + "id": uuid.uuid4().hex[:12], + "ts": datetime.now(timezone.utc).isoformat(), + "actor": actor if actor in _VALID_ACTORS else derive_actor(), + "action": action, + "skill": skill, + "evidence": evidence or {}, + "before": before or [], + "after": after or [], + } + path = ledger_path() + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "a", encoding="utf-8") as fh: + fh.write(json.dumps(entry, ensure_ascii=False) + "\n") + return entry["id"] + except Exception as e: + logger.warning("skill_ledger: failed to append entry (%s) — mutation unaffected", e) + return None + + +def record_mutation( + action: str, + skill: str, + before_root: Optional[Path] = None, + before: Optional[List[Dict[str, str]]] = None, + after_root: Optional[Path] = None, + actor: Optional[str] = None, + evidence: Optional[Dict[str, Any]] = None, +) -> Optional[str]: + """One-stop hook for mutation call sites: capture after-state from + *after_root* (pre-captured *before* list, or capture from *before_root*) + and append. NEVER raises and never blocks the mutation.""" + if not ledger_enabled(): + return None + try: + if before is None: + before = snapshot_paths(before_root) + after = snapshot_paths(after_root) + return append_entry( + action, skill, before=before, after=after, actor=actor, evidence=evidence + ) + except Exception as e: + logger.warning("skill_ledger: record_mutation failed (%s) — mutation unaffected", e) + return None + + +def capture_before(root: Optional[Path]) -> Optional[List[Dict[str, str]]]: + """Best-effort pre-mutation capture. Returns None on failure or when the + ledger is disabled (callers pass the result straight to record_mutation).""" + if not ledger_enabled(): + return None + try: + return snapshot_paths(root) + except Exception as e: + logger.warning("skill_ledger: before-capture failed (%s) — mutation unaffected", e) + return None + + +def list_entries( + skill: Optional[str] = None, limit: Optional[int] = None +) -> List[Dict[str, Any]]: + """Read the ledger, newest first. Malformed lines are skipped.""" + path = ledger_path() + if not path.exists(): + return [] + rows: List[Dict[str, Any]] = [] + try: + with open(path, "r", encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + row = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(row, dict): + rows.append(row) + except OSError: + return [] + if skill: + rows = [r for r in rows if r.get("skill") == skill] + rows.reverse() + if limit is not None and limit >= 0: + rows = rows[:limit] + return rows + + +def get_entry(entry_id: str) -> Optional[Dict[str, Any]]: + if not entry_id: + return None + for row in list_entries(): + if row.get("id") == entry_id: + return row + return None + + +# --------------------------------------------------------------------------- +# Single-edit rollback +# --------------------------------------------------------------------------- + +def _is_within(root: Path, path: Path) -> bool: + """True when *path* (normalized, no symlink resolution needed for the + containment check itself) sits under *root*. Handles ``..`` traversal.""" + try: + root_r = Path(os.path.normpath(str(root))) + path_r = Path(os.path.normpath(str(path))) + return path_r == root_r or root_r in path_r.parents + except Exception: + return False + + +def _validate_entry_paths(entry: Dict[str, Any]) -> Optional[str]: + """All paths in an entry must live under HERMES_HOME. Defense in depth — + a hand-edited ledger must not become a write-anywhere primitive.""" + home = get_hermes_home() + for section in ("before", "after"): + for item in entry.get(section) or []: + p = Path(str(item.get("path", ""))) + if not _is_within(home, p): + return f"entry references a path outside {home}: {p}" + return None + + +def rollback_entry(entry_id: str) -> Tuple[bool, str]: + """Restore the before-state of the single mutation *entry_id*. + + Fail-closed semantics (mirrors agent/curator_backup.rollback + #63366): + 1. Every needed before-blob must exist — verified BEFORE any change. + 2. A pre-rollback safety ledger entry capturing the CURRENT state of + every touched path is appended first; if that capture fails, the + rollback aborts and nothing is changed. + """ + entry = get_entry(entry_id) + if entry is None: + return False, f"no ledger entry with id '{entry_id}'" + + path_err = _validate_entry_paths(entry) + if path_err: + return False, f"refusing rollback: {path_err}" + + before = entry.get("before") or [] + after = entry.get("after") or [] + + # Pre-check every blob we need so we never fail mid-restore. + for item in before: + if read_blob(str(item.get("sha256", ""))) is None: + return False, ( + f"missing blob {item.get('sha256')} for {item.get('path')}; " + "rollback aborted, nothing was changed" + ) + + # Touched paths = union of before/after. Capture their CURRENT state as + # the safety entry so the rollback itself is undoable. FAIL CLOSED. + touched = {str(i["path"]) for i in before + after if i.get("path")} + try: + safety_before: List[Dict[str, str]] = [] + for p in sorted(touched): + fp = Path(p) + if fp.is_file(): + safety_before.append({"path": p, "sha256": _store_blob(fp.read_bytes())}) + safety_id = append_entry( + "pre-rollback", + entry.get("skill", "?"), + before=safety_before, + after=safety_before, + evidence={"rollback_target": entry_id}, + ) + except Exception as e: + return False, ( + f"pre-rollback safety capture failed ({e}); rollback aborted and " + "current skills were not changed" + ) + if safety_id is None: + return False, ( + "pre-rollback safety capture failed (ledger disabled or " + "unwritable); rollback aborted and current skills were not changed" + ) + + # Restore: write every before-file, remove files the mutation created. + before_paths = {str(i["path"]) for i in before} + restored = 0 + removed = 0 + for item in before: + fp = Path(str(item["path"])) + data = read_blob(str(item["sha256"])) + assert data is not None # pre-checked above + fp.parent.mkdir(parents=True, exist_ok=True) + fp.write_bytes(data) + restored += 1 + for item in after: + p = str(item.get("path", "")) + if p and p not in before_paths: + fp = Path(p) + try: + if fp.is_file(): + fp.unlink() + removed += 1 + except OSError as e: + logger.warning("skill_ledger: could not remove %s during rollback: %s", p, e) + + append_entry( + "rollback", + entry.get("skill", "?"), + before=safety_before, + after=before, + evidence={"rollback_target": entry_id, "restored": restored, "removed": removed}, + ) + return True, ( + f"rolled back entry {entry_id} ({entry.get('action')} on " + f"'{entry.get('skill')}'): {restored} file(s) restored, {removed} removed. " + f"Safety entry {safety_id} captured the pre-rollback state." + ) diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index 3d018a3da9..9c602bca7d 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -1575,6 +1575,21 @@ def skill_manage( if gate_result is not None: return gate_result + # Audit ledger (tracker #79686 P3): capture the pre-mutation state of the + # skill directory so every mutation — any actor — lands in the append-only + # JSONL ledger with before/after blobs. Telemetry, not a gate: failures + # here must NEVER block the mutation (capture_before returns None on + # error, and record_mutation below swallows everything). + _ledger_before = None + _ledger_before_dir = None + try: + from tools import skill_ledger as _ledger + _pre = _find_skill(name) + _ledger_before_dir = _pre["path"] if _pre else None + _ledger_before = _ledger.capture_before(_ledger_before_dir) + except Exception: + pass + if action == "create": if not content: return tool_error("content is required for 'create'. Provide the full SKILL.md text (frontmatter + body).", success=False) @@ -1611,6 +1626,30 @@ def skill_manage( result = {"success": False, "error": f"Unknown action '{action}'. Use: create, edit, patch, delete, write_file, remove_file"} if result.get("success"): + # Audit ledger append (best-effort; never blocks the mutation). + try: + from tools import skill_ledger as _ledger + _post = _find_skill(name) + _after_dir = _post["path"] if _post else None + _evidence = {} + if action == "delete": + # Record delete intent: consolidation vs prune, and whether + # the recoverable-archive path handled it (curator pass). + _evidence["absorbed_into"] = absorbed_into + _evidence["archived"] = bool(result.get("_archived")) + if session_id: + _evidence["session_id"] = session_id + if file_path: + _evidence["file_path"] = file_path + _ledger.record_mutation( + action, + name, + before=_ledger_before if _ledger_before is not None else [], + after_root=_after_dir, + evidence=_evidence, + ) + except Exception: + pass try: from agent.prompt_builder import clear_skills_system_prompt_cache clear_skills_system_prompt_cache(clear_snapshot=True) diff --git a/tools/skill_usage.py b/tools/skill_usage.py index 2208e29ae9..4c30e866ab 100644 --- a/tools/skill_usage.py +++ b/tools/skill_usage.py @@ -1111,6 +1111,14 @@ def archive_skill(skill_name: str) -> Tuple[bool, str]: if dest.exists(): dest = archive_root / f"{skill_dir.name}-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}" + # Audit ledger pre-capture (best-effort; never blocks the archive). + _ledger_before = None + try: + from tools import skill_ledger as _ledger + _ledger_before = _ledger.capture_before(skill_dir) + except Exception: + _ledger = None # type: ignore[assignment] + try: skill_dir.rename(dest) except OSError: @@ -1126,6 +1134,16 @@ def archive_skill(skill_name: str) -> Tuple[bool, str]: add_suppressed_name(skill_name) set_state(skill_name, STATE_ARCHIVED) + try: + if _ledger is not None: + _ledger.record_mutation( + "archive", + skill_name, + before=_ledger_before if _ledger_before is not None else [], + after_root=dest, + ) + except Exception: + pass return True, f"archived to {dest}" @@ -1188,6 +1206,14 @@ def restore_skill(skill_name: str) -> Tuple[bool, str]: if dest.exists(): return False, f"destination already exists: {dest}" + # Audit ledger pre-capture (best-effort; never blocks the restore). + _ledger_before = None + try: + from tools import skill_ledger as _ledger + _ledger_before = _ledger.capture_before(src) + except Exception: + _ledger = None # type: ignore[assignment] + try: src.rename(dest) except OSError: @@ -1201,6 +1227,16 @@ def restore_skill(skill_name: str) -> Tuple[bool, str]: remove_suppressed_name(skill_name) set_state(skill_name, STATE_ACTIVE) + try: + if _ledger is not None: + _ledger.record_mutation( + "restore", + skill_name, + before=_ledger_before if _ledger_before is not None else [], + after_root=dest, + ) + except Exception: + pass return True, f"restored to {dest}" diff --git a/website/docs/user-guide/features/curator.md b/website/docs/user-guide/features/curator.md index 853e4205ac..e6d8617f16 100644 --- a/website/docs/user-guide/features/curator.md +++ b/website/docs/user-guide/features/curator.md @@ -112,6 +112,10 @@ hermes curator restore # move an archived skill back to active hermes curator list-archived # list skills currently in ~/.hermes/skills/.archive/ hermes curator archive # manually archive a single skill now hermes curator prune [--days N] # bulk-archive agent-created skills idle >= N days (default 90) +hermes curator ledger # list the per-mutation audit ledger (all actors) +hermes curator ledger --skill --limit 50 # filter/paginate ledger entries +hermes curator rollback # undo a single mutation from the ledger +hermes curator purge [--days N] [--dry-run] # delete archived skills older than the TTL (explicit only) ``` ## Backups and rollback @@ -143,6 +147,45 @@ Set `curator.backup.enabled: false` to disable automatic snapshotting. The manua The same subcommands are available as the `/curator` slash command inside a running session (CLI or gateway platforms). +## Audit ledger and single-edit rollback + +Whole-run snapshots answer "undo everything the last curator pass did" — but sometimes you want to know *who changed what* and undo exactly one mutation. Every skill mutation — curator auto-transitions, agent `skill_manage` calls, and your own CLI archive/restore/purge — appends one entry to the append-only JSONL ledger at `~/.hermes/skills/.curator_ledger.jsonl`: + +- **actor** — `curator` (background review fork / auto-transitions), `agent` (foreground agent tool calls), or `user` (CLI commands) +- **action** — `create`, `edit`, `patch`, `delete`, `write_file`, `remove_file`, `archive`, `restore`, `purge`, `rollback` +- **evidence** — delete intent (`absorbed_into` for consolidations, empty for prunes, and whether the recoverable-archive path handled it), triggering session id when available +- **before/after** — per-file `{path, sha256}` manifests. File contents are stored content-addressed (deduped by hash) under `~/.hermes/.curator_backups/blobs/`, so a hundred entries touching the same unchanged file cost one blob. + +```bash +hermes curator ledger # newest 20 entries +hermes curator ledger --skill my-skill --limit 50 +hermes curator rollback # restore that one mutation's before-state +``` + +Single-entry rollback restores exactly the files that mutation touched (and removes files it created) from the blob store — nothing else in the skills tree moves. Like whole-tree rollback, it takes a safety ledger entry of the current state first and **fails closed**: if the safety capture can't be written, nothing is changed. Because foreground deletes are ledgered too, `hermes curator rollback ` can resurrect a hard-deleted skill. + +The ledger is telemetry, never a gate — if writing an entry fails, the mutation still goes through. Disable it with: + +```yaml +skills: + ledger: false +``` + +## Archive TTL purge + +Archived skills are kept forever by default. If you want `~/.hermes/skills/.archive/` bounded, set a TTL and purge explicitly — purging never runs automatically, and every purged skill is captured into the ledger (with blobs) first, so even a purge leaves an auditable, recoverable trail: + +```yaml +curator: + archive_ttl_days: 180 # 0 (default) = never purge +``` + +```bash +hermes curator purge --dry-run # preview what would be deleted +hermes curator purge # delete archives older than the TTL (with confirmation) +hermes curator purge --days 90 # one-off TTL override +``` + ## What "agent-created" means The curator only manages skills explicitly marked as **agent-created** in