Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+653
View File
@@ -0,0 +1,653 @@
"""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 re
import tarfile
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"
# Snapshot-id shape used by agent.curator_backup (kept in sync by the
# test suite; duplicated here so the ledger can read the newest
# skills.tar.gz without importing the backup stack).
_BACKUP_ID_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z(-\d{2})?$")
# ".archive/<name>-YYYYMMDDHHMMSS" collision suffix added by archive_skill.
_ARCHIVE_TS_SUFFIX_RE = re.compile(r"^(.+)-\d{14}$")
# Actions whose rollback must restore a COMPLETE skill package. These are
# the actions where a hollow before-capture (support files re-homed out of
# the tree first) makes `hermes curator rollback` restore a shell of a
# skill instead of the package (issue #96962).
_PACKAGE_RESTORE_ACTIONS = frozenset({"delete", "archive", "purge"})
_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],
*,
complete_package: bool = False,
) -> 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).
``complete_package=True`` unions in files from the newest curator
``skills.tar.gz`` for this skill (issue #96962). Consolidation re-homes
``references/`` / ``scripts/`` out of the tree before the
delete/archive, so a disk-only capture ledgers ``files: 1`` and
rollback would restore a hollow skill. Disk hashes win over the
backup copy."""
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:
files = []
if not complete_package:
return []
out: List[Dict[str, str]] = []
for f in files:
data = f.read_bytes()
out.append({"path": str(f), "sha256": _store_blob(data)})
if complete_package:
out = fill_snapshot_from_curator_backup(root, out)
return out
# ---------------------------------------------------------------------------
# Package-completeness fill from the newest curator backup (issue #96962)
# ---------------------------------------------------------------------------
def _skills_dir() -> Path:
return get_hermes_home() / "skills"
def _package_rel(root: Path) -> Optional[str]:
"""Relative POSIX path of a skill directory under ``skills/``, or None
when *root* is not inside the local skills dir. Backup / hub metadata
roots are excluded — they are never a skill package."""
try:
rel = Path(os.path.normpath(str(root))).relative_to(
Path(os.path.normpath(str(_skills_dir())))
)
except ValueError:
return None
posix = rel.as_posix().strip("/")
if not posix:
return None
top = posix.split("/", 1)[0]
if top in {".curator_backups", ".hub", ".archive"}:
return None
return posix
def _strip_archive_timestamp(name: str) -> str:
match = _ARCHIVE_TS_SUFFIX_RE.match(name)
return match.group(1) if match else name
def package_prefixes(
root: Optional[Path] = None,
skill: Optional[str] = None,
before: Optional[List[Dict[str, str]]] = None,
) -> List[str]:
"""Tar member prefixes that belong to this skill's package.
Every spelling the package may carry: its live location under
``skills/``, the bare skill name, the name minus an archive collision
suffix, and — for rollback-time fills where *root* is gone — the
package parent recorded in the before-state SKILL.md paths."""
found: List[str] = []
def add(prefix: Optional[str]) -> None:
if not prefix:
return
prefix = prefix.strip("/")
if prefix and prefix not in found:
found.append(prefix)
if root is not None:
add(_package_rel(Path(root)))
for item in before or []:
path = Path(str(item.get("path", "")))
if path.name == "SKILL.md":
add(_package_rel(path.parent))
if skill:
add(skill)
add(_strip_archive_timestamp(skill))
return found
def _latest_skills_tarball() -> Optional[Path]:
"""Newest ``skills.tar.gz`` under ``skills/.curator_backups/``."""
backups = _skills_dir() / ".curator_backups"
if not backups.is_dir():
return None
candidates: List[Path] = []
try:
children = list(backups.iterdir())
except OSError:
return None
for child in children:
if not child.is_dir() or not _BACKUP_ID_RE.match(child.name):
continue
archive = child / "skills.tar.gz"
if archive.is_file():
candidates.append(archive)
if not candidates:
return None
# Parent dirs sort lexicographically == chronologically for the id shape.
candidates.sort(key=lambda p: p.parent.name, reverse=True)
return candidates[0]
def _read_package_files_from_latest_backup(prefixes: List[str]) -> Dict[str, bytes]:
"""``{posix-relpath: bytes}`` for files under *prefixes* in the newest
snapshot. Malicious member names (absolute, ``..`` traversal) are
rejected."""
if not prefixes:
return {}
archive = _latest_skills_tarball()
if archive is None:
return {}
prefixed = tuple(p if p.endswith("/") else p + "/" for p in prefixes)
exact = set(prefixes)
out: Dict[str, bytes] = {}
try:
with tarfile.open(archive, "r:gz") as tf:
for member in tf.getmembers():
if not member.isfile():
continue
name = member.name.replace("\\", "/").lstrip("./")
if not name or name.startswith("/") or ".." in Path(name).parts:
continue
if name not in exact and not name.startswith(prefixed):
continue
extracted = tf.extractfile(member)
if extracted is None:
continue
out[name] = extracted.read()
except (OSError, tarfile.TarError) as exc:
logger.debug("skill_ledger: could not read curator backup package: %s", exc)
return {}
return out
def fill_snapshot_from_curator_backup(
root: Optional[Path],
existing: Optional[List[Dict[str, str]]] = None,
*,
skill: Optional[str] = None,
) -> List[Dict[str, str]]:
"""Union missing skill-package files from the newest curator snapshot.
Completeness fill, not a gate: failures are swallowed and the existing
capture is returned unchanged. Disk/already-ledgered hashes win — the
backup only fills paths ABSENT from *existing*.
Filled files are addressed where the rollback must restore them: under
*root* itself when it is known (delete/archive/purge all restore to the
captured location — for purge that is ``.archive/<name>/``, NOT the
live tree), else under the live skills dir. Backup members carry a
leading package-dir segment (arcname = top-level dir name); it is
stripped when *root* already names the package. Every fill target must
stay under ``skills/`` and HERMES_HOME.
"""
out = list(existing or [])
prefixes = package_prefixes(root, skill, out)
if not prefixes:
return out
try:
extra = _read_package_files_from_latest_backup(prefixes)
except Exception as exc:
logger.debug("skill_ledger: backup package fill failed: %s", exc)
return out
if not extra:
return out
skills = _skills_dir()
dest_root = Path(root) if root is not None else None
pkg_name = (
_strip_archive_timestamp(dest_root.name) if dest_root is not None else None
)
have = set()
for item in out:
path = Path(str(item.get("path", "")))
try:
have.add(
Path(os.path.normpath(str(path)))
.relative_to(Path(os.path.normpath(str(skills))))
.as_posix()
)
except ValueError:
continue
for rel, data in extra.items():
parts = rel.split("/")
if dest_root is not None and parts and parts[0] in (pkg_name, dest_root.name):
parts = parts[1:]
if not parts:
continue
base = dest_root if dest_root is not None else skills
dest = base.joinpath(*parts)
if not _is_within(skills, dest) or not _is_within(get_hermes_home(), dest):
continue
try:
rel_key = (
Path(os.path.normpath(str(dest)))
.relative_to(Path(os.path.normpath(str(skills))))
.as_posix()
)
except ValueError:
continue
if rel_key in have:
continue
try:
out.append({"path": str(dest), "sha256": _store_blob(data)})
except Exception as exc:
logger.debug("skill_ledger: backup blob store failed for %s: %s", rel, exc)
continue
have.add(rel_key)
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.
Package-destroying actions (delete/archive/purge) always capture a
COMPLETE package: re-homed support files are filled in from the newest
curator backup so a rollback never restores a hollow skill (#96962)."""
if not ledger_enabled():
return None
try:
_complete = action in _PACKAGE_RESTORE_ACTIONS
if before is None:
before = snapshot_paths(before_root, complete_package=_complete)
elif _complete:
before = fill_snapshot_from_curator_backup(
before_root, before, skill=skill
)
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],
*,
complete_package: bool = False,
skill: Optional[str] = None,
) -> 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).
``complete_package=True`` fills support files from the newest curator
backup — required for delete/archive/purge captures, where consolidation
may have re-homed the support files out of the tree first (#96962)."""
if not ledger_enabled():
return None
try:
captured = snapshot_paths(root)
if complete_package:
captured = fill_snapshot_from_curator_backup(root, captured, skill=skill)
return captured
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 = list(entry.get("before") or [])
after = list(entry.get("after") or [])
# Historical hollow entries (#96962): a delete/archive/purge captured
# only what was left after consolidation re-homed the support files
# (``files: 1`` = SKILL.md). Fill the before-state from the newest
# curator backup so the rollback restores the complete package, not a
# shell. Disk hashes already in the entry win; this only adds missing
# paths. The filled set is re-validated against HERMES_HOME below.
if entry.get("action") in _PACKAGE_RESTORE_ACTIONS:
skill_root: Optional[Path] = None
for item in before:
p = Path(str(item.get("path", "")))
if p.name == "SKILL.md":
skill_root = p.parent
break
before = fill_snapshot_from_curator_backup(
skill_root, before, skill=str(entry.get("skill") or "") or None
)
path_err = _validate_entry_paths({**entry, "before": before, "after": after})
if path_err:
return False, f"refusing rollback: {path_err}"
# 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."
)