| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552 |
- import asyncio
- import json
- import os
- import tempfile
- import unittest
- from pathlib import Path
- from unittest.mock import patch
- from cyber_agent.core.dream import (
- DreamScope,
- DreamScopeError,
- cross_trace_integrate,
- )
- from cyber_agent.core.memory import (
- MEMORY_IDENTITY_CONTEXT_KEY,
- MemoryConfig,
- MemoryPathError,
- compute_memory_identity,
- is_declared_memory_path,
- load_memory_files,
- resolve_memory_path,
- )
- from cyber_agent.tools.builtin.memory import dream as dream_tool
- from cyber_agent.trace.models import Trace
- class FakeDreamStore:
- def __init__(self, base_path: Path, traces):
- self.base_path = base_path
- self.traces = {trace.trace_id: trace for trace in traces}
- self.base_path.mkdir(parents=True, exist_ok=True)
- async def list_traces(self, limit=1000):
- return list(self.traces.values())[:limit]
- async def get_trace(self, trace_id):
- return self.traces.get(trace_id)
- async def get_cognition_log(self, trace_id):
- path = self._get_cognition_log_file(trace_id)
- if not path.exists():
- return {"trace_id": trace_id, "events": []}
- return json.loads(path.read_text(encoding="utf-8"))
- async def mark_reflections_consumed(
- self, trace_id, reflections, consumed_at
- ):
- log = await self.get_cognition_log(trace_id)
- wanted = {
- (tuple(item.get("sequence_range") or []), item.get("summary", ""))
- for item in reflections
- }
- changed = 0
- for event in log.get("events", []):
- key = (
- tuple(event.get("sequence_range") or []),
- event.get("summary", ""),
- )
- if (
- event.get("type") == "reflection"
- and not event.get("consumed_at")
- and key in wanted
- ):
- event["consumed_at"] = consumed_at
- changed += 1
- self._get_cognition_log_file(trace_id).write_text(
- json.dumps(log),
- encoding="utf-8",
- )
- return changed
- def _get_cognition_log_file(self, trace_id):
- path = self.base_path / trace_id / "cognition_log.json"
- path.parent.mkdir(parents=True, exist_ok=True)
- return path
- def set_log(self, trace_id, events):
- self._get_cognition_log_file(trace_id).write_text(
- json.dumps({"trace_id": trace_id, "events": events}),
- encoding="utf-8",
- )
- def make_trace(trace_id, scope, *, uid=None, agent_type=None, identity=None):
- return Trace(
- trace_id=trace_id,
- mode="agent",
- uid=scope.uid if uid is None else uid,
- agent_type=scope.agent_type if agent_type is None else agent_type,
- context={
- MEMORY_IDENTITY_CONTEXT_KEY: (
- scope.memory_identity if identity is None else identity
- )
- },
- )
- def reflection(summary):
- return {
- "type": "reflection",
- "timestamp": f"ts-{summary}",
- "sequence_range": [1, 2],
- "summary": summary,
- }
- class MemorySecurityTests(unittest.TestCase):
- def test_identity_uses_real_base_and_file_patterns(self):
- with tempfile.TemporaryDirectory() as tmp:
- base = Path(tmp) / "memory"
- base.mkdir()
- alias = Path(tmp) / "memory-alias"
- alias.symlink_to(base, target_is_directory=True)
- first = MemoryConfig(
- base_path=str(base),
- files={"journals/**/*.md": "journals", "identity.md": "self"},
- dream_prompt="first prompt",
- )
- second = MemoryConfig(
- base_path=str(alias),
- files={"identity.md": "different purpose", "journals/**/*.md": ""},
- dream_prompt="different prompt",
- )
- self.assertEqual(
- compute_memory_identity(first), compute_memory_identity(second)
- )
- third = MemoryConfig(
- base_path=str(base),
- files={"identity.md": "self"},
- )
- self.assertNotEqual(
- compute_memory_identity(first), compute_memory_identity(third)
- )
- def test_declared_glob_does_not_let_star_cross_directories(self):
- config = MemoryConfig(
- base_path="/tmp/not-used",
- files={"relationships/*.md": "people", "journals/**/*.md": "logs"},
- )
- self.assertTrue(is_declared_memory_path(config, "relationships/a.md"))
- self.assertFalse(
- is_declared_memory_path(config, "relationships/private/a.md")
- )
- self.assertTrue(is_declared_memory_path(config, "journals/2026/07/a.md"))
- def test_resolution_rejects_non_normal_paths_and_symlink_escape(self):
- with tempfile.TemporaryDirectory() as tmp:
- root = Path(tmp)
- base = root / "memory"
- outside = root / "outside"
- base.mkdir()
- outside.mkdir()
- (base / "link").symlink_to(outside, target_is_directory=True)
- config = MemoryConfig(base_path=str(base), files={"**/*.md": "all"})
- for unsafe in (
- "../outside/escape.md",
- "./identity.md",
- "/tmp/escape.md",
- "nested//identity.md",
- "nested\\identity.md",
- "link/escape.md",
- ):
- with self.subTest(path=unsafe):
- with self.assertRaises(MemoryPathError):
- resolve_memory_path(config, unsafe)
- def test_loader_does_not_follow_symlink_outside_base(self):
- with tempfile.TemporaryDirectory() as tmp:
- root = Path(tmp)
- base = root / "memory"
- outside = root / "outside"
- base.mkdir()
- outside.mkdir()
- (outside / "secret.md").write_text("secret", encoding="utf-8")
- (base / "safe.md").write_text("safe", encoding="utf-8")
- (base / "linked.md").symlink_to(outside / "secret.md")
- config = MemoryConfig(base_path=str(base), files={"*.md": "root"})
- loaded = load_memory_files(config)
- self.assertEqual(loaded, [("safe.md", "root", "safe")])
- class DreamIsolationTests(unittest.IsolatedAsyncioTestCase):
- async def asyncSetUp(self):
- self.tmp = tempfile.TemporaryDirectory()
- self.root = Path(self.tmp.name)
- self.memory_root = self.root / "memory"
- self.memory_root.mkdir()
- self.config = MemoryConfig(
- base_path=str(self.memory_root),
- files={"identity.md": "self", "journals/**/*.md": "logs"},
- )
- self.scope = DreamScope(
- uid="user-1",
- agent_type="writer",
- memory_identity=compute_memory_identity(self.config),
- )
- async def asyncTearDown(self):
- self.tmp.cleanup()
- async def test_filters_uid_agent_type_and_memory_identity_together(self):
- matching = make_trace("matching", self.scope)
- wrong_uid = make_trace("wrong-uid", self.scope, uid="user-2")
- wrong_agent = make_trace("wrong-agent", self.scope, agent_type="researcher")
- wrong_memory = make_trace("wrong-memory", self.scope, identity="memory-v1:other")
- store = FakeDreamStore(
- self.root / "traces",
- [matching, wrong_uid, wrong_agent, wrong_memory],
- )
- for trace in (matching, wrong_uid, wrong_agent, wrong_memory):
- store.set_log(trace.trace_id, [reflection(trace.trace_id)])
- calls = []
- async def llm_call(**kwargs):
- calls.append(kwargs)
- return {"content": '{"updates": [], "reasoning": "merged"}'}
- consumed, updated, reasoning = await cross_trace_integrate(
- store, llm_call, self.config, self.scope
- )
- self.assertEqual((consumed, updated, reasoning), (1, [], "merged"))
- prompt = calls[0]["messages"][1]["content"]
- self.assertIn("matching", prompt)
- self.assertNotIn("wrong-uid", prompt)
- self.assertNotIn("wrong-agent", prompt)
- self.assertNotIn("wrong-memory", prompt)
- self.assertTrue(
- (await store.get_cognition_log("matching"))["events"][0].get(
- "consumed_at"
- )
- )
- for trace_id in ("wrong-uid", "wrong-agent", "wrong-memory"):
- self.assertNotIn(
- "consumed_at",
- (await store.get_cognition_log(trace_id))["events"][0],
- )
- async def test_concurrent_dreams_keep_memory_configs_isolated(self):
- other_root = self.root / "other-memory"
- other_root.mkdir()
- other_config = MemoryConfig(
- base_path=str(other_root),
- files={"identity.md": "self"},
- )
- other_scope = DreamScope(
- uid=self.scope.uid,
- agent_type=self.scope.agent_type,
- memory_identity=compute_memory_identity(other_config),
- )
- first_trace = make_trace("first", self.scope)
- second_trace = make_trace("second", other_scope)
- store = FakeDreamStore(
- self.root / "traces", [first_trace, second_trace]
- )
- store.set_log(first_trace.trace_id, [reflection("first-summary")])
- store.set_log(second_trace.trace_id, [reflection("second-summary")])
- async def first_llm(**kwargs):
- await asyncio.sleep(0)
- self.assertIn("first-summary", kwargs["messages"][1]["content"])
- self.assertNotIn("second-summary", kwargs["messages"][1]["content"])
- return {
- "content": json.dumps(
- {
- "updates": [
- {"path": "identity.md", "new_content": "first"}
- ],
- "reasoning": "first",
- }
- )
- }
- async def second_llm(**kwargs):
- await asyncio.sleep(0)
- self.assertIn("second-summary", kwargs["messages"][1]["content"])
- self.assertNotIn("first-summary", kwargs["messages"][1]["content"])
- return {
- "content": json.dumps(
- {
- "updates": [
- {"path": "identity.md", "new_content": "second"}
- ],
- "reasoning": "second",
- }
- )
- }
- first_result, second_result = await asyncio.gather(
- cross_trace_integrate(
- store, first_llm, self.config, self.scope
- ),
- cross_trace_integrate(
- store, second_llm, other_config, other_scope
- ),
- )
- self.assertEqual(first_result[0], 1)
- self.assertEqual(second_result[0], 1)
- self.assertEqual(
- (self.memory_root / "identity.md").read_text(encoding="utf-8"),
- "first",
- )
- self.assertEqual(
- (other_root / "identity.md").read_text(encoding="utf-8"),
- "second",
- )
- async def test_scope_identity_must_match_memory_config(self):
- trace = make_trace("trace", self.scope)
- store = FakeDreamStore(self.root / "traces", [trace])
- store.set_log(trace.trace_id, [reflection("summary")])
- wrong_scope = DreamScope(
- uid=self.scope.uid,
- agent_type=self.scope.agent_type,
- memory_identity="memory-v1:wrong",
- )
- async def llm_call(**kwargs):
- self.fail("LLM must not run for a mismatched scope")
- with self.assertRaises(DreamScopeError):
- await cross_trace_integrate(
- store, llm_call, self.config, wrong_scope
- )
- async def test_allowed_updates_are_written_then_reflection_is_consumed(self):
- trace = make_trace("trace", self.scope)
- store = FakeDreamStore(self.root / "traces", [trace])
- store.set_log(trace.trace_id, [reflection("summary")])
- async def llm_call(**kwargs):
- return {
- "content": json.dumps(
- {
- "updates": [
- {"path": "identity.md", "new_content": "new identity"},
- {
- "path": "journals/2026/entry.md",
- "new_content": "new journal",
- },
- ],
- "reasoning": "useful",
- }
- )
- }
- result = await cross_trace_integrate(
- store, llm_call, self.config, self.scope
- )
- self.assertEqual(
- result, (1, ["identity.md", "journals/2026/entry.md"], "useful")
- )
- self.assertEqual(
- (self.memory_root / "identity.md").read_text(encoding="utf-8"),
- "new identity",
- )
- self.assertEqual(
- (self.memory_root / "journals/2026/entry.md").read_text(
- encoding="utf-8"
- ),
- "new journal",
- )
- self.assertTrue(
- (await store.get_cognition_log(trace.trace_id))["events"][0].get(
- "consumed_at"
- )
- )
- self.assertEqual(list(self.memory_root.rglob("*.tmp")), [])
- async def test_invalid_update_rejects_entire_plan_without_consuming(self):
- trace = make_trace("trace", self.scope)
- store = FakeDreamStore(self.root / "traces", [trace])
- unsafe_paths = [
- "../memory-evil/escape.md",
- "/tmp/escape.md",
- "not-declared.txt",
- "journals/../escape.md",
- "journals\\escape.md",
- ]
- for index, unsafe_path in enumerate(unsafe_paths):
- with self.subTest(path=unsafe_path):
- event = reflection(f"summary-{index}")
- store.set_log(trace.trace_id, [event])
- async def llm_call(**kwargs):
- return {
- "content": json.dumps(
- {
- "updates": [
- {
- "path": unsafe_path,
- "new_content": "must not write",
- }
- ],
- "reasoning": "bad",
- }
- )
- }
- result = await cross_trace_integrate(
- store, llm_call, self.config, self.scope
- )
- self.assertEqual(result, (0, [], ""))
- self.assertNotIn(
- "consumed_at",
- (await store.get_cognition_log(trace.trace_id))["events"][0],
- )
- self.assertFalse((self.root / "memory-evil" / "escape.md").exists())
- async def test_symlink_escape_is_rejected_without_consuming(self):
- outside = self.root / "outside"
- outside.mkdir()
- (self.memory_root / "journals").symlink_to(
- outside, target_is_directory=True
- )
- trace = make_trace("trace", self.scope)
- store = FakeDreamStore(self.root / "traces", [trace])
- store.set_log(trace.trace_id, [reflection("summary")])
- async def llm_call(**kwargs):
- return {
- "content": json.dumps(
- {
- "updates": [
- {
- "path": "journals/escape.md",
- "new_content": "must not escape",
- }
- ],
- "reasoning": "bad",
- }
- )
- }
- result = await cross_trace_integrate(
- store, llm_call, self.config, self.scope
- )
- self.assertEqual(result, (0, [], ""))
- self.assertFalse((outside / "escape.md").exists())
- self.assertNotIn(
- "consumed_at",
- (await store.get_cognition_log(trace.trace_id))["events"][0],
- )
- async def test_failed_atomic_replace_preserves_old_file_and_does_not_consume(self):
- target = self.memory_root / "identity.md"
- target.write_text("old identity", encoding="utf-8")
- trace = make_trace("trace", self.scope)
- store = FakeDreamStore(self.root / "traces", [trace])
- store.set_log(trace.trace_id, [reflection("summary")])
- async def llm_call(**kwargs):
- return {
- "content": json.dumps(
- {
- "updates": [
- {"path": "identity.md", "new_content": "new identity"}
- ],
- "reasoning": "useful",
- }
- )
- }
- with patch("cyber_agent.core.dream.os.replace", side_effect=OSError("boom")):
- with self.assertRaises(OSError):
- await cross_trace_integrate(
- store, llm_call, self.config, self.scope
- )
- self.assertEqual(target.read_text(encoding="utf-8"), "old identity")
- self.assertNotIn(
- "consumed_at",
- (await store.get_cognition_log(trace.trace_id))["events"][0],
- )
- self.assertEqual(list(self.memory_root.glob(".identity.md.*.tmp")), [])
- async def test_multi_file_failure_rolls_back_every_published_update(self):
- identity = self.memory_root / "identity.md"
- journal = self.memory_root / "journals" / "today.md"
- journal.parent.mkdir()
- identity.write_text("old identity", encoding="utf-8")
- journal.write_text("old journal", encoding="utf-8")
- trace = make_trace("trace", self.scope)
- store = FakeDreamStore(self.root / "traces", [trace])
- store.set_log(trace.trace_id, [reflection("summary")])
- async def llm_call(**kwargs):
- return {
- "content": json.dumps({
- "updates": [
- {"path": "identity.md", "new_content": "new identity"},
- {"path": "journals/today.md", "new_content": "new journal"},
- ],
- "reasoning": "useful",
- })
- }
- real_replace = os.replace
- replace_calls = 0
- def fail_second_target(source, destination):
- nonlocal replace_calls
- replace_calls += 1
- if replace_calls == 2:
- raise OSError("second replacement failed")
- return real_replace(source, destination)
- with patch(
- "cyber_agent.core.dream.os.replace",
- side_effect=fail_second_target,
- ):
- with self.assertRaisesRegex(OSError, "second replacement failed"):
- await cross_trace_integrate(
- store, llm_call, self.config, self.scope
- )
- self.assertEqual("old identity", identity.read_text(encoding="utf-8"))
- self.assertEqual("old journal", journal.read_text(encoding="utf-8"))
- self.assertNotIn(
- "consumed_at",
- (await store.get_cognition_log(trace.trace_id))["events"][0],
- )
- self.assertEqual(
- [],
- list(self.memory_root.rglob("*.dream-stage")),
- )
- async def test_builtin_dream_requires_memory_config_and_scope_in_context(self):
- class Runner:
- trace_store = object()
- llm_call = object()
- missing = await dream_tool(context={"runner": Runner()})
- self.assertEqual(missing.error, "memory_config not in tool context")
- missing_scope = await dream_tool(
- context={"runner": Runner(), "memory_config": self.config}
- )
- self.assertEqual(missing_scope.error, "dream_scope not in tool context")
- if __name__ == "__main__":
- unittest.main()
|