feat(curator): per-mutation audit ledger + single-edit rollback
Tracker #79686 P3. Every skill mutation — curator, agent, or user — now appends one entry to the append-only JSONL ledger at ~/.hermes/skills/.curator_ledger.jsonl, with per-file before/after manifests whose contents are stored content-addressed (sha256-deduped) under ~/.hermes/.curator_backups/blobs/. - tools/skill_ledger.py: append/list/get, blob store, actor derivation (curator|agent|user), single-entry rollback that takes a pre-rollback safety entry first and FAILS CLOSED when that capture fails (consistent with the whole-run tarball rollback hardening from #63366). Path containment check so a hand-edited ledger can't write outside HERMES_HOME. - Hooked all three choke points: skill_manage() dispatch (all actors, delete intent recorded via absorbed_into/archived evidence), archive_skill()/restore_skill(), and curator auto-transitions (tagged actor=curator via a ContextVar override). - Ledger failures never block the mutation — telemetry, not a gate. Config gate skills.ledger (default true). - hermes curator ledger [--skill NAME] [--limit N] and hermes curator rollback <entry-id> (whole-tree snapshot rollback unchanged). - Optional TTL purge of skills/.archive/: curator.archive_ttl_days (default 0 = never) + explicit hermes curator purge, recorded in the ledger with before-blobs so purges stay recoverable. - Docs: curator.md sections on the ledger, single-edit rollback, and archive TTL purge. Curator invariants unchanged: only created_by:agent skills auto-transition, never hard-delete autonomously, pinned exempt; foreground user deletes stay hard-delete (and are now recoverable via the ledger). Closes #45778, #50875. Tests adapted from #50261 by @yu-xin-c.
This commit is contained in:
+16
-1
@@ -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:
|
||||
|
||||
@@ -2001,6 +2001,14 @@ DEFAULT_CONFIG = {
|
||||
# never crammed into a chat bubble), apply with
|
||||
# /skills approve <id> or drop with /skills reject <id>.
|
||||
"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 <entry-id>`.
|
||||
# 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/<utc-iso>/skills.tar.gz so the
|
||||
|
||||
+205
-12
@@ -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 <stamp>`` 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 <id>`; "
|
||||
"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
|
||||
<stamp>`` 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 <snapshot>` 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)."""
|
||||
|
||||
@@ -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"
|
||||
@@ -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 <entry-id>``.
|
||||
- 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."
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
|
||||
@@ -112,6 +112,10 @@ hermes curator restore <skill> # move an archived skill back to active
|
||||
hermes curator list-archived # list skills currently in ~/.hermes/skills/.archive/
|
||||
hermes curator archive <skill> # 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 <name> --limit 50 # filter/paginate ledger entries
|
||||
hermes curator rollback <entry-id> # 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 <entry-id> # 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 <entry-id>` 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
|
||||
|
||||
Reference in New Issue
Block a user