Files
hermes-agent/tools/write_approval.py
T
Teknium d4cec15b47 refactor(tools): first-wave simplification of tools/ (file ops split, lazy_deps, code_exec, approval, browser, delegate, mcp, skills, terminal, voice, media)
Behavior-neutral structural pass over tools/*: god-file extractions into
sibling modules (file_operations_common/lint/search, file_tools_paths/
read_tracking/write, code_execution_env/rpc, tool_search_catalog/names/
validation, tts_command_provider, ...), duplicate helper unification,
if/elif -> dispatch tables, dead-code removal, docstring compaction.
Tool schemas (get_tool_definitions) verified byte-identical to base.
2026-09-02 14:43:45 -07:00

376 lines
14 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 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 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:
d = _pending_dir(subsystem)
d.mkdir(parents=True, exist_ok=True)
path = d / f"{pid}.json"
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(json.loads(p.read_text(encoding="utf-8")))
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 json.loads(path.read_text(encoding="utf-8"))
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
# ---------------------------------------------------------------------------
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).
"""
__slots__ = ("allow", "blocked", "stage", "message")
def __init__(self, *, allow=False, blocked=False, stage=False, message=""):
self.allow = allow
self.blocked = blocked
self.stage = stage
self.message = message
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 if body else 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 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 = ""
try:
from tools.skill_manager_tool import _find_skill
except Exception:
_find_skill = None # type: ignore
found = _find_skill(name) if _find_skill is not None else None
if found:
if action != "edit":
target_label = payload.get("file_path") or "SKILL.md"
try:
p = found["path"] / 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)"