test_computer_policy.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. import importlib.util
  2. import json
  3. import sys
  4. import tempfile
  5. import threading
  6. import time
  7. import unittest
  8. from pathlib import Path
  9. from types import SimpleNamespace
  10. from unittest.mock import patch
  11. spec = importlib.util.spec_from_file_location('test_yaoyao_bridge', Path(__file__).parents[1] / 'bridge_runtime.py')
  12. bridge = importlib.util.module_from_spec(spec)
  13. sys.modules[spec.name] = bridge
  14. spec.loader.exec_module(bridge)
  15. class ReviewAgent(SimpleNamespace):
  16. def _spawn_background_review_now(self, messages_snapshot, **kwargs):
  17. self.review_calls.append((messages_snapshot, kwargs, bridge._skill_review_scope.get()))
  18. class ComputerPolicyTests(unittest.TestCase):
  19. def setUp(self):
  20. self.temp = tempfile.TemporaryDirectory()
  21. self.home = Path(self.temp.name)
  22. self.agent = ReviewAgent(session_id='stored', enabled_toolsets=['all'], review_calls=[],
  23. skip_context_files=False, load_soul_identity=False,
  24. _background_review_agent=None, _background_review_run=None)
  25. self.session = {'agent': self.agent, 'session_key': 'stored', 'running': True, 'cwd': str(self.home),
  26. '_yaoyao_computer_policy': {'mode': 'isolated', 'hostAccess': False}}
  27. self.server = SimpleNamespace(_sessions={'runtime': self.session}, _sessions_lock=threading.RLock())
  28. self.binding = bridge.Binding('runtime', 'stored', 'default', self.home, 'generation',
  29. 'http://127.0.0.1:12345', 'a' * 40, time.time() * 1000 + 60000,
  30. ('user', 'password'), self.session, self.agent,
  31. computer_policy=dict(self.session['_yaoyao_computer_policy']))
  32. bridge._bindings.clear(); bridge._generations.clear(); bridge._children.clear()
  33. bridge._bindings['runtime'] = self.binding
  34. self.patches = [patch.dict(sys.modules, {'agent.redact': SimpleNamespace(redact_sensitive_text=lambda text, **kw: text)}),
  35. patch.object(bridge, '_server', return_value=self.server),
  36. patch.object(bridge, '_check_session_mode'),
  37. patch.object(bridge, '_transport_identity', return_value=self.binding.owner),
  38. patch.object(bridge, '_profile_home', return_value=self.home)]
  39. for p in self.patches: p.start()
  40. def tearDown(self):
  41. for p in reversed(self.patches): p.stop()
  42. self.temp.cleanup()
  43. def test_isolated_skills_and_vm_tools_use_native_catalog_but_host_operations_require_approval(self):
  44. for name in ['skills_list', 'skill_view', 'vision_analyze', 'yaoyao_computer_shell_aabbcc']:
  45. self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name=name, args={}))
  46. for name in ['terminal', 'process_manage', 'read_file', 'write_file', 'execute_code', 'delegate_task', 'session_search', 'browser_navigate', 'yaoyao_computer_copy_file_aabbcc']:
  47. result = bridge.computer_directive(session_id='stored', tool_name=name, args={'path': '/authorized.txt'})
  48. self.assertEqual(result['action'], 'approve')
  49. self.assertIn('/authorized.txt', result['message'])
  50. self.binding.computer_policy['hostAccess'] = True
  51. self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name='terminal', args={}))
  52. self.binding.computer_policy.update(mode='profile', hostAccess=False)
  53. self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name='terminal', args={}))
  54. def test_stopped_expired_unbound_and_replaced_sessions_cannot_call_native_tools(self):
  55. for change in ['stop', 'expire', 'unbind', 'replace']:
  56. with self.subTest(change=change):
  57. self.session.update(running=True, agent=self.agent)
  58. self.binding.expires_at = time.time() * 1000 + 60000
  59. bridge._bindings['runtime'] = self.binding
  60. if change == 'stop': self.session['running'] = False
  61. if change == 'expire': self.binding.expires_at = 0
  62. if change == 'unbind': bridge._bindings.clear()
  63. if change == 'replace': self.session['agent'] = SimpleNamespace(session_id='stored')
  64. self.assertEqual(bridge.computer_directive(session_id='runtime', tool_name='skill_view', args={})['action'], 'block')
  65. def test_ordinary_profile_sessions_are_unaffected(self):
  66. self.session.pop('_yaoyao_computer_policy')
  67. self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name='terminal', args={}))
  68. def test_policy_cannot_be_changed_during_renewal(self):
  69. self.session['running'] = False
  70. body = {'session_id': 'runtime', 'stored_session_id': 'stored', 'profile': 'default',
  71. 'generation': 'generation', 'bridge_url': self.binding.bridge_url, 'token': self.binding.token,
  72. 'expires_at': time.time() * 1000 + 60000, 'computer_policy': {'mode': 'profile', 'hostAccess': True}}
  73. with patch.object(bridge, '_agent_has_tools', return_value=True):
  74. with self.assertRaises(bridge.BridgeError) as error:
  75. bridge.bind(self.binding.owner, body)
  76. self.assertEqual(error.exception.code, 'generation_conflict')
  77. def test_transfer_is_owned_by_hermes_and_checks_generation_owner_and_protected_paths(self):
  78. constants = SimpleNamespace(set_hermes_home_override=lambda p: None, reset_hermes_home_override=lambda p: None)
  79. file_tools = SimpleNamespace(_check_sensitive_path=lambda p, t: 'denied' if p.endswith('protected') else None,
  80. _check_protected_instruction_write=lambda p, t: None,
  81. _check_approval_required_write=lambda p, t: None)
  82. safety = SimpleNamespace(get_read_block_error=lambda p: 'denied' if p.endswith('protected') else None)
  83. modules = {'hermes_constants': constants, 'tools.file_tools': file_tools, 'agent.file_safety': safety}
  84. (self.home / 'authorized').write_bytes(b'authorized-input')
  85. body = {'session_id': 'runtime', 'generation': 'generation', 'action': 'read', 'path': 'authorized'}
  86. with patch.dict(sys.modules, modules):
  87. self.assertEqual(bridge.computer_file(self.binding.owner, body)['data'], 'YXV0aG9yaXplZC1pbnB1dA==')
  88. for owner, changed in [(('other', 'password'), body), (self.binding.owner, {**body, 'generation': 'stale'}),
  89. (self.binding.owner, {**body, 'path': 'protected'})]:
  90. with self.assertRaises(bridge.BridgeError): bridge.computer_file(owner, changed)
  91. bridge.computer_file(self.binding.owner, {**body, 'action': 'write', 'path': 'result', 'data': 'cmVzdWx0'})
  92. self.assertEqual((self.home / 'result').read_bytes(), b'result')
  93. self.session['running'] = False
  94. with self.assertRaises(bridge.BridgeError): bridge.computer_file(self.binding.owner, body)
  95. def test_workspace_memory_uses_agent_controls_without_mutating_another_agent(self):
  96. stopped = []
  97. other = SimpleNamespace(_memory_enabled=True)
  98. self.agent._memory_store = object()
  99. self.agent._memory_manager = SimpleNamespace(get_all_tool_names=lambda: {'provider_remember'}, shutdown_all=lambda: stopped.append(True))
  100. self.agent._memory_enabled = self.agent._user_profile_enabled = True
  101. self.agent.skip_background_review = False
  102. self.agent.disabled_toolsets = ['terminal']
  103. invalidated = []
  104. self.agent._invalidate_system_prompt = lambda: invalidated.append(True)
  105. self.binding.workspace_memory = True
  106. self.session['_yaoyao_workspace_memory'] = True
  107. bridge._isolate_profile_memory(self.agent, self.binding)
  108. self.assertIsNone(self.agent._memory_store)
  109. self.assertIsNone(self.agent._memory_manager)
  110. self.assertFalse(self.agent._memory_enabled)
  111. self.assertFalse(self.agent._user_profile_enabled)
  112. self.assertFalse(self.agent.skip_background_review)
  113. self.assertEqual(self.agent.disabled_toolsets, ['terminal', 'memory'])
  114. self.assertEqual(stopped, [True]); self.assertEqual(invalidated, [True])
  115. self.assertTrue(other._memory_enabled)
  116. for tool in ('memory', 'provider_remember'):
  117. self.assertEqual(bridge.computer_directive(session_id='stored', tool_name=tool, args={})['action'], 'block')
  118. self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name='skill_view', args={}))
  119. def test_skill_review_is_installed_once_and_never_runs_memory_review(self):
  120. bridge._enable_skill_review(self.agent, self.binding)
  121. wrapped = self.agent._spawn_background_review_now
  122. bridge._enable_skill_review(self.agent, self.binding)
  123. self.assertIs(wrapped, self.agent._spawn_background_review_now)
  124. self.agent._spawn_background_review_now([], review_memory=True)
  125. self.assertEqual(self.agent.review_calls, [])
  126. self.agent._spawn_background_review_now([{'role': 'user', 'content': 'verified work'}], review_memory=True, review_skills=True)
  127. self.assertEqual(len(self.agent.review_calls), 1)
  128. _, kwargs, scope = self.agent.review_calls[0]
  129. self.assertFalse(kwargs['review_memory'])
  130. self.assertTrue(kwargs['review_skills'])
  131. self.assertIs(scope.parent, self.agent)
  132. self.assertIsNone(bridge._skill_review_scope.get())
  133. def test_review_fork_keeps_isolation_and_only_skill_permission_after_turn_unbind(self):
  134. self.agent.skip_context_files = True
  135. self.agent.load_soul_identity = False
  136. self.agent.disabled_toolsets = ['memory']
  137. scope = bridge._SkillReviewScope(self.agent, self.binding, time.monotonic() + 60)
  138. parent = bridge._SkillReviewParent(scope)
  139. parent._background_review_run = SimpleNamespace(cancel_requested=threading.Event())
  140. fork = self.context_agent()
  141. fork._memory_store = fork._memory_manager = object()
  142. fork._memory_enabled = fork._user_profile_enabled = True
  143. tools = [{'function': {'name': name}} for name in ['skills_list', 'skill_view', 'skill_manage', 'memory']]
  144. with patch.dict(sys.modules, {'model_tools': SimpleNamespace(get_tool_definitions=lambda **kw: tools)}):
  145. parent._background_review_agent = fork
  146. self.assertTrue(fork.skip_context_files)
  147. self.assertFalse(fork.load_soul_identity)
  148. self.assertTrue(fork.skip_background_review)
  149. self.assertIsNone(fork._memory_manager)
  150. self.assertIsNone(fork._memory_store)
  151. self.assertFalse(fork._memory_enabled)
  152. self.assertFalse(fork._user_profile_enabled)
  153. self.assertIsNone(fork._cached_system_prompt)
  154. self.assertEqual(fork.valid_tool_names, {'skills_list', 'skill_view', 'skill_manage'})
  155. bridge._bindings.clear()
  156. self.session['running'] = False
  157. constants = SimpleNamespace(get_hermes_home=lambda: self.home)
  158. with patch.dict(sys.modules, {'hermes_constants': constants}):
  159. token = bridge._skill_review_scope.set(scope)
  160. try:
  161. self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name='skill_manage'))
  162. for tool in ['memory', 'provider_remember', 'read_file', 'terminal', 'yaoyao_computer_shell', 'workspace_send_to_agent']:
  163. self.assertEqual(bridge.computer_directive(session_id='stored', tool_name=tool)['action'], 'block')
  164. self.assertEqual(bridge.computer_directive(session_id='other-session', tool_name='skill_manage')['action'], 'block')
  165. constants.get_hermes_home = lambda: self.home / 'other-profile'
  166. self.assertEqual(bridge.computer_directive(session_id='stored', tool_name='skill_manage')['action'], 'block')
  167. constants.get_hermes_home = lambda: self.home
  168. scope.run.cancel_requested.set()
  169. self.assertEqual(bridge.computer_directive(session_id='stored', tool_name='skill_manage')['action'], 'block')
  170. scope.run.cancel_requested.clear()
  171. parent._background_review_agent = None
  172. self.assertEqual(bridge.computer_directive(session_id='stored', tool_name='skill_manage')['action'], 'block')
  173. finally:
  174. bridge._skill_review_scope.reset(token)
  175. self.assertEqual(bridge.computer_directive(session_id='stored', tool_name='skill_manage')['action'], 'block')
  176. def context_agent(self, session_id='stored'):
  177. agent = SimpleNamespace(session_id=session_id, enabled_toolsets=['skills'], disabled_toolsets=[],
  178. skip_context_files=False, load_soul_identity=True,
  179. _cached_system_prompt='previous-profile-context')
  180. agent._invalidate_system_prompt = lambda: setattr(agent, '_cached_system_prompt', None)
  181. return agent
  182. def test_isolated_bind_and_resumed_agent_clear_context_cache_without_changing_history(self):
  183. self.session.update(running=False, history=[{'role': 'user', 'content': 'keep-history'}])
  184. original = self.context_agent('ordinary-session')
  185. body = {'session_id': 'runtime', 'stored_session_id': 'stored', 'profile': 'default',
  186. 'bridge_url': self.binding.bridge_url, 'token': self.binding.token,
  187. 'expires_at': time.time() * 1000 + 60000, 'computer_policy': self.binding.computer_policy}
  188. with patch.object(bridge, '_agent_has_tools', return_value=True):
  189. for generation in ['first-turn', 'resumed-turn']:
  190. agent = self.context_agent()
  191. self.session['agent'] = agent
  192. bridge.bind(self.binding.owner, {**body, 'generation': generation})
  193. self.assertTrue(agent.skip_context_files)
  194. self.assertFalse(agent.load_soul_identity)
  195. self.assertIsNone(agent._cached_system_prompt)
  196. self.assertEqual(agent.enabled_toolsets, ['skills'])
  197. self.assertEqual(self.session['history'], [{'role': 'user', 'content': 'keep-history'}])
  198. bridge.unbind(self.binding.owner, {'session_id': 'runtime', 'generation': generation})
  199. self.assertTrue(agent.skip_context_files)
  200. self.assertFalse(original.skip_context_files)
  201. self.assertTrue(original.load_soul_identity)
  202. def test_isolated_child_inherits_context_policy_and_keeps_skill_tools(self):
  203. child = self.context_agent('child-session')
  204. self.agent._active_children = [child]
  205. tools = [{'function': {'name': name}} for name in ['skills_list', 'skill_view', 'skill_manage']]
  206. model_tools = SimpleNamespace(get_tool_definitions=lambda **kw: tools, get_toolset_for_tool=lambda name: 'skills')
  207. with patch.dict(sys.modules, {'model_tools': model_tools}):
  208. bridge.inherit_child(parent_session_id='stored', child_session_id='child-session')
  209. self.assertTrue(child.skip_context_files)
  210. self.assertFalse(child.load_soul_identity)
  211. self.assertIsNone(child._cached_system_prompt)
  212. self.assertEqual(child.valid_tool_names, {'skills_list', 'skill_view', 'skill_manage'})
  213. def test_context_isolation_preserves_profile_mode_and_requires_supported_agent(self):
  214. for policy in [None, {'mode': 'profile', 'hostAccess': True}]:
  215. agent = self.context_agent()
  216. self.binding.computer_policy = policy
  217. bridge._isolate_profile_context(agent, self.binding)
  218. self.assertFalse(agent.skip_context_files)
  219. self.assertTrue(agent.load_soul_identity)
  220. self.assertEqual(agent._cached_system_prompt, 'previous-profile-context')
  221. self.binding.computer_policy = {'mode': 'isolated', 'hostAccess': False}
  222. with self.assertRaises(bridge.BridgeError) as error:
  223. bridge._isolate_profile_context(SimpleNamespace(), self.binding)
  224. self.assertEqual(error.exception.code, 'context_isolation_unavailable')
  225. if __name__ == '__main__':
  226. unittest.main()