Files
hermes-agent/tests/tools/test_write_approval.py
kshitijk4poor 5c3afeee45 refactor(memory): derive a payload's destructive ops in one place
"The replace/remove ops of a staged payload, single or batch" was spelled
three ways across apply_memory_pending, the CLI pending list and the pin
step, one of them re-typing _BG_DELETE_ACTIONS as a literal tuple. One
destructive_ops() helper beside the constant replaces them, and the CLI
drops isinstance guards on records this code itself wrote (the apply path
never checked them either).

Tests: the legacy/unpinned case had its own outcome and an early return
inside the parametrised "names the removed entry" test; it is its own
test now, so each name states what it asserts.
2026-09-24 15:38:22 +05:30

352 lines
15 KiB
Python

"""Tests for the memory/skill write-approval gate (tools/write_approval.py)
and the shared slash-command handlers (hermes_cli/write_approval_commands.py).
Covers the boolean write_approval gate (off by default = write freely; on =
require approval) for both subsystems, the foreground-vs-background staging
split, pending store CRUD, and the list/approve/reject/diff/approval
subcommand dispatch.
"""
import json
import os
import tempfile
import shutil
import pytest
@pytest.fixture
def hermes_home(monkeypatch):
d = tempfile.mkdtemp(prefix="hermes_wa_test_")
home = os.path.join(d, ".hermes")
os.makedirs(home)
monkeypatch.setenv("HERMES_HOME", home)
yield home
shutil.rmtree(d, ignore_errors=True)
def _set_approval(subsystem, enabled):
import hermes_cli.config as cfg
c = cfg.load_config()
c.setdefault(subsystem, {})["write_approval"] = enabled
cfg.save_config(c)
# ---------------------------------------------------------------------------
# Config resolution
# ---------------------------------------------------------------------------
def test_list_pending_skips_non_dict_record(hermes_home):
"""A parseable-but-non-object pending file must be skipped, not crash the sort."""
from tools import write_approval as wa
wa.stage_write("memory", {"action": "add", "target": "user", "content": "ok"},
summary="ok", origin="foreground")
pending_dir = wa._pending_path("memory", "").parent
(pending_dir / "bad.json").write_text('"not a record"', encoding="utf-8")
records = wa.list_pending("memory")
assert len(records) == 1 and records[0]["payload"]["content"] == "ok"
assert wa.get_pending("memory", "bad") is None
def test_normalize_enabled_coerces_values():
from tools import write_approval as wa
# Real bools pass through.
assert wa._normalize_enabled(True) is True
assert wa._normalize_enabled(False) is False
# Truthy strings → True (incl. legacy 'approve').
assert wa._normalize_enabled("on") is True
assert wa._normalize_enabled("approve") is True
assert wa._normalize_enabled("true") is True
# Everything else → False (gate off is the safe default).
assert wa._normalize_enabled("off") is False
assert wa._normalize_enabled("garbage") is False
assert wa._normalize_enabled(None) is False
# ---------------------------------------------------------------------------
# Memory gate
# ---------------------------------------------------------------------------
def test_memory_gate_off_allows_write(hermes_home):
# Default (gate off) → write straight through, no staging.
from tools.memory_tool import memory_tool, MemoryStore
from tools import write_approval as wa
store = MemoryStore(); store.load_from_disk()
r = json.loads(memory_tool("add", "user", "save me", store=store))
assert r["success"] is True
assert r["entry_count"] == 1
assert wa.pending_count("memory") == 0
def test_cli_memory_approve_without_live_agent_uses_fresh_store(hermes_home, capsys):
"""#46783: ``/memory approve`` from a context with no live agent (e.g. the
Desktop GUI) passed ``memory_store=None`` into the shared handler, which
returned "memory store unavailable" and applied nothing. The CLI handler must
fall back to a freshly loaded on-disk store, like the gateway path does."""
import json
from tools.memory_tool import memory_tool, MemoryStore
from tools import write_approval as wa
from hermes_cli.cli_commands_mixin import CLICommandsMixin
_set_approval("memory", True)
staging = MemoryStore(); staging.load_from_disk()
r = json.loads(memory_tool("add", "memory", "remember the launch date", store=staging))
assert r.get("pending_id"), r
assert wa.pending_count("memory") == 1
# Bare CLI handler with no live agent → store resolves to None pre-fix.
handler = CLICommandsMixin.__new__(CLICommandsMixin)
handler.agent = None
handler._handle_memory_command("/memory approve all")
out = capsys.readouterr().out
assert "memory store unavailable" not in out, out
assert "Approved 1" in out, out
assert wa.pending_count("memory") == 0
# The approved write landed in a freshly loaded on-disk store (MEMORY.md).
reloaded = MemoryStore(); reloaded.load_from_disk()
assert any("remember the launch date" in e for e in reloaded.memory_entries)
def test_load_on_disk_store_honors_configured_limits_and_permissions(hermes_home, monkeypatch):
"""Fresh approval stores must match the live agent's limits and target gates."""
from tools.memory_tool import MemoryStore, load_on_disk_store
# Config override path: helper picks up configured limits and store flags.
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {
"memory": {
"memory_char_limit": 999,
"user_char_limit": 444,
"memory_enabled": False,
"user_profile_enabled": True,
}
},
)
store = load_on_disk_store()
assert store.memory_char_limit == 999
assert store.user_char_limit == 444
assert store.memory_enabled is False
assert store.user_profile_enabled is True
# Failure path: config raises → defaults, never blows up.
def _boom():
raise RuntimeError("no config")
monkeypatch.setattr("hermes_cli.config.load_config", _boom)
fallback = load_on_disk_store()
defaults = MemoryStore()
assert fallback.memory_char_limit == defaults.memory_char_limit
assert fallback.user_char_limit == defaults.user_char_limit
assert fallback.memory_enabled is True
assert fallback.user_profile_enabled is True
# ---------------------------------------------------------------------------
# Shared command handler
# ---------------------------------------------------------------------------
def test_handle_approve_all(hermes_home):
from hermes_cli.write_approval_commands import handle_pending_subcommand
from tools.memory_tool import MemoryStore
from tools import write_approval as wa
store = MemoryStore(); store.load_from_disk()
wa.stage_write("memory", {"action": "add", "target": "user", "content": "a"},
summary="a", origin="foreground")
wa.stage_write("memory", {"action": "add", "target": "user", "content": "b"},
summary="b", origin="foreground")
out = handle_pending_subcommand(wa.MEMORY, ["approve", "all"], memory_store=store)
assert "Approved 2" in out
assert wa.pending_count("memory") == 0
assert len(store.user_entries) == 2
def test_handle_approve_surfaces_overwritten_entry(hermes_home):
"""#117952: on the /memory approve surface a partial-entry replace must show the
approver the FULL entry it overwrote — the store's replaced_entries field used to be
dropped by _apply_one, so the incident path stayed silent."""
from hermes_cli.write_approval_commands import handle_pending_subcommand
from tools.memory_tool import MemoryStore
from tools import write_approval as wa
store = MemoryStore(); store.load_from_disk()
entry = "RULE A: gate merges. RULE B: ci per HEAD. RULE C: never squash."
store.add("memory", entry)
wa.stage_write("memory", {"action": "batch", "target": "memory", "operations": [
{"action": "replace", "old_text": "RULE B: ci per HEAD.", "content": "RULE B: CI is per-head.",
"matched_entry": entry}]},
summary="batch", origin="background_review")
out = handle_pending_subcommand(wa.MEMORY, ["approve", "all"], memory_store=store)
assert "Approved 1" in out and entry in out
assert store.memory_entries == ["RULE B: CI is per-head."]
_KEPT = "Repo lives in ~/src/app; tests via make test"
_REVIEWED = "Staging DB: pg-staging-2 (old cluster, retiring)"
def _review_stages_remove(shape):
"""Seed memory, then stage a remove the way the unattended background review does."""
from tools.memory_tool import MemoryStore, memory_tool
from tools.skill_provenance import (reset_current_write_origin, reset_review_attended,
set_current_write_origin, set_review_attended)
store = MemoryStore(); store.load_from_disk()
for entry in (_KEPT, _REVIEWED):
assert store.add("memory", entry)["success"]
op = {"action": "remove", "old_text": "Staging DB"}
kwargs = op if shape == "single" else {"operations": [op, {"action": "add", "content": "Deploys via make ship"}]}
origin, attended = set_current_write_origin("background_review"), set_review_attended(False)
try:
staged = json.loads(memory_tool(target="memory", store=store, **kwargs))
finally:
reset_review_attended(attended)
reset_current_write_origin(origin)
assert staged["staged"] is True, staged
return store, staged["pending_id"]
@pytest.mark.parametrize("shape", ["single", "batch"])
def test_approve_refuses_staged_remove_whose_entry_changed(hermes_home, shape):
"""Approval re-ran the staged old_text search against the file as it is THEN, so it
deleted the newer entry the live agent had written in place, which the approver never saw."""
from hermes_cli.write_approval_commands import handle_pending_subcommand
from tools.memory_tool import load_on_disk_store, memory_tool
from tools import write_approval as wa
store, pid = _review_stages_remove(shape)
newer = "Staging DB: pg-staging-3 (migrated 2026-09-20, creds in vault 'stg')"
assert json.loads(memory_tool(action="replace", old_text="pg-staging-2", content=newer, store=store))["success"]
out = handle_pending_subcommand(wa.MEMORY, ["approve", pid], memory_store=load_on_disk_store())
assert load_on_disk_store().memory_entries == [_KEPT, newer], out
assert "changed since it was staged" in out
assert wa.get_pending(wa.MEMORY, pid) is not None
# The pending list shows the whole entry the write targets, not just its search string.
assert _REVIEWED in handle_pending_subcommand(wa.MEMORY, ["pending"])
@pytest.mark.parametrize("shape", ["single", "batch"])
def test_approve_names_the_entry_a_remove_deleted(hermes_home, shape):
"""Approve listed what a replace overwrote but was silent about what a remove deleted."""
from hermes_cli.write_approval_commands import handle_pending_subcommand
from tools.memory_tool import load_on_disk_store
from tools import write_approval as wa
_store, pid = _review_stages_remove(shape)
out = handle_pending_subcommand(wa.MEMORY, ["approve", pid], memory_store=load_on_disk_store())
assert _REVIEWED not in load_on_disk_store().memory_entries, out
assert _REVIEWED in out
def test_approve_refuses_unpinned_legacy_remove(hermes_home):
"""A record staged before removes were pinned to their full entry has no verifiable target,
so approve refuses it (keeping the record) instead of replaying its old_text search."""
from hermes_cli.write_approval_commands import handle_pending_subcommand
from tools.memory_tool import load_on_disk_store
from tools import write_approval as wa
_store, pid = _review_stages_remove("single")
path = wa._pending_path(wa.MEMORY, pid)
record = json.loads(path.read_text(encoding="utf-8"))
record["payload"].pop("matched_entry", None)
path.write_text(json.dumps(record), encoding="utf-8")
assert "unpinned legacy target" in handle_pending_subcommand(wa.MEMORY, ["pending"])
out = handle_pending_subcommand(wa.MEMORY, ["approve", pid], memory_store=load_on_disk_store())
assert "Approved 0" in out and "predates entry pinning" in out, out
assert _REVIEWED in load_on_disk_store().memory_entries
assert wa.get_pending(wa.MEMORY, pid) is not None
def test_handle_approval_on(hermes_home):
from hermes_cli.write_approval_commands import handle_pending_subcommand
from tools import write_approval as wa
captured = {}
out = handle_pending_subcommand(
wa.MEMORY, ["approval", "on"],
set_mode_fn=lambda enabled: captured.update(enabled=enabled),
)
assert captured["enabled"] is True
assert "on" in out
def test_handle_approval_off(hermes_home):
from hermes_cli.write_approval_commands import handle_pending_subcommand
from tools import write_approval as wa
captured = {}
out = handle_pending_subcommand(
wa.SKILLS, ["approval", "off"],
set_mode_fn=lambda enabled: captured.update(enabled=enabled),
)
assert captured["enabled"] is False
assert "off" in out
# ---------------------------------------------------------------------------
# Inline (interactive CLI) approval path — regression for the bug where the
# per-thread approval callback was never passed to prompt_dangerous_approval,
# so every gated foreground memory write was silently denied.
# ---------------------------------------------------------------------------
@pytest.fixture
def approval_callback_cleanup():
yield
from tools.terminal_tool import set_approval_callback
set_approval_callback(None)
def test_memory_inline_approve_writes(hermes_home, approval_callback_cleanup):
from tools.memory_tool import memory_tool, MemoryStore
from tools.terminal_tool import set_approval_callback
from tools import write_approval as wa
_set_approval("memory", True)
calls = []
def approve_cb(command, description, **kw):
calls.append((command, description))
return "once"
set_approval_callback(approve_cb)
store = MemoryStore(); store.load_from_disk()
r = json.loads(memory_tool("add", "memory", "approved fact", store=store))
assert r["success"] is True
assert r.get("staged") is None # real write, not staged
assert store.memory_entries == ["approved fact"]
assert wa.pending_count("memory") == 0
# The registered callback must actually be invoked (not the input() path).
assert len(calls) == 1
assert "approved fact" in calls[0][0]
def test_memory_inline_deny_blocks(hermes_home, approval_callback_cleanup):
from tools.memory_tool import memory_tool, MemoryStore
from tools.terminal_tool import set_approval_callback
from tools import write_approval as wa
_set_approval("memory", True)
set_approval_callback(lambda command, description, **kw: "deny")
store = MemoryStore(); store.load_from_disk()
r = json.loads(memory_tool("add", "memory", "denied fact", store=store))
assert r["success"] is False
assert "denied" in r["error"].lower()
assert store.memory_entries == []
assert wa.pending_count("memory") == 0 # denied, not staged
def test_memory_invalid_params_rejected_before_staging(hermes_home):
# Param validation must run BEFORE the gate so a broken write is rejected
# immediately instead of staged and failing at approve time.
from tools.memory_tool import memory_tool, MemoryStore
from tools import write_approval as wa
_set_approval("memory", True)
store = MemoryStore(); store.load_from_disk()
r = json.loads(memory_tool("add", "memory", None, store=store))
assert r["success"] is False
assert wa.pending_count("memory") == 0