test_dream_security.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552
  1. import asyncio
  2. import json
  3. import os
  4. import tempfile
  5. import unittest
  6. from pathlib import Path
  7. from unittest.mock import patch
  8. from cyber_agent.core.dream import (
  9. DreamScope,
  10. DreamScopeError,
  11. cross_trace_integrate,
  12. )
  13. from cyber_agent.core.memory import (
  14. MEMORY_IDENTITY_CONTEXT_KEY,
  15. MemoryConfig,
  16. MemoryPathError,
  17. compute_memory_identity,
  18. is_declared_memory_path,
  19. load_memory_files,
  20. resolve_memory_path,
  21. )
  22. from cyber_agent.tools.builtin.memory import dream as dream_tool
  23. from cyber_agent.trace.models import Trace
  24. class FakeDreamStore:
  25. def __init__(self, base_path: Path, traces):
  26. self.base_path = base_path
  27. self.traces = {trace.trace_id: trace for trace in traces}
  28. self.base_path.mkdir(parents=True, exist_ok=True)
  29. async def list_traces(self, limit=1000):
  30. return list(self.traces.values())[:limit]
  31. async def get_trace(self, trace_id):
  32. return self.traces.get(trace_id)
  33. async def get_cognition_log(self, trace_id):
  34. path = self._get_cognition_log_file(trace_id)
  35. if not path.exists():
  36. return {"trace_id": trace_id, "events": []}
  37. return json.loads(path.read_text(encoding="utf-8"))
  38. async def mark_reflections_consumed(
  39. self, trace_id, reflections, consumed_at
  40. ):
  41. log = await self.get_cognition_log(trace_id)
  42. wanted = {
  43. (tuple(item.get("sequence_range") or []), item.get("summary", ""))
  44. for item in reflections
  45. }
  46. changed = 0
  47. for event in log.get("events", []):
  48. key = (
  49. tuple(event.get("sequence_range") or []),
  50. event.get("summary", ""),
  51. )
  52. if (
  53. event.get("type") == "reflection"
  54. and not event.get("consumed_at")
  55. and key in wanted
  56. ):
  57. event["consumed_at"] = consumed_at
  58. changed += 1
  59. self._get_cognition_log_file(trace_id).write_text(
  60. json.dumps(log),
  61. encoding="utf-8",
  62. )
  63. return changed
  64. def _get_cognition_log_file(self, trace_id):
  65. path = self.base_path / trace_id / "cognition_log.json"
  66. path.parent.mkdir(parents=True, exist_ok=True)
  67. return path
  68. def set_log(self, trace_id, events):
  69. self._get_cognition_log_file(trace_id).write_text(
  70. json.dumps({"trace_id": trace_id, "events": events}),
  71. encoding="utf-8",
  72. )
  73. def make_trace(trace_id, scope, *, uid=None, agent_type=None, identity=None):
  74. return Trace(
  75. trace_id=trace_id,
  76. mode="agent",
  77. uid=scope.uid if uid is None else uid,
  78. agent_type=scope.agent_type if agent_type is None else agent_type,
  79. context={
  80. MEMORY_IDENTITY_CONTEXT_KEY: (
  81. scope.memory_identity if identity is None else identity
  82. )
  83. },
  84. )
  85. def reflection(summary):
  86. return {
  87. "type": "reflection",
  88. "timestamp": f"ts-{summary}",
  89. "sequence_range": [1, 2],
  90. "summary": summary,
  91. }
  92. class MemorySecurityTests(unittest.TestCase):
  93. def test_identity_uses_real_base_and_file_patterns(self):
  94. with tempfile.TemporaryDirectory() as tmp:
  95. base = Path(tmp) / "memory"
  96. base.mkdir()
  97. alias = Path(tmp) / "memory-alias"
  98. alias.symlink_to(base, target_is_directory=True)
  99. first = MemoryConfig(
  100. base_path=str(base),
  101. files={"journals/**/*.md": "journals", "identity.md": "self"},
  102. dream_prompt="first prompt",
  103. )
  104. second = MemoryConfig(
  105. base_path=str(alias),
  106. files={"identity.md": "different purpose", "journals/**/*.md": ""},
  107. dream_prompt="different prompt",
  108. )
  109. self.assertEqual(
  110. compute_memory_identity(first), compute_memory_identity(second)
  111. )
  112. third = MemoryConfig(
  113. base_path=str(base),
  114. files={"identity.md": "self"},
  115. )
  116. self.assertNotEqual(
  117. compute_memory_identity(first), compute_memory_identity(third)
  118. )
  119. def test_declared_glob_does_not_let_star_cross_directories(self):
  120. config = MemoryConfig(
  121. base_path="/tmp/not-used",
  122. files={"relationships/*.md": "people", "journals/**/*.md": "logs"},
  123. )
  124. self.assertTrue(is_declared_memory_path(config, "relationships/a.md"))
  125. self.assertFalse(
  126. is_declared_memory_path(config, "relationships/private/a.md")
  127. )
  128. self.assertTrue(is_declared_memory_path(config, "journals/2026/07/a.md"))
  129. def test_resolution_rejects_non_normal_paths_and_symlink_escape(self):
  130. with tempfile.TemporaryDirectory() as tmp:
  131. root = Path(tmp)
  132. base = root / "memory"
  133. outside = root / "outside"
  134. base.mkdir()
  135. outside.mkdir()
  136. (base / "link").symlink_to(outside, target_is_directory=True)
  137. config = MemoryConfig(base_path=str(base), files={"**/*.md": "all"})
  138. for unsafe in (
  139. "../outside/escape.md",
  140. "./identity.md",
  141. "/tmp/escape.md",
  142. "nested//identity.md",
  143. "nested\\identity.md",
  144. "link/escape.md",
  145. ):
  146. with self.subTest(path=unsafe):
  147. with self.assertRaises(MemoryPathError):
  148. resolve_memory_path(config, unsafe)
  149. def test_loader_does_not_follow_symlink_outside_base(self):
  150. with tempfile.TemporaryDirectory() as tmp:
  151. root = Path(tmp)
  152. base = root / "memory"
  153. outside = root / "outside"
  154. base.mkdir()
  155. outside.mkdir()
  156. (outside / "secret.md").write_text("secret", encoding="utf-8")
  157. (base / "safe.md").write_text("safe", encoding="utf-8")
  158. (base / "linked.md").symlink_to(outside / "secret.md")
  159. config = MemoryConfig(base_path=str(base), files={"*.md": "root"})
  160. loaded = load_memory_files(config)
  161. self.assertEqual(loaded, [("safe.md", "root", "safe")])
  162. class DreamIsolationTests(unittest.IsolatedAsyncioTestCase):
  163. async def asyncSetUp(self):
  164. self.tmp = tempfile.TemporaryDirectory()
  165. self.root = Path(self.tmp.name)
  166. self.memory_root = self.root / "memory"
  167. self.memory_root.mkdir()
  168. self.config = MemoryConfig(
  169. base_path=str(self.memory_root),
  170. files={"identity.md": "self", "journals/**/*.md": "logs"},
  171. )
  172. self.scope = DreamScope(
  173. uid="user-1",
  174. agent_type="writer",
  175. memory_identity=compute_memory_identity(self.config),
  176. )
  177. async def asyncTearDown(self):
  178. self.tmp.cleanup()
  179. async def test_filters_uid_agent_type_and_memory_identity_together(self):
  180. matching = make_trace("matching", self.scope)
  181. wrong_uid = make_trace("wrong-uid", self.scope, uid="user-2")
  182. wrong_agent = make_trace("wrong-agent", self.scope, agent_type="researcher")
  183. wrong_memory = make_trace("wrong-memory", self.scope, identity="memory-v1:other")
  184. store = FakeDreamStore(
  185. self.root / "traces",
  186. [matching, wrong_uid, wrong_agent, wrong_memory],
  187. )
  188. for trace in (matching, wrong_uid, wrong_agent, wrong_memory):
  189. store.set_log(trace.trace_id, [reflection(trace.trace_id)])
  190. calls = []
  191. async def llm_call(**kwargs):
  192. calls.append(kwargs)
  193. return {"content": '{"updates": [], "reasoning": "merged"}'}
  194. consumed, updated, reasoning = await cross_trace_integrate(
  195. store, llm_call, self.config, self.scope
  196. )
  197. self.assertEqual((consumed, updated, reasoning), (1, [], "merged"))
  198. prompt = calls[0]["messages"][1]["content"]
  199. self.assertIn("matching", prompt)
  200. self.assertNotIn("wrong-uid", prompt)
  201. self.assertNotIn("wrong-agent", prompt)
  202. self.assertNotIn("wrong-memory", prompt)
  203. self.assertTrue(
  204. (await store.get_cognition_log("matching"))["events"][0].get(
  205. "consumed_at"
  206. )
  207. )
  208. for trace_id in ("wrong-uid", "wrong-agent", "wrong-memory"):
  209. self.assertNotIn(
  210. "consumed_at",
  211. (await store.get_cognition_log(trace_id))["events"][0],
  212. )
  213. async def test_concurrent_dreams_keep_memory_configs_isolated(self):
  214. other_root = self.root / "other-memory"
  215. other_root.mkdir()
  216. other_config = MemoryConfig(
  217. base_path=str(other_root),
  218. files={"identity.md": "self"},
  219. )
  220. other_scope = DreamScope(
  221. uid=self.scope.uid,
  222. agent_type=self.scope.agent_type,
  223. memory_identity=compute_memory_identity(other_config),
  224. )
  225. first_trace = make_trace("first", self.scope)
  226. second_trace = make_trace("second", other_scope)
  227. store = FakeDreamStore(
  228. self.root / "traces", [first_trace, second_trace]
  229. )
  230. store.set_log(first_trace.trace_id, [reflection("first-summary")])
  231. store.set_log(second_trace.trace_id, [reflection("second-summary")])
  232. async def first_llm(**kwargs):
  233. await asyncio.sleep(0)
  234. self.assertIn("first-summary", kwargs["messages"][1]["content"])
  235. self.assertNotIn("second-summary", kwargs["messages"][1]["content"])
  236. return {
  237. "content": json.dumps(
  238. {
  239. "updates": [
  240. {"path": "identity.md", "new_content": "first"}
  241. ],
  242. "reasoning": "first",
  243. }
  244. )
  245. }
  246. async def second_llm(**kwargs):
  247. await asyncio.sleep(0)
  248. self.assertIn("second-summary", kwargs["messages"][1]["content"])
  249. self.assertNotIn("first-summary", kwargs["messages"][1]["content"])
  250. return {
  251. "content": json.dumps(
  252. {
  253. "updates": [
  254. {"path": "identity.md", "new_content": "second"}
  255. ],
  256. "reasoning": "second",
  257. }
  258. )
  259. }
  260. first_result, second_result = await asyncio.gather(
  261. cross_trace_integrate(
  262. store, first_llm, self.config, self.scope
  263. ),
  264. cross_trace_integrate(
  265. store, second_llm, other_config, other_scope
  266. ),
  267. )
  268. self.assertEqual(first_result[0], 1)
  269. self.assertEqual(second_result[0], 1)
  270. self.assertEqual(
  271. (self.memory_root / "identity.md").read_text(encoding="utf-8"),
  272. "first",
  273. )
  274. self.assertEqual(
  275. (other_root / "identity.md").read_text(encoding="utf-8"),
  276. "second",
  277. )
  278. async def test_scope_identity_must_match_memory_config(self):
  279. trace = make_trace("trace", self.scope)
  280. store = FakeDreamStore(self.root / "traces", [trace])
  281. store.set_log(trace.trace_id, [reflection("summary")])
  282. wrong_scope = DreamScope(
  283. uid=self.scope.uid,
  284. agent_type=self.scope.agent_type,
  285. memory_identity="memory-v1:wrong",
  286. )
  287. async def llm_call(**kwargs):
  288. self.fail("LLM must not run for a mismatched scope")
  289. with self.assertRaises(DreamScopeError):
  290. await cross_trace_integrate(
  291. store, llm_call, self.config, wrong_scope
  292. )
  293. async def test_allowed_updates_are_written_then_reflection_is_consumed(self):
  294. trace = make_trace("trace", self.scope)
  295. store = FakeDreamStore(self.root / "traces", [trace])
  296. store.set_log(trace.trace_id, [reflection("summary")])
  297. async def llm_call(**kwargs):
  298. return {
  299. "content": json.dumps(
  300. {
  301. "updates": [
  302. {"path": "identity.md", "new_content": "new identity"},
  303. {
  304. "path": "journals/2026/entry.md",
  305. "new_content": "new journal",
  306. },
  307. ],
  308. "reasoning": "useful",
  309. }
  310. )
  311. }
  312. result = await cross_trace_integrate(
  313. store, llm_call, self.config, self.scope
  314. )
  315. self.assertEqual(
  316. result, (1, ["identity.md", "journals/2026/entry.md"], "useful")
  317. )
  318. self.assertEqual(
  319. (self.memory_root / "identity.md").read_text(encoding="utf-8"),
  320. "new identity",
  321. )
  322. self.assertEqual(
  323. (self.memory_root / "journals/2026/entry.md").read_text(
  324. encoding="utf-8"
  325. ),
  326. "new journal",
  327. )
  328. self.assertTrue(
  329. (await store.get_cognition_log(trace.trace_id))["events"][0].get(
  330. "consumed_at"
  331. )
  332. )
  333. self.assertEqual(list(self.memory_root.rglob("*.tmp")), [])
  334. async def test_invalid_update_rejects_entire_plan_without_consuming(self):
  335. trace = make_trace("trace", self.scope)
  336. store = FakeDreamStore(self.root / "traces", [trace])
  337. unsafe_paths = [
  338. "../memory-evil/escape.md",
  339. "/tmp/escape.md",
  340. "not-declared.txt",
  341. "journals/../escape.md",
  342. "journals\\escape.md",
  343. ]
  344. for index, unsafe_path in enumerate(unsafe_paths):
  345. with self.subTest(path=unsafe_path):
  346. event = reflection(f"summary-{index}")
  347. store.set_log(trace.trace_id, [event])
  348. async def llm_call(**kwargs):
  349. return {
  350. "content": json.dumps(
  351. {
  352. "updates": [
  353. {
  354. "path": unsafe_path,
  355. "new_content": "must not write",
  356. }
  357. ],
  358. "reasoning": "bad",
  359. }
  360. )
  361. }
  362. result = await cross_trace_integrate(
  363. store, llm_call, self.config, self.scope
  364. )
  365. self.assertEqual(result, (0, [], ""))
  366. self.assertNotIn(
  367. "consumed_at",
  368. (await store.get_cognition_log(trace.trace_id))["events"][0],
  369. )
  370. self.assertFalse((self.root / "memory-evil" / "escape.md").exists())
  371. async def test_symlink_escape_is_rejected_without_consuming(self):
  372. outside = self.root / "outside"
  373. outside.mkdir()
  374. (self.memory_root / "journals").symlink_to(
  375. outside, target_is_directory=True
  376. )
  377. trace = make_trace("trace", self.scope)
  378. store = FakeDreamStore(self.root / "traces", [trace])
  379. store.set_log(trace.trace_id, [reflection("summary")])
  380. async def llm_call(**kwargs):
  381. return {
  382. "content": json.dumps(
  383. {
  384. "updates": [
  385. {
  386. "path": "journals/escape.md",
  387. "new_content": "must not escape",
  388. }
  389. ],
  390. "reasoning": "bad",
  391. }
  392. )
  393. }
  394. result = await cross_trace_integrate(
  395. store, llm_call, self.config, self.scope
  396. )
  397. self.assertEqual(result, (0, [], ""))
  398. self.assertFalse((outside / "escape.md").exists())
  399. self.assertNotIn(
  400. "consumed_at",
  401. (await store.get_cognition_log(trace.trace_id))["events"][0],
  402. )
  403. async def test_failed_atomic_replace_preserves_old_file_and_does_not_consume(self):
  404. target = self.memory_root / "identity.md"
  405. target.write_text("old identity", encoding="utf-8")
  406. trace = make_trace("trace", self.scope)
  407. store = FakeDreamStore(self.root / "traces", [trace])
  408. store.set_log(trace.trace_id, [reflection("summary")])
  409. async def llm_call(**kwargs):
  410. return {
  411. "content": json.dumps(
  412. {
  413. "updates": [
  414. {"path": "identity.md", "new_content": "new identity"}
  415. ],
  416. "reasoning": "useful",
  417. }
  418. )
  419. }
  420. with patch("cyber_agent.core.dream.os.replace", side_effect=OSError("boom")):
  421. with self.assertRaises(OSError):
  422. await cross_trace_integrate(
  423. store, llm_call, self.config, self.scope
  424. )
  425. self.assertEqual(target.read_text(encoding="utf-8"), "old identity")
  426. self.assertNotIn(
  427. "consumed_at",
  428. (await store.get_cognition_log(trace.trace_id))["events"][0],
  429. )
  430. self.assertEqual(list(self.memory_root.glob(".identity.md.*.tmp")), [])
  431. async def test_multi_file_failure_rolls_back_every_published_update(self):
  432. identity = self.memory_root / "identity.md"
  433. journal = self.memory_root / "journals" / "today.md"
  434. journal.parent.mkdir()
  435. identity.write_text("old identity", encoding="utf-8")
  436. journal.write_text("old journal", encoding="utf-8")
  437. trace = make_trace("trace", self.scope)
  438. store = FakeDreamStore(self.root / "traces", [trace])
  439. store.set_log(trace.trace_id, [reflection("summary")])
  440. async def llm_call(**kwargs):
  441. return {
  442. "content": json.dumps({
  443. "updates": [
  444. {"path": "identity.md", "new_content": "new identity"},
  445. {"path": "journals/today.md", "new_content": "new journal"},
  446. ],
  447. "reasoning": "useful",
  448. })
  449. }
  450. real_replace = os.replace
  451. replace_calls = 0
  452. def fail_second_target(source, destination):
  453. nonlocal replace_calls
  454. replace_calls += 1
  455. if replace_calls == 2:
  456. raise OSError("second replacement failed")
  457. return real_replace(source, destination)
  458. with patch(
  459. "cyber_agent.core.dream.os.replace",
  460. side_effect=fail_second_target,
  461. ):
  462. with self.assertRaisesRegex(OSError, "second replacement failed"):
  463. await cross_trace_integrate(
  464. store, llm_call, self.config, self.scope
  465. )
  466. self.assertEqual("old identity", identity.read_text(encoding="utf-8"))
  467. self.assertEqual("old journal", journal.read_text(encoding="utf-8"))
  468. self.assertNotIn(
  469. "consumed_at",
  470. (await store.get_cognition_log(trace.trace_id))["events"][0],
  471. )
  472. self.assertEqual(
  473. [],
  474. list(self.memory_root.rglob("*.dream-stage")),
  475. )
  476. async def test_builtin_dream_requires_memory_config_and_scope_in_context(self):
  477. class Runner:
  478. trace_store = object()
  479. llm_call = object()
  480. missing = await dream_tool(context={"runner": Runner()})
  481. self.assertEqual(missing.error, "memory_config not in tool context")
  482. missing_scope = await dream_tool(
  483. context={"runner": Runner(), "memory_config": self.config}
  484. )
  485. self.assertEqual(missing_scope.error, "dream_scope not in tool context")
  486. if __name__ == "__main__":
  487. unittest.main()