test_recursive_tool_capabilities.py 14 KB

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