test_recursive_tool_capabilities.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. import json
  2. import os
  3. import tempfile
  4. import unittest
  5. from unittest.mock import patch
  6. from cyber_agent.core.agent_mode import RECURSIVE_CAPABILITY_TOOLS_CONTEXT_KEY
  7. from cyber_agent.core.runner import AgentRunner, RunConfig
  8. from cyber_agent.core.task_protocol import ensure_task_protocol
  9. from cyber_agent.tools.builtin.knowledge import KnowledgeConfig
  10. from cyber_agent.tools.builtin.subagent import agent
  11. from cyber_agent.tools.registry import ToolRegistry
  12. from cyber_agent.trace.goal_models import GoalTree
  13. from cyber_agent.trace.models import Trace
  14. from cyber_agent.trace.store import FileSystemTraceStore
  15. class FakeToolRegistry:
  16. TOOL_NAMES = [
  17. "agent",
  18. "evaluate",
  19. "bash_command",
  20. "read_file",
  21. "write_file",
  22. "goal",
  23. ]
  24. def get_tool_names(self):
  25. return list(self.TOOL_NAMES)
  26. class RecordingRunner:
  27. def __init__(self, store):
  28. self.store = store
  29. self.tools = FakeToolRegistry()
  30. self.debug = False
  31. self.stdin_check = None
  32. self._cancel_events = {}
  33. self.configs = []
  34. async def run_result(self, messages, config, on_event=None):
  35. self.configs.append(config)
  36. await self.store.update_trace(config.trace_id, status="completed")
  37. return {
  38. "status": "completed",
  39. "summary": "done",
  40. "saved_knowledge_ids": [],
  41. "stats": {"total_messages": 1, "total_tokens": 1, "total_cost": 0.0},
  42. }
  43. class RecursiveCapabilityInheritanceTest(unittest.IsolatedAsyncioTestCase):
  44. async def asyncSetUp(self):
  45. self.temp_dir = tempfile.TemporaryDirectory()
  46. self.store = FileSystemTraceStore(self.temp_dir.name)
  47. self.runner = RecordingRunner(self.store)
  48. self.root_id = "capability-root"
  49. await self.store.create_trace(Trace(
  50. trace_id=self.root_id,
  51. mode="agent",
  52. task="root",
  53. uid="user-1",
  54. model="fake",
  55. context={
  56. "agent_mode": "recursive",
  57. "agent_mode_revision": 1,
  58. "agent_depth": 0,
  59. "root_trace_id": self.root_id,
  60. },
  61. ))
  62. await self.store.update_goal_tree(self.root_id, GoalTree(mission="root"))
  63. async def asyncTearDown(self):
  64. self.temp_dir.cleanup()
  65. def context(self, trace_id, capabilities=None):
  66. context = {
  67. "store": self.store,
  68. "trace_id": trace_id,
  69. "runner": self.runner,
  70. }
  71. if capabilities is not None:
  72. context[RECURSIVE_CAPABILITY_TOOLS_CONTEXT_KEY] = capabilities
  73. return context
  74. async def test_child_and_grandchild_only_inherit_parent_capabilities(self):
  75. parent_capabilities = ["agent", "read_file"]
  76. child_result = await agent(
  77. task="child",
  78. context=self.context(self.root_id, parent_capabilities),
  79. )
  80. child_config = self.runner.configs[-1]
  81. self.assertEqual({"agent", "read_file"}, set(child_config.tools))
  82. self.assertNotIn("write_file", child_config.tools)
  83. grandchild_result = await agent(
  84. task="grandchild",
  85. context=self.context(child_result["sub_trace_id"], child_config.tools),
  86. )
  87. grandchild_config = self.runner.configs[-1]
  88. self.assertEqual({"agent", "read_file"}, set(grandchild_config.tools))
  89. self.assertNotIn("write_file", grandchild_config.tools)
  90. self.assertEqual(
  91. child_result["sub_trace_id"],
  92. (await self.store.get_trace(grandchild_result["sub_trace_id"])).parent_trace_id,
  93. )
  94. async def test_missing_recursive_capability_snapshot_fails_before_spawn(self):
  95. result = await agent(task="child", context=self.context(self.root_id))
  96. self.assertEqual("failed", result["status"])
  97. self.assertIn("capability context", result["error"])
  98. children = await self.store.list_traces(parent_trace_id=self.root_id, limit=10)
  99. self.assertEqual([], children)
  100. class RecursiveRuntimeDispatchTest(unittest.IsolatedAsyncioTestCase):
  101. async def test_recursive_framework_context_fields_cannot_be_overridden(self):
  102. with tempfile.TemporaryDirectory() as directory:
  103. store = FileSystemTraceStore(directory)
  104. runner = AgentRunner(trace_store=store, llm_call=lambda **kwargs: None)
  105. trace = Trace(
  106. trace_id="real-trace",
  107. mode="agent",
  108. task="test",
  109. context={"agent_mode": "recursive", "agent_mode_revision": 2},
  110. )
  111. goal_tree = GoalTree(mission="test")
  112. config = RunConfig(context={
  113. "store": "spoof-store",
  114. "trace_id": "spoof-trace",
  115. "goal_id": "spoof-goal",
  116. "runner": "spoof-runner",
  117. "goal_tree": "spoof-tree",
  118. "sequence": -1,
  119. RECURSIVE_CAPABILITY_TOOLS_CONTEXT_KEY: ["spoof-tool"],
  120. })
  121. context = runner._build_tool_context(
  122. config=config,
  123. trace=trace,
  124. trace_id=trace.trace_id,
  125. goal_id="real-goal",
  126. goal_tree=goal_tree,
  127. sequence=7,
  128. side_branch_ctx=None,
  129. trigger_event=None,
  130. )
  131. self.assertIs(store, context["store"])
  132. self.assertEqual("real-trace", context["trace_id"])
  133. self.assertEqual("real-goal", context["goal_id"])
  134. self.assertIs(runner, context["runner"])
  135. self.assertIs(goal_tree, context["goal_tree"])
  136. self.assertEqual(7, context["sequence"])
  137. self.assertNotEqual(
  138. ["spoof-tool"],
  139. context[RECURSIVE_CAPABILITY_TOOLS_CONTEXT_KEY],
  140. )
  141. async def test_pending_review_runtime_allowlist_rejects_forged_agent(self):
  142. registry = ToolRegistry()
  143. agent_calls = 0
  144. async def agent():
  145. nonlocal agent_calls
  146. agent_calls += 1
  147. return "called"
  148. async def review_task_result():
  149. return "reviewed"
  150. registry.register(agent)
  151. registry.register(review_task_result)
  152. runner = AgentRunner(tool_registry=registry, llm_call=lambda **kwargs: None)
  153. trace = Trace(
  154. trace_id="pending-root",
  155. mode="agent",
  156. task="pending",
  157. context={"agent_mode": "recursive", "agent_mode_revision": 2},
  158. )
  159. state = ensure_task_protocol(trace.context)
  160. state["pending_reviews"]["child"] = {"task_report": {}}
  161. config = RunConfig(
  162. tools=["agent", "review_task_result"],
  163. tool_groups=[],
  164. )
  165. allowed = runner._get_runtime_tool_names(config, trace)
  166. self.assertEqual({"review_task_result"}, allowed)
  167. result = json.loads(await registry.execute(
  168. "agent",
  169. {},
  170. allowed_tool_names=allowed,
  171. ))
  172. self.assertIn("not allowed", result["error"])
  173. self.assertEqual(0, agent_calls)
  174. self.assertEqual(0, registry.get_stats("agent")["agent"]["call_count"])
  175. async def test_registry_rejects_before_counting_or_calling(self):
  176. registry = ToolRegistry()
  177. calls = 0
  178. async def dangerous():
  179. nonlocal calls
  180. calls += 1
  181. return "called"
  182. registry.register(dangerous)
  183. result = json.loads(await registry.execute(
  184. "dangerous",
  185. {},
  186. allowed_tool_names=set(),
  187. ))
  188. self.assertIn("not allowed", result["error"])
  189. self.assertEqual(0, calls)
  190. self.assertEqual(
  191. 0,
  192. registry.get_stats("dangerous")["dangerous"]["call_count"],
  193. )
  194. async def test_forged_tool_call_is_blocked_in_serial_and_parallel_dispatch(self):
  195. for parallel in (False, True):
  196. with self.subTest(parallel=parallel):
  197. temp_dir = tempfile.TemporaryDirectory()
  198. self.addCleanup(temp_dir.cleanup)
  199. store = FileSystemTraceStore(temp_dir.name)
  200. registry = ToolRegistry()
  201. dangerous_calls = 0
  202. async def safe():
  203. return "safe"
  204. async def dangerous():
  205. nonlocal dangerous_calls
  206. dangerous_calls += 1
  207. return "dangerous"
  208. registry.register(safe, groups=["test"])
  209. registry.register(dangerous, groups=["test"])
  210. llm_calls = 0
  211. async def fake_llm(**kwargs):
  212. nonlocal llm_calls
  213. llm_calls += 1
  214. if kwargs.get("tools") == []:
  215. return {
  216. "content": json.dumps({
  217. "outcome": "passed",
  218. "scope": "root",
  219. "reason": "unauthorized tools were not executed",
  220. "issues": [],
  221. "retry_from": None,
  222. }),
  223. "tool_calls": [],
  224. "finish_reason": "stop",
  225. }
  226. if llm_calls == 1:
  227. return {
  228. "content": "",
  229. "tool_calls": [{
  230. "id": "forged-call",
  231. "type": "function",
  232. "function": {"name": "dangerous", "arguments": "{}"},
  233. }],
  234. "finish_reason": "tool_calls",
  235. }
  236. return {"content": "done", "finish_reason": "stop"}
  237. runner = AgentRunner(
  238. trace_store=store,
  239. tool_registry=registry,
  240. llm_call=fake_llm,
  241. )
  242. with patch.dict(os.environ, {"AGENT_MODE": "recursive"}, clear=False):
  243. result = await runner.run_result(
  244. messages=[{"role": "user", "content": "test"}],
  245. config=RunConfig(
  246. tools=["safe"],
  247. tool_groups=[],
  248. parallel_tool_execution=parallel,
  249. enable_research_flow=False,
  250. root_completion_criteria=["forged tools are never executed"],
  251. knowledge=KnowledgeConfig(
  252. enable_extraction=False,
  253. enable_completion_extraction=False,
  254. enable_injection=False,
  255. ),
  256. ),
  257. )
  258. self.assertEqual("completed", result["status"])
  259. self.assertEqual(0, dangerous_calls)
  260. self.assertEqual(
  261. 0,
  262. registry.get_stats("dangerous")["dangerous"]["call_count"],
  263. )
  264. if __name__ == "__main__":
  265. unittest.main()