Files
hermes-agent/tests/plugins/memory/test_holographic_project_scope.py
T

310 lines
18 KiB
Python

"""Real SQLite source-linked tools, isolated from every user's memory store."""
import json
import pytest
from hermes_constants import get_hermes_home
from hermes_state import SessionDB
from plugins.memory.holographic import HolographicMemoryProvider
@pytest.fixture
def project_env(tmp_path):
home = get_hermes_home()
db = SessionDB(home / "state.db")
roots = {name: tmp_path / name for name in ("alpha", "beta")}
for root in roots.values():
root.mkdir()
for sid, project in (("current", "alpha"), ("source", "alpha"), ("other", "beta")):
db.create_session(sid, source="desktop", cwd=str(roots[project]))
mid = db.append_message("source", role="user", content='Use "Atlas" for deployment')
other = db.append_message("other", role="user", content="FOREIGN_PRIVATE deployment")
provider = HolographicMemoryProvider({"project_scoped": True, "hrr_dim": 64,
"db_path": str(tmp_path / "forbidden-global.db")})
provider.initialize("current")
yield provider, db, mid, other, roots
provider.shutdown()
db.close()
def call(provider, action, **args):
return json.loads(provider.handle_tool_call("fact_store", {"action": action, **args}))
def source(mid, sid="source", **extra):
return {"session_id": sid, "message_id": mid, **extra}
def test_source_linked_tool_roundtrip_and_revocation(project_env):
provider, db, mid, other, roots = project_env
rejected = call(provider, "add", content="unlinked candidate")
assert rejected["success"] is False
assert rejected["status"] == "sources_required"
added = call(provider, "add", content='"Atlas" deployment candidate', sources=[source(mid)])
assert added["success"] and added["status"] == "candidate"
assert added["identity"]["current_session_id"] == "current"
fact_id = added["fact_id"]
for query in ({"action": "list"}, {"action": "search", "query": "Atlas"},
{"action": "probe", "entity": "Atlas"}, {"action": "related", "entity": "Atlas"},
{"action": "reason", "entities": ["Atlas"]}):
result = call(provider, **query)
rows = result.get("facts", result.get("results"))
assert result["success"] and any(r["fact_id"] == fact_id for r in rows)
assert all(r["assertion"] == "candidate_not_confirmed" and r["sources"] for r in rows)
denied = call(provider, "add", content="FOREIGN_PRIVATE", sources=[source(other, "other")])
assert not denied["success"]
assert "FOREIGN_PRIVATE" not in json.dumps(denied)
assert not (roots["alpha"].parent / "forbidden-global.db").exists()
assert not (get_hermes_home() / "memory_store.db").exists()
db._conn.execute("UPDATE messages SET content=? WHERE id=?", ("new deployment source", mid))
db._conn.commit()
for query in ({"action": "list"}, {"action": "search", "query": "Atlas"},
{"action": "probe", "entity": "Atlas"}, {"action": "related", "entity": "Atlas"},
{"action": "reason", "entities": ["Atlas"]}, {"action": "contradict"}):
result = call(provider, **query)
assert result["success"]
assert "Atlas" not in json.dumps(result)
fresh = call(provider, "add", content="new candidate", sources=[source(mid)])
assert fresh["success"]
db._conn.execute("DELETE FROM messages WHERE id=?", (mid,))
db._conn.commit()
assert call(provider, "list")["facts"] == []
def test_project_switch_feedback_and_unlinked_hooks(project_env):
provider, db, mid, other, roots = project_env
first = call(provider, "add", content="alpha candidate", sources=[source(mid)])
alpha_path = provider._store.db_path
feedback = json.loads(provider.handle_tool_call("fact_feedback", {"action": "helpful", "fact_ref": first["fact_ref"]}))
assert feedback["success"] and feedback["new_trust"] > feedback["old_trust"]
assert feedback["assertion"] == "candidate_not_confirmed"
# Updating current-session ownership must invalidate a live provider's cached store.
db._conn.execute("UPDATE sessions SET cwd=? WHERE id='current'", (str(roots["beta"]),))
db._conn.commit()
assert call(provider, "list")["facts"] == []
assert provider._store.db_path != alpha_path
denied = json.loads(provider.handle_tool_call("fact_feedback", {"action": "helpful", "fact_ref": first["fact_ref"]}))
assert not denied["success"]
second = call(provider, "add", content="beta candidate", sources=[source(other, "other")])
assert second["success"]
provider._config["auto_extract"] = True
provider.on_memory_write("add", "user", "HOOK_PRIVATE")
provider.on_session_end([{"role": "user", "content": "I prefer HOOK_PRIVATE"}])
assert provider.prefetch("beta", session_id="other") == ""
assert "HOOK_PRIVATE" not in json.dumps(call(provider, "list"))
db._conn.execute("UPDATE sessions SET cwd=NULL WHERE id='current'")
db._conn.commit()
assert not call(provider, "list")["success"]
assert not call(provider, "add", content="no project", sources=[source(other, "other")])["success"]
assert not (get_hermes_home() / "memory_store.db").exists()
def test_source_safety_atomicity_and_legacy_contract(project_env, tmp_path, monkeypatch):
provider, db, mid, other, roots = project_env
for kwargs in ({"content": "password=hunter2"}, {"role": "tool", "content": "tool text"},
{"content": "hidden source", "display_kind": "hidden"},
{"content": [{"type": "image_url", "image_url": {"url": "data:image/png;base64,PRIVATE"}}]}):
role = kwargs.pop("role", "user")
unsafe = db.append_message("source", role=role, **kwargs)
assert not call(provider, "add", content="unsafe candidate", sources=[source(unsafe)])["success"]
for ref in (source(mid, content_hash="0" * 64), source(str(mid)), source(mid, content_hash="BAD")):
assert not call(provider, "add", content="invalid link", sources=[ref])["success"]
added = call(provider, "add", content="safe candidate", sources=[source(mid)])
assert added["success"]
assert not call(provider, "update", fact_ref=added["fact_ref"], content="edited without source")["success"]
updated = call(provider, "update", fact_ref=added["fact_ref"], content="updated candidate", sources=[source(mid)])
assert updated["success"] and updated["updated"]
store = provider._store
store._conn.execute("""CREATE TRIGGER reject_source BEFORE INSERT ON fact_sources
BEGIN SELECT RAISE(ABORT, 'source mapping failed'); END""")
assert not call(provider, "add", content="atomic rollback candidate", sources=[source(mid)])["success"]
assert not store._conn.execute("SELECT 1 FROM facts WHERE content='atomic rollback candidate'").fetchone()
store._conn.execute("DROP TRIGGER reject_source")
unlinked = store.add_fact("unlinked internal candidate")
assert "unlinked internal" not in json.dumps(call(provider, "list"))
assert not json.loads(provider.handle_tool_call("fact_feedback", {"action": "helpful", "fact_id": unlinked}))["success"]
from tools.session_search_project import ProjectRecall
original = ProjectRecall.response
def revoke_before_response(recall, **payload):
db._conn.execute("UPDATE messages SET active=0,compacted=0 WHERE id=?", (mid,))
db._conn.commit()
return original(recall, **payload)
with monkeypatch.context() as patcher:
patcher.setattr(ProjectRecall, "response", revoke_before_response)
race = call(provider, "add", content="raced candidate", sources=[source(mid)])
assert not race["success"] and race["status"] == "source_revoked"
assert not store._conn.execute("SELECT 1 FROM facts WHERE content='raced candidate'").fetchone()
scoped_schema = provider.get_tool_schemas()[0]
assert "candidate" in scoped_schema["description"].lower()
assert "sources" in scoped_schema["parameters"]["properties"]
legacy = HolographicMemoryProvider({"project_scoped": "false", "db_path": str(tmp_path / "legacy.db"), "hrr_dim": 64})
try:
legacy.initialize("legacy")
result = call(legacy, "add", content="legacy source free")
assert result["status"] == "added"
assert call(legacy, "list")["facts"][0]["content"] == "legacy source free"
legacy.on_memory_write("add", "user", "legacy mirror")
assert "legacy mirror" in json.dumps(call(legacy, "list"))
assert "legacy source free" in legacy.prefetch("legacy")
assert legacy.get_tool_schemas()[0]["description"] != scoped_schema["description"]
finally:
legacy.shutdown()
def test_invalid_feedback_scope_race_and_remove(project_env, monkeypatch):
provider, db, mid, other, roots = project_env
added = call(provider, "add", content="stable candidate", sources=[source(mid)])
before = call(provider, "list")["facts"][0]["trust_score"]
denied = json.loads(provider.handle_tool_call("fact_feedback", {"action": "bogus", "fact_ref": added["fact_ref"]}))
assert not denied["success"] and denied["status"] == "invalid_request"
assert call(provider, "list")["facts"][0]["trust_score"] == before
from tools.session_search_project import ProjectRecall
original = ProjectRecall.response
def move_before_response(recall, **payload):
db._conn.execute("UPDATE sessions SET cwd=? WHERE id='current'", (str(roots["beta"]),))
db._conn.commit()
return original(recall, **payload)
store = provider._store
with monkeypatch.context() as patcher:
patcher.setattr(ProjectRecall, "response", move_before_response)
race = call(provider, "add", content="scope raced candidate", sources=[source(mid)])
assert not race["success"] and race["status"] == "scope_changed"
assert not store._conn.execute("SELECT 1 FROM facts WHERE content='scope raced candidate'").fetchone()
db._conn.execute("UPDATE sessions SET cwd=? WHERE id='current'", (str(roots["alpha"]),))
db._conn.commit()
removed = call(provider, "remove", fact_ref=added["fact_ref"])
assert removed["success"] and removed["removed"]
assert call(provider, "list")["facts"] == []
assert store.sources(added["fact_id"]) == []
foreign_home = roots["alpha"].parent / "another-profile"
foreign_home.mkdir()
with monkeypatch.context() as patcher:
patcher.setenv("HERMES_HOME", str(foreign_home))
assert call(provider, "list")["status"] == "profile_changed"
assert not (foreign_home / "state.db").exists()
def test_session_switch_releases_old_store_and_rebinds(project_env):
provider, db, mid, other, roots = project_env
assert call(provider, "add", content="alpha private", sources=[source(mid)])["success"]
old_store = provider._store
config = dict(provider._config)
provider.on_session_switch("other", parent_session_id="current", reset=True)
result = call(provider, "list")
assert result["success"] and result["facts"] == []
assert result["identity"]["current_session_id"] == "other"
assert old_store._entry is None
assert provider._config == config
provider.on_session_switch("current", rewound=True)
assert call(provider, "list")["facts"][0]["content"] == "alpha private"
@pytest.mark.parametrize("owner,other_owner", [("A", "B"), (None, ""), (None, "None")])
def test_same_project_same_content_is_principal_isolated(project_env, owner, other_owner):
provider, db, mid, other, roots = project_env
db._conn.execute("UPDATE sessions SET user_id=? WHERE id IN ('current','source')", (owner,))
db._conn.commit()
db.create_session("user-b", source="desktop", cwd=str(roots["alpha"]), user_id=other_owner)
b_mid = db.append_message("user-b", role="user", content="B source")
a = call(provider, "add", content="same candidate", tags="A_PRIVATE", sources=[source(mid)])
assert a["success"]
a_path = provider._store.db_path
b_provider = HolographicMemoryProvider({"project_scoped": True, "hrr_dim": 64})
try:
b_provider.initialize("user-b")
b = call(b_provider, "add", content="same candidate", tags="B_TAG", sources=[source(b_mid, "user-b")])
assert b["success"]
b_rows = call(b_provider, "list")
assert "A_PRIVATE" not in json.dumps(b_rows)
assert b_rows["facts"][0]["tags"] == "B_TAG"
assert b_provider._store.db_path != a_path
a_rows = call(provider, "list")["facts"]
assert len(a_rows) == 1 and a_rows[0]["tags"] == "A_PRIVATE"
assert a_rows[0]["sources"][0]["session_id"] == "source"
assert not call(b_provider, "add", content="foreign source", sources=[source(mid)])["success"]
assert not call(b_provider, "remove", fact_ref=a["fact_ref"], fact_id=b["fact_id"])["success"]
assert call(b_provider, "list")["facts"][0]["fact_ref"] == b["fact_ref"]
finally:
b_provider.shutdown()
def test_old_project_ref_cannot_mutate_colliding_fact_id(project_env):
provider, db, mid, other, roots = project_env
a = call(provider, "add", content="A candidate", sources=[source(mid)])
db._conn.execute("UPDATE sessions SET cwd=? WHERE id='current'", (str(roots["beta"]),))
db._conn.commit()
b = call(provider, "add", content="B candidate", sources=[source(other, "other")])
assert a["fact_id"] == b["fact_id"] == 1
before = call(provider, "list")["facts"]
old_ref = a.get("fact_ref", str(a["fact_id"]))
for tool, action in (("fact_feedback", "helpful"), ("fact_store", "remove"), ("fact_store", "update")):
result = json.loads(provider.handle_tool_call(tool, {
"action": action, "fact_ref": old_ref, "fact_id": b["fact_id"], "tags": "WRONG"}))
assert not result["success"]
assert result["status"] == "invalid_fact_ref"
assert call(provider, "list")["facts"] == before
def test_fact_ref_version_sources_feedback_and_recreated_store(project_env):
provider, db, mid, other, roots = project_env
a = call(provider, "add", content="version one", sources=[source(mid)])
ref = a["fact_ref"]
assert call(provider, "list")["facts"][0]["fact_ref"] == ref
assert call(provider, "list")["coverage"]["complete"] is False
for tool, action in (("fact_feedback", "helpful"), ("fact_store", "update"), ("fact_store", "remove")):
denied = json.loads(provider.handle_tool_call(tool, {"action": action, "fact_id": a["fact_id"]}))
assert not denied["success"] and denied["status"] == "fact_ref_required"
feedback = json.loads(provider.handle_tool_call("fact_feedback", {"action": "helpful", "fact_ref": ref}))
assert feedback["success"] and feedback["fact_ref"] == ref
updated = call(provider, "update", fact_ref=ref, content="version two", sources=[source(mid)])
assert updated["success"] and updated["fact_ref"] != ref
assert call(provider, "remove", fact_ref=ref)["status"] == "invalid_fact_ref"
mid2 = db.append_message("source", role="user", content="replacement source")
replaced = call(provider, "update", fact_ref=updated["fact_ref"], sources=[source(mid2)])
assert replaced["success"] and replaced["fact_ref"] != updated["fact_ref"]
assert call(provider, "remove", fact_ref=updated["fact_ref"])["status"] == "invalid_fact_ref"
path = provider._store.db_path
provider.shutdown()
provider.initialize("current")
assert call(provider, "list")["facts"][0]["fact_ref"] == replaced["fact_ref"]
provider.shutdown()
path.unlink()
provider.initialize("current")
recreated = call(provider, "add", content="version two", sources=[source(mid2)])
assert recreated["fact_id"] == a["fact_id"]
assert recreated["fact_ref"] != replaced["fact_ref"]
assert call(provider, "remove", fact_ref=replaced["fact_ref"])["status"] == "invalid_fact_ref"
schemas = {s["name"]: s["parameters"] for s in provider.get_tool_schemas()}
assert "fact_ref" in schemas["fact_store"]["properties"]
assert "fact_id" not in schemas["fact_store"]["properties"]
assert "fact_ref" in schemas["fact_feedback"]["required"]
def test_trusted_caller_context_fails_closed_during_queued_switch(project_env):
provider, db, mid, other, roots = project_env
assert call(provider, "add", content="queued old candidate", sources=[source(mid)])["success"]
result = json.loads(provider.handle_tool_call("fact_store", {"action": "list", "session_id": "current"}, session_id="other"))
assert not result["success"] and result["status"] == "session_changed"
assert "queued old candidate" not in json.dumps(result)
# Model arguments cannot authorize a rebind, nor override trusted metadata.
result = json.loads(provider.handle_tool_call("fact_store", {"action": "list", "session_id": "other", "user_id": "B"}, session_id="current"))
assert result["success"] and result["facts"][0]["content"] == "queued old candidate"
provider.on_session_switch("other")
result = json.loads(provider.handle_tool_call("fact_store", {"action": "list"}, session_id="other"))
assert result["success"] and result["facts"] == []
def test_principal_changed_before_commit_rolls_back(project_env, monkeypatch):
provider, db, mid, other, roots = project_env
from tools.session_search_project import ProjectRecall
original = ProjectRecall.response
def change_after_response(recall, **payload):
response = original(recall, **payload)
db._conn.execute("UPDATE sessions SET user_id='B' WHERE id='current'")
db._conn.commit()
return response
with monkeypatch.context() as patcher:
patcher.setattr(ProjectRecall, "response", change_after_response)
result = call(provider, "add", content="principal raced candidate", sources=[source(mid)])
assert not result["success"] and result["status"] == "scope_changed"
assert not provider._store._conn.execute("SELECT 1 FROM facts WHERE content='principal raced candidate'").fetchone()