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:
Teknium
2026-08-06 09:52:42 -07:00
parent 070c6a5f8d
commit efe41abde0
8 changed files with 1107 additions and 13 deletions
+16 -1
View File
@@ -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:
+13
View File
@@ -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
View File
@@ -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)."""
+367
View File
@@ -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"
+388
View File
@@ -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."
)
+39
View File
@@ -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)
+36
View File
@@ -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