Files
hermes-agent/tools/write_approval.py
2026-10-08 17:22:54 +02:00

414 lines
18 KiB
Python

#!/usr/bin/env python3
"""Write-approval gate + pending store for memory and skill writes.
A per-subsystem boolean ``write_approval`` gates the agent's cross-session writes —
**memory** (MEMORY.md / USER.md) and **skills** (SKILL.md + files) — from either
origin (**foreground** turn or **background_review** fork). ``false`` (default)
writes freely; ``true`` never commits directly: it prompts inline (memory,
interactive CLI only) or **stages** the write under
``<HERMES_HOME>/pending/{memory,skills}/<id>.json`` for out-of-band review.
"""
from __future__ import annotations
import difflib
import json
import logging
import os
import re
import time
import uuid
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional
from hermes_constants import get_hermes_home
from utils import atomic_json_write
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``).
CONFIG_KEY = "write_approval"
_TRUTHY_STRINGS = frozenset({"on", "true", "yes", "1", "approve", "enabled"})
# --- 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
return _normalize_enabled(cfg_get(load_config(), subsystem, CONFIG_KEY, default=False))
except Exception:
return False
def _normalize_enabled(value: Any) -> bool:
"""Coerce a config value to bool; unknown → False (gate off). The string branch
covers hand-edited configs (YAML already parses bare on/off/yes/no)."""
if isinstance(value, bool):
return value
return isinstance(value, str) and value.strip().lower() in _TRUTHY_STRINGS
# --- Pending store (file-backed) ---
def _pending_path(subsystem: str, pending_id: str) -> Path:
return get_hermes_home() / "pending" / subsystem / f"{pending_id}.json"
def _pending_files(subsystem: str) -> list:
d = _pending_path(subsystem, "").parent
return list(d.glob("*.json")) if d.exists() else []
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; ``origin`` is ``foreground`` or ``background_review``.
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:
atomic_json_write(_pending_path(subsystem, pid), record)
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."""
records: list[dict[str, Any]] = []
for p in _pending_files(subsystem):
try:
record = json.loads(p.read_text(encoding="utf-8-sig"))
if not isinstance(record, dict):
raise ValueError(f"expected a JSON object, got {type(record).__name__}")
records.append(record)
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:
data = json.loads(path.read_text(encoding="utf-8-sig"))
return data if isinstance(data, dict) else None
except Exception:
return None
def discard_pending(subsystem: str, pending_id: str) -> bool:
"""Delete a pending record. Returns True if it existed."""
try:
path = _pending_path(subsystem, pending_id)
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_path(subsystem, "").parent
if not d.exists():
return 0
with suppress(Exception):
return sum(1 for _ in d.glob("*.json"))
return 0
# --- Write origin ---
def current_origin() -> str:
"""``foreground`` or ``background_review`` — reuses the skill-provenance ContextVar
the background review fork sets; foreground turns leave it at the default."""
with suppress(Exception):
from tools.skill_provenance import get_current_write_origin
return get_current_write_origin()
return "foreground"
# --- Gate decision ---
@dataclass(slots=True, kw_only=True)
class GateDecision:
"""Result of evaluating the write gate; exactly one flag is True. ``allow``: do the real write;
``blocked``: user denied the inline prompt (``message`` says why); ``stage``: 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 _slash_review_surface() -> bool:
"""Whether the ACTIVE turn has a human who can type ``/<subsystem> pending``: the interactive
CLI, TUI/desktop (exec route), and chat-gateway sessions all answer it now. Headless worker
contexts — cron (``HERMES_CRON_SESSION``), kanban (``HERMES_KANBAN_TASK``) and the unattended
programmatic platforms (api_server, webhook delivery) — have nobody at a prompt, so the staged
hint must not name a command nobody can deliver (#98330); it names the pending dir instead."""
def _env(name: str) -> str:
try:
from gateway.session_context import get_session_env
return get_session_env(name, "") or ""
except Exception: # standalone/tests: process env is the fallback
return os.environ.get(name, "") or ""
if _env("HERMES_KANBAN_TASK").strip():
return False
try:
from utils import is_truthy_value
if is_truthy_value(_env("HERMES_CRON_SESSION")):
return False
except Exception:
pass
platform = (_env("HERMES_SESSION_PLATFORM") or os.environ.get("HERMES_PLATFORM", "")).strip().lower()
return platform not in {"webhook", "msgraph_webhook", "api_server"}
def _staged(subsystem: str) -> GateDecision:
command = f"/{subsystem} pending"
if _slash_review_surface():
where = f"review with {command}"
else:
where = (f"pending records live in {_pending_path(subsystem, '').parent} "
f"(review from an interactive CLI session with {command})")
return GateDecision(stage=True, message=(f"Staged for approval ({subsystem}.write_approval is on). "
f"Not yet saved — {where}."))
def evaluate_gate(subsystem: str, *, inline_summary: str = "", inline_detail: str = "") -> GateDecision:
"""Decide what to do with a pending write: gate off → allow; gate on + skills (any origin) or
background → stage; gate on + memory + foreground → inline prompt when an interactive channel
exists, else stage. The gate only ever delays a write, never silently refuses it; ``blocked``
is produced only when the user actively denies the inline prompt."""
if not write_approval_enabled(subsystem):
return GateDecision(allow=True)
# Skills are too big to review inline; a background write runs in a daemon thread with no user.
if subsystem == SKILLS or current_origin() == "background_review":
return _staged(subsystem)
granted = _prompt_inline_memory_approval(inline_summary, inline_detail)
if granted is None:
return _staged(MEMORY)
if granted:
return GateDecision(allow=True)
return GateDecision(blocked=True, message="Memory write denied by user. The change was not saved.")
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 (``tools.terminal_tool.set_approval_callback``) directly, not
``prompt_dangerous_approval``: that wrapper falls back to ``input()`` (deadlock-prone under
prompt_toolkit; silent deny in gateway sessions) and turns callback errors into a deny, whereas
here a missing channel or failed prompt must stage instead.
``hermes chat -q`` (and so every kanban worker), cron and unattended platforms can register a callback too,
but nobody answers it: stage at once instead of waiting the approval timeout. See #15216.
"""
from tools.approval_context import _no_user_can_answer
if _no_user_can_answer():
return None
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?"
try:
from tools.approval_prompt import callback_accepts
extra = {"title": "Save to memory?"} if callback_accepts(callback, "title") else {}
choice = callback(detail.strip() or header, f"Save to memory: {header}", allow_permanent=False, **extra)
except Exception as e:
logger.error("Inline memory approval prompt failed: %s", e)
return None
# unknown outcome → stage rather than drop
return {"once": True, "session": True, "deny": False}.get(choice)
# --- Skill-specific helpers (gist + diff for the review affordances) ---
_GIST_TEMPLATES = {"write_file": "write {file_path} in '{name}'", "remove_file": "remove {file_path} from '{name}'",
"delete": "delete skill '{name}'"}
def skill_gist(action: str, name: str, *, content: str = "", file_path: str = "",
old_string: str = "", new_string: str = "") -> str:
"""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."""
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"
return f"{'create' if action == 'create' else 'rewrite'} '{name}'{f' — {desc}' if desc else ''} ({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)"
return _GIST_TEMPLATES.get(action, "{action} '{name}'").format(action=action, name=name, file_path=file_path)
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
except Exception:
return None
# Only the import is guarded (as on main); a lookup failure propagates.
found = _find_skill(name)
return found["path"] if found else None
def _staged_base(name: str, target_label: str, staged: Optional[dict[str, dict[str, str]]]) -> str:
"""What an op diffs against: the content earlier ops of the same batch left in this file,
else the on-disk copy (empty for a file that does not exist yet)."""
files = (staged or {}).get(name)
if files and target_label in files:
return files[target_label]
skill_dir = _find_skill_path(name)
if not skill_dir:
return ""
with suppress(Exception):
p = skill_dir / target_label
if p.exists():
return p.read_text(encoding="utf-8-sig")
return ""
def skill_pending_diff(
record: dict[str, Any], staged: Optional[dict[str, dict[str, str]]] = None
) -> str:
"""Full content (create) or unified diff vs. the base file (edit/patch/write_file),
rendered by /skills diff <id> on surfaces that can show it. ``staged`` carries the file
contents the earlier ops of a batch left behind, so op *i* diffs against ops 1..*i-1* and
disk is read only for files no earlier op touched. A staged batch renders each op's diff
under its gist header (single-op path below is unchanged)."""
payload = record.get("payload", {})
action = payload.get("action", "")
name = payload.get("name", "")
if action == "batch":
return _batch_pending_diff(payload)
if action == "create":
return payload.get("content") or ""
if action not in {"edit", "patch", "write_file"}:
return {"remove_file": f"remove file: {payload.get('file_path')} from skill '{name}'",
"delete": f"delete skill '{name}'"}.get(action, f"({action} on '{name}')")
# patch/write_file target a file inside the skill; edit always targets SKILL.md.
target_label = "SKILL.md" if action == "edit" else (payload.get("file_path") or "SKILL.md")
current = _staged_base(name, target_label, staged)
if action == "patch":
old_s, new_s = payload.get("old_string") or "", payload.get("new_string") or ""
if not current:
new = f"(patch {old_s!r} → {new_s!r})"
else:
# Fold through the same matcher approve will run, so the preview can't
# fabricate a result the approve path would reject (repeated anchor without
# replace_all, whitespace-only anchor, escape drift, old_string == new_string).
folded, patch_err = _fold_patch(current, old_s, new_s, payload.get("replace_all"))
if patch_err:
return f"(patch would fail: {patch_err})"
new = folded
else:
new = payload.get("content" if action == "edit" else "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)"
def _fold_patch(base: str, old_string: str, new_string: str, replace_all: Any = False
) -> tuple[str, Optional[str]]:
"""Fold one patch op through ``fuzzy_find_and_replace`` — the SAME matcher the approve
path runs (``_patch_skill`` / ``apply_skill_pending``) — so a previewed diff is exactly
what an approval would commit, and a patch approval would reject (repeated anchor with
``replace_all`` unset, whitespace-only anchor, escape drift, ``old_string == new_string``)
renders as an explicit failed-patch note instead of a fabricated folded result.
Returns ``(folded_content, None)`` or ``(base, error)``; base is returned unchanged on
error so the caller can still render the unchanged content, matching the engine's own
``(content, 0, None, error)`` contract. Local import: keeps write_approval importable
without the tool chain (fuzzy_match is core-adjacent but guarded for safety anyway).
"""
from tools.fuzzy_match import fuzzy_find_and_replace
folded, match_count, _strategy, error = fuzzy_find_and_replace(
base, old_string, str(new_string), bool(replace_all))
if error or match_count == 0:
return base, error or "Could not find a match for old_string in the file"
return folded, None
def _fold_staged(staged: dict[str, dict[str, str]], action: str, name: str,
op: dict[str, Any]) -> None:
"""Record what one op leaves in the staged view the next op diffs against."""
if not name:
return
if action == "delete":
staged.pop(name, None)
return
label = "SKILL.md" if action == "edit" else (op.get("file_path") or "SKILL.md")
files = staged.setdefault(name, {})
if action in {"create", "edit"}:
files["SKILL.md"] = op.get("content") or ""
elif action == "write_file":
files[label] = op.get("file_content") or ""
elif action == "patch":
base = _staged_base(name, label, staged)
folded, patch_err = _fold_patch(base, op.get("old_string") or "",
op.get("new_string") or "", op.get("replace_all"))
if patch_err is None:
files[label] = folded
# On error the staged label keeps its prior content: approve would abort the batch
# at this op, so nothing later in the preview should diff against a folded result
# that will never be committed.
elif action == "remove_file":
files.pop(label, None)
def _batch_pending_diff(payload: dict[str, Any]) -> str:
"""Per-op diffs for a staged ``batch`` payload (skill_manage operations[]). Ops apply in
order, so each op reuses the single-op path above with the earlier ops' staged content as
its base (disk only for files no earlier op touched) — a patch to a skill an earlier op
created shows a real diff, which is the case this renderer exists for."""
ops = [op for op in (payload.get("operations") or []) if isinstance(op, dict)]
total = len(ops)
staged: dict[str, dict[str, str]] = {}
parts = []
for i, op in enumerate(ops):
op_action, op_name = op.get("action", ""), op.get("name") or ""
gist = skill_gist(op_action, op_name, content=op.get("content") or "",
file_path=op.get("file_path") or "",
old_string=op.get("old_string") or "",
new_string=op.get("new_string") or "")
diff = skill_pending_diff({"payload": {**op, "name": op_name}}, staged)
parts.append(f"## op {i + 1}/{total}: {gist}\n\n{diff}")
_fold_staged(staged, op_action, op_name, op)
return "\n\n".join(parts) or "(empty batch)"