test_runtime_hardening.py 44 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111
  1. import asyncio
  2. import importlib
  3. import json
  4. import os
  5. import tempfile
  6. import unittest
  7. from datetime import datetime
  8. from pathlib import Path
  9. from unittest.mock import patch
  10. from fastapi import HTTPException
  11. from cyber_agent.core.memory import MemoryConfig, compute_memory_identity
  12. from cyber_agent.core.run_snapshot import (
  13. RunConfigSnapshotError,
  14. RunConfigSnapshotV1,
  15. load_run_config_snapshot,
  16. persist_run_config_snapshot,
  17. )
  18. from cyber_agent.core.runner import AgentRunner, RunConfig
  19. from cyber_agent.tools.approval import (
  20. ToolApprovalBatchV1,
  21. ToolApprovalCallV1,
  22. tool_argument_hash,
  23. )
  24. from cyber_agent.tools.builtin.knowledge import KnowledgeConfig
  25. from cyber_agent.tools.registry import ToolRegistry
  26. from cyber_agent.trace import run_api
  27. from cyber_agent.trace.goal_models import GoalTree
  28. from cyber_agent.trace.models import Message, Trace
  29. from cyber_agent.trace.run_api import (
  30. DecideToolApprovalRequest,
  31. TraceRunRequest,
  32. ToolApprovalDecision,
  33. decide_tool_approval,
  34. _restore_runner_and_config,
  35. reconcile_traces,
  36. run_trace,
  37. set_runner,
  38. stop_trace,
  39. )
  40. from cyber_agent.trace.store import (
  41. FileSystemTraceStore,
  42. TraceStoreCorruptionError,
  43. )
  44. def quiet_knowledge() -> KnowledgeConfig:
  45. return KnowledgeConfig(
  46. enable_extraction=False,
  47. enable_completion_extraction=False,
  48. enable_injection=False,
  49. )
  50. class RunSnapshotHardeningTest(unittest.IsolatedAsyncioTestCase):
  51. async def test_v1_snapshot_shape_and_hash_remain_byte_compatible(self):
  52. config = RunConfig(
  53. model="v1-fixture",
  54. temperature=0.25,
  55. max_iterations=3,
  56. tools=[],
  57. tool_groups=[],
  58. exclude_tools=[],
  59. auto_execute_tools=True,
  60. agent_type="default",
  61. uid="fixture",
  62. skills=[],
  63. enable_memory=False,
  64. knowledge=quiet_knowledge(),
  65. parallel_tool_execution=False,
  66. child_execution_mode="sequential",
  67. max_parallel_children=1,
  68. side_branch_max_turns=2,
  69. goal_compression="none",
  70. enable_prompt_caching=True,
  71. enable_research_flow=False,
  72. extra_llm_params={},
  73. context={},
  74. )
  75. snapshot = RunConfigSnapshotV1.from_run_config(
  76. config,
  77. memory_identity=None,
  78. )
  79. self.assertEqual(1, snapshot.schema_version)
  80. self.assertNotIn("application_ref", snapshot.model_dump())
  81. self.assertEqual(
  82. "1fdd11b90c121e13fa30aa5d246c20a50b602baa6b3a1aeed186b2c1ea72745b",
  83. snapshot.snapshot_hash,
  84. )
  85. async def test_full_snapshot_round_trip_and_hash_tamper_detection(self):
  86. with tempfile.TemporaryDirectory() as temp_dir:
  87. memory = MemoryConfig(
  88. base_path=str(Path(temp_dir) / "memory"),
  89. files={"identity.md": "identity", "journals/**": "journal"},
  90. dream_prompt="dream",
  91. reflect_prompt="reflect",
  92. )
  93. config = RunConfig(
  94. model="model-x",
  95. temperature=0.17,
  96. max_iterations=37,
  97. tools=["alpha", "beta"],
  98. tool_groups=["ignored-when-tools-explicit"],
  99. exclude_tools=["beta"],
  100. auto_execute_tools=False,
  101. agent_type="writer",
  102. uid="user-7",
  103. skills=["planning"],
  104. enable_memory=True,
  105. memory=memory,
  106. knowledge=KnowledgeConfig(
  107. enable_extraction=False,
  108. default_scopes=["project:test"],
  109. ),
  110. parallel_tool_execution=True,
  111. child_execution_mode="parallel",
  112. max_parallel_children=4,
  113. side_branch_max_turns=7,
  114. goal_compression="on_complete",
  115. enable_prompt_caching=False,
  116. enable_research_flow=False,
  117. extra_llm_params={"top_p": 0.8},
  118. context={"project_name": "demo", "business": {"lane": 2}},
  119. )
  120. identity = compute_memory_identity(memory)
  121. snapshot = RunConfigSnapshotV1.from_run_config(
  122. config,
  123. memory_identity=identity,
  124. system_prompt_hash="a" * 64,
  125. )
  126. context = {}
  127. persist_run_config_snapshot(context, snapshot)
  128. loaded = load_run_config_snapshot(context)
  129. restored = RunConfig()
  130. restored.apply_snapshot(loaded)
  131. self.assertEqual("model-x", restored.model)
  132. self.assertEqual(["alpha", "beta"], restored.tools)
  133. self.assertEqual(["ignored-when-tools-explicit"], restored.tool_groups)
  134. self.assertEqual("parallel", restored.child_execution_mode)
  135. self.assertEqual(4, restored.max_parallel_children)
  136. self.assertEqual(7, restored.side_branch_max_turns)
  137. self.assertEqual(["project:test"], restored.knowledge.default_scopes)
  138. self.assertEqual(memory.files, restored.memory.files)
  139. self.assertEqual(identity, loaded.memory_identity)
  140. self.assertEqual("demo", loaded.project_name)
  141. context["run_config_snapshot"]["model"] = "tampered"
  142. with self.assertRaisesRegex(RunConfigSnapshotError, "hash mismatch"):
  143. load_run_config_snapshot(context)
  144. async def test_precreated_trace_binds_actual_system_prompt_hash_on_first_run(self):
  145. with tempfile.TemporaryDirectory() as temp_dir:
  146. store = FileSystemTraceStore(temp_dir)
  147. config = RunConfig(
  148. trace_id="precreated-child",
  149. model="fake",
  150. tools=[],
  151. tool_groups=[],
  152. system_prompt="fixed child prompt",
  153. knowledge=quiet_knowledge(),
  154. )
  155. context = {"agent_mode": "legacy", "agent_mode_revision": 1}
  156. persist_run_config_snapshot(
  157. context,
  158. RunConfigSnapshotV1.from_run_config(
  159. config,
  160. memory_identity=None,
  161. ),
  162. )
  163. await store.create_trace(Trace(
  164. trace_id=config.trace_id,
  165. mode="agent",
  166. task="child",
  167. model="fake",
  168. context=context,
  169. ))
  170. runner = AgentRunner(trace_store=store)
  171. await runner._build_history(
  172. config.trace_id,
  173. [],
  174. GoalTree(mission="child"),
  175. config,
  176. 1,
  177. )
  178. persisted = await store.get_trace(config.trace_id)
  179. snapshot = load_run_config_snapshot(persisted.context)
  180. self.assertIsNotNone(snapshot.system_prompt_hash)
  181. self.assertEqual(64, len(snapshot.system_prompt_hash))
  182. async def test_old_recursive_refuses_resume_but_legacy_is_inferred_once(self):
  183. with tempfile.TemporaryDirectory() as temp_dir:
  184. store = FileSystemTraceStore(temp_dir)
  185. recursive = Trace(
  186. trace_id="old-recursive",
  187. mode="agent",
  188. task="old",
  189. context={
  190. "agent_mode": "recursive",
  191. "agent_mode_revision": 2,
  192. },
  193. )
  194. legacy = Trace(
  195. trace_id="old-legacy",
  196. mode="agent",
  197. task="old",
  198. model="legacy-model",
  199. context={"agent_mode": "legacy", "agent_mode_revision": 1},
  200. )
  201. await store.create_trace(recursive)
  202. await store.create_trace(legacy)
  203. runner = AgentRunner(trace_store=store, llm_call=lambda **_: None)
  204. with self.assertRaisesRegex(ValueError, "recursive_revision_2_read_only"):
  205. await runner._prepare_existing_trace(
  206. RunConfig(trace_id=recursive.trace_id)
  207. )
  208. _trace, _goal, _sequence = await runner._prepare_existing_trace(
  209. RunConfig(trace_id=legacy.trace_id)
  210. )
  211. persisted = await store.get_trace(legacy.trace_id)
  212. self.assertTrue(load_run_config_snapshot(persisted.context).legacy_inferred)
  213. async def test_old_legacy_project_snapshot_is_inferred_before_runner_selection(self):
  214. with tempfile.TemporaryDirectory() as temp_dir:
  215. store = FileSystemTraceStore(temp_dir)
  216. trace = Trace(
  217. trace_id="old-project-legacy",
  218. mode="agent",
  219. task="old project",
  220. model="legacy-project-model",
  221. context={
  222. "agent_mode": "legacy",
  223. "agent_mode_revision": 1,
  224. "project_name": "legacy_demo",
  225. },
  226. )
  227. await store.create_trace(trace)
  228. base_runner = AgentRunner(trace_store=store)
  229. project_runner = AgentRunner(trace_store=store)
  230. async def init_project_env():
  231. return project_runner, [], RunConfig()
  232. module = type("ProjectModule", (), {
  233. "init_project_env": staticmethod(init_project_env),
  234. })
  235. real_import_module = importlib.import_module
  236. def import_project(name, *args, **kwargs):
  237. if name == "examples.legacy_demo.run":
  238. return module
  239. return real_import_module(name, *args, **kwargs)
  240. with patch("importlib.import_module", side_effect=import_project):
  241. restored_runner, config = await _restore_runner_and_config(
  242. trace=trace,
  243. base_runner=base_runner,
  244. )
  245. self.assertIs(project_runner, restored_runner)
  246. self.assertEqual("legacy-project-model", config.model)
  247. persisted = await store.get_trace(trace.trace_id)
  248. snapshot = load_run_config_snapshot(persisted.context)
  249. self.assertTrue(snapshot.legacy_inferred)
  250. self.assertEqual("legacy_demo", snapshot.project_name)
  251. class ToolPolicyHardeningTest(unittest.IsolatedAsyncioTestCase):
  252. async def asyncSetUp(self):
  253. self.registry = ToolRegistry()
  254. self.executed = []
  255. async def exact_one(value: int = 0):
  256. self.executed.append(("exact_one", value))
  257. return "one"
  258. async def grouped_two(value: int = 0):
  259. self.executed.append(("grouped_two", value))
  260. return "two"
  261. self.registry.register(exact_one, groups=["shared"])
  262. self.registry.register(grouped_two, groups=["shared"])
  263. async def test_explicit_tools_are_exact_for_schema_and_dispatch(self):
  264. runner = AgentRunner(tool_registry=self.registry)
  265. names = runner._get_configured_tool_names(
  266. tools=["exact_one"],
  267. tool_groups=["shared"],
  268. exclude_tools=[],
  269. )
  270. schemas = runner._get_tool_schemas(
  271. tools=["exact_one"],
  272. tool_groups=["shared"],
  273. exclude_tools=[],
  274. )
  275. self.assertEqual({"exact_one"}, names)
  276. self.assertEqual(
  277. {"exact_one"},
  278. {schema["function"]["name"] for schema in schemas},
  279. )
  280. denied = await self.registry.execute(
  281. "grouped_two",
  282. {},
  283. allowed_tool_names=names,
  284. )
  285. self.assertIn("not allowed", denied)
  286. self.assertEqual([], self.executed)
  287. async def test_url_and_confirmation_are_hard_execution_gates(self):
  288. async def guarded(value: int = 0):
  289. self.executed.append(("guarded", value))
  290. return "ok"
  291. self.registry.register(
  292. guarded,
  293. requires_confirmation=True,
  294. editable_params=["value"],
  295. url_patterns=["https://allowed.example.com/**"],
  296. )
  297. call_id = "call-1"
  298. arguments = {"value": 4}
  299. digest = tool_argument_hash(
  300. tool_call_id=call_id,
  301. tool_name="guarded",
  302. arguments=arguments,
  303. )
  304. grant = {
  305. "batch_id": "batch-1",
  306. "trace_id": "trace-1",
  307. "tool_call_id": call_id,
  308. "tool_name": "guarded",
  309. "argument_hash": digest,
  310. "decision": "approved",
  311. }
  312. missing_url = await self.registry.execute(
  313. "guarded", arguments, tool_call_id=call_id, approval_grant=grant,
  314. context={"trace_id": "trace-1"},
  315. )
  316. wrong_url = await self.registry.execute(
  317. "guarded", arguments, tool_call_id=call_id, approval_grant=grant,
  318. context={"trace_id": "trace-1", "page_url": "https://evil.example/x"},
  319. )
  320. missing_grant = await self.registry.execute(
  321. "guarded", arguments, tool_call_id=call_id,
  322. context={
  323. "trace_id": "trace-1",
  324. "page_url": "https://allowed.example.com/path",
  325. },
  326. )
  327. self.assertIn("URL policy denied", missing_url)
  328. self.assertIn("URL policy denied", wrong_url)
  329. self.assertIn("confirmation required", missing_grant)
  330. self.assertEqual([], self.executed)
  331. accepted = await self.registry.execute(
  332. "guarded", arguments, tool_call_id=call_id, approval_grant=grant,
  333. context={
  334. "trace_id": "trace-1",
  335. "page_url": "https://allowed.example.com/path",
  336. },
  337. )
  338. self.assertEqual("ok", accepted)
  339. self.assertEqual([("guarded", 4)], self.executed)
  340. class ToolApprovalStateMachineTest(unittest.IsolatedAsyncioTestCase):
  341. async def asyncSetUp(self):
  342. self.temp = tempfile.TemporaryDirectory()
  343. self.store = FileSystemTraceStore(self.temp.name)
  344. self.registry = ToolRegistry()
  345. self.side_effects = []
  346. run_api._running_tasks.clear()
  347. run_api._running_runners.clear()
  348. run_api._approval_locks.clear()
  349. async def dangerous_tool(value: int, fixed: str = "fixed"):
  350. self.side_effects.append(("dangerous", value, fixed))
  351. return {"value": value}
  352. async def safe_tool(label: str):
  353. self.side_effects.append(("safe", label))
  354. return "safe"
  355. self.registry.register(
  356. dangerous_tool,
  357. requires_confirmation=True,
  358. editable_params=["value"],
  359. groups=["tests"],
  360. )
  361. self.registry.register(safe_tool, groups=["tests"])
  362. self.model_calls = 0
  363. async def llm_call(**kwargs):
  364. self.model_calls += 1
  365. if self.model_calls == 1:
  366. return {
  367. "content": "",
  368. "tool_calls": [
  369. {
  370. "id": "danger-1",
  371. "type": "function",
  372. "function": {
  373. "name": "dangerous_tool",
  374. "arguments": json.dumps({"value": 1, "fixed": "fixed"}),
  375. },
  376. },
  377. {
  378. "id": "safe-1",
  379. "type": "function",
  380. "function": {
  381. "name": "safe_tool",
  382. "arguments": json.dumps({"label": "after-approval"}),
  383. },
  384. },
  385. ],
  386. "finish_reason": "tool_calls",
  387. }
  388. return {
  389. "content": "done",
  390. "tool_calls": [],
  391. "finish_reason": "stop",
  392. }
  393. self.runner = AgentRunner(
  394. trace_store=self.store,
  395. tool_registry=self.registry,
  396. llm_call=llm_call,
  397. )
  398. set_runner(self.runner)
  399. async def asyncTearDown(self):
  400. tasks = list(run_api._running_tasks.values())
  401. for task in tasks:
  402. if not task.done():
  403. task.cancel()
  404. if tasks:
  405. await asyncio.gather(*tasks, return_exceptions=True)
  406. run_api._running_tasks.clear()
  407. run_api._running_runners.clear()
  408. run_api._approval_locks.clear()
  409. self.temp.cleanup()
  410. async def start_waiting_batch(self):
  411. with patch.dict(os.environ, {"AGENT_MODE": "legacy"}):
  412. result = await self.runner.run_result(
  413. [{"role": "user", "content": "run tools"}],
  414. RunConfig(
  415. model="fake",
  416. tools=["dangerous_tool", "safe_tool"],
  417. tool_groups=[],
  418. enable_research_flow=False,
  419. knowledge=quiet_knowledge(),
  420. ),
  421. )
  422. self.assertEqual("waiting_confirmation", result["status"])
  423. self.assertEqual([], self.side_effects)
  424. batch = await self.store.get_tool_approval_batch(result["trace_id"])
  425. self.assertEqual("pending", batch.status)
  426. self.assertEqual(1, len(batch.pending_calls))
  427. return result["trace_id"], batch
  428. async def test_mixed_batch_waits_then_executes_approved_edit_and_auto_call(self):
  429. trace_id, batch = await self.start_waiting_batch()
  430. response = await decide_tool_approval(
  431. trace_id,
  432. batch.batch_id,
  433. DecideToolApprovalRequest(decisions=[ToolApprovalDecision(
  434. tool_call_id="danger-1",
  435. decision="approve",
  436. edited_arguments={"value": 9, "fixed": "fixed"},
  437. )]),
  438. )
  439. self.assertEqual("started", response.status)
  440. await run_api._running_tasks[trace_id]
  441. self.assertEqual(
  442. [("dangerous", 9, "fixed"), ("safe", "after-approval")],
  443. self.side_effects,
  444. )
  445. persisted = await self.store.get_tool_approval_batch(trace_id)
  446. self.assertEqual("completed", persisted.status)
  447. self.assertTrue(all(
  448. call.execution_status == "executed" for call in persisted.calls
  449. ))
  450. self.assertEqual("completed", (await self.store.get_trace(trace_id)).status)
  451. async def test_auto_execute_false_requires_confirmation_for_safe_tools_too(self):
  452. registry = ToolRegistry()
  453. effects = []
  454. async def ordinary_tool(value: int):
  455. effects.append(value)
  456. return "ok"
  457. registry.register(ordinary_tool)
  458. async def llm_call(**_kwargs):
  459. return {
  460. "content": "",
  461. "tool_calls": [{
  462. "id": "ordinary-1",
  463. "type": "function",
  464. "function": {
  465. "name": "ordinary_tool",
  466. "arguments": json.dumps({"value": 3}),
  467. },
  468. }],
  469. "finish_reason": "tool_calls",
  470. }
  471. runner = AgentRunner(
  472. trace_store=self.store,
  473. tool_registry=registry,
  474. llm_call=llm_call,
  475. )
  476. result = await runner.run_result(
  477. [{"role": "user", "content": "confirm everything"}],
  478. RunConfig(
  479. model="fake",
  480. tools=["ordinary_tool"],
  481. tool_groups=[],
  482. auto_execute_tools=False,
  483. enable_research_flow=False,
  484. knowledge=quiet_knowledge(),
  485. ),
  486. )
  487. self.assertEqual("waiting_confirmation", result["status"])
  488. self.assertEqual([], effects)
  489. batch = await self.store.get_tool_approval_batch(result["trace_id"])
  490. self.assertEqual(["pending"], [call.decision for call in batch.calls])
  491. async def test_reject_is_persisted_and_repeat_decision_is_conflict(self):
  492. trace_id, batch = await self.start_waiting_batch()
  493. await decide_tool_approval(
  494. trace_id,
  495. batch.batch_id,
  496. DecideToolApprovalRequest(decisions=[ToolApprovalDecision(
  497. tool_call_id="danger-1",
  498. decision="reject",
  499. )]),
  500. )
  501. await run_api._running_tasks[trace_id]
  502. self.assertEqual([("safe", "after-approval")], self.side_effects)
  503. persisted = await self.store.get_tool_approval_batch(trace_id)
  504. dangerous = next(c for c in persisted.calls if c.tool_call_id == "danger-1")
  505. self.assertEqual("rejected", dangerous.execution_status)
  506. with self.assertRaises(HTTPException) as raised:
  507. await decide_tool_approval(
  508. trace_id,
  509. batch.batch_id,
  510. DecideToolApprovalRequest(decisions=[ToolApprovalDecision(
  511. tool_call_id="danger-1",
  512. decision="approve",
  513. )]),
  514. )
  515. self.assertEqual(409, raised.exception.status_code)
  516. async def test_editing_non_editable_argument_is_rejected_without_mutation(self):
  517. trace_id, batch = await self.start_waiting_batch()
  518. with self.assertRaises(HTTPException) as raised:
  519. await decide_tool_approval(
  520. trace_id,
  521. batch.batch_id,
  522. DecideToolApprovalRequest(decisions=[ToolApprovalDecision(
  523. tool_call_id="danger-1",
  524. decision="approve",
  525. edited_arguments={"value": 1, "fixed": "tampered"},
  526. )]),
  527. )
  528. self.assertEqual(422, raised.exception.status_code)
  529. unchanged = await self.store.get_tool_approval_batch(trace_id)
  530. self.assertEqual("pending", unchanged.status)
  531. self.assertEqual("pending", unchanged.pending_calls[0].decision)
  532. self.assertEqual([], self.side_effects)
  533. async def test_waiting_trace_rejects_plain_run_and_stop_cancels_batch(self):
  534. trace_id, batch = await self.start_waiting_batch()
  535. with self.assertRaises(HTTPException) as raised:
  536. await run_trace(trace_id, TraceRunRequest(messages=[]))
  537. self.assertEqual(409, raised.exception.status_code)
  538. stopped = await stop_trace(trace_id)
  539. self.assertEqual("stopping", stopped.status)
  540. self.assertEqual("stopped", (await self.store.get_trace(trace_id)).status)
  541. self.assertEqual(
  542. "cancelled",
  543. (await self.store.get_tool_approval_batch(trace_id)).status,
  544. )
  545. async def test_persisted_argument_hash_tampering_is_detected(self):
  546. trace_id, _batch = await self.start_waiting_batch()
  547. approval_path = Path(self.temp.name) / trace_id / "tool_approvals.json"
  548. raw = json.loads(approval_path.read_text(encoding="utf-8"))
  549. raw["calls"][0]["effective_arguments"]["value"] = 999
  550. approval_path.write_text(json.dumps(raw), encoding="utf-8")
  551. with self.assertRaises(TraceStoreCorruptionError):
  552. await self.store.get_tool_approval_batch(trace_id)
  553. async def test_restart_marks_executing_side_effect_unknown_and_fails_closed(self):
  554. trace_id, batch = await self.start_waiting_batch()
  555. pending = batch.pending_calls[0]
  556. pending.decision = "approved"
  557. pending.execution_status = "executing"
  558. batch.status = "executing"
  559. await self.store.replace_tool_approval_batch(trace_id, batch)
  560. await self.store.update_trace(trace_id, status="running")
  561. await reconcile_traces()
  562. persisted = await self.store.get_tool_approval_batch(trace_id)
  563. trace = await self.store.get_trace(trace_id)
  564. self.assertEqual("execution_unknown", persisted.status)
  565. self.assertEqual(
  566. "execution_unknown",
  567. next(
  568. call for call in persisted.calls
  569. if call.tool_call_id == pending.tool_call_id
  570. ).execution_status,
  571. )
  572. self.assertEqual("failed", trace.status)
  573. self.assertIn("unknown", trace.error_message)
  574. self.assertEqual([], self.side_effects)
  575. async def test_restart_restores_pending_batch_to_waiting_confirmation(self):
  576. trace_id, _batch = await self.start_waiting_batch()
  577. # Crash window: approval state is durable, but the following Trace
  578. # status update has not happened yet.
  579. await self.store.update_trace(trace_id, status="running")
  580. await reconcile_traces()
  581. trace = await self.store.get_trace(trace_id)
  582. persisted = await self.store.get_tool_approval_batch(trace_id)
  583. self.assertEqual("waiting_confirmation", trace.status)
  584. self.assertEqual("pending", persisted.status)
  585. self.assertEqual([], self.side_effects)
  586. async def test_orphaned_review_restores_confirmation_and_never_replays_unknown(self):
  587. registry = ToolRegistry()
  588. review_side_effects = []
  589. async def review_task_result(child_trace_id: str):
  590. review_side_effects.append(child_trace_id)
  591. return {"status": "completed"}
  592. registry.register(review_task_result)
  593. runner = AgentRunner(
  594. trace_store=self.store,
  595. tool_registry=registry,
  596. llm_call=self.runner.llm_call,
  597. )
  598. trace = Trace(
  599. trace_id="orphaned-review-approval",
  600. mode="agent",
  601. uid="user-1",
  602. context={},
  603. )
  604. await self.store.create_trace(trace)
  605. assistant = Message.create(
  606. trace_id=trace.trace_id,
  607. role="assistant",
  608. sequence=1,
  609. content={
  610. "text": "",
  611. "tool_calls": [{
  612. "id": "orphaned-review-call",
  613. "type": "function",
  614. "function": {
  615. "name": "review_task_result",
  616. "arguments": json.dumps({"child_trace_id": "child-1"}),
  617. },
  618. }],
  619. },
  620. )
  621. await self.store.add_message(assistant)
  622. await self.store.update_trace(
  623. trace.trace_id,
  624. head_sequence=1,
  625. last_sequence=1,
  626. )
  627. config = RunConfig(auto_execute_tools=False)
  628. with self.assertRaisesRegex(RuntimeError, "restored to persistent approval"):
  629. await runner._heal_orphaned_tool_calls(
  630. [assistant],
  631. trace,
  632. None,
  633. config,
  634. 2,
  635. )
  636. batch = await self.store.get_tool_approval_batch(trace.trace_id)
  637. self.assertEqual("pending", batch.status)
  638. self.assertEqual("waiting_confirmation", (
  639. await self.store.get_trace(trace.trace_id)
  640. ).status)
  641. self.assertEqual([], review_side_effects)
  642. batch.status = "executing"
  643. batch.calls[0].decision = "approved"
  644. batch.calls[0].execution_status = "executing"
  645. await self.store.replace_tool_approval_batch(trace.trace_id, batch)
  646. await self.store.update_trace(trace.trace_id, status="running")
  647. with self.assertRaisesRegex(RuntimeError, "outcome is unknown"):
  648. await runner._heal_orphaned_tool_calls(
  649. [assistant],
  650. await self.store.get_trace(trace.trace_id),
  651. None,
  652. config,
  653. 2,
  654. )
  655. unknown = await self.store.get_tool_approval_batch(trace.trace_id)
  656. self.assertEqual("execution_unknown", unknown.status)
  657. self.assertEqual([], review_side_effects)
  658. async def test_restart_resumes_decided_batch_before_any_side_effect(self):
  659. trace_id, batch = await self.start_waiting_batch()
  660. # Crash window: decisions are durable, but no call has yet been marked
  661. # executing and no side effect has begun.
  662. pending = batch.pending_calls[0]
  663. pending.decision = "approved"
  664. pending.decided_at = datetime.now().isoformat()
  665. batch.status = "decided"
  666. batch.updated_at = datetime.now().isoformat()
  667. await self.store.replace_tool_approval_batch(trace_id, batch)
  668. await self.store.update_trace(trace_id, status="running")
  669. await reconcile_traces()
  670. self.assertIn(trace_id, run_api._running_tasks)
  671. await run_api._running_tasks[trace_id]
  672. persisted = await self.store.get_tool_approval_batch(trace_id)
  673. trace = await self.store.get_trace(trace_id)
  674. self.assertEqual("completed", persisted.status)
  675. self.assertEqual("completed", trace.status)
  676. self.assertEqual(
  677. [("dangerous", 1, "fixed"), ("safe", "after-approval")],
  678. self.side_effects,
  679. )
  680. async def test_post_side_effect_persistence_failure_is_immediately_unknown(self):
  681. trace_id, batch = await self.start_waiting_batch()
  682. original_add_message = self.store.add_message
  683. async def fail_tool_result_persistence(message):
  684. if message.role == "tool":
  685. raise OSError("injected result persistence failure")
  686. return await original_add_message(message)
  687. with patch.object(
  688. self.store,
  689. "add_message",
  690. side_effect=fail_tool_result_persistence,
  691. ):
  692. await decide_tool_approval(
  693. trace_id,
  694. batch.batch_id,
  695. DecideToolApprovalRequest(decisions=[ToolApprovalDecision(
  696. tool_call_id="danger-1",
  697. decision="approve",
  698. )]),
  699. )
  700. await run_api._running_tasks[trace_id]
  701. persisted = await self.store.get_tool_approval_batch(trace_id)
  702. trace = await self.store.get_trace(trace_id)
  703. self.assertEqual("execution_unknown", persisted.status)
  704. self.assertEqual("failed", trace.status)
  705. self.assertEqual([("dangerous", 1, "fixed")], self.side_effects)
  706. class TraceStoreHardeningTest(unittest.IsolatedAsyncioTestCase):
  707. async def asyncSetUp(self):
  708. self.temp = tempfile.TemporaryDirectory()
  709. self.store = FileSystemTraceStore(self.temp.name)
  710. self.trace = Trace(trace_id="trace@safe-1", mode="agent", task="test")
  711. await self.store.create_trace(self.trace)
  712. async def asyncTearDown(self):
  713. self.temp.cleanup()
  714. async def test_concurrent_messages_and_events_keep_unique_monotonic_counters(self):
  715. await asyncio.gather(*[
  716. self.store.add_message(Message.create(
  717. trace_id=self.trace.trace_id,
  718. role="user",
  719. sequence=index,
  720. content=f"message-{index}",
  721. ))
  722. for index in range(1, 41)
  723. ])
  724. trace = await self.store.get_trace(self.trace.trace_id)
  725. events = await self.store.get_events(self.trace.trace_id)
  726. self.assertEqual(40, trace.total_messages)
  727. self.assertEqual(40, trace.last_sequence)
  728. self.assertEqual(list(range(1, 41)), [e["event_id"] for e in events])
  729. extra_ids = await asyncio.gather(*[
  730. self.store.append_event(self.trace.trace_id, "extra", {"index": index})
  731. for index in range(20)
  732. ])
  733. self.assertEqual(list(range(41, 61)), sorted(extra_ids))
  734. self.assertEqual(60, (await self.store.get_trace(self.trace.trace_id)).last_event_id)
  735. async def test_duplicate_message_id_fails_without_double_counting(self):
  736. message = Message.create(
  737. trace_id=self.trace.trace_id,
  738. role="user",
  739. sequence=1,
  740. content="once",
  741. )
  742. await self.store.add_message(message)
  743. with self.assertRaisesRegex(ValueError, "message_id already exists"):
  744. await self.store.add_message(message)
  745. trace = await self.store.get_trace(self.trace.trace_id)
  746. self.assertEqual(1, trace.total_messages)
  747. self.assertEqual(1, len(await self.store.get_trace_messages(trace.trace_id)))
  748. self.assertEqual(1, len(await self.store.get_events(trace.trace_id)))
  749. async def test_attachment_suffix_cannot_overwrite_message_json(self):
  750. message = Message.create(
  751. trace_id=self.trace.trace_id,
  752. role="user",
  753. sequence=1,
  754. content="durable",
  755. )
  756. await self.store.add_message(message)
  757. attachment = await self.store.write_message_attachment(
  758. self.trace.trace_id,
  759. message.message_id,
  760. suffix=".json",
  761. data=b'{"attachment":true}',
  762. )
  763. self.assertEqual("attachments", attachment.parent.name)
  764. self.assertEqual(b'{"attachment":true}', attachment.read_bytes())
  765. messages = await self.store.get_trace_messages(self.trace.trace_id)
  766. self.assertEqual(["durable"], [item.content for item in messages])
  767. async def test_atomic_replace_failure_keeps_previous_meta_valid(self):
  768. meta_path = Path(self.temp.name) / self.trace.trace_id / "meta.json"
  769. before = json.loads(meta_path.read_text(encoding="utf-8"))
  770. with patch("cyber_agent.trace.store.os.replace", side_effect=OSError("fault")):
  771. with self.assertRaises(OSError):
  772. await self.store.update_trace(self.trace.trace_id, status="failed")
  773. after = json.loads(meta_path.read_text(encoding="utf-8"))
  774. self.assertEqual(before, after)
  775. async def test_corruption_and_path_traversal_fail_closed(self):
  776. with self.assertRaises(ValueError):
  777. await self.store.get_trace("../outside")
  778. with self.assertRaises(ValueError):
  779. await self.store.get_message("bad/name")
  780. goal_path = Path(self.temp.name) / self.trace.trace_id / "goal.json"
  781. goal_path.write_text("{broken", encoding="utf-8")
  782. with self.assertRaises(TraceStoreCorruptionError):
  783. await self.store.get_goal_tree(self.trace.trace_id)
  784. corrupt_dir = Path(self.temp.name) / "corrupt-trace"
  785. corrupt_dir.mkdir()
  786. (corrupt_dir / "meta.json").write_text("[]", encoding="utf-8")
  787. with self.assertRaises(TraceStoreCorruptionError):
  788. await self.store.list_traces()
  789. async def test_startup_reconciliation_restores_file_derived_counters(self):
  790. for index in range(1, 4):
  791. await self.store.add_message(Message.create(
  792. trace_id=self.trace.trace_id,
  793. role="user",
  794. sequence=index,
  795. content=f"message-{index}",
  796. prompt_tokens=index,
  797. ))
  798. await self.store.update_trace(
  799. self.trace.trace_id,
  800. total_messages=0,
  801. last_sequence=0,
  802. last_event_id=0,
  803. )
  804. report = await self.store.reconcile_trace_integrity(self.trace.trace_id)
  805. trace = await self.store.get_trace(self.trace.trace_id)
  806. self.assertEqual({"messages": 3, "last_sequence": 3, "last_event_id": 3}, report)
  807. self.assertEqual(3, trace.total_messages)
  808. self.assertEqual(6, trace.total_prompt_tokens)
  809. class SideBranchPersistenceTest(unittest.IsolatedAsyncioTestCase):
  810. async def test_all_side_branch_types_reconstruct_and_respect_total_turn_budget(self):
  811. for branch_type in ("compression", "reflection", "knowledge_eval"):
  812. with self.subTest(branch_type=branch_type), tempfile.TemporaryDirectory() as temp_dir:
  813. store = FileSystemTraceStore(temp_dir)
  814. branch_id = f"{branch_type}_persisted"
  815. context = {
  816. "agent_mode": "legacy",
  817. "agent_mode_revision": 1,
  818. "active_side_branch": {
  819. "type": branch_type,
  820. "branch_id": branch_id,
  821. "start_head_seq": 1,
  822. "start_sequence": 2,
  823. "start_iteration": 99,
  824. "max_turns": 2,
  825. # Simulate a crash after the assistant Message was
  826. # durable but before this counter update.
  827. "turns_used": 0,
  828. },
  829. }
  830. config = RunConfig(
  831. trace_id=f"trace-{branch_type}",
  832. model="fake",
  833. max_iterations=5,
  834. side_branch_max_turns=9,
  835. tools=[],
  836. tool_groups=[],
  837. enable_research_flow=False,
  838. knowledge=quiet_knowledge(),
  839. )
  840. persist_run_config_snapshot(
  841. context,
  842. RunConfigSnapshotV1.from_run_config(
  843. config,
  844. memory_identity=None,
  845. ),
  846. )
  847. trace = Trace(
  848. trace_id=config.trace_id,
  849. mode="agent",
  850. task="resume branch",
  851. model="fake",
  852. context=context,
  853. head_sequence=1,
  854. )
  855. await store.create_trace(trace)
  856. await store.update_goal_tree(trace.trace_id, GoalTree(mission="resume"))
  857. await store.add_message(Message.create(
  858. trace_id=trace.trace_id,
  859. role="user",
  860. sequence=1,
  861. content="main",
  862. ))
  863. await store.add_message(Message.create(
  864. trace_id=trace.trace_id,
  865. role="user",
  866. sequence=2,
  867. parent_sequence=1,
  868. branch_type=branch_type,
  869. branch_id=branch_id,
  870. content="branch instruction",
  871. ))
  872. await store.add_message(Message.create(
  873. trace_id=trace.trace_id,
  874. role="assistant",
  875. sequence=3,
  876. parent_sequence=2,
  877. branch_type=branch_type,
  878. branch_id=branch_id,
  879. content={"text": "first branch turn", "tool_calls": []},
  880. ))
  881. await store.add_message(Message.create(
  882. trace_id=trace.trace_id,
  883. role="assistant",
  884. sequence=4,
  885. parent_sequence=1,
  886. branch_type=branch_type,
  887. branch_id="neighbor-branch",
  888. content={"text": "must not enter recovered history", "tool_calls": []},
  889. ))
  890. await store.update_trace(trace.trace_id, head_sequence=1)
  891. first_prompt = []
  892. calls = 0
  893. async def llm_call(**kwargs):
  894. nonlocal calls
  895. calls += 1
  896. if calls == 1:
  897. first_prompt.extend(kwargs["messages"])
  898. content = (
  899. "[[SUMMARY]] compacted"
  900. if branch_type == "compression"
  901. else (
  902. json.dumps({"evaluations": []})
  903. if branch_type == "knowledge_eval"
  904. else "reflection complete"
  905. )
  906. )
  907. return {"content": content, "tool_calls": [], "finish_reason": "stop"}
  908. return {"content": "main complete", "tool_calls": [], "finish_reason": "stop"}
  909. runner = AgentRunner(trace_store=store, llm_call=llm_call)
  910. await runner.run_result([], config)
  911. messages = await store.get_trace_messages(trace.trace_id)
  912. branch_assistants = [
  913. message for message in messages
  914. if message.role == "assistant"
  915. and message.branch_id == branch_id
  916. and message.branch_type == branch_type
  917. ]
  918. self.assertEqual(2, len(branch_assistants))
  919. self.assertNotIn(
  920. "must not enter recovered history",
  921. json.dumps(first_prompt, ensure_ascii=False),
  922. )
  923. persisted = await store.get_trace(trace.trace_id)
  924. self.assertNotIn("active_side_branch", persisted.context)
  925. async def test_recovered_exhausted_branch_never_receives_an_extra_turn(self):
  926. for branch_type in ("compression", "reflection", "knowledge_eval"):
  927. with self.subTest(branch_type=branch_type), tempfile.TemporaryDirectory() as temp_dir:
  928. store = FileSystemTraceStore(temp_dir)
  929. trace_id = f"exhausted-{branch_type}"
  930. branch_id = f"{branch_type}-crash"
  931. context = {
  932. "agent_mode": "legacy",
  933. "agent_mode_revision": 1,
  934. "active_side_branch": {
  935. "type": branch_type,
  936. "branch_id": branch_id,
  937. "start_head_seq": 1,
  938. "start_sequence": 2,
  939. "start_iteration": 0,
  940. "max_turns": 1,
  941. "turns_used": 0,
  942. },
  943. }
  944. config = RunConfig(
  945. trace_id=trace_id,
  946. model="fake",
  947. max_iterations=2,
  948. tools=[],
  949. tool_groups=[],
  950. enable_research_flow=False,
  951. knowledge=quiet_knowledge(),
  952. )
  953. persist_run_config_snapshot(
  954. context,
  955. RunConfigSnapshotV1.from_run_config(
  956. config,
  957. memory_identity=None,
  958. ),
  959. )
  960. await store.create_trace(Trace(
  961. trace_id=trace_id,
  962. mode="agent",
  963. task="resume exhausted branch",
  964. model="fake",
  965. context=context,
  966. head_sequence=1,
  967. ))
  968. await store.update_goal_tree(trace_id, GoalTree(mission="resume"))
  969. await store.add_message(Message.create(
  970. trace_id=trace_id,
  971. role="user",
  972. sequence=1,
  973. content="main",
  974. ))
  975. await store.add_message(Message.create(
  976. trace_id=trace_id,
  977. role="user",
  978. sequence=2,
  979. parent_sequence=1,
  980. branch_type=branch_type,
  981. branch_id=branch_id,
  982. content="branch instruction",
  983. ))
  984. await store.add_message(Message.create(
  985. trace_id=trace_id,
  986. role="assistant",
  987. sequence=3,
  988. parent_sequence=2,
  989. branch_type=branch_type,
  990. branch_id=branch_id,
  991. content={"text": "already used turn", "tool_calls": []},
  992. ))
  993. await store.update_trace(trace_id, head_sequence=1)
  994. prompts = []
  995. async def llm_call(**kwargs):
  996. prompts.append(kwargs["messages"])
  997. return {
  998. "content": "main complete",
  999. "tool_calls": [],
  1000. "finish_reason": "stop",
  1001. }
  1002. runner = AgentRunner(trace_store=store, llm_call=llm_call)
  1003. await runner.run_result([], config)
  1004. messages = await store.get_trace_messages(trace_id)
  1005. branch_assistants = [
  1006. message for message in messages
  1007. if message.role == "assistant"
  1008. and message.branch_id == branch_id
  1009. and message.branch_type == branch_type
  1010. ]
  1011. self.assertEqual(1, len(branch_assistants))
  1012. self.assertNotIn(
  1013. "already used turn",
  1014. json.dumps(prompts, ensure_ascii=False),
  1015. )
  1016. self.assertNotIn(
  1017. "active_side_branch",
  1018. (await store.get_trace(trace_id)).context,
  1019. )
  1020. if __name__ == "__main__":
  1021. unittest.main()