Files
hermes-agent/tools/write_approval.py

374 lines
13 KiB
Python

#!/usr/bin/env python3
"""Write-approval gate + pending store for memory and skill writes.
The agent writes to two cross-session stores — **memory** (MEMORY.md / USER.md,
small entries) and **skills** (SKILL.md + files, potentially 10-100 KB) — from
two origins: **foreground** (a normal turn) and **background_review** (the
autonomous self-improvement fork). A per-subsystem boolean ``write_approval``
gates those writes: ``false`` (default) writes freely; ``true`` never commits
directly — it prompts inline (memory, interactive CLI only) or **stages** the
write to a pending store for out-of-band review.
Staging is mandatory for background writes (a daemon thread cannot block on a
prompt), gateway sessions (no inline channel — review via ``/memory pending``),
and all skill writes (too big to eyeball mid-loop). Memory shows full content;
skills show metadata + a gist + a ``diff`` escape hatch.
Pending records live under ``<HERMES_HOME>/pending/{memory,skills}/<id>.json``
so they survive restarts and can be reviewed from CLI, gateway, or dashboard.
"""
from __future__ import annotations
import difflib
import json
import logging
import os
import re
import time
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional
from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
# Subsystem identifiers
MEMORY = "memory"
SKILLS = "skills"
_SUBSYSTEMS = (MEMORY, SKILLS)
# Per-subsystem config key. Intentionally a single boolean with no "block all
# writes" state — to disable a subsystem use its own enable flag
# (e.g. ``memory.memory_enabled: false``).
CONFIG_KEY = "write_approval"
# --- Config resolution ---
def write_approval_enabled(subsystem: str) -> bool:
"""Read ``<subsystem>.write_approval``; any unset/invalid value means gate off."""
if subsystem not in _SUBSYSTEMS:
return False
try:
from hermes_cli.config import load_config, cfg_get
raw = cfg_get(load_config(), subsystem, CONFIG_KEY, default=False)
except Exception:
return False
return _normalize_enabled(raw)
def _normalize_enabled(value: Any) -> bool:
"""Coerce a config value to bool; unknown → False (gate off).
YAML already parses bare on/off/yes/no as bools; the string branch covers
hand-edited configs.
"""
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"on", "true", "yes", "1", "approve", "enabled"}
return False
# --- Pending store (file-backed) ---
def _pending_dir(subsystem: str) -> Path:
return get_hermes_home() / "pending" / subsystem
def _pending_path(subsystem: str, pending_id: str) -> Path:
return _pending_dir(subsystem) / f"{pending_id}.json"
def _read_record(path: Path) -> Dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def stage_write(subsystem: str, payload: Dict[str, Any],
*, summary: str, origin: str) -> Dict[str, Any]:
"""Persist a pending write and return its record (``id`` + metadata).
``payload`` is the exact kwargs to replay the write on approval; ``summary``
is the one-line description shown in pending lists; ``origin`` is
``foreground`` or ``background_review`` (audit). Best-effort: on disk
failure it logs and still returns a record — the write is lost, which is
the safe failure for an approval gate (nothing silently committed).
"""
pid = uuid.uuid4().hex[:8]
record = {
"id": pid,
"subsystem": subsystem,
"action": payload.get("action", ""),
"summary": (summary or "").strip(),
"origin": origin or "foreground",
"created_at": time.time(),
"payload": payload,
}
try:
path = _pending_path(subsystem, pid)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, path)
except Exception as e: # pragma: no cover - disk failure path
logger.error("Failed to stage pending %s write: %s", subsystem, e, exc_info=True)
return record
def list_pending(subsystem: str) -> List[Dict[str, Any]]:
"""Return all pending records for ``subsystem``, oldest first."""
d = _pending_dir(subsystem)
if not d.exists():
return []
records: List[Dict[str, Any]] = []
for p in d.glob("*.json"):
try:
records.append(_read_record(p))
except Exception:
logger.warning("Skipping unreadable pending record: %s", p)
records.sort(key=lambda r: r.get("created_at", 0))
return records
def get_pending(subsystem: str, pending_id: str) -> Optional[Dict[str, Any]]:
"""Return a single pending record by id, or None."""
path = _pending_path(subsystem, pending_id)
if not path.exists():
return None
try:
return _read_record(path)
except Exception:
return None
def discard_pending(subsystem: str, pending_id: str) -> bool:
"""Delete a pending record. Returns True if it existed."""
path = _pending_path(subsystem, pending_id)
try:
if path.exists():
path.unlink()
return True
except Exception as e: # pragma: no cover
logger.error("Failed to discard pending %s/%s: %s", subsystem, pending_id, e)
return False
def pending_count(subsystem: str) -> int:
"""Cheap count of pending records (for notification badges)."""
d = _pending_dir(subsystem)
if not d.exists():
return 0
try:
return sum(1 for _ in d.glob("*.json"))
except Exception:
return 0
# --- Write origin ---
def current_origin() -> str:
"""Return ``foreground`` or ``background_review``.
Reuses the skill-provenance ContextVar the background review fork sets;
foreground turns leave it at the default.
"""
try:
from tools.skill_provenance import get_current_write_origin
return get_current_write_origin()
except Exception:
return "foreground"
# --- Gate decision ---
@dataclass(slots=True, kw_only=True)
class GateDecision:
"""Result of evaluating the write gate. Exactly one flag is True.
``allow`` proceed with the real write; ``blocked`` the user denied an inline
prompt (``message`` explains why); ``stage`` the caller must ``stage_write``
the payload (``message`` is the user-facing "staged for approval" note).
"""
allow: bool = False
blocked: bool = False
stage: bool = False
message: str = ""
def _staged(subsystem: str) -> GateDecision:
where = "/skills pending" if subsystem == SKILLS else "/memory pending"
return GateDecision(
stage=True,
message=(
f"Staged for approval ({subsystem}.write_approval is on). "
f"Not yet saved — review with {where}."
),
)
def evaluate_gate(subsystem: str, *, inline_summary: str = "",
inline_detail: str = "") -> GateDecision:
"""Decide what to do with a pending write for ``subsystem``.
Decision matrix:
gate off (default) → allow
gate on, memory + interactive CLI → inline approve/deny prompt
gate on, memory + gateway/script/bg → stage
gate on, skills (any origin) → stage (too big to review inline)
The gate only ever delays a write, never silently refuses it; ``blocked``
is produced only when the user actively denies the inline prompt.
``inline_summary``/``inline_detail`` feed the memory inline prompt.
"""
if not write_approval_enabled(subsystem):
return GateDecision(allow=True)
# Skills always stage; a background write runs in a daemon thread with no user.
if subsystem == SKILLS or current_origin() == "background_review":
return _staged(subsystem)
# Memory + foreground: prompt inline if an interactive channel exists;
# otherwise (gateway, script, prompt failure) stage instead of blind-denying.
granted = _prompt_inline_memory_approval(inline_summary, inline_detail)
if granted is True:
return GateDecision(allow=True)
if granted is False:
return GateDecision(
blocked=True,
message="Memory write denied by user. The change was not saved.",
)
return _staged(MEMORY)
def _prompt_inline_memory_approval(summary: str, detail: str) -> Optional[bool]:
"""Prompt inline for a memory write: True approved, False denied, None → stage.
Uses the per-thread CLI approval callback registered for dangerous
commands (``tools.terminal_tool.set_approval_callback``), invoked directly
rather than via ``prompt_dangerous_approval``: that wrapper falls back to
``input()`` (deadlock-prone under prompt_toolkit; silent deny in gateway
sessions, whose ``/approve`` round-trip lives in the pending-approval
queue) and converts callback errors into a deny. Here a missing channel or
failed prompt must stage instead.
"""
try:
from tools.terminal_tool import _get_approval_callback
except Exception:
return None
callback = _get_approval_callback()
if callback is None:
return None
header = summary.strip() or "Save to memory?"
body = detail.strip()
try:
choice = callback(body or header, f"Save to memory: {header}", allow_permanent=False)
except Exception as e:
logger.error("Inline memory approval prompt failed: %s", e)
return None
if choice in {"once", "session"}:
return True
if choice == "deny":
return False
return None # unknown outcome → no decision, stage rather than drop
# --- Skill-specific helpers (gist + diff for the review affordances) ---
def skill_gist(action: str, name: str, *, content: str = "",
file_path: str = "", old_string: str = "",
new_string: str = "") -> str:
"""Build a one-line heuristic gist (no model call) for a pending skill write.
create/edit use the frontmatter ``description:``; patch/write_file describe
the size of the change. The full diff stays behind /skills diff.
"""
if action in {"create", "edit"} and content:
desc = _frontmatter_description(content)
size = f"{len(content) // 1024 + 1} KB" if len(content) >= 1024 else f"{len(content)} chars"
verb = "create" if action == "create" else "rewrite"
return f"{verb} '{name}' — {desc} ({size})" if desc else f"{verb} '{name}' ({size})"
if action == "patch":
removed = old_string.count("\n") + 1 if old_string else 0
added = new_string.count("\n") + 1 if new_string else 0
return f"patch '{name}' {file_path or 'SKILL.md'} (+{added}/-{removed} lines)"
if action == "write_file":
return f"write {file_path} in '{name}'"
if action == "remove_file":
return f"remove {file_path} from '{name}'"
if action == "delete":
return f"delete skill '{name}'"
return f"{action} '{name}'"
def _frontmatter_description(content: str) -> str:
"""Extract the ``description:`` value from SKILL.md YAML frontmatter (≤140 chars)."""
m = re.search(r"^description:\s*(.+)$", content, re.MULTILINE)
return m.group(1).strip().strip("'\"")[:140] if m else ""
def _find_skill_path(name: str) -> Optional[Path]:
"""Directory of an installed skill, or None if unknown / lookup unavailable."""
try:
from tools.skill_manager_tool import _find_skill
found = _find_skill(name)
return found["path"] if found else None
except Exception:
return None
def skill_pending_diff(record: Dict[str, Any]) -> str:
"""Full content (create) or unified diff vs. the on-disk skill (edit/patch/write_file).
Rendered by /skills diff <id> on surfaces that can show it (CLI pager,
dashboard, pending JSON file).
"""
payload = record.get("payload", {})
action = payload.get("action", "")
name = payload.get("name", "")
if action == "create":
return payload.get("content") or ""
if action == "remove_file":
return f"remove file: {payload.get('file_path')} from skill '{name}'"
if action == "delete":
return f"delete skill '{name}'"
if action not in {"edit", "patch", "write_file"}:
return f"({action} on '{name}')"
# patch/write_file target a file inside the skill; edit always targets SKILL.md.
target_label = "SKILL.md"
current = ""
skill_dir = _find_skill_path(name)
if skill_dir:
if action != "edit":
target_label = payload.get("file_path") or "SKILL.md"
try:
p = skill_dir / target_label
if p.exists():
current = p.read_text(encoding="utf-8")
except Exception:
current = ""
if action == "edit":
new = payload.get("content") or ""
elif action == "patch":
old_s = payload.get("old_string") or ""
new_s = payload.get("new_string") or ""
new = current.replace(old_s, new_s) if current else f"(patch {old_s!r} → {new_s!r})"
else:
new = payload.get("file_content") or ""
diff = difflib.unified_diff(
current.splitlines(keepends=True),
new.splitlines(keepends=True),
fromfile=f"a/{target_label}",
tofile=f"b/{target_label}",
)
return "".join(diff) or "(no textual change)"